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
29 changes: 29 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,10 @@ import {
createWorktreeLease,
cleanWorktreeLease,
cancelWorktreeLease,
collectWorktreeGraph,
formatWorktreeLeaseResult,
formatWorktreeCancelResult,
formatWorktreeGraph,
} from "./worktree-lease.js";
import { evaluateCommandPolicy, formatCommandPolicyDecision } from "./command-policy.js";
import {
Expand Down Expand Up @@ -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 <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,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
Expand Down
111 changes: 111 additions & 0 deletions src/worktree-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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[] = [];
Expand Down
110 changes: 110 additions & 0 deletions tests/integration/workspace-graph.test.ts
Original file line number Diff line number Diff line change
@@ -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<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-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<string, string>;

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<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("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");
});
});
Loading
Loading