perf(core): read current git branch directly from .git instead of spawning git (#5432)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* perf(core): read current git branch directly from .git instead of spawning git

Reading the current branch for the CLI status line previously shelled out to `git rev-parse --abbrev-ref HEAD` on the render path. Replace it with a direct read of .git/HEAD.

- Add shared module packages/core/src/utils/gitDirect.ts: resolveBranchName, readGitHead, a reference-counted reflog watcher (watchRepoBranch), ref-name validation (isValidRefName), and a gitDir resolution cache. gitDir resolution reuses the existing gitDiff.resolveGitDir (ancestor walk + worktree gitdir pointer); only HEAD is re-read on reflog changes.
- Rewrite useGitBranchName as a thin wrapper over the shared module; the (cwd) => string | undefined hook signature is unchanged.

* fix(core): handle fs.FSWatcher 'error' and don't cache a watcher-less entry

Addresses review feedback on the reflog watcher:

- Add an 'error' handler to the reflog fs.FSWatcher. fs.FSWatcher is an EventEmitter, so an unhandled 'error' (reflog removed by `git gc`/`reflog expire`, worktree removal, inode change, or a platform watch limit) would crash the process. The watch is now torn down instead, and subscribers simply stop auto-refreshing.
- When there is no reflog yet (unborn repo), return a no-op disposer without caching a watcher-less entry, so a later caller can establish the watch once the reflog appears (e.g. after the first commit).
- Tests: FSWatcher 'error' teardown, reflog-appears-later re-watch, concurrent-caller dedup (post-await re-check), and the hook's [cwd]-change re-subscribe path.

* fix(core): harden gitDirect against out-of-repo reads and fs.watch throws

Addresses security/robustness review:

- Containment guard: resolveTrustedGitDir rejects a .git-FILE `gitdir:` pointer that escapes the repo. After realpath, the resolved gitDir must be the repo's own `<root>/.git`, or live under some `.git/worktrees/` (linked worktree) or `.git/modules/` (submodule). This stops a crafted project from making the status line read/watch an arbitrary out-of-repo path (the old `git rev-parse` path refused such repos with exit 128).
- Refuse a symlinked HEAD (readGitHead) and a symlinked reflog (watchRepoBranch) via lstat, so neither follows a link out of the repo.
- Wrap fs.watch in try/catch: a TOCTOU vanish (git gc / reflog expire / worktree removal) or a platform watch limit makes fs.watch throw synchronously; return a no-op disposer rather than rejecting (which the hook's bare `void init()` would surface as an unhandled rejection). The existing 'error' listener only covers async emitter errors.
- Hook: guard `void init()` with .catch() as belt-and-suspenders.
- Tests: containment (decoy rejected, submodule accepted), symlink HEAD/reflog refused, fs.watch sync-throw no-op, and the hook still rendering when watch setup rejects.

* fix(core): validate the git object store instead of path-shape containment

Replaces the path-segment containment guard — which qqqys showed a crafted `.git/worktrees/x` path could spoof — with git's own validity check: a trusted gitDir must have an object store. A standalone repo has `objects/` + `refs/` directly; a linked worktree / submodule gitdir instead carries a `commondir` file pointing at the main gitdir that does. Incomplete forgeries (a lone HEAD, or a path-shaped `.git/worktrees/x` containing only a HEAD) have neither and are rejected — exactly what `git rev-parse` rejects with 'not a git repository' (exit 128). Verified the criterion against real git across standalone / unborn / worktree / fake structures.

- Addresses qqqys (path-shape `.git/worktrees/fake` with only HEAD) and yiliang114 (fake `.git` dir with only HEAD+logs) — both now return undefined.
- Tests: both PoCs rejected; worktree (via commondir) and submodule (own object store) accepted; makeRepo now creates objects/ + refs/ so fixtures are real git dirs.

* fix(core): harden ref-name gate (C1/length) and stop caching non-repo misses

- isValidRefName: also reject C1 controls (0x80-0x9f) and U+2028/U+2029, and cap length at 255. With git no longer vetting the value, a hand-written HEAD could otherwise carry terminal escape bytes (CSI/OSC) or layout-desyncing line separators into the status line, and string-width undercounts C1.
- getCachedGitDir: cache only successful resolutions. A null (non-repo) result was cached permanently, so a directory that became a repo mid-session (git init / clone) never showed a branch until restart — a regression from the old git rev-parse path which always hit the filesystem.

* fix(core): isolate subscriber callbacks in the shared reflog watcher

In the shared-watcher fan-out (one fs.watch per gitDir, many subscribers), a subscriber whose onChange throws synchronously would halt iteration — later subscribers never fire — and the exception would escape to the event loop as uncaughtException, poisoning every component watching that repo. Wrap each callback in try/catch. The sole current caller (useGitBranchName) can't throw, but watchRepoBranch is exported public API.

* fix(core): close readGitHead symlink TOCTOU with O_NOFOLLOW; per-component ref rules

Addresses review:
- readGitHead: open HEAD with O_NOFOLLOW instead of lstat-then-readFile, so a symlinked HEAD is refused atomically (ELOOP) and can't be swapped in during the check->read gap. Mirrors the existing O_NOFOLLOW use in gitDiff.ts; falls back to plain O_RDONLY where the flag is absent (Windows).
- isValidRefName: also reject names where any slash-separated component starts with a dot or ends with .lock (git's check-ref-format applies per component, not just to the whole name).
- hasGitStore: run the two isDir probes in parallel.
- Reword the module doc (drop the cross-product reference) and note the residual, bounded lstat->watch TOCTOU on logs/HEAD (the watch only ever fires readGitHead, which opens HEAD with O_NOFOLLOW, and never reads logs/HEAD content).

* fix(core): clear watchers in clearGitDirCache; debug-log watcher failures; guard refresh

- clearGitDirCache now also closes the shared reflog watchers. Both maps are gitDir-keyed, so clearing only the resolution cache would leak the watchers' fds.
- Add a debug logger and warn on the unexpected paths (fs.watch synchronous throw, FSWatcher 'error'). The common silent fallbacks (not a repo, no HEAD) stay quiet so a status-line read can't log-spam.
- useGitBranchName: guard the watcher-triggered void refresh() with .catch() — the synchronous try/catch inside watchRepoBranch can't observe an async rejection.

* fix(core): bound the HEAD read, cap ref length per-component, guard fs.constants

- readGitHead: read a bounded 4 KB prefix and parse only the first line instead of loading the whole file — a pathologically large HEAD can no longer be read into memory.
- isValidRefName: the length cap is now per slash-separated component (git's actual filesystem limit), not the whole ref — a valid deeply-nested ref longer than 255 total is no longer wrongly rejected.
- Access fs.constants via optional chaining (O_RDONLY/O_NOFOLLOW/F_OK), matching gitDiff.ts, so a mock or platform without `constants` can't throw.

* fix(core): bound/O_NOFOLLOW the commondir read; block bidi/zero-width + more ref rules

- Share a readFirstLineNoFollow helper between HEAD and commondir, so the commondir read is now also bounded (4 KB) + O_NOFOLLOW — a crafted oversized or symlinked commondir can no longer OOM the status-line path or redirect the validity check out of the repo.
- isValidRefName also rejects: bidi-override (U+202A-202E, U+2066-2069) and zero-width (U+200B-200D, U+FEFF) characters (display spoofing); a slash-separated component ending in a dot (git check-ref-format); and the literal name 'HEAD' (ambiguous with a detached HEAD, which git rejects as a branch).

* fix(core): O_NONBLOCK against FIFO hangs, swallow close errors, test commondir symlink

- readFirstLineNoFollow: open with O_NONBLOCK so a crafted FIFO .git/HEAD or commondir can't block indefinitely and pin a libuv thread-pool slot (the old git rev-parse path had subprocess timeouts; the direct read had none). And `await fh.close().catch(() => {})` so a close error (EIO / stale NFS handle) can't escape — the helper promises null on any failure — matching gitDiff.ts / fileHistoryService.ts.
- Test: a symlinked commondir is refused via O_NOFOLLOW, closing the coverage gap alongside the existing symlinked HEAD and reflog tests.
This commit is contained in:
Shaojin Wen 2026-06-21 07:09:19 +08:00 committed by GitHub
parent 8553013ee8
commit 6b2f800abd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1066 additions and 306 deletions

View file

@ -4,297 +4,181 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type Mock,
} from 'vitest';
import { act } from 'react';
import { renderHook } from '@testing-library/react';
import { resolveBranchName, watchRepoBranch } from '@qwen-code/qwen-code-core';
import { useGitBranchName } from './useGitBranchName.js';
import { fs, vol } from 'memfs'; // For mocking fs
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
// Mock @qwen-code/qwen-code-core
vi.mock('@qwen-code/qwen-code-core', async () => {
const original = await vi.importActual<
typeof import('@qwen-code/qwen-code-core')
>('@qwen-code/qwen-code-core');
return {
...original,
execCommand: vi.fn(),
isCommandAvailable: vi.fn(),
};
});
// The hook is a thin wrapper over core's gitDirect helpers; the direct-read
// logic itself is covered by core's gitDirect.test.ts. Here we mock those two
// functions and exercise the hook's wiring and lifecycle.
vi.mock('@qwen-code/qwen-code-core', () => ({
resolveBranchName: vi.fn(),
watchRepoBranch: vi.fn(),
}));
// Mock fs and fs/promises
vi.mock('node:fs', async () => {
const memfs = await vi.importActual<typeof import('memfs')>('memfs');
return {
...memfs.fs,
default: memfs.fs,
};
});
vi.mock('node:fs/promises', async () => {
const memfs = await vi.importActual<typeof import('memfs')>('memfs');
return {
...memfs.fs.promises,
default: memfs.fs.promises,
};
});
const mockResolve = resolveBranchName as Mock;
const mockWatch = watchRepoBranch as Mock;
const CWD = '/test/project';
const GIT_LOGS_HEAD_PATH = path.join(CWD, '.git', 'logs', 'HEAD');
async function flushAsyncEffects() {
vi.runAllTimers();
await Promise.resolve();
await Promise.resolve();
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
}
describe('useGitBranchName', () => {
beforeEach(() => {
vol.reset(); // Reset in-memory filesystem
vol.fromJSON({
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
});
vi.useFakeTimers(); // Use fake timers for async operations
(isCommandAvailable as Mock).mockReturnValue({ available: true });
mockResolve.mockReset();
mockWatch.mockReset();
// Default: the watcher registers and hands back a no-op disposer.
mockWatch.mockResolvedValue(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
});
it('should return branch name', async () => {
(execCommand as Mock).mockResolvedValueOnce({
stdout: 'main\n',
stderr: '',
code: 0,
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
vi.runAllTimers(); // Advance timers to trigger useEffect and exec callback
rerender(); // Rerender to get the updated state
});
expect(result.current).toBe('main');
});
it('should return undefined if git command fails', async () => {
(execCommand as Mock).mockRejectedValue(new Error('Git error'));
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
expect(result.current).toBeUndefined();
await act(async () => {
vi.runAllTimers();
rerender();
});
expect(result.current).toBeUndefined();
});
it('should return short commit hash if branch is HEAD (detached state)', async () => {
(execCommand as Mock).mockImplementation(
async (_command: string, args?: readonly string[] | null) => {
if (args?.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n', stderr: '', code: 0 };
} else if (args?.includes('--short')) {
return { stdout: 'a1b2c3d\n', stderr: '', code: 0 };
}
return { stdout: '', stderr: '', code: 0 };
},
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
vi.runAllTimers();
rerender();
});
expect(result.current).toBe('a1b2c3d');
});
it('should return undefined if branch is HEAD and getting commit hash fails', async () => {
(execCommand as Mock).mockImplementation(
async (_command: string, args?: readonly string[] | null) => {
if (args?.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n', stderr: '', code: 0 };
} else if (args?.includes('--short')) {
throw new Error('Git error');
}
return { stdout: '', stderr: '', code: 0 };
},
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
vi.runAllTimers();
rerender();
});
expect(result.current).toBeUndefined();
});
it('should update branch name when .git/logs/HEAD changes', async () => {
let onWatchEvent: ((eventType: string) => void) | undefined;
const watchMock = vi
.spyOn(fs, 'watch')
.mockImplementation(
(_filename: unknown, options?: unknown, listener?: unknown) => {
const callback =
typeof options === 'function'
? (options as (
eventType: string,
filename: string | Buffer | null,
) => void)
: typeof listener === 'function'
? (listener as (
eventType: string,
filename: string | Buffer | null,
) => void)
: undefined;
onWatchEvent = callback
? (eventType: string) => callback(eventType, null)
: undefined;
return {
close: vi.fn(),
} as unknown as ReturnType<typeof fs.watch>;
},
);
(execCommand as Mock)
.mockResolvedValueOnce({
stdout: 'main\n',
stderr: '',
code: 0,
})
.mockResolvedValueOnce({
stdout: 'develop\n',
stderr: '',
code: 0,
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
it('reads the branch name on mount', async () => {
mockResolve.mockResolvedValue('main');
const { result } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
await flushAsyncEffects();
});
expect(result.current).toBe('main');
});
it('is undefined when not in a git repository', async () => {
mockResolve.mockResolvedValue(undefined);
const { result } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
await flushAsyncEffects();
});
expect(result.current).toBeUndefined();
});
it('subscribes to branch changes for the given cwd', async () => {
mockResolve.mockResolvedValue('main');
renderHook(() => useGitBranchName(CWD));
await act(async () => {
await flushAsyncEffects();
});
expect(mockWatch).toHaveBeenCalledWith(CWD, expect.any(Function));
});
it('refreshes the branch name when the watcher fires', async () => {
mockResolve.mockResolvedValueOnce('main').mockResolvedValueOnce('develop');
let fire: (() => void) | undefined;
mockWatch.mockImplementation(async (_cwd: string, onChange: () => void) => {
fire = onChange;
return () => {};
});
const { result } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
await flushAsyncEffects();
rerender();
});
expect(result.current).toBe('main');
expect(watchMock).toHaveBeenCalledWith(
GIT_LOGS_HEAD_PATH,
expect.any(Function),
);
// Simulate the watcher event after updating the reflog file.
await act(async () => {
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop'); // Trigger watcher
onWatchEvent?.('change');
await flushAsyncEffects(); // Process timers for watcher and exec
rerender();
fire?.();
await flushAsyncEffects();
});
expect(result.current).toBe('develop');
});
it('should handle watcher setup error silently', async () => {
// Remove .git/logs/HEAD to cause an error in fs.watch setup
vol.unlinkSync(GIT_LOGS_HEAD_PATH);
(execCommand as Mock).mockResolvedValue({
stdout: 'main\n',
stderr: '',
code: 0,
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
vi.runAllTimers();
rerender();
});
expect(result.current).toBe('main'); // Branch name should still be fetched initially
(execCommand as Mock).mockResolvedValueOnce({
stdout: 'develop\n',
stderr: '',
code: 0,
});
// This write would trigger the watcher if it was set up
// but since it failed, the branch name should not update
// We need to create the file again for writeFileSync to not throw
vol.fromJSON({
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/develop',
});
await act(async () => {
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop');
vi.runAllTimers();
rerender();
});
// Branch name should not change because watcher setup failed
expect(result.current).toBe('main');
});
it('should cleanup watcher on unmount', async () => {
const closeMock = vi.fn();
const watchMock = vi.spyOn(fs, 'watch').mockReturnValue({
close: closeMock,
} as unknown as ReturnType<typeof fs.watch>);
(execCommand as Mock).mockResolvedValue({
stdout: 'main\n',
stderr: '',
code: 0,
});
const { unmount, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
await flushAsyncEffects();
rerender();
});
expect(watchMock).toHaveBeenCalled();
unmount();
expect(watchMock).toHaveBeenCalledWith(
GIT_LOGS_HEAD_PATH,
expect.any(Function),
);
expect(closeMock).toHaveBeenCalled();
});
it('should not create watcher if setup completes after unmount', async () => {
let resolveAccess!: () => void;
vi.spyOn(fsPromises, 'access').mockReturnValue(
new Promise<void>((resolve) => {
resolveAccess = resolve;
}),
);
const closeMock = vi.fn();
const watchMock = vi.spyOn(fs, 'watch').mockReturnValue({
close: closeMock,
} as unknown as ReturnType<typeof fs.watch>);
(execCommand as Mock).mockResolvedValue({
stdout: 'main\n',
stderr: '',
code: 0,
});
it('disposes the watcher on unmount', async () => {
mockResolve.mockResolvedValue('main');
const dispose = vi.fn();
mockWatch.mockResolvedValue(dispose);
const { unmount } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
await flushAsyncEffects();
});
unmount();
expect(dispose).toHaveBeenCalledTimes(1);
});
it('disposes immediately if the watcher resolves after unmount', async () => {
mockResolve.mockResolvedValue('main');
const dispose = vi.fn();
let resolveWatch!: (d: () => void) => void;
mockWatch.mockImplementation(
() =>
new Promise<() => void>((resolve) => {
resolveWatch = resolve;
}),
);
const { unmount } = renderHook(() => useGitBranchName(CWD));
// Let init() progress past the initial read to the pending watch setup.
await act(async () => {
await flushAsyncEffects();
});
unmount();
await act(async () => {
resolveAccess();
await Promise.resolve();
resolveWatch(dispose);
await flushAsyncEffects();
});
expect(watchMock).not.toHaveBeenCalled();
expect(closeMock).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledTimes(1);
});
it('re-subscribes when cwd changes', async () => {
mockResolve.mockResolvedValue('main');
const dispose1 = vi.fn();
const dispose2 = vi.fn();
mockWatch.mockResolvedValueOnce(dispose1).mockResolvedValueOnce(dispose2);
const { rerender } = renderHook(({ cwd }) => useGitBranchName(cwd), {
initialProps: { cwd: '/repo-a' },
});
await act(async () => {
await flushAsyncEffects();
});
expect(mockWatch).toHaveBeenCalledWith('/repo-a', expect.any(Function));
rerender({ cwd: '/repo-b' });
await act(async () => {
await flushAsyncEffects();
});
// The old repo's watcher is disposed, and the new cwd is resolved + watched.
expect(dispose1).toHaveBeenCalledTimes(1);
expect(mockResolve).toHaveBeenCalledWith('/repo-b');
expect(mockWatch).toHaveBeenCalledWith('/repo-b', expect.any(Function));
});
it('still renders the branch if watcher setup rejects', async () => {
mockResolve.mockResolvedValue('main');
mockWatch.mockRejectedValue(new Error('watch boom'));
const { result } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
await flushAsyncEffects();
});
// The initial read still rendered; the rejected setup is swallowed by the
// hook's .catch() (no unhandled rejection).
expect(result.current).toBe('main');
});
});

