From c4581dd065e05476d033057e1b0b14d0d0085037 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 7 Apr 2026 21:54:16 +0200 Subject: [PATCH] feat(agent-manager): sync worktree branch from git worktree list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piggyback on the existing `git worktree list --porcelain` call in probeWorktreePresence to detect branch changes (e.g. after checking out a PR branch). Zero additional git calls — the branch info was already returned but discarded. --- .../src/agent-manager/AgentManagerProvider.ts | 16 ++++++++++++---- .../kilo-vscode/src/agent-manager/GitOps.ts | 8 ++++---- .../src/agent-manager/GitStatsPoller.ts | 5 ++++- .../src/agent-manager/WorktreeStateManager.ts | 13 +++++++++++++ .../tests/unit/git-stats-poller.test.ts | 17 ++++++++++------- 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d70103ab362..3554990404d 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -695,11 +695,11 @@ export class AgentManagerProvider implements Disposable { this.pushState() // Disk removal after state is clean — pollers no longer reference this worktree. try { - await manager.removeWorktree(worktree.path, worktree.branch) + await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch) } catch (error) { this.log(`Failed to remove worktree from disk: ${error}`) } - this.log(`Deleted worktree ${worktreeId} (${worktree.branch})`) + this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`) return null } @@ -1444,12 +1444,20 @@ export class AgentManagerProvider implements Disposable { const entries = result.worktrees.filter((item) => ids.has(item.worktreeId)) if (entries.length === 0) return + // Sync branches from git worktree list (no extra git calls) + let branchChanged = false + for (const entry of entries) { + if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) { + branchChanged = true + } + } + const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId)) - const changed = + const staleChanged = next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId)) this.staleWorktreeIds = next - if (changed) { + if (staleChanged || branchChanged) { this.pushState() } } diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 9aa393513ce..dcd74e95987 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -130,14 +130,14 @@ export class GitOps { } /** Return the set of worktree paths for the repo, excluding bare entries. */ - async listWorktreePaths(cwd: string): Promise> { + async listWorktreePaths(cwd: string): Promise> { const raw = await this.raw(["worktree", "list", "--porcelain"], cwd) - const paths = new Set() + const result = new Map() for (const entry of parseWorktreeList(raw)) { if (entry.bare) continue - paths.add(normalizePath(entry.path)) + result.set(normalizePath(entry.path), entry.branch) } - return paths + return result } /** diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 27260b2f059..301261d137e 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -26,6 +26,8 @@ export interface LocalStats { export interface WorktreePresence { worktreeId: string missing: boolean + /** Current branch from `git worktree list`, if available. */ + branch?: string } export interface WorktreePresenceResult { @@ -231,7 +233,8 @@ export class GitStatsPoller { () => false, ) const missing = !exists || !tracked.has(normalized) - return { worktreeId: wt.id, missing } + const branch = tracked.get(normalized) + return { worktreeId: wt.id, missing, branch } }), ) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index e7c0ba50214..c41ce547aa8 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -32,6 +32,9 @@ export interface Worktree { prUrl?: string /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ prState?: string + /** Original branch created with the worktree, used for cleanup on deletion. + * Set automatically when `branch` is updated via live sync. */ + originalBranch?: string } /** @@ -169,6 +172,16 @@ export class WorktreeStateManager { return wt } + updateWorktreeBranch(id: string, branch: string): boolean { + const wt = this.worktrees.get(id) + if (!wt || wt.branch === branch) return false + if (!wt.originalBranch) wt.originalBranch = wt.branch + this.log(`Updated worktree ${id} branch: ${wt.branch} → ${branch}`) + wt.branch = branch + void this.save() + return true + } + updateWorktreeLabel(id: string, label: string): void { const wt = this.worktrees.get(id) if (!wt) return diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index ef9ec16b823..53176ffccb9 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { GitStatsPoller } from "../../src/agent-manager/GitStatsPoller" +import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller" import { GitOps } from "../../src/agent-manager/GitOps" import type { Worktree } from "../../src/agent-manager/WorktreeStateManager" @@ -128,7 +128,7 @@ describe("GitStatsPoller", () => { const wtPath = path.join(root, "wt-a") fs.mkdirSync(wtPath, { recursive: true }) - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], @@ -154,7 +154,10 @@ describe("GitStatsPoller", () => { poller.stop() fs.rmSync(root, { recursive: true, force: true }) - expect(presence[0]).toEqual({ worktrees: [{ worktreeId: "a", missing: false }], degraded: false }) + expect(presence[0]).toEqual({ + worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }], + degraded: false, + }) }) it("emits degraded probe when git worktree listing fails", async () => { @@ -162,7 +165,7 @@ describe("GitStatsPoller", () => { const wtPath = path.join(root, "wt-a") fs.mkdirSync(wtPath, { recursive: true }) - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], @@ -199,7 +202,7 @@ describe("GitStatsPoller", () => { const calls: string[] = [] const emitted: Array> = [] - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const client = { worktree: { @@ -240,8 +243,8 @@ describe("GitStatsPoller", () => { expect(calls.some((cwd) => cwd === wtBPath)).toBe(false) expect(presence[0]).toEqual({ worktrees: [ - { worktreeId: "a", missing: false }, - { worktreeId: "b", missing: true }, + { worktreeId: "a", missing: false, branch: "branch-a" }, + { worktreeId: "b", missing: true, branch: undefined }, ], degraded: false, })