Skip to content

Commit ddbf266

Browse files
committed
feat: capture staged diff, staged TODOs, and test failure details
- Add stagedDiffOutput (git diff --cached) so staged changes are tracked separately from the combined HEAD diff - Surface TODOs from staged changes as StagedTODOs in Brief, rendered as a dedicated "todos in staged changes" section - Add FailureDetail to TestResult: accumulates go test -json output lines per failing test so stack traces and assertion messages are included in the snapshot instead of just test names
1 parent ff99252 commit ddbf266

6 files changed

Lines changed: 265 additions & 11 deletions

File tree

‎brief.go‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ type Config struct {
2020

2121
// Brief is the collected snapshot of a repository's current state.
2222
type Brief struct {
23-
Dir string `json:"dir"`
24-
Branch string `json:"branch"`
25-
Commits []Commit `json:"commits"`
26-
Status []StatusLine `json:"status"`
27-
TODOs []TODOItem `json:"todos"`
28-
Tests *TestResult `json:"tests,omitempty"`
29-
At time.Time `json:"at"`
23+
Dir string `json:"dir"`
24+
Branch string `json:"branch"`
25+
Commits []Commit `json:"commits"`
26+
Status []StatusLine `json:"status"`
27+
TODOs []TODOItem `json:"todos"`
28+
StagedTODOs []TODOItem `json:"staged_todos,omitempty"`
29+
Tests *TestResult `json:"tests,omitempty"`
30+
At time.Time `json:"at"`
3031
}
3132

3233
// Run parses args and runs the tool, writing output to stdout.
@@ -91,6 +92,10 @@ func Collect(cfg Config) (*Brief, error) {
9192
b.TODOs = todosInDiff(diff)
9293
}
9394

95+
if staged, err := stagedDiffOutput(absDir); err == nil {
96+
b.StagedTODOs = todosInDiff(staged)
97+
}
98+
9499
if cfg.RunTests {
95100
b.Tests, _ = runTests(absDir, 60*time.Second)
96101
}
@@ -126,14 +131,28 @@ func printText(w io.Writer, b *Brief) error {
126131
}
127132
}
128133

134+
if len(b.StagedTODOs) > 0 {
135+
fmt.Fprintf(w, "\n## todos in staged changes\n")
136+
for _, td := range b.StagedTODOs {
137+
fmt.Fprintf(w, "- `%s:%d` **%s**: %s\n", td.File, td.Line, td.Kind, td.Text)
138+
}
139+
}
140+
129141
if b.Tests != nil {
130142
fmt.Fprintf(w, "\n## tests\n")
131143
if b.Tests.Passed {
132144
fmt.Fprintf(w, "✓ %d passed (%s)\n", b.Tests.Count, b.Tests.Elapsed.Round(time.Millisecond))
133145
} else {
134146
fmt.Fprintf(w, "✗ failed\n")
147+
detailsByTest := make(map[string][]string, len(b.Tests.Details))
148+
for _, d := range b.Tests.Details {
149+
detailsByTest[d.Test] = d.Messages
150+
}
135151
for _, f := range b.Tests.Failed {
136152
fmt.Fprintf(w, "- %s\n", f)
153+
for _, msg := range detailsByTest[f] {
154+
fmt.Fprintf(w, " %s", msg)
155+
}
137156
}
138157
}
139158
}

‎brief_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,54 @@ func TestCollect_TODOsInDiff(t *testing.T) {
6464
}
6565
}
6666

67+
func TestCollect_StagedTODOs(t *testing.T) {
68+
dir := tempGitRepo(t)
69+
70+
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// TODO: staged work\n"), 0644); err != nil {
71+
t.Fatal(err)
72+
}
73+
cmd := exec.Command("git", "add", "main.go")
74+
cmd.Dir = dir
75+
if out, err := cmd.CombinedOutput(); err != nil {
76+
t.Fatalf("git add: %v\n%s", err, out)
77+
}
78+
79+
b, err := Collect(Config{Dir: dir, NumCommits: 5})
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
if len(b.StagedTODOs) == 0 {
84+
t.Error("expected StagedTODOs from staged diff")
85+
}
86+
if b.StagedTODOs[0].Kind != "TODO" {
87+
t.Errorf("Kind = %q, want TODO", b.StagedTODOs[0].Kind)
88+
}
89+
if b.StagedTODOs[0].Text != "staged work" {
90+
t.Errorf("Text = %q, want %q", b.StagedTODOs[0].Text, "staged work")
91+
}
92+
}
93+
94+
func TestCollect_UnstagedTODOs_NotInStaged(t *testing.T) {
95+
dir := tempGitRepo(t)
96+
97+
// Write TODO but do NOT stage it
98+
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// TODO: unstaged work\n"), 0644); err != nil {
99+
t.Fatal(err)
100+
}
101+
102+
b, err := Collect(Config{Dir: dir, NumCommits: 5})
103+
if err != nil {
104+
t.Fatal(err)
105+
}
106+
if len(b.StagedTODOs) != 0 {
107+
t.Errorf("expected no StagedTODOs for unstaged change, got %v", b.StagedTODOs)
108+
}
109+
// TODOs in unstaged diff (via git diff HEAD) should still appear
110+
if len(b.TODOs) == 0 {
111+
t.Error("expected TODOs from unstaged diff")
112+
}
113+
}
114+
67115
func TestCollect_MultipleCommits(t *testing.T) {
68116
dir := tempGitRepo(t)
69117

@@ -135,6 +183,28 @@ func TestPrintText_TODOsAndTests(t *testing.T) {
135183
}
136184
}
137185

