diff --git a/src/index.ts b/src/index.ts index d29a63a..a8ee30e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,7 +140,9 @@ import { import { createWorktreeLease, cleanWorktreeLease, + cancelWorktreeLease, formatWorktreeLeaseResult, + formatWorktreeCancelResult, } from "./worktree-lease.js"; import { evaluateCommandPolicy, formatCommandPolicyDecision } from "./command-policy.js"; import { @@ -373,6 +375,8 @@ program .option("--outcomes-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 ", "Stable agent identity for a leased worktree (with --create-worktree/--clean-worktree)") .option("--worktree-root ", "Directory where leased worktrees live (default /.oh-my-cli/worktrees)") .option("--command-policy ", "Evaluate one shell command against the offline command policy and exit") @@ -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) { @@ -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); diff --git a/src/worktree-lease.ts b/src/worktree-lease.ts index 690df8c..7a4a940 100644 --- a/src/worktree-lease.ts +++ b/src/worktree-lease.ts @@ -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; } @@ -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. */ @@ -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"); +} diff --git a/tests/integration/cancellation-cleanup.test.ts b/tests/integration/cancellation-cleanup.test.ts new file mode 100644 index 0000000..22a4d27 --- /dev/null +++ b/tests/integration/cancellation-cleanup.test.ts @@ -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, + 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; + + 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 { + 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"); + }); +}); diff --git a/tests/unit/cancellation-cleanup.test.ts b/tests/unit/cancellation-cleanup.test.ts new file mode 100644 index 0000000..7f8d6cd --- /dev/null +++ b/tests/unit/cancellation-cleanup.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + createWorktreeLease, + cancelWorktreeLease, + formatWorktreeCancelResult, + WORKTREE_LEASE_SCHEMA, + WORKTREE_LEASE_VERSION, +} from "../../src/worktree-lease.js"; + +const tmpDirs: string[] = []; +afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); +}); + +function git(cwd: string, ...args: string[]): void { + execFileSync("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "ignore"] }); +} + +// A temp repo with one commit on `main`. +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-cancel-")); + 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; +} + +function createLease(repo: string): string { + const result = createWorktreeLease({ repo, taskIdentity: "task-1", agentIdentity: "agent-1" }); + if (!result.ok) throw new Error(`create failed: ${result.reason}`); + return result.lease.worktreePath; +} + +describe("cancelWorktreeLease", () => { + it("cancels a clean lease: removes the worktree, preserves the branch", () => { + const repo = makeRepo(); + const wtPath = createLease(repo); + expect(fs.existsSync(wtPath)).toBe(true); + + const result = cancelWorktreeLease({ repo, taskIdentity: "task-1", agentIdentity: "agent-1" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.cancelled).toBe(true); + expect(result.worktreeRemoved).toBe(true); + expect(result.forced).toBe(false); + } + // The worktree is gone but the lease branch is preserved. + expect(fs.existsSync(wtPath)).toBe(false); + const branches = execFileSync("git", ["-C", repo, "branch", "--list", "lease/wt-*"], { encoding: "utf8" }); + expect(branches.trim()).not.toBe(""); + }); + + it("preserves committed work and reports the preserved commits", () => { + const repo = makeRepo(); + const wtPath = createLease(repo); + fs.writeFileSync(path.join(wtPath, "b.txt"), "agent work\n"); + git(wtPath, "add", "b.txt"); + git(wtPath, "commit", "-q", "-m", "agent commit"); + + const result = cancelWorktreeLease({ repo, taskIdentity: "task-1", agentIdentity: "agent-1" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.cancelled).toBe(true); + expect(result.preserved.commits.map((c) => c.subject)).toContain("agent commit"); + // The branch (with the commit) still exists. + expect(execFileSync("git", ["-C", repo, "branch", "--list", result.lease.branch], { encoding: "utf8" }).trim()).not.toBe(""); + } + }); + + it("fails closed on uncommitted work without --cancel-force", () => { + const repo = makeRepo(); + const wtPath = createLease(repo); + fs.writeFileSync(path.join(wtPath, "c.txt"), "uncommitted\n"); + + const result = cancelWorktreeLease({ repo, taskIdentity: "task-1", agentIdentity: "agent-1" }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("uncommitted_changes"); + } + // The worktree is retained (not removed). + expect(fs.existsSync(wtPath)).toBe(true); + }); + + it("discards uncommitted work with --cancel-force", () => { + const repo = makeRepo(); + const wtPath = createLease(repo); + fs.writeFileSync(path.join(wtPath, "c.txt"), "uncommitted\n"); + + const result = cancelWorktreeLease({ repo, taskIdentity: "task-1", agentIdentity: "agent-1" }, { force: true }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.cancelled).toBe(true); + expect(result.forced).toBe(true); + expect(result.worktreeRemoved).toBe(true); + } + expect(fs.existsSync(wtPath)).toBe(false); + }); + + it("is an idempotent no-op when the lease is already absent", () => { + const repo = makeRepo(); + const result = cancelWorktreeLease({ repo, taskIdentity: "never-leased", agentIdentity: "agent-x" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.cancelled).toBe(false); + expect(result.worktreeRemoved).toBe(false); + } + }); + + it("fails closed when identities are missing", () => { + const repo = makeRepo(); + const result = cancelWorktreeLease({ repo, taskIdentity: "", agentIdentity: "" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("ambiguous"); + }); +}); + +describe("formatWorktreeCancelResult", () => { + it("renders a successful cancellation with preserved commits", () => { + const text = formatWorktreeCancelResult({ + ok: true, + cancelled: true, + worktreeRemoved: true, + forced: false, + lease: { + schema: WORKTREE_LEASE_SCHEMA, + v: WORKTREE_LEASE_VERSION, + leaseId: "abc123", + branch: "lease/wt-abc123", + worktreePath: "~/repo/.oh-my-cli/worktrees/abc123", + baseSha: "def456", + taskIdentity: "task", + agentIdentity: "agent", + }, + preserved: { + branch: "lease/wt-abc123", + commits: [{ sha: "1234567890ab", subject: "agent commit" }], + truncatedCommits: 0, + }, + }); + expect(text).toContain("Worktree lease cancellation"); + expect(text).toContain("status: cancelled"); + expect(text).toContain("branch: lease/wt-abc123 (preserved)"); + expect(text).toContain("(removed)"); + expect(text).toContain("preserved commits: 1"); + expect(text).toContain("agent commit"); + }); + + it("renders a refusal", () => { + const text = formatWorktreeCancelResult({ + ok: false, + reason: "uncommitted_changes", + message: "lease worktree has 1 uncommitted change(s) that would be lost", + }); + expect(text).toContain("result: refused"); + expect(text).toContain("reason: uncommitted_changes"); + }); +});