mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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
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:
parent
8553013ee8
commit
6b2f800abd
5 changed files with 1066 additions and 306 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
520
packages/core/src/utils/gitDirect.test.ts
Normal file
520
packages/core/src/utils/gitDirect.test.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
375
packages/core/src/utils/gitDirect.ts
Normal file
375
packages/core/src/utils/gitDirect.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue