feat(agent-manager): add fetch caching and post-checkout hook tolerance (#7241)

* feat(agent-manager): add fetch caching and post-checkout hook tolerance

Add two optimizations to worktree creation:

1. Fetch cache (60s TTL) avoids redundant git fetch calls when creating
   multiple worktrees from the same base branch (e.g., multi-version mode).
   Reduces second+ worktree creation from ~3.5s to ~1.2s by skipping the
   network round-trip.

2. Post-checkout hook tolerance detects failures from husky/lefthook that
   cause non-zero exit codes even though the worktree was created. Verifies
   via git worktree list --porcelain before treating as a real error.

* fix(agent-manager): apply hook tolerance to collision retry path

Extract runWorktreeAdd() helper so both the initial worktree add and
the branch-collision retry path get post-checkout hook tolerance.
This commit is contained in:
Marius
2026-03-18 09:57:26 -03:00
committed by GitHub
parent 20124185d3
commit a19f4c5092
@@ -114,6 +114,12 @@ export class WorktreeManager {
// conflicts. Operations on different repositories proceed in parallel.
private static locks = new Map<string, Promise<void>>()
// Cache for fetched refs: avoids redundant git fetch calls when creating
// multiple worktrees from the same base branch (e.g., multi-version mode).
// Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done
private static fetchCache = new Map<string, number>()
private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute
private withGitLock<T>(fn: () => Promise<T>): Promise<T> {
const key = this.root
const prev = WorktreeManager.locks.get(key) ?? Promise.resolve()
@@ -246,7 +252,7 @@ export class WorktreeManager {
const args = params.existingBranch
? ["worktree", "add", worktreePath, branch]
: ["worktree", "add", "-b", branch, worktreePath, startRef!]
await this.git.raw(args)
await this.runWorktreeAdd(args, worktreePath)
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
if (msg.includes("already checked out")) {
@@ -265,7 +271,7 @@ export class WorktreeManager {
const retryArgs = params.existingBranch
? ["worktree", "add", worktreePath, branch]
: ["worktree", "add", "-b", branch, worktreePath, startRef!]
await this.git.raw(retryArgs)
await this.runWorktreeAdd(retryArgs, worktreePath)
}
this.log(
@@ -281,6 +287,55 @@ export class WorktreeManager {
}
}
/**
* Run `git worktree add` with post-checkout hook tolerance.
*
* Hooks like husky or lefthook run after `git worktree add` and can cause
* a non-zero exit code even though the worktree was created successfully.
* When a hook failure is detected, we verify the worktree was registered
* via `git worktree list --porcelain` before treating it as a real error.
*/
private async runWorktreeAdd(args: string[], wtPath: string): Promise<void> {
try {
await this.git.raw(args)
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
if (this.isHookError(msg) && (await this.worktreeRegistered(wtPath))) {
this.log(`Ignoring post-checkout hook failure for ${wtPath}: ${msg}`)
return
}
throw error
}
}
/**
* Detect post-checkout hook failures in git error output.
* Hooks like husky or lefthook run after `git worktree add` and can fail
* with a non-zero exit code even though the worktree was created.
*/
private isHookError(msg: string): boolean {
const lower = msg.toLowerCase()
return (
(lower.includes("hook") || lower.includes("husky") || lower.includes("lefthook")) &&
(lower.includes("post-checkout") || lower.includes("post_checkout"))
)
}
/**
* Verify that git actually registered a worktree at the given path by
* checking `git worktree list --porcelain`. Used to confirm that a
* worktree was created despite a non-zero exit code (e.g., hook failure).
*/
private async worktreeRegistered(wtPath: string): Promise<boolean> {
try {
const raw = await this.git.raw(["worktree", "list", "--porcelain"])
const normalized = normalizePath(wtPath)
return parseWorktreeList(raw).some((e) => normalizePath(e.path) === normalized)
} catch {
return false
}
}
/**
* Remove a worktree directory and its git bookkeeping.
* Called in two scenarios:
@@ -545,12 +600,29 @@ export class WorktreeManager {
): Promise<StartPointResult> {
const { allowFallback = true } = opts || {}
// 1. Remote fetch
// 1. Remote fetch (with caching to avoid redundant fetches in multi-version mode)
const remote = await this.resolveRemote()
if (remote) {
const cacheKey = `${this.root}:${remote}:${branch}`
const cached = WorktreeManager.fetchCache.get(cacheKey)
// Skip fetch if recently fetched (within TTL) AND ref exists locally
if (cached && Date.now() - cached < WorktreeManager.FETCH_CACHE_TTL) {
if (await this.refExistsLocally(`${remote}/${branch}`)) {
return {
ref: `${remote}/${branch}`,
branch,
remote,
source: "remote",
}
}
}
// Either not cached or cache is stale - do the fetch
onProgress?.("fetching", `Fetching ${remote}/${branch}...`)
try {
await this.git.fetch(remote, branch)
WorktreeManager.fetchCache.set(cacheKey, Date.now())
if (await this.refExistsLocally(`${remote}/${branch}`)) {
return {
ref: `${remote}/${branch}`,