mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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.
520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
/**
|
|
* @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();
|
|
},
|
|
);
|
|
});
|