diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 22059cbb..c2d707a5 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -19,6 +19,8 @@ const worktreeBootstrapState = new Map { const normalized = normalizeDirectoryPath(directory); @@ -1025,6 +1027,72 @@ const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage return result; }; +const wait = (milliseconds: number): Promise => 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 => { + 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 => { + 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 => { + 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 => { 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, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 4fb14dda..21e6f12f 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 4b738b00..2d9dc841 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -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, diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index bb839ac0..daeb56bd 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -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;