diff --git a/.changeset/worktree-for-cli.md b/.changeset/worktree-for-cli.md new file mode 100644 index 00000000000..e13eb58f33a --- /dev/null +++ b/.changeset/worktree-for-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add `kilo --worktree ` to create (or reuse) a git worktree and start the TUI there, placed at `.kilo/worktrees/` alongside worktrees created by the VS Code extension's Agent Manager. Also adds `kilo worktree create/list/remove` for managing worktrees without launching the TUI, and a `/worktree` command in the TUI to list and remove them. Resuming an explicit `--session ` now tries to restart in the worktree the session was originally created in, if it still exists. diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 6258904e8c9..9b82c2a1d7e 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -168,6 +168,12 @@ export const TuiThreadCommand = cmd({ type: "boolean", describe: "fetch session from cloud and continue locally (use with --session)", }) + // kilocode_change start - create/reuse a git worktree before starting + .option("worktree", { + type: "string", + describe: "create (or reuse) a git worktree with this name and start kilo there", + }) + // kilocode_change end .option("prompt", { type: "string", describe: "prompt to use", @@ -262,6 +268,7 @@ export const TuiThreadCommand = cmd({ const { importCloudSession, localSessionID, validateCloudFork } = await import("@/kilocode/cloud-session") const { KiloTuiThreadDaemon } = await import("@/kilocode/cli/cmd/tui/thread") const { preload } = await import("@/kilocode/cli/cmd/tui") + const { resolveTuiDirectory } = await import("@/kilocode/cli/cmd/tui-worktree") // kilocode_change end const unguard = win32InstallCtrlCGuard() const shutdown = { @@ -286,7 +293,14 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const next = resolveThreadDirectory(args.project) + // kilocode_change start - `--worktree ` creates/reuses a worktree; resuming + // an explicit `--session ` tries to restart in that session's worktree + const next = await resolveTuiDirectory(args, resolveThreadDirectory(args.project)).catch((error) => { + UI.error(errorMessage(error)) + process.exitCode = 1 + }) + if (!next) return + // kilocode_change end const file = await target() // kilocode_change start const preloads = preload(typeof KILO_WORKER_PATH !== "undefined", () => diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts new file mode 100644 index 00000000000..78ba537c957 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -0,0 +1,216 @@ +// kilocode_change - new file +// Supports `kilo --worktree ` (create/reuse a git worktree before the TUI +// starts, placed at `.kilo/worktrees/` to match Agent Manager's own +// worktrees) and resuming an explicit `--session ` in the worktree it was +// created in. +import path from "path" +import type { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" +import { UI } from "@/cli/ui" +import { Filesystem } from "@/util/filesystem" +import { errorMessage } from "@/util/error" + +const log = Log.create({ service: "kilocode.tui-worktree" }) + +// Matches packages/kilo-vscode/src/agent-manager/WorktreeManager.ts's placement +// and its ensureGitExclude(), keeping `.kilo/worktrees/` out of `git status`. +const KILO_WORKTREE_DIR = ".kilo/worktrees" + +// Exported for tests. +export async function ensureGitExclude(root: string) { + const excludePath = path.join(root, ".git", "info", "exclude") + const current = (await Filesystem.readText(excludePath).catch(() => "")).replace(/\s+$/, "") + if (current.includes(`${KILO_WORKTREE_DIR}/`)) return + const prefix = current ? `${current}\n\n` : "" + await Filesystem.write(excludePath, `${prefix}# Kilo Code agent worktrees\n${KILO_WORKTREE_DIR}/\n`).catch((err) => + log.error("failed to update .git/info/exclude", { excludePath, err }), + ) +} + +function samePath(a: string, b: string) { + return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b +} + +// Exported for tests. +export function slugify(name: string) { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +// `InstanceStore.Interface.provide` loads (and caches) the instance context and +// scopes an effect to it; this adds the AppRuntime execution plus a matching +// `disposeDirectory` once the caller is done with `root`. +async function withInstance( + root: string, + fn: (run: (effect: Effect.Effect) => Promise) => Promise, +) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { InstanceStore } = await import("@/project/instance-store") + const run = (effect: Effect.Effect) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.provide({ directory: root }, effect))) + try { + return await fn(run) + } finally { + await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeDirectory(root))) + } +} + +type WaitResult = { ok: true } | { ok: false; message: string } + +/** Waits for `directory`'s `worktree.setup.ready`/`worktree.failed` event (full + * readiness, not just checkout); `cancel()` drops the timer/listener early. */ +function waitForWorktreeEvent( + bus: typeof import("@/bus/global").GlobalBus, + event: typeof import("@/worktree").Worktree.Event, + directory: string, + timeoutMs: number, +) { + const deferred = Promise.withResolvers() + let handler = (_e: { directory?: string; payload?: any }) => {} + const cleanup = () => { + clearTimeout(timer) + bus.off("event", handler) + } + // Not `unref()`'d: it's the only thing keeping the launcher's event loop + // alive while waiting, so a collected timer would let it exit 0 on a stall. + const timer = setTimeout(() => { + cleanup() + deferred.resolve({ ok: false, message: "Timed out waiting for the worktree to finish setting up" }) + }, timeoutMs) + handler = (e) => { + if (e.directory !== directory) return + if (e.payload?.type === event.SetupReady.type) { + cleanup() + deferred.resolve({ ok: true }) + } else if (e.payload?.type === event.Failed.type) { + cleanup() + deferred.resolve({ ok: false, message: e.payload.properties?.message ?? "Worktree setup failed" }) + } + } + bus.on("event", handler) + return { promise: deferred.promise, cancel: cleanup } +} + +// Exported for `kilo worktree create` (worktree.ts), which calls this and +// exits instead of going on to launch the TUI. +export async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_000) { + const { Worktree } = await import("@/worktree") + const { GlobalBus } = await import("@/bus/global") + const { InstanceState } = await import("@/effect/instance-state") + const { primaryWorktree } = await import("@/kilocode/primary-worktree") + const { Git } = await import("@/git") + const slug = slugify(name) + if (!slug) throw new Error(`Invalid worktree name "${name}"`) + return withInstance(root, async (run) => { + const ctx = await run(InstanceState.context) + // ctx.worktree is whichever checkout the command runs in. Creating under + // it unconditionally would nest `.kilo/worktrees/` inside another + // worktree and branch off that worktree's HEAD instead of the primary + // checkout's — refuse rather than get this wrong. + const primary = await run(primaryWorktree(ctx.worktree)) + if (primary && !samePath(primary, ctx.worktree)) + throw new Error( + `Cannot create worktree "${slug}" from inside another worktree (${ctx.worktree}). Run this from the primary checkout at ${primary} instead.`, + ) + + const directory = path.join(ctx.worktree, KILO_WORKTREE_DIR, slug) + // Trust neither signal alone: a directory can exist without a live git + // registration (orphaned), and a registration can outlive its directory + // (deleted out-of-band, or pruned). + const registered = (await run(Worktree.Service.use((svc) => svc.list()))).some((w) => + samePath(w.directory, directory), + ) + const exists = await Filesystem.exists(directory) + + if (registered && exists) { + // Assumes whatever created it already finished setting up; a worktree + // still booting when its own process was interrupted won't be detected. + UI.println(`Using existing worktree "${slug}" at ${directory}`) + return directory + } + if (exists) throw new Error(`"${directory}" already exists but is not a registered git worktree.`) + // Registered but missing: reclaim the dead registration (and its branch) + // the same way a fresh run would need to, so the name is free to reuse. + if (registered) { + await run(Worktree.Service.use((svc) => svc.remove({ directory }))).catch((error) => { + throw new Error(`Failed to reclaim stale worktree "${slug}": ${errorMessage(error)}`) + }) + } + + // `createFromInfo` below passes `branch: slug` straight to `git worktree + // add -b`, which fails outright if that branch already exists — e.g. one + // left behind by `git worktree prune` (which drops the registration but + // never deletes the branch). Clear it first so the name can be reused, but + // only with `-d` (not `-D`): it refuses unless the branch is fully merged, + // so we never silently discard unmerged work from an interrupted worktree + // or an unrelated branch that happens to share the name. + const runGit = (args: string[]) => run(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const branchRef = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${slug}`]) + if (branchRef.exitCode === 0) { + const deleted = await runGit(["branch", "-d", slug]) + if (deleted.exitCode !== 0) { + const message = deleted.stderr.toString("utf8").trim() || deleted.text().trim() + throw new Error( + `Branch "${slug}" already exists${message ? ` (${message})` : ""}. Remove it (e.g. \`git branch -D ${slug}\` if you're sure it's safe to discard) and retry.`, + ) + } + } + + await ensureGitExclude(ctx.worktree) + UI.println(`Creating worktree "${slug}"...`) + const wait = waitForWorktreeEvent(GlobalBus, Worktree.Event, directory, timeoutMs) + await run(Worktree.Service.use((svc) => svc.createFromInfo({ name: slug, branch: slug, directory }))).catch( + (error) => { + wait.cancel() + throw error + }, + ) + const result = await wait.promise + if (!result.ok) throw new Error(`Failed to create worktree "${slug}": ${result.message}`) + UI.println(`Worktree ready at ${directory}`) + return directory + }) +} + +// Reads only the session's `directory` column against a throwaway +// Database-only layer instead of the full AppRuntime (Plugin/LSP/MCP/Provider/ +// Observability/etc), since `--session ` is common and shouldn't pay for +// bootstrapping the whole app graph in the launcher process just for this. +async function resolveSessionWorktree(sessionID: string, fallback: string) { + try { + const { Effect, Schema } = await import("effect") + const { Database } = await import("@opencode-ai/core/database/database") + const { SessionTable } = await import("@opencode-ai/core/session/sql") + const { eq } = await import("drizzle-orm") + const { SessionID } = await import("@/session/schema") + const id = Schema.decodeUnknownSync(SessionID)(sessionID) + const row = await Effect.runPromise( + Database.Service.use(({ db }) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).pipe( + Effect.provide(Database.layerFromPath(Database.path())), + ), + ) + const directory = row?.directory + if (!directory || directory === fallback) return fallback + if (!(await Filesystem.exists(directory))) return fallback + UI.println(`Resuming session in its original worktree: ${directory}`) + return directory + } catch { + // Unknown session, missing directory, or lookup failure: fall back to the + // resolved cwd and let normal session validation report the real error. + return fallback + } +} + +/** + * Resolves the directory to launch the TUI in: creates/reuses `--worktree + * `, or when resuming an explicit `--session ` without `--project`, + * tries that session's original worktree. Otherwise returns `root` unchanged. + */ +export function resolveTuiDirectory(args: { worktree?: string; session?: string; project?: string }, root: string) { + if (args.worktree) return resolveWorktree(args.worktree, root) + if (args.session && !args.project) return resolveSessionWorktree(args.session, root) + return Promise.resolve(root) +} diff --git a/packages/opencode/src/kilocode/cli/cmd/worktree.ts b/packages/opencode/src/kilocode/cli/cmd/worktree.ts new file mode 100644 index 00000000000..f9179b40d51 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/worktree.ts @@ -0,0 +1,99 @@ +// kilocode_change - new file +// `kilo worktree list`/`remove`: CLI-side counterpart to `kilo --worktree ` +// (tui-worktree.ts) and the TUI's `/worktree` alias for the workspaces dialog +// (packages/tui/src/app.tsx). All three go through the same `Worktree.Service`. +import path from "path" +import { Effect } from "effect" +import { cmd } from "@/cli/cmd/cmd" +import { CliError, effectCmd, fail } from "@/cli/effect-cmd" +import { UI } from "@/cli/ui" +import { errorMessage } from "@/util/error" +import { slugify } from "@/kilocode/cli/cmd/tui-worktree" + +const wrapErr = (message: string) => (effect: Effect.Effect) => + effect.pipe(Effect.mapError((error) => new CliError({ message: `${message}: ${errorMessage(error)}` }))) + +// Lazy: this module is imported eagerly by KiloCli.register (see setup.ts), so +// `@/worktree`'s heavier transitive graph (Project, Provider, ...) must not +// load until a handler actually runs, matching tui-worktree.ts's own imports. +// `tryPromise` (not `promise`): a failed dynamic import must flow through each +// call site's own `wrapErr`, not become an unrecoverable defect. +const importWorktree = Effect.tryPromise(() => import("@/worktree")) + +const listWorktrees = importWorktree.pipe( + Effect.flatMap(({ Worktree }) => Worktree.Service.use((svc) => svc.list())), + wrapErr("Failed to list worktrees"), +) + +export const WorktreeCommand = cmd({ + command: "worktree", + describe: "manage git worktrees", + builder: (yargs) => + yargs.command(WorktreeCreateCommand).command(WorktreeListCommand).command(WorktreeRemoveCommand).demandCommand(), + async handler() {}, +}) + +export const WorktreeCreateCommand = cmd({ + command: "create ", + describe: "create (or reuse) a git worktree by name", + builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true }), + async handler(args) { + // Plain cmd(), not effectCmd(): resolveWorktree loads/disposes its own + // instance context (it's shared with `kilo --worktree`'s pre-TUI-launch + // path in tui-worktree.ts), so it can't run inside effectCmd's own. + const { resolveWorktree } = await import("@/kilocode/cli/cmd/tui-worktree") + const directory = await resolveWorktree(args.name, process.cwd()).catch((error) => { + UI.error(errorMessage(error)) + process.exitCode = 1 + }) + // Prints only the resolved path on stdout (status messages already went + // to stderr via resolveWorktree's own UI.println calls), so scripts can + // do e.g. `cd "$(kilo worktree create foo)"`. + if (directory) console.log(directory) + }, +}) + +export const WorktreeListCommand = effectCmd({ + command: "list", + describe: "list git worktrees for the current project", + handler: Effect.fn("Cli.worktree.list")(function* () { + const list = yield* listWorktrees + if (!list.length) { + UI.println("No worktrees found.") + return + } + // Data rows go to stdout (like `kilo session list`'s table/JSON), not + // UI.println's stderr, so `kilo worktree list | ...` actually captures them. + for (const w of list) console.log(`${w.name}${w.branch ? ` (${w.branch})` : ""} ${w.directory}`) + }), +}) + +export const WorktreeRemoveCommand = effectCmd({ + command: "remove ", + // Worktree.Service.remove() force-deletes (`git branch -D`) the worktree's + // branch too, with no way to opt out (matches the TUI workspaces dialog's + // existing semantics) — make that explicit rather than surprise users. + describe: "remove a git worktree by name, deleting its branch too", + builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true }), + handler: Effect.fn("Cli.worktree.remove")(function* (args) { + const slug = slugify(args.name) + if (!slug) { + yield* fail(`Invalid worktree name "${args.name}"`) + return + } + const list = yield* listWorktrees + // Matches the reuse logic in tui-worktree.ts: list() remaps `name` to the + // project ID when a worktree's basename collides with the primary + // checkout's, so also match on the directory basename. + const found = list.find((w) => w.name.toLowerCase() === slug || path.basename(w.directory).toLowerCase() === slug) + if (!found) { + yield* fail(`No worktree named "${args.name}" found.`) + return + } + const removeMsg = `Failed to remove worktree "${args.name}"` + const { Worktree } = yield* importWorktree.pipe(wrapErr(removeMsg)) + yield* Worktree.Service.use((svc) => svc.remove({ directory: found.directory })).pipe(wrapErr(removeMsg)) + const branchNote = found.branch ? ` and branch "${found.branch}"` : "" + UI.println(`Removed worktree "${found.name}" at ${found.directory}${branchNote}`) + }), +}) diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index fd14af0f88f..d7fd3e3c071 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -11,6 +11,7 @@ import { DaemonCommand } from "@/kilocode/cli/cmd/daemon" import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup" import { RemoteCommand } from "@/cli/cmd/remote" import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config" +import { WorktreeCommand } from "@/kilocode/cli/cmd/worktree" const log = Log.create({ service: "kilocode.cli" }) @@ -56,6 +57,7 @@ export namespace KiloCli { .command(RemoteCommand) .command(DaemonCommand) .command(ConfigCLICommand) + .command(WorktreeCommand) if (InstallationBuildKind !== "release") cli.command(DevSetupCommand).command(DevAliasCommand) // Safe self-reference: `cli` is a typed parameter and yargs `.command()` returns the same // instance, so the help command can resolve the fully-built root at handler time. This also diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f8f8ab7324d..ab3c7727e7c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -277,6 +277,18 @@ const layer: Layer.Layer< }) yield* runStartScripts(info.directory, { projectID, extra }) + + // kilocode_change start - signal full readiness once setup also completes + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { + type: Event.SetupReady.type, + properties: { name: info.name, ...(info.branch ? { branch: info.branch } : {}) }, + }, + }) + // kilocode_change end }) const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index be22f5648ae..99403925aad 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -9,7 +9,7 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(EventManifest.Latest.size).toBe(89) // kilocode_change - include global.config.updated + expect(EventManifest.Latest.size).toBe(90) // kilocode_change - include global.config.updated and worktree.setup.ready expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) diff --git a/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts new file mode 100644 index 00000000000..627017ec0fc --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { tmpdir } from "../../../fixture/fixture" +import { ensureGitExclude, slugify } from "@/kilocode/cli/cmd/tui-worktree" +import { Filesystem } from "@/util/filesystem" + +describe("slugify", () => { + test("lowercases and dashes non-alphanumeric runs", () => { + expect(slugify("My Feature!")).toBe("my-feature") + expect(slugify("fix_bug--123")).toBe("fix-bug-123") + }) + + test("trims leading/trailing dashes", () => { + expect(slugify("--hello--")).toBe("hello") + }) + + test("returns empty for names with no alphanumeric characters", () => { + expect(slugify("!!!")).toBe("") + expect(slugify(" ")).toBe("") + }) +}) + +describe("ensureGitExclude", () => { + test("appends the exclude entry when the file exists but is empty", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(tmp.path) + expect(await Filesystem.readText(excludePath)).toContain(".kilo/worktrees/") + }) + + test("preserves existing content and adds a newline before the new entry", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "*.log") + await ensureGitExclude(tmp.path) + const content = await Filesystem.readText(excludePath) + expect(content).toContain("*.log") + expect(content).toContain(".kilo/worktrees/") + }) + + test("is idempotent when the entry already exists", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(tmp.path) + await ensureGitExclude(tmp.path) + const content = await Filesystem.readText(excludePath) + expect(content.match(/\.kilo\/worktrees\//g)?.length).toBe(1) + }) + + test("does not throw when .git/info is missing", async () => { + await using tmp = await tmpdir() + await ensureGitExclude(tmp.path) + }) +}) diff --git a/packages/schema/src/worktree-event.ts b/packages/schema/src/worktree-event.ts index c42ea5821e1..522e70dfa30 100644 --- a/packages/schema/src/worktree-event.ts +++ b/packages/schema/src/worktree-event.ts @@ -19,4 +19,14 @@ export const Failed = Event.define({ }, }) -export const Definitions = Event.inventory(Ready, Failed) +// kilocode_change start - fires after the worktree's start script finishes, unlike Ready +export const SetupReady = Event.define({ + type: "worktree.setup.ready", + schema: { + name: Schema.String, + branch: optional(Schema.String), + }, +}) +// kilocode_change end + +export const Definitions = Event.inventory(Ready, Failed, SetupReady) // kilocode_change diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c96296caca7..b148b9c44f5 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -632,6 +632,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi category: "Workspace", hidden: !Flag.KILO_EXPERIMENTAL_WORKSPACES, slashName: "workspaces", + slashAliases: ["worktree", "worktrees"], // kilocode_change - `kilo --worktree` worktrees are workspaces too run: () => { dialog.replace(() => ) },