View file

@ -5,71 +5,51 @@
*/
import { useState, useEffect } from 'react';
import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { resolveBranchName, watchRepoBranch } from '@qwen-code/qwen-code-core';
/**
* Tracks the current git branch (or a short commit hash when detached) for
* `cwd`, read directly from `.git` via core's gitDirect helpers no `git`
* subprocess. Re-reads automatically when the repository's reflog moves
* (branch switch, commit, reset).
*/
export function useGitBranchName(cwd: string): string | undefined {
const [branchName, setBranchName] = useState<string | undefined>(undefined);
useEffect(() => {
let cancelled = false;
let dispose: (() => void) | undefined;
const fetchWithGuard = async () => {
try {
if (!isCommandAvailable('git').available) {
return;
}
const refresh = async () => {
const name = await resolveBranchName(cwd);
if (!cancelled) setBranchName(name);
};
const { stdout } = await execCommand(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd },
);
if (cancelled) return;
const branch = stdout.toString().trim();
if (branch && branch !== 'HEAD') {
setBranchName(branch);
} else {
const { stdout: hashStdout } = await execCommand(
'git',
['rev-parse', '--short', 'HEAD'],
{ cwd },
);
if (!cancelled) {
setBranchName(hashStdout.toString().trim());
}
}
} catch {
if (!cancelled) setBranchName(undefined);
const init = async () => {
await refresh();
if (cancelled) return;
const disposer = await watchRepoBranch(cwd, () => {
// Guard the watcher-triggered refresh too: the synchronous try/catch
// inside watchRepoBranch can't observe an async rejection.
void refresh().catch(() => {});
});
// The component may have unmounted while we were resolving the watcher;
// if so, dispose immediately rather than leaking the subscription.
if (cancelled) {
disposer();
} else {
dispose = disposer;
}
};
fetchWithGuard();
const gitLogsHeadPath = path.join(cwd, '.git', 'logs', 'HEAD');
let watcher: fs.FSWatcher | undefined;
const setupWatcher = async () => {
try {
await fsPromises.access(gitLogsHeadPath, fs.constants.F_OK);
if (cancelled) return;
watcher = fs.watch(gitLogsHeadPath, (eventType: string) => {
if (eventType === 'change' || eventType === 'rename') {
if (!cancelled) fetchWithGuard();
}
});
} catch (_watchError) {
// Silently ignore watcher errors
}
};
setupWatcher();
// Defensive: init() shouldn't reject (resolveBranchName / watchRepoBranch
// swallow their own errors), but guard so a future change can't surface an
// unhandled rejection on the render path.
void init().catch(() => {});
return () => {
cancelled = true;
watcher?.close();
dispose?.();
};
}, [cwd]);

View file

@ -370,6 +370,7 @@ export * from './utils/formatters.js';
export * from './utils/generateContentResponseUtilities.js';
export * from './utils/getFolderStructure.js';
export * from './utils/gitDiff.js';
export * from './utils/gitDirect.js';
export * from './utils/gitIgnoreParser.js';
export * from './utils/gitUtils.js';
export * from './utils/ignorePatterns.js';

View file

@ -0,0 +1,520 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest';
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
// Keep the real filesystem (so resolution/HEAD parsing read real temp repos),
// but replace fs.watch with a spy so the shared-watcher logic is observable.
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, default: actual, watch: vi.fn() };
});
import {
isValidRefName,
isValidGitSha,
readGitHead,
resolveBranchName,
watchRepoBranch,
clearGitDirCache,
} from './gitDirect.js';
const watchMock = fs.watch as unknown as Mock;
const tmpRoots: string[] = [];
async function makeRepo(
headContent: string,
opts: { withReflog?: boolean } = {},
): Promise<string> {
const { withReflog = true } = opts;
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-gitdirect-'));
tmpRoots.push(dir);
// A real git dir carries an object store; isRealGitDir requires objects/ + refs/.
await fsp.mkdir(path.join(dir, '.git', 'objects'), { recursive: true });
await fsp.mkdir(path.join(dir, '.git', 'refs'), { recursive: true });
await fsp.writeFile(path.join(dir, '.git', 'HEAD'), headContent);
if (withReflog) {
await fsp.mkdir(path.join(dir, '.git', 'logs'), { recursive: true });
await fsp.writeFile(path.join(dir, '.git', 'logs', 'HEAD'), 'reflog\n');
}
return dir;
}
async function makeBareDir(): Promise<string> {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-nogit-'));
tmpRoots.push(dir);
return dir;
}
afterEach(async () => {
clearGitDirCache();
watchMock.mockReset();
for (const dir of tmpRoots.splice(0)) {
await fsp.rm(dir, { recursive: true, force: true });
}
});
describe('isValidRefName', () => {
it.each([
'main',
'feature/foo',
'release/2.0',
'v1.2.3',
'fix-123',
'a_b',
`long/${'x'.repeat(255)}`, // >255 total, but each component within the cap
])('accepts %s', (name) => {
expect(isValidRefName(name)).toBe(true);
});
it.each([
'',
'/foo',
'foo/',
'.hidden',
'foo.',
'foo.lock',
'a..b',
'a//b',
'a@{0}',
'foo bar',
'a\tb',
'foo^',
'foo~',
'foo:bar',
'foo?x',
'a*b',
'a[b',
'a\\b',
'../../evil',
'a\x9bb', // C1 control (CSI) — terminal escape injection
'a\u2028b', // Unicode line separator — status-line layout desync
'a'.repeat(256), // exceeds the length cap
'feature/.hidden', // a component starts with a dot
'test.lock/branch', // a component ends with .lock
'feature/bar./baz', // a component ends with a dot
'HEAD', // ambiguous with detached HEAD; git rejects it as a branch
'a\u202eb', // bidi override (RLO) — visual spoofing
'a\u200bb', // zero-width space — invisible spoofing
])('rejects %j', (name) => {
expect(isValidRefName(name)).toBe(false);
});
});
describe('isValidGitSha', () => {
it('accepts 40-hex (SHA-1) and 64-hex (SHA-256)', () => {
expect(isValidGitSha('a'.repeat(40))).toBe(true);
expect(isValidGitSha('f'.repeat(64))).toBe(true);
});
it('rejects non-hex, wrong length, and uppercase', () => {
expect(isValidGitSha('abc')).toBe(false);
expect(isValidGitSha('g'.repeat(40))).toBe(false);
expect(isValidGitSha('a'.repeat(41))).toBe(false);
expect(isValidGitSha('A'.repeat(40))).toBe(false);
});
});
describe('readGitHead', () => {
it('parses a branch', async () => {
const repo = await makeRepo('ref: refs/heads/main\n');
expect(await readGitHead(path.join(repo, '.git'))).toEqual({
type: 'branch',
name: 'main',
});
});
it('bounds the read and parses only the first line of a huge HEAD', async () => {
const repo = await makeRepo(`ref: refs/heads/main\n${'x'.repeat(100_000)}`);
// The 100 KB tail is never loaded; only the first line is parsed.
expect(await readGitHead(path.join(repo, '.git'))).toEqual({
type: 'branch',
name: 'main',
});
});
it('preserves nested branch names', async () => {
const repo = await makeRepo('ref: refs/heads/feature/foo\n');
expect(await readGitHead(path.join(repo, '.git'))).toEqual({
type: 'branch',
name: 'feature/foo',
});
});
it('returns the full sha when detached', async () => {
const sha = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678';
const repo = await makeRepo(`${sha}\n`);
expect(await readGitHead(path.join(repo, '.git'))).toEqual({
type: 'detached',
name: sha,
});
});
it('rejects HEAD pointing outside refs/heads', async () => {
const repo = await makeRepo('ref: refs/remotes/origin/main\n');
expect(await readGitHead(path.join(repo, '.git'))).toBeNull();
});
it('rejects an invalid ref name (path traversal)', async () => {
const repo = await makeRepo('ref: refs/heads/../../evil\n');
expect(await readGitHead(path.join(repo, '.git'))).toBeNull();
});
it('returns null for garbage HEAD content', async () => {
const repo = await makeRepo('not-a-valid-head\n');
expect(await readGitHead(path.join(repo, '.git'))).toBeNull();
});
it('returns null when HEAD is missing', async () => {
const dir = await makeBareDir();
await fsp.mkdir(path.join(dir, '.git'), { recursive: true });
expect(await readGitHead(path.join(dir, '.git'))).toBeNull();
});
it.skipIf(process.platform === 'win32')(
'refuses a symlinked HEAD (would follow out of the repo)',
async () => {
const repo = await makeRepo('ref: refs/heads/main\n');
const secret = path.join(await makeBareDir(), 'secret');
await fsp.writeFile(secret, 'ref: refs/heads/leaked\n');
const headPath = path.join(repo, '.git', 'HEAD');
await fsp.rm(headPath);
await fsp.symlink(secret, headPath);
expect(await readGitHead(path.join(repo, '.git'))).toBeNull();
},
);
});
describe('resolveBranchName', () => {
it('returns the branch name', async () => {
const repo = await makeRepo('ref: refs/heads/main\n');
expect(await resolveBranchName(repo)).toBe('main');
});
it('walks up from a subdirectory to the repo root', async () => {
const repo = await makeRepo('ref: refs/heads/main\n');
const sub = path.join(repo, 'a', 'b', 'c');
await fsp.mkdir(sub, { recursive: true });
expect(await resolveBranchName(sub)).toBe('main');
});
it('reads through a worktree gitdir pointer file', async () => {
const main = await makeRepo('ref: refs/heads/main\n');
const realGitDir = path.join(main, '.git', 'worktrees', 'wt1');
await fsp.mkdir(realGitDir, { recursive: true });
await fsp.writeFile(
path.join(realGitDir, 'HEAD'),
'ref: refs/heads/feature\n',
);
// A real worktree gitdir has no objects/ of its own; commondir points at
// the main gitdir (which has objects/ + refs/).
await fsp.writeFile(path.join(realGitDir, 'commondir'), '../..\n');
const worktree = await makeBareDir();
await fsp.writeFile(path.join(worktree, '.git'), `gitdir: ${realGitDir}\n`);
expect(await resolveBranchName(worktree)).toBe('feature');
});
it.skipIf(process.platform === 'win32')(
'refuses a symlinked commondir',
async () => {
const main = await makeRepo('ref: refs/heads/main\n');
const realGitDir = path.join(main, '.git', 'worktrees', 'wt1');
await fsp.mkdir(realGitDir, { recursive: true });
await fsp.writeFile(
path.join(realGitDir, 'HEAD'),
'ref: refs/heads/feature\n',
);
// commondir is a symlink, not a regular file → O_NOFOLLOW refuses it, so
// the gitdir can't be validated and no branch is surfaced.
await fsp.symlink('../..', path.join(realGitDir, 'commondir'));
const worktree = await makeBareDir();
await fsp.writeFile(
path.join(worktree, '.git'),
`gitdir: ${realGitDir}\n`,
);
expect(await resolveBranchName(worktree)).toBeUndefined();
},
);
it('returns a 7-char short hash when detached', async () => {
const sha = 'abcdef1234567890abcdef1234567890abcdef12';
const repo = await makeRepo(`${sha}\n`);
expect(await resolveBranchName(repo)).toBe('abcdef1');
});
it('returns undefined outside a repository', async () => {
const dir = await makeBareDir();
expect(await resolveBranchName(dir)).toBeUndefined();
});
it('does not cache a non-repo miss (detects git init mid-session)', async () => {
const dir = await makeBareDir();
expect(await resolveBranchName(dir)).toBeUndefined();
// The directory becomes a real repo mid-session. The earlier miss must not
// have been cached, so the branch resolves without clearing anything.
await fsp.mkdir(path.join(dir, '.git', 'objects'), { recursive: true });
await fsp.mkdir(path.join(dir, '.git', 'refs'), { recursive: true });
await fsp.writeFile(
path.join(dir, '.git', 'HEAD'),
'ref: refs/heads/main\n',
);
expect(await resolveBranchName(dir)).toBe('main');
});
it('caches a successful resolution; clearGitDirCache forces re-resolution', async () => {
const repo = await makeRepo('ref: refs/heads/main\n');
expect(await resolveBranchName(repo)).toBe('main');
// Remove the object store. The cached gitDir is still used (only HEAD is
// re-read), so the branch still resolves...
await fsp.rm(path.join(repo, '.git', 'objects'), {
recursive: true,
force: true,
});
expect(await resolveBranchName(repo)).toBe('main');
// ...until the cache is cleared and re-resolution rejects the storeless dir.
clearGitDirCache();
expect(await resolveBranchName(repo)).toBeUndefined();
});
it('rejects a .git gitdir pointer to a non-repo path', async () => {
// A crafted `.git` FILE pointing at an out-of-repo dir with a fake HEAD but
// no object store.
const decoy = await makeBareDir();
await fsp.writeFile(path.join(decoy, 'HEAD'), 'ref: refs/heads/pwned\n');
const project = await makeBareDir();
await fsp.writeFile(path.join(project, '.git'), `gitdir: ${decoy}\n`);
// Without the object-store check this would surface 'pwned'.
expect(await resolveBranchName(project)).toBeUndefined();
});
it('accepts a submodule gitdir with its own object store', async () => {
const main = await makeRepo('ref: refs/heads/main\n');
const modDir = path.join(main, '.git', 'modules', 'sub');
await fsp.mkdir(path.join(modDir, 'objects'), { recursive: true });
await fsp.mkdir(path.join(modDir, 'refs'), { recursive: true });
await fsp.writeFile(path.join(modDir, 'HEAD'), 'ref: refs/heads/submod\n');
const sub = await makeBareDir();
await fsp.writeFile(path.join(sub, '.git'), `gitdir: ${modDir}\n`);
expect(await resolveBranchName(sub)).toBe('submod');
});
it('rejects a .git/worktrees/* gitdir with no object store', async () => {
// Path shape alone must not be trusted: a crafted `.git/worktrees/fake`
// with only a HEAD (no objects/, no commondir) is rejected, like git.
const other = await makeBareDir();
const fake = path.join(other, '.git', 'worktrees', 'fake');
await fsp.mkdir(fake, { recursive: true });
await fsp.writeFile(path.join(fake, 'HEAD'), 'ref: refs/heads/pwned\n');
const project = await makeBareDir();
await fsp.writeFile(path.join(project, '.git'), `gitdir: ${fake}\n`);
expect(await resolveBranchName(project)).toBeUndefined();
});
it('rejects a fake .git directory with only a HEAD (no object store)', async () => {
const dir = await makeBareDir();
await fsp.mkdir(path.join(dir, '.git', 'logs'), { recursive: true });
await fsp.writeFile(
path.join(dir, '.git', 'HEAD'),
'ref: refs/heads/FAKE-DOTGIT\n',
);
await fsp.writeFile(path.join(dir, '.git', 'logs', 'HEAD'), 'x\n');
expect(await resolveBranchName(dir)).toBeUndefined();
});
});
describe('watchRepoBranch', () => {
// Mock fs.watch to return an observable FSWatcher: a close() spy plus
// captured change/error listeners we can fire from the test.
function installWatchMock() {
let listener: ((eventType: string) => void) | undefined;
let errorHandler: (() => void) | undefined;
const close = vi.fn();
watchMock.mockImplementation(
(_p: string, l: (eventType: string) => void) => {
listener = l;
return {
close,
on: (event: string, handler: () => void) => {
if (event === 'error') errorHandler = handler;
},
} as unknown as fs.FSWatcher;
},
);
return {
close,
fire: (eventType: string) => listener?.(eventType),
emitError: () => errorHandler?.(),
};
}
it('shares one watcher across subscribers and tears down on last unsubscribe', async () => {
const w = installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n');
const s1 = vi.fn();
const s2 = vi.fn();
const dispose1 = await watchRepoBranch(repo, s1);
const dispose2 = await watchRepoBranch(repo, s2);
expect(watchMock).toHaveBeenCalledTimes(1);
expect(watchMock).toHaveBeenCalledWith(
path.join(repo, '.git', 'logs', 'HEAD'),
expect.any(Function),
);
w.fire('change');
expect(s1).toHaveBeenCalledTimes(1);
expect(s2).toHaveBeenCalledTimes(1);
dispose1();
expect(w.close).not.toHaveBeenCalled();
w.fire('change');
expect(s1).toHaveBeenCalledTimes(1); // unsubscribed
expect(s2).toHaveBeenCalledTimes(2);
dispose2();
expect(w.close).toHaveBeenCalledTimes(1);
});
it('isolates a throwing subscriber from the others', async () => {
const w = installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n');
const bad = vi.fn(() => {
throw new Error('subscriber boom');
});
const good = vi.fn();
const disposeBad = await watchRepoBranch(repo, bad);
const disposeGood = await watchRepoBranch(repo, good);
// One subscriber throwing must not halt the fan-out or escape the watch.
expect(() => w.fire('change')).not.toThrow();
expect(bad).toHaveBeenCalledTimes(1);
expect(good).toHaveBeenCalledTimes(1);
disposeBad();
disposeGood();
});
it('refreshes on rename events but ignores unknown ones', async () => {
const w = installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n');
const sub = vi.fn();
const dispose = await watchRepoBranch(repo, sub);
w.fire('rename');
expect(sub).toHaveBeenCalledTimes(1);
w.fire('something-else');
expect(sub).toHaveBeenCalledTimes(1);
dispose();
});
it("tears down the watch on an FSWatcher 'error' instead of crashing", async () => {
const w = installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n');
const sub = vi.fn();
const dispose = await watchRepoBranch(repo, sub);
// An unhandled 'error' on an EventEmitter would throw; ours must not.
expect(() => w.emitError()).not.toThrow();
expect(w.close).toHaveBeenCalledTimes(1);
// The dead watch is gone: a stale event no longer reaches the subscriber,
// and disposing is a safe no-op.
w.fire('change');
expect(sub).not.toHaveBeenCalled();
expect(() => dispose()).not.toThrow();
});
it('does not watch without a reflog, but watches once it appears', async () => {
installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n', {
withReflog: false,
});
const dispose1 = await watchRepoBranch(repo, vi.fn());
expect(watchMock).not.toHaveBeenCalled();
expect(() => dispose1()).not.toThrow();
// The reflog appears (e.g. first commit); a later caller must be able to
// establish the watch — the earlier miss must not be cached.
await fsp.mkdir(path.join(repo, '.git', 'logs'), { recursive: true });
await fsp.writeFile(path.join(repo, '.git', 'logs', 'HEAD'), 'reflog\n');
const dispose2 = await watchRepoBranch(repo, vi.fn());
expect(watchMock).toHaveBeenCalledTimes(1);
dispose2();
});
it('dedupes concurrent subscribers into a single watcher', async () => {
installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n');
// Both calls race through getCachedGitDir + access() before either registers
// the entry, exercising the post-await re-check path.
const [dispose1, dispose2] = await Promise.all([
watchRepoBranch(repo, vi.fn()),
watchRepoBranch(repo, vi.fn()),
]);
expect(watchMock).toHaveBeenCalledTimes(1);
dispose1();
dispose2();
});
it('returns a no-op disposer outside a repository', async () => {
installWatchMock();
const dir = await makeBareDir();
const dispose = await watchRepoBranch(dir, vi.fn());
expect(watchMock).not.toHaveBeenCalled();
expect(() => dispose()).not.toThrow();
});
it('clearGitDirCache tears down active watchers (no fd leak)', async () => {
const w = installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n');
const dispose = await watchRepoBranch(repo, vi.fn());
expect(watchMock).toHaveBeenCalledTimes(1);
clearGitDirCache();
expect(w.close).toHaveBeenCalledTimes(1);
// The entry was dropped, so a later subscriber re-establishes the watch...
await watchRepoBranch(repo, vi.fn());
expect(watchMock).toHaveBeenCalledTimes(2);
// ...and the stale disposer is a safe no-op.
expect(() => dispose()).not.toThrow();
});
it('returns a no-op (never rejects) when fs.watch throws synchronously', async () => {
watchMock.mockImplementation(() => {
// TOCTOU: logs/HEAD vanished after access(), or a platform watch limit.
throw new Error('ENOENT');
});
const repo = await makeRepo('ref: refs/heads/main\n');
// Must resolve to a disposer, not reject (which would surface as unhandled).
const dispose = await watchRepoBranch(repo, vi.fn());
expect(() => dispose()).not.toThrow();
});
it.skipIf(process.platform === 'win32')(
'refuses a symlinked reflog (no out-of-repo watch)',
async () => {
installWatchMock();
const repo = await makeRepo('ref: refs/heads/main\n', {
withReflog: false,
});
const target = path.join(await makeBareDir(), 'evil-log');
await fsp.writeFile(target, 'x\n');
await fsp.mkdir(path.join(repo, '.git', 'logs'), { recursive: true });
await fsp.symlink(target, path.join(repo, '.git', 'logs', 'HEAD'));
const dispose = await watchRepoBranch(repo, vi.fn());
expect(watchMock).not.toHaveBeenCalled();
expect(() => dispose()).not.toThrow();
},
);
});

