Skip to content

Commit 75dc2ee

Browse files
committed
feat: add git integration for comparing commits, branches, and working tree changes
Support comparing git refs with the same interactive TUI used for files and directories. New CLI modes: no args (working tree vs HEAD), single ref (ref vs HEAD), two refs, and -C flag for targeting any repo. Detail pane shows commit metadata with PR links on the root node.
1 parent 0831e50 commit 75dc2ee

File tree

14 files changed

+1379
-42
lines changed

14 files changed

+1379
-42
lines changed

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
<img src=".github/assets/drift_logo.png" alt="Drift logo" width="700">
55
</p>
66

7-
A **fast**, **interactive** file **comparison tool**.
7+
A **fast**, **interactive** file **comparison tool**.
88

9-
Compare directories, archives, binaries, plists, and text files with a terminal UI or structured JSON output.
9+
Compare directories, archives, binaries, plists, text files, and git commits with a terminal UI or structured JSON output.
1010

1111
[![asciicast](https://asciinema.org/a/858111.svg)](https://asciinema.org/a/858111)
1212

@@ -51,6 +51,33 @@ drift -m binary MyApp-v1.0/MyApp.app/libcore.dylib MyApp-v2.0/MyApp.app/libcore.
5151
drift --json MyApp-v1.0 MyApp-v2.0
5252
```
5353

54+
### Git mode
55+
56+
drift can compare git commits, branches, and working tree changes - bringing its interactive TUI to your git workflow.
57+
58+
```sh
59+
# View uncommitted changes (staged + unstaged + untracked)
60+
drift
61+
62+
# Compare a ref against HEAD
63+
drift HEAD~3
64+
drift main
65+
66+
# Compare any two refs (commits, branches, tags)
67+
drift main feature-branch
68+
drift v1.0.0 v2.0.0
69+
70+
# Target a different repo with -C
71+
drift -C ~/projects/my-app HEAD~1 HEAD
72+
73+
# Force git mode when a ref collides with a file path
74+
drift --git main feature
75+
```
76+
77+
When comparing git refs, the root node in the tree shows commit metadata including SHA, author, date, and links to the commit and pull request on GitHub.
78+
79+
Auto-detection resolves arguments as git refs when they don't match filesystem paths. Use `--git` to skip path detection entirely.
80+
5481
## Agent skill
5582

5683
drift ships a skill that gives AI coding agents native access to structured file comparison via `drift --json`. The skill is included in every [GitHub release](https://github.com/block/drift/releases).
@@ -79,6 +106,7 @@ drift auto-detects the comparison mode based on the inputs:
79106

80107
| Mode | Inputs | What it shows |
81108
|------|--------|---------------|
109+
| **git** | Commits, branches, tags | File tree of changes between refs with commit metadata and per-file diffs |
82110
| **tree** | Directories, archives | File tree with added/removed/modified indicators, per-file diffs |
83111
| **binary** | Mach-O binaries | Sections, sizes, symbols, load commands. Requires `nm` and `size` |
84112
| **plist** | Property lists (.plist) | Structured key-value diff. Binary plists require `plutil` |
@@ -135,7 +163,7 @@ Every JSON result includes:
135163
| Field | Description |
136164
|-------|-------------|
137165
| `path_a`, `path_b` | The compared paths |
138-
| `mode` | Detected comparison mode (`tree`, `binary`, `plist`, `text`) |
166+
| `mode` | Detected comparison mode (`git`, `tree`, `binary`, `plist`, `text`) |
139167
| `root` | The diff tree - each node has `name`, `path`, `status`, `kind`, `size_a`, `size_b`, and optional `children` |
140168
| `summary` | Aggregate counts: `added`, `removed`, `modified`, `unchanged`, `size_delta` |
141169

@@ -180,6 +208,7 @@ drift works on **macOS**, **Linux**, and **Windows**. Core features (directory/a
180208

181209
| Tool | Used for | Availability |
182210
| --- | --- | --- |
211+
| `git` | Git mode (commit/branch/worktree comparison) | All platforms |
183212
| `nm`, `size` | Mach-O binary analysis | macOS (Xcode CLI Tools), Linux (binutils) |
184213
| `plutil` | Binary plist conversion | macOS only (XML plists work everywhere) |
185214
| `xclip` or `xsel` | Clipboard | Linux only (macOS and Windows work natively) |

cmd/drift/main.go

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"os"
67

78
"github.com/alecthomas/kong"
@@ -14,17 +15,25 @@ import (
1415
var Version = "dev"
1516

1617
type cmd struct {
17-
PathA string `arg:"" help:"First path to compare."`
18-
PathB string `arg:"" help:"Second path to compare."`
18+
PathA string `arg:"" optional:"" help:"First path or git ref to compare."`
19+
PathB string `arg:"" optional:"" help:"Second path or git ref to compare."`
20+
Dir string `short:"C" help:"Change to this directory before doing anything." default:"" type:"existingdir"`
1921
Mode string `short:"m" help:"Force comparison mode (tree, binary, plist, text, image)." default:""`
22+
Git bool `help:"Force git mode (compare refs, not paths)." default:"false"`
2023
JSON bool `help:"Force JSON output."`
2124
Version kong.VersionFlag `help:"Print version and exit."`
2225
}
2326

2427
func (c *cmd) run() error {
28+
if c.Dir != "" {
29+
if err := os.Chdir(c.Dir); err != nil {
30+
return fmt.Errorf("cannot change to directory %s: %w", c.Dir, err)
31+
}
32+
}
33+
2534
interactive := !c.JSON && goterm.IsTerminal(int(os.Stdout.Fd()))
2635

27-
result, err := compare.Compare(c.PathA, c.PathB, c.Mode)
36+
result, err := c.resolve()
2837
if err != nil {
2938
return err
3039
}
@@ -34,7 +43,7 @@ func (c *cmd) run() error {
3443
}
3544

3645
// For standalone (non-tree) modes, automatically include the detail diff.
37-
if result.Mode != compare.ModeTree {
46+
if result.Mode != compare.ModeTree && result.Mode != compare.ModeGit {
3847
detail, err := compare.Detail(result, result.Root)
3948
if err != nil {
4049
return err
@@ -48,6 +57,64 @@ func (c *cmd) run() error {
4857
return outputJSON(result)
4958
}
5059

60+
// resolve determines the comparison mode and returns the result.
61+
func (c *cmd) resolve() (*compare.Result, error) {
62+
// No args: git working tree mode.
63+
if c.PathA == "" && c.PathB == "" {
64+
if !compare.IsGitRepo() {
65+
return nil, fmt.Errorf("no arguments provided and not in a git repository\n\nUsage: drift <pathA> <pathB>")
66+
}
67+
return compare.CompareGitWorkTree()
68+
}
69+
70+
// --git flag: treat args as git refs unconditionally.
71+
if c.Git {
72+
refA := c.PathA
73+
refB := c.PathB
74+
if refB == "" {
75+
// Single arg with --git: compare ref against HEAD.
76+
refB = refA
77+
refA = "HEAD"
78+
}
79+
return compare.Compare(refA, refB, compare.ModeGit)
80+
}
81+
82+
// Single arg: try as git ref, compare against HEAD.
83+
if c.PathB == "" {
84+
if !compare.IsGitRepo() {
85+
return nil, fmt.Errorf("single argument requires a git repository\n\nUsage: drift <pathA> <pathB>")
86+
}
87+
if _, err := compare.ResolveGitRef(c.PathA); err != nil {
88+
return nil, fmt.Errorf("%s is not a valid path or git ref", c.PathA)
89+
}
90+
return compare.Compare("HEAD", c.PathA, compare.ModeGit)
91+
}
92+
93+
// Two args: check if both are filesystem paths first.
94+
_, errA := os.Stat(c.PathA)
95+
_, errB := os.Stat(c.PathB)
96+
97+
if errA == nil && errB == nil {
98+
// Both exist on disk: use existing path comparison.
99+
return compare.Compare(c.PathA, c.PathB, c.Mode)
100+
}
101+
102+
// At least one doesn't exist as a path: try git refs.
103+
if compare.IsGitRepo() {
104+
_, gitErrA := compare.ResolveGitRef(c.PathA)
105+
_, gitErrB := compare.ResolveGitRef(c.PathB)
106+
if gitErrA == nil && gitErrB == nil {
107+
return compare.Compare(c.PathA, c.PathB, compare.ModeGit)
108+
}
109+
}
110+
111+
// Fall through: report the original filesystem error.
112+
if errA != nil {
113+
return nil, fmt.Errorf("cannot access %s: %w", c.PathA, errA)
114+
}
115+
return nil, fmt.Errorf("cannot access %s: %w", c.PathB, errB)
116+
}
117+
51118
type standaloneOutput struct {
52119
*compare.Result
53120
Detail *compare.DetailResult `json:"detail"`
@@ -64,7 +131,7 @@ func main() {
64131
ctx := kong.Parse(
65132
&cli,
66133
kong.Name("drift"),
67-
kong.Description("Compare files, directories, archives, and binaries."),
134+
kong.Description("Compare files, directories, archives, binaries, and git refs."),
68135
kong.UsageOnError(),
69136
kong.Vars{"version": Version},
70137
)

compare/binary.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,17 @@ func extractFromBinary(source, relPath string) (map[string]bool, []sectionInfo,
7979
return syms, secs, nil
8080
}
8181

82-
// prepareBinaryPath returns a filesystem path to the binary. For archives,
83-
// it extracts the file to a temp location and returns a cleanup function.
82+
// prepareBinaryPath returns a filesystem path to the binary. For archives
83+
// and git sources, it extracts the file to a temp location and returns a cleanup function.
8484
func prepareBinaryPath(source, relPath string) (string, func(), error) {
85+
if isGitSource(source) {
86+
data, err := readGitContent(gitRef(source), relPath)
87+
if err != nil {
88+
return "", nil, err
89+
}
90+
return writeToTempFile(data)
91+
}
92+
8593
info, err := os.Stat(source)
8694
if err != nil {
8795
return "", nil, err

compare/compare.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
ModePlist Mode = "plist"
1818
ModeText Mode = "text"
1919
ModeImage Mode = "image"
20+
ModeGit Mode = "git"
2021
)
2122

2223
// Compare runs the appropriate comparison for the given paths and mode.
@@ -44,20 +45,28 @@ func Compare(pathA, pathB, mode Mode) (*Result, error) {
4445
root, err = compareSingle(pathA, pathB, KindText)
4546
case ModeImage:
4647
root, err = compareSingle(pathA, pathB, KindImage)
48+
case ModeGit:
49+
root, err = compareGit(pathA, pathB)
4750
default:
48-
return nil, fmt.Errorf("unknown mode: %s (valid: tree, binary, plist, text, image)", mode)
51+
return nil, fmt.Errorf("unknown mode: %s (valid: tree, binary, plist, text, image, git)", mode)
4952
}
5053
if err != nil {
5154
return nil, err
5255
}
5356

54-
return &Result{
57+
result := &Result{
5558
PathA: pathA,
5659
PathB: pathB,
5760
Mode: mode,
5861
Root: root,
5962
Summary: ComputeSummary(root),
60-
}, nil
63+
}
64+
65+
if mode == ModeGit {
66+
result.Git = BuildGitMeta(pathA, pathB)
67+
}
68+
69+
return result, nil
6170
}
6271

6372
// compareSingle builds a single-node tree for standalone file comparison.

compare/detail.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,30 @@ type DirSummary struct {
2828
// Detail computes an on-demand detail diff for a specific node.
2929
// It reads file content from the sources referenced in the result.
3030
func Detail(result *Result, node *Node) (*DetailResult, error) {
31+
// Resolve source paths. For git mode, prefix with git:: so that
32+
// readContent/contentHash/prepareBinaryPath dispatch correctly.
33+
sourceA, sourceB := result.PathA, result.PathB
34+
if result.Mode == ModeGit {
35+
sourceA, sourceB = gitSourcePaths(result)
36+
}
37+
3138
switch node.Kind {
3239
case KindDirectory, KindDSYM:
3340
return &DetailResult{Kind: node.Kind, Dir: summarizeDir(node)}, nil
3441
case KindPlist:
35-
diff, err := comparePlist(result.PathA, result.PathB, node.Path, node.Status)
42+
diff, err := comparePlist(sourceA, sourceB, node.Path, node.Status)
3643
if err != nil {
3744
return nil, err
3845
}
3946
return &DetailResult{Kind: KindPlist, Plist: diff}, nil
4047
case KindMachO:
41-
diff, err := compareBinary(result.PathA, result.PathB, node.Path, node.Status)
48+
diff, err := compareBinary(sourceA, sourceB, node.Path, node.Status)
4249
if err != nil {
4350
return nil, err
4451
}
4552
return &DetailResult{Kind: KindMachO, Binary: diff}, nil
4653
case KindText:
47-
diff, err := compareText(result.PathA, result.PathB, node.Path, node.Status)
54+
diff, err := compareText(sourceA, sourceB, node.Path, node.Status)
4855
if err != nil {
4956
if errors.Is(err, ErrBinaryContent) {
5057
// File was classified as text but contains binary data.
@@ -54,7 +61,7 @@ func Detail(result *Result, node *Node) (*DetailResult, error) {
5461
}
5562
return &DetailResult{Kind: KindText, Text: diff}, nil
5663
case KindImage:
57-
diff, err := compareImage(result.PathA, result.PathB, node.Path, node.Status)
64+
diff, err := compareImage(sourceA, sourceB, node.Path, node.Status)
5865
if err != nil {
5966
return nil, err
6067
}

0 commit comments

Comments
 (0)