From 86c44ea629c01eb3f4e7bafdb76e0617cbeed6ed Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 02:11:14 -0500 Subject: [PATCH 1/5] fix(git): count changed lines across the whole repository with diff.relative 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. --- src/utils/__tests__/git-counts-exec.test.ts | 89 +++++++++++++++++++++ src/utils/__tests__/git-test-helpers.ts | 9 ++- src/utils/__tests__/git.test.ts | 18 +++++ src/utils/git.ts | 13 ++- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/git-counts-exec.test.ts diff --git a/src/utils/__tests__/git-counts-exec.test.ts b/src/utils/__tests__/git-counts-exec.test.ts new file mode 100644 index 000000000..8e0e9452c --- /dev/null +++ b/src/utils/__tests__/git-counts-exec.test.ts @@ -0,0 +1,89 @@ +import type * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + afterAll, + beforeAll, + describe, + expect, + it +} from 'vitest'; + +// Real git, no mocks: the count widgets count the whole repository, as Git +// Status does, wherever in it the session is and whatever the user's git config +// says. Each test checks that plain git, run the same way, would count less, so +// the setup is the real trigger. The git helpers run in their own process, as +// other suites mock child_process globally under Bun. +const require = createRequire(import.meta.url); +const { execFileSync } = require('node:child_process') as typeof childProcess; + +describe.skipIf(process.platform === 'win32')('git counts across the whole repository', () => { + let root = ''; + let probePath = ''; + let env: NodeJS.ProcessEnv = {}; + + // What a helper exported by git.ts returns with the session in cwd, read + // fresh rather than from an earlier call's cache + function gitHelper(name: string, cwd: string): unknown { + fs.rmSync(path.join(root, 'home'), { recursive: true, force: true }); + return JSON.parse(execFileSync(process.execPath, [probePath, JSON.stringify([name, cwd])], { encoding: 'utf8', env })) as unknown; + } + + function plainGit(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); + } + + function write(file: string, text: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + } + + // A repository with these files committed + function makeRepo(name: string, files: Record): string { + const dir = path.join(root, name); + fs.mkdirSync(dir); + plainGit(dir, 'init', '-q'); + for (const [file, text] of Object.entries(files)) { + write(path.join(dir, file), text); + } + plainGit(dir, 'add', '.'); + plainGit(dir, 'commit', '-qm', 'init'); + return dir; + } + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-git-counts-')); + const bundlePath = path.join(root, 'git.mjs'); + probePath = path.join(root, 'probe.mjs'); + execFileSync('bun', ['build', fileURLToPath(new URL('../git.ts', import.meta.url)), '--target=node', `--outfile=${bundlePath}`], { stdio: 'pipe' }); + fs.writeFileSync(probePath, ` + import * as git from './git.mjs'; + const [name, cwd] = JSON.parse(process.argv[2]); + console.log(JSON.stringify(git[name]({ data: { cwd } }))); + `); + const globalConfig = path.join(root, 'global-gitconfig'); + fs.writeFileSync(globalConfig, ''); + // HOME holds the git cache + env = { ...process.env, HOME: path.join(root, 'home'), GIT_CONFIG_GLOBAL: globalConfig, GIT_CONFIG_NOSYSTEM: '1' }; + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('counts changed lines outside the session\'s directory with diff.relative set', () => { + const dir = makeRepo('relative', { 'a/x': '1\n', 'b/y': '1\n', 'c/z': '1\n' }); + write(path.join(dir, 'a', 'x'), '1\n2\n3\n'); + plainGit(dir, 'add', 'a/x'); + write(path.join(dir, 'b', 'y'), '1\n2\n'); + plainGit(dir, 'config', 'diff.relative', 'true'); + + for (const cwd of [dir, path.join(dir, 'b'), path.join(dir, 'c')]) { + expect(gitHelper('getGitChangeCounts', cwd)).toEqual({ insertions: 3, deletions: 0 }); + } + expect(plainGit(path.join(dir, 'c'), 'diff', '--cached', '--shortstat')).toBe(''); + }); +}); diff --git a/src/utils/__tests__/git-test-helpers.ts b/src/utils/__tests__/git-test-helpers.ts index 1512bdf1a..b2297eade 100644 --- a/src/utils/__tests__/git-test-helpers.ts +++ b/src/utils/__tests__/git-test-helpers.ts @@ -49,12 +49,17 @@ export function expectGitExecOptions(options: unknown, cwd?: string): void { expect(options).not.toHaveProperty('cwd'); } -// What execGit adds to status and diff, after the subcommand +// What execGit adds before diff, and to status and diff after the subcommand +const DIFF_CONFIG_ARGS = ['-c', 'diff.relative=false']; const WORK_TREE_SAFETY_OPTIONS = new Set(['--ignore-submodules=dirty', '--no-ext-diff', '--no-textconv']); /** The command a fake git answers, without the hardening every call carries */ export function gitCommandOf(args: string[]): string { - const command = args.slice(GIT_HARDENING_ARGS.length); + let command = args.slice(GIT_HARDENING_ARGS.length); expect(args.slice(0, GIT_HARDENING_ARGS.length)).toEqual(GIT_HARDENING_ARGS); + if (command[0] === 'diff' || command[DIFF_CONFIG_ARGS.length] === 'diff') { + expect(command.slice(0, DIFF_CONFIG_ARGS.length)).toEqual(DIFF_CONFIG_ARGS); + command = command.slice(DIFF_CONFIG_ARGS.length); + } return command.filter(arg => !WORK_TREE_SAFETY_OPTIONS.has(arg)).join(' '); } diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index a09a8927c..e5c824a3d 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -196,6 +196,24 @@ describe('git utils', () => { '--ignore-submodules=dirty' ])); }); + + it('sets diff.relative back to false for diff, so it covers the whole repository', () => { + const { root } = createGitRepo(); + mockExecFileSync.mockReturnValueOnce(' 1 file changed, 1 insertion(+)\n'); + + expect(execGit(['diff', '--shortstat'], root)).toBe(' 1 file changed, 1 insertion(+)\n'); + + expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([ + ...GIT_HARDENING_ARGS, + '-c', + 'diff.relative=false', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--ignore-submodules=dirty', + '--shortstat' + ]); + }); }); describe('runGit', () => { diff --git a/src/utils/git.ts b/src/utils/git.ts index b9dd94420..c1348fa93 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -69,6 +69,13 @@ const WORK_TREE_COMMAND_ARGS = new Map([ ['diff', ['--no-ext-diff', '--no-textconv', '--ignore-submodules=dirty']] ]); +// Options set ahead of a command so it reports on the whole repository. +// diff.relative (git 2.28 and later) limits diff to the current directory; git +// before 2.28 ignores the setting, where it would reject --no-relative. +const WHOLE_REPOSITORY_ARGS = new Map([ + ['diff', ['-c', 'diff.relative=false']] +]); + // `git config --get-regexp` exits with 1 when nothing matches const GIT_CONFIG_NO_MATCH_STATUS = 1; @@ -257,7 +264,8 @@ function getRepositoryFilterOverrideArgs(cwd: string | undefined): string[] | nu /** * Runs git as every status line call should: with fsmonitor off, no bare * repository found by searching upward, and, for commands that re-read work tree - * files, none of the repository's own filter drivers or external diff tools. + * files, none of the repository's own filter drivers or external diff tools, + * and with diff covering the whole repository whatever diff.relative says. * Throws when git fails, or when the repository's filters can't be turned off. */ export function execGit(args: string[], cwd: string | undefined): string { @@ -267,10 +275,11 @@ export function execGit(args: string[], cwd: string | undefined): string { if (filterArgs === null) { throw new Error('The repository\'s filter drivers could not be turned off'); } + const wholeRepositoryArgs = WHOLE_REPOSITORY_ARGS.get(subcommand) ?? []; return execFileSync( resolveExecutable('git'), - [...GIT_HARDENING_ARGS, ...filterArgs, subcommand, ...(workTreeArgs ?? []), ...rest], + [...GIT_HARDENING_ARGS, ...filterArgs, ...wholeRepositoryArgs, subcommand, ...(workTreeArgs ?? []), ...rest], gitExecOptions(cwd) ); } From ec00fe8fd74964383d25d70e8346839356096be4 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 02:13:36 -0500 Subject: [PATCH 2/5] fix(git): count conflicts across the whole repository 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. --- docs/USAGE.md | 2 ++ src/utils/__tests__/git-counts-exec.test.ts | 38 +++++++++++++++++++-- src/utils/__tests__/git-test-helpers.ts | 16 ++++++--- src/utils/__tests__/git.test.ts | 16 +++++++++ src/utils/git.ts | 20 +++++++---- src/widgets/__tests__/GitConflicts.test.ts | 8 +++++ 6 files changed, 87 insertions(+), 13 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index a60352bee..5831a2cad 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -46,6 +46,8 @@ ccstatusline --version - **Git Upstream Owner** / **Git Upstream Repo** / **Git Upstream Owner/Repo** / **Git Is Fork** - Show upstream remote metadata and whether the current repo is a fork. Git Is Fork displays `⑂` for forks by default; press `g` to customize it, or use raw mode for `true`/`false`. - **Git Worktree** / **Git Worktree Mode** / **Git Worktree Name** / **Git Worktree Branch** / **Git Worktree Original Branch** - Show worktree status plus the active worktree's name and branch metadata. +The count widgets (Git Changes, Git Insertions, Git Deletions, Git Conflicts and Git Staged/Unstaged/Untracked Files) count the whole repository, as Git Status does, even when the session is in a subdirectory or your git config sets `diff.relative`. + ### Jujutsu (JJ) - **JJ Revision** / **JJ Bookmarks** / **JJ Workspace** - Show the current change ID, bookmarks, or workspace name, with customizable prefix glyphs through `g`. diff --git a/src/utils/__tests__/git-counts-exec.test.ts b/src/utils/__tests__/git-counts-exec.test.ts index 8e0e9452c..62abb0b9e 100644 --- a/src/utils/__tests__/git-counts-exec.test.ts +++ b/src/utils/__tests__/git-counts-exec.test.ts @@ -20,6 +20,9 @@ import { const require = createRequire(import.meta.url); const { execFileSync } = require('node:child_process') as typeof childProcess; +// Turns off pathspec magic, so git reads `:/` as a file name +const LITERAL_PATHSPECS_ENV: NodeJS.ProcessEnv = { GIT_LITERAL_PATHSPECS: '1' }; + describe.skipIf(process.platform === 'win32')('git counts across the whole repository', () => { let root = ''; let probePath = ''; @@ -27,9 +30,9 @@ describe.skipIf(process.platform === 'win32')('git counts across the whole repos // What a helper exported by git.ts returns with the session in cwd, read // fresh rather than from an earlier call's cache - function gitHelper(name: string, cwd: string): unknown { + function gitHelper(name: string, cwd: string, extraEnv: NodeJS.ProcessEnv = {}): unknown { fs.rmSync(path.join(root, 'home'), { recursive: true, force: true }); - return JSON.parse(execFileSync(process.execPath, [probePath, JSON.stringify([name, cwd])], { encoding: 'utf8', env })) as unknown; + return JSON.parse(execFileSync(process.execPath, [probePath, JSON.stringify([name, cwd])], { encoding: 'utf8', env: { ...env, ...extraEnv } })) as unknown; } function plainGit(cwd: string, ...args: string[]): string { @@ -86,4 +89,35 @@ describe.skipIf(process.platform === 'win32')('git counts across the whole repos } expect(plainGit(path.join(dir, 'c'), 'diff', '--cached', '--shortstat')).toBe(''); }); + + // Mid-merge, with top.txt and sub/f.txt both conflicted and nothing in docs/ + function makeConflictRepo(name: string): string { + const dir = makeRepo(name, { 'top.txt': 'base\n', 'sub/f.txt': 'base\n', 'docs/readme.md': 'readme\n' }); + plainGit(dir, 'checkout', '-qb', 'other'); + write(path.join(dir, 'top.txt'), 'other\n'); + write(path.join(dir, 'sub', 'f.txt'), 'other\n'); + plainGit(dir, 'commit', '-qam', 'other'); + plainGit(dir, 'checkout', '-q', '-'); + write(path.join(dir, 'top.txt'), 'mine\n'); + write(path.join(dir, 'sub', 'f.txt'), 'mine\n'); + plainGit(dir, 'commit', '-qam', 'mine'); + expect(() => plainGit(dir, 'merge', 'other')).toThrow(); + return dir; + } + + it('counts conflicts outside the session\'s directory', () => { + const dir = makeConflictRepo('conflicts'); + + for (const cwd of [dir, path.join(dir, 'sub'), path.join(dir, 'docs')]) { + expect(gitHelper('getGitConflictCount', cwd)).toBe(2); + } + expect(plainGit(path.join(dir, 'docs'), 'ls-files', '--unmerged')).toBe(''); + }); + + it('counts conflicts with GIT_LITERAL_PATHSPECS set', () => { + const dir = makeConflictRepo('conflicts-literal'); + + expect(gitHelper('getGitConflictCount', dir, LITERAL_PATHSPECS_ENV)).toBe(2); + expect(execFileSync('git', ['ls-files', '--unmerged', '--', ':/'], { cwd: dir, env: { ...env, ...LITERAL_PATHSPECS_ENV }, encoding: 'utf8' })).toBe(''); + }); }); diff --git a/src/utils/__tests__/git-test-helpers.ts b/src/utils/__tests__/git-test-helpers.ts index b2297eade..e5686fc0d 100644 --- a/src/utils/__tests__/git-test-helpers.ts +++ b/src/utils/__tests__/git-test-helpers.ts @@ -49,17 +49,23 @@ export function expectGitExecOptions(options: unknown, cwd?: string): void { expect(options).not.toHaveProperty('cwd'); } -// What execGit adds before diff, and to status and diff after the subcommand -const DIFF_CONFIG_ARGS = ['-c', 'diff.relative=false']; +// What execGit adds before diff and ls-files, and to status and diff after the +// subcommand +const WHOLE_REPOSITORY_ARGS = new Map([ + ['diff', ['-c', 'diff.relative=false']], + ['ls-files', ['--no-literal-pathspecs']] +]); const WORK_TREE_SAFETY_OPTIONS = new Set(['--ignore-submodules=dirty', '--no-ext-diff', '--no-textconv']); /** The command a fake git answers, without the hardening every call carries */ export function gitCommandOf(args: string[]): string { let command = args.slice(GIT_HARDENING_ARGS.length); expect(args.slice(0, GIT_HARDENING_ARGS.length)).toEqual(GIT_HARDENING_ARGS); - if (command[0] === 'diff' || command[DIFF_CONFIG_ARGS.length] === 'diff') { - expect(command.slice(0, DIFF_CONFIG_ARGS.length)).toEqual(DIFF_CONFIG_ARGS); - command = command.slice(DIFF_CONFIG_ARGS.length); + for (const [subcommand, added] of WHOLE_REPOSITORY_ARGS) { + if (command[0] === subcommand || command[added.length] === subcommand) { + expect(command.slice(0, added.length)).toEqual(added); + command = command.slice(added.length); + } } return command.filter(arg => !WORK_TREE_SAFETY_OPTIONS.has(arg)).join(' '); } diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index e5c824a3d..8f287b5be 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -214,6 +214,22 @@ describe('git utils', () => { '--shortstat' ]); }); + + it('turns pathspec magic back on for ls-files, so `:/` covers the whole repository', () => { + const { root } = createGitRepo(); + mockExecFileSync.mockReturnValueOnce(''); + + execGit(['ls-files', '--unmerged', '--', ':/'], root); + + expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([ + ...GIT_HARDENING_ARGS, + '--no-literal-pathspecs', + 'ls-files', + '--unmerged', + '--', + ':/' + ]); + }); }); describe('runGit', () => { diff --git a/src/utils/git.ts b/src/utils/git.ts index c1348fa93..bdba4d725 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -55,9 +55,10 @@ const GIT_COMMAND_TIMEOUT_MS = 5_000; // In-process cache keeps cwd in the key; the persistent cache uses one file // per (repo, cwd) pair, stores cwd once at the file level and keys entries by -// command. Per-cwd files matter because some outputs are cwd-scoped (e.g. -// `ls-files --unmerged`) and because sessions in the same repo at different -// cwds (root vs a subdirectory) would otherwise evict each other every render. +// command. Per-cwd files matter because some outputs depend on cwd (ls-files +// prints paths relative to it) and because sessions in the same repo at +// different cwds (root vs a subdirectory) would otherwise evict each other +// every render. const gitCommandCache = new Map(); const filterOverrideCache = new Map(); @@ -72,8 +73,12 @@ const WORK_TREE_COMMAND_ARGS = new Map([ // Options set ahead of a command so it reports on the whole repository. // diff.relative (git 2.28 and later) limits diff to the current directory; git // before 2.28 ignores the setting, where it would reject --no-relative. +// GIT_LITERAL_PATHSPECS=1 in the environment makes ls-files read the `:/` +// pathspec as a file name; git has accepted --no-literal-pathspecs since it +// added that variable. const WHOLE_REPOSITORY_ARGS = new Map([ - ['diff', ['-c', 'diff.relative=false']] + ['diff', ['-c', 'diff.relative=false']], + ['ls-files', ['--no-literal-pathspecs']] ]); // `git config --get-regexp` exits with 1 when nothing matches @@ -265,7 +270,8 @@ function getRepositoryFilterOverrideArgs(cwd: string | undefined): string[] | nu * Runs git as every status line call should: with fsmonitor off, no bare * repository found by searching upward, and, for commands that re-read work tree * files, none of the repository's own filter drivers or external diff tools, - * and with diff covering the whole repository whatever diff.relative says. + * and with diff and ls-files covering the whole repository whatever + * diff.relative or GIT_LITERAL_PATHSPECS says. * Throws when git fails, or when the repository's filters can't be turned off. */ export function execGit(args: string[], cwd: string | undefined): string { @@ -708,7 +714,9 @@ export function getGitAheadBehind(context: RenderContext): GitAheadBehind | null } export function getGitConflictCount(context: RenderContext): number { - const output = runGit('ls-files --unmerged', context); + // `:/` covers the whole repository, as git status does; without a path + // ls-files only lists the session's directory + const output = runGit('ls-files --unmerged -- :/', context); if (!output) return 0; diff --git a/src/widgets/__tests__/GitConflicts.test.ts b/src/widgets/__tests__/GitConflicts.test.ts index 7bd2f758b..936d250cf 100644 --- a/src/widgets/__tests__/GitConflicts.test.ts +++ b/src/widgets/__tests__/GitConflicts.test.ts @@ -12,6 +12,7 @@ import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { mockExecutableResolution } from '../../utils/__tests__/executable-path-test-helpers'; import { clearGitCache } from '../../utils/git'; +import { GIT_HARDENING_ARGS } from '../../utils/git-hardening'; import { GitConflictsWidget } from '../GitConflicts'; vi.mock('node:child_process', () => ({ @@ -138,6 +139,13 @@ describe('GitConflictsWidget', () => { expect(render()).toBe('⚠2'); }); + it('lists the unmerged files of the whole repository, not only the session\'s directory', () => { + mockConflictCount(2); + + expect(render()).toBe('⚠2'); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--unmerged', '--', ':/']); + }); + it('renders raw conflicts as a numeric count', () => { mockConflictCount(1); From 6cc341bd1a48935805b047b907b0312ad1646cf2 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 02:16:49 -0500 Subject: [PATCH 3/5] fix(git): count each untracked file in a new directory 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. --- docs/USAGE.md | 2 +- src/utils/__tests__/git-counts-exec.test.ts | 51 ++++++++++++ src/utils/__tests__/git.test.ts | 74 +++++++++++++---- src/utils/git.ts | 79 ++++++++++++++----- src/widgets/__tests__/GitCountWidgets.test.ts | 22 +++--- .../__tests__/GitUntrackedFiles.test.ts | 8 +- src/widgets/shared/git-count-widget.ts | 20 +++-- 7 files changed, 199 insertions(+), 57 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 5831a2cad..3cc3ac0ca 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -46,7 +46,7 @@ ccstatusline --version - **Git Upstream Owner** / **Git Upstream Repo** / **Git Upstream Owner/Repo** / **Git Is Fork** - Show upstream remote metadata and whether the current repo is a fork. Git Is Fork displays `⑂` for forks by default; press `g` to customize it, or use raw mode for `true`/`false`. - **Git Worktree** / **Git Worktree Mode** / **Git Worktree Name** / **Git Worktree Branch** / **Git Worktree Original Branch** - Show worktree status plus the active worktree's name and branch metadata. -The count widgets (Git Changes, Git Insertions, Git Deletions, Git Conflicts and Git Staged/Unstaged/Untracked Files) count the whole repository, as Git Status does, even when the session is in a subdirectory or your git config sets `diff.relative`. +The count widgets (Git Changes, Git Insertions, Git Deletions, Git Conflicts and Git Staged/Unstaged/Untracked Files) count the whole repository, as Git Status does, even when the session is in a subdirectory or your git config sets `diff.relative`. Git Untracked Files counts each file inside a new directory, including when `status.showUntrackedFiles` keeps them out of `git status`. ### Jujutsu (JJ) diff --git a/src/utils/__tests__/git-counts-exec.test.ts b/src/utils/__tests__/git-counts-exec.test.ts index 62abb0b9e..5fb90ce6a 100644 --- a/src/utils/__tests__/git-counts-exec.test.ts +++ b/src/utils/__tests__/git-counts-exec.test.ts @@ -120,4 +120,55 @@ describe.skipIf(process.platform === 'win32')('git counts across the whole repos expect(gitHelper('getGitConflictCount', dir, LITERAL_PATHSPECS_ENV)).toBe(2); expect(execFileSync('git', ['ls-files', '--unmerged', '--', ':/'], { cwd: dir, env: { ...env, ...LITERAL_PATHSPECS_ENV }, encoding: 'utf8' })).toBe(''); }); + + // d/ is new, holding three files, next to one new file at the top + function makeUntrackedRepo(name: string): string { + const dir = makeRepo(name, { f: 'hi\n' }); + for (const file of ['d/1', 'd/2', 'd/3', 'top']) { + write(path.join(dir, file), ''); + } + return dir; + } + + it('counts each untracked file in a new directory', () => { + const dir = makeUntrackedRepo('untracked'); + + for (const cwd of [dir, path.join(dir, 'd')]) { + expect(gitHelper('getGitUntrackedFileCount', cwd)).toBe(4); + } + expect(plainGit(dir, 'status', '--porcelain', '-z').split('\0').filter(Boolean)).toEqual(['?? d/', '?? top']); + }); + + it('counts untracked files with GIT_LITERAL_PATHSPECS set', () => { + const dir = makeUntrackedRepo('untracked-literal'); + + expect(gitHelper('getGitUntrackedFileCount', dir, LITERAL_PATHSPECS_ENV)).toBe(4); + expect(execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', ':/'], { cwd: dir, env: { ...env, ...LITERAL_PATHSPECS_ENV }, encoding: 'utf8' })).toBe(''); + }); + + it('counts untracked files with status.showUntrackedFiles set to no', () => { + const dir = makeUntrackedRepo('untracked-hidden'); + plainGit(dir, 'config', 'status.showUntrackedFiles', 'no'); + + expect(gitHelper('getGitUntrackedFileCount', dir)).toBe(4); + expect(plainGit(dir, 'status', '--porcelain', '-z')).toBe(''); + }); + + it('counts files next to an untracked tree listed in more than 1 MiB', () => { + const dir = makeRepo('untracked-large', { staged: '1\n', unstaged: '1\n' }); + write(path.join(dir, 'staged'), '2\n'); + plainGit(dir, 'add', 'staged'); + write(path.join(dir, 'unstaged'), '2\n'); + // Long names keep the file count, and so the test's run time, low + const packages = path.join(dir, '.venv', 'lib', 'site-packages'); + fs.mkdirSync(packages, { recursive: true }); + for (let index = 0; index < 5000; index += 1) { + fs.writeFileSync(path.join(packages, `${'m'.repeat(200)}-${index}.py`), ''); + } + + expect(gitHelper('getGitFileStatusCounts', dir)).toEqual({ staged: 1, unstaged: 1 }); + expect(gitHelper('getGitUntrackedFileCount', dir)).toBe(5000); + // execFileSync stops at 1 MiB unless told otherwise + expect(() => plainGit(dir, 'status', '--porcelain', '-z', '--untracked-files=all')).toThrow(/ENOBUFS/); + }); }); diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 8f287b5be..9a5f2cd9b 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -19,6 +19,7 @@ import { getGitChangeCounts, getGitFileStatusCounts, getGitStatus, + getGitUntrackedFileCount, isInsideGitWorkTree, resolveGitCwd, runGit @@ -828,13 +829,12 @@ describe('git utils', () => { }); describe('getGitFileStatusCounts', () => { - it('counts staged, unstaged, and untracked files from porcelain status', () => { + it('counts staged and unstaged files from porcelain status, leaving untracked ones out', () => { mockExecFileSync.mockReturnValueOnce('M staged-a.ts\0A staged-b.ts\0 M unstaged-a.ts\0?? new-a.ts\0?? new-b.ts\0'); expect(getGitFileStatusCounts({})).toEqual({ staged: 2, - unstaged: 1, - untracked: 2 + unstaged: 1 }); }); @@ -843,8 +843,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 1, - unstaged: 1, - untracked: 0 + unstaged: 1 }); }); @@ -853,8 +852,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 0, - unstaged: 0, - untracked: 0 + unstaged: 0 }); }); @@ -863,8 +861,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 1, - unstaged: 0, - untracked: 1 + unstaged: 0 }); }); @@ -873,8 +870,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 1, - unstaged: 1, - untracked: 0 + unstaged: 1 }); }); @@ -883,8 +879,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 0, - unstaged: 1, - untracked: 1 + unstaged: 1 }); }); @@ -893,8 +888,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 0, - unstaged: 2, - untracked: 0 + unstaged: 2 }); }); @@ -903,9 +897,55 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 0, - unstaged: 0, - untracked: 0 + unstaged: 0 }); }); }); + + describe('getGitUntrackedFileCount', () => { + it('counts each file the whole repository\'s untracked listing holds', () => { + mockExecFileSync.mockReturnValueOnce('new.ts\0new-dir/a.ts\0new-dir/b.ts\0'); + + expect(getGitUntrackedFileCount({ data: { cwd: '/tmp/repo' } })).toBe(3); + expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ':/']); + expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2], '/tmp/repo'); + }); + + // execFileSync's default of 1 MiB holds about 16,000 paths, which an + // untracked node_modules or .venv can pass + it('lets the listing run past execFileSync\'s 1 MiB default', () => { + mockExecFileSync.mockReturnValueOnce(''); + + getGitUntrackedFileCount({}); + + expect((mockExecFileSync.mock.calls[0]?.[2] as { maxBuffer?: number }).maxBuffer).toBeGreaterThan(1024 * 1024); + }); + + it('returns zero when nothing is untracked', () => { + mockExecFileSync.mockReturnValueOnce(''); + + expect(getGitUntrackedFileCount({})).toBe(0); + }); + + it('returns zero when git fails', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); }); + + expect(getGitUntrackedFileCount({})).toBe(0); + }); + + it('keeps the count in the persistent cache rather than the listing', () => { + vi.spyOn(Date, 'now').mockReturnValue(1000); + const home = useTempHome(); + const { root } = createGitRepo(); + const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 }; + mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); + + expect(getGitUntrackedFileCount(context)).toBe(2); + expect(Object.values(readGitCacheJson(home).entries ?? {})).toEqual([expect.objectContaining({ output: '2' })]); + + clearGitCache(); + expect(getGitUntrackedFileCount(context)).toBe(2); + expect(mockExecFileSync.mock.calls).toHaveLength(1); + }); + }); }); diff --git a/src/utils/git.ts b/src/utils/git.ts index bdba4d725..53450d448 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -22,7 +22,6 @@ export interface GitChangeCounts { export interface GitFileStatusCounts { staged: number; unstaged: number; - untracked: number; } interface GitRepoMetadata { @@ -84,6 +83,16 @@ const WHOLE_REPOSITORY_ARGS = new Map([ // `git config --get-regexp` exits with 1 when nothing matches const GIT_CONFIG_NO_MATCH_STATUS = 1; +// Every untracked file in the repository, one by one, skipping what +// .gitignore excludes as git status does +const UNTRACKED_FILES_ARGS = ['ls-files', '--others', '--exclude-standard', '-z', '--', ':/']; +// The listing runs to megabytes in a large tree that isn't ignored, such as a +// node_modules made before its .gitignore entry, so the cache keeps the count +const UNTRACKED_FILE_COUNT_CACHE_KEY = `count of ${UNTRACKED_FILES_ARGS.join(' ')}`; +// Room for about a million paths of 60-odd bytes, as in a node_modules; +// execFileSync's default of 1 MiB holds about 16,000 +const UNTRACKED_FILES_MAX_BUFFER_BYTES = 64 * 1024 * 1024; + function getCacheDir(): string { return path.join(os.homedir(), '.cache', 'ccstatusline'); } @@ -199,7 +208,7 @@ function mayDefineFilters(gitDir: string): boolean { return /filter|include/i.test(configText); } -function gitExecOptions(cwd: string | undefined) { +function gitExecOptions(cwd: string | undefined, maxBuffer?: number) { // --no-optional-locks (or GIT_OPTIONAL_LOCKS=0) prevents read-only commands // (diff, status, rev-list, ...) from racing on .git/index.lock when another // git process is writing it. @@ -212,7 +221,8 @@ function gitExecOptions(cwd: string | undefined) { env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, timeout: GIT_COMMAND_TIMEOUT_MS, windowsHide: true, - ...(cwd ? { cwd } : {}) + ...(cwd ? { cwd } : {}), + ...(maxBuffer === undefined ? {} : { maxBuffer }) }; } @@ -272,9 +282,10 @@ function getRepositoryFilterOverrideArgs(cwd: string | undefined): string[] | nu * files, none of the repository's own filter drivers or external diff tools, * and with diff and ls-files covering the whole repository whatever * diff.relative or GIT_LITERAL_PATHSPECS says. - * Throws when git fails, or when the repository's filters can't be turned off. + * Throws when git fails, prints more than maxBuffer bytes (1 MiB by default), + * or when the repository's filters can't be turned off. */ -export function execGit(args: string[], cwd: string | undefined): string { +export function execGit(args: string[], cwd: string | undefined, maxBuffer?: number): string { const [subcommand = '', ...rest] = args; const workTreeArgs = WORK_TREE_COMMAND_ARGS.get(subcommand); const filterArgs = workTreeArgs ? getRepositoryFilterOverrideArgs(cwd) : []; @@ -286,7 +297,7 @@ export function execGit(args: string[], cwd: string | undefined): string { return execFileSync( resolveExecutable('git'), [...GIT_HARDENING_ARGS, ...filterArgs, ...wholeRepositoryArgs, subcommand, ...(workTreeArgs ?? []), ...rest], - gitExecOptions(cwd) + gitExecOptions(cwd, maxBuffer) ); } @@ -530,8 +541,20 @@ export function runGit(command: string, context: RenderContext): string | null { } export function runGitArgs(args: string[], context: RenderContext, cacheCommand?: string): string | null { + return runCachedGit(context, cacheCommand ?? args.join('\0'), (cwd) => { + const output = execGit(args, cwd).trimEnd(); + return output.length > 0 ? output : null; + }); +} + +// What run makes of git's output in the session's directory, kept under +// cacheToken; null when git fails +function runCachedGit( + context: RenderContext, + cacheToken: string, + run: (cwd: string | undefined) => string | null +): string | null { const cwd = resolveGitCwd(context); - const cacheToken = cacheCommand ?? args.join('\0'); const memoryCacheKey = `${cacheToken}|${cwd ?? ''}`; const persistentCacheKey = cacheToken; const metadata = getGitRepoMetadata(cwd); @@ -557,9 +580,7 @@ export function runGitArgs(args: string[], context: RenderContext, cacheCommand? } try { - const output = execGit(args, cwd).trimEnd(); - - const result = output.length > 0 ? output : null; + const result = run(cwd); const entry = createCacheEntry(result, metadata, now); gitCommandCache.set(memoryCacheKey, entry); writePersistentCacheEntry(metadata, persistentCacheKey, cwd, entry); @@ -659,12 +680,11 @@ export function getGitFileStatusCounts(context: RenderContext): GitFileStatusCou const output = runGit('status --porcelain -z', context); if (!output) { - return { staged: 0, unstaged: 0, untracked: 0 }; + return { staged: 0, unstaged: 0 }; } let staged = 0; let unstaged = 0; - let untracked = 0; const entries = output.split('\0'); @@ -673,21 +693,38 @@ export function getGitFileStatusCounts(context: RenderContext): GitFileStatusCou if (typeof line !== 'string' || line.length < 2) continue; - if (line.startsWith('??')) { - untracked += 1; - } else { - if (/^[MADRCTU]/.test(line)) - staged += 1; - if (/^.[MADRCTU]/.test(line)) - unstaged += 1; - } + if (/^[MADRCTU]/.test(line)) + staged += 1; + if (/^.[MADRCTU]/.test(line)) + unstaged += 1; if (hasRenameOrCopyStatus(line)) { index += 1; } } - return { staged, unstaged, untracked }; + return { staged, unstaged }; +} + +function countNulTerminatedEntries(output: string): number { + let count = 0; + for (let end = output.indexOf('\0'); end !== -1; end = output.indexOf('\0', end + 1)) { + count += 1; + } + return count; +} + +// Untracked files are listed one by one: git status lists a new directory as +// one entry by default, and none under status.showUntrackedFiles=no. That walks +// every new directory, so this call is its own, and the staged and unstaged +// counts keep reading the git status Git Status shares. +export function getGitUntrackedFileCount(context: RenderContext): number { + const count = runCachedGit(context, UNTRACKED_FILE_COUNT_CACHE_KEY, (cwd) => { + const output = execGit(UNTRACKED_FILES_ARGS, cwd, UNTRACKED_FILES_MAX_BUFFER_BYTES); + return `${countNulTerminatedEntries(output)}`; + }); + const parsed = Number.parseInt(count ?? '', 10); + return Number.isNaN(parsed) ? 0 : parsed; } export interface GitAheadBehind { diff --git a/src/widgets/__tests__/GitCountWidgets.test.ts b/src/widgets/__tests__/GitCountWidgets.test.ts index a1220740f..0c3166662 100644 --- a/src/widgets/__tests__/GitCountWidgets.test.ts +++ b/src/widgets/__tests__/GitCountWidgets.test.ts @@ -32,25 +32,28 @@ vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })); const mockExecFileSync = execFileSync as unknown as { mockImplementation: (impl: (command: string, args: string[], options?: { cwd?: string }) => string) => void }; // Repositories keyed by working directory: what `git status --porcelain -z`, -// `git diff --shortstat` and `git diff --cached --shortstat` print there. -// '/repos/none' is not a git work tree. -const REPOS: Record = { - // 3 staged, 2 unstaged and 2 untracked files; 11 lines added, 9 removed +// `git ls-files --others --exclude-standard -z -- :/`, `git diff --shortstat` +// and `git diff --cached --shortstat` print there. '/repos/none' is not a git +// work tree. +const REPOS: Record = { + // 3 staged, 2 unstaged and 2 untracked files, one in the new f/; 11 lines + // added, 9 removed '/repos/dirty': { status: 'M a.ts\0A b.ts\0 M c.ts\0MM d.ts\0?? e.ts\0?? f/\0', + untracked: 'e.ts\0f/g.ts\0', unstagedStat: ' 2 files changed, 4 insertions(+), 9 deletions(-)', stagedStat: ' 2 files changed, 7 insertions(+)' }, - '/repos/clean': { status: '', unstagedStat: '', stagedStat: '' }, + '/repos/clean': { status: '', untracked: '', unstagedStat: '', stagedStat: '' }, // Only removed lines, unstaged - '/repos/removed': { status: ' M a.ts\0', unstagedStat: ' 1 file changed, 3 deletions(-)', stagedStat: '' }, + '/repos/removed': { status: ' M a.ts\0', untracked: '', unstagedStat: ' 1 file changed, 3 deletions(-)', stagedStat: '' }, // Only added lines, staged - '/repos/added': { status: 'M a.ts\0', unstagedStat: '', stagedStat: ' 1 file changed, 2 insertions(+)' }, + '/repos/added': { status: 'M a.ts\0', untracked: '', unstagedStat: '', stagedStat: ' 1 file changed, 2 insertions(+)' }, // One untracked file, so no line changes - '/repos/untracked': { status: '?? a.ts\0', unstagedStat: '', stagedStat: '' } + '/repos/untracked': { status: '?? a.ts\0', untracked: 'a.ts\0', unstagedStat: '', stagedStat: '' } }; -// Answers the four commands these widgets run like git would +// Answers the five commands these widgets run like git would beforeEach(() => { clearGitCache(); mockExecFileSync.mockImplementation((_command, args, options) => { @@ -61,6 +64,7 @@ beforeEach(() => { const outputs: Record = { 'rev-parse --is-inside-work-tree': 'true\n', 'status --porcelain -z': repo.status, + 'ls-files --others --exclude-standard -z -- :/': repo.untracked, 'diff --shortstat': repo.unstagedStat, 'diff --cached --shortstat': repo.stagedStat }; diff --git a/src/widgets/__tests__/GitUntrackedFiles.test.ts b/src/widgets/__tests__/GitUntrackedFiles.test.ts index 3b61d10f0..b6f7d6928 100644 --- a/src/widgets/__tests__/GitUntrackedFiles.test.ts +++ b/src/widgets/__tests__/GitUntrackedFiles.test.ts @@ -73,16 +73,16 @@ describe('GitUntrackedFilesWidget', () => { it('renders untracked file count', () => { mockExecFileSync.mockReturnValueOnce('true\n'); - mockExecFileSync.mockReturnValueOnce('M a.ts\0 M b.ts\0?? c.ts\0?? d.ts\0'); + mockExecFileSync.mockReturnValueOnce('c.ts\0d/e.ts\0'); expect(render({ cwd: '/tmp/worktree' })).toBe('?:2'); expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2], '/tmp/worktree'); - expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'status', '--ignore-submodules=dirty', '--porcelain', '-z']); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ':/']); }); it('renders raw untracked file count', () => { mockExecFileSync.mockReturnValueOnce('true\n'); - mockExecFileSync.mockReturnValueOnce('?? a.ts\0?? b.ts\0'); + mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); expect(render({ rawValue: true })).toBe('2'); }); @@ -103,7 +103,7 @@ describe('GitUntrackedFilesWidget', () => { it('keeps non-zero counts visible with the zero state enabled', () => { mockExecFileSync.mockReturnValueOnce('true\n'); - mockExecFileSync.mockReturnValueOnce('?? a.ts\0?? b.ts\0'); + mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); expect(render({ hide: 'zero' })).toBe('?:2'); }); diff --git a/src/widgets/shared/git-count-widget.ts b/src/widgets/shared/git-count-widget.ts index 2715f1866..f587ba619 100644 --- a/src/widgets/shared/git-count-widget.ts +++ b/src/widgets/shared/git-count-widget.ts @@ -11,6 +11,7 @@ import type { import { getGitChangeCounts, getGitFileStatusCounts, + getGitUntrackedFileCount, isInsideGitWorkTree, type GitChangeCounts, type GitFileStatusCounts @@ -77,16 +78,25 @@ export abstract class GitCountWidgetBase implements Widget { supportsColors(_item: WidgetItem): boolean { return true; } } -// Git Staged, Unstaged and Untracked Files show one of git status's file -// counts after a short label, or the bare count when raw +type GitFileCountField = keyof GitFileStatusCounts | 'untracked'; + +// Only Git Untracked Files runs the call that lists every untracked file +function getGitFileCount(context: RenderContext, field: GitFileCountField): number { + return field === 'untracked' + ? getGitUntrackedFileCount(context) + : getGitFileStatusCounts(context)[field]; +} + +// Git Staged, Unstaged and Untracked Files show a file count after a short +// label, or the bare count when raw export abstract class GitFileCountWidget extends GitCountWidgetBase { - protected abstract readonly field: keyof GitFileStatusCounts; + protected abstract readonly field: GitFileCountField; protected abstract readonly label: string; getLabelPrefix(): string { return this.label; } protected getCounts(context: RenderContext): number { - return getGitFileStatusCounts(context)[this.field]; + return getGitFileCount(context, this.field); } protected isZero(count: number): boolean { @@ -100,7 +110,7 @@ export abstract class GitFileCountWidget extends GitCountWidgetBase { getNumericValue(context: RenderContext, _item: WidgetItem): number | null { if (!isInsideGitWorkTree(context)) return null; - return getGitFileStatusCounts(context)[this.field]; + return getGitFileCount(context, this.field); } supportsRawValue(): boolean { return true; } From 2b6991f73f266b10711ed44793804b863d109183 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 12:05:48 -0500 Subject: [PATCH 4/5] fix(git): count no untracked files under status.showUntrackedFiles=no 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. --- docs/USAGE.md | 2 +- src/utils/__tests__/git-counts-exec.test.ts | 8 +++-- src/utils/__tests__/git.test.ts | 36 ++++++++++++++++--- src/utils/git.ts | 14 +++++--- .../__tests__/GitUntrackedFiles.test.ts | 6 +++- 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 3cc3ac0ca..cebb7c741 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -46,7 +46,7 @@ ccstatusline --version - **Git Upstream Owner** / **Git Upstream Repo** / **Git Upstream Owner/Repo** / **Git Is Fork** - Show upstream remote metadata and whether the current repo is a fork. Git Is Fork displays `⑂` for forks by default; press `g` to customize it, or use raw mode for `true`/`false`. - **Git Worktree** / **Git Worktree Mode** / **Git Worktree Name** / **Git Worktree Branch** / **Git Worktree Original Branch** - Show worktree status plus the active worktree's name and branch metadata. -The count widgets (Git Changes, Git Insertions, Git Deletions, Git Conflicts and Git Staged/Unstaged/Untracked Files) count the whole repository, as Git Status does, even when the session is in a subdirectory or your git config sets `diff.relative`. Git Untracked Files counts each file inside a new directory, including when `status.showUntrackedFiles` keeps them out of `git status`. +The count widgets (Git Changes, Git Insertions, Git Deletions, Git Conflicts and Git Staged/Unstaged/Untracked Files) count the whole repository, as Git Status does, even when the session is in a subdirectory or your git config sets `diff.relative`. Git Untracked Files counts each file inside a new directory. Under `status.showUntrackedFiles = no` it shows 0, as `git status` lists no untracked files there. ### Jujutsu (JJ) diff --git a/src/utils/__tests__/git-counts-exec.test.ts b/src/utils/__tests__/git-counts-exec.test.ts index 5fb90ce6a..87494e066 100644 --- a/src/utils/__tests__/git-counts-exec.test.ts +++ b/src/utils/__tests__/git-counts-exec.test.ts @@ -146,12 +146,16 @@ describe.skipIf(process.platform === 'win32')('git counts across the whole repos expect(execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', ':/'], { cwd: dir, env: { ...env, ...LITERAL_PATHSPECS_ENV }, encoding: 'utf8' })).toBe(''); }); - it('counts untracked files with status.showUntrackedFiles set to no', () => { + it('counts no untracked files with status.showUntrackedFiles set to no, as git status lists none', () => { const dir = makeUntrackedRepo('untracked-hidden'); plainGit(dir, 'config', 'status.showUntrackedFiles', 'no'); - expect(gitHelper('getGitUntrackedFileCount', dir)).toBe(4); + expect(gitHelper('getGitUntrackedFileCount', dir)).toBe(0); expect(plainGit(dir, 'status', '--porcelain', '-z')).toBe(''); + expect(plainGit(dir, 'ls-files', '--others', '--exclude-standard', '-z').split('\0').filter(Boolean)).toHaveLength(4); + + plainGit(dir, 'config', 'status.showUntrackedFiles', 'normal'); + expect(gitHelper('getGitUntrackedFileCount', dir)).toBe(4); }); it('counts files next to an untracked tree listed in more than 1 MiB', () => { diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 9a5f2cd9b..7730b6cce 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -41,6 +41,7 @@ vi.mock('node:child_process', () => ({ const mockExecFileSync = execFileSync as unknown as { mock: { calls: unknown[][] }; mockImplementation: (impl: () => never) => void; + mockImplementationOnce: (impl: () => never) => void; mockReturnValue: (value: string) => void; mockReturnValueOnce: (value: string) => void; }; @@ -904,21 +905,33 @@ describe('git utils', () => { describe('getGitUntrackedFileCount', () => { it('counts each file the whole repository\'s untracked listing holds', () => { + mockExecFileSync.mockReturnValueOnce(' M changed.ts\0?? new.ts\0?? new-dir/\0'); mockExecFileSync.mockReturnValueOnce('new.ts\0new-dir/a.ts\0new-dir/b.ts\0'); expect(getGitUntrackedFileCount({ data: { cwd: '/tmp/repo' } })).toBe(3); - expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ':/']); - expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2], '/tmp/repo'); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ':/']); + expectGitExecOptions(mockExecFileSync.mock.calls[1]?.[2], '/tmp/repo'); }); // execFileSync's default of 1 MiB holds about 16,000 paths, which an // untracked node_modules or .venv can pass it('lets the listing run past execFileSync\'s 1 MiB default', () => { + mockExecFileSync.mockReturnValueOnce('?? node_modules/\0'); mockExecFileSync.mockReturnValueOnce(''); getGitUntrackedFileCount({}); - expect((mockExecFileSync.mock.calls[0]?.[2] as { maxBuffer?: number }).maxBuffer).toBeGreaterThan(1024 * 1024); + expect((mockExecFileSync.mock.calls[1]?.[2] as { maxBuffer?: number }).maxBuffer).toBeGreaterThan(1024 * 1024); + }); + + // As under status.showUntrackedFiles=no, where git status lists no + // untracked files however many there are + it('runs no listing when git status lists nothing untracked', () => { + mockExecFileSync.mockReturnValueOnce('M staged.ts\0 M changed.ts\0'); + + expect(getGitUntrackedFileCount({})).toBe(0); + expect(mockExecFileSync.mock.calls).toHaveLength(1); + expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'status', '--ignore-submodules=dirty', '--porcelain', '-z']); }); it('returns zero when nothing is untracked', () => { @@ -927,6 +940,15 @@ describe('git utils', () => { expect(getGitUntrackedFileCount({})).toBe(0); }); + it('returns zero without listing when git status fails, as the staged and unstaged counts do', () => { + mockExecFileSync.mockImplementationOnce(() => { throw new Error('git status failed'); }); + mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); + + expect(getGitUntrackedFileCount({})).toBe(0); + expect(mockExecFileSync.mock.calls).toHaveLength(1); + expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'status', '--ignore-submodules=dirty', '--porcelain', '-z']); + }); + it('returns zero when git fails', () => { mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); }); @@ -938,14 +960,18 @@ describe('git utils', () => { const home = useTempHome(); const { root } = createGitRepo(); const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 }; + mockExecFileSync.mockReturnValueOnce('?? a.ts\0?? b.ts\0'); mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); expect(getGitUntrackedFileCount(context)).toBe(2); - expect(Object.values(readGitCacheJson(home).entries ?? {})).toEqual([expect.objectContaining({ output: '2' })]); + expect(Object.values(readGitCacheJson(home).entries ?? {})).toEqual([ + expect.objectContaining({ output: '?? a.ts\0?? b.ts\0' }), + expect.objectContaining({ output: '2' }) + ]); clearGitCache(); expect(getGitUntrackedFileCount(context)).toBe(2); - expect(mockExecFileSync.mock.calls).toHaveLength(1); + expect(mockExecFileSync.mock.calls).toHaveLength(2); }); }); }); diff --git a/src/utils/git.ts b/src/utils/git.ts index 53450d448..c51fbafc0 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -714,11 +714,17 @@ function countNulTerminatedEntries(output: string): number { return count; } -// Untracked files are listed one by one: git status lists a new directory as -// one entry by default, and none under status.showUntrackedFiles=no. That walks -// every new directory, so this call is its own, and the staged and unstaged -// counts keep reading the git status Git Status shares. +// Untracked files are listed one by one, as git status lists a new directory +// as one entry by default. The listing walks every new directory, so it runs +// only when the git status Git Status and the other file counts share lists +// something untracked. Otherwise the count is 0, as git status says, which +// under status.showUntrackedFiles=no lists no untracked files at all. export function getGitUntrackedFileCount(context: RenderContext): number { + const status = getGitStatus(context); + if (!status.untracked) { + return 0; + } + const count = runCachedGit(context, UNTRACKED_FILE_COUNT_CACHE_KEY, (cwd) => { const output = execGit(UNTRACKED_FILES_ARGS, cwd, UNTRACKED_FILES_MAX_BUFFER_BYTES); return `${countNulTerminatedEntries(output)}`; diff --git a/src/widgets/__tests__/GitUntrackedFiles.test.ts b/src/widgets/__tests__/GitUntrackedFiles.test.ts index b6f7d6928..778dab5af 100644 --- a/src/widgets/__tests__/GitUntrackedFiles.test.ts +++ b/src/widgets/__tests__/GitUntrackedFiles.test.ts @@ -73,15 +73,18 @@ describe('GitUntrackedFilesWidget', () => { it('renders untracked file count', () => { mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('M a.ts\0 M b.ts\0?? c.ts\0?? d/\0'); mockExecFileSync.mockReturnValueOnce('c.ts\0d/e.ts\0'); expect(render({ cwd: '/tmp/worktree' })).toBe('?:2'); expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2], '/tmp/worktree'); - expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ':/']); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'status', '--ignore-submodules=dirty', '--porcelain', '-z']); + expect(mockExecFileSync.mock.calls[2]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ':/']); }); it('renders raw untracked file count', () => { mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('?? a.ts\0?? b.ts\0'); mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); expect(render({ rawValue: true })).toBe('2'); @@ -103,6 +106,7 @@ describe('GitUntrackedFilesWidget', () => { it('keeps non-zero counts visible with the zero state enabled', () => { mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('?? a.ts\0?? b.ts\0'); mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); expect(render({ hide: 'zero' })).toBe('?:2'); From 7e256faf2d408b9610e797d9c73396efb8578587 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 12:43:20 -0500 Subject: [PATCH 5/5] test(git): cover an untracked listing that fails after git status 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. --- src/utils/__tests__/git.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 7730b6cce..b16195562 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -942,13 +942,22 @@ describe('git utils', () => { it('returns zero without listing when git status fails, as the staged and unstaged counts do', () => { mockExecFileSync.mockImplementationOnce(() => { throw new Error('git status failed'); }); - mockExecFileSync.mockReturnValueOnce('a.ts\0b.ts\0'); expect(getGitUntrackedFileCount({})).toBe(0); expect(mockExecFileSync.mock.calls).toHaveLength(1); expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'status', '--ignore-submodules=dirty', '--porcelain', '-z']); }); + // As when the listing runs past the timeout every git call has + it('returns zero when the listing fails after git status lists something untracked', () => { + mockExecFileSync.mockReturnValueOnce('?? a.ts\0'); + mockExecFileSync.mockImplementationOnce(() => { throw new Error('ls-files failed'); }); + + expect(getGitUntrackedFileCount({})).toBe(0); + expect(mockExecFileSync.mock.calls).toHaveLength(2); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ':/']); + }); + it('returns zero when git fails', () => { mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });