@taranek/orche 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync, existsSync, watch, unlinkSync, writeFileSync, mkdirSync }
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import path from "node:path";
5
5
  import { createWorktree } from "./worktree.js";
6
+ import { pruneCommand } from "./prune.js";
6
7
  import { getMultiplexer } from "./multiplexer.js";
7
8
  import { buildLayout } from "./layout.js";
8
9
  import { getReviewBinaryPath } from "./review-manager.js";
@@ -60,6 +61,16 @@ function deliverReview(pendingPath) {
60
61
  }
61
62
  async function runReview(worktreePath) {
62
63
  const resolvedPath = path.resolve(worktreePath);
64
+ // Refuse to run unless the target is inside a git worktree — otherwise the
65
+ // review app may try to recursively watch enormous trees (e.g. $HOME).
66
+ try {
67
+ execFileSync("git", ["-C", resolvedPath, "rev-parse", "--show-toplevel"], {
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ });
70
+ }
71
+ catch {
72
+ die(`not a git worktree: ${resolvedPath}`);
73
+ }
63
74
  const args = ["--worktree=" + resolvedPath];
64
75
  console.log(`opening review for ${worktreePath}...`);
65
76
  // Watch for .pending files from the Electron app and deliver them
@@ -78,6 +89,15 @@ async function runReview(worktreePath) {
78
89
  process.exit(0);
79
90
  }
80
91
  });
92
+ // Exit the CLI when the review app quits — otherwise the fs watcher
93
+ // keeps the event loop alive and the user has to ctrl+c.
94
+ const onReviewExit = (code) => {
95
+ try {
96
+ watcher.close();
97
+ }
98
+ catch { }
99
+ process.exit(code ?? 0);
100
+ };
81
101
  // Dev mode: launch electron from local monorepo
82
102
  const cliDir = path.dirname(new URL(import.meta.url).pathname);
83
103
  const localReviewDir = path.resolve(cliDir, "../../review");
