Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .bazelversion
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
8.3.1

6 changes: 3 additions & 3 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module(
bazel_dep(name = "rules_proto", version = "7.1.0")
bazel_dep(name = "rules_go", version = "0.57.0")
bazel_dep(name = "gazelle", version = "0.45.0")
bazel_dep(name = "build_stack_rules_proto", version = "4.1.0")
bazel_dep(name = "build_stack_rules_proto", version = "4.1.1")

# -------------------------------------------------------------------
# Configuration: Go
Expand All @@ -33,7 +33,7 @@ use_repo(
# Configuration: Protobuf Deps
# -------------------------------------------------------------------

proto_repository = use_extension("@build_stack_rules_proto//extensions:proto_repository.bzl", "proto_repository", dev_dependency = True)
proto_repository = use_extension("@build_stack_rules_proto//extensions:proto_repository.bzl", "proto_repository")
proto_repository.archive(
name = "protobufapis",
build_directives = [
Expand All @@ -51,7 +51,7 @@ proto_repository.archive(
],
build_file_generation = "clean",
build_file_proto_mode = "file",
cfgs = ["@//:rules_proto_config.yaml"],
cfgs = ["//:rules_proto_config.yaml"],
deleted_files = [
"google/protobuf/*test*.proto",
"google/protobuf/*unittest*.proto",
Expand Down
207 changes: 190 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,212 @@

# bazel-aquery-differ

This is a port of
<https://github.com/bazelbuild/bazel/blob/master/tools/aquery_differ/aquery_differ.py>
to golang.
A tool to compare Bazel action query outputs with an interactive HTML report.
This is a re-imagination of the [Bazel
aquery_differ.py](https://github.com/bazelbuild/bazel/blob/master/tools/aquery_differ/aquery_differ.py)
in Go with enhanced visualization features.

## Features

- Compare Bazel action graphs between builds or git commits
- Interactive HTML reports with GitHub-style diff visualization
- Syntax-highlighted diffs (unified diff and go-cmp formats)
- Built-in web server with auto-open browser support
- Native Bazel rules for integration into your build

## Installation

Download and unzip a release artifact, or clone and `bazel build //cmd/aquerydiff`.
### As a Bazel Module

## Usage
Add to your `MODULE.bazel`:

```starlark
bazel_dep(name = "bazel-aquery-differ", version = "0.0.0")
```

> **Note**: This module is not yet published to the Bazel Central Registry. For
> now, use an `archive_override` or `git_override` pointing to this repository.

### As a Standalone Binary

Download a release artifact, or build from source:

```bash
aquerydiff --before <BEFORE_FILE> --after <AFTER_FILE> --report_dir <REPORT_DIR>
git clone https://github.com/stackb/bazel-aquery-differ.git
cd bazel-aquery-differ
bazel build //cmd/aquerydiff
```

## Usage

### Using Bazel Rules

Load the rules in your `BUILD.bazel` file:

```starlark
load("@bazel-aquery-differ//rules:defs.bzl", "aquery_diff", "aquery_git_diff")
```

You can generate the `<BEFORE_FILE>` (and `<AFTER_FILE>`) using:
#### Rule: `aquery_diff`

Compare two aquery output files:

```starlark
aquery_diff(
name = "compare_actions",
before = "before.pb",
after = "after.pb",
)
```

**Attributes:**

| Attribute | Type | Default | Description |
|-----------|----------|------------------|------------------------------------------------------------------------------|
| `before` | `label` | **required** | Baseline aquery file (`.pb`, `.proto`, `.textproto`, `.json`, `.jsonproto`) |
| `after` | `label` | **required** | Comparison aquery file (same format options) |
| `match` | `string` | `"output_files"` | Strategy to match before and after actions: `"output_files"` or `"mnemonic"` |
| `serve` | `bool` | `True` | Start web server to view report |
| `open` | `bool` | `True` | Automatically open browser to report |
| `unidiff` | `bool` | `False` | Generate unified diffs (can be slow for large actions) |
| `cmpdiff` | `bool` | `True` | Generate go-cmp diffs (fast, structural comparison) |

Run the comparison:

```bash
bazel aquery //pkg:target-name --output jsonproto > before.json
bazel aquery //pkg:target-name --output textproto > before.textproto
bazel aquery //pkg:target-name --output proto > before.pb
bazel run //path/to:compare_actions
```

> **Performance Note**: The `unidiff` attribute defaults to `False` because generating unified diffs can be prohibitively slow for large actions with many inputs/outputs. The `cmpdiff` format (enabled by default) is much faster and provides good structural comparison for most use cases. Only enable `unidiff` if you need the traditional unified diff format and are willing to wait for the additional processing time.

**Choosing a Match Strategy:**

The `match` attribute determines how actions are paired between the before and
after builds:

- **`output_files`** (default): Actions are matched by their output file paths.
Use this when comparing the same target across different commits or
configurations. This is the most common use case and ensures you're comparing
the exact same action that produces the same outputs.

- **`mnemonic`**: Actions are matched by their mnemonic (action type, e.g.,
"GoCompile", "CppCompile"). Use this when comparing different targets that use
similar build rules. For example, comparing `//old/pkg:binary` vs
`//new/pkg:binary` where both are `go_binary` targets but produce different
output paths. This helps identify how the same type of action differs between
targets.

Example using mnemonic matching:

```starlark
aquery_diff(
name = "compare_go_binaries",
before = "old_binary.pb",
after = "new_binary.pb",
match = "mnemonic", # Compare by action type instead of output path
)
```

> The file extensions are relevant; the proto decoder will be `protojson` if
`.json`, `prototext` if `.textproto` and `proto` otherwise.
#### Rule: `aquery_git_diff`

Compare aquery outputs between git commits:

```starlark
aquery_git_diff(
name = "git_compare",
before = "main",
after = "feature-branch",
target = "//my/package:target",
)
```

**Attributes:**

Same as `aquery_diff`, plus:

| Attribute | Type | Default | Description |
|-----------|----------|--------------|------------------------------------------------------------|
| `target` | `string` | **required** | Bazel target to aquery (e.g., `//pkg:binary`, `deps(...)`) |
| `bazel` | `string` | `"bazel"` | Path to bazel executable |
| `before` | `string` | **required** | Git commit/branch/tag for baseline |
| `after` | `string` | **required** | Git commit/branch/tag for comparison |

This rule will:
1. Check for uncommitted changes (fails if found)
2. Checkout `before` commit and run `bazel aquery`
3. Checkout `after` commit and run `bazel aquery`
4. Restore original commit
5. Generate comparison report

An HTML report and accessory files will be written to the given `--report_dir`,
which you could serve as follows:
### Using the CLI

Generate aquery files using Bazel:

```bash
# Binary proto format (recommended for large graphs)
bazel aquery //pkg:target --output=proto > before.pb

# Text proto format (human-readable)
bazel aquery //pkg:target --output=textproto > before.textproto

# JSON proto format
bazel aquery //pkg:target --output=jsonproto > before.json
```
(cd <REPORT_DIR> && python3 -m http.server 8000) &

> **Supported formats**: The tool automatically detects format based on file extension:
> - Binary: `.pb`, `.proto`
> - Text: `.textproto`
> - JSON: `.json`, `.jsonproto`

Run the comparison:

```bash
aquerydiff \
--before before.pb \
--after after.pb \
--report_dir ./output \
--serve \
--open
```

> Report will look something like:
**CLI Flags:**

- `--before` - Path to baseline aquery file
- `--after` - Path to comparison aquery file
- `--report_dir` - Directory to write HTML report
- `--match` - Matching strategy: `output_files` (default) or `mnemonic`
- `--serve` - Start web server (default: true)
- `--open` - Open browser automatically (default: true)
- `--unidiff` - Generate unified diffs (default: false)
- `--cmpdiff` - Generate go-cmp diffs (default: true)

> **Note**: The report title is automatically derived from the most common target in the action graph.

### Report Output

The HTML report shows:

- **Actions only in before** - Removed actions
- **Actions only in after** - New actions
- **Non-equal actions** - Actions with changes
- **Equal actions** - Unchanged actions

Each action displays:
- Mnemonic (action type)
- Output files
- Links to before/after JSON/textproto representations
- Colorized diffs (unified and/or go-cmp format)

<img width="934" alt="Example report showing action comparison" src="https://user-images.githubusercontent.com/50580/209453563-064db4dd-4068-4d2f-8bb3-35c425bfb8b5.png">

## Example

See the [examples/simple](examples/simple) directory for working examples using both rules.

## Contributing

Contributions welcome! Please open an issue or pull request.

## License

<img width="934" alt="image" src="https://user-images.githubusercontent.com/50580/209453563-064db4dd-4068-4d2f-8bb3-35c425bfb8b5.png">
Apache 2.0
12 changes: 9 additions & 3 deletions cmd/aquerydiff/config.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package main

type config struct {
beforeFile string
afterFile string
reportDir string
beforeFile string
afterFile string
reportDir string
matchingStrategy string
port string
unidiff bool
cmpdiff bool
serve bool
open bool
}
71 changes: 69 additions & 2 deletions cmd/aquerydiff/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"

anpb "github.com/bazelbuild/bazelapis/src/main/protobuf/analysis_v2"
"github.com/stackb/bazel-aquery-differ/pkg/action"
Expand All @@ -26,8 +29,14 @@ func run(args []string) error {
flags := flag.NewFlagSet("aquerydiff", flag.ExitOnError)
flags.StringVar(&config.beforeFile, "before", "", "filepath to aquery file (before)")
flags.StringVar(&config.afterFile, "after", "", "filepath to aquery file (after)")
flags.StringVar(&config.matchingStrategy, "match", "output_files", "method used to build mapping of before & after actions (output_files|mnemonic)")
flags.StringVar(&config.reportDir, "report_dir", "", "path to directory where report files should be written")
if err := flags.Parse(os.Args[1:]); err != nil {
flags.StringVar(&config.port, "port", "8000", "port number to use when serving content")
flags.BoolVar(&config.unidiff, "unidiff", false, "compute unidiffs (can be slow)")
flags.BoolVar(&config.cmpdiff, "cmpdiff", true, "compute go-cmp diffs (usually fast)")
flags.BoolVar(&config.serve, "serve", false, "start webserver")
flags.BoolVar(&config.open, "open", false, "open browser to webserver URL")
if err := flags.Parse(args); err != nil {
return err
}

Expand Down Expand Up @@ -64,7 +73,21 @@ func run(args []string) error {
return err
}

beforeOnly, afterOnly, both := action.Partition(beforeGraph.OutputMap, afterGraph.OutputMap)
var mapper action.ActionMapper
switch config.matchingStrategy {
case "output_files":
mapper = action.NewOutputFilesMap
case "mnemonic":
mapper = action.NewMnemonicFileMap
default:
return fmt.Errorf("unknown matching strategy '%s'", config.matchingStrategy)
}

beforeOnly, afterOnly, both := action.Partition(
mapper(beforeGraph.Actions),
mapper(afterGraph.Actions),
)

var equal action.OutputPairs
var nonEqual action.OutputPairs

Expand All @@ -80,7 +103,17 @@ func run(args []string) error {
}
}

// Derive target from the action graph (prefer before, fallback to after)
target := beforeGraph.GetPrimaryTarget()
if target == "" {
target = afterGraph.GetPrimaryTarget()
}
if target == "" {
target = "unknown"
}

r := report.Html{
Target: target,
BeforeFile: config.beforeFile,
AfterFile: config.afterFile,
Before: beforeGraph,
Expand All @@ -89,6 +122,8 @@ func run(args []string) error {
AfterOnly: afterOnly,
Equal: equal,
NonEqual: nonEqual,
Unidiff: config.unidiff,
Cmpdiff: config.cmpdiff,
}

log.Printf("Generating report in: %s", config.reportDir)
Expand All @@ -99,5 +134,37 @@ func run(args []string) error {

log.Printf("aquerydiff report available at <%s>", config.reportDir)

if config.serve {
log.Printf("Starting webserver on port %s, serving %s", config.port, config.reportDir)
http.Handle("/", http.FileServer(http.Dir(config.reportDir)))

if config.open {
url := "http://localhost:" + config.port
log.Printf("Opening browser to %s", url)
if err := openBrowser(url); err != nil {
log.Printf("Failed to open browser: %v", err)
}
}

return http.ListenAndServe(":"+config.port, nil)
}

return nil
}

func openBrowser(url string) error {
var cmd string
var args []string

switch runtime.GOOS {
case "windows":
cmd = "cmd"
args = []string{"/c", "start"}
case "darwin":
cmd = "open"
default: // "linux", "freebsd", "openbsd", "netbsd"
cmd = "xdg-open"
}
args = append(args, url)
return exec.Command(cmd, args...).Start()
}
Loading