mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix(agent-manager): keep metadata out of worktrees (#10004)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user