From 6bb0f8261cadb91783f712b892efea17003617d7 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 7 May 2026 14:48:54 +0200 Subject: [PATCH] fix(agent-manager): keep metadata out of worktrees (#10004) --- .changeset/clean-worktree-metadata.md | 5 ++ .../src/agent-manager/WorktreeManager.ts | 68 ++++++++++++------- .../tests/unit/worktree-manager.test.ts | 19 ++++++ 3 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 .changeset/clean-worktree-metadata.md diff --git a/.changeset/clean-worktree-metadata.md b/.changeset/clean-worktree-metadata.md new file mode 100644 index 00000000000..84d6ad7f4d6 --- /dev/null +++ b/.changeset/clean-worktree-metadata.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager worktree recovery metadata out of worktree git changes. diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 7ed0c1e0114..888fb95eac6 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -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 { - const dir = path.join(worktreePath, KILO_DIR) - if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true }) - const meta: Record = { 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 { + const dir = await this.worktreeGitDir(worktreePath) + if (!dir) return undefined + return path.join(dir, GIT_METADATA_FILE) + } + + private async worktreeGitDir(worktreePath: string): Promise { + 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 { - 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". diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 96d07371828..76f831fa87e 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -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 { + 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/ 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)