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) + } +}