Skip to content

fix(git): count the whole repository in the git count widgets - #823

Open
eric-engberg wants to merge 7 commits into
sirmalloc:mainfrom
eric-engberg:fix/git-widget-scope
Open

eric-engberg wants to merge 7 commits into
sirmalloc:mainfrom
eric-engberg:fix/git-widget-scope

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Five git count widgets reported less than the repository holds:

  • Git Conflicts counted only the conflicts under the session's directory. During a merge, a session in a subdirectory showed too few conflicts, or ⚠0 / the clean glyph / nothing, while Git Status on the same line showed !.
  • Git Changes / Git Insertions / Git Deletions counted only lines under the session's directory when the user's git config has diff.relative = true.
  • Git Untracked Files counted a new directory as one file however many it held.

All the git count widgets now count the whole repository, as Git Status does, wherever the session is. Git Untracked Files counts files, and still shows 0 under status.showUntrackedFiles = no, where git status lists no untracked files. docs/USAGE.md says so.

Why

Claude Code sends the session's working directory as cwd. That is often a subdirectory: a monorepo package the session was started in, or one Claude moved into. The other count widgets already read the repo-wide git status, so one line contradicted itself. Piped renders on main (scratch repos):

  • Merge with top.txt and sub/f.txt both UU, line Git Status | Git Conflicts:
    • from the root: !+* | ⚠2
    • from sub/: !+* | ⚠1
    • from docs/: !+* | ⚠0
  • git config diff.relative true, +2 lines in a/x and +1 in b/y, line Git Changes | Git Unstaged Files:
    • from the root: (+3,-0) | M:2
    • from b/: (+1,-0) | M:2
    • from c/: (+0,-0) | M:2, or Git Changes hidden under its zero state
  • mkdir d && touch d/1 d/2 d/3: Git Untracked Files shows ?:1. Running git add d then moves Git Staged Files from S:0 to S:3.

The causes:

  • git ls-files --unmerged without a pathspec lists only the current directory's subtree.
  • diff.relative limits git diff --shortstat to the current directory.
  • In git's default untracked mode, git status --porcelain prints a new directory as a single ?? d/ entry.

How

  • Conflicts: git ls-files --unmerged -- :/. The :/ pathspec covers the whole repository from any directory.
  • Pathspec magic: execGit puts --no-literal-pathspecs before every ls-files. With GIT_LITERAL_PATHSPECS=1 in the environment, git reads :/ as a file name, and both counts that use it would be 0 even at the root. Git added that option in the same release as the variable (1.8.2).
  • Line counts: execGit puts -c diff.relative=false before every diff. It's set as config rather than --no-relative because git before 2.28 rejects --no-relative but ignores an unknown setting. The other diff options (no external diff, no textconv, submodules) are unchanged.
  • Untracked files: Git Untracked Files now counts from git ls-files --others --exclude-standard -z -- :/. That lists every untracked file in the repository one by one and skips what .gitignore excludes, as git status does. The listing runs only when the git status --porcelain -z output that Git Status and Git Staged and Unstaged Files already share (the same cached call) lists an untracked ?? entry. Without one the count is 0, as git status says: nothing is untracked, or status.showUntrackedFiles = no keeps untracked files out of it. When git status fails, the count is 0 without a listing, as Git Staged and Unstaged Files show then. A listing that fails, such as one cut off by the 5 s timeout every git call has, also shows 0. Git Staged and Unstaged Files keep reading that git status call, so they cost what they did before, and a listing that fails can't zero their counts.
    • A large tree that isn't ignored can list in more than 1 MiB, such as a node_modules or .venv created before its .gitignore entry. A copy of this repo's node_modules (36,530 files) lists in 2.25 MB, past execFileSync's default buffer. The call therefore gets 64 MiB (execGit takes an optional maxBuffer), and the git cache stores the count instead of the listing. The caching that runGitArgs did now lives in a shared helper, so the count gets the same in-memory and on-disk cache.
    • Cost, best of 30 runs on a tree with that node_modules untracked next to one staged and one unstaged change: git status --porcelain -z 25 ms (Git Status, Staged and Unstaged Files, unchanged), git ls-files --others 146 ms. On a 20,000-file .venv: 24 ms and 46 ms. The listing runs only when Git Untracked Files is on the line and git status lists something untracked, so it never runs under status.showUntrackedFiles = no. Git Untracked Files reads the cached git status the other git widgets share, so with Git Status or another file count on the line it adds no git status call of its own.
    • On that tree, main shows +*? S:1 M:1 ?:1 and this branch shows +*? S:1 M:1 ?:36530.

Unchanged: a session at the root, without diff.relative, in a tree with no new untracked directories, sees the same numbers as before. Git Untracked Files still shows 0 under status.showUntrackedFiles = no and when git status fails, as on main. A git ls-files path is still printed relative to the session's directory, so the git cache stays per directory; its comment now says that instead of calling ls-files --unmerged cwd-scoped.

