Files
openclaw/src/cli/worktrees-cli.ts
Peter Steinberger 0acd851a3b feat(agents): add managed git worktree lifecycle (create/provision/snapshot/restore/GC) (#100535)
Centralized managed worktrees under <state-dir>/worktrees/<repo-fingerprint>/<name>
with branch-per-task (openclaw/<name>), .worktreeinclude provisioning, an optional
.openclaw/worktree-setup.sh repo hook, and a SQLite registry in the shared state DB.
Removal always snapshots the tree (untracked included, gitignored excluded) to
refs/openclaw/snapshots/<id>; restore rebuilds the branch at the original commit with
the snapshot content as uncommitted state. Lossless run-end cleanup, 7-day idle GC for
run-owned worktrees (manual exempt), orphan reconciliation, 30-day snapshot retention.
Surfaces: worktrees.* gateway RPC (operator.admin mutations), openclaw worktrees CLI,
Control UI page, plugin-SDK facade + Workboard kind:"worktree" materialization.

E2E-verified on Testbox: full create->work->remove->restore->gc lifecycle.
2026-07-06 05:24:58 +01:00

130 lines
4.3 KiB
TypeScript

import type { Command } from "commander";
import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core/src/table.js";
import { managedWorktrees } from "../agents/worktrees/service.js";
import type { ManagedWorktreeRecord } from "../agents/worktrees/types.js";
import { defaultRuntime } from "../runtime.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
type JsonOption = { json?: boolean };
function printJson(value: unknown): void {
// writeJson targets process.stdout directly; runtime.log routes through the console
// logger and never reaches piped stdout, which breaks `--json | jq` consumers.
defaultRuntime.writeJson(value);
}
function printRecord(record: ManagedWorktreeRecord, json: boolean): void {
if (json) {
printJson(record);
return;
}
defaultRuntime.log(`${record.id}\t${record.path}`);
}
export function registerWorktreesCli(program: Command): void {
const worktrees = program
.command("worktrees")
.description("Create, inspect, restore, and clean up managed worktrees");
worktrees
.command("list")
.description("List active and restorable managed worktrees")
.option("--json", "Output JSON", false)
.action(async (opts: JsonOption) => {
const records = await managedWorktrees.list();
if (opts.json) {
printJson({ worktrees: records });
return;
}
if (records.length === 0) {
defaultRuntime.log("No managed worktrees.");
return;
}
defaultRuntime.log(
renderTable({
width: getTerminalTableWidth(),
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Repo", header: "Repo", minWidth: 18, flex: true },
{ key: "Branch", header: "Branch", minWidth: 18, flex: true },
{ key: "Status", header: "Status", minWidth: 10 },
],
rows: records.map((record) => ({
ID: record.id,
Repo: record.repoRoot,
Branch: record.branch,
Status: record.removedAt ? "restorable" : "active",
})),
}).trimEnd(),
);
});
worktrees
.command("create")
.description("Create a managed worktree")
.argument("<repoRoot>", "Source git checkout")
.option("--name <name>", "Managed worktree name")
.option("--base-ref <ref>", "Git ref to branch from")
.option("--json", "Output JSON", false)
.action(async (repoRoot: string, opts: JsonOption & { name?: string; baseRef?: string }) => {
printRecord(
await managedWorktrees.create({
repoRoot,
name: opts.name,
baseRef: opts.baseRef,
ownerKind: "manual",
}),
opts.json === true,
);
});
worktrees
.command("remove")
.description("Snapshot and remove a managed worktree")
.argument("<id>", "Managed worktree id")
.option("--force", "Remove even if snapshot creation fails", false)
.option("--json", "Output JSON", false)
.action(async (id: string, opts: JsonOption & { force?: boolean }) => {
const result = await managedWorktrees.remove({
id,
reason: "manual-delete",
force: opts.force,
});
if (opts.json) {
printJson(result);
} else {
defaultRuntime.log(
result.snapshotError
? `Removed ${id} without a snapshot: ${result.snapshotError}`
: `Removed ${id}.`,
);
}
});
worktrees
.command("restore")
.description("Restore a managed worktree from its snapshot")
.argument("<id>", "Managed worktree id")
.option("--json", "Output JSON", false)
.action(async (id: string, opts: JsonOption) => {
printRecord(await managedWorktrees.restore({ id }), opts.json === true);
});
worktrees
.command("gc")
.description("Run managed worktree cleanup now")
.option("--json", "Output JSON", false)
.action(async (opts: JsonOption) => {
const result = await managedWorktrees.gc();
if (opts.json) {
printJson(result);
} else {
defaultRuntime.log(
`Removed ${result.removed.length}; deleted ${result.orphansDeleted} orphans; pruned ${result.snapshotsPruned} snapshots.`,
);
}
});
applyParentDefaultHelpAction(worktrees);
}