@taranek/orche 0.0.12 → 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 +32 -2
- package/dist/prune.d.ts +1 -0
- package/dist/prune.js +79 -0
- package/dist/worktree.d.ts +7 -0
- package/dist/worktree.js +52 -1
- package/package.json +7 -3
- package/src/cli.ts +30 -2
- package/src/prune.ts +102 -0
- package/src/worktree.ts +57 -1
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]
|
package/dist/prune.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/worktree.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
23
|
-
"
|
|
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");
|