From 6b2f800abd948e0e5c4a26e2fdfa0c0c2e033308 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 21 Jun 2026 07:09:19 +0800 Subject: [PATCH] perf(core): read current git branch directly from .git instead of spawning git (#5432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `/.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. --- .../cli/src/ui/hooks/useGitBranchName.test.ts | 394 +++++-------- packages/cli/src/ui/hooks/useGitBranchName.ts | 82 ++- packages/core/src/index.ts | 1 + packages/core/src/utils/gitDirect.test.ts | 520 ++++++++++++++++++ packages/core/src/utils/gitDirect.ts | 375 +++++++++++++ 5 files changed, 1066 insertions(+), 306 deletions(-) create mode 100644 packages/core/src/utils/gitDirect.test.ts create mode 100644 packages/core/src/utils/gitDirect.ts diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.ts b/packages/cli/src/ui/hooks/useGitBranchName.test.ts index bf602ebf6b..05f429fef9 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.test.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.ts @@ -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('memfs'); - return { - ...memfs.fs, - default: memfs.fs, - }; -}); - -vi.mock('node:fs/promises', async () => { - const memfs = await vi.importActual('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; - }, - ); - (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); - - (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((resolve) => { - resolveAccess = resolve; - }), - ); - - const closeMock = vi.fn(); - const watchMock = vi.spyOn(fs, 'watch').mockReturnValue({ - close: closeMock, - } as unknown as ReturnType); - - (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'); }); }); diff --git a/packages/cli/src/ui/hooks/useGitBranchName.ts b/packages/cli/src/ui/hooks/useGitBranchName.ts index 4583f611d2..0ca8a70d09 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.ts @@ -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(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]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e523222664..847b970eae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/utils/gitDirect.test.ts b/packages/core/src/utils/gitDirect.test.ts new file mode 100644 index 0000000000..c62a192924 --- /dev/null +++ b/packages/core/src/utils/gitDirect.test.ts @@ -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(); + 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 { + 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 { + 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(); + }, + ); +}); diff --git a/packages/core/src/utils/gitDirect.ts b/packages/core/src/utils/gitDirect.ts new file mode 100644 index 0000000000..1075045761 --- /dev/null +++ b/packages/core/src/utils/gitDirect.ts @@ -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(); + +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + const gitDir = await resolveGitDir(cwd); + if (!gitDir) return null; + return (await isRealGitDir(gitDir)) ? gitDir : null; +} + +async function getCachedGitDir(cwd: string): Promise { + 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 `/HEAD` directly. Returns null when HEAD is missing, + * unreadable, or unrecognized. + * + * The branch name is taken verbatim from the `ref: refs/heads/` 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 { + // 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 { + 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(); + +/** 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 + * `/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); + } + }; +}