From 25780c92035c9fb2e2641ee04b544fc4f7ba9941 Mon Sep 17 00:00:00 2001 From: Bruno Fantauzzi Date: Thu, 6 Aug 2026 18:55:28 -0300 Subject: [PATCH] fix(git): run post-checkout hook after worktree creation bootstrap (#2721) Worktrees are created with `git worktree add --no-checkout` and populated with `git reset --hard`, neither of which runs git's post-checkout hook. Invoke the hook explicitly after population with git's standard arguments (null ref previous HEAD, checked-out HEAD, flag 1) and the worktree as cwd, mirroring `git worktree add` without --no-checkout. A missing or non-executable hook is skipped (matching git) and a failing hook is logged as a warning, never failing worktree creation or session bootstrap. Applied to both the web server git service and the VS Code runtime's git service. --- packages/vscode/src/gitService.ts | 58 ++++++++ packages/web/server/lib/git/DOCUMENTATION.md | 3 +- packages/web/server/lib/git/service.js | 58 ++++++++ packages/web/server/lib/git/service.test.js | 148 +++++++++++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 35f24347..7f2a67d6 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -33,6 +33,7 @@ const WORKTREE_PHASE_GIT_READY = 'git-ready' as const; const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const; const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; +const GIT_NULL_REF = '0'.repeat(40); const toBootstrapStateKey = (directory: string): string => { const normalized = normalizeDirectoryPath(directory); @@ -1154,6 +1155,62 @@ const populateWorktreeWithLockRecovery = async (directory: string): Promise => { + let hookDirectory: string | null = null; + try { + const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'hooks']); + if (!result.success) return; + hookDirectory = normalizeDirectoryPath(String(result.stdout || '').trim()); + } catch { + return; + } + if (!hookDirectory) return; + + const hookPath = path.join(hookDirectory, 'post-checkout'); + try { + const stat = await fs.promises.stat(hookPath); + if (!stat.isFile()) return; + if (process.platform !== 'win32') { + await fs.promises.access(hookPath, fs.constants.X_OK); + } + } catch { + // Missing or non-executable hooks are skipped, matching git. + return; + } + + const [headResult, gitDirResult] = await Promise.all([ + runGitCommand(directory, ['rev-parse', 'HEAD']), + runGitCommand(directory, ['rev-parse', '--absolute-git-dir']), + ]); + if (!headResult.success || !gitDirResult.success) return; + const head = String(headResult.stdout || '').trim(); + const gitDir = String(gitDirResult.stdout || '').trim(); + if (!head || !gitDir) return; + + try { + await execFileAsync(hookPath, [GIT_NULL_REF, head, '1'], { + cwd: directory, + env: { + ...(await buildGitEnv()), + GIT_DIR: gitDir, + GIT_WORK_TREE: path.resolve(directory), + }, + windowsHide: true, + }); + } catch (error) { + // A failing hook must not fail worktree creation or session bootstrap: + // warn and continue. + console.warn('[GitService] post-checkout hook failed in worktree:', error instanceof Error ? error.message : String(error)); + } +}; + const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise => { const gitDir = path.join(primaryWorktree, '.git'); const idFile = path.join(gitDir, 'opencode'); @@ -1478,6 +1535,7 @@ const queueWorktreeBootstrap = (args: { const task = new Promise((resolve) => setTimeout(resolve, 0)) .then(async () => { await populateWorktreeWithLockRecovery(directory); + await runPostCheckoutHook(directory); if (setUpstream) { await applyUpstreamConfiguration({ primaryWorktree, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 98368370..ec45bc5d 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -48,7 +48,7 @@ The following functions are exported and used by the web server: ### Worktree Operations - `getWorktrees(directory)`: List all git worktrees for a repository. - `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config). -- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). +- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap. - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch). - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary). @@ -95,6 +95,7 @@ The following functions are internal helpers used by exported functions: - `resolveCandidateDirectory(...)`: Generate unique worktree directory candidates. - `resolveBranchForExistingMode(...)`: Resolve branch for existing-mode worktree creation. - `applyUpstreamConfiguration(...)`: Set upstream tracking for new branches. +- `runPostCheckoutHook(directory)`: Invoke the worktree's `post-checkout` hook after population, because `git worktree add --no-checkout` and the bootstrap's `git reset --hard` never run git hooks. Runs with git's standard arguments and the worktree as cwd; skips missing/non-executable hooks and never throws on hook failure. - And various other internal helpers for Git command execution and parsing. ## Response Contracts diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index d058dd91..bd790acb 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -25,6 +25,7 @@ const WORKTREE_BOOTSTRAP_FAILED = 'failed'; const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created'; const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready'; const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-ready'; +const GIT_NULL_REF = '0'.repeat(40); const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; @@ -1028,6 +1029,62 @@ export const populateWorktreeWithLockRecovery = async (directory) => { await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree'); }; +// Worktrees are created with `git worktree add --no-checkout` and populated +// with `git reset --hard`, neither of which runs git's post-checkout hook — +// git only runs it for checkouts, clone, and worktree add *without* +// --no-checkout. Invoke the hook explicitly after population to restore git's +// checkout semantics: git passes the previous HEAD (null ref for a brand-new +// worktree), the new HEAD, and flag 1 for a branch checkout, and runs the hook +// from the worktree top-level. +const runPostCheckoutHook = async (directory) => { + let hookDirectory = null; + try { + const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'hooks']); + if (!result.success) return; + hookDirectory = normalizeDirectoryPath(String(result.stdout || '').trim()); + } catch { + return; + } + if (!hookDirectory) return; + + const hookPath = path.join(hookDirectory, 'post-checkout'); + try { + const stat = await fsp.stat(hookPath); + if (!stat.isFile()) return; + if (process.platform !== 'win32') { + await fsp.access(hookPath, fs.constants.X_OK); + } + } catch { + // Missing or non-executable hooks are skipped, matching git. + return; + } + + const [headResult, gitDirResult] = await Promise.all([ + runGitCommand(directory, ['rev-parse', 'HEAD']), + runGitCommand(directory, ['rev-parse', '--absolute-git-dir']), + ]); + if (!headResult.success || !gitDirResult.success) return; + const head = String(headResult.stdout || '').trim(); + const gitDir = String(gitDirResult.stdout || '').trim(); + if (!head || !gitDir) return; + + try { + await execFileAsync(hookPath, [GIT_NULL_REF, head, '1'], { + cwd: directory, + env: { + ...(await buildGitEnv()), + GIT_DIR: gitDir, + GIT_WORK_TREE: path.resolve(directory), + }, + windowsHide: true, + }); + } catch (error) { + // A failing hook must not fail worktree creation or session bootstrap: + // warn and continue. + console.warn(`[GitService] post-checkout hook failed in worktree ${directory}: ${error instanceof Error ? error.message : String(error)}`); + } +}; + const derivePrimaryWorktreeRootFromGitDir = (gitDir) => { const normalized = normalizePath(gitDir); if (!normalized) return null; @@ -1719,6 +1776,7 @@ const queueWorktreeBootstrap = (args) => { const task = new Promise((resolve) => setTimeout(resolve, 0)) .then(async () => { await populateWorktreeWithLockRecovery(directory); + await runPostCheckoutHook(directory); if (setUpstream) { await applyUpstreamConfiguration({ primaryWorktree, diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 7b06d022..3127fe92 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -537,6 +537,154 @@ describe('createWorktree', () => { } }); + const installPostCheckoutHook = (repo, script, executable = true) => { + const hookPath = path.join(repo, '.git', 'hooks', 'post-checkout'); + fs.writeFileSync(hookPath, script); + if (executable) { + fs.chmodSync(hookPath, 0o755); + } + return hookPath; + }; + + it('runs the post-checkout hook after populating a created worktree', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + const head = runGit(repo, ['rev-parse', 'HEAD']).trim(); + + const hookLog = path.join(dataHome, 'post-checkout.log'); + installPostCheckoutHook( + repo, + `#!/bin/sh\nprintf '%s|%s|%s|%s' "$1" "$2" "$3" "$(pwd -P)" > ${JSON.stringify(hookLog)}\n`, + ); + + const created = await createWorktree(repo, { + mode: 'new', + worktreeName: 'hook-test', + branchName: 'openchamber/hook-test', + returnAfterDirectoryCreated: true, + }); + + await expect.poll(() => { + try { + return fs.readFileSync(hookLog, 'utf8'); + } catch { + return ''; + } + }, { timeout: 5_000 }).not.toBe(''); + + const [previousHead, newHead, flag, cwd] = fs.readFileSync(hookLog, 'utf8').split('|'); + expect(previousHead).toBe('0000000000000000000000000000000000000000'); + expect(newHead).toBe(head); + expect(flag).toBe('1'); + expect(cwd).toBe(fs.realpathSync(created.path)); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); + + it('skips a non-executable post-checkout hook', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + + const hookLog = path.join(dataHome, 'post-checkout-skipped.log'); + installPostCheckoutHook( + repo, + `#!/bin/sh\nprintf 'ran' > ${JSON.stringify(hookLog)}\n`, + false, + ); + + const created = await createWorktree(repo, { + mode: 'new', + worktreeName: 'hook-skip-test', + branchName: 'openchamber/hook-skip-test', + returnAfterDirectoryCreated: true, + }); + + await expect.poll( + async () => (await getWorktreeBootstrapStatus(created.path)).status, + { timeout: 5_000 }, + ).toBe('ready'); + expect(fs.existsSync(hookLog)).toBe(false); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); + + it('does not fail worktree bootstrap when the post-checkout hook fails', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + + const hookLog = path.join(dataHome, 'post-checkout-failed.log'); + installPostCheckoutHook( + repo, + `#!/bin/sh\nprintf 'ran' > ${JSON.stringify(hookLog)}\nexit 1\n`, + ); + + const created = await createWorktree(repo, { + mode: 'new', + worktreeName: 'hook-fail-test', + branchName: 'openchamber/hook-fail-test', + returnAfterDirectoryCreated: true, + }); + + await expect.poll( + async () => (await getWorktreeBootstrapStatus(created.path)).status, + { timeout: 5_000 }, + ).toBe('ready'); + expect(fs.readFileSync(hookLog, 'utf8')).toBe('ran'); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); + it('waits for active bootstrap work before removing a worktree', async () => { if (!canRunGit()) return;