From a7263cbaf7ea0f0250eb5168bb8aad9d8b49a9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 9 Jun 2026 16:31:55 +0800 Subject: [PATCH] fix(agent): avoid concurrent worktree config writes (#572) Git writes upstream branch config when a worktree starts from origin/main. Use the already-resolved base SHA as the worktree start point so parallel agent worktrees do not race on shared .git/config. Tested: bun test src/utils/__tests__/worktree.test.ts Tested: real gpt-5.5 Sub2API run with four worktree-isolated agents Not-tested: bun run check:server currently fails unrelated WebSocket restart/timeouts Confidence: high Scope-risk: narrow --- src/utils/__tests__/worktree.test.ts | 117 +++++++++++++++++++++++++++ src/utils/worktree.ts | 6 +- 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/worktree.test.ts diff --git a/src/utils/__tests__/worktree.test.ts b/src/utils/__tests__/worktree.test.ts new file mode 100644 index 00000000..b4f55f9d --- /dev/null +++ b/src/utils/__tests__/worktree.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + getCwdState, + getOriginalCwd, + setCwdState, + setOriginalCwd, +} from '../../bootstrap/state.js' +import { resetGitFileWatcher } from '../git/gitFilesystem.js' +import { resetSettingsCache } from '../settings/settingsCache.js' +import { createAgentWorktree, worktreeBranchName } from '../worktree.js' + +let tempDir: string +let repoDir: string +let originalCwdState: string +let originalCwd: string +let originalConfigDir: string | undefined + +function runGit(cwd: string, args: string[], allowFailure = false): { + exitCode: number + stdout: string + stderr: string +} { + const result = Bun.spawnSync(['git', ...args], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = new TextDecoder().decode(result.stdout) + const stderr = new TextDecoder().decode(result.stderr) + if (!allowFailure && result.exitCode !== 0) { + throw new Error(stderr || stdout) + } + return { exitCode: result.exitCode, stdout, stderr } +} + +function writeRepoFile(relativePath: string, content: string): void { + const filePath = join(repoDir, relativePath) + mkdirSync(join(filePath, '..'), { recursive: true }) + writeFileSync(filePath, content) +} + +function commit(message: string): void { + runGit(repoDir, ['add', '.']) + runGit(repoDir, ['commit', '-m', message]) +} + +describe('createAgentWorktree', () => { + beforeEach(() => { + originalCwdState = getCwdState() + originalCwd = getOriginalCwd() + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + + tempDir = mkdtempSync(join(tmpdir(), 'cc-haha-agent-worktree-')) + repoDir = join(tempDir, 'repo') + const originDir = join(tempDir, 'origin.git') + mkdirSync(repoDir, { recursive: true }) + + process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'claude-config') + resetSettingsCache() + resetGitFileWatcher() + + runGit(repoDir, ['init', '-b', 'main']) + runGit(repoDir, ['config', 'user.email', 'worktree-test@example.com']) + runGit(repoDir, ['config', 'user.name', 'Worktree Test']) + writeRepoFile('README.md', '# worktree test\n') + commit('initial') + + runGit(tempDir, ['init', '--bare', originDir]) + runGit(originDir, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + runGit(repoDir, ['remote', 'add', 'origin', originDir]) + runGit(repoDir, ['push', '-u', 'origin', 'main']) + + setCwdState(repoDir) + setOriginalCwd(repoDir) + process.chdir(repoDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + setCwdState(originalCwdState) + setOriginalCwd(originalCwd) + resetGitFileWatcher() + resetSettingsCache() + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + test('creates agent worktree branches without upstream tracking config', async () => { + const slug = 'agent-race-proof' + const { worktreeBranch } = await createAgentWorktree(slug) + + expect(worktreeBranch).toBe(worktreeBranchName(slug)) + + const upstreamRemote = runGit( + repoDir, + ['config', '--get', `branch.${worktreeBranch}.remote`], + true, + ) + const upstreamMerge = runGit( + repoDir, + ['config', '--get', `branch.${worktreeBranch}.merge`], + true, + ) + + expect(upstreamRemote.exitCode).not.toBe(0) + expect(upstreamRemote.stdout.trim()).toBe('') + expect(upstreamMerge.exitCode).not.toBe(0) + expect(upstreamMerge.stdout.trim()).toBe('') + }) +}) diff --git a/src/utils/worktree.ts b/src/utils/worktree.ts index bfb6a52e..e47c1da5 100644 --- a/src/utils/worktree.ts +++ b/src/utils/worktree.ts @@ -357,8 +357,10 @@ async function getOrCreateWorktree( addArgs.push('--no-checkout') } // -B (not -b): reset any orphan branch left behind by a removed worktree dir. - // Saves a `git branch -D` subprocess (~15ms spawn overhead) on every create. - addArgs.push('-B', worktreeBranch, worktreePath, baseBranch) + // Use the resolved SHA rather than origin/; a remote-tracking start + // point makes git write branch..remote/merge into shared .git/config, + // which races when multiple agent worktrees start together. + addArgs.push('-B', worktreeBranch, worktreePath, baseSha) const { code: createCode, stderr: createStderr } = await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })