From b32c5533e88351836905fd262d815cc64e67253f Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 16 Jul 2026 16:11:08 -0300 Subject: [PATCH] fix: count untracked additions in a repo with no commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff ran `git diff --numstat HEAD`, which errors in a freshly `git init`'d repo (no HEAD) and returned early, skipping the untracked-file counting block. The +/- badge on the session card and footer showed +0 -0 even though the review panel rendered the full diff. Resolve the diff base the way DiffText already does — fall back to git's empty tree when HEAD is missing — and wrap the numstat parse in an err == nil guard so a numstat failure never skips the untracked block. --- internal/project/project.go | 30 +++++++++++++++++------------- internal/project/project_test.go | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/internal/project/project.go b/internal/project/project.go index eb05a132c..a8a012186 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -142,20 +142,24 @@ func (s *Service) Diff(path string) DiffStats { if out, err := exec.Command("git", "-C", path, "status", "--porcelain").Output(); err == nil { stats.Files = countLines(out) } - out, err := exec.Command("git", "-C", path, "diff", "--numstat", "HEAD").Output() - if err != nil { - return stats - } - for line := range strings.Lines(string(out)) { - cols := strings.Fields(line) - if len(cols) < 3 { - continue + // A repository without commits has no HEAD; diff against git's empty tree, + // same as DiffText. Errors here must not skip the untracked block below. + base := "HEAD" + if _, err := runGit(path, "rev-parse", "--verify", "HEAD"); err != nil { + base = emptyTreeHash + } + if out, err := exec.Command("git", "-C", path, "diff", "--numstat", base).Output(); err == nil { + for line := range strings.Lines(string(out)) { + cols := strings.Fields(line) + if len(cols) < 3 { + continue + } + // Binary files report "-" for both counts; Atoi fails and adds zero. + added, _ := strconv.Atoi(cols[0]) + deleted, _ := strconv.Atoi(cols[1]) + stats.Added += added + stats.Deleted += deleted } - // Binary files report "-" for both counts; Atoi fails and adds zero. - added, _ := strconv.Atoi(cols[0]) - deleted, _ := strconv.Atoi(cols[1]) - stats.Added += added - stats.Deleted += deleted } // Untracked files are invisible to `git diff`; count their lines as // additions, the way Warp and forge diff views present a fresh file. diff --git a/internal/project/project_test.go b/internal/project/project_test.go index caea9a802..52dd2d4c3 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -150,3 +150,21 @@ func TestDiff(t *testing.T) { t.Errorf("Diff(non-repo) = %+v, want zero", got) } } + +// TestDiffNoCommits covers a freshly `git init`'d repo with no HEAD: numstat +// against HEAD fails, and the untracked additions must still be counted rather +// than the whole stat collapsing to +0 -0. +func TestDiffNoCommits(t *testing.T) { + repo := t.TempDir() + if out, err := exec.Command("git", "init", repo).CombinedOutput(); err != nil { + t.Skipf("git init unavailable: %v (%s)", err, out) + } + if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("x\ny\nz\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := New(nil).Diff(repo) + if got.Files != 1 || got.Added != 3 || got.Deleted != 0 { + t.Errorf("Diff(no-commit repo) = %+v, want {Files:1 Added:3 Deleted:0}", got) + } +}