mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 02:11:42 +00:00
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.
130 lines
4.3 KiB
TypeScript
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);
|
|
}
|