fix: recover worktree bootstrap from stale index.lock

Retries transient index.lock conflicts during worktree population
Removes unchanged stale locks automatically and continues bootstrap
Adds coverage for stale lock recovery
This commit is contained in:
Bohdan Triapitsyn
2026-07-18 22:06:25 +03:00
parent fee6a55bab
commit e9d93a6744
4 changed files with 162 additions and 2 deletions
+69 -1
View File
@@ -19,6 +19,8 @@ const worktreeBootstrapState = new Map<string, { status: 'pending' | 'ready' | '
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const;
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
const toBootstrapStateKey = (directory: string): string => {
const normalized = normalizeDirectoryPath(directory);
@@ -1025,6 +1027,72 @@ const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage
return result;
};
const wait = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
const isIndexLockError = (result: GitCommandResult): boolean => {
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
};
const getWorktreeIndexLockPath = async (directory: string): Promise<string | null> => {
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
if (!result.success) {
return null;
}
const value = String(result.stdout || '').trim();
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
};
const getFileIdentity = async (filePath: string): Promise<string | null> => {
try {
const stat = await fs.promises.stat(filePath);
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return null;
}
throw error;
}
};
const populateWorktreeWithLockRecovery = async (directory: string): Promise<void> => {
let result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(result.message || 'Failed to populate worktree');
}
await fs.promises.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
});
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
};
const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise<string> => {
const gitDir = path.join(primaryWorktree, '.git');
const idFile = path.join(gitDir, 'opencode');
@@ -1419,7 +1487,7 @@ const queueWorktreeBootstrap = (args: {
} = args;
setTimeout(() => {
const run = async () => {
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
await populateWorktreeWithLockRecovery(directory);
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
@@ -122,6 +122,7 @@ The following functions are internal helpers used by exported functions:
- `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues.
- `bootstrapStatus`: Background setup status, with `pending`, `ready`, or `failed`.
- Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup.
- Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures.
### Log Response
- `all`: Array of commit objects with hash, date, message, author info, stats.
+69 -1
View File
@@ -21,6 +21,8 @@ const gitIndexMutationQueues = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
const WORKTREE_BOOTSTRAP_READY = 'ready';
const WORKTREE_BOOTSTRAP_FAILED = 'failed';
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
const toBootstrapStateKey = (directory) => {
const normalized = normalizeDirectoryPath(directory);
@@ -885,6 +887,72 @@ const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => {
return result;
};
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const isIndexLockError = (result) => {
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
};
const getWorktreeIndexLockPath = async (directory) => {
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
if (!result.success) {
return null;
}
const value = String(result.stdout || '').trim();
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
};
const getFileIdentity = async (filePath) => {
try {
const stat = await fsp.stat(filePath);
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
} catch (error) {
if (error?.code === 'ENOENT') {
return null;
}
throw error;
}
};
export const populateWorktreeWithLockRecovery = async (directory) => {
let result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(result.message || 'Failed to populate worktree');
}
await fsp.unlink(lockPath).catch((error) => {
if (error?.code !== 'ENOENT') {
throw error;
}
});
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
};
const derivePrimaryWorktreeRootFromGitDir = (gitDir) => {
const normalized = normalizePath(gitDir);
if (!normalized) return null;
@@ -1664,7 +1732,7 @@ const queueWorktreeBootstrap = (args) => {
} = args;
setTimeout(() => {
const run = async () => {
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
await populateWorktreeWithLockRecovery(directory);
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
@@ -10,6 +10,7 @@ import {
cherryPick,
createWorktree,
getStatus,
populateWorktreeWithLockRecovery,
removeWorktree,
resolvePrimaryWorktreeRoot,
resolveWorktreeTopLevel,
@@ -321,6 +322,28 @@ describe('worktree root resolution', () => {
// ---------------------------------------------------------------------------
describe('createWorktree', () => {
it('recovers from an unchanged stale index lock while populating a worktree', async () => {
if (!canRunGit()) return;
const repo = createTempDir();
const worktree = 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']);
fs.rmSync(worktree, { recursive: true, force: true });
runGit(repo, ['worktree', 'add', '--no-checkout', '-b', 'feature/stale-lock', worktree, 'HEAD']);
const lockPath = runGit(worktree, ['rev-parse', '--git-path', 'index.lock']).trim();
fs.writeFileSync(lockPath, 'stale');
await expect(populateWorktreeWithLockRecovery(worktree)).resolves.toBeUndefined();
expect(fs.existsSync(lockPath)).toBe(false);
expect(fs.readFileSync(path.join(worktree, 'README.md'), 'utf8')).toBe('# Test\n');
});
it('preflights fast create branch-in-use failures before creating the candidate directory', async () => {
if (!canRunGit()) return;