diff --git a/docs/USAGE.md b/docs/USAGE.md index 2496063ec..e1e7e3d99 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -47,6 +47,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. Git Worktree supports `f` for fish-style abbreviation, including `main` → `m`. +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) - **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 new file mode 100644 index 000000000..87494e066 --- /dev/null +++ b/src/utils/__tests__/git-counts-exec.test.ts @@ -0,0 +1,178 @@ +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; + +// 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 = ''; + 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, 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: { ...env, ...extraEnv } })) 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(''); + }); + + // 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(''); + }); + + // 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 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(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', () => { + 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-helpers.ts b/src/utils/__tests__/git-test-helpers.ts index 1512bdf1a..e5686fc0d 100644 --- a/src/utils/__tests__/git-test-helpers.ts +++ b/src/utils/__tests__/git-test-helpers.ts @@ -49,12 +49,23 @@ 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 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 { - 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); + 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 e0ac56af3..b8a83e45d 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -20,6 +20,7 @@ import { getGitConflictCount, getGitFileStatusCounts, getGitStatus, + getGitUntrackedFileCount, isInsideGitWorkTree, resolveGitCwd, runGit, @@ -42,6 +43,7 @@ vi.mock('node:child_process', () => ({ const mockExecFileSync = execFileSync as unknown as { mock: { calls: unknown[][] }; mockImplementation: (impl: () => string) => void; + mockImplementationOnce: (impl: () => string) => void; mockReturnValue: (value: string) => void; mockReturnValueOnce: (value: string) => void; }; @@ -127,7 +129,7 @@ describe('git utils', () => { mockExecFileSync.mockReturnValueOnce(filenames.flatMap(filename => [1, 2, 3].map(stage => `100644 hash ${stage}\t${filename}\0`)).join('')); expect(getGitConflictCount({})).toBe(filenames.length); - expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'ls-files', '--unmerged', '-z']); + expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--unmerged', '-z', '--', ':/']); expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ encoding: 'latin1' })); }); @@ -135,7 +137,7 @@ describe('git utils', () => { useTempHome(); const { root } = createGitRepo(); const context: RenderContext = { data: { cwd: root } }; - const args = ['ls-files', '--unmerged', '-z']; + const args = ['ls-files', '--unmerged', '-z', '--', ':/']; const bytes = Buffer.from('100644 hash 1\tfile-\xfe.txt\0' + '100644 hash 2\tfile-\xff.txt\0', 'latin1'); mockExecFileSync.mockReturnValueOnce(bytes.toString('utf8')); mockExecFileSync.mockReturnValueOnce(bytes.toString('latin1')); @@ -239,6 +241,40 @@ 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' + ]); + }); + + 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', () => { @@ -885,13 +921,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 }); }); @@ -900,8 +935,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 1, - unstaged: 1, - untracked: 0 + unstaged: 1 }); }); @@ -910,8 +944,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 0, - unstaged: 0, - untracked: 0 + unstaged: 0 }); }); @@ -920,8 +953,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 1, - unstaged: 0, - untracked: 1 + unstaged: 0 }); }); @@ -930,8 +962,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 1, - unstaged: 1, - untracked: 0 + unstaged: 1 }); }); @@ -940,8 +971,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 0, - unstaged: 1, - untracked: 1 + unstaged: 1 }); }); @@ -950,8 +980,7 @@ describe('git utils', () => { expect(getGitFileStatusCounts({})).toEqual({ staged: 0, - unstaged: 2, - untracked: 0 + unstaged: 2 }); }); @@ -960,9 +989,89 @@ 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(' 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[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[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', () => { + mockExecFileSync.mockReturnValueOnce(''); + + 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'); }); + + 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'); }); + + 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\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: '?? a.ts\0?? b.ts\0' }), + expect.objectContaining({ output: '2' }) + ]); + + clearGitCache(); + expect(getGitUntrackedFileCount(context)).toBe(2); + expect(mockExecFileSync.mock.calls).toHaveLength(2); + }); + }); }); diff --git a/src/utils/git.ts b/src/utils/git.ts index 5086a5d15..4a387ebb2 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 { @@ -55,9 +54,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(); @@ -69,9 +69,30 @@ 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. +// 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']], + ['ls-files', ['--no-literal-pathspecs']] +]); + // `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'); } @@ -187,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. @@ -200,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 }) }; } @@ -257,21 +279,25 @@ 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. - * Throws when git fails, or when the repository's filters can't be turned off. + * 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, 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, encoding: 'utf8' | 'latin1' = 'utf8'): string { +export function execGit(args: string[], cwd: string | undefined, encoding: 'utf8' | 'latin1' = 'utf8', maxBuffer?: number): string { const [subcommand = '', ...rest] = args; const workTreeArgs = WORK_TREE_COMMAND_ARGS.get(subcommand); const filterArgs = workTreeArgs ? getRepositoryFilterOverrideArgs(cwd) : []; 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], - { ...gitExecOptions(cwd), encoding } + [...GIT_HARDENING_ARGS, ...filterArgs, ...wholeRepositoryArgs, subcommand, ...(workTreeArgs ?? []), ...rest], + { ...gitExecOptions(cwd, maxBuffer), encoding } ); } @@ -519,10 +545,24 @@ export function runGitArgs(args: string[], context: RenderContext, cacheCommand? } function runGitArgsEncoded(args: string[], context: RenderContext, cacheCommand: string | undefined, encoding: 'utf8' | 'latin1'): string | null { - const cwd = resolveGitCwd(context); const commandToken = cacheCommand ?? args.join('\0'); // Byte-preserving output must never reuse an older, lossy UTF-8 entry. const cacheToken = encoding === 'utf8' ? commandToken : `${encoding}\0${commandToken}`; + return runCachedGit(context, cacheToken, (cwd) => { + const rawOutput = execGit(args, cwd, encoding); + const output = encoding === 'utf8' ? rawOutput.trimEnd() : rawOutput; + 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 memoryCacheKey = `${cacheToken}|${cwd ?? ''}`; const persistentCacheKey = cacheToken; const metadata = getGitRepoMetadata(cwd); @@ -549,9 +589,7 @@ function runGitArgsEncoded(args: string[], context: RenderContext, cacheCommand: let result: string | null; try { - const rawOutput = execGit(args, cwd, encoding); - const output = encoding === 'utf8' ? rawOutput.trimEnd() : rawOutput; - result = output.length > 0 ? output : null; + result = run(cwd); } catch { result = null; } @@ -652,12 +690,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'); @@ -666,21 +703,44 @@ 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, 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, 'utf8', UNTRACKED_FILES_MAX_BUFFER_BYTES); + return `${countNulTerminatedEntries(output)}`; + }); + const parsed = Number.parseInt(count ?? '', 10); + return Number.isNaN(parsed) ? 0 : parsed; } export interface GitAheadBehind { @@ -709,7 +769,9 @@ export function getGitAheadBehind(context: RenderContext): GitAheadBehind | null export function getGitConflictCount(context: RenderContext): number { // Latin-1 maps each byte to a distinct code point, preserving arbitrary Git // pathnames through string comparisons and the JSON cache. It is not display text. - const output = runGitArgsEncoded(['ls-files', '--unmerged', '-z'], context, undefined, 'latin1'); + // `:/` covers the whole repository, as git status does; without a path + // ls-files only lists the session's directory + const output = runGitArgsEncoded(['ls-files', '--unmerged', '-z', '--', ':/'], context, undefined, 'latin1'); if (!output) return 0; diff --git a/src/widgets/__tests__/GitConflicts.test.ts b/src/widgets/__tests__/GitConflicts.test.ts index c9460394e..85fa7ee87 100644 --- a/src/widgets/__tests__/GitConflicts.test.ts +++ b/src/widgets/__tests__/GitConflicts.test.ts @@ -139,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', '-z', '--', ':/']); + }); + it('counts filenames with repeated spaces separately', () => { mockExecFileSync.mockReturnValueOnce('true\n'); mockExecFileSync.mockReturnValueOnce( @@ -146,7 +153,7 @@ describe('GitConflictsWidget', () => { ); expect(render()).toBe('⚠2'); - expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'ls-files', '--unmerged', '-z']); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual([...GIT_HARDENING_ARGS, '--no-literal-pathspecs', 'ls-files', '--unmerged', '-z', '--', ':/']); }); it('renders raw conflicts as a numeric count', () => { 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..778dab5af 100644 --- a/src/widgets/__tests__/GitUntrackedFiles.test.ts +++ b/src/widgets/__tests__/GitUntrackedFiles.test.ts @@ -73,16 +73,19 @@ 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('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, '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'); }); @@ -104,6 +107,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; }