fix(agent-manager): keep metadata out of worktrees (#10004)

This commit is contained in:
Marius
2026-05-07 14:48:54 +02:00
committed by GitHub
parent 3fb2e5d7e9
commit 6bb0f8261c
3 changed files with 69 additions and 23 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager worktree recovery metadata out of worktree git changes.
@@ -86,6 +86,7 @@ import { KILO_DIR, LEGACY_DIR, migrateAgentManagerData } from "./constants"
const SESSION_ID_FILE = "session-id"
const METADATA_FILE = "metadata.json"
const GIT_METADATA_FILE = "kilo-agent-manager-metadata.json"
export class WorktreeManager {
private readonly root: string
@@ -438,24 +439,21 @@ export class WorktreeManager {
}
async writeMetadata(worktreePath: string, sessionId: string, parentBranch: string, remote?: string): Promise<void> {
const dir = path.join(worktreePath, KILO_DIR)
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
const meta: Record<string, string> = { sessionId, parentBranch }
if (remote) meta.remote = remote
// Write both formats: session-id for backward compat, metadata.json for parentBranch+remote
await Promise.all([
fs.promises.writeFile(path.join(dir, SESSION_ID_FILE), sessionId, "utf-8"),
fs.promises.writeFile(path.join(dir, METADATA_FILE), JSON.stringify(meta), "utf-8"),
])
const file = await this.gitMetadataPath(worktreePath)
if (!file) throw new Error(`Could not resolve git metadata directory for ${worktreePath}`)
await fs.promises.writeFile(file, JSON.stringify(meta), "utf-8")
this.log(`Wrote metadata for session ${sessionId} to ${worktreePath}`)
await this.ensureWorktreeExclude(worktreePath)
}
async readMetadata(
worktreePath: string,
): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> {
const current = await this.readCurrentMetadata(worktreePath)
if (current) return current
// Check .kilo/ first, then legacy .kilocode/
for (const dirName of [KILO_DIR, LEGACY_DIR]) {
const result = await this.readMetadataFrom(worktreePath, dirName)
@@ -464,6 +462,44 @@ export class WorktreeManager {
return undefined
}
private async readCurrentMetadata(
worktreePath: string,
): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> {
try {
const file = await this.gitMetadataPath(worktreePath)
if (!file) return undefined
const content = await fs.promises.readFile(file, "utf-8")
const data = JSON.parse(content) as { sessionId?: string; parentBranch?: string; remote?: string }
if (!data.sessionId) return undefined
return {
sessionId: data.sessionId,
parentBranch: data.parentBranch,
remote: data.remote,
}
} catch (e) {
this.log(`readMetadata: git metadata unreadable in ${worktreePath}: ${e}`)
return undefined
}
}
private async gitMetadataPath(worktreePath: string): Promise<string | undefined> {
const dir = await this.worktreeGitDir(worktreePath)
if (!dir) return undefined
return path.join(dir, GIT_METADATA_FILE)
}
private async worktreeGitDir(worktreePath: string): Promise<string | undefined> {
const gitPath = path.join(worktreePath, ".git")
const stat = await fs.promises.stat(gitPath)
if (stat.isDirectory()) return gitPath
if (!stat.isFile()) return undefined
const content = await fs.promises.readFile(gitPath, "utf-8")
const match = content.match(/^gitdir:\s*(.+)$/m)
if (!match) return undefined
return path.resolve(worktreePath, match[1].trim())
}
private async readMetadataFrom(
worktreePath: string,
dirName: string,
@@ -526,20 +562,6 @@ export class WorktreeManager {
}
}
private async ensureWorktreeExclude(worktreePath: string): Promise<void> {
try {
const content = await fs.promises.readFile(path.join(worktreePath, ".git"), "utf-8")
const match = content.match(/^gitdir:\s*(.+)$/m)
if (!match) return
const worktreeGitDir = path.resolve(worktreePath, match[1].trim())
const mainGitDir = path.dirname(path.dirname(worktreeGitDir))
await this.addExcludeEntry(path.join(mainGitDir, "info", "exclude"), `${KILO_DIR}/`, "Kilo Code session metadata")
} catch (error) {
this.log(`Warning: Failed to update git exclude for worktree: ${error}`)
}
}
/**
* Returns true when target is strictly inside the managed worktrees directory.
* Prevents sibling-prefix confusion such as "/worktrees-evil".
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"
import os from "node:os"
import path from "node:path"
import fs from "node:fs/promises"
import { existsSync } from "node:fs"
import { WorktreeManager } from "../../src/agent-manager/WorktreeManager"
import { generateBranchName, sanitizeBranchName, versionedName } from "../../src/agent-manager/branch-name"
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
@@ -37,6 +38,12 @@ function createManager(root: string): WorktreeManager {
return new WorktreeManager(root, (msg) => logs.push(msg))
}
// Test-only helper to verify metadata writes keep the temp worktree checkout clean.
async function changedFiles(cwd: string): Promise<string[]> {
const raw = await simpleGit(cwd).raw(["status", "--porcelain", "--untracked-files=all", "--"])
return raw.trim().split("\n").filter(Boolean)
}
/** Create a temp repo with a bare origin remote so origin/<branch> refs exist. */
async function createTempRepoWithOrigin(): Promise<{ bare: string; clone: string }> {
// Use a non-bare seed repo to control the initial branch name, then clone bare
@@ -551,6 +558,18 @@ describe("WorktreeManager metadata", () => {
expect(meta?.remote).toBe("origin")
})
it("writes metadata outside the worktree checkout", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "session-status" })
await mgr.writeMetadata(result.path, "sess-clean-123", "feature-branch", "origin")
expect(existsSync(path.join(result.path, ".kilo", "session-id"))).toBe(false)
expect(existsSync(path.join(result.path, ".kilo", "metadata.json"))).toBe(false)
expect(await changedFiles(result.path)).toEqual([])
})
it("returns undefined when no metadata exists", async () => {
const root = await createTempRepo()
const mgr = createManager(root)