Overlap with open upstream PRs: #632, #797 and #798 change git.ts too and conflict with this one, and so does my #822 (git.ts and the git count widget). When either of #822 and this one merges, I'll merge main into the other. In that merge Git Untracked Files shows nothing when git status fails, as #822's other counts do.

Demo

Piped renders of scratch repositories with fake data, run with bun run src/ccstatusline.ts on main (before) and on this branch (after).

Git Conflicts, mid-merge, from the root, packages/api/ and packages/web/

Powerline

Before:

Git Conflicts before the fix in Powerline mode

After:

Git Conflicts after the fix in Powerline mode

Plain

Before:

Git Conflicts before the fix in plain mode

After:

Git Conflicts after the fix in plain mode

Git Untracked Files with a new folder of 4 files plus notes.md

The cases are the plain tree, the same tree under status.showUntrackedFiles = no, and the tree after git add .. Before: ?:2, ?:0, then S:5. After: ?:5, ?:0, then S:5.

Powerline

Before:

Git Untracked Files before the fix in Powerline mode

After:

Git Untracked Files after the fix in Powerline mode

Plain

Before:

Git Untracked Files before the fix in plain mode

After:

Git Untracked Files after the fix in plain mode

Git Changes / Insertions with diff.relative = true, from the root, packages/web/ and docs/

Powerline

Before:

Git Changes before the fix in Powerline mode

After:

Git Changes after the fix in Powerline mode

Plain

Before:

Git Changes before the fix in plain mode

After:

Git Changes after the fix in plain mode

Testing

  • On main as of 2.2.34 (0c94df7): it merges cleanly. bun run lint is clean, and bun test on Linux CI gives 4421 pass, 0 fail (4403 on main). The built CLI under Bun 1.4.4 and Node 26.13.0 agrees across runtimes and matches main in plain and Powerline mode, and the TUI opens and exits cleanly under both. It adds no JavaScript or Node API newer than Node 14, the build's target.
  • 18 new tests, plus one getGitFileStatusCounts test rewritten because that function no longer counts untracked files. Run against main's code, each alone, 13 of the 18 fail. (git.test.ts needs a shim that exports main's untracked count under the new name to load there.) The other 5 pass on main and guard what this branch adds: the untracked count's 0 when nothing is untracked and when git fails; its two cases that run no listing (git status lists nothing untracked, or fails), which main passes because it only ever read git status; and conflicts with GIT_LITERAL_PATHSPECS=1, which main counted at the root because it passed no pathspec.
    • src/utils/__tests__/git-counts-exec.test.ts (7 tests). It runs real git with no mocks: it builds each repository state, calls the git helpers in a child process and, except under status.showUntrackedFiles = no, checks that plain git run the same way counts less. The cases are line counts with diff.relative from a subdirectory; conflicts outside the session's directory; conflicts with GIT_LITERAL_PATHSPECS=1; untracked files in a new directory; the same with GIT_LITERAL_PATHSPECS=1; no untracked files under status.showUntrackedFiles = no, where git status lists none, and each one again once it is set to normal; and staged, unstaged and untracked counts next to an untracked tree that lists in more than 1 MiB, where plain execFileSync throws ENOBUFS.
    • git.test.ts (10 tests): the arguments execGit adds before diff and before ls-files. For the untracked count: its arguments, a buffer above 1 MiB, no listing when git status lists nothing untracked or fails, 0 when nothing is untracked and when git fails, a listing that fails, and a persistent cache that keeps the count rather than the listing.
      • The failed listing: git status lists ?? a.ts and the untracked listing then throws. The count is 0 after both calls (the status, then the ls-files listing), and the test fails if that fallback returns anything other than 0.
    • GitConflicts.test.ts (1 test): the :/ arguments.
    • Existing tests follow the split. The Git Count widgets' fake git answers the ls-files listing, the Git Untracked Files tests mock git status and then the listing, and the shared fake-git helper strips the arguments execGit now adds.
  • Node: the changed test files plus the other users of the shared fake-git helper (8 files), run with node node_modules/vitest/vitest.mjs run on Node 26.10.0. 300 of 316 pass on this branch, and 283 of 298 on main (the same files without the new one). Every failure is in git.test.ts, from spying on os.homedir, which Node's ESM forbids. That covers the same 15 tests on both, plus the new persistent-cache test, which uses the same temp-home helper. Every other new test passes under Node.
  • runtime-check.sh on Bun 1.4.2 and Node 26.10.0:
    • The baseline line renders the same as main, with both runtimes agreeing.
    • Four repro lines differ from main as intended, with both runtimes agreeing in plain and Powerline mode: conflicts from docs/, line counts from an unchanged subdirectory with diff.relative = true, the file counts in the node_modules tree, and conflicts plus untracked files at the root with GIT_LITERAL_PATHSPECS=1 (there main also shows ⚠2, and only the untracked count of a new directory differs).
    • Git Status | Git Untracked Files | Git Staged Files on a new 4-file folder plus one new file: under status.showUntrackedFiles = no it renders the same as main, and without the setting it differs as intended, with both runtimes agreeing in plain and Powerline mode.
    • The TUI opens and exits cleanly on both.
  • Built dist/ under Node 26.10.0 and Bun 1.4.2, the same on both: ⚠2 from docs/, (+3,-0) from c/ with diff.relative, S:1 M:1 ?:36530 in the node_modules tree, ⚠2 | ?:3 at the root with GIT_LITERAL_PATHSPECS=1, and on a new 4-file folder plus one new file, ?:0 | S:0 under status.showUntrackedFiles = no and ? | ?:5 | S:0 without it.

…elative set

Git Changes, Git Insertions and Git Deletions add up `git diff --shortstat`
and `git diff --cached --shortstat`, run from the session's directory. With
`diff.relative = true` in the user's git config (git 2.28 and later), both
commands only look at the current directory. A session in a subdirectory then
counts only the lines changed under it: with +2 in a/x and +1 in b/y, a session
in b/ showed (+1,-0), and one in an unchanged directory showed (+0,-0) or hid
the widget under its zero state, while Git Unstaged Files on the same line,
which reads the repo-wide `git status`, showed both files.

Every diff the status line runs now gets `-c diff.relative=false`, so the
counts cover the whole repository, as they do without that setting. The
override goes in as config rather than as --no-relative because git before
2.28 rejects --no-relative but ignores the unknown setting.
Git Conflicts counts the paths `git ls-files --unmerged` prints, run from the
session's directory. Without a pathspec, ls-files only lists that directory's
subtree, so during a merge a session in a subdirectory missed every conflict
outside it. With top.txt and sub/f.txt both conflicted, a session in sub/
showed 1, and one in docs/ showed 0, the clean glyph, or nothing under the
zero hide state, while Git Status on the same line showed `!` from the
repo-wide `git status`.

The `:/` pathspec makes ls-files list the whole repository from any
directory, so the count matches what Git Status reports. ls-files also gets
--no-literal-pathspecs: with GIT_LITERAL_PATHSPECS=1 in the environment, git
reads `:/` as a file name, and the count would drop to 0 even at the root,
where it was right before. Git added that option together with the variable,
in 1.8.2. With this and the previous change, every git count widget covers the
whole repository, which the usage docs now say.
Git Untracked Files adds one for every `??` entry of `git status --porcelain`.
In git's default untracked mode, a new directory is a single `?? dir/` entry
however many files it holds, so a new folder with three files showed ?:1, and
Git Staged Files jumped by three once they were added. With
status.showUntrackedFiles=no in the user's config, the count was always 0.

The count now comes from `git ls-files --others --exclude-standard -z -- :/`,
which lists every untracked file in the repository, one by one, whatever that
setting says, and skips what .gitignore excludes as git status does. Only Git
Untracked Files runs it: Git Staged and Unstaged Files keep reading the
`git status` call they share with Git Status, so they cost what they did, and a
listing that fails can't take their counts down with it.

The listing runs to megabytes when a large tree isn't ignored, such as a
node_modules or .venv made before its .gitignore entry: a copy of this repo's
node_modules, 36,530 files, lists in 2.25 MB, past execFileSync's 1 MiB
default. The call gets room for about a million paths (64 MiB), and the git
cache keeps the count rather than the listing, which every later read of the
cache file would otherwise have to parse.
Git Untracked Files counted from `git ls-files --others`, which ignores
status.showUntrackedFiles. Under `no` the line could show ?:5 next to a
Git Status with no `?`, and someone who set `no` to skip the untracked
walk in a large tree paid for that walk whenever the widget was on the
line.

The listing now runs only when `git status --porcelain -z`, the cached
call Git Status and the staged and unstaged counts already share, lists
an untracked entry. Without one the count is 0, as git status says:
nothing is untracked, or status.showUntrackedFiles=no hides it all. In
the default mode a new directory still counts each file inside it. When
git status fails the count is 0 without a listing, as the staged and
unstaged counts are.
Git Untracked Files now asks git status first and lists untracked files
only when git status shows one. "returns zero when git fails" makes
every git call throw, so it stops at git status and no longer reaches
the listing. Nothing covered a listing that fails after git status
showed an untracked file, such as one cut off by the 5 s timeout every
git call has, where the count falls back to 0. A new test covers it: a
status with `?? a.ts`, then a listing that throws, gives 0 after both
calls.

The test before it queued a listing output that was never used, since
no listing runs when git status fails. vi.clearAllMocks() keeps queued
values, so that output became the next test's git status. Its call
count check already shows no listing runs, so the queued value is gone,
and the new test queues only values it uses.
# Conflicts:
#	src/utils/__tests__/git.test.ts
#	src/utils/git.ts
#	src/widgets/__tests__/GitConflicts.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant