Skip to content

Commit d73d771

Browse files
committed
feat: implement agit diff — semantic diff between commits
1 parent 2f5c0ee commit d73d771

5 files changed

Lines changed: 984 additions & 2 deletions

File tree

.claude/settings.local.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,33 @@
5656
"Bash(git push origin v0.1.1)",
5757
"Bash(gh run list --repo Madhurr/agit --limit 3)",
5858
"Bash(gh run watch 22799056340 --repo Madhurr/agit)",
59-
"Bash(gh run view 22799056340 --repo Madhurr/agit --log-failed)"
59+
"Bash(gh run view 22799056340 --repo Madhurr/agit --log-failed)",
60+
"Bash(gh release view v0.1.2 --repo Madhurr/agit)",
61+
"WebFetch(domain:github.com)",
62+
"Bash(node --version)",
63+
"Bash(nvm install 22 --lts)",
64+
"Bash(nvm use 22)",
65+
"Bash(npm install -g pnpm)",
66+
"Bash(pnpm install)",
67+
"Bash(pnpm build)",
68+
"Bash(node dist/cli.js --help)",
69+
"Bash(ls /home/madhur/openclaw/dist/*.js)",
70+
"Bash(python3 -c \"import json,sys; p=json.load\\(sys.stdin\\); print\\(''bin:'', p.get\\(''bin''\\)\\); print\\(''main:'', p.get\\(''main''\\)\\); print\\(''scripts:'', list\\(p.get\\(''scripts'',{}\\).keys\\(\\)\\)[:10]\\)\")",
71+
"Bash(node openclaw.mjs --help)",
72+
"Bash(node openclaw.mjs config --help)",
73+
"Bash(python3 -c \"import json,sys; p=json.load\\(sys.stdin\\); print\\(''name:'', p.get\\(''name''\\)\\); print\\(''description:'', p.get\\(''description'',''''\\)[:100]\\); print\\(''scripts:'', list\\(p.get\\(''scripts'',{}\\).keys\\(\\)\\)[:15]\\)\")",
74+
"Bash(lsof -i :18789)",
75+
"Bash(git clone https://github.com/router-for-me/CLIProxyAPI.git /tmp/CLIProxyAPI)",
76+
"Bash(go build -o cliproxy .)",
77+
"Bash(go build -o cliproxy ./cmd/server/)",
78+
"Bash(ls ~/.claude/*.json)",
79+
"Bash(sudo cp /tmp/CLIProxyAPI/cliproxy /usr/local/bin/cliproxy)",
80+
"Bash(openclaw configure --help)",
81+
"Bash(openclaw skills --help)",
82+
"Bash(openclaw skill --help)",
83+
"Bash(openclaw --help)",
84+
"Bash(clawhub --help)",
85+
"Bash(clawhub search agent)"
6086
]
6187
}
6288
}

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,30 @@ Shows full agent context for a commit (defaults to HEAD).
228228
--json outputs the raw CommitNote JSON.
229229
```
230230

231+
### `agit diff`
232+
```
233+
Usage: agit diff [from] [to] [--json] [--files]
234+
235+
Compare agent reasoning between two commits.
236+
Shows what changed in intent, confidence, risks, unknowns,
237+
alternatives, and other metadata — the reasoning diff, not the code diff.
238+
239+
agit diff # HEAD~1 vs HEAD
240+
agit diff abc1234 # abc1234 vs HEAD
241+
agit diff abc1234 def5678 # any two commits
242+
243+
Flags:
244+
--json output as JSON (for agents reading diffs)
245+
--files include file-level changes
246+
```
247+
248+
Output groups changes into: **Changed**, **Added**, **Resolved**, **Removed**.
249+
250+
- Confidence changes show ↑/↓ direction with color
251+
- Risk resolutions are highlighted with ✓
252+
- Unknowns that disappear show as "resolved"
253+
- Works even when one commit has no agit note
254+
231255
### `agit init`
232256
```
233257
Usage: agit init
@@ -300,7 +324,7 @@ The CommitNote JSON stored per commit:
300324
- [x] `agit init` — repo initialization
301325
- [x] GitHub App — agent context in PR comments
302326
- [x] GoReleaser — multi-platform binary releases
303-
- [ ] `agit diff` — semantic diff between any two commits
327+
- [x] `agit diff` — semantic diff between any two commits
304328
- [ ] VS Code extension — inline context in editor
305329
- [ ] GitLab App
306330

