From 1edeec6ee430f0db74ac90b15838495e2ccb9521 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:33:43 +0800 Subject: [PATCH] feat(cli): add a read-only --list-workspaces command showing the leased parallel workspaces with branch and state (#232) Provisioning (#50), integration (#228), and cancellation (#230) of parallel workspaces existed, but there was no single view of which leased workspaces exist and what state they are in, so operators had to inspect git worktrees by hand. Add collectWorktreeGraph plus an opt-in --list-workspaces command that enumerates the leased worktrees under the lease root and reports each one's redacted path, branch, abbreviated head, and clean/dirty state, bounded and available as JSON or text. Strictly read-only (never mutates), fails closed on a non-repository, and reports an empty graph (not an error) when there are no leases. --- src/index.ts | 29 +++++ src/worktree-lease.ts | 111 +++++++++++++++++++ tests/integration/workspace-graph.test.ts | 110 +++++++++++++++++++ tests/unit/workspace-graph.test.ts | 127 ++++++++++++++++++++++ 4 files changed, 377 insertions(+) create mode 100644 tests/integration/workspace-graph.test.ts create mode 100644 tests/unit/workspace-graph.test.ts 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"); + }); +});