diff --git a/src/index.ts b/src/index.ts index a8ee30e..59bc222 100644 --- a/src/index.ts +++ b/src/index.ts @@ -141,8 +141,10 @@ import { createWorktreeLease, cleanWorktreeLease, cancelWorktreeLease, + collectWorktreeGraph, formatWorktreeLeaseResult, formatWorktreeCancelResult, + formatWorktreeGraph, } from "./worktree-lease.js"; import { evaluateCommandPolicy, formatCommandPolicyDecision } from "./command-policy.js"; import { @@ -377,6 +379,7 @@ program .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("--list-workspaces", "List the leased parallel workspaces (worktrees) with branch and state (read-only, redacted) and exit") .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,6 +1381,32 @@ program process.exit(result.ok ? 0 : 1); } + // List-workspaces mode: a read-only, bounded, redacted graph of the leased + // parallel workspaces (worktree-lease.ts collectWorktreeGraph). Never mutates + // anything. Exits 0 (an empty lease set is an empty graph, not an error); + // exits 2 on a usage error or a non-repository target. + if (opts.listWorkspaces) { + const format = String(opts.output ?? "text"); + if (format !== "text" && format !== "json") { + process.stderr.write(`Error: invalid output format "${format}"\n`); + process.exit(2); + } + let graph; + try { + graph = collectWorktreeGraph({ repo: opts.workspace, worktreeRoot: opts.worktreeRoot }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`${msg}\n`); + process.exit(2); + } + if (format === "json") { + process.stdout.write(JSON.stringify(graph) + "\n"); + } else { + process.stdout.write(formatWorktreeGraph(graph) + "\n"); + } + process.exit(0); + } + // Leased-worktree mode: create or clean one isolated git worktree per // mutating delegated agent, offline (no provider config needed). Exits 0 // on success (including idempotent no-ops), 1 on a safety refusal, and 2 diff --git a/src/worktree-lease.ts b/src/worktree-lease.ts index 7a4a940..d8de3b3 100644 --- a/src/worktree-lease.ts +++ b/src/worktree-lease.ts @@ -31,6 +31,9 @@ import { redactSecrets, redactHomePath } from "./permission-impact.js"; export const WORKTREE_LEASE_SCHEMA = "oh-my-cli.worktree-lease"; export const WORKTREE_LEASE_VERSION = 1; +export const WORKTREE_GRAPH_SCHEMA = "oh-my-cli.worktree-graph"; +export const WORKTREE_GRAPH_VERSION = 1; + /** Options shared by lease creation and cleanup. */ export interface WorktreeLeaseOptions { /** The parent repository workspace the lease is carved from. */ @@ -121,6 +124,29 @@ export type WorktreeCancelResult = } | { ok: false; reason: WorktreeCancelRefusalReason; message: string }; +/** One leased parallel workspace in the operator-visible graph (read-only). */ +export interface WorktreeGraphEntry { + /** Home-collapsed absolute path to the leased worktree. */ + worktreePath: string; + /** Branch checked out in the worktree ("(detached)" when detached). */ + branch: string; + /** Abbreviated HEAD commit of the worktree. */ + head: string; + /** True when the worktree has uncommitted changes. */ + dirty: boolean; +} + +/** A bounded, redacted, read-only view of the repository's leased workspaces. */ +export interface WorktreeGraph { + schema: typeof WORKTREE_GRAPH_SCHEMA; + v: typeof WORKTREE_GRAPH_VERSION; + /** Home-collapsed lease worktree root that was enumerated. */ + worktreeRoot: string; + entries: WorktreeGraphEntry[]; + /** Count of workspaces beyond the bound (0 when none). */ + truncated: number; +} + function redact(text: string): string { return redactSecrets(text).text; } @@ -523,6 +549,70 @@ export function cancelWorktreeLease( return { ok: true, cancelled: true, worktreeRemoved, forced: force, lease, preserved }; } +// Bound the workspace graph so a repository with many leases cannot inflate output. +const MAX_GRAPH_ENTRIES = 100; + +/** + * Collect a read-only, bounded, redacted graph of the repository's leased parallel + * workspaces: the worktrees under the lease worktree root, each with its branch, + * abbreviated head, and clean/dirty state. Never mutates anything. Throws a + * redacted error when the target is not a repository. + */ +export function collectWorktreeGraph(opts: { repo: string; worktreeRoot?: string }): WorktreeGraph { + const repo = path.resolve(opts.repo); + const commonDir = repoCommonDir(repo); + if (commonDir === null) { + throw new Error("Worktree graph error: target is not a git repository"); + } + const worktreeRoot = path.resolve(opts.worktreeRoot ?? defaultWorktreeRoot(commonDir)); + const rootReal = safeRealpath(worktreeRoot); + + const entries: WorktreeGraphEntry[] = []; + let current: { path?: string; head?: string; branch?: string; detached?: boolean } = {}; + const flush = (): void => { + if (current.path) { + const pathReal = safeRealpath(current.path); + const underRoot = + pathReal === rootReal || + pathReal.startsWith(rootReal + path.sep) || + current.path.startsWith(worktreeRoot + path.sep); + if (underRoot && pathReal !== safeRealpath(repo)) { + entries.push({ + worktreePath: redactHomePath(current.path), + branch: current.detached || !current.branch ? "(detached)" : current.branch.replace(/^refs\/heads\//, ""), + head: (current.head ?? "").slice(0, 12), + dirty: dirtyCount(current.path) > 0, + }); + } + } + current = {}; + }; + + const out = git(repo, ["worktree", "list", "--porcelain"]).stdout; + for (const line of out.split("\n")) { + if (line.startsWith("worktree ")) { + flush(); + current.path = line.slice("worktree ".length).trim(); + } else if (line.startsWith("HEAD ")) { + current.head = line.slice("HEAD ".length).trim(); + } else if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length).trim(); + } else if (line.trim() === "detached") { + current.detached = true; + } + } + flush(); + + const bounded = entries.slice(0, MAX_GRAPH_ENTRIES); + return { + schema: WORKTREE_GRAPH_SCHEMA, + v: WORKTREE_GRAPH_VERSION, + worktreeRoot: redactHomePath(worktreeRoot), + entries: bounded, + truncated: Math.max(0, entries.length - bounded.length), + }; +} + // --- formatting ------------------------------------------------------------- /** A concise, redacted, human-readable lease result. */ @@ -560,6 +650,27 @@ export function formatWorktreeLeaseResult( return lines.join("\n"); } +/** A concise, redacted, human-readable workspace graph. */ +export function formatWorktreeGraph(graph: WorktreeGraph): string { + const lines: string[] = []; + lines.push(`Worktree graph (${graph.schema} v${graph.v})`); + lines.push("─".repeat(40)); + lines.push(`worktree root: ${graph.worktreeRoot}`); + if (graph.entries.length === 0) { + lines.push("workspaces: (none)"); + } else { + lines.push(`workspaces: ${graph.entries.length}`); + for (const entry of graph.entries) { + lines.push(` ${entry.worktreePath}`); + lines.push(` branch: ${entry.branch} head: ${entry.head} state: ${entry.dirty ? "dirty" : "clean"}`); + } + if (graph.truncated > 0) { + lines.push(` … ${graph.truncated} more workspace(s) beyond the bound`); + } + } + return lines.join("\n"); +} + /** A concise, redacted, human-readable cancellation result. */ export function formatWorktreeCancelResult(result: WorktreeCancelResult): string { const lines: string[] = []; diff --git a/tests/integration/workspace-graph.test.ts b/tests/integration/workspace-graph.test.ts new file mode 100644 index 0000000..b5ce880 --- /dev/null +++ b/tests/integration/workspace-graph.test.ts @@ -0,0 +1,110 @@ +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-graph-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, ".gitignore"), ".oh-my-cli/\n"); + fs.writeFileSync(path.join(repo, "a.txt"), "base\n"); + git(repo, "add", ".gitignore", "a.txt"); + git(repo, "commit", "-q", "-m", "base"); + return repo; +} + +describe("Integration: workspace graph (--list-workspaces)", () => { + let homeDir: string; + let env: Record; + + beforeAll(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-graph-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("lists leased workspaces with clean/dirty state (JSON), read-only", async () => { + const repo = makeRepo(); + const cleanPath = await createLease(repo, "task-clean", "agent-1"); + const dirtyPath = await createLease(repo, "task-dirty", "agent-2"); + fs.writeFileSync(path.join(dirtyPath, "c.txt"), "uncommitted\n"); + + const headBefore = execFileSync("git", ["-C", cleanPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const r = await runCli(["--list-workspaces", "--workspace", repo, "--output", "json"], env); + expect(r.code).toBe(0); + const graph = JSON.parse(r.stdout); + expect(graph.schema).toBe("oh-my-cli.worktree-graph"); + expect(graph.entries).toHaveLength(2); + const flags = graph.entries.map((e: { dirty: boolean }) => e.dirty).sort(); + expect(flags).toEqual([false, true]); + for (const e of graph.entries) { + expect(e.branch).toMatch(/^lease\/wt-/); + expect(e.head).toMatch(/^[0-9a-f]{12}$/); + } + + // Read-only: the listed worktree is unchanged. + const headAfter = execFileSync("git", ["-C", cleanPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + expect(headAfter).toBe(headBefore); + }); + + it("reports an empty graph (text) when there are no leased workspaces", async () => { + const repo = makeRepo(); + const r = await runCli(["--list-workspaces", "--workspace", repo], env); + expect(r.code).toBe(0); + expect(r.stdout).toContain("Worktree graph"); + expect(r.stdout).toContain("workspaces: (none)"); + }); + + it("exits 2 on a non-repository target", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-graph-int-norepo-")); + tmpDirs.push(dir); + const r = await runCli(["--list-workspaces", "--workspace", dir], env); + expect(r.code).toBe(2); + expect(r.stderr).toContain("not a git repository"); + }); +}); diff --git a/tests/unit/workspace-graph.test.ts b/tests/unit/workspace-graph.test.ts new file mode 100644 index 0000000..7e52604 --- /dev/null +++ b/tests/unit/workspace-graph.test.ts @@ -0,0 +1,127 @@ +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, + collectWorktreeGraph, + formatWorktreeGraph, + WORKTREE_GRAPH_SCHEMA, + WORKTREE_GRAPH_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"] }); +} + +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-graph-")); + 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"); + // Mirror the product repo: the lease worktree root is git-ignored so carving + // worktrees does not dirty the parent. + fs.writeFileSync(path.join(repo, ".gitignore"), ".oh-my-cli/\n"); + fs.writeFileSync(path.join(repo, "a.txt"), "base\n"); + git(repo, "add", ".gitignore", "a.txt"); + git(repo, "commit", "-q", "-m", "base"); + return repo; +} + +function createLease(repo: string, task: string, agent: string): string { + const result = createWorktreeLease({ repo, taskIdentity: task, agentIdentity: agent }); + if (!result.ok) throw new Error(`create failed: ${result.reason}`); + return result.lease.worktreePath; +} + +describe("collectWorktreeGraph", () => { + it("reports an empty graph when there are no leased workspaces", () => { + const repo = makeRepo(); + const graph = collectWorktreeGraph({ repo }); + expect(graph.schema).toBe(WORKTREE_GRAPH_SCHEMA); + expect(graph.v).toBe(WORKTREE_GRAPH_VERSION); + expect(graph.entries).toEqual([]); + expect(graph.truncated).toBe(0); + }); + + it("lists leased workspaces with branch, head, and clean state", () => { + const repo = makeRepo(); + createLease(repo, "task-1", "agent-1"); + const graph = collectWorktreeGraph({ repo }); + expect(graph.entries).toHaveLength(1); + const entry = graph.entries[0]; + expect(entry.branch).toMatch(/^lease\/wt-/); + expect(entry.head).toMatch(/^[0-9a-f]{12}$/); + expect(entry.dirty).toBe(false); + }); + + it("classifies a dirty workspace correctly", () => { + const repo = makeRepo(); + const cleanPath = createLease(repo, "task-clean", "agent-1"); + const dirtyPath = createLease(repo, "task-dirty", "agent-2"); + fs.writeFileSync(path.join(dirtyPath, "c.txt"), "uncommitted\n"); + void cleanPath; + + const graph = collectWorktreeGraph({ repo }); + expect(graph.entries).toHaveLength(2); + const byPath = new Map(graph.entries.map((e) => [e.worktreePath, e.dirty])); + // Exactly one dirty and one clean. + const dirtyFlags = graph.entries.map((e) => e.dirty).sort(); + expect(dirtyFlags).toEqual([false, true]); + expect(byPath.size).toBe(2); + }); + + it("does not mutate the worktrees it lists", () => { + const repo = makeRepo(); + const wtPath = createLease(repo, "task-ro", "agent-1"); + const headBefore = execFileSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + collectWorktreeGraph({ repo }); + const headAfter = execFileSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + expect(headAfter).toBe(headBefore); + expect(fs.existsSync(wtPath)).toBe(true); + }); + + it("throws on a non-repository target", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-graph-norepo-")); + tmpDirs.push(dir); + expect(() => collectWorktreeGraph({ repo: dir })).toThrow(/not a git repository/); + }); +}); + +describe("formatWorktreeGraph", () => { + it("renders an empty graph", () => { + const text = formatWorktreeGraph({ + schema: WORKTREE_GRAPH_SCHEMA, + v: WORKTREE_GRAPH_VERSION, + worktreeRoot: "~/repo/.oh-my-cli/worktrees", + entries: [], + truncated: 0, + }); + expect(text).toContain("Worktree graph"); + expect(text).toContain("workspaces: (none)"); + }); + + it("renders a populated graph with state", () => { + const text = formatWorktreeGraph({ + schema: WORKTREE_GRAPH_SCHEMA, + v: WORKTREE_GRAPH_VERSION, + worktreeRoot: "~/repo/.oh-my-cli/worktrees", + entries: [ + { worktreePath: "~/repo/.oh-my-cli/worktrees/abc", branch: "lease/wt-abc", head: "1234567890ab", dirty: false }, + { worktreePath: "~/repo/.oh-my-cli/worktrees/def", branch: "lease/wt-def", head: "abcdef123456", dirty: true }, + ], + truncated: 0, + }); + expect(text).toContain("workspaces: 2"); + expect(text).toContain("lease/wt-abc"); + expect(text).toContain("state: clean"); + expect(text).toContain("state: dirty"); + }); +});