Files
kilocode/packages/kilo-vscode/tests/unit/worktree-manager.test.ts
T
2026-08-20 10:48:11 +02:00

1305 lines
48 KiB
TypeScript

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,
semanticBranchName,
versionedName,
} from "../../src/agent-manager/branch-name"
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
import { GitOps } from "../../src/agent-manager/GitOps"
import type { PRInfo } from "../../src/agent-manager/git-import"
import simpleGit from "simple-git"
// Each test gets its own temp directory -- no shared state, safe to run in parallel.
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map(async (dir) => {
await fs.rm(dir, { recursive: true, force: true })
}),
)
})
function gitExec(args: string[]) {
const res = Bun.spawnSync(args, { stdout: "ignore", stderr: "pipe" })
if (res.exitCode !== 0) {
const err = Buffer.from(res.stderr).toString("utf8")
throw new Error(`git command failed (${args.join(" ")}): ${err}`)
}
}
/** Create a temp git repo with an initial commit (required for worktrees). */
async function createTempRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-"))
tempDirs.push(dir)
gitExec(["git", "init", "-b", "main", dir])
gitExec(["git", "-C", dir, "config", "user.email", "test@test.com"])
gitExec(["git", "-C", dir, "config", "user.name", "Test"])
await fs.writeFile(path.join(dir, "README.md"), "init")
gitExec(["git", "-C", dir, "add", "."])
gitExec(["git", "-C", dir, "commit", "-m", "initial commit"])
return dir
}
function createManager(root: string, ops?: GitOps): WorktreeManager {
const logs: string[] = []
return new WorktreeManager(root, (msg) => logs.push(msg), ops)
}
// 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 }> {
const bare = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-bare-"))
const clone = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-clone-"))
tempDirs.push(bare, clone)
gitExec(["git", "init", "--bare", "-b", "main", bare])
gitExec(["git", "clone", bare, clone])
gitExec(["git", "-C", clone, "config", "user.email", "test@test.com"])
gitExec(["git", "-C", clone, "config", "user.name", "Test"])
await fs.writeFile(path.join(clone, "README.md"), "init")
gitExec(["git", "-C", clone, "add", "."])
gitExec(["git", "-C", clone, "commit", "-m", "initial commit"])
gitExec(["git", "-C", clone, "push", "-u", "origin", "main"])
return { bare, clone }
}
// ---------------------------------------------------------------------------
// generateBranchName
// ---------------------------------------------------------------------------
describe("generateBranchName", () => {
it("generates a two-word predicate-object name", () => {
const name = generateBranchName("anything")
// Should be two lowercase words joined by a hyphen
expect(name).toMatch(/^[a-z]+-[a-z]+$/)
})
it("avoids existing branches", () => {
// Generate 50 names and collect them; none should collide with the existing list
const existing = ["brave-piano", "sunny-cloud"]
for (let i = 0; i < 50; i++) {
const name = generateBranchName("task", existing)
expect(existing).not.toContain(name)
}
})
it("falls back to numeric suffix when collisions are likely", () => {
// Supply a huge existing list — eventually a numeric suffix or timestamp is used
const name = generateBranchName("task", [])
expect(typeof name).toBe("string")
expect(name.length).toBeGreaterThan(0)
})
it("ignores the prompt and always returns friendly words", () => {
const a = generateBranchName("")
const b = generateBranchName("FIX BUG")
// Both should be lowercase word-hyphen-word patterns
expect(a).toMatch(/^[a-z]+-[a-z]+/)
expect(b).toMatch(/^[a-z]+-[a-z]+/)
})
})
// ---------------------------------------------------------------------------
// sanitizeBranchName
// ---------------------------------------------------------------------------
describe("semanticBranchName", () => {
it("creates a branch slug from a generated session title", () => {
expect(semanticBranchName("Fix token refresh race")).toBe("fix-token-refresh-race")
})
it("normalizes a user prefix and keeps branch separators", () => {
expect(semanticBranchName("Add billing alerts", "marius/features/")).toBe("marius/features/add-billing-alerts")
})
it("reserves the length limit for the prefix", () => {
expect(semanticBranchName("a".repeat(100), "team/").length).toBeLessThanOrEqual(50)
})
it("returns empty when the title has no usable characters", () => {
expect(semanticBranchName("修复登录")).toBe("")
})
})
describe("sanitizeBranchName", () => {
it("replaces spaces with hyphens", () => {
expect(sanitizeBranchName("model comparison")).toBe("model-comparison")
})
it("lowercases input", () => {
expect(sanitizeBranchName("My Feature")).toBe("my-feature")
})
it("strips special characters", () => {
expect(sanitizeBranchName("fix bug #123 & add feature!")).toBe("fix-bug-123-add-feature")
})
it("collapses consecutive hyphens", () => {
expect(sanitizeBranchName("one two three")).toBe("one-two-three")
})
it("strips leading and trailing hyphens", () => {
expect(sanitizeBranchName("---hello---")).toBe("hello")
})
it("truncates to maxLength", () => {
const result = sanitizeBranchName("a".repeat(100))
expect(result.length).toBeLessThanOrEqual(50)
})
it("returns empty string for whitespace-only input", () => {
expect(sanitizeBranchName(" ")).toBe("")
})
it("returns empty string for empty input", () => {
expect(sanitizeBranchName("")).toBe("")
})
it("handles custom maxLength", () => {
const result = sanitizeBranchName("abcdefghij", 5)
expect(result).toBe("abcde")
})
})
// ---------------------------------------------------------------------------
// versionedName
// ---------------------------------------------------------------------------
describe("versionedName", () => {
it("returns base name for first version", () => {
const result = versionedName("auth-refactor", 0, 3)
expect(result).toEqual({ branch: "auth-refactor", label: "auth-refactor" })
})
it("appends _v2 to branch and v2 to label for second version", () => {
const result = versionedName("auth-refactor", 1, 3)
expect(result).toEqual({ branch: "auth-refactor_v2", label: "auth-refactor v2" })
})
it("appends _v3 to branch and v3 to label for third version", () => {
const result = versionedName("auth-refactor", 2, 3)
expect(result).toEqual({ branch: "auth-refactor_v3", label: "auth-refactor v3" })
})
it("returns undefined for both when no name provided", () => {
expect(versionedName(undefined, 0, 3)).toEqual({ branch: undefined, label: undefined })
expect(versionedName(undefined, 1, 3)).toEqual({ branch: undefined, label: undefined })
})
it("returns undefined for empty string name", () => {
expect(versionedName("", 0, 2)).toEqual({ branch: undefined, label: undefined })
})
it("no suffix for single version", () => {
const result = versionedName("test", 0, 1)
expect(result).toEqual({ branch: "test", label: "test" })
})
})
// ---------------------------------------------------------------------------
// WorktreeStateManager -- updateWorktreeLabel
// ---------------------------------------------------------------------------
describe("WorktreeStateManager.updateWorktreeLabel", () => {
it("persists label on a worktree", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-"))
tempDirs.push(dir)
const state = new WorktreeStateManager(dir, () => {})
const wt = state.addWorktree({ branch: "test", path: dir, parentBranch: "main" })
state.updateWorktreeLabel(wt.id, "my custom name")
await state.flush()
expect(state.getWorktree(wt.id)?.label).toBe("my custom name")
})
it("clears label when set to empty string", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-"))
tempDirs.push(dir)
const state = new WorktreeStateManager(dir, () => {})
const wt = state.addWorktree({ branch: "test", path: dir, parentBranch: "main", label: "initial" })
await state.flush()
state.updateWorktreeLabel(wt.id, "")
await state.flush()
expect(state.getWorktree(wt.id)?.label).toBeUndefined()
})
it("survives save and reload", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-"))
tempDirs.push(dir)
const state = new WorktreeStateManager(dir, () => {})
const wt = state.addWorktree({ branch: "test", path: dir, parentBranch: "main", label: "persisted" })
await state.flush()
const state2 = new WorktreeStateManager(dir, () => {})
await state2.load()
expect(state2.getWorktree(wt.id)?.label).toBe("persisted")
})
it("no-ops for nonexistent worktree", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-label-"))
tempDirs.push(dir)
const state = new WorktreeStateManager(dir, () => {})
state.updateWorktreeLabel("nonexistent", "test")
await state.flush()
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- createWorktree
// ---------------------------------------------------------------------------
describe("WorktreeManager.createWorktree", () => {
it("creates a worktree with a new branch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "test task" })
// Branch should be a friendly two-word name (e.g. "brave-piano")
expect(result.branch).toMatch(/^[a-z]+-[a-z]+/)
expect(result.parentBranch).toBeTruthy()
// Worktree directory should exist and have a .git file (not directory)
const stat = await fs.stat(path.join(result.path, ".git"))
expect(stat.isFile()).toBe(true)
// Branch should exist in the repo
const git = simpleGit(root)
const branches = await git.branch()
expect(branches.all).toContain(result.branch)
})
it("uses existing branch when specified", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
await git.branch(["feature-branch"])
const mgr = createManager(root)
const result = await mgr.createWorktree({ existingBranch: "feature-branch" })
expect(result.branch).toBe("feature-branch")
const stat = await fs.stat(path.join(result.path, ".git"))
expect(stat.isFile()).toBe(true)
})
it("throws when existing branch does not exist", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
await expect(mgr.createWorktree({ existingBranch: "nonexistent" })).rejects.toThrow(
'Branch "nonexistent" does not exist',
)
})
it("throws when workspace is not a git repo", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-nogit-"))
tempDirs.push(dir)
const mgr = createManager(dir)
await expect(mgr.createWorktree({ prompt: "test" })).rejects.toThrow("not a git repository")
})
it("creates worktrees directory under .kilo/worktrees/", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "test" })
expect(result.path).toContain(path.join(".kilo", "worktrees"))
})
it("records parentBranch as default branch", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "test" })
expect(result.parentBranch).toBe(branch)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- removeWorktree
// ---------------------------------------------------------------------------
describe("WorktreeManager.removeWorktree", () => {
it("removes an existing worktree", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "removeme" })
expect(await fs.stat(result.path).then(() => true)).toBe(true)
await mgr.removeWorktree(result.path)
const exists = await fs
.stat(result.path)
.then(() => true)
.catch(() => false)
expect(exists).toBe(false)
}, 15_000)
it("does not throw when worktree path does not exist", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
// Should not throw
await mgr.removeWorktree(path.join(root, ".kilo", "worktrees", "nonexistent"))
})
it("removes orphaned directory that git does not know about", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
// Create an orphaned directory (not a real worktree)
const orphanPath = path.join(root, ".kilo", "worktrees", "orphan")
await fs.mkdir(orphanPath, { recursive: true })
await fs.writeFile(path.join(orphanPath, "file.txt"), "orphan")
await mgr.removeWorktree(orphanPath)
const exists = await fs
.stat(orphanPath)
.then(() => true)
.catch(() => false)
expect(exists).toBe(false)
})
it("cleans up git metadata after removal", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const git = simpleGit(root)
const result = await mgr.createWorktree({ prompt: "prune-check" })
await mgr.removeWorktree(result.path)
// Allow background rm to complete
await new Promise((r) => setTimeout(r, 200))
// git worktree list should only show the main repo
const raw = await git.raw(["worktree", "list", "--porcelain"])
const dirs = raw
.split("\n")
.filter((l) => l.startsWith("worktree "))
.map((l) => l.replace("worktree ", ""))
expect(dirs).toHaveLength(1)
})
it("deletes the local branch when branch is provided", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const git = simpleGit(root)
const result = await mgr.createWorktree({ prompt: "branch-delete" })
const branches = await git.branch()
expect(branches.all).toContain(result.branch)
await mgr.removeWorktree(result.path, result.branch)
const after = await git.branch()
expect(after.all).not.toContain(result.branch)
})
it("keeps the branch when branch param is omitted", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const git = simpleGit(root)
const result = await mgr.createWorktree({ prompt: "keep-branch" })
await mgr.removeWorktree(result.path)
const after = await git.branch()
expect(after.all).toContain(result.branch)
})
it(
"returns quickly even with a dirty worktree",
async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "dirty-wt" })
// Make the worktree dirty with uncommitted files
await fs.writeFile(path.join(result.path, "dirty.txt"), "uncommitted")
for (let i = 0; i < 20; i++) {
await fs.writeFile(path.join(result.path, `bulk-${i}.txt`), "x".repeat(1000))
}
const start = Date.now()
await mgr.removeWorktree(result.path)
const elapsed = Date.now() - start
// The blocking portion (rename + prune) should complete well under 3s.
// Old approach with git worktree remove (non-force then force) was much slower.
expect(elapsed).toBeLessThan(3000)
// Original path should be gone immediately
const exists = await fs
.stat(result.path)
.then(() => true)
.catch(() => false)
expect(exists).toBe(false)
},
{ timeout: 15000 },
)
it(
"eventual cleanup: files are fully deleted after background rm",
async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "eventual" })
await fs.writeFile(path.join(result.path, "data.txt"), "content")
await mgr.removeWorktree(result.path)
// Poll until background rm finishes (up to 5s)
const worktreesDir = path.join(root, ".kilo", "worktrees")
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
const entries = await fs.readdir(worktreesDir)
if (!entries.some((e) => e.startsWith(".kilo-delete-"))) break
await new Promise((r) => setTimeout(r, 100))
}
// No .kilo-delete-* temp dirs should remain
const entries = await fs.readdir(worktreesDir)
const orphans = entries.filter((e) => e.startsWith(".kilo-delete-"))
expect(orphans).toHaveLength(0)
},
{ timeout: 10000 },
)
})
// ---------------------------------------------------------------------------
// WorktreeManager -- discoverWorktrees cleans orphaned temp dirs
// ---------------------------------------------------------------------------
describe("WorktreeManager.discoverWorktrees orphan cleanup", () => {
it("cleans up .kilo-delete-* dirs left by interrupted deletions", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
// Create a worktree so the worktrees directory exists
const wt = await mgr.createWorktree({ prompt: "real-wt" })
// Simulate an orphaned temp dir from an interrupted deletion
const orphan = path.join(root, ".kilo", "worktrees", ".kilo-delete-fake-uuid")
await fs.mkdir(orphan, { recursive: true })
await fs.writeFile(path.join(orphan, "leftover.txt"), "stale")
const discovered = await mgr.discoverWorktrees()
// Should only discover the real worktree, not the orphan
expect(discovered).toHaveLength(1)
expect(discovered[0]?.branch).toBe(wt.branch)
// Wait for background cleanup
await new Promise((r) => setTimeout(r, 300))
const exists = await fs
.stat(orphan)
.then(() => true)
.catch(() => false)
expect(exists).toBe(false)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- createWorktree cleans up leftover directories
// ---------------------------------------------------------------------------
describe("WorktreeManager.createWorktree cleanup", () => {
it("cleans up leftover worktree directory before re-creation", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
// Create a worktree, then remove it improperly (just delete via git but leave artifacts)
const first = await mgr.createWorktree({ existingBranch: undefined, prompt: "cleanup-test" })
const branch = first.branch
// Remove the worktree properly, then recreate the directory as an orphan
// to simulate a crash that left a stale directory
await mgr.removeWorktree(first.path)
await fs.mkdir(first.path, { recursive: true })
await fs.writeFile(path.join(first.path, "stale.txt"), "leftover")
// Creating a worktree with the same branch name (via existingBranch) should
// clean up the stale directory and succeed
const second = await mgr.createWorktree({ existingBranch: branch })
expect(second.branch).toBe(branch)
const gitFile = await fs.stat(path.join(second.path, ".git"))
expect(gitFile.isFile()).toBe(true)
// Stale file should be gone
const staleExists = await fs
.stat(path.join(second.path, "stale.txt"))
.then(() => true)
.catch(() => false)
expect(staleExists).toBe(false)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- session ID persistence
// ---------------------------------------------------------------------------
describe("WorktreeManager metadata", () => {
it("round-trips writeMetadata / readMetadata with parentBranch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "session-test" })
await mgr.writeMetadata(result.path, "sess-abc-123", "feature-branch", "origin")
const meta = await mgr.readMetadata(result.path)
expect(meta?.sessionId).toBe("sess-abc-123")
expect(meta?.parentBranch).toBe("feature-branch")
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)
const result = await mgr.createWorktree({ prompt: "no-session" })
const meta = await mgr.readMetadata(result.path)
expect(meta).toBeUndefined()
})
it("reads legacy session-id file when metadata.json is missing", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.createWorktree({ prompt: "legacy-test" })
// Write only the legacy session-id file (no metadata.json)
const dir = path.join(result.path, ".kilo")
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(path.join(dir, "session-id"), "legacy-sess-456", "utf-8")
const meta = await mgr.readMetadata(result.path)
expect(meta?.sessionId).toBe("legacy-sess-456")
expect(meta?.parentBranch).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- discoverWorktrees
// ---------------------------------------------------------------------------
describe("WorktreeManager.discoverWorktrees", () => {
it("discovers worktrees with session IDs", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const wt1 = await mgr.createWorktree({ prompt: "discover-one" })
const wt2 = await mgr.createWorktree({ prompt: "discover-two" })
await mgr.writeMetadata(wt1.path, "sess-1", "main")
await mgr.writeMetadata(wt2.path, "sess-2", "main")
const discovered = await mgr.discoverWorktrees()
expect(discovered.length).toBe(2)
const ids = discovered.map((d) => d.sessionId).sort()
expect(ids).toEqual(["sess-1", "sess-2"])
for (const info of discovered) {
expect(info.branch).toBeTruthy()
expect(info.path).toBeTruthy()
expect(info.parentBranch).toBeTruthy()
expect(info.createdAt).toBeGreaterThan(0)
}
})
it("returns empty array when no worktrees directory exists", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const discovered = await mgr.discoverWorktrees()
expect(discovered).toEqual([])
})
it("includes worktrees without metadata (sessionId undefined)", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
await mgr.createWorktree({ prompt: "no-session-id" })
const discovered = await mgr.discoverWorktrees()
expect(discovered.length).toBe(1)
expect(discovered[0]?.sessionId).toBeUndefined()
})
it("recovers parentBranch from persisted metadata", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const wt = await mgr.createWorktree({ prompt: "parent-recovery" })
await mgr.writeMetadata(wt.path, "sess-parent", "feature/my-branch")
const discovered = await mgr.discoverWorktrees()
const found = discovered.find((d) => d.sessionId === "sess-parent")
expect(found).toBeDefined()
expect(found!.parentBranch).toBe("feature/my-branch")
})
it("repairs stale gitdir refs when .kilo/worktrees already exists", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const worktree = path.join(root, ".kilo", "worktrees", "partial")
const gitdir = path.join(root, ".git", "worktrees", "partial", "gitdir")
await fs.mkdir(worktree, { recursive: true })
await fs.mkdir(path.dirname(gitdir), { recursive: true })
await fs.writeFile(gitdir, path.join(root, ".kilocode", "worktrees", "partial", ".git"), "utf-8")
await mgr.discoverWorktrees()
const fixed = await fs.readFile(gitdir, "utf-8")
expect(fixed).toContain(path.join(root, ".kilo", "worktrees", "partial", ".git"))
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- ensureGitExclude
// ---------------------------------------------------------------------------
describe("WorktreeManager.ensureGitExclude", () => {
it("adds Agent Manager state and worktrees to .git/info/exclude", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
await mgr.ensureGitExclude()
const content = await fs.readFile(path.join(root, ".git", "info", "exclude"), "utf-8")
expect(content).toContain(".kilo/worktrees/")
expect(content).toContain(".kilo/agent-manager.json")
})
it("adds only specific legacy Agent Manager paths", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
await mgr.ensureGitExclude()
const content = await fs.readFile(path.join(root, ".git", "info", "exclude"), "utf-8")
expect(content).toContain(".kilocode/worktrees/")
expect(content).toContain(".kilocode/agent-manager.json")
expect(content).toContain(".kilocode/setup-script")
expect(content).not.toContain("\n.kilocode/\n")
})
it("is idempotent -- does not duplicate entries", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
await mgr.ensureGitExclude()
await mgr.ensureGitExclude()
await mgr.ensureGitExclude()
const content = await fs.readFile(path.join(root, ".git", "info", "exclude"), "utf-8")
const count = content.split(".kilo/worktrees/").length - 1
expect(count).toBe(1)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- automatic branch rename
// ---------------------------------------------------------------------------
describe("WorktreeManager.renameBranch", () => {
it("renames a local-only branch without moving or cleaning the worktree", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const created = await mgr.createWorktree({ branchName: "quiet-river" })
await fs.writeFile(path.join(created.path, "draft.txt"), "keep me")
const branch = await mgr.renameBranch(created.path, created.branch, "fix-token-refresh")
expect(branch).toBe("fix-token-refresh")
expect((await simpleGit(created.path).revparse(["--abbrev-ref", "HEAD"])).trim()).toBe(branch)
expect(await fs.readFile(path.join(created.path, "draft.txt"), "utf-8")).toBe("keep me")
expect((await simpleGit(root).branch()).all).not.toContain(created.branch)
})
it("suffixes a generated name that already exists", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const created = await mgr.createWorktree({ branchName: "quiet-river" })
await simpleGit(root).branch(["fix-auth"])
expect(await mgr.renameBranch(created.path, created.branch, "fix-auth")).toBe("fix-auth-2")
})
it("does not rename a branch that exists on a remote", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const created = await mgr.createWorktree({ branchName: "quiet-river" })
const hash = (await simpleGit(root).revparse(["HEAD"])).trim()
await simpleGit(root).raw(["update-ref", `refs/remotes/origin/${created.branch}`, hash])
await expect(mgr.renameBranch(created.path, created.branch, "fix-auth")).rejects.toThrow(
"already exists on a remote",
)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- branch name collision retry
// ---------------------------------------------------------------------------
describe("WorktreeManager.createWorktree branch collision", () => {
it("creates a suffixed worktree without replacing an active explicitly named worktree", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const first = await mgr.createWorktree({ branchName: "echo-hello-world" })
const second = await mgr.createWorktree({ branchName: "echo-hello-world" })
expect(first.branch).toBe("echo-hello-world")
expect(second.branch).toBe("echo-hello-world-2")
expect((await fs.stat(path.join(first.path, ".git"))).isFile()).toBe(true)
expect((await fs.stat(path.join(second.path, ".git"))).isFile()).toBe(true)
expect((await simpleGit(root).branch()).all.filter((branch) => branch.startsWith("echo-hello-world"))).toEqual([
"echo-hello-world",
"echo-hello-world-2",
])
})
it("uses the same suffix sequence when only the requested branch already exists", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
const mgr = createManager(root)
const first = await mgr.createWorktree({ branchName: "collide" })
await git.raw(["worktree", "remove", "--force", first.path])
expect((await git.branch()).all).toContain("collide")
const second = await mgr.createWorktree({ branchName: "collide" })
expect(second.branch).toBe("collide-2")
expect((await fs.stat(path.join(second.path, ".git"))).isFile()).toBe(true)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- removeWorktree safety guard
// ---------------------------------------------------------------------------
describe("WorktreeManager.removeWorktree safety", () => {
it("refuses to remove paths outside the worktrees directory", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
// Create a directory outside .kilo/worktrees/
const outside = path.join(root, "important-data")
await fs.mkdir(outside, { recursive: true })
await fs.writeFile(path.join(outside, "file.txt"), "precious")
// Attempt to remove it — should be silently refused
await mgr.removeWorktree(outside)
// Directory should still exist
const exists = await fs
.stat(outside)
.then(() => true)
.catch(() => false)
expect(exists).toBe(true)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- listBranches
// ---------------------------------------------------------------------------
describe("WorktreeManager.listBranches", () => {
it("returns the current branch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const { branches, defaultBranch } = await mgr.listBranches()
const names = branches.map((b) => b.name)
const git = simpleGit(root)
const current = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
expect(names).toContain(current)
expect(defaultBranch).toBeTruthy()
})
it("includes branches created after init", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
await git.branch(["feature-test"])
const mgr = createManager(root)
const { branches } = await mgr.listBranches()
expect(branches.map((b) => b.name)).toContain("feature-test")
})
it("marks local branches as isLocal", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const { branches } = await mgr.listBranches()
for (const b of branches) {
expect(b.isLocal).toBe(true)
}
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- checkedOutBranches
// ---------------------------------------------------------------------------
describe("WorktreeManager.checkedOutBranches", () => {
it("includes the main branch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const checked = await mgr.checkedOutBranches()
const git = simpleGit(root)
const current = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
expect(checked.has(current)).toBe(true)
})
it("includes worktree branches", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const wt = await mgr.createWorktree({ prompt: "checked-out-test" })
const checked = await mgr.checkedOutBranches()
expect(checked.has(wt.branch)).toBe(true)
})
it("excludes branches after worktree removal", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const wt = await mgr.createWorktree({ prompt: "removal-test" })
await mgr.removeWorktree(wt.path)
const checked = await mgr.checkedOutBranches()
expect(checked.has(wt.branch)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- Start Point Resolution & Helpers
// ---------------------------------------------------------------------------
describe("WorktreeManager helpers", () => {
it("hasOriginRemote returns false when no remote exists", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
expect(await mgr.hasOriginRemote()).toBe(false)
})
it("hasOriginRemote returns true when origin exists", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
await git.addRemote("origin", "https://example.com/repo.git")
const mgr = createManager(root)
expect(await mgr.hasOriginRemote()).toBe(true)
})
it("refExistsLocally verifies refs", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
const mgr = createManager(root)
const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
expect(await mgr.refExistsLocally(head)).toBe(true)
expect(await mgr.refExistsLocally("nonexistent")).toBe(false)
expect(await mgr.refExistsLocally("origin/HEAD")).toBe(false)
})
it("repoUsesLfs detects .gitattributes", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
expect(await mgr.repoUsesLfs()).toBe(false)
await fs.writeFile(path.join(root, ".gitattributes"), "*.png filter=lfs diff=lfs merge=lfs -text")
expect(await mgr.repoUsesLfs()).toBe(true)
})
it("repoUsesLfs detects .git/lfs directory", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
expect(await mgr.repoUsesLfs()).toBe(false)
await fs.mkdir(path.join(root, ".git", "lfs"), { recursive: true })
expect(await mgr.repoUsesLfs()).toBe(true)
})
})
describe("WorktreeManager.resolveStartPoint", () => {
it("falls back to local branch when no remote exists", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
const mgr = createManager(root)
const res = await mgr.resolveStartPoint(head)
expect(res.source).toBe("local-branch")
expect(res.ref).toBe(head)
})
it("returns bare branch + remote when remote exists", async () => {
const { clone } = await createTempRepoWithOrigin()
const mgr = createManager(clone)
const res = await mgr.resolveStartPoint("main")
expect(res.source).toBe("remote")
expect(res.ref).toBe("origin/main")
expect(res.branch).toBe("main")
expect(res.remote).toBe("origin")
})
it("returns bare branch + remote for stale tracking ref", async () => {
const { clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
// Remove origin so fetch fails, but the local tracking ref remains
await git.removeRemote("origin")
const mgr = createManager(clone)
const res = await mgr.resolveStartPoint("main")
// After removing the remote, resolveRemote() returns undefined,
// so "origin/main" won't be tried as ${remote}/${branch}. Falls back to local.
expect(res.source).toBe("local-branch")
expect(res.branch).toBe("main")
expect(res.remote).toBeUndefined()
})
it("returns bare branch name for local-only source", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
const mgr = createManager(root)
const res = await mgr.resolveStartPoint(head)
expect(res.source).toBe("local-branch")
expect(res.branch).toBe(head)
expect(res.remote).toBeUndefined()
})
it("falls back to default branch when requested does not exist", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
const mgr = createManager(root)
const res = await mgr.resolveStartPoint("nonexistent-feature")
expect(res.source).toBe("fallback")
expect(res.branch).toBe(head) // fallback to default (HEAD)
expect(res.warning).toContain("falling back to")
})
it("does not fallback when allowFallback is false", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
await expect(mgr.resolveStartPoint("nonexistent", undefined, { allowFallback: false })).rejects.toThrow(
"Could not resolve start point",
)
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- resolveBaseBranch
// ---------------------------------------------------------------------------
describe("WorktreeManager.resolveBaseBranch", () => {
it("uses the shared remote default instead of stale local metadata", async () => {
const { clone } = await createTempRepoWithOrigin()
gitExec(["git", "-C", clone, "branch", "master"])
gitExec(["git", "-C", clone, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/master"])
const ops = new GitOps({
log: () => undefined,
runGit: async (args) => {
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
if (args[0] === "ls-remote") return "ref: refs/heads/main\tHEAD\nabc123\tHEAD"
return ""
},
})
const mgr = createManager(clone, ops)
expect(await mgr.resolveBaseBranch()).toEqual({ branch: "main", remote: "origin" })
expect((await simpleGit(clone).raw(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).trim()).toBe(
"origin/master",
)
})
it("returns bare branch + remote when origin remote and tracking ref exist", async () => {
const { clone } = await createTempRepoWithOrigin()
const mgr = createManager(clone)
const result = await mgr.resolveBaseBranch()
expect(result).toEqual({ branch: "main", remote: "origin" })
})
it("returns bare branch without remote when no origin remote exists", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const result = await mgr.resolveBaseBranch()
const git = simpleGit(root)
const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
expect(result).toEqual({ branch: head })
expect(result.remote).toBeUndefined()
})
it("returns bare branch without remote when origin exists but tracking ref does not", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
// Add a remote that points nowhere — origin exists but origin/main ref doesn't
await git.addRemote("origin", "https://example.com/repo.git")
const mgr = createManager(root)
const result = await mgr.resolveBaseBranch()
const git2 = simpleGit(root)
const head = (await git2.revparse(["--abbrev-ref", "HEAD"])).trim()
expect(result).toEqual({ branch: head })
expect(result.remote).toBeUndefined()
})
})
describe("WorktreeManager.createWorktree advanced", () => {
it("returns startPointSource in result", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const res = await mgr.createWorktree({ prompt: "source-test" })
expect(res.startPointSource).toBe("local-branch") // no remote in temp repo
})
it("does not set upstream tracking on new branch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const res = await mgr.createWorktree({ prompt: "no-upstream" })
const git = simpleGit(res.path)
// Checking upstream should fail
let error
try {
await git.revparse(["--abbrev-ref", `${res.branch}@{upstream}`])
} catch (e) {
error = e
}
expect(error).toBeDefined()
})
it("fires onProgress callbacks", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const steps: string[] = []
await mgr.createWorktree({
prompt: "progress-test",
onProgress: (step) => steps.push(step),
})
expect(steps).toContain("verifying")
expect(steps).toContain("creating")
})
it("creates from an explicitly selected base branch", async () => {
const root = await createTempRepo()
const git = simpleGit(root)
const mgr = createManager(root)
// Create a new branch 'develop'
await git.checkoutLocalBranch("develop")
await fs.writeFile(path.join(root, "dev.txt"), "dev")
await git.add(".")
await git.commit("dev commit")
// Create worktree from 'develop'
const res = await mgr.createWorktree({
prompt: "feature",
baseBranch: "develop",
})
expect(res.parentBranch).toBe("develop")
const wtGit = simpleGit(res.path)
const headParams = await wtGit.log(["-1"])
const devParams = await git.log(["-1"])
expect(headParams.latest?.hash).toBe(devParams.latest?.hash)
})
it("creates from a base branch excluded by the remote fetch refspec", async () => {
const { clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
await git.checkoutLocalBranch("topic")
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
await git.add(".")
await git.commit("topic commit")
await git.push("origin", "topic")
await git.checkout("main")
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
const result = await createManager(clone).createWorktree({ baseBranch: "topic", prompt: "from topic" })
const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim()
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
expect(worktreeHead).toBe(remoteHead)
expect(result.parentBranch).toBe("topic")
})
it("creates from a same-repository PR branch excluded by the remote fetch refspec", async () => {
const { clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
await git.checkoutLocalBranch("topic")
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
await git.add(".")
await git.commit("topic commit")
await git.push("origin", "topic")
await git.checkout("main")
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
await git.branch(["-D", "topic"])
const manager = createManager(clone)
const internal = manager as unknown as {
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
}
internal.fetchPRInfo = async () => ({
headRefName: "topic",
isCrossRepository: false,
title: "Topic PR",
})
const result = await manager.createFromPR("https://github.com/org/repo/pull/1")
const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim()
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
expect(worktreeHead).toBe(remoteHead)
expect(result.parentBranch).toBe("topic")
})
it("does not track a deleted PR source branch when using the pull ref fallback", async () => {
const { bare, clone } = await createTempRepoWithOrigin()
const git = simpleGit(clone)
await git.checkoutLocalBranch("topic")
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
await git.add(".")
await git.commit("topic commit")
await git.push("origin", "topic")
const head = (await git.revparse(["topic"])).trim()
await git.checkout("main")
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
gitExec(["git", "--git-dir", bare, "update-ref", "refs/pull/1/head", head])
gitExec(["git", "--git-dir", bare, "update-ref", "-d", "refs/heads/topic"])
await git.branch(["-D", "topic"])
const manager = createManager(clone)
const internal = manager as unknown as {
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
}
internal.fetchPRInfo = async () => ({
headRefName: "topic",
isCrossRepository: false,
title: "Topic PR",
})
const result = await manager.createFromPR("https://github.com/org/repo/pull/1")
const upstream = await git.raw(["config", "--get", "branch.topic.remote"]).catch(() => "")
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
expect(worktreeHead).toBe(head)
expect(upstream.trim()).toBe("")
})
})
// ---------------------------------------------------------------------------
// WorktreeManager -- git lock serialization
// ---------------------------------------------------------------------------
describe("WorktreeManager git lock serialization", () => {
it("concurrent worktree creations both succeed", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
const [a, b] = await Promise.all([
mgr.createWorktree({ prompt: "concurrent-a" }),
mgr.createWorktree({ prompt: "concurrent-b" }),
])
expect(a.branch).not.toBe(b.branch)
const statA = await fs.stat(path.join(a.path, ".git"))
const statB = await fs.stat(path.join(b.path, ".git"))
expect(statA.isFile()).toBe(true)
expect(statB.isFile()).toBe(true)
})
it("lock releases after error so subsequent operations succeed", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
// First operation fails (nonexistent branch)
const failing = mgr.createWorktree({ existingBranch: "nonexistent" }).catch((e: unknown) => e)
// Second operation queues behind the first and should succeed after lock release
const succeeding = mgr.createWorktree({ prompt: "after-error" })
const [err, result] = await Promise.all([failing, succeeding])
expect(err).toBeInstanceOf(Error)
expect(result.branch).toBeTruthy()
const stat = await fs.stat(path.join(result.path, ".git"))
expect(stat.isFile()).toBe(true)
})
it("concurrent remove and create on the same repo do not conflict", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
// Create a worktree first
const wt = await mgr.createWorktree({ prompt: "to-remove" })
// Concurrently remove and create
const [, created] = await Promise.all([mgr.removeWorktree(wt.path), mgr.createWorktree({ prompt: "new-one" })])
expect(created.branch).toBeTruthy()
const stat = await fs.stat(path.join(created.path, ".git"))
expect(stat.isFile()).toBe(true)
})
})