cmd/diff.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/fatih/color"
10+
"github.com/spf13/cobra"
11+
12+
"github.com/madhurm/agit/internal/drift"
13+
"github.com/madhurm/agit/internal/git"
14+
"github.com/madhurm/agit/internal/notes"
15+
)
16+
17+
var diffCmd = &cobra.Command{
18+
Use: "diff [from] [to]",
19+
Short: "Semantic diff of agent context between two commits",
20+
Long: `Compare agent reasoning between two commits.
21+
22+
Shows what changed in intent, confidence, risks, unknowns, alternatives,
23+
and other agent metadata — not the code diff, but the reasoning diff.
24+
25+
Examples:
26+
agit diff # HEAD~1 vs HEAD
27+
agit diff abc1234 # abc1234 vs HEAD
28+
agit diff abc1234 def5678 # abc1234 vs def5678`,
29+
Args: cobra.MaximumNArgs(2),
30+
RunE: runDiff,
31+
}
32+
33+
func init() {
34+
rootCmd.AddCommand(diffCmd)
35+
diffCmd.Flags().Bool("json", false, "output as JSON")
36+
diffCmd.Flags().Bool("files", false, "include file-level changes")
37+
}
38+
39+
func runDiff(cmd *cobra.Command, args []string) error {
40+
dir, err := os.Getwd()
41+
if err != nil {
42+
return fmt.Errorf("get working dir: %w", err)
43+
}
44+
45+
jsonOutput, _ := cmd.Flags().GetBool("json")
46+
showFiles, _ := cmd.Flags().GetBool("files")
47+
48+
// Resolve commit hashes
49+
var fromRef, toRef string
50+
51+
switch len(args) {
52+
case 0:
53+
fromRef = "HEAD~1"
54+
toRef = "HEAD"
55+
case 1:
56+
fromRef = args[0]
57+
toRef = "HEAD"
58+
case 2:
59+
fromRef = args[0]
60+
toRef = args[1]
61+
}
62+
63+
fromHash, err := resolveRef(dir, fromRef)
64+
if err != nil {
65+
return fmt.Errorf("cannot resolve %q: %w", fromRef, err)
66+
}
67+
68+
toHash, err := resolveRef(dir, toRef)
69+
if err != nil {
70+
return fmt.Errorf("cannot resolve %q: %w", toRef, err)
71+
}
72+
73+
// Read notes
74+
fromNote, _ := notes.Read(dir, fromHash)
75+
toNote, _ := notes.Read(dir, toHash)
76+
77+
// Compute semantic diff
78+
result := drift.Diff(fromHash, fromNote, toHash, toNote)
79+
80+
// Optionally include file changes
81+
if showFiles {
82+
files, err := git.DiffFiles(dir, toHash)
83+
if err == nil {
84+
result.FilesAdded = files
85+
}
86+
}
87+
88+
if jsonOutput {
89+
data, err := json.MarshalIndent(result, "", " ")
90+
if err != nil {
91+
return fmt.Errorf("marshal JSON: %w", err)
92+
}
93+
fmt.Println(string(data))
94+
return nil
95+
}
96+
97+
printDiff(result, fromRef, toRef)
98+
return nil
99+
}
100+
101+
func printDiff(result *drift.DiffResult, fromRef, toRef string) {
102+
header := color.New(color.Bold, color.FgYellow)
103+
dim := color.New(color.Faint)
104+
105+
// Header
106+
header.Printf("agit diff %s..%s\n", shortRef(result.FromHash), shortRef(result.ToHash))
107+
fmt.Println()
108+
109+
if !result.FromNote && !result.ToNote {
110+
dim.Println("Neither commit has agent context.")
111+
return
112+
}
113+
114+
if !result.FromNote {
115+
color.Cyan("ℹ %s has no agit note — showing all context as new\n\n", fromRef)
116+
}
117+
118+
if !result.ToNote {
119+
color.Yellow("⚠ %s has no agit note — agent context was dropped\n", toRef)
120+
return
121+
}
122+
123+
if len(result.Changes) == 0 {
124+
color.Green("✓ No semantic changes in agent context\n")
125+
return
126+
}
127+
128+
fmt.Printf("%s\n\n", result.Summary)
129+
130+
// Group changes by kind for cleaner output
131+
var added, changed, removed, resolved []drift.FieldDiff
132+
for _, c := range result.Changes {
133+
switch c.Kind {
134+
case drift.Added:
135+
added = append(added, c)
136+
case drift.Changed:
137+
changed = append(changed, c)
138+
case drift.Removed:
139+
removed = append(removed, c)
140+
case drift.Resolved:
141+
resolved = append(resolved, c)
142+
}
143+
}
144+
145+
if len(changed) > 0 {
146+
header.Println("Changed:")
147+
for _, c := range changed {
148+
printChange(c)
149+
}
150+
fmt.Println()
151+
}
152+
153+
if len(added) > 0 {
154+
header.Println("Added:")
155+
for _, c := range added {
156+
printAdded(c)
157+
}
158+
fmt.Println()
159+
}
160+
161+
if len(resolved) > 0 {
162+
header.Println("Resolved:")
163+
for _, c := range resolved {
164+
printResolved(c)
165+
}
166+
fmt.Println()
167+
}
168+
169+
if len(removed) > 0 {
170+
header.Println("Removed:")
171+
for _, c := range removed {
172+
dim.Printf(" - %s\n", c.Detail)
173+
}
174+
fmt.Println()
175+
}
176+
177+
if len(result.FilesAdded) > 0 {
178+
header.Println("Files changed:")
179+
for _, f := range result.FilesAdded {
180+
fmt.Printf(" %s\n", f)
181+
}
182+
fmt.Println()
183+
}
184+
}
185+
186+
func printChange(c drift.FieldDiff) {
187+
switch c.Field {
188+
case "confidence":
189+
// Color based on direction
190+
if strings.Contains(c.Detail, "↑") {
191+
color.Green(" %s %s → %s", fieldLabel(c.Field), c.Old, c.New)
192+
} else {
193+
color.Red(" %s %s → %s", fieldLabel(c.Field), c.Old, c.New)
194+
}
195+
fmt.Println()
196+
case "risks":
197+
sev := severityColor(c.Severity)
198+
sev.Printf(" %s %s\n", fieldLabel(c.Field), c.Detail)
199+
case "test_results":
200+
fmt.Printf(" %s %s\n", fieldLabel(c.Field), c.Detail)
201+
default:
202+
fmt.Printf(" %s %q → %q\n", fieldLabel(c.Field), c.Old, c.New)
203+
}
204+
}
205+
206+
func printAdded(c drift.FieldDiff) {
207+
switch c.Field {
208+
case "risks":
209+
sev := severityColor(c.Severity)
210+
sev.Printf(" + %s\n", c.Detail)
211+
default:
212+
color.Green(" + %s\n", c.Detail)
213+
}
214+
}
215+
216+
func printResolved(c drift.FieldDiff) {
217+
color.Green(" ✓ %s\n", c.Detail)
218+
}
219+
220+
func fieldLabel(field string) string {
221+
labels := map[string]string{
222+
"intent": "Intent:",
223+
"confidence": "Confidence:",
224+
"confidence_rationale": "Rationale:",
225+
"task": "Task:",
226+
"agent": "Agent:",
227+
"risks": "Risk:",
228+
"unknowns": "Unknown:",
229+
"alternatives": "Alternative:",
230+
"ripple_effects": "Ripple:",
231+
"key_decisions": "Decision:",
232+
"test_results": "Tests:",
233+
}
234+
if label, ok := labels[field]; ok {
235+
return label
236+
}
237+
return field + ":"
238+
}
239+
240+
func severityColor(severity string) *color.Color {
241+
switch severity {
242+
case "high":
243+
return color.New(color.FgRed)
244+
case "medium":
245+
return color.New(color.FgYellow)
246+
default:
247+
return color.New(color.FgCyan)
248+
}
249+
}
250+
251+
func resolveRef(dir, ref string) (string, error) {
252+
hash, err := git.RunGit(dir, "rev-parse", ref)
253+
if err != nil {
254+
return "", err
255+
}
256+
return hash, nil
257+
}
258+
259+
func shortRef(hash string) string {
260+
if len(hash) > 7 {
261+
return hash[:7]
262+
}
263+
return hash
264+
}

0 commit comments

Comments
 (0)