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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-09 16:31:55 +08:00
parent 760295dcd8
commit a7263cbaf7
2 changed files with 121 additions and 2 deletions

View File

@ -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('')
})
})

View File

@ -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/<branch>; a remote-tracking start
// point makes git write branch.<name>.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 })