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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user