Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/disable-kilo-folder-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep the Agent Manager `.kilo` directory out of git in subdirectory workspaces and avoid creating it in folders that are not git repositories.
40 changes: 34 additions & 6 deletions packages/kilo-vscode/src/agent-manager/WorktreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,10 @@ export class WorktreeManager {
/** Adopt leftover pooled slots at startup and discard broken ones. */
async reconcilePool(): Promise<void> {
await this.ensureMigrated()
await this.ensureDir()
// Exclude before creating anything: a repository that cannot be excluded
// must not leave an untracked `.kilo/worktrees` directory behind.
await this.ensureGitExclude()
await this.ensureDir()
return this.pool.reconcile()
}

Expand Down Expand Up @@ -427,7 +429,8 @@ export class WorktreeManager {

const requested = params.existingBranch ?? params.branchName
const { resolvedRemote } = await this.preflight(params, requested)
await Promise.all([this.ensureDir(), this.ensureGitExclude()])
await this.ensureGitExclude()
await this.ensureDir()

// Resolve start point (parent branch + remote)
let parent: string
Expand Down Expand Up @@ -1057,8 +1060,7 @@ export class WorktreeManager {
// ---------------------------------------------------------------------------

async ensureGitExclude(): Promise<void> {
const gitDir = await resolveGitDir(this.root)
const excludePath = path.join(gitDir, "info", "exclude")
const target = await this.excludeTarget()
const items = [
[".kilo/worktrees/", "Kilo Code agent worktrees"],
[".kilo/agent-manager.json", "Kilo Agent Manager state"],
Expand All @@ -1077,10 +1079,36 @@ export class WorktreeManager {
] as const

for (const [entry, comment] of items) {
await this.addExcludeEntry(excludePath, entry, comment)
await this.addExcludeEntry(target.file, `${target.prefix}${entry}`, comment)
}
}

/**
* Resolve the repository exclude file and the repository-relative path to
* this manager's root.
*
* Git answers both questions for a linked worktree and for a workspace that
* is a subdirectory of a repository. `--show-prefix` is already relative and
* uses forward slashes, so it avoids symlink mismatches such as macOS
* `/var` versus `/private/var`. The anchored ignore patterns then point at
* the real `.kilo` directory instead of the repository root.
*
* `--git-path` is used without `--path-format=absolute`, because older Git
* echoes unsupported rev-parse flags to stdout with exit code 0, which would
* silently corrupt the path. Its result is relative to this command's cwd,
* so it is resolved against this root.
*
* Failures propagate: callers decide whether to continue without excludes.
* There is deliberately no silent fallback, because a guessed prefix would
* write ignore patterns anchored to the wrong directory.
*/
private async excludeTarget(): Promise<{ file: string; prefix: string }> {
const exclude = (await this.git.raw(["rev-parse", "--git-path", "info/exclude"])).trim()
if (!exclude || exclude.startsWith("--")) throw new Error("git rev-parse did not return an exclude path")
const prefix = (await this.git.raw(["rev-parse", "--show-prefix"])).trim()
return { file: path.resolve(this.root, exclude), prefix }
}

/**
* Returns true when target is strictly inside the managed worktrees directory.
* Prevents sibling-prefix confusion such as "/worktrees-evil".
Expand All @@ -1102,7 +1130,7 @@ export class WorktreeManager {
let content = ""
if (fs.existsSync(excludePath)) {
content = await fs.promises.readFile(excludePath, "utf-8")
if (content.includes(entry)) return
if (content.split(/\r?\n/).includes(entry)) return
}

const pad = content.endsWith("\n") || content === "" ? "" : "\n"
Expand Down
26 changes: 26 additions & 0 deletions packages/kilo-vscode/tests/unit/worktree-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,32 @@ describe("WorktreeManager.ensureGitExclude", () => {
const count = content.split(".kilo/worktrees/").length - 1
expect(count).toBe(1)
})

it("prefixes ignore entries when the root is a subdirectory of the repository", async () => {
const root = await createTempRepo()
const sub = path.join(root, "packages", "app")
await fs.mkdir(sub, { recursive: true })
const mgr = createManager(sub)

await mgr.ensureGitExclude()

const content = await fs.readFile(path.join(root, ".git", "info", "exclude"), "utf-8")
expect(content).toContain("packages/app/.kilo/worktrees/")
expect(content).toContain("packages/app/.kilo/agent-manager.json")
})

it("keeps a subdirectory workspace clean after pool reconcile", async () => {
const root = await createTempRepo()
const sub = path.join(root, "packages", "app")
await fs.mkdir(sub, { recursive: true })
const mgr = createManager(sub)

await mgr.reconcilePool()

const status = await simpleGit(root).raw(["status", "--porcelain", "--untracked-files=all"])
expect(status.trim()).toBe("")
expect(existsSync(path.join(sub, ".kilo", "worktrees"))).toBe(true)
})
})

// ---------------------------------------------------------------------------
Expand Down
Loading