Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ import {
import {
createWorktreeLease,
cleanWorktreeLease,
cancelWorktreeLease,
formatWorktreeLeaseResult,
formatWorktreeCancelResult,
} from "./worktree-lease.js";
import { evaluateCommandPolicy, formatCommandPolicyDecision } from "./command-policy.js";
import {
Expand Down Expand Up @@ -373,6 +375,8 @@ program
.option("--outcomes-file <file>", "Command-outcomes file (JSON array) to include in --export-evidence")
.option("--create-worktree", "Create a leased git worktree for a mutating delegated agent and exit")
.option("--clean-worktree", "Clean a leased git worktree after verified completion and exit")
.option("--cancel-worktree", "Cancel a leased git worktree, preserving committed work and failing closed on uncommitted work, and exit")
.option("--cancel-force", "With --cancel-worktree, discard uncommitted work instead of refusing")
.option("--agent-identity <id>", "Stable agent identity for a leased worktree (with --create-worktree/--clean-worktree)")
.option("--worktree-root <dir>", "Directory where leased worktrees live (default <workspace>/.oh-my-cli/worktrees)")
.option("--command-policy <command>", "Evaluate one shell command against the offline command policy and exit")
Expand Down Expand Up @@ -1378,14 +1382,15 @@ program
// mutating delegated agent, offline (no provider config needed). Exits 0
// on success (including idempotent no-ops), 1 on a safety refusal, and 2
// on a usage error or unexpected git failure.
if (opts.createWorktree || opts.cleanWorktree) {
if (opts.createWorktree || opts.cleanWorktree || opts.cancelWorktree) {
const format = String(opts.output ?? "text");
if (format !== "text" && format !== "json") {
process.stderr.write(`Error: invalid output format "${format}"\n`);
process.exit(2);
}
if (opts.createWorktree && opts.cleanWorktree) {
process.stderr.write("Error: choose one of --create-worktree or --clean-worktree\n");
const chosen = [opts.createWorktree, opts.cleanWorktree, opts.cancelWorktree].filter(Boolean).length;
if (chosen > 1) {
process.stderr.write("Error: choose one of --create-worktree, --clean-worktree, or --cancel-worktree\n");
process.exit(2);
}
if (!opts.taskIdentity) {
Expand All @@ -1402,6 +1407,15 @@ program
agentIdentity: String(opts.agentIdentity),
worktreeRoot: opts.worktreeRoot,
};
if (opts.cancelWorktree) {
const cancelResult = cancelWorktreeLease(leaseOpts, { force: Boolean(opts.cancelForce) });
if (format === "json") {
process.stdout.write(JSON.stringify(cancelResult) + "\n");
} else {
process.stdout.write(formatWorktreeCancelResult(cancelResult) + "\n");
}
process.exit(cancelResult.ok ? 0 : cancelResult.reason === "git_error" ? 2 : 1);
}
const result = opts.createWorktree
? createWorktreeLease(leaseOpts)
: cleanWorktreeLease(leaseOpts);
Expand Down
161 changes: 161 additions & 0 deletions src/worktree-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,39 @@ export type WorktreeCleanResult =
| { ok: true; lease: WorktreeLease; cleaned: boolean }
| { ok: false; reason: WorktreeCleanRefusalReason; message: string };

/** Why a lease cancellation was refused (the lease is retained safely). */
export type WorktreeCancelRefusalReason =
| "non_repository"
| "ambiguous"
| "uncommitted_changes"
| "git_error";

/** A commit preserved on the lease branch by a cancellation (bounded, redacted). */
export interface PreservedCommit {
sha: string;
subject: string;
}

/** The committed work a cancellation preserves on the (kept) lease branch. */
export interface WorktreeCancelPreserved {
branch: string;
commits: PreservedCommit[];
truncatedCommits: number;
}

export type WorktreeCancelResult =
| {
ok: true;
lease: WorktreeLease;
/** False when the lease was already absent (idempotent no-op). */
cancelled: boolean;
worktreeRemoved: boolean;
/** True when uncommitted work was discarded via --cancel-force. */
forced: boolean;
preserved: WorktreeCancelPreserved;
}
| { ok: false; reason: WorktreeCancelRefusalReason; message: string };

function redact(text: string): string {
return redactSecrets(text).text;
}
Expand Down Expand Up @@ -394,6 +427,102 @@ export function cleanWorktreeLease(opts: WorktreeLeaseOptions): WorktreeCleanRes
return { ok: true, cleaned: true, lease };
}

// Bound the preserved-commit list so a long-lived lease cannot inflate the report.
const MAX_PRESERVED_COMMITS = 50;

// List the lease branch's commits not yet in the parent HEAD — the work a
// cancellation preserves by keeping the branch. Bounded and redacted.
function collectPreservedCommits(repo: string, branch: string): WorktreeCancelPreserved {
const parentHead = git(repo, ["rev-parse", "HEAD"]).stdout.trim();
if (!parentHead || !branchRefExists(repo, branch)) {
return { branch, commits: [], truncatedCommits: 0 };
}
const log = git(repo, ["log", "--format=%H%x09%s", `${parentHead}..${branch}`]);
const lines = log.stdout.split("\n").filter((line) => line.trim() !== "");
const commits = lines.slice(0, MAX_PRESERVED_COMMITS).map((line) => {
const tab = line.indexOf("\t");
const sha = tab >= 0 ? line.slice(0, tab) : line;
const subject = tab >= 0 ? line.slice(tab + 1) : "";
return { sha: sha.slice(0, 12), subject: redact(subject) };
});
return { branch, commits, truncatedCommits: Math.max(0, lines.length - commits.length) };
}

/**
* Cancel a leased workspace: the safe teardown path for a cancelled agent. Unlike
* `cleanWorktreeLease` (which requires the work to be merged before deleting the
* branch), cancellation PRESERVES committed work by keeping the lease branch and
* only removing the worktree, so the agent's commits survive for later integration.
* It fails closed when the worktree holds uncommitted changes that would be lost,
* unless `force` acknowledges the discard. Idempotent: cancelling an absent lease
* is a no-op. The parent worktree is never touched.
*/
export function cancelWorktreeLease(
opts: WorktreeLeaseOptions,
cancelOpts: { force?: boolean } = {},
): WorktreeCancelResult {
const force = Boolean(cancelOpts.force);
const repo = path.resolve(opts.repo);
const task = (opts.taskIdentity ?? "").trim();
const agent = (opts.agentIdentity ?? "").trim();
if (!task || !agent) {
return {
ok: false,
reason: "ambiguous",
message: "both --task-identity and --agent-identity are required to locate a lease",
};
}

const commonDir = repoCommonDir(repo);
if (commonDir === null) {
return { ok: false, reason: "non_repository", message: "target is not a git repository" };
}

const identity = deriveLeaseIdentity({ repoKey: commonDir, taskIdentity: task, agentIdentity: agent });
const worktreeRoot = path.resolve(opts.worktreeRoot ?? defaultWorktreeRoot(commonDir));
const worktreePath = path.join(worktreeRoot, identity.leaseId);

const wtListed = worktreeRegistered(repo, worktreePath);
const branchExists = branchRefExists(repo, identity.branch);
const baseSha = git(repo, ["rev-parse", "--verify", "--quiet", identity.branch]).stdout.trim();
const lease = buildLease({ identity, worktreePath, baseSha, taskIdentity: task, agentIdentity: agent });
const emptyPreserved: WorktreeCancelPreserved = { branch: identity.branch, commits: [], truncatedCommits: 0 };

if (!wtListed && !branchExists) {
// Already absent — idempotent no-op.
return { ok: true, cancelled: false, worktreeRemoved: false, forced: force, lease, preserved: emptyPreserved };
}

// Fail closed on uncommitted work unless --force acknowledges the loss.
const dirty = wtListed ? dirtyCount(worktreePath) : 0;
if (dirty > 0 && !force) {
return {
ok: false,
reason: "uncommitted_changes",
message: `lease worktree has ${dirty} uncommitted change(s) that would be lost; commit them or pass --cancel-force to discard`,
};
}

// Preserve committed work (the branch is kept), then remove the worktree.
const preserved = collectPreservedCommits(repo, identity.branch);
let worktreeRemoved = false;
if (wtListed) {
const rm = force
? git(repo, ["worktree", "remove", "--force", worktreePath])
: git(repo, ["worktree", "remove", worktreePath]);
if (!rm.ok) {
return {
ok: false,
reason: "git_error",
message: redact(`git worktree remove failed: ${rm.stderr.trim() || "unknown error"}`),
};
}
worktreeRemoved = true;
}

return { ok: true, cancelled: true, worktreeRemoved, forced: force, lease, preserved };
}

// --- formatting -------------------------------------------------------------

/** A concise, redacted, human-readable lease result. */
Expand Down Expand Up @@ -430,3 +559,35 @@ export function formatWorktreeLeaseResult(
}
return lines.join("\n");
}

/** A concise, redacted, human-readable cancellation result. */
export function formatWorktreeCancelResult(result: WorktreeCancelResult): string {
const lines: string[] = [];
lines.push(`Worktree lease cancellation (${WORKTREE_LEASE_SCHEMA} v${WORKTREE_LEASE_VERSION})`);
lines.push("─".repeat(40));
lines.push("action: cancel");
if (result.ok) {
const status = !result.cancelled
? "already absent (idempotent)"
: result.forced
? "cancelled (forced; uncommitted work discarded)"
: "cancelled";
lines.push("result: ok");
lines.push(`status: ${status}`);
lines.push(`lease: ${result.lease.leaseId}`);
lines.push(`branch: ${result.lease.branch} (preserved)`);
lines.push(`worktree: ${result.lease.worktreePath}${result.worktreeRemoved ? " (removed)" : ""}`);
lines.push(`preserved commits: ${result.preserved.commits.length}`);
for (const commit of result.preserved.commits) {
lines.push(` ${commit.sha} ${commit.subject}`);
}
if (result.preserved.truncatedCommits > 0) {
lines.push(` … ${result.preserved.truncatedCommits} more commit(s) beyond the bound`);
}
} else {
lines.push("result: refused");
lines.push(`reason: ${result.reason}`);
lines.push(`detail: ${redact(result.message)}`);
}
return lines.join("\n");
}
133 changes: 133 additions & 0 deletions tests/integration/cancellation-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execFileSync } from "node:child_process";
import { spawn } from "node:child_process";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";

function runCli(
args: string[],
env: Record<string, string | undefined>,
timeoutMs = 20_000,
): Promise<{ stdout: string; stderr: string; code: number | null }> {
return new Promise((resolve, reject) => {
const cliPath = path.resolve(import.meta.dirname, "../../dist/index.js");
const proc = spawn("node", [cliPath, ...args], {
env: { ...process.env, ...env },
timeout: timeoutMs,
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d) => { stdout += d; });
proc.stderr.on("data", (d) => { stderr += d; });
proc.on("close", (code) => resolve({ stdout, stderr, code }));
proc.on("error", reject);
});
}

function git(cwd: string, ...args: string[]): void {
execFileSync("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "ignore"] });
}

const tmpDirs: string[] = [];
function makeRepo(): string {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-cancel-int-"));
tmpDirs.push(repo);
execFileSync("git", ["init", "-q", "-b", "main", repo], { stdio: ["ignore", "pipe", "ignore"] });
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test");
fs.writeFileSync(path.join(repo, "a.txt"), "base\n");
git(repo, "add", "a.txt");
git(repo, "commit", "-q", "-m", "base");
return repo;
}

describe("Integration: cancellation cleanup (--cancel-worktree)", () => {
let homeDir: string;
let env: Record<string, string>;

beforeAll(() => {
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-cancel-int-home-"));
tmpDirs.push(homeDir);
env = { HOME: homeDir, OPENAI_API_KEY: "", OPENAI_BASE_URL: "", OPENAI_MODEL: "" };
});

afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});

async function createLease(repo: string, task: string, agent: string): Promise<string> {
const r = await runCli(
["--create-worktree", "--task-identity", task, "--agent-identity", agent, "--workspace", repo, "--output", "json"],
env,
);
expect(r.code).toBe(0);
const result = JSON.parse(r.stdout);
expect(result.ok).toBe(true);
return result.lease.worktreePath as string;
}

it("cancels a clean lease via the CLI: worktree removed, branch preserved", async () => {
const repo = makeRepo();
const wtPath = await createLease(repo, "task-clean", "agent-1");
expect(fs.existsSync(wtPath)).toBe(true);

const r = await runCli(
["--cancel-worktree", "--task-identity", "task-clean", "--agent-identity", "agent-1", "--workspace", repo, "--output", "json"],
env,
);
expect(r.code).toBe(0);
const result = JSON.parse(r.stdout);
expect(result.ok).toBe(true);
expect(result.cancelled).toBe(true);
expect(result.worktreeRemoved).toBe(true);
expect(fs.existsSync(wtPath)).toBe(false);
// The lease branch is preserved.
const branches = execFileSync("git", ["-C", repo, "branch", "--list", "lease/wt-*"], { encoding: "utf8" });
expect(branches.trim()).not.toBe("");
});

it("refuses to cancel a dirty lease without --cancel-force (exit 1)", async () => {
const repo = makeRepo();
const wtPath = await createLease(repo, "task-dirty", "agent-1");
fs.writeFileSync(path.join(wtPath, "c.txt"), "uncommitted\n");

const r = await runCli(
["--cancel-worktree", "--task-identity", "task-dirty", "--agent-identity", "agent-1", "--workspace", repo, "--output", "json"],
env,
);
expect(r.code).toBe(1);
const result = JSON.parse(r.stdout);
expect(result.ok).toBe(false);
expect(result.reason).toBe("uncommitted_changes");
// The worktree is retained.
expect(fs.existsSync(wtPath)).toBe(true);
});

it("discards uncommitted work with --cancel-force (exit 0)", async () => {
const repo = makeRepo();
const wtPath = await createLease(repo, "task-force", "agent-1");
fs.writeFileSync(path.join(wtPath, "c.txt"), "uncommitted\n");

const r = await runCli(
["--cancel-worktree", "--cancel-force", "--task-identity", "task-force", "--agent-identity", "agent-1", "--workspace", repo, "--output", "json"],
env,
);
expect(r.code).toBe(0);
const result = JSON.parse(r.stdout);
expect(result.ok).toBe(true);
expect(result.forced).toBe(true);
expect(result.worktreeRemoved).toBe(true);
expect(fs.existsSync(wtPath)).toBe(false);
});

it("rejects combining --cancel-worktree with --clean-worktree (exit 2)", async () => {
const repo = makeRepo();
const r = await runCli(
["--cancel-worktree", "--clean-worktree", "--task-identity", "t", "--agent-identity", "a", "--workspace", repo],
env,
);
expect(r.code).toBe(2);
expect(r.stderr).toContain("choose one of");
});
});
Loading
Loading