feat(agent-manager): sync worktree branch from git worktree list (#8534)

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.
This commit is contained in:
Marius
2026-04-08 09:22:38 +02:00
committed by GitHub
parent 1a5be52c77
commit 60bfeaf630
5 changed files with 43 additions and 16 deletions
@@ -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()
}
}
@@ -130,14 +130,14 @@ export class GitOps {
}
/** Return the set of worktree paths for the repo, excluding bare entries. */
async listWorktreePaths(cwd: string): Promise<Set<string>> {
async listWorktreePaths(cwd: string): Promise<Map<string, string>> {
const raw = await this.raw(["worktree", "list", "--porcelain"], cwd)
const paths = new Set<string>()
const result = new Map<string, string>()
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
}
/**
@@ -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 }
}),
)
@@ -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
@@ -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<Array<{ worktreeId: string; additions: number; deletions: number; commits: number }>> = []
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,
})