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_PHASE_SETUP_READY = 'setup-ready' as const;
|
||||||
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
|
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
|
||||||
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
|
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
|
||||||
|
const GIT_NULL_REF = '0'.repeat(40);
|
||||||
|
|
||||||
const toBootstrapStateKey = (directory: string): string => {
|
const toBootstrapStateKey = (directory: string): string => {
|
||||||
const normalized = normalizeDirectoryPath(directory);
|
const normalized = normalizeDirectoryPath(directory);
|
||||||
@@ -1154,6 +1155,62 @@ const populateWorktreeWithLockRecovery = async (directory: string): Promise<void
|
|||||||
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
|
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 ensureOpenCodeProjectId = async (primaryWorktree: string): Promise<string> => {
|
||||||
const gitDir = path.join(primaryWorktree, '.git');
|
const gitDir = path.join(primaryWorktree, '.git');
|
||||||
const idFile = path.join(gitDir, 'opencode');
|
const idFile = path.join(gitDir, 'opencode');
|
||||||
@@ -1478,6 +1535,7 @@ const queueWorktreeBootstrap = (args: {
|
|||||||
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
|
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await populateWorktreeWithLockRecovery(directory);
|
await populateWorktreeWithLockRecovery(directory);
|
||||||
|
await runPostCheckoutHook(directory);
|
||||||
if (setUpstream) {
|
if (setUpstream) {
|
||||||
await applyUpstreamConfiguration({
|
await applyUpstreamConfiguration({
|
||||||
primaryWorktree,
|
primaryWorktree,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ The following functions are exported and used by the web server:
|
|||||||
### Worktree Operations
|
### Worktree Operations
|
||||||
- `getWorktrees(directory)`: List all git worktrees for a repository.
|
- `getWorktrees(directory)`: List all git worktrees for a repository.
|
||||||
- `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
|
- `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).
|
- `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
|
||||||
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
|
- `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.
|
- `resolveCandidateDirectory(...)`: Generate unique worktree directory candidates.
|
||||||
- `resolveBranchForExistingMode(...)`: Resolve branch for existing-mode worktree creation.
|
- `resolveBranchForExistingMode(...)`: Resolve branch for existing-mode worktree creation.
|
||||||
- `applyUpstreamConfiguration(...)`: Set upstream tracking for new branches.
|
- `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.
|
- And various other internal helpers for Git command execution and parsing.
|
||||||
|
|
||||||
## Response Contracts
|
## Response Contracts
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const WORKTREE_BOOTSTRAP_FAILED = 'failed';
|
|||||||
const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created';
|
const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created';
|
||||||
const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready';
|
const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready';
|
||||||
const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-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_RETRY_DELAY_MS = 250;
|
||||||
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
|
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');
|
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 derivePrimaryWorktreeRootFromGitDir = (gitDir) => {
|
||||||
const normalized = normalizePath(gitDir);
|
const normalized = normalizePath(gitDir);
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
@@ -1719,6 +1776,7 @@ const queueWorktreeBootstrap = (args) => {
|
|||||||
const task = new Promise((resolve) => setTimeout(resolve, 0))
|
const task = new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await populateWorktreeWithLockRecovery(directory);
|
await populateWorktreeWithLockRecovery(directory);
|
||||||
|
await runPostCheckoutHook(directory);
|
||||||
if (setUpstream) {
|
if (setUpstream) {
|
||||||
await applyUpstreamConfiguration({
|
await applyUpstreamConfiguration({
|
||||||
primaryWorktree,
|
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 () => {
|
it('waits for active bootstrap work before removing a worktree', async () => {
|
||||||
if (!canRunGit()) return;
|
if (!canRunGit()) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user