@@ -88,18 +108,20 @@ async function runReview(worktreePath) {
88
108
  const electronBin = path.join(devReviewPath, "node_modules/.bin/electron");
89
109
  if (debug)
90
110
  console.error(`[review] launching: ${electronBin} ${[devReviewPath, ...args].join(" ")}`);
91
- spawn(electronBin, [devReviewPath, ...args], {
111
+ const child = spawn(electronBin, [devReviewPath, ...args], {
92
112
  stdio: ["ignore", "inherit", "inherit"],
93
113
  env: { ...process.env, VITE_DEV_SERVER_URL: undefined },
94
114
  });
115
+ child.on("exit", onReviewExit);
95
116
  return;
96
117
  }
97
118
  const binaryPath = await getReviewBinaryPath();
98
119
  if (debug)
99
120
  console.error(`[review] launching: ${binaryPath} ${args.join(" ")}`);
100
- spawn(binaryPath, args, {
121
+ const child = spawn(binaryPath, args, {
101
122
  stdio: ["ignore", "inherit", "inherit"],
102
123
  });
124
+ child.on("exit", onReviewExit);
103
125
  }
104
126
  function getRepoRoot(cwd) {
105
127
  // If we're inside an orche worktree, walk up to the real repo
@@ -137,11 +159,15 @@ orche — orchestrate agents across git worktrees
137
159
  Usage:
138
160
  orche start <task> Start a new session for <task>
139
161
  orche review [path] Open the review UI for a worktree
162
+ orche prune [--all] [-f] Remove orche worktrees (interactive multiselect)
140
163
 
141
164
  Examples:
142
165
  orche start fix-auth Create worktree + session for "fix-auth"
143
166
  orche review Review changes in current directory
144
167
  orche review ./worktree Review changes in a specific worktree
168
+ orche prune Pick worktrees to remove
169
+ orche prune --all Remove all orche worktrees
170
+ orche prune --force Allow removing worktrees with uncommitted changes
145
171
 
146
172
  Requires a ${CONFIG_NAME} file in the current directory.
147
173
  Use ${CONFIG_LOCAL_NAME} for local overrides (not committed).
@@ -157,6 +183,10 @@ async function main() {
157
183
  if (subcommand === "start") {
158
184
  startSession();
159
185
  }
186
+ else if (subcommand === "prune") {
187
+ const repoRoot = getRepoRoot(process.cwd());
188
+ await pruneCommand(repoRoot, process.argv.slice(3));
189
+ }
160
190
  else if (subcommand === "review") {
161
191
  const explicitPath = process.argv[3] && !process.argv[3].startsWith("--")
162
192
  ? process.argv[3]
@@ -0,0 +1 @@
1
+ export declare function pruneCommand(repoRoot: string, argv: string[]): Promise<void>;
package/dist/prune.js ADDED
@@ -0,0 +1,79 @@
1
+ import path from "node:path";
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { listOrcheWorktrees, removeWorktree, } from "./worktree.js";
5
+ function parseOptions(argv) {
6
+ return {
7
+ all: argv.includes("--all"),
8
+ force: argv.includes("--force") || argv.includes("-f"),
9
+ };
10
+ }
11
+ async function selectWorktrees(worktrees) {
12
+ const choice = await p.multiselect({
13
+ message: "Select worktrees to remove",
14
+ required: false,
15
+ options: worktrees.map((wt) => {
16
+ const branch = wt.branch ? ` ${pc.cyan(wt.branch)}` : "";
17
+ const hint = wt.dirty ? "uncommitted changes" : undefined;
18
+ return {
19
+ value: wt.worktreePath,
20
+ label: `${path.basename(wt.worktreePath)}${branch}`,
21
+ hint,
22
+ };
23
+ }),
24
+ });
25
+ if (p.isCancel(choice))
26
+ return null;
27
+ const picked = choice;
28
+ return worktrees.filter((wt) => picked.includes(wt.worktreePath));
29
+ }
30
+ function pruneWorktrees(repoRoot, targets, force) {
31
+ let removed = 0;
32
+ let skipped = 0;
33
+ let failed = 0;
34
+ for (const wt of targets) {
35
+ const name = path.basename(wt.worktreePath);
36
+ if (wt.dirty && !force) {
37
+ p.log.warn(`${name} — uncommitted changes (pass --force to remove)`);
38
+ skipped++;
39
+ continue;
40
+ }
41
+ try {
42
+ removeWorktree(repoRoot, wt.worktreePath, force);
43
+ p.log.success(`removed ${name}`);
44
+ removed++;
45
+ }
46
+ catch (err) {
47
+ p.log.error(`${name}: ${err?.message ?? err}`);
48
+ failed++;
49
+ }
50
+ }
51
+ return { removed, skipped, failed };
52
+ }
53
+ export async function pruneCommand(repoRoot, argv) {
54
+ const opts = parseOptions(argv);
55
+ p.intro("orche prune");
56
+ const worktrees = listOrcheWorktrees(repoRoot);
57
+ if (worktrees.length === 0) {
58
+ p.outro("no orche worktrees found");
59
+ return;
60
+ }
61
+ let targets;
62
+ if (opts.all) {
63
+ targets = worktrees;
64
+ }
65
+ else {
66
+ const picked = await selectWorktrees(worktrees);
67
+ if (picked === null) {
68
+ p.cancel("cancelled");
69
+ return;
70
+ }
71
+ if (picked.length === 0) {
72
+ p.outro("nothing selected");
73
+ return;
74
+ }
75
+ targets = picked;
76
+ }
77
+ const { removed, skipped, failed } = pruneWorktrees(repoRoot, targets, opts.force);
78
+ p.outro(`${removed} removed · ${skipped} skipped · ${failed} failed`);
79
+ }
@@ -3,3 +3,10 @@ export interface WorktreeInfo {
3
3
  branchName: string;
4
4
  }
5
5
  export declare function createWorktree(repoPath: string, name: string): WorktreeInfo;
6
+ export interface OrcheWorktree {
7
+ worktreePath: string;
8
+ branch: string | null;
9
+ dirty: boolean;
10
+ }
11
+ export declare function listOrcheWorktrees(repoPath: string): OrcheWorktree[];
12
+ export declare function removeWorktree(repoPath: string, worktreePath: string, force: boolean): void;
package/dist/worktree.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execSync, execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, appendFileSync, mkdirSync, symlinkSync, readdirSync, lstatSync, } from "node:fs";
3
3
  import path from "node:path";
4
4
  function slugify(text) {
@@ -43,6 +43,57 @@ export function createWorktree(repoPath, name) {
43
43
  console.log(` worktree: ${worktreePath} (${branchName})`);
44
44
  return { worktreePath, branchName };
45
45
  }
46
+ const ORCHE_SEGMENT = `${path.sep}.orche${path.sep}worktrees${path.sep}`;
47
+ function parseWorktreeList(output) {
48
+ const result = [];
49
+ for (const block of output.split(/\n\n+/)) {
50
+ if (!block.trim())
51
+ continue;
52
+ let worktreePath = null;
53
+ let branch = null;
54
+ for (const line of block.split("\n")) {
55
+ if (line.startsWith("worktree "))
56
+ worktreePath = line.slice("worktree ".length);
57
+ else if (line.startsWith("branch "))
58
+ branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
59
+ }
60
+ if (worktreePath)
61
+ result.push({ worktreePath, branch });
62
+ }
63
+ return result;
64
+ }
65
+ function isWorktreeDirty(worktreePath) {
66
+ try {
67
+ const out = execFileSync("git", ["status", "--porcelain"], {
68
+ cwd: worktreePath,
69
+ encoding: "utf-8",
70
+ });
71
+ return out.trim().length > 0;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ export function listOrcheWorktrees(repoPath) {
78
+ const out = execFileSync("git", ["worktree", "list", "--porcelain"], {
79
+ cwd: repoPath,
80
+ encoding: "utf-8",
81
+ });
82
+ return parseWorktreeList(out)
83
+ .filter((w) => w.worktreePath.includes(ORCHE_SEGMENT))
84
+ .map((w) => ({
85
+ worktreePath: w.worktreePath,
86
+ branch: w.branch,
87
+ dirty: isWorktreeDirty(w.worktreePath),
88
+ }));
89
+ }
90
+ export function removeWorktree(repoPath, worktreePath, force) {
91
+ const args = ["worktree", "remove"];
92
+ if (force)
93
+ args.push("--force");
94
+ args.push(worktreePath);
95
+ execFileSync("git", args, { cwd: repoPath, stdio: "inherit" });
96
+ }
46
97
  function symlinkNodeModules(repoPath, worktreePath) {
47
98
  // Symlink root node_modules
48
99
  const rootNm = path.join(repoPath, "node_modules");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taranek/orche",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "orche": "./dist/cli.js"
@@ -19,7 +19,11 @@
19
19
  "dev": "tsc --watch"
20
20
  },
21
21
  "devDependencies": {
22
- "typescript": "^5.2.2",
23
- "@types/node": "^25.2.0"
22
+ "@types/node": "^25.2.0",
23
+ "typescript": "^5.2.2"
24
+ },
25
+ "dependencies": {
26
+ "@clack/prompts": "^1.2.0",
27
+ "picocolors": "^1.1.1"
24
28
  }
25
29
  }
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { readFileSync, existsSync, watch, unlinkSync, writeFileSync, mkdirSync }
4
4
  import { execFileSync, spawn } from "node:child_process";
5
5
  import path from "node:path";
6
6
  import { createWorktree } from "./worktree.js";
7
+ import { pruneCommand } from "./prune.js";
7
8
  import { getMultiplexer } from "./multiplexer.js";
8
9
  import { buildLayout } from "./layout.js";
9
10
  import { getReviewBinaryPath } from "./review-manager.js";
@@ -64,6 +65,17 @@ function deliverReview(pendingPath: string): void {
64
65
 
65
66
  async function runReview(worktreePath: string): Promise<void> {
66
67
  const resolvedPath = path.resolve(worktreePath);
68
+
69
+ // Refuse to run unless the target is inside a git worktree — otherwise the
70
+ // review app may try to recursively watch enormous trees (e.g. $HOME).
71
+ try {
72
+ execFileSync("git", ["-C", resolvedPath, "rev-parse", "--show-toplevel"], {
73
+ stdio: ["ignore", "pipe", "ignore"],
74
+ });
75
+ } catch {
76
+ die(`not a git worktree: ${resolvedPath}`);
77
+ }
78
+
67
79
  const args = ["--worktree=" + resolvedPath];
68
80
 
69
81
  console.log(`opening review for ${worktreePath}...`);
@@ -86,6 +98,13 @@ async function runReview(worktreePath: string): Promise<void> {
86
98
  }
87
99
  });
88
100
 
101
+ // Exit the CLI when the review app quits — otherwise the fs watcher
102
+ // keeps the event loop alive and the user has to ctrl+c.
103
+ const onReviewExit = (code: number | null): void => {
104
+ try { watcher.close(); } catch {}
105
+ process.exit(code ?? 0);
106
+ };
107
+
89
108
  // Dev mode: launch electron from local monorepo
90
109
  const cliDir = path.dirname(new URL(import.meta.url).pathname);
91
110
  const localReviewDir = path.resolve(cliDir, "../../review");
@@ -95,18 +114,20 @@ async function runReview(worktreePath: string): Promise<void> {
95
114
  if (devReviewPath) {
96
115
  const electronBin = path.join(devReviewPath, "node_modules/.bin/electron");
97
116
  if (debug) console.error(`[review] launching: ${electronBin} ${[devReviewPath, ...args].join(" ")}`);
98
- spawn(electronBin, [devReviewPath, ...args], {
117
+ const child = spawn(electronBin, [devReviewPath, ...args], {
99
118
  stdio: ["ignore", "inherit", "inherit"],
100
119
  env: { ...process.env, VITE_DEV_SERVER_URL: undefined },
101
120
  });
121
+ child.on("exit", onReviewExit);
102
122
  return;
103
123
  }
104
124
 
105
125
  const binaryPath = await getReviewBinaryPath();
106
126
  if (debug) console.error(`[review] launching: ${binaryPath} ${args.join(" ")}`);
107
- spawn(binaryPath, args, {
127
+ const child = spawn(binaryPath, args, {
108
128
  stdio: ["ignore", "inherit", "inherit"],
109
129
  });
130
+ child.on("exit", onReviewExit);
110
131
  }
111
132
 
112
133
  function getRepoRoot(cwd: string): string {
@@ -157,11 +178,15 @@ orche — orchestrate agents across git worktrees
157
178
  Usage:
158
179
  orche start <task> Start a new session for <task>
159
180
  orche review [path] Open the review UI for a worktree
181
+ orche prune [--all] [-f] Remove orche worktrees (interactive multiselect)
160
182
 
161
183
  Examples:
162
184
  orche start fix-auth Create worktree + session for "fix-auth"
163
185
  orche review Review changes in current directory
164
186
  orche review ./worktree Review changes in a specific worktree
187
+ orche prune Pick worktrees to remove
188
+ orche prune --all Remove all orche worktrees
189
+ orche prune --force Allow removing worktrees with uncommitted changes
165
190
 
166
191
  Requires a ${CONFIG_NAME} file in the current directory.
167
192
  Use ${CONFIG_LOCAL_NAME} for local overrides (not committed).
@@ -179,6 +204,9 @@ async function main(): Promise<void> {
179
204
 
180
205
  if (subcommand === "start") {
181
206
  startSession();
207
+ } else if (subcommand === "prune") {
208
+ const repoRoot = getRepoRoot(process.cwd());
209
+ await pruneCommand(repoRoot, process.argv.slice(3));
182
210
  } else if (subcommand === "review") {
183
211
  const explicitPath = process.argv[3] && !process.argv[3].startsWith("--")
184
212
  ? process.argv[3]
package/src/prune.ts ADDED
@@ -0,0 +1,102 @@
1
+ import path from "node:path";
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import {
5
+ listOrcheWorktrees,
6
+ removeWorktree,
7
+ type OrcheWorktree,
8
+ } from "./worktree.js";
9
+
10
+ interface PruneOptions {
11
+ all: boolean;
12
+ force: boolean;
13
+ }
14
+
15
+ function parseOptions(argv: string[]): PruneOptions {
16
+ return {
17
+ all: argv.includes("--all"),
18
+ force: argv.includes("--force") || argv.includes("-f"),
19
+ };
20
+ }
21
+
22
+ async function selectWorktrees(
23
+ worktrees: OrcheWorktree[]
24
+ ): Promise<OrcheWorktree[] | null> {
25
+ const choice = await p.multiselect<string>({
26
+ message: "Select worktrees to remove",
27
+ required: false,
28
+ options: worktrees.map((wt) => {
29
+ const branch = wt.branch ? ` ${pc.cyan(wt.branch)}` : "";
30
+ const hint = wt.dirty ? "uncommitted changes" : undefined;
31
+ return {
32
+ value: wt.worktreePath,
33
+ label: `${path.basename(wt.worktreePath)}${branch}`,
34
+ hint,
35
+ };
36
+ }),
37
+ });
38
+
39
+ if (p.isCancel(choice)) return null;
40
+ const picked = choice as string[];
41
+ return worktrees.filter((wt) => picked.includes(wt.worktreePath));
42
+ }
43
+
44
+ function pruneWorktrees(
45
+ repoRoot: string,
46
+ targets: OrcheWorktree[],
47
+ force: boolean
48
+ ): { removed: number; skipped: number; failed: number } {
49
+ let removed = 0;
50
+ let skipped = 0;
51
+ let failed = 0;
52
+
53
+ for (const wt of targets) {
54
+ const name = path.basename(wt.worktreePath);
55
+ if (wt.dirty && !force) {
56
+ p.log.warn(`${name} — uncommitted changes (pass --force to remove)`);
57
+ skipped++;
58
+ continue;
59
+ }
60
+ try {
61
+ removeWorktree(repoRoot, wt.worktreePath, force);
62
+ p.log.success(`removed ${name}`);
63
+ removed++;
64
+ } catch (err: any) {
65
+ p.log.error(`${name}: ${err?.message ?? err}`);
66
+ failed++;
67
+ }
68
+ }
69
+
70
+ return { removed, skipped, failed };
71
+ }
72
+
73
+ export async function pruneCommand(repoRoot: string, argv: string[]): Promise<void> {
74
+ const opts = parseOptions(argv);
75
+
76
+ p.intro("orche prune");
77
+
78
+ const worktrees = listOrcheWorktrees(repoRoot);
79
+ if (worktrees.length === 0) {
80
+ p.outro("no orche worktrees found");
81
+ return;
82
+ }
83
+
84
+ let targets: OrcheWorktree[];
85
+ if (opts.all) {
86
+ targets = worktrees;
87
+ } else {
88
+ const picked = await selectWorktrees(worktrees);
89
+ if (picked === null) {
90
+ p.cancel("cancelled");
91
+ return;
92
+ }
93
+ if (picked.length === 0) {
94
+ p.outro("nothing selected");
95
+ return;
96
+ }
97
+ targets = picked;
98
+ }
99
+
100
+ const { removed, skipped, failed } = pruneWorktrees(repoRoot, targets, opts.force);
101
+ p.outro(`${removed} removed · ${skipped} skipped · ${failed} failed`);
102
+ }
package/src/worktree.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execSync, execFileSync } from "node:child_process";
2
2
  import {
3
3
  existsSync,
4
4
  readFileSync,
@@ -69,6 +69,62 @@ export function createWorktree(repoPath: string, name: string): WorktreeInfo {
69
69
  return { worktreePath, branchName };
70
70
  }
71
71
 
72
+ export interface OrcheWorktree {
73
+ worktreePath: string;
74
+ branch: string | null;
75
+ dirty: boolean;
76
+ }
77
+
78
+ const ORCHE_SEGMENT = `${path.sep}.orche${path.sep}worktrees${path.sep}`;
79
+
80
+ function parseWorktreeList(output: string): { worktreePath: string; branch: string | null }[] {
81
+ const result: { worktreePath: string; branch: string | null }[] = [];
82
+ for (const block of output.split(/\n\n+/)) {
83
+ if (!block.trim()) continue;
84
+ let worktreePath: string | null = null;
85
+ let branch: string | null = null;
86
+ for (const line of block.split("\n")) {
87
+ if (line.startsWith("worktree ")) worktreePath = line.slice("worktree ".length);
88
+ else if (line.startsWith("branch ")) branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
89
+ }
90
+ if (worktreePath) result.push({ worktreePath, branch });
91
+ }
92
+ return result;
93
+ }
94
+
95
+ function isWorktreeDirty(worktreePath: string): boolean {
96
+ try {
97
+ const out = execFileSync("git", ["status", "--porcelain"], {
98
+ cwd: worktreePath,
99
+ encoding: "utf-8",
100
+ });
101
+ return out.trim().length > 0;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ export function listOrcheWorktrees(repoPath: string): OrcheWorktree[] {
108
+ const out = execFileSync("git", ["worktree", "list", "--porcelain"], {
109
+ cwd: repoPath,
110
+ encoding: "utf-8",
111
+ });
112
+ return parseWorktreeList(out)
113
+ .filter((w) => w.worktreePath.includes(ORCHE_SEGMENT))
114
+ .map((w) => ({
115
+ worktreePath: w.worktreePath,
116
+ branch: w.branch,
117
+ dirty: isWorktreeDirty(w.worktreePath),
118
+ }));
119
+ }
120
+
121
+ export function removeWorktree(repoPath: string, worktreePath: string, force: boolean): void {
122
+ const args = ["worktree", "remove"];
123
+ if (force) args.push("--force");
124
+ args.push(worktreePath);
125
+ execFileSync("git", args, { cwd: repoPath, stdio: "inherit" });
126
+ }
127
+
72
128
  function symlinkNodeModules(repoPath: string, worktreePath: string): void {
73
129
  // Symlink root node_modules
74
130
  const rootNm = path.join(repoPath, "node_modules");