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.
This commit is contained in:
@@ -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<void
|
||||
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: string): Promise<void> => {
|
||||
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<string> => {
|
||||
const gitDir = path.join(primaryWorktree, '.git');
|
||||
const idFile = path.join(gitDir, 'opencode');
|
||||
@@ -1478,6 +1535,7 @@ const queueWorktreeBootstrap = (args: {
|
||||
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
.then(async () => {
|
||||
await populateWorktreeWithLockRecovery(directory);
|
||||
await runPostCheckoutHook(directory);
|
||||
if (setUpstream) {
|
||||
await applyUpstreamConfiguration({
|
||||
primaryWorktree,
|
||||
|
||||
Reference in New Issue
Block a user