186+
func TestPrintText_StagedTODOs(t *testing.T) {
187+
b := &Brief{
188+
Dir: "/repo",
189+
Branch: "main",
190+
StagedTODOs: []TODOItem{
191+
{File: "api.go", Line: 7, Kind: "TODO", Text: "wire up handler"},
192+
},
193+
}
194+
195+
var buf bytes.Buffer
196+
if err := printText(&buf, b); err != nil {
197+
t.Fatal(err)
198+
}
199+
out := buf.String()
200+
201+
for _, want := range []string{"todos in staged changes", "api.go:7", "TODO", "wire up handler"} {
202+
if !strings.Contains(out, want) {
203+
t.Errorf("output missing %q:\n%s", want, out)
204+
}
205+
}
206+
}
207+
138208
func TestPrintText_FailedTests(t *testing.T) {
139209
b := &Brief{
140210
Dir: "/repo",
@@ -158,6 +228,32 @@ func TestPrintText_FailedTests(t *testing.T) {
158228
}
159229
}
160230

231+
func TestPrintText_FailedTestsWithDetails(t *testing.T) {
232+
b := &Brief{
233+
Dir: "/repo",
234+
Branch: "main",
235+
Tests: &TestResult{
236+
Passed: false,
237+
Failed: []string{"TestFoo"},
238+
Details: []FailureDetail{
239+
{Test: "TestFoo", Messages: []string{" foo_test.go:42: assertion failed\n"}},
240+
},
241+
},
242+
}
243+
244+
var buf bytes.Buffer
245+
if err := printText(&buf, b); err != nil {
246+
t.Fatal(err)
247+
}
248+
out := buf.String()
249+
250+
for _, want := range []string{"TestFoo", "foo_test.go:42", "assertion failed"} {
251+
if !strings.Contains(out, want) {
252+
t.Errorf("output missing %q:\n%s", want, out)
253+
}
254+
}
255+
}
256+
161257
func TestPrintJSON(t *testing.T) {
162258
dir := tempGitRepo(t)
163259
b, err := Collect(Config{Dir: dir, NumCommits: 5})

‎git.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ func diffOutput(dir string) (string, error) {
7979
return gitCmd(dir, "diff", "HEAD")
8080
}
8181

82+
func stagedDiffOutput(dir string) (string, error) {
83+
return gitCmd(dir, "diff", "--cached")
84+
}
85+
8286
func gitCmd(dir string, args ...string) (string, error) {
8387
cmd := exec.Command("git", args...)
8488
cmd.Dir = dir

‎git_test.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,55 @@ func TestRecentCommits(t *testing.T) {
180180
}
181181
}
182182

183+
func TestStagedDiffOutput_Empty(t *testing.T) {
184+
dir := tempGitRepo(t)
185+
out, err := stagedDiffOutput(dir)
186+
if err != nil {
187+
t.Fatal(err)
188+
}
189+
if out != "" {
190+
t.Errorf("expected empty staged diff, got %q", out)
191+
}
192+
}
193+
194+
func TestStagedDiffOutput_ShowsStagedContent(t *testing.T) {
195+
dir := tempGitRepo(t)
196+
197+
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// staged change\n"), 0644); err != nil {
198+
t.Fatal(err)
199+
}
200+
cmd := exec.Command("git", "add", "main.go")
201+
cmd.Dir = dir
202+
if out, err := cmd.CombinedOutput(); err != nil {
203+
t.Fatalf("git add: %v\n%s", err, out)
204+
}
205+
206+
diff, err := stagedDiffOutput(dir)
207+
if err != nil {
208+
t.Fatal(err)
209+
}
210+
if !strings.Contains(diff, "staged change") {
211+
t.Errorf("staged diff missing expected content, got %q", diff)
212+
}
213+
}
214+
215+
func TestStagedDiffOutput_ExcludesUnstaged(t *testing.T) {
216+
dir := tempGitRepo(t)
217+
218+
// Write file but do NOT stage it
219+
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// unstaged only\n"), 0644); err != nil {
220+
t.Fatal(err)
221+
}
222+
223+
diff, err := stagedDiffOutput(dir)
224+
if err != nil {
225+
t.Fatal(err)
226+
}
227+
if strings.Contains(diff, "unstaged only") {
228+
t.Errorf("staged diff should not contain unstaged content, got %q", diff)
229+
}
230+
}
231+
183232
func TestWorkingStatus(t *testing.T) {
184233
dir := tempGitRepo(t)
185234

‎tests.go‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,19 @@ import (
88
"time"
99
)
1010

11+
// FailureDetail holds the output messages for a single failing test.
12+
type FailureDetail struct {
13+
Test string `json:"test"`
14+
Messages []string `json:"messages"`
15+
}
16+
1117
// TestResult holds the outcome of running go test.
1218
type TestResult struct {
13-
Passed bool `json:"passed"`
14-
Count int `json:"count"`
15-
Elapsed time.Duration `json:"elapsed_ns"`
16-
Failed []string `json:"failed,omitempty"`
19+
Passed bool `json:"passed"`
20+
Count int `json:"count"`
21+
Elapsed time.Duration `json:"elapsed_ns"`
22+
Failed []string `json:"failed,omitempty"`
23+
Details []FailureDetail `json:"details,omitempty"`
1724
}
1825

1926
func runTests(dir string, timeout time.Duration) (*TestResult, error) {
@@ -35,13 +42,16 @@ type testEvent struct {
3542
Action string `json:"Action"`
3643
Test string `json:"Test"`
3744
Elapsed float64 `json:"Elapsed"`
45+
Output string `json:"Output"`
3846
}
3947

4048
func parseTestEvents(output string) *TestResult {
4149
var (
4250
r TestResult
4351
elapsed float64
4452
hasData bool
53+
// accumulate output lines per test name
54+
testOutput = map[string][]string{}
4555
)
4656
r.Passed = true
4757

@@ -55,15 +65,24 @@ func parseTestEvents(output string) *TestResult {
5565
}
5666
hasData = true
5767
switch ev.Action {
68+
case "output":
69+
if ev.Test != "" && ev.Output != "" {
70+
testOutput[ev.Test] = append(testOutput[ev.Test], ev.Output)
71+
}
5872
case "pass":
5973
if ev.Test != "" {
6074
r.Count++
75+
delete(testOutput, ev.Test)
6176
} else {
6277
elapsed += ev.Elapsed
6378
}
6479
case "fail":
6580
if ev.Test != "" {
6681
r.Failed = append(r.Failed, ev.Test)
82+
if msgs := testOutput[ev.Test]; len(msgs) > 0 {
83+
r.Details = append(r.Details, FailureDetail{Test: ev.Test, Messages: msgs})
84+
}
85+
delete(testOutput, ev.Test)
6786
} else {
6887
r.Passed = false
6988
elapsed += ev.Elapsed

‎tests_test.go‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package brief
22

33
import (
4+
"strings"
45
"testing"
56
"time"
67
)
@@ -53,6 +54,72 @@ func TestParseTestEvents_WithFailure(t *testing.T) {
5354
}
5455
}
5556

57+
func TestParseTestEvents_FailureDetails(t *testing.T) {
58+
input := `{"Action":"run","Package":"p","Test":"TestFoo"}
59+
{"Action":"output","Package":"p","Test":"TestFoo","Output":" foo_test.go:42: want true, got false\n"}
60+
{"Action":"output","Package":"p","Test":"TestFoo","Output":" foo_test.go:43: another error\n"}
61+
{"Action":"fail","Package":"p","Test":"TestFoo","Elapsed":0.001}
62+
{"Action":"fail","Package":"p","Elapsed":0.005}
63+
`
64+
r := parseTestEvents(input)
65+
if r == nil {
66+
t.Fatal("nil result")
67+
}
68+
if r.Passed {
69+
t.Error("want Passed = false")
70+
}
71+
if len(r.Details) != 1 {
72+
t.Fatalf("len(Details) = %d, want 1", len(r.Details))
73+
}
74+
if r.Details[0].Test != "TestFoo" {
75+
t.Errorf("Details[0].Test = %q, want TestFoo", r.Details[0].Test)
76+
}
77+
if len(r.Details[0].Messages) != 2 {
78+
t.Fatalf("len(Messages) = %d, want 2", len(r.Details[0].Messages))
79+
}
80+
if !strings.Contains(r.Details[0].Messages[0], "want true, got false") {
81+
t.Errorf("Messages[0] = %q", r.Details[0].Messages[0])
82+
}
83+
}
84+
85+
func TestParseTestEvents_FailureDetails_MultipleTests(t *testing.T) {
86+
input := `{"Action":"run","Package":"p","Test":"TestA"}
87+
{"Action":"output","Package":"p","Test":"TestA","Output":" a_test.go:1: err A\n"}
88+
{"Action":"fail","Package":"p","Test":"TestA","Elapsed":0.001}
89+
{"Action":"run","Package":"p","Test":"TestB"}
90+
{"Action":"output","Package":"p","Test":"TestB","Output":" b_test.go:2: err B\n"}
91+
{"Action":"fail","Package":"p","Test":"TestB","Elapsed":0.002}
92+
{"Action":"run","Package":"p","Test":"TestC"}
93+
{"Action":"pass","Package":"p","Test":"TestC","Elapsed":0.001}
94+
{"Action":"fail","Package":"p","Elapsed":0.005}
95+
`
96+
r := parseTestEvents(input)
97+
if r == nil {
98+
t.Fatal("nil result")
99+
}
100+
if len(r.Details) != 2 {
101+
t.Fatalf("len(Details) = %d, want 2", len(r.Details))
102+
}
103+
if r.Details[0].Test != "TestA" || r.Details[1].Test != "TestB" {
104+
t.Errorf("Details tests = %q, %q", r.Details[0].Test, r.Details[1].Test)
105+
}
106+
}
107+
108+
func TestParseTestEvents_PassedTestsHaveNoDetails(t *testing.T) {
109+
input := `{"Action":"run","Package":"p","Test":"TestFoo"}
110+
{"Action":"output","Package":"p","Test":"TestFoo","Output":"=== RUN TestFoo\n"}
111+
{"Action":"pass","Package":"p","Test":"TestFoo","Elapsed":0.001}
112+
{"Action":"pass","Package":"p","Elapsed":0.005}
113+
`
114+
r := parseTestEvents(input)
115+
if r == nil {
116+
t.Fatal("nil result")
117+
}
118+
if len(r.Details) != 0 {
119+
t.Errorf("expected no Details for passing tests, got %v", r.Details)
120+
}
121+
}
122+
56123
func TestParseTestEvents_Empty(t *testing.T) {
57124
if r := parseTestEvents(""); r != nil {
58125
t.Errorf("want nil for empty input, got %+v", r)

0 commit comments

Comments
 (0)