fix serialize git worktree mutations and forward teammate PATH (#721)

This commit is contained in:
guanjiawei
2026-04-16 21:44:56 +08:00
committed by GitHub
parent 2ff5710329
commit b280c740a6
4 changed files with 291 additions and 136 deletions

View File

@@ -0,0 +1,33 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { buildInheritedEnvVars } from './spawnUtils.js'
const ORIGINAL_ENV = { ...process.env }
beforeEach(() => {
for (const key of Object.keys(process.env)) {
delete process.env[key]
}
})
afterEach(() => {
for (const key of Object.keys(process.env)) {
delete process.env[key]
}
Object.assign(process.env, ORIGINAL_ENV)
})
test('buildInheritedEnvVars marks spawned teammates as host-managed for provider routing', () => {
const envVars = buildInheritedEnvVars()
expect(envVars).toContain('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1')
})
test('buildInheritedEnvVars forwards PATH for source-built teammate tool lookups', () => {
process.env.PATH = '/custom/bin:/usr/bin'
const envVars = buildInheritedEnvVars()
expect(envVars).toContain('PATH=')
expect(envVars).toContain('/custom/bin\\:/usr/bin')
})

View File

@@ -141,6 +141,9 @@ const TEAMMATE_ENV_VARS = [
'NODE_EXTRA_CA_CERTS', 'NODE_EXTRA_CA_CERTS',
'REQUESTS_CA_BUNDLE', 'REQUESTS_CA_BUNDLE',
'CURL_CA_BUNDLE', 'CURL_CA_BUNDLE',
// Source builds may rely on user shell PATH for rg/node/bun and other tools.
// Forward it so teammates resolve the same toolchain as the parent session.
'PATH',
] as const ] as const
/** /**
@@ -149,7 +152,13 @@ const TEAMMATE_ENV_VARS = [
* plus any provider/config env vars that are set in the current process. * plus any provider/config env vars that are set in the current process.
*/ */
export function buildInheritedEnvVars(): string { export function buildInheritedEnvVars(): string {
const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1'] const envVars = [
'CLAUDECODE=1',
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1',
// Teammates should inherit the leader-selected provider route instead of
// replaying persisted ~/.claude or settings.env provider defaults.
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1',
]
for (const key of TEAMMATE_ENV_VARS) { for (const key of TEAMMATE_ENV_VARS) {
const value = process.env[key] const value = process.env[key]

View File

@@ -0,0 +1,69 @@
import { afterEach, expect, test } from 'bun:test'
import {
_resetGitWorktreeMutationLocksForTesting,
withGitWorktreeMutationLock,
} from './worktree.js'
afterEach(() => {
_resetGitWorktreeMutationLocksForTesting()
})
test('withGitWorktreeMutationLock serializes mutations for the same repo', async () => {
const order: string[] = []
let releaseFirst!: () => void
const firstGate = new Promise<void>(resolve => {
releaseFirst = resolve
})
const first = withGitWorktreeMutationLock('/repo', async () => {
order.push('first:start')
await firstGate
order.push('first:end')
})
const second = withGitWorktreeMutationLock('/repo', async () => {
order.push('second:start')
order.push('second:end')
})
await Promise.resolve()
await Promise.resolve()
expect(order).toEqual(['first:start'])
releaseFirst()
await Promise.all([first, second])
expect(order).toEqual([
'first:start',
'first:end',
'second:start',
'second:end',
])
})
test('withGitWorktreeMutationLock does not serialize different repos', async () => {
const order: string[] = []
let releaseFirst!: () => void
const firstGate = new Promise<void>(resolve => {
releaseFirst = resolve
})
const first = withGitWorktreeMutationLock('/repo-a', async () => {
order.push('a:start')
await firstGate
order.push('a:end')
})
const second = withGitWorktreeMutationLock('/repo-b', async () => {
order.push('b:start')
order.push('b:end')
})
await Promise.resolve()
await Promise.resolve()
expect(order).toEqual(['a:start', 'b:start', 'b:end'])
releaseFirst()
await Promise.all([first, second])
})

View File

@@ -192,6 +192,36 @@ type WorktreeCreateResult =
existed: false existed: false
} }
const gitWorktreeMutationLocks = new Map<string, Promise<void>>()
export async function withGitWorktreeMutationLock<T>(
repoRoot: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = gitWorktreeMutationLocks.get(repoRoot) ?? Promise.resolve()
let releaseCurrent!: () => void
const current = new Promise<void>(resolve => {
releaseCurrent = resolve
})
const next = previous.catch(() => {}).then(() => current)
gitWorktreeMutationLocks.set(repoRoot, next)
await previous.catch(() => {})
try {
return await fn()
} finally {
releaseCurrent()
if (gitWorktreeMutationLocks.get(repoRoot) === next) {
gitWorktreeMutationLocks.delete(repoRoot)
}
}
}
export function _resetGitWorktreeMutationLocksForTesting(): void {
gitWorktreeMutationLocks.clear()
}
// Env vars to prevent git/SSH from prompting for credentials (which hangs the CLI). // Env vars to prevent git/SSH from prompting for credentials (which hangs the CLI).
// GIT_TERMINAL_PROMPT=0 prevents git from opening /dev/tty for credential prompts. // GIT_TERMINAL_PROMPT=0 prevents git from opening /dev/tty for credential prompts.
// GIT_ASKPASS='' disables askpass GUI programs. // GIT_ASKPASS='' disables askpass GUI programs.
@@ -254,124 +284,136 @@ async function getOrCreateWorktree(
} }
} }
// New worktree: fetch base branch then add return withGitWorktreeMutationLock(repoRoot, async () => {
await mkdir(worktreesDir(repoRoot), { recursive: true }) const lockedExistingHead = await readWorktreeHeadSha(worktreePath)
if (lockedExistingHead) {
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV } return {
worktreePath,
let baseBranch: string worktreeBranch,
let baseSha: string | null = null headCommit: lockedExistingHead,
if (options?.prNumber) { existed: true,
const { code: prFetchCode, stderr: prFetchStderr } = }
await execFileNoThrowWithCwd(
gitExe(),
['fetch', 'origin', `pull/${options.prNumber}/head`],
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
)
if (prFetchCode !== 0) {
throw new Error(
`Failed to fetch PR #${options.prNumber}: ${prFetchStderr.trim() || 'PR may not exist or the repository may not have a remote named "origin"'}`,
)
} }
baseBranch = 'FETCH_HEAD'
} else { // New worktree: fetch base branch then add
// If origin/<branch> already exists locally, skip fetch. In large repos await mkdir(worktreesDir(repoRoot), { recursive: true })
// (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph
// scan before even hitting the network. A slightly stale base is fine — const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
// the user can pull in the worktree if they want latest.
// resolveRef reads the loose/packed ref directly; when it succeeds we let baseBranch: string
// already have the SHA, so the later rev-parse is skipped entirely. let baseSha: string | null = null
const [defaultBranch, gitDir] = await Promise.all([ if (options?.prNumber) {
getDefaultBranch(), const { code: prFetchCode, stderr: prFetchStderr } =
resolveGitDir(repoRoot), await execFileNoThrowWithCwd(
]) gitExe(),
const originRef = `origin/${defaultBranch}` ['fetch', 'origin', `pull/${options.prNumber}/head`],
const originSha = gitDir { cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
? await resolveRef(gitDir, `refs/remotes/origin/${defaultBranch}`) )
: null if (prFetchCode !== 0) {
if (originSha) { throw new Error(
baseBranch = originRef `Failed to fetch PR #${options.prNumber}: ${prFetchStderr.trim() || 'PR may not exist or the repository may not have a remote named "origin"'}`,
baseSha = originSha )
}
baseBranch = 'FETCH_HEAD'
} else { } else {
const { code: fetchCode } = await execFileNoThrowWithCwd( // If origin/<branch> already exists locally, skip fetch. In large repos
gitExe(), // (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph
['fetch', 'origin', defaultBranch], // scan before even hitting the network. A slightly stale base is fine —
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv }, // the user can pull in the worktree if they want latest.
) // resolveRef reads the loose/packed ref directly; when it succeeds we
baseBranch = fetchCode === 0 ? originRef : 'HEAD' // already have the SHA, so the later rev-parse is skipped entirely.
const [defaultBranch, gitDir] = await Promise.all([
getDefaultBranch(),
resolveGitDir(repoRoot),
])
const originRef = `origin/${defaultBranch}`
const originSha = gitDir
? await resolveRef(gitDir, `refs/remotes/origin/${defaultBranch}`)
: null
if (originSha) {
baseBranch = originRef
baseSha = originSha
} else {
const { code: fetchCode } = await execFileNoThrowWithCwd(
gitExe(),
['fetch', 'origin', defaultBranch],
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
)
baseBranch = fetchCode === 0 ? originRef : 'HEAD'
}
} }
}
// For the fetch/PR-fetch paths we still need the SHA — the fs-only resolveRef // For the fetch/PR-fetch paths we still need the SHA — the fs-only resolveRef
// above only covers the "origin/<branch> already exists locally" case. // above only covers the "origin/<branch> already exists locally" case.
if (!baseSha) { if (!baseSha) {
const { stdout, code: shaCode } = await execFileNoThrowWithCwd( const { stdout, code: shaCode } = await execFileNoThrowWithCwd(
gitExe(),
['rev-parse', baseBranch],
{ cwd: repoRoot },
)
if (shaCode !== 0) {
throw new Error(
`Failed to resolve base branch "${baseBranch}": git rev-parse failed`,
)
}
baseSha = stdout.trim()
}
const sparsePaths = getInitialSettings().worktree?.sparsePaths
const addArgs = ['worktree', 'add']
if (sparsePaths?.length) {
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)
const { code: createCode, stderr: createStderr } =
await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })
if (createCode !== 0) {
throw new Error(`Failed to create worktree: ${createStderr}`)
}
if (sparsePaths?.length) {
// If sparse-checkout or checkout fail after --no-checkout, the worktree
// is registered and HEAD is set but the working tree is empty. Next run's
// fast-resume (rev-parse HEAD) would succeed and present a broken worktree
// as "resumed". Tear it down before propagating the error.
const tearDown = async (msg: string): Promise<never> => {
await execFileNoThrowWithCwd(
gitExe(), gitExe(),
['worktree', 'remove', '--force', worktreePath], ['rev-parse', baseBranch],
{ cwd: repoRoot }, { cwd: repoRoot },
) )
throw new Error(msg) if (shaCode !== 0) {
throw new Error(
`Failed to resolve base branch "${baseBranch}": git rev-parse failed`,
)
}
baseSha = stdout.trim()
} }
const { code: sparseCode, stderr: sparseErr } =
await execFileNoThrowWithCwd( const sparsePaths = getInitialSettings().worktree?.sparsePaths
const addArgs = ['worktree', 'add']
if (sparsePaths?.length) {
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)
const { code: createCode, stderr: createStderr } =
await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })
if (createCode !== 0) {
throw new Error(`Failed to create worktree: ${createStderr}`)
}
if (sparsePaths?.length) {
// If sparse-checkout or checkout fail after --no-checkout, the worktree
// is registered and HEAD is set but the working tree is empty. Next run's
// fast-resume (rev-parse HEAD) would succeed and present a broken worktree
// as "resumed". Tear it down before propagating the error.
const tearDown = async (msg: string): Promise<never> => {
await execFileNoThrowWithCwd(
gitExe(),
['worktree', 'remove', '--force', worktreePath],
{ cwd: repoRoot },
)
throw new Error(msg)
}
const { code: sparseCode, stderr: sparseErr } =
await execFileNoThrowWithCwd(
gitExe(),
['sparse-checkout', 'set', '--cone', '--', ...sparsePaths],
{ cwd: worktreePath },
)
if (sparseCode !== 0) {
await tearDown(`Failed to configure sparse-checkout: ${sparseErr}`)
}
const { code: coCode, stderr: coErr } = await execFileNoThrowWithCwd(
gitExe(), gitExe(),
['sparse-checkout', 'set', '--cone', '--', ...sparsePaths], ['checkout', 'HEAD'],
{ cwd: worktreePath }, { cwd: worktreePath },
) )
if (sparseCode !== 0) { if (coCode !== 0) {
await tearDown(`Failed to configure sparse-checkout: ${sparseErr}`) await tearDown(`Failed to checkout sparse worktree: ${coErr}`)
}
} }
const { code: coCode, stderr: coErr } = await execFileNoThrowWithCwd(
gitExe(),
['checkout', 'HEAD'],
{ cwd: worktreePath },
)
if (coCode !== 0) {
await tearDown(`Failed to checkout sparse worktree: ${coErr}`)
}
}
return { return {
worktreePath, worktreePath,
worktreeBranch, worktreeBranch,
headCommit: baseSha, headCommit: baseSha,
baseBranch, baseBranch,
existed: false, existed: false,
} }
})
} }
/** /**
@@ -984,39 +1026,41 @@ export async function removeAgentWorktree(
return false return false
} }
// Run from the main repo root, not the worktree (which we're about to delete) return withGitWorktreeMutationLock(gitRoot, async () => {
const { code: removeCode, stderr: removeError } = // Run from the main repo root, not the worktree (which we're about to delete)
await execFileNoThrowWithCwd( const { code: removeCode, stderr: removeError } =
gitExe(), await execFileNoThrowWithCwd(
['worktree', 'remove', '--force', worktreePath], gitExe(),
{ cwd: gitRoot }, ['worktree', 'remove', '--force', worktreePath],
) { cwd: gitRoot },
)
if (removeCode !== 0) { if (removeCode !== 0) {
logForDebugging(`Failed to remove agent worktree: ${removeError}`, { logForDebugging(`Failed to remove agent worktree: ${removeError}`, {
level: 'error', level: 'error',
}) })
return false return false
} }
logForDebugging(`Removed agent worktree at: ${worktreePath}`) logForDebugging(`Removed agent worktree at: ${worktreePath}`)
if (!worktreeBranch) { if (!worktreeBranch) {
return true
}
// Delete the temporary worktree branch from the main repo
const { code: deleteBranchCode, stderr: deleteBranchError } =
await execFileNoThrowWithCwd(gitExe(), ['branch', '-D', worktreeBranch], {
cwd: gitRoot,
})
if (deleteBranchCode !== 0) {
logForDebugging(
`Could not delete agent worktree branch: ${deleteBranchError}`,
{ level: 'error' },
)
}
return true return true
} })
// Delete the temporary worktree branch from the main repo
const { code: deleteBranchCode, stderr: deleteBranchError } =
await execFileNoThrowWithCwd(gitExe(), ['branch', '-D', worktreeBranch], {
cwd: gitRoot,
})
if (deleteBranchCode !== 0) {
logForDebugging(
`Could not delete agent worktree branch: ${deleteBranchError}`,
{ level: 'error' },
)
}
return true
} }
/** /**