Skip to content

Commit 21b5bc1

Browse files
committed
Add brief diff subcommand
Filters the toolchain report to only show tools, languages, and configuration relevant to files changed between git refs. Defaults to comparing against the default branch with uncommitted changes included.
1 parent df9c15f commit 21b5bc1

7 files changed

Lines changed: 675 additions & 3 deletions

File tree

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ Add this to your `CLAUDE.md`, `agents.md`, or equivalent agent instructions file
1212

1313
```
1414
Before starting work on this project, run `brief .` to understand the toolchain,
15-
test commands, linters, and project conventions.
15+
test commands, linters, and project conventions. If on a branch, also run
16+
`brief diff` to see which parts of the toolchain are affected by your changes.
1617
```
1718

18-
The agent will get back structured information about the project's language, package manager, test runner, linter, formatter, build tools, and more, so it doesn't have to guess or ask you.
19+
The agent will get back structured information about the project's language, package manager, test runner, linter, formatter, build tools, and more, so it doesn't have to guess or ask you. On a feature branch, `brief diff` narrows that down to just the tools relevant to what's been changed, so the agent knows which linters to run, which test frameworks matter, and which config files are in play.
1920

2021
To let Claude Code run `brief` without prompting for approval each time, add this to `~/.claude/settings.json`:
2122

@@ -39,6 +40,7 @@ Or download a binary from [releases](https://github.com/git-pkgs/brief/releases)
3940

4041
```
4142
brief [flags] [path | url] Detect project toolchain
43+
brief diff [flags] [ref1] [ref2] Detect only what changed between refs
4244
brief enrich [flags] [path] Detect and enrich with external data
4345
brief list tools All tools in the knowledge base
4446
brief list ecosystems Supported ecosystems
@@ -94,6 +96,20 @@ Lines: 22912 code 191 files (scc)
9496

9597
Use `--verbose` to include homepage, docs, and repo links for each detected tool.
9698

99+
## Diff
100+
101+
`brief diff` runs the same detection but filters the report to only show tools, languages, and configuration relevant to files that changed. Useful for understanding what a branch or PR touches in terms of toolchain.
102+
103+
```
104+
brief diff Compare against default branch + uncommitted
105+
brief diff main Compare main to HEAD + uncommitted
106+
brief diff v1.0.0 v2.0.0 Compare between two refs
107+
```
108+
109+
With no arguments it auto-detects the default branch from `origin/HEAD`, falling back to `main` or `master`. The output lists changed files and only the toolchain entries those files relate to: if you changed a `.go` file, you'll see Go and its tools but not Python. If you changed `.golangci.yml`, you'll see golangci-lint. If you changed `go.mod`, you'll see dependency information.
110+
111+
Same output format as `brief` -- JSON when piped, human-readable on a TTY.
112+
97113
## Enrichment
98114

99115
`brief enrich` runs the same scan, then fetches metadata from external APIs about the project itself: what packages it publishes, their download counts and dependents, runtime end-of-life status, and OpenSSF Scorecard.

brief.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ type RuntimeEOL struct {
160160
type Report struct {
161161
Version string `json:"version"`
162162
Path string `json:"path"`
163+
DiffRef string `json:"diff_ref,omitempty"`
164+
ChangedFiles []string `json:"changed_files,omitempty"`
163165
Languages []Detection `json:"languages"`
164166
PackageManagers []Detection `json:"package_managers"`
165167
Scripts []Script `json:"scripts,omitempty"`

cmd/brief/diff.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/git-pkgs/brief"
13+
"github.com/git-pkgs/brief/detect"
14+
"github.com/git-pkgs/brief/kb"
15+
"github.com/git-pkgs/brief/report"
16+
)
17+
18+
func cmdDiff(args []string) {
19+
fs := flag.NewFlagSet("brief diff", flag.ExitOnError)
20+
jsonFlag := fs.Bool("json", false, "Force JSON output")
21+
humanFlag := fs.Bool("human", false, "Force human-readable output")
22+
verbose := fs.Bool("verbose", false, "Include breadcrumb/reference information")
23+
category := fs.String("category", "", "Only report on specific category")
24+
_ = fs.Parse(args)
25+
26+
// Determine the project root (git toplevel).
27+
root, err := gitToplevel()
28+
if err != nil {
29+
_, _ = fmt.Fprintf(os.Stderr, "error: not a git repository\n")
30+
os.Exit(1)
31+
}
32+
33+
// Determine diff refs.
34+
// No args: diff from default branch to working tree (including uncommitted).
35+
// One arg: diff from that ref to working tree.
36+
// Two args: diff between two refs.
37+
var ref1, ref2 string
38+
var diffRef string
39+
includeUncommitted := false
40+
41+
switch fs.NArg() {
42+
case 0:
43+
// Default: compare against the default branch + uncommitted changes.
44+
ref1 = detectDefaultBranch(root)
45+
includeUncommitted = true
46+
diffRef = ref1 + "..HEAD (+ uncommitted)"
47+
case 1:
48+
ref1 = fs.Arg(0)
49+
includeUncommitted = true
50+
diffRef = ref1 + " (+ uncommitted)"
51+
case 2:
52+
ref1 = fs.Arg(0)
53+
ref2 = fs.Arg(1)
54+
diffRef = ref1 + ".." + ref2
55+
default:
56+
_, _ = fmt.Fprintf(os.Stderr, "usage: brief diff [ref1] [ref2]\n")
57+
os.Exit(1)
58+
}
59+
60+
changedFiles, err := gitChangedFiles(context.Background(), root, ref1, ref2, includeUncommitted)
61+
if err != nil {
62+
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
63+
os.Exit(1)
64+
}
65+
66+
if len(changedFiles) == 0 {
67+
_, _ = fmt.Fprintf(os.Stderr, "no changed files\n")
68+
os.Exit(0)
69+
}
70+
71+
knowledgeBase, err := kb.Load(brief.KnowledgeFS)
72+
if err != nil {
73+
_, _ = fmt.Fprintf(os.Stderr, "error loading knowledge base: %v\n", err)
74+
os.Exit(1)
75+
}
76+
77+
engine := detect.New(knowledgeBase, root)
78+
r, err := engine.Run()
79+
if err != nil {
80+
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
81+
os.Exit(1)
82+
}
83+
84+
r.DiffRef = diffRef
85+
r.ChangedFiles = changedFiles
86+
87+
r = detect.FilterByChangedFiles(r, knowledgeBase, changedFiles)
88+
89+
if *category != "" {
90+
r = filterCategory(r, *category)
91+
}
92+
93+
useJSON := *jsonFlag || (!*humanFlag && !isTTY())
94+
95+
if useJSON {
96+
if err := report.JSON(os.Stdout, r); err != nil {
97+
_, _ = fmt.Fprintf(os.Stderr, "error writing JSON: %v\n", err)
98+
os.Exit(1)
99+
}
100+
} else {
101+
report.Human(os.Stdout, r, *verbose)
102+
}
103+
}
104+
105+
// gitToplevel returns the root of the git repository.
106+
func gitToplevel() (string, error) {
107+
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
108+
out, err := cmd.Output()
109+
if err != nil {
110+
return "", err
111+
}
112+
return strings.TrimSpace(string(out)), nil
113+
}
114+
115+
// detectDefaultBranch tries to find the default branch name (main or master).
116+
func detectDefaultBranch(root string) string {
117+
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "origin/HEAD")
118+
cmd.Dir = root
119+
if out, err := cmd.Output(); err == nil {
120+
ref := strings.TrimSpace(string(out))
121+
if after, ok := strings.CutPrefix(ref, "origin/"); ok && after != "" {
122+
return after
123+
}
124+
}
125+
126+
// Fall back to checking if main or master exists.
127+
for _, branch := range []string{"main", "master"} {
128+
cmd := exec.Command("git", "rev-parse", "--verify", branch)
129+
cmd.Dir = root
130+
if err := cmd.Run(); err == nil {
131+
return branch
132+
}
133+
}
134+
135+
return "main"
136+
}
137+
138+
// gitChangedFiles returns the list of files that differ between refs.
139+
// If includeUncommitted is true, also includes staged and unstaged changes.
140+
func gitChangedFiles(ctx context.Context, root, ref1, ref2 string, includeUncommitted bool) ([]string, error) {
141+
seen := make(map[string]bool)
142+
var files []string
143+
144+
addFiles := func(output string) {
145+
for _, f := range strings.Split(strings.TrimSpace(output), "\n") {
146+
f = strings.TrimSpace(f)
147+
if f != "" && !seen[f] {
148+
seen[f] = true
149+
files = append(files, f)
150+
}
151+
}
152+
}
153+
154+
if ref2 != "" {
155+
// Two-ref diff: just diff between the two.
156+
cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", ref1+"..."+ref2)
157+
cmd.Dir = root
158+
out, err := cmd.Output()
159+
if err != nil {
160+
// Try two-dot diff if three-dot fails.
161+
cmd = exec.CommandContext(ctx, "git", "diff", "--name-only", ref1, ref2)
162+
cmd.Dir = root
163+
out, err = cmd.Output()
164+
if err != nil {
165+
return nil, fmt.Errorf("git diff %s %s: %w", ref1, ref2, err)
166+
}
167+
}
168+
addFiles(string(out))
169+
} else {
170+
// Compare ref1 to HEAD.
171+
cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", ref1+"...HEAD")
172+
cmd.Dir = root
173+
out, err := cmd.Output()
174+
if err != nil {
175+
cmd = exec.CommandContext(ctx, "git", "diff", "--name-only", ref1, "HEAD")
176+
cmd.Dir = root
177+
out, err = cmd.Output()
178+
if err != nil {
179+
return nil, fmt.Errorf("git diff %s: %w", ref1, err)
180+
}
181+
}
182+
addFiles(string(out))
183+
184+
if includeUncommitted {
185+
// Staged changes.
186+
cmd = exec.CommandContext(ctx, "git", "diff", "--name-only", "--cached")
187+
cmd.Dir = root
188+
if out, err := cmd.Output(); err == nil {
189+
addFiles(string(out))
190+
}
191+
192+
// Unstaged changes.
193+
cmd = exec.CommandContext(ctx, "git", "diff", "--name-only")
194+
cmd.Dir = root
195+
if out, err := cmd.Output(); err == nil {
196+
addFiles(string(out))
197+
}
198+
199+
// Untracked files.
200+
cmd = exec.CommandContext(ctx, "git", "ls-files", "--others", "--exclude-standard")
201+
cmd.Dir = root
202+
if out, err := cmd.Output(); err == nil {
203+
addFiles(string(out))
204+
}
205+
}
206+
}
207+
208+
// Make paths relative to root.
209+
for i, f := range files {
210+
if filepath.IsAbs(f) {
211+
if rel, err := filepath.Rel(root, f); err == nil {
212+
files[i] = rel
213+
}
214+
}
215+
}
216+
217+
return files, nil
218+
}

cmd/brief/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ func main() {
3333
case "enrich":
3434
cmdEnrich(os.Args[2:])
3535
return
36+
case "diff":
37+
cmdDiff(os.Args[2:])
38+
return
3639
}
3740
}
3841

0 commit comments

Comments
 (0)