View file

@ -0,0 +1,375 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';
import * as path from 'node:path';
import { resolveGitDir } from './gitDiff.js';
import { createDebugLogger } from './debugLogger.js';
/**
* Direct-read git helpers: resolve the current branch / HEAD by reading the
* `.git` metadata files instead of spawning `git`. A plain file read is
* microseconds versus milliseconds for a `git` subprocess on a hot path (the
* status line re-reads the branch on render), and it cannot hang on a large
* repository.
*
* Scope: this only covers reading the current branch / HEAD. Heavier git
* operations (diff, log, merge-base, remotes) still belong on the `git` binary.
*
* The `.git` directory itself is resolved by {@link resolveGitDir} (shared with
* gitDiff it walks up to the repo root and follows a worktree `gitdir:`
* pointer); here we add a small cache plus HEAD parsing and a reflog watcher.
*/
const SHORT_SHA_LENGTH = 7;
// Failure returns are intentionally silent for the common cases (not a repo, no
// HEAD): a status-line display must not log-spam. Only the unexpected paths
// (watcher errors) emit a debug line, consistent with the other utils here.
const debug = createDebugLogger('gitDirect');
// Bound the HEAD read: it is one short line, never megabytes.
const MAX_HEAD_BYTES = 4096;
// git's per-component length cap (a filesystem limit). Applied per
// slash-separated component, NOT to the whole ref — a valid ref can be longer
// than one component's limit.
const MAX_REF_COMPONENT_LENGTH = 255;
// Control chars (C0 0x00-0x1f, space 0x20, DEL 0x7f, C1 0x80-0x9f), zero-width
// (U+200B-U+200D, U+FEFF) and bidi-override (U+202A-U+202E, U+2066-U+2069)
// characters, the Unicode line separators, and the characters git disallows in a
// ref name: ~ ^ : ? * [ and backslash. With `git` no longer vetting the value, a
// hand-written HEAD could otherwise smuggle terminal escapes (CSI/OSC), bidi or
// zero-width spoofing, or line separators into the status-line branch name.
// The class is long but must stay on one line so the eslint-disable applies.
// prettier-ignore
// eslint-disable-next-line no-control-regex
const INVALID_REF_CHARS = /[\x00-\x20\x7f-\x9f\u200b-\u200d\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff~^:?*[\\]/;
/**
* Validate a branch/ref name well enough to trust it as a display value and,
* defensively, before anything downstream might use it as a path segment. This
* is a sufficient subset of git's `check-ref-format` rules: it rejects empty
* names, leading/trailing slashes, leading/trailing dots, `..` (path
* traversal), `@{`, `.lock` suffixes, and the control/space/special characters
* git itself forbids.
*/
export function isValidRefName(name: string): boolean {
// 'HEAD' is ambiguous with a detached HEAD and git rejects it as a branch name.
if (!name || name === 'HEAD') return false;
if (name.startsWith('/') || name.endsWith('/')) return false;
if (name.startsWith('.') || name.endsWith('.')) return false;
if (name.endsWith('.lock')) return false;
if (name.includes('..') || name.includes('//')) return false;
// git applies the dot/.lock rules per slash-separated component, not just to
// the whole name: no component may start or end with a dot, or end with `.lock`.
if (name.includes('/.') || name.includes('./') || name.includes('.lock/')) {
return false;
}
if (name.includes('@{')) return false;
if (INVALID_REF_CHARS.test(name)) return false;
// Length limit is per slash-separated component (a filesystem cap), not the
// whole ref — a deeply nested but valid ref can exceed any single component.
if (name.split('/').some((c) => c.length > MAX_REF_COMPONENT_LENGTH)) {
return false;
}
return true;
}
/** A SHA-1 (40 hex) or SHA-256 (64 hex) object id. */
export function isValidGitSha(value: string): boolean {
return /^[0-9a-f]{40}$/.test(value) || /^[0-9a-f]{64}$/.test(value);
}
// resolveGitDir walks ancestors and parses the worktree gitdir pointer on every
// call; a successful result is stable for a given cwd within a session, so it
// is cached. A miss (non-repo) is NOT cached — the directory may become a repo
// mid-session (git init / clone), so it is always re-checked. HEAD is never
// cached either: it is re-read every call so a branch switch shows at once.
const gitDirCache = new Map<string, string>();
/**
* Clear all cached gitDir state (e.g. after a repo is created/removed). Both
* the resolution cache and the shared reflog watchers are gitDir-keyed, so this
* also tears the watchers down clearing only half would leak their fds.
*/
export function clearGitDirCache(): void {
closeAllRepoBranchWatches();
gitDirCache.clear();
}
/** True if `p` exists and is a directory. */
async function isDir(p: string): Promise<boolean> {
try {
return (await fsPromises.stat(p)).isDirectory();
} catch {
return false;
}
}
/** A git object store: `objects/` + `refs/` (standalone repo, or a worktree's common dir). */
async function hasGitStore(dir: string): Promise<boolean> {
const [objects, refs] = await Promise.all([
isDir(path.join(dir, 'objects')),
isDir(path.join(dir, 'refs')),
]);
return objects && refs;
}
/**
* Read the first line of a file with O_NOFOLLOW (refuse symlinks atomically) and
* a bounded prefix (never load a pathologically large file). Returns null on any
* failure. Shared by the HEAD and commondir reads.
*/
async function readFirstLineNoFollow(filePath: string): Promise<string | null> {
let fh: fsPromises.FileHandle;
try {
// O_NOFOLLOW refuses a symlink (ELOOP). O_NONBLOCK never blocks on a FIFO —
// a crafted `.git/HEAD` or commondir named pipe would otherwise hang here
// and pin a libuv thread-pool slot. Both are no-ops on a regular file.
fh = await fsPromises.open(
filePath,
(fs.constants?.O_RDONLY ?? 0) |
(fs.constants?.O_NOFOLLOW ?? 0) |
(fs.constants?.O_NONBLOCK ?? 0),
);
} catch {
return null;
}
try {
const buf = Buffer.allocUnsafe(MAX_HEAD_BYTES);
const { bytesRead } = await fh.read(buf, 0, MAX_HEAD_BYTES, 0);
return buf.toString('utf-8', 0, bytesRead).split('\n', 1)[0] ?? '';
} catch {
return null;
} finally {
// Per the codebase convention a close error must not escape — the docstring
// promises null on any failure.
await fh.close().catch(() => {});
}
}
/**
* Verify `gitDir` is a real git directory, the way git itself decides so the
* automatic, zero-click display read can't be tricked where `git rev-parse`
* couldn't.
*
* Security: `resolveGitDir` follows a `.git`-FILE `gitdir:` pointer verbatim, so
* a crafted project could aim it at an arbitrary path or stand up a fake `.git`
* with just a HEAD; the old `git rev-parse` path refused both with "not a git
* repository" (exit 128). git treats a directory as a gitdir only if the object
* store is present: a standalone repo has `objects/` + `refs/` directly; a
* linked worktree / submodule gitdir instead carries a `commondir` file
* pointing at the main gitdir that does. Incomplete forgeries (a lone HEAD, or a
* path-shaped `.git/worktrees/x` containing only a HEAD) have neither and are
* rejected. This matches git's own validity check rather than a path shape,
* which a `gitdir:` pointer can fake.
*/
async function isRealGitDir(gitDir: string): Promise<boolean> {
if (await hasGitStore(gitDir)) return true;
// A worktree/submodule gitdir has no object store of its own; commondir points
// at the main gitdir that does. Read it bounded + O_NOFOLLOW, like HEAD, so a
// crafted oversized or symlinked commondir can't OOM or redirect us.
const rel = await readFirstLineNoFollow(path.join(gitDir, 'commondir'));
if (!rel) return false;
return hasGitStore(path.resolve(gitDir, rel.trim()));
}
async function resolveTrustedGitDir(cwd: string): Promise<string | null> {
const gitDir = await resolveGitDir(cwd);
if (!gitDir) return null;
return (await isRealGitDir(gitDir)) ? gitDir : null;
}
async function getCachedGitDir(cwd: string): Promise<string | null> {
const key = path.resolve(cwd);
const cached = gitDirCache.get(key);
if (cached !== undefined) return cached;
const gitDir = await resolveTrustedGitDir(key);
// Only cache a successful resolution. A null (non-repo) result may become
// valid later (git init / clone mid-session), so always re-check.
if (gitDir !== null) gitDirCache.set(key, gitDir);
return gitDir;
}
/** Parsed HEAD: a branch name, or a detached commit (full object id). */
export interface GitHead {
type: 'branch' | 'detached';
/** Branch name when `type === 'branch'`, otherwise the full commit sha. */
name: string;
}
/**
* Read and parse `<gitDir>/HEAD` directly. Returns null when HEAD is missing,
* unreadable, or unrecognized.
*
* The branch name is taken verbatim from the `ref: refs/heads/<branch>` line,
* so packed-refs never need to be consulted. A detached HEAD holds the raw
* object id, which is returned as-is (callers shorten it for display).
*/
export async function readGitHead(gitDir: string): Promise<GitHead | null> {
// O_NOFOLLOW refuses a symlinked HEAD atomically (no lstat→read TOCTOU); the
// bounded read parses only the first line so a huge file isn't loaded.
const firstLine = await readFirstLineNoFollow(path.join(gitDir, 'HEAD'));
if (firstLine === null) return null;
const content = firstLine.trim();
if (content.startsWith('ref:')) {
const ref = content.slice(4).trim();
if (!ref.startsWith('refs/heads/')) return null;
const name = ref.slice('refs/heads/'.length);
if (!isValidRefName(name)) return null;
return { type: 'branch', name };
}
// Detached HEAD: the file holds a raw (SHA-1 or SHA-256) object id.
if (isValidGitSha(content)) {
return { type: 'detached', name: content };
}
return null;
}
/**
* Resolve a display string for the current branch of `cwd`: the branch name,
* or a short commit hash when detached. Returns undefined when `cwd` is not in
* a git repository or HEAD can't be read.
*/
export async function resolveBranchName(
cwd: string,
): Promise<string | undefined> {
const gitDir = await getCachedGitDir(cwd);
if (!gitDir) return undefined;
const head = await readGitHead(gitDir);
if (!head) return undefined;
return head.type === 'branch'
? head.name
: head.name.slice(0, SHORT_SHA_LENGTH);
}
interface RepoBranchWatch {
watcher: fs.FSWatcher;
subscribers: Set<() => void>;
}
// Keyed by resolved gitDir so that multiple subscribers on the same repository
// share a single fs.watch.
const repoBranchWatches = new Map<string, RepoBranchWatch>();
/** Close every shared reflog watcher and drop the entries. */
function closeAllRepoBranchWatches(): void {
for (const entry of repoBranchWatches.values()) {
try {
entry.watcher.close();
} catch {
// already closed
}
}
repoBranchWatches.clear();
}
/**
* Subscribe to branch changes for `cwd`'s repository.
*
* Multiple subscribers on the same git dir share one `fs.watch` on
* `<gitDir>/logs/HEAD` (the reflog, which moves on branch switch / commit /
* reset). The returned disposer removes this subscriber and tears the watch
* down once the last subscriber leaves. If the repo can't be resolved or has
* no reflog yet, the disposer is a harmless no-op.
*/
export async function watchRepoBranch(
cwd: string,
onChange: () => void,
): Promise<() => void> {
const gitDir = await getCachedGitDir(cwd);
if (!gitDir) return () => {};
let entry = repoBranchWatches.get(gitDir);
if (!entry) {
const logsHeadPath = path.join(gitDir, 'logs', 'HEAD');
try {
await fsPromises.access(logsHeadPath, fs.constants?.F_OK ?? 0);
// Refuse a symlinked reflog: we'd otherwise place a persistent watch on a
// file outside the repo. A residual lstat→watch TOCTOU remains (fs.watch
// has no O_NOFOLLOW form), but the watch only ever fires readGitHead —
// which opens HEAD with O_NOFOLLOW — and never reads logs/HEAD's content.
if ((await fsPromises.lstat(logsHeadPath)).isSymbolicLink()) {
return () => {};
}
} catch {
// No reflog yet (unborn repo) or unreadable. Return a no-op without
// caching a watcher-less entry, so a later caller can establish the
// watch once the reflog appears (e.g. after the first commit).
return () => {};
}
// A concurrent caller may have registered the entry while we awaited
// access(); this post-await block runs atomically w.r.t. other microtasks,
// so re-checking here guarantees a single watcher per gitDir.
const existing = repoBranchWatches.get(gitDir);
if (existing) {
entry = existing;
} else {
let watcher: fs.FSWatcher;
try {
watcher = fs.watch(logsHeadPath, (eventType: string) => {
if (eventType === 'change' || eventType === 'rename') {
repoBranchWatches.get(gitDir)?.subscribers.forEach((cb) => {
// Isolate subscriber failures: in this shared-watcher fan-out one
// throwing callback must not halt the others or crash the watch.
try {
cb();
} catch {
// ignore a subscriber's own error
}
});
}
});
} catch (err) {
// fs.watch throws synchronously if logs/HEAD vanished after access()
// (TOCTOU: git gc / reflog expire / worktree removal) or a platform
// watch limit (ENOSPC) is hit. Fall back to a no-op rather than
// rejecting (which the hook's bare `void init()` would surface as an
// unhandled rejection).
debug.warn(`failed to watch reflog for ${gitDir}: ${err}`);
return () => {};
}
// fs.FSWatcher is an EventEmitter: an unhandled 'error' (reflog removed
// by `git gc` / `reflog expire`, worktree removal, inode change, or a
// platform watch limit) would crash the process. Tear the watch down
// instead — subscribers simply stop auto-refreshing.
watcher.on('error', (err: Error) => {
debug.warn(
`reflog watcher error for ${gitDir}; branch will no longer auto-update: ${err?.message}`,
);
const current = repoBranchWatches.get(gitDir);
if (current?.watcher === watcher) {
try {
watcher.close();
} catch {
// already closed
}
repoBranchWatches.delete(gitDir);
}
});
entry = { watcher, subscribers: new Set() };
repoBranchWatches.set(gitDir, entry);
}
}
entry.subscribers.add(onChange);
let disposed = false;
return () => {
if (disposed) return;
disposed = true;
const e = repoBranchWatches.get(gitDir);
if (!e) return;
e.subscribers.delete(onChange);
if (e.subscribers.size === 0) {
e.watcher.close();
repoBranchWatches.delete(gitDir);
}
};
}