From a19f4c5092bb0cd9e1c6ddcf9c9bb5af087fe397 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 18 Mar 2026 13:57:26 +0100 Subject: [PATCH] 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. --- .../src/agent-manager/WorktreeManager.ts | 78 ++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 1d64d1a0e29..0a49be95d23 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -114,6 +114,12 @@ export class WorktreeManager { // conflicts. Operations on different repositories proceed in parallel. private static locks = new Map>() + // 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() + private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute + private withGitLock(fn: () => Promise): Promise { 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 { + 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 { + 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 { 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}`,