Files
Kilo-Org_kilocode/packages/opencode/test/kilocode/rules-migrator.test.ts
T
Marius 466644eb25 fix: use .kilo instead of .kilocode for config directories (#6881)
* fix: use .kilo instead of .kilocode for config directories

* fix: keep global ~/.kilocode untouched for legacy CLI compat

Only rename project-level .kilocode/ to .kilo/. The global ~/.kilocode
directory must stay because legacy CLI instances and legacy-migration.ts
in kilo-gateway still read from it.

* test: update VS Code extension tests to use .kilo directory paths

* fix: correct paths.ts comment and use specific git exclude entries

- Fix comment referencing ~/.kilo when globalDir() returns ~/.kilocode
- Revert to specific .kilo/ git exclude entries instead of blanket .kilo/
  to avoid hiding user-authored rules/workflows/config from git status

* fix: handle legacy per-worktree metadata and stale paths in state

- readMetadata() falls back to .kilocode/ inside worktrees since the
  per-worktree metadata dirs aren't renamed by the top-level migration
- Rewrite stale .kilocode/ paths in agent-manager.json on load

* fix: run .kilocode migration at extension activation, not just Agent Manager

Move migration call to the top of activate() so it runs for all users
on every extension startup, before kilo serve is spawned or any code
reads from the .kilo directory.

* fix: update git worktree refs after .kilocode → .kilo rename

After renaming the directory, git's internal .git/worktrees/*/gitdir
files still reference the old .kilocode path. This causes git to lose
track of worktrees, leading to crashes. Both the CLI and extension
migration now rewrite these gitdir files after a successful rename.

* fix: read from both .kilo and .kilocode, write to .kilo

Replace the one-time directory rename migration with a dual-read strategy:

- CLI config (rules, workflows, skills, MCP, project-id): read from both
  .kilo/ and .kilocode/ directories, with .kilo taking precedence
- Agent Manager data (worktrees, state, setup scripts): migrate from
  .kilocode/ to .kilo/ at startup since the extension exclusively owns these
- Config discovery (paths.ts, config.ts): include .kilocode in directory
  and agent/command pattern matching

Key decisions:
- .kilo/ is the new canonical write location for all new data
- .kilocode/ is read as a legacy fallback (no data loss for existing users)
- No directory rename: both dirs can coexist safely
- Agent Manager migration is item-level (moves individual files), not a
  full directory rename, so it handles both-dirs-exist gracefully
- Windows path rewrite in WorktreeStateManager handles both / and \ separators
- Workflow/MCP load order: .kilocode first, .kilo second (last wins)
- Rules dedup via seen-set with .kilo checked first (first wins)
- Delete migrate-kilo-dir.ts (no longer needed)

* fix: resolve .git file when fixing worktree refs during migration

When the project root is itself a worktree, .git is a file pointing
at the shared git dir, not a directory. Follow the gitdir pointer to
find the actual .git/worktrees/ location.

* fix: recover partial .kilo migrations and global dirs

Always repair stale git worktree refs when .kilo worktrees already exist so partially migrated repos recover on startup. Also dual-read global skills, rules, and workflows from both legacy and new home directories.

* fix: keep .kilo ahead of legacy config dirs

* fix: narrow legacy agent manager excludes

* chore: link migration cleanup follow-up
2026-03-12 12:50:28 +01:00

254 lines
9.0 KiB
TypeScript

import { test, expect, describe } from "bun:test"
import { RulesMigrator } from "../../src/kilocode/rules-migrator"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
async function withHome<T>(home: string, fn: () => Promise<T>): Promise<T> {
const prev = process.env.HOME
process.env.HOME = home
try {
return await fn()
} finally {
if (prev) process.env.HOME = prev
else delete process.env.HOME
}
}
describe("RulesMigrator", () => {
describe("discoverRules", () => {
test("discovers legacy .kilocoderules file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules"), "# Project rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].source).toBe("legacy")
expect(rules[0].path).toContain(".kilocoderules")
expect(rules[0].mode).toBeUndefined()
})
test("discovers .kilo/rules/ directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilo", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules", "coding.md"), "# Coding rules")
await Bun.write(path.join(dir, ".kilo", "rules", "testing.md"), "# Testing rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(2)
expect(rules.every((r) => r.source === "project")).toBe(true)
expect(rules.every((r) => r.mode === undefined)).toBe(true)
})
test("discovers rules from legacy .kilocode/rules/", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules", "legacy.md"), "# Legacy rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules.some((r) => r.path.includes("legacy.md"))).toBe(true)
})
test(".kilo/rules/ takes precedence over .kilocode/rules/ for same filename", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilo", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules", "main.md"), "# New rules")
await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Old rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
const mainRules = rules.filter((r) => r.path.includes("main.md"))
// Only one main.md should be found (.kilo wins)
expect(mainRules).toHaveLength(1)
expect(mainRules[0].path).toContain(".kilo/")
})
test("discovers mode-specific directory rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilo", "rules-code"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules-code", "style.md"), "# Code style")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].source).toBe("project")
expect(rules[0].mode).toBe("code")
})
test("discovers mode-specific legacy file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].source).toBe("legacy")
expect(rules[0].mode).toBe("architect")
})
test("ignores non-markdown files in rules directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilo", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules", "rules.md"), "# Rules")
await Bun.write(path.join(dir, ".kilo", "rules", "notes.txt"), "Notes")
await Bun.write(path.join(dir, ".kilo", "rules", "config.json"), "{}")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(1)
expect(rules[0].path).toContain("rules.md")
})
test("returns empty array for project without rules", async () => {
await using tmp = await tmpdir()
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(0)
})
test("discovers multiple rule sources together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Legacy file
await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules")
// Directory rules
await fs.mkdir(path.join(dir, ".kilo", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules", "main.md"), "# Main rules")
// Mode-specific
await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules")
},
})
const rules = await RulesMigrator.discoverRules(tmp.path)
expect(rules).toHaveLength(3)
expect(rules.some((r) => r.source === "legacy" && !r.mode)).toBe(true)
expect(rules.some((r) => r.source === "project")).toBe(true)
expect(rules.some((r) => r.mode === "code")).toBe(true)
})
test("discovers global rules from ~/.kilo/rules/", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilo", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules", "global.md"), "# Global rules")
await fs.mkdir(path.join(dir, "repo"), { recursive: true })
},
})
const rules = await withHome(tmp.path, () => RulesMigrator.discoverRules(path.join(tmp.path, "repo")))
expect(
rules.some((r) => r.source === "global" && r.path.includes(path.join(".kilo", "rules", "global.md"))),
).toBe(true)
})
})
describe("migrate", () => {
test("returns instructions array with discovered rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".kilo", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules", "main.md"), "# Main rules")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(1)
expect(result.instructions[0]).toContain("main.md")
})
test("warns about legacy files", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true)
expect(result.warnings.some((w) => w.includes(".kilo/rules/"))).toBe(true)
})
test("skips mode-specific rules when includeModeSpecific is false", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules")
},
})
const result = await RulesMigrator.migrate({
projectDir: tmp.path,
includeModeSpecific: false,
})
expect(result.instructions).toHaveLength(0)
expect(result.warnings.some((w) => w.includes("skipped"))).toBe(true)
})
test("includes mode-specific rules by default", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(1)
})
test("returns empty result for project without rules", async () => {
await using tmp = await tmpdir()
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(0)
expect(result.warnings).toHaveLength(0)
})
test("combines all rule sources", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy")
await fs.mkdir(path.join(dir, ".kilo", "rules"), { recursive: true })
await Bun.write(path.join(dir, ".kilo", "rules", "main.md"), "# Main")
await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect")
},
})
const result = await RulesMigrator.migrate({ projectDir: tmp.path })
expect(result.instructions).toHaveLength(3)
})
})
})