@chlrc/aiw 0.1.0
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/README.md +681 -0
- package/README.zh-CN.md +681 -0
- package/bin/aiw +8 -0
- package/config/agents.toml +24 -0
- package/config/aiw.toml +41 -0
- package/config/commit-prompt.md +8 -0
- package/config/lazygit-delta.yml +15 -0
- package/package.json +42 -0
- package/scripts/install-global.sh +16 -0
- package/src/agent.mjs +53 -0
- package/src/cli.mjs +422 -0
- package/src/commit.mjs +175 -0
- package/src/config.mjs +190 -0
- package/src/deps.mjs +172 -0
- package/src/git.mjs +210 -0
- package/src/hooks.mjs +252 -0
- package/src/init.mjs +719 -0
- package/src/layout.mjs +54 -0
- package/src/prompt.mjs +60 -0
- package/src/run.mjs +78 -0
- package/src/workspace.mjs +1422 -0
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { aiwBinPath, resolveAgent } from "./config.mjs";
|
|
4
|
+
import { assertGate } from "./deps.mjs";
|
|
5
|
+
import { assertGitRoot, isDirty } from "./git.mjs";
|
|
6
|
+
import { runWorkspaceHook } from "./hooks.mjs";
|
|
7
|
+
import { askInput, pickFromList } from "./prompt.mjs";
|
|
8
|
+
import { quoteShell, runInherit, tryCapture } from "./run.mjs";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_STALE_SECONDS = 7 * 24 * 60 * 60;
|
|
11
|
+
const INTEGRATED_STATES = new Set(["integrated", "same_commit", "empty"]);
|
|
12
|
+
|
|
13
|
+
const ANSI = {
|
|
14
|
+
reset: "\x1b[0m",
|
|
15
|
+
bold: "\x1b[1m",
|
|
16
|
+
dim: "\x1b[2m",
|
|
17
|
+
red: "\x1b[31m",
|
|
18
|
+
green: "\x1b[32m",
|
|
19
|
+
yellow: "\x1b[33m",
|
|
20
|
+
magenta: "\x1b[35m",
|
|
21
|
+
cyan: "\x1b[36m",
|
|
22
|
+
gray: "\x1b[90m"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function commandWorkspace(config, argv) {
|
|
26
|
+
const subcommand = argv[0] || "list";
|
|
27
|
+
const rest = argv.slice(1);
|
|
28
|
+
switch (subcommand) {
|
|
29
|
+
case "list":
|
|
30
|
+
case "status":
|
|
31
|
+
await workspaceList(config, rest);
|
|
32
|
+
return;
|
|
33
|
+
case "open":
|
|
34
|
+
case "switch":
|
|
35
|
+
await workspaceOpen(config, rest);
|
|
36
|
+
return;
|
|
37
|
+
case "done":
|
|
38
|
+
await workspaceDone(config, rest);
|
|
39
|
+
return;
|
|
40
|
+
case "remove":
|
|
41
|
+
case "rm":
|
|
42
|
+
await workspaceRemove(config, rest);
|
|
43
|
+
return;
|
|
44
|
+
case "gc":
|
|
45
|
+
case "clean":
|
|
46
|
+
await workspaceGc(config, rest);
|
|
47
|
+
return;
|
|
48
|
+
case "states":
|
|
49
|
+
case "state":
|
|
50
|
+
printStateHelp();
|
|
51
|
+
return;
|
|
52
|
+
case "help":
|
|
53
|
+
case "-h":
|
|
54
|
+
case "--help":
|
|
55
|
+
printWorkspaceHelp();
|
|
56
|
+
return;
|
|
57
|
+
default: {
|
|
58
|
+
const error = new Error(`unknown workspace command: ${subcommand}`);
|
|
59
|
+
error.exitCode = 2;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function recordWorkspaceTarget(repo, branch, targetBranch) {
|
|
66
|
+
if (!branch || !targetBranch || branch === targetBranch) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const metadata = readWorkspaceMetadata(repo);
|
|
70
|
+
metadata.workspaces[branch] = {
|
|
71
|
+
...(metadata.workspaces[branch] || {}),
|
|
72
|
+
targetBranch,
|
|
73
|
+
targetSource: "aiw",
|
|
74
|
+
updatedAt: Math.floor(Date.now() / 1000),
|
|
75
|
+
createdAt: metadata.workspaces[branch]?.createdAt || Math.floor(Date.now() / 1000)
|
|
76
|
+
};
|
|
77
|
+
writeWorkspaceMetadata(repo, metadata);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function workspaceList(config, argv) {
|
|
81
|
+
assertGate("workspace", config);
|
|
82
|
+
const flags = parseWorkspaceFlags(argv);
|
|
83
|
+
const repo = assertGitRoot(process.cwd());
|
|
84
|
+
const workspaces = collectWorkspaceRecords(repo);
|
|
85
|
+
if (flags.json) {
|
|
86
|
+
console.log(JSON.stringify(workspaces, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log(formatWorkspaceTable(workspaces, {
|
|
90
|
+
color: shouldUseColor(flags),
|
|
91
|
+
staleSeconds: staleSecondsFromFlags(flags, config)
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function workspaceOpen(config, argv) {
|
|
96
|
+
const flags = parseWorkspaceFlags(argv);
|
|
97
|
+
const agent = resolveAgent(config, flags.agent || config.defaults.agent);
|
|
98
|
+
assertGate("workspace", config);
|
|
99
|
+
assertGate("layout", config, agent);
|
|
100
|
+
|
|
101
|
+
const repo = assertGitRoot(process.cwd());
|
|
102
|
+
const layoutCommand = `${quoteShell(aiwBinPath())} layout --agent ${quoteShell(agent.name)}`;
|
|
103
|
+
const selected = flags.positionals[0] || "";
|
|
104
|
+
|
|
105
|
+
if (!selected) {
|
|
106
|
+
if (process.stdin.isTTY) {
|
|
107
|
+
const pickedTarget = await selectWorkspaceOpenTarget(repo, flags);
|
|
108
|
+
await openWorkspaceTarget(repo, pickedTarget, flags, agent, layoutCommand);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const wtArgs = pickerSwitchArgs(flags, layoutCommand);
|
|
112
|
+
if (flags.dryRun) {
|
|
113
|
+
console.log(`cd ${quoteShell(repo)} && wt ${wtArgs.map(quoteShell).join(" ")}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await runInherit("wt", wtArgs, { cwd: repo });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await openWorkspaceTarget(repo, selected, flags, agent, layoutCommand);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function openWorkspaceTarget(repo, selected, flags, agent, layoutCommand) {
|
|
124
|
+
const workspaces = collectWorkspaceRecords(repo);
|
|
125
|
+
const record = findWorkspace(workspaces, selected);
|
|
126
|
+
|
|
127
|
+
if (record?.branch) {
|
|
128
|
+
const wtArgs = ["switch", record.branch, "-x", layoutCommand];
|
|
129
|
+
if (flags.dryRun) {
|
|
130
|
+
console.log(`cd ${quoteShell(repo)} && wt ${wtArgs.map(quoteShell).join(" ")}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await runInherit("wt", wtArgs, { cwd: repo });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const directPath = record?.path || selected;
|
|
138
|
+
const directRoot = directPath ? gitRootIfExists(directPath) : "";
|
|
139
|
+
if (directRoot) {
|
|
140
|
+
const layoutArgs = ["layout", "--agent", agent.name];
|
|
141
|
+
if (flags.dryRun) {
|
|
142
|
+
console.log(`cd ${quoteShell(directRoot)} && ${quoteShell(aiwBinPath())} ${layoutArgs.map(quoteShell).join(" ")}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await runInherit(aiwBinPath(), layoutArgs, { cwd: directRoot });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (selected) {
|
|
150
|
+
const wtArgs = ["switch", selected, "-x", layoutCommand];
|
|
151
|
+
if (flags.dryRun) {
|
|
152
|
+
console.log(`cd ${quoteShell(repo)} && wt ${wtArgs.map(quoteShell).join(" ")}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await runInherit("wt", wtArgs, { cwd: repo });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const error = new Error("workspace target is required");
|
|
160
|
+
error.exitCode = 2;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function selectWorkspaceOpenTarget(repo, flags) {
|
|
165
|
+
const workspaces = collectWorkspaceRecords(repo);
|
|
166
|
+
const localBranches = listLocalBranches(repo);
|
|
167
|
+
const worktreeBranches = new Set(workspaces.map((workspace) => workspace.branch).filter(Boolean));
|
|
168
|
+
const entries = workspacePickerEntries(workspaces);
|
|
169
|
+
|
|
170
|
+
if (!flags.worktreesOnly) {
|
|
171
|
+
for (const branch of localBranches) {
|
|
172
|
+
if (!worktreeBranches.has(branch)) {
|
|
173
|
+
entries.push({
|
|
174
|
+
label: pickerBranchLabel(branch, "branch", "no worktree"),
|
|
175
|
+
target: branch
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (flags.remotes) {
|
|
182
|
+
const knownBranches = new Set([...localBranches, ...worktreeBranches]);
|
|
183
|
+
for (const branch of listRemoteBranches(repo)) {
|
|
184
|
+
if (!knownBranches.has(branch)) {
|
|
185
|
+
entries.push({
|
|
186
|
+
label: pickerBranchLabel(branch, "remote", "no local branch"),
|
|
187
|
+
target: branch
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (entries.length === 0) {
|
|
194
|
+
const error = new Error("no workspaces or branches found");
|
|
195
|
+
error.exitCode = 4;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const labels = entries.map((entry) => entry.label);
|
|
200
|
+
const defaultEntry = entries.find((entry) => entry.previous) || entries.find((entry) => entry.current) || entries[0];
|
|
201
|
+
const selected = await pickFromList("Open workspace", labels, {
|
|
202
|
+
defaultItem: defaultEntry.label,
|
|
203
|
+
force: true
|
|
204
|
+
});
|
|
205
|
+
const selectedKey = selected.trim();
|
|
206
|
+
return entries.find((entry) => entry.label === selected || entry.label.trim() === selectedKey)?.target || selected;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function workspacePickerEntries(workspaces) {
|
|
210
|
+
const branchWidth = Math.max(6, ...workspaces.map((workspace) => (workspace.branch || "(detached)").length));
|
|
211
|
+
return workspaces.map((workspace) => {
|
|
212
|
+
const branch = workspace.branch || "(detached)";
|
|
213
|
+
const gitState = workspace.dirty ? "dirty" : "clean";
|
|
214
|
+
const state = stateLabel(workspace.state);
|
|
215
|
+
const cmux = workspace.cmux ? "open" : "-";
|
|
216
|
+
const mark = workspace.current ? "@" : workspace.previous ? "-" : " ";
|
|
217
|
+
return {
|
|
218
|
+
label: `${mark} ${branch.padEnd(branchWidth)} ${gitState.padEnd(5)} ${state.padEnd(8)} ${cmux.padEnd(4)} ${workspace.path}`,
|
|
219
|
+
target: workspace.branch || workspace.path,
|
|
220
|
+
current: workspace.current,
|
|
221
|
+
previous: workspace.previous
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pickerBranchLabel(branch, kind, note) {
|
|
227
|
+
return ` ${branch.padEnd(Math.max(6, branch.length))} ${kind.padEnd(5)} ${"-".padEnd(8)} ${"-".padEnd(4)} ${note}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pickerSwitchArgs(flags, layoutCommand) {
|
|
231
|
+
const wtArgs = ["switch"];
|
|
232
|
+
if (!flags.worktreesOnly) {
|
|
233
|
+
wtArgs.push("--branches");
|
|
234
|
+
}
|
|
235
|
+
if (flags.remotes) {
|
|
236
|
+
wtArgs.push("--remotes");
|
|
237
|
+
}
|
|
238
|
+
wtArgs.push("-x", layoutCommand);
|
|
239
|
+
return wtArgs;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function workspaceDone(config, argv) {
|
|
243
|
+
assertGate("workspace", config);
|
|
244
|
+
if (hasHelpFlag(argv)) {
|
|
245
|
+
await runInherit("wt", ["merge", ...argv]);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const flags = parseDoneFlags(argv);
|
|
249
|
+
const repo = assertGitRoot(process.cwd());
|
|
250
|
+
assertDoneAllowed(repo);
|
|
251
|
+
if (isDirty(repo)) {
|
|
252
|
+
const error = new Error("working tree has uncommitted changes; use aiw git before aiw workspace done");
|
|
253
|
+
error.exitCode = 5;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
const closeTarget = flags.closeCmux ? cmuxWorkspaceRefForPath(repo) : "";
|
|
257
|
+
const mergeArgs = await withSelectedMergeTarget(repo, flags.passthrough);
|
|
258
|
+
await runWorkspaceHook(config, "pre_remove", {
|
|
259
|
+
repo,
|
|
260
|
+
cwd: repo,
|
|
261
|
+
workspacePath: repo,
|
|
262
|
+
branch: currentBranch(repo),
|
|
263
|
+
target: mergeTarget(mergeArgs)
|
|
264
|
+
});
|
|
265
|
+
await runInherit("wt", ["merge", ...mergeArgs], { cwd: repo });
|
|
266
|
+
if (closeTarget) {
|
|
267
|
+
closeCmuxWorkspace(closeTarget);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function workspaceRemove(config, argv) {
|
|
272
|
+
assertGate("workspace", config);
|
|
273
|
+
if (hasHelpFlag(argv)) {
|
|
274
|
+
await runInherit("wt", ["remove", ...argv]);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const repo = assertGitRoot(process.cwd());
|
|
278
|
+
const dirtyTargets = hasForceFlag(argv) ? [] : dirtyRemoveTargets(repo, argv);
|
|
279
|
+
if (dirtyTargets.length > 0) {
|
|
280
|
+
const error = new Error(`workspace has uncommitted changes: ${dirtyTargets.join(", ")}; rerun with --force only if you intend to discard/remove`);
|
|
281
|
+
error.exitCode = 5;
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
for (const context of removeHookContexts(repo, argv)) {
|
|
285
|
+
await runWorkspaceHook(config, "pre_remove", {
|
|
286
|
+
...context,
|
|
287
|
+
dryRun: hasDryRunFlag(argv)
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
await runInherit("wt", ["remove", ...argv], { cwd: repo });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function workspaceGc(config, argv) {
|
|
294
|
+
assertGate("workspace", config);
|
|
295
|
+
const flags = parseWorkspaceFlags(argv);
|
|
296
|
+
if (flags.dryRun && (flags.apply || flags.yes)) {
|
|
297
|
+
const error = new Error("aiw workspace gc cannot combine --dry-run with --apply/--yes");
|
|
298
|
+
error.exitCode = 2;
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const repo = assertGitRoot(process.cwd());
|
|
303
|
+
const staleSeconds = staleSecondsFromFlags(flags, config);
|
|
304
|
+
const workspaces = collectWorkspaceRecords(repo);
|
|
305
|
+
const plan = buildGcPlan(workspaces, {
|
|
306
|
+
staleSeconds
|
|
307
|
+
});
|
|
308
|
+
if (flags.json && !flags.apply && !flags.yes) {
|
|
309
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!flags.json) {
|
|
313
|
+
console.log(formatGcPreview(plan, {
|
|
314
|
+
color: shouldUseColor(flags),
|
|
315
|
+
dryRun: flags.dryRun
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
if (flags.dryRun || plan.removable.length === 0) {
|
|
319
|
+
if (flags.json && (flags.apply || flags.yes)) {
|
|
320
|
+
console.log(JSON.stringify({
|
|
321
|
+
...plan,
|
|
322
|
+
removed: [],
|
|
323
|
+
skipped: []
|
|
324
|
+
}, null, 2));
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!flags.apply && !flags.yes && !process.stdin.isTTY) {
|
|
330
|
+
if (!flags.json) {
|
|
331
|
+
console.log("Not interactive. Rerun with --apply or --yes to remove safe workspaces.");
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const shouldApply = flags.apply || flags.yes || await confirmGcApply(plan);
|
|
337
|
+
if (!shouldApply) {
|
|
338
|
+
if (!flags.json) {
|
|
339
|
+
console.log("Cancelled. No files were removed.");
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const refreshedPlan = buildGcPlan(collectWorkspaceRecords(repo), {
|
|
345
|
+
staleSeconds
|
|
346
|
+
});
|
|
347
|
+
const result = await applyGcPlan(config, repo, refreshedPlan);
|
|
348
|
+
if (flags.json) {
|
|
349
|
+
console.log(JSON.stringify({
|
|
350
|
+
...refreshedPlan,
|
|
351
|
+
removed: result.removed,
|
|
352
|
+
skipped: result.skipped
|
|
353
|
+
}, null, 2));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (result.removed.length === 0) {
|
|
357
|
+
console.log("No removable workspaces after refresh.");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
console.log(`Removed ${result.removed.length} workspace(s): ${result.removed.join(", ")}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function collectWorkspaceRecords(repo) {
|
|
364
|
+
const cmuxPaths = collectCmuxWorkspacePaths();
|
|
365
|
+
const entries = readWorktrunkList(repo) || readGitWorktreeList(repo);
|
|
366
|
+
const metadata = readWorkspaceMetadata(repo);
|
|
367
|
+
const worktreeBranches = new Set(entries.map((entry) => entry.branch).filter(Boolean));
|
|
368
|
+
const nonWorktreeBranches = listLocalBranches(repo).filter((branch) => !worktreeBranches.has(branch));
|
|
369
|
+
return entries.map((entry) => {
|
|
370
|
+
const resolvedPath = normalizePath(entry.path);
|
|
371
|
+
const dirty = entry.dirty ?? isWorktreeDirty(resolvedPath);
|
|
372
|
+
const lastChangedAt = entry.lastChangedAt || worktreeLastChangedAt(resolvedPath);
|
|
373
|
+
const targetBranch = entry.branch ? stringValue(metadata.workspaces[entry.branch]?.targetBranch) : "";
|
|
374
|
+
const targetMerged = entry.branch && targetBranch
|
|
375
|
+
? isBranchAncestor(repo, entry.branch, targetBranch)
|
|
376
|
+
: false;
|
|
377
|
+
const mergedTargets = entry.branch && !targetBranch
|
|
378
|
+
? mergedIntoTargets(repo, entry.branch, nonWorktreeBranches)
|
|
379
|
+
: [];
|
|
380
|
+
return {
|
|
381
|
+
branch: entry.branch || "",
|
|
382
|
+
path: resolvedPath,
|
|
383
|
+
kind: entry.kind || "worktree",
|
|
384
|
+
current: Boolean(entry.current),
|
|
385
|
+
previous: Boolean(entry.previous),
|
|
386
|
+
dirty,
|
|
387
|
+
state: entry.state || "",
|
|
388
|
+
integrationReason: entry.integrationReason || "",
|
|
389
|
+
targetBranch,
|
|
390
|
+
targetSource: targetBranch ? stringValue(metadata.workspaces[entry.branch]?.targetSource) || "aiw" : "",
|
|
391
|
+
targetMerged,
|
|
392
|
+
mergedTargets,
|
|
393
|
+
cmux: cmuxPaths.has(resolvedPath),
|
|
394
|
+
commit: entry.commit || "",
|
|
395
|
+
lastChangedAt,
|
|
396
|
+
ageSeconds: lastChangedAt ? Math.max(0, Math.floor((Date.now() - lastChangedAt) / 1000)) : null
|
|
397
|
+
};
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readWorktrunkList(repo) {
|
|
402
|
+
const result = tryCapture("wt", ["list", "--format", "json"], { cwd: repo });
|
|
403
|
+
if (!result.ok || !result.stdout) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const parsed = JSON.parse(result.stdout);
|
|
408
|
+
if (!Array.isArray(parsed)) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
return parsed.map((entry) => ({
|
|
412
|
+
branch: stringValue(entry.branch),
|
|
413
|
+
path: stringValue(entry.path),
|
|
414
|
+
kind: stringValue(entry.kind),
|
|
415
|
+
current: Boolean(entry.is_current),
|
|
416
|
+
previous: Boolean(entry.is_previous),
|
|
417
|
+
dirty: isWorktrunkDirty(entry.working_tree),
|
|
418
|
+
state: stringValue(entry.main_state),
|
|
419
|
+
integrationReason: stringValue(entry.integration_reason),
|
|
420
|
+
commit: stringValue(entry.commit?.short_sha),
|
|
421
|
+
lastChangedAt: timestampMillis(entry.commit?.timestamp)
|
|
422
|
+
})).filter((entry) => entry.path);
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function readGitWorktreeList(repo) {
|
|
429
|
+
const result = tryCapture("git", ["worktree", "list", "--porcelain"], { cwd: repo });
|
|
430
|
+
if (!result.ok || !result.stdout) {
|
|
431
|
+
return [];
|
|
432
|
+
}
|
|
433
|
+
const current = normalizePath(repo);
|
|
434
|
+
return result.stdout.split(/\n\n+/).map((block) => parseWorktreeBlock(block, current)).filter(Boolean);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseWorktreeBlock(block, current) {
|
|
438
|
+
const entry = {
|
|
439
|
+
branch: "",
|
|
440
|
+
path: "",
|
|
441
|
+
kind: "worktree",
|
|
442
|
+
current: false,
|
|
443
|
+
previous: false,
|
|
444
|
+
commit: ""
|
|
445
|
+
};
|
|
446
|
+
for (const line of block.split(/\r?\n/)) {
|
|
447
|
+
const [key, ...rest] = line.split(" ");
|
|
448
|
+
const value = rest.join(" ");
|
|
449
|
+
if (key === "worktree") {
|
|
450
|
+
entry.path = value;
|
|
451
|
+
} else if (key === "HEAD") {
|
|
452
|
+
entry.commit = value.slice(0, 7);
|
|
453
|
+
} else if (key === "branch") {
|
|
454
|
+
entry.branch = value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value;
|
|
455
|
+
} else if (key === "detached") {
|
|
456
|
+
entry.kind = "detached";
|
|
457
|
+
} else if (key === "bare") {
|
|
458
|
+
entry.kind = "bare";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (!entry.path) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
entry.current = normalizePath(entry.path) === current;
|
|
465
|
+
return entry;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function collectCmuxWorkspacePaths() {
|
|
469
|
+
const paths = new Set();
|
|
470
|
+
const result = tryCapture("cmux", ["list-workspaces", "--json"]);
|
|
471
|
+
if (!result.ok || !result.stdout) {
|
|
472
|
+
return paths;
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const parsed = JSON.parse(result.stdout);
|
|
476
|
+
const workspaces = Array.isArray(parsed.workspaces) ? parsed.workspaces : [];
|
|
477
|
+
for (const workspace of workspaces) {
|
|
478
|
+
if (workspace.current_directory) {
|
|
479
|
+
paths.add(normalizePath(workspace.current_directory));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
return paths;
|
|
484
|
+
}
|
|
485
|
+
return paths;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function cmuxWorkspaceRefForPath(workspacePath) {
|
|
489
|
+
const targetPath = normalizePath(workspacePath);
|
|
490
|
+
const result = tryCapture("cmux", ["list-workspaces", "--json"]);
|
|
491
|
+
if (!result.ok || !result.stdout) {
|
|
492
|
+
return "";
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const parsed = JSON.parse(result.stdout);
|
|
496
|
+
const workspaces = Array.isArray(parsed.workspaces) ? parsed.workspaces : [];
|
|
497
|
+
const match = workspaces.find((workspace) => {
|
|
498
|
+
return workspace.current_directory && normalizePath(workspace.current_directory) === targetPath;
|
|
499
|
+
});
|
|
500
|
+
return stringValue(match?.ref);
|
|
501
|
+
} catch {
|
|
502
|
+
return "";
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function closeCmuxWorkspace(workspaceRef) {
|
|
507
|
+
if (!workspaceRef) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
tryCapture("cmux", ["close-workspace", "--workspace", workspaceRef]);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function assertDoneAllowed(repo) {
|
|
514
|
+
const current = currentWorkspaceRecord(repo);
|
|
515
|
+
if (current?.state === "is_main" || isPrimaryWorktree(repo)) {
|
|
516
|
+
const error = new Error("aiw workspace done must be run from a feature worktree, not the main workspace");
|
|
517
|
+
error.exitCode = 5;
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function currentWorkspaceRecord(repo) {
|
|
523
|
+
const currentPath = normalizePath(repo);
|
|
524
|
+
return collectWorkspaceRecords(repo).find((workspace) => {
|
|
525
|
+
return workspace.current || workspace.path === currentPath;
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function isPrimaryWorktree(repo) {
|
|
530
|
+
const gitDir = gitRevPath(repo, "--git-dir");
|
|
531
|
+
const commonDir = gitRevPath(repo, "--git-common-dir");
|
|
532
|
+
return Boolean(gitDir && commonDir && gitDir === commonDir);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function gitRevPath(repo, option) {
|
|
536
|
+
const result = tryCapture("git", ["rev-parse", option], { cwd: repo });
|
|
537
|
+
if (!result.ok || !result.stdout) {
|
|
538
|
+
return "";
|
|
539
|
+
}
|
|
540
|
+
const resolved = path.isAbsolute(result.stdout)
|
|
541
|
+
? result.stdout
|
|
542
|
+
: path.resolve(repo, result.stdout);
|
|
543
|
+
return normalizePath(resolved);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function formatWorkspaceTable(workspaces, options = {}) {
|
|
547
|
+
if (workspaces.length === 0) {
|
|
548
|
+
return "No workspaces found.";
|
|
549
|
+
}
|
|
550
|
+
const painter = createPainter(options.color);
|
|
551
|
+
const plan = buildGcPlan(workspaces, {
|
|
552
|
+
staleSeconds: options.staleSeconds ?? DEFAULT_STALE_SECONDS
|
|
553
|
+
});
|
|
554
|
+
const signals = new Map([
|
|
555
|
+
...plan.removable.map((workspace) => [workspace.path, workspace.gc]),
|
|
556
|
+
...plan.warnings.map((workspace) => [workspace.path, workspace.gc])
|
|
557
|
+
]);
|
|
558
|
+
const rows = workspaces.map((workspace) => ({
|
|
559
|
+
workspace: {
|
|
560
|
+
...workspace,
|
|
561
|
+
gc: signals.get(workspace.path) || enrichWorkspaceSignals(workspace, plan.staleSeconds).gc
|
|
562
|
+
},
|
|
563
|
+
values: {
|
|
564
|
+
mark: workspace.current ? "@" : workspace.previous ? "-" : "",
|
|
565
|
+
branch: workspace.branch || "(detached)",
|
|
566
|
+
git: workspace.dirty ? "dirty" : "clean",
|
|
567
|
+
state: stateLabel(workspace.state),
|
|
568
|
+
target: targetLabel(workspace),
|
|
569
|
+
merged: mergedLabel(workspace),
|
|
570
|
+
cmux: workspace.cmux ? "open" : "-",
|
|
571
|
+
age: formatAge(workspace.ageSeconds),
|
|
572
|
+
gc: gcLabel(signals.get(workspace.path) || enrichWorkspaceSignals(workspace, plan.staleSeconds).gc),
|
|
573
|
+
path: workspace.path
|
|
574
|
+
}
|
|
575
|
+
}));
|
|
576
|
+
const columns = [
|
|
577
|
+
["", "mark"],
|
|
578
|
+
["BRANCH", "branch"],
|
|
579
|
+
["GIT", "git"],
|
|
580
|
+
["STATE", "state"],
|
|
581
|
+
["TARGET", "target"],
|
|
582
|
+
["MERGED", "merged"],
|
|
583
|
+
["CMUX", "cmux"],
|
|
584
|
+
["AGE", "age"],
|
|
585
|
+
["GC", "gc"],
|
|
586
|
+
["PATH", "path"]
|
|
587
|
+
];
|
|
588
|
+
const widths = columns.map(([header, key]) => {
|
|
589
|
+
return Math.max(header.length, ...rows.map((row) => String(row.values[key]).length));
|
|
590
|
+
});
|
|
591
|
+
const summary = formatWorkspaceSummary(workspaces, plan, painter);
|
|
592
|
+
const header = painter.bold(columns.map(([label], index) => label.padEnd(widths[index])).join(" ").trimEnd());
|
|
593
|
+
const body = rows.map((row) => {
|
|
594
|
+
return columns.map(([, key], index) => {
|
|
595
|
+
const value = String(row.values[key]);
|
|
596
|
+
return colorWorkspaceCell(key, value.padEnd(widths[index]), row.workspace, painter);
|
|
597
|
+
}).join(" ").trimEnd();
|
|
598
|
+
});
|
|
599
|
+
return [
|
|
600
|
+
summary,
|
|
601
|
+
header,
|
|
602
|
+
...body,
|
|
603
|
+
formatStateLegend(workspaces, painter)
|
|
604
|
+
].join("\n");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function findWorkspace(workspaces, target) {
|
|
608
|
+
const normalizedTarget = normalizePath(target);
|
|
609
|
+
return workspaces.find((workspace) => {
|
|
610
|
+
return workspace.branch === target ||
|
|
611
|
+
workspace.path === normalizedTarget ||
|
|
612
|
+
path.basename(workspace.path) === target;
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function gitRootIfExists(target) {
|
|
617
|
+
const resolved = normalizePath(target);
|
|
618
|
+
if (!resolved || !fs.existsSync(resolved)) {
|
|
619
|
+
return "";
|
|
620
|
+
}
|
|
621
|
+
const result = tryCapture("git", ["rev-parse", "--show-toplevel"], { cwd: resolved });
|
|
622
|
+
return result.ok ? normalizePath(result.stdout) : "";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function listLocalBranches(repo) {
|
|
626
|
+
const result = tryCapture("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd: repo });
|
|
627
|
+
return result.ok ? result.stdout.split(/\r?\n/).filter(Boolean).sort() : [];
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function listRemoteBranches(repo) {
|
|
631
|
+
const result = tryCapture("git", ["for-each-ref", "--format=%(refname:short)", "refs/remotes"], { cwd: repo });
|
|
632
|
+
if (!result.ok) {
|
|
633
|
+
return [];
|
|
634
|
+
}
|
|
635
|
+
return [...new Set(result.stdout.split(/\r?\n/)
|
|
636
|
+
.filter((branch) => branch && !branch.endsWith("/HEAD"))
|
|
637
|
+
.map((branch) => branch.startsWith("origin/") ? branch.slice("origin/".length) : branch))]
|
|
638
|
+
.sort();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function currentBranch(repo) {
|
|
642
|
+
const result = tryCapture("git", ["branch", "--show-current"], { cwd: repo });
|
|
643
|
+
return result.ok ? result.stdout : "";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function mergedIntoTargets(repo, branch, targets) {
|
|
647
|
+
return targets.filter((target) => {
|
|
648
|
+
if (!target || target === branch) {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
return isBranchAncestor(repo, branch, target);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function isBranchAncestor(repo, branch, target) {
|
|
656
|
+
const result = tryCapture("git", ["merge-base", "--is-ancestor", branch, target], { cwd: repo });
|
|
657
|
+
return result.ok;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function isWorktreeDirty(worktreePath) {
|
|
661
|
+
if (!worktreePath) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
const result = tryCapture("git", ["status", "--short"], { cwd: worktreePath });
|
|
665
|
+
return result.ok && result.stdout.length > 0;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function worktreeLastChangedAt(worktreePath) {
|
|
669
|
+
if (!worktreePath) {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const result = tryCapture("git", ["log", "-1", "--format=%ct"], { cwd: worktreePath });
|
|
673
|
+
return result.ok ? timestampMillis(result.stdout) : null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function timestampMillis(value) {
|
|
677
|
+
const timestamp = Number(value);
|
|
678
|
+
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp * 1000 : null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function isWorktrunkDirty(workingTree) {
|
|
682
|
+
if (!workingTree || typeof workingTree !== "object") {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
return Boolean(
|
|
686
|
+
workingTree.staged ||
|
|
687
|
+
workingTree.modified ||
|
|
688
|
+
workingTree.untracked ||
|
|
689
|
+
workingTree.renamed ||
|
|
690
|
+
workingTree.deleted
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function buildGcPlan(workspaces, options = {}) {
|
|
695
|
+
const staleSeconds = options.staleSeconds ?? DEFAULT_STALE_SECONDS;
|
|
696
|
+
const enriched = workspaces.map((workspace) => enrichWorkspaceSignals(workspace, staleSeconds));
|
|
697
|
+
const removable = enriched.filter((workspace) => workspace.gc.removable).map((workspace) => ({
|
|
698
|
+
...workspace,
|
|
699
|
+
command: removeCommand(workspace)
|
|
700
|
+
}));
|
|
701
|
+
const warnings = enriched.filter((workspace) => workspace.gc.stale && !workspace.gc.removable).map((workspace) => ({
|
|
702
|
+
...workspace,
|
|
703
|
+
reason: gcBlockedReason(workspace)
|
|
704
|
+
}));
|
|
705
|
+
return {
|
|
706
|
+
staleSeconds,
|
|
707
|
+
removable,
|
|
708
|
+
warnings
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function enrichWorkspaceSignals(workspace, staleSeconds) {
|
|
713
|
+
const merged = isMergedWorkspace(workspace);
|
|
714
|
+
const stale = typeof workspace.ageSeconds === "number" && workspace.ageSeconds >= staleSeconds;
|
|
715
|
+
const removable = !workspace.current && !workspace.dirty && merged && Boolean(workspace.branch || workspace.path);
|
|
716
|
+
return {
|
|
717
|
+
...workspace,
|
|
718
|
+
gc: {
|
|
719
|
+
merged,
|
|
720
|
+
stale,
|
|
721
|
+
removable
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function isMergedWorkspace(workspace) {
|
|
727
|
+
if (workspace.targetBranch) {
|
|
728
|
+
return Boolean(workspace.targetMerged);
|
|
729
|
+
}
|
|
730
|
+
return INTEGRATED_STATES.has(workspace.state) || (Array.isArray(workspace.mergedTargets) && workspace.mergedTargets.length > 0);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function gcBlockedReason(workspace) {
|
|
734
|
+
const reasons = [];
|
|
735
|
+
if (workspace.current) {
|
|
736
|
+
reasons.push("current");
|
|
737
|
+
}
|
|
738
|
+
if (workspace.dirty) {
|
|
739
|
+
reasons.push("dirty");
|
|
740
|
+
}
|
|
741
|
+
if (!workspace.gc?.merged) {
|
|
742
|
+
reasons.push("not merged");
|
|
743
|
+
}
|
|
744
|
+
if (!workspace.branch && !workspace.path) {
|
|
745
|
+
reasons.push("missing target");
|
|
746
|
+
}
|
|
747
|
+
return reasons.join(", ") || "manual review";
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function formatGcPreview(plan, options = {}) {
|
|
751
|
+
const painter = createPainter(options.color);
|
|
752
|
+
const sections = [
|
|
753
|
+
`${options.dryRun ? "GC dry-run" : "GC plan"}: ${painter.green(String(plan.removable.length))} removable, ${plan.warnings.length > 0 ? painter.yellow(String(plan.warnings.length)) : "0"} stale warning(s). stale >= ${plan.staleSeconds}s`
|
|
754
|
+
];
|
|
755
|
+
|
|
756
|
+
if (plan.removable.length > 0) {
|
|
757
|
+
sections.push(formatGcTable("Removable: clean + merged/integrated", plan.removable, painter, true));
|
|
758
|
+
} else {
|
|
759
|
+
sections.push("Removable: none");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (plan.warnings.length > 0) {
|
|
763
|
+
sections.push(formatGcTable("Warnings: stale but blocked from auto cleanup", plan.warnings, painter, false));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
sections.push(options.dryRun
|
|
767
|
+
? "No files were removed. Run without --dry-run to confirm cleanup, or use --apply/--yes."
|
|
768
|
+
: "Only removable workspaces can be deleted; stale warnings are not touched.");
|
|
769
|
+
return sections.join("\n");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function formatGcTable(title, workspaces, painter, includeCommand) {
|
|
773
|
+
const rows = workspaces.map((workspace) => ({
|
|
774
|
+
workspace,
|
|
775
|
+
values: {
|
|
776
|
+
branch: workspace.branch || "(detached)",
|
|
777
|
+
age: formatAge(workspace.ageSeconds),
|
|
778
|
+
state: stateLabel(workspace.state),
|
|
779
|
+
path: workspace.path,
|
|
780
|
+
reason: workspace.reason || "-",
|
|
781
|
+
command: workspace.command || removeCommand(workspace)
|
|
782
|
+
}
|
|
783
|
+
}));
|
|
784
|
+
const columns = includeCommand ? [
|
|
785
|
+
["BRANCH", "branch"],
|
|
786
|
+
["AGE", "age"],
|
|
787
|
+
["STATE", "state"],
|
|
788
|
+
["PATH", "path"],
|
|
789
|
+
["COMMAND", "command"]
|
|
790
|
+
] : [
|
|
791
|
+
["BRANCH", "branch"],
|
|
792
|
+
["AGE", "age"],
|
|
793
|
+
["STATE", "state"],
|
|
794
|
+
["REASON", "reason"],
|
|
795
|
+
["PATH", "path"]
|
|
796
|
+
];
|
|
797
|
+
const widths = columns.map(([header, key]) => {
|
|
798
|
+
return Math.max(header.length, ...rows.map((row) => String(row.values[key]).length));
|
|
799
|
+
});
|
|
800
|
+
const header = painter.bold(columns.map(([label], index) => label.padEnd(widths[index])).join(" ").trimEnd());
|
|
801
|
+
const body = rows.map((row) => {
|
|
802
|
+
return columns.map(([, key], index) => {
|
|
803
|
+
const value = String(row.values[key]).padEnd(widths[index]);
|
|
804
|
+
if (key === "state") {
|
|
805
|
+
return colorState(value, row.workspace.state, painter);
|
|
806
|
+
}
|
|
807
|
+
if (key === "command") {
|
|
808
|
+
return painter.dim(value);
|
|
809
|
+
}
|
|
810
|
+
if (key === "reason") {
|
|
811
|
+
return painter.yellow(value);
|
|
812
|
+
}
|
|
813
|
+
return value;
|
|
814
|
+
}).join(" ").trimEnd();
|
|
815
|
+
});
|
|
816
|
+
return [title, header, ...body].join("\n");
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function confirmGcApply(plan) {
|
|
820
|
+
if (!process.stdin.isTTY) {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
const answer = await askInput(`Remove ${plan.removable.length} safe workspace(s)? Type y to confirm`);
|
|
824
|
+
return answer.toLowerCase() === "y";
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function applyGcPlan(config, repo, plan) {
|
|
828
|
+
const removed = [];
|
|
829
|
+
const skipped = [];
|
|
830
|
+
for (const workspace of plan.removable) {
|
|
831
|
+
const target = workspace.branch || workspace.path;
|
|
832
|
+
if (!target) {
|
|
833
|
+
skipped.push({
|
|
834
|
+
target: workspace.path || workspace.branch || "",
|
|
835
|
+
reason: "missing target"
|
|
836
|
+
});
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
await runWorkspaceHook(config, "pre_remove", {
|
|
841
|
+
repo: workspace.path || repo,
|
|
842
|
+
cwd: workspace.path || repo,
|
|
843
|
+
workspacePath: workspace.path || "",
|
|
844
|
+
branch: workspace.branch || "",
|
|
845
|
+
target
|
|
846
|
+
});
|
|
847
|
+
} catch (error) {
|
|
848
|
+
skipped.push({
|
|
849
|
+
target,
|
|
850
|
+
reason: error.message
|
|
851
|
+
});
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
const result = tryCapture("wt", ["--yes", "remove", target], { cwd: repo });
|
|
855
|
+
if (!result.ok) {
|
|
856
|
+
skipped.push({
|
|
857
|
+
target,
|
|
858
|
+
reason: result.stderr || result.stdout || `wt remove exited with ${result.status}`
|
|
859
|
+
});
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
removed.push(target);
|
|
863
|
+
const workspaceRef = cmuxWorkspaceRefForPath(workspace.path);
|
|
864
|
+
if (workspaceRef) {
|
|
865
|
+
closeCmuxWorkspace(workspaceRef);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
removed,
|
|
870
|
+
skipped
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function removeCommand(workspace) {
|
|
875
|
+
const target = workspace.branch || workspace.path;
|
|
876
|
+
return `aiw workspace remove ${quoteShell(target)}`;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function targetLabel(workspace) {
|
|
880
|
+
if (workspace.targetBranch) {
|
|
881
|
+
return workspace.targetBranch;
|
|
882
|
+
}
|
|
883
|
+
if (Array.isArray(workspace.mergedTargets) && workspace.mergedTargets.length > 0) {
|
|
884
|
+
return `?${workspace.mergedTargets[0]}`;
|
|
885
|
+
}
|
|
886
|
+
return "-";
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function mergedLabel(workspace) {
|
|
890
|
+
if (workspace.targetBranch) {
|
|
891
|
+
return workspace.targetMerged ? "yes" : "no";
|
|
892
|
+
}
|
|
893
|
+
if (INTEGRATED_STATES.has(workspace.state)) {
|
|
894
|
+
return "inferred";
|
|
895
|
+
}
|
|
896
|
+
if (Array.isArray(workspace.mergedTargets) && workspace.mergedTargets.length > 0) {
|
|
897
|
+
return "inferred";
|
|
898
|
+
}
|
|
899
|
+
return "-";
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function gcLabel(gc) {
|
|
903
|
+
if (gc?.removable) {
|
|
904
|
+
return "remove";
|
|
905
|
+
}
|
|
906
|
+
if (gc?.stale) {
|
|
907
|
+
return "stale";
|
|
908
|
+
}
|
|
909
|
+
return "-";
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function formatAge(ageSeconds) {
|
|
913
|
+
if (typeof ageSeconds !== "number") {
|
|
914
|
+
return "-";
|
|
915
|
+
}
|
|
916
|
+
if (ageSeconds < 60) {
|
|
917
|
+
return `${ageSeconds}s`;
|
|
918
|
+
}
|
|
919
|
+
const minutes = Math.floor(ageSeconds / 60);
|
|
920
|
+
if (minutes < 60) {
|
|
921
|
+
return `${minutes}m`;
|
|
922
|
+
}
|
|
923
|
+
const hours = Math.floor(minutes / 60);
|
|
924
|
+
if (hours < 48) {
|
|
925
|
+
return `${hours}h`;
|
|
926
|
+
}
|
|
927
|
+
return `${Math.floor(hours / 24)}d`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function formatWorkspaceSummary(workspaces, plan, painter) {
|
|
931
|
+
const dirty = workspaces.filter((workspace) => workspace.dirty).length;
|
|
932
|
+
const open = workspaces.filter((workspace) => workspace.cmux).length;
|
|
933
|
+
const gc = plan.removable.length;
|
|
934
|
+
const stale = plan.warnings.length;
|
|
935
|
+
return [
|
|
936
|
+
painter.bold(`Workspaces: ${workspaces.length}`),
|
|
937
|
+
`dirty ${dirty > 0 ? painter.red(String(dirty)) : painter.green("0")}`,
|
|
938
|
+
`cmux open ${open > 0 ? painter.green(String(open)) : "0"}`,
|
|
939
|
+
`removable ${gc > 0 ? painter.green(String(gc)) : "0"}`,
|
|
940
|
+
`stale warnings ${stale > 0 ? painter.yellow(String(stale)) : "0"}`
|
|
941
|
+
].join(" ");
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function colorWorkspaceCell(key, value, workspace, painter) {
|
|
945
|
+
if (key === "mark" && workspace.current) {
|
|
946
|
+
return painter.cyan(painter.bold(value));
|
|
947
|
+
}
|
|
948
|
+
if (key === "mark" && workspace.previous) {
|
|
949
|
+
return painter.yellow(value);
|
|
950
|
+
}
|
|
951
|
+
if (key === "branch" && workspace.current) {
|
|
952
|
+
return painter.bold(value);
|
|
953
|
+
}
|
|
954
|
+
if (key === "git") {
|
|
955
|
+
return workspace.dirty ? painter.red(painter.bold(value)) : painter.green(value);
|
|
956
|
+
}
|
|
957
|
+
if (key === "state") {
|
|
958
|
+
return colorState(value, workspace.state, painter);
|
|
959
|
+
}
|
|
960
|
+
if (key === "target") {
|
|
961
|
+
if (workspace.targetBranch) {
|
|
962
|
+
return painter.cyan(value);
|
|
963
|
+
}
|
|
964
|
+
return value.trim().startsWith("?") ? painter.yellow(value) : painter.dim(value);
|
|
965
|
+
}
|
|
966
|
+
if (key === "merged") {
|
|
967
|
+
if (value.trim() === "yes") {
|
|
968
|
+
return painter.green(value);
|
|
969
|
+
}
|
|
970
|
+
if (value.trim() === "inferred") {
|
|
971
|
+
return painter.yellow(value);
|
|
972
|
+
}
|
|
973
|
+
return value.trim() === "-" ? painter.dim(value) : painter.red(value);
|
|
974
|
+
}
|
|
975
|
+
if (key === "cmux") {
|
|
976
|
+
return workspace.cmux ? painter.green(value) : painter.dim(value);
|
|
977
|
+
}
|
|
978
|
+
if (key === "gc") {
|
|
979
|
+
if (workspace.gc?.removable) {
|
|
980
|
+
return painter.green(value);
|
|
981
|
+
}
|
|
982
|
+
if (workspace.gc?.stale) {
|
|
983
|
+
return painter.yellow(value);
|
|
984
|
+
}
|
|
985
|
+
return painter.dim(value);
|
|
986
|
+
}
|
|
987
|
+
if (key === "age" && workspace.gc?.stale) {
|
|
988
|
+
return painter.yellow(value);
|
|
989
|
+
}
|
|
990
|
+
return value;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function colorState(value, state, painter) {
|
|
994
|
+
if (state === "is_main") {
|
|
995
|
+
return painter.cyan(value);
|
|
996
|
+
}
|
|
997
|
+
if (INTEGRATED_STATES.has(state)) {
|
|
998
|
+
return painter.green(value);
|
|
999
|
+
}
|
|
1000
|
+
if (state === "would_conflict" || state === "orphan") {
|
|
1001
|
+
return painter.red(painter.bold(value));
|
|
1002
|
+
}
|
|
1003
|
+
if (state === "diverged") {
|
|
1004
|
+
return painter.magenta(value);
|
|
1005
|
+
}
|
|
1006
|
+
if (state === "ahead" || state === "behind") {
|
|
1007
|
+
return painter.yellow(value);
|
|
1008
|
+
}
|
|
1009
|
+
return painter.dim(value);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function formatStateLegend(workspaces, painter) {
|
|
1013
|
+
const states = [...new Set(workspaces.map((workspace) => workspace.state).filter(Boolean))];
|
|
1014
|
+
const shown = states.map((state) => {
|
|
1015
|
+
return `${colorState(stateLabel(state), state, painter)}=${stateDescription(state)}`;
|
|
1016
|
+
});
|
|
1017
|
+
if (shown.length === 0) {
|
|
1018
|
+
return `STATE: ${painter.dim("no state values")} GC: remove=clean+merged, stale=old but manual review. Full legend: aiw workspace states`;
|
|
1019
|
+
}
|
|
1020
|
+
return `STATE: ${shown.join("; ")}. GC: remove=clean+merged, stale=old but manual review. Full legend: aiw workspace states`;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function stateLabel(state) {
|
|
1024
|
+
switch (state) {
|
|
1025
|
+
case "is_main":
|
|
1026
|
+
return "main";
|
|
1027
|
+
case "same_commit":
|
|
1028
|
+
return "same";
|
|
1029
|
+
case "integrated":
|
|
1030
|
+
return "merged";
|
|
1031
|
+
case "would_conflict":
|
|
1032
|
+
return "conflict";
|
|
1033
|
+
case "":
|
|
1034
|
+
return "-";
|
|
1035
|
+
default:
|
|
1036
|
+
return state;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function stateDescription(state) {
|
|
1041
|
+
switch (state) {
|
|
1042
|
+
case "is_main":
|
|
1043
|
+
return "default worktree";
|
|
1044
|
+
case "orphan":
|
|
1045
|
+
return "no common base with default";
|
|
1046
|
+
case "would_conflict":
|
|
1047
|
+
return "merge would conflict";
|
|
1048
|
+
case "empty":
|
|
1049
|
+
return "no effective branch changes";
|
|
1050
|
+
case "same_commit":
|
|
1051
|
+
return "same HEAD as default";
|
|
1052
|
+
case "integrated":
|
|
1053
|
+
return "content already in default";
|
|
1054
|
+
case "diverged":
|
|
1055
|
+
return "both branch and default moved";
|
|
1056
|
+
case "ahead":
|
|
1057
|
+
return "branch has changes to merge";
|
|
1058
|
+
case "behind":
|
|
1059
|
+
return "behind default";
|
|
1060
|
+
default:
|
|
1061
|
+
return state || "unknown";
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function createPainter(enabled) {
|
|
1066
|
+
const wrap = (code, text) => enabled ? `${code}${text}${ANSI.reset}` : text;
|
|
1067
|
+
return {
|
|
1068
|
+
bold: (text) => wrap(ANSI.bold, text),
|
|
1069
|
+
dim: (text) => wrap(ANSI.dim, text),
|
|
1070
|
+
red: (text) => wrap(ANSI.red, text),
|
|
1071
|
+
green: (text) => wrap(ANSI.green, text),
|
|
1072
|
+
yellow: (text) => wrap(ANSI.yellow, text),
|
|
1073
|
+
magenta: (text) => wrap(ANSI.magenta, text),
|
|
1074
|
+
cyan: (text) => wrap(ANSI.cyan, text),
|
|
1075
|
+
gray: (text) => wrap(ANSI.gray, text)
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function shouldUseColor(flags) {
|
|
1080
|
+
if (flags.color === "always") {
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
if (flags.color === "never" || process.env.NO_COLOR) {
|
|
1084
|
+
return false;
|
|
1085
|
+
}
|
|
1086
|
+
return process.stdout.isTTY;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function hasForceFlag(argv) {
|
|
1090
|
+
return argv.includes("--force") || argv.includes("-f");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function hasHelpFlag(argv) {
|
|
1094
|
+
return argv.includes("--help") || argv.includes("-h");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function hasDryRunFlag(argv) {
|
|
1098
|
+
return argv.includes("--dry-run");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function withSelectedMergeTarget(repo, argv) {
|
|
1102
|
+
if (mergeTarget(argv)) {
|
|
1103
|
+
return argv;
|
|
1104
|
+
}
|
|
1105
|
+
const current = currentBranch(repo);
|
|
1106
|
+
const recordedTarget = workspaceTargetBranch(repo, current);
|
|
1107
|
+
if (!process.stdin.isTTY) {
|
|
1108
|
+
return recordedTarget ? [...argv, recordedTarget] : argv;
|
|
1109
|
+
}
|
|
1110
|
+
const branches = listLocalBranches(repo).filter((branch) => branch !== current);
|
|
1111
|
+
if (recordedTarget && !branches.includes(recordedTarget)) {
|
|
1112
|
+
branches.unshift(recordedTarget);
|
|
1113
|
+
}
|
|
1114
|
+
if (branches.length === 0) {
|
|
1115
|
+
return argv;
|
|
1116
|
+
}
|
|
1117
|
+
const selected = await pickFromList("Merge target", branches, {
|
|
1118
|
+
defaultItem: recordedTarget || defaultMergeTarget(branches)
|
|
1119
|
+
});
|
|
1120
|
+
return [...argv, selected];
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function mergeTarget(argv) {
|
|
1124
|
+
const optionsWithValues = new Set(["-C", "--config", "--format", "--stage"]);
|
|
1125
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1126
|
+
const arg = argv[index];
|
|
1127
|
+
if (optionsWithValues.has(arg)) {
|
|
1128
|
+
index += 1;
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (arg.startsWith("-")) {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
return arg;
|
|
1135
|
+
}
|
|
1136
|
+
return "";
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function defaultMergeTarget(branches) {
|
|
1140
|
+
return branches.includes("dev")
|
|
1141
|
+
? "dev"
|
|
1142
|
+
: branches.find((branch) => branch === "develop") || branches[0];
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function dirtyRemoveTargets(repo, argv) {
|
|
1146
|
+
const targets = removeTargets(argv);
|
|
1147
|
+
if (targets.length === 0) {
|
|
1148
|
+
return isDirty(repo) ? [repo] : [];
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const workspaces = collectWorkspaceRecords(repo);
|
|
1152
|
+
const dirtyPaths = [];
|
|
1153
|
+
for (const target of targets) {
|
|
1154
|
+
const record = findWorkspace(workspaces, target);
|
|
1155
|
+
const targetRoot = record?.path || gitRootIfExists(target);
|
|
1156
|
+
if (targetRoot && isDirty(targetRoot)) {
|
|
1157
|
+
dirtyPaths.push(targetRoot);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return [...new Set(dirtyPaths)];
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function removeHookContexts(repo, argv) {
|
|
1164
|
+
const targets = removeTargets(argv);
|
|
1165
|
+
if (targets.length === 0) {
|
|
1166
|
+
return [{
|
|
1167
|
+
repo,
|
|
1168
|
+
cwd: repo,
|
|
1169
|
+
workspacePath: repo,
|
|
1170
|
+
branch: currentBranch(repo),
|
|
1171
|
+
target: repo
|
|
1172
|
+
}];
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const workspaces = collectWorkspaceRecords(repo);
|
|
1176
|
+
const contexts = [];
|
|
1177
|
+
const seen = new Set();
|
|
1178
|
+
for (const target of targets) {
|
|
1179
|
+
const record = findWorkspace(workspaces, target);
|
|
1180
|
+
const workspacePath = record?.path || gitRootIfExists(target) || repo;
|
|
1181
|
+
const key = `${workspacePath}\0${target}`;
|
|
1182
|
+
if (seen.has(key)) {
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
seen.add(key);
|
|
1186
|
+
contexts.push({
|
|
1187
|
+
repo: workspacePath,
|
|
1188
|
+
cwd: workspacePath,
|
|
1189
|
+
workspacePath,
|
|
1190
|
+
branch: record?.branch || "",
|
|
1191
|
+
target
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
return contexts;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function removeTargets(argv) {
|
|
1198
|
+
const targets = [];
|
|
1199
|
+
const optionsWithValues = new Set(["-C", "--config", "--format"]);
|
|
1200
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1201
|
+
const arg = argv[index];
|
|
1202
|
+
if (arg === "--") {
|
|
1203
|
+
targets.push(...argv.slice(index + 1));
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1206
|
+
if (optionsWithValues.has(arg)) {
|
|
1207
|
+
index += 1;
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (arg.startsWith("-")) {
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
targets.push(arg);
|
|
1214
|
+
}
|
|
1215
|
+
return targets;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function normalizePath(value) {
|
|
1219
|
+
if (!value) {
|
|
1220
|
+
return "";
|
|
1221
|
+
}
|
|
1222
|
+
const expanded = value === "~" || value.startsWith("~/")
|
|
1223
|
+
? path.join(process.env.HOME || "", value.slice(value === "~" ? 1 : 2))
|
|
1224
|
+
: value;
|
|
1225
|
+
const resolved = path.resolve(expanded);
|
|
1226
|
+
try {
|
|
1227
|
+
return fs.realpathSync.native(resolved);
|
|
1228
|
+
} catch {
|
|
1229
|
+
return resolved;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function stringValue(value) {
|
|
1234
|
+
return typeof value === "string" ? value : "";
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function workspaceTargetBranch(repo, branch) {
|
|
1238
|
+
return branch ? stringValue(readWorkspaceMetadata(repo).workspaces[branch]?.targetBranch) : "";
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function readWorkspaceMetadata(repo) {
|
|
1242
|
+
const metadataPath = workspaceMetadataPath(repo);
|
|
1243
|
+
if (!metadataPath || !fs.existsSync(metadataPath)) {
|
|
1244
|
+
return emptyWorkspaceMetadata();
|
|
1245
|
+
}
|
|
1246
|
+
try {
|
|
1247
|
+
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
|
1248
|
+
return {
|
|
1249
|
+
version: 1,
|
|
1250
|
+
workspaces: parsed && typeof parsed.workspaces === "object" && parsed.workspaces !== null
|
|
1251
|
+
? parsed.workspaces
|
|
1252
|
+
: {}
|
|
1253
|
+
};
|
|
1254
|
+
} catch {
|
|
1255
|
+
return emptyWorkspaceMetadata();
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function writeWorkspaceMetadata(repo, metadata) {
|
|
1260
|
+
const metadataPath = workspaceMetadataPath(repo);
|
|
1261
|
+
if (!metadataPath) {
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
|
|
1265
|
+
fs.writeFileSync(metadataPath, `${JSON.stringify({
|
|
1266
|
+
version: 1,
|
|
1267
|
+
workspaces: metadata.workspaces || {}
|
|
1268
|
+
}, null, 2)}\n`);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function emptyWorkspaceMetadata() {
|
|
1272
|
+
return {
|
|
1273
|
+
version: 1,
|
|
1274
|
+
workspaces: {}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function workspaceMetadataPath(repo) {
|
|
1279
|
+
const result = tryCapture("git", ["rev-parse", "--git-common-dir"], { cwd: repo });
|
|
1280
|
+
if (!result.ok || !result.stdout) {
|
|
1281
|
+
return "";
|
|
1282
|
+
}
|
|
1283
|
+
const commonDir = path.isAbsolute(result.stdout)
|
|
1284
|
+
? result.stdout
|
|
1285
|
+
: path.resolve(repo, result.stdout);
|
|
1286
|
+
return path.join(commonDir, "aiw", "workspaces.json");
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function parseWorkspaceFlags(argv) {
|
|
1290
|
+
const flags = {
|
|
1291
|
+
positionals: []
|
|
1292
|
+
};
|
|
1293
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1294
|
+
const arg = argv[index];
|
|
1295
|
+
switch (arg) {
|
|
1296
|
+
case "--agent":
|
|
1297
|
+
flags.agent = argv[++index];
|
|
1298
|
+
break;
|
|
1299
|
+
case "--json":
|
|
1300
|
+
flags.json = true;
|
|
1301
|
+
break;
|
|
1302
|
+
case "--dry-run":
|
|
1303
|
+
flags.dryRun = true;
|
|
1304
|
+
break;
|
|
1305
|
+
case "--stale-seconds":
|
|
1306
|
+
flags.staleSeconds = Number(argv[++index]);
|
|
1307
|
+
break;
|
|
1308
|
+
case "--apply":
|
|
1309
|
+
flags.apply = true;
|
|
1310
|
+
break;
|
|
1311
|
+
case "--yes":
|
|
1312
|
+
case "-y":
|
|
1313
|
+
flags.yes = true;
|
|
1314
|
+
break;
|
|
1315
|
+
case "--no-close-cmux":
|
|
1316
|
+
flags.closeCmux = false;
|
|
1317
|
+
break;
|
|
1318
|
+
case "--close-cmux":
|
|
1319
|
+
flags.closeCmux = true;
|
|
1320
|
+
break;
|
|
1321
|
+
case "--branches":
|
|
1322
|
+
flags.branches = true;
|
|
1323
|
+
break;
|
|
1324
|
+
case "--remotes":
|
|
1325
|
+
flags.remotes = true;
|
|
1326
|
+
break;
|
|
1327
|
+
case "--worktrees-only":
|
|
1328
|
+
flags.worktreesOnly = true;
|
|
1329
|
+
break;
|
|
1330
|
+
case "--no-color":
|
|
1331
|
+
flags.color = "never";
|
|
1332
|
+
break;
|
|
1333
|
+
case "--color": {
|
|
1334
|
+
const value = argv[++index] || "always";
|
|
1335
|
+
flags.color = value === "never" || value === "auto" ? value : "always";
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
default:
|
|
1339
|
+
flags.positionals.push(arg);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return flags;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function parseDoneFlags(argv) {
|
|
1346
|
+
const flags = {
|
|
1347
|
+
closeCmux: true,
|
|
1348
|
+
passthrough: []
|
|
1349
|
+
};
|
|
1350
|
+
for (const arg of argv) {
|
|
1351
|
+
if (arg === "--no-close-cmux") {
|
|
1352
|
+
flags.closeCmux = false;
|
|
1353
|
+
} else if (arg === "--close-cmux") {
|
|
1354
|
+
flags.closeCmux = true;
|
|
1355
|
+
} else {
|
|
1356
|
+
flags.passthrough.push(arg);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return flags;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function staleSecondsFromFlags(flags, config = {}) {
|
|
1363
|
+
if (flags.staleSeconds === undefined) {
|
|
1364
|
+
const configured = Number(config.workspace?.stale_seconds);
|
|
1365
|
+
return Number.isFinite(configured) && configured >= 0
|
|
1366
|
+
? Math.floor(configured)
|
|
1367
|
+
: DEFAULT_STALE_SECONDS;
|
|
1368
|
+
}
|
|
1369
|
+
if (!Number.isFinite(flags.staleSeconds) || flags.staleSeconds < 0) {
|
|
1370
|
+
const error = new Error("--stale-seconds must be a non-negative number");
|
|
1371
|
+
error.exitCode = 2;
|
|
1372
|
+
throw error;
|
|
1373
|
+
}
|
|
1374
|
+
return Math.floor(flags.staleSeconds);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function printStateHelp() {
|
|
1378
|
+
console.log(`Workspace state values:
|
|
1379
|
+
is_main Default branch worktree
|
|
1380
|
+
empty No effective branch changes
|
|
1381
|
+
same_commit Branch HEAD equals default branch HEAD
|
|
1382
|
+
integrated Branch content is already integrated into default
|
|
1383
|
+
ahead Branch has changes to merge into default
|
|
1384
|
+
behind Branch is behind default
|
|
1385
|
+
diverged Branch and default both moved
|
|
1386
|
+
would_conflict Simulated merge would conflict
|
|
1387
|
+
orphan No common ancestor with default
|
|
1388
|
+
|
|
1389
|
+
Integration reasons when state is integrated:
|
|
1390
|
+
ancestor Branch is in default branch history
|
|
1391
|
+
trees_match Branch tree content matches default
|
|
1392
|
+
no_added_changes Diff from default adds no changes
|
|
1393
|
+
merge_adds_nothing Simulated merge produces default tree
|
|
1394
|
+
patch-id-match Branch diff matches a squash-merge commit`);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function printWorkspaceHelp() {
|
|
1398
|
+
console.log(`Usage: aiw workspace <command> [options]
|
|
1399
|
+
|
|
1400
|
+
Commands:
|
|
1401
|
+
list [--json] [--color mode] [--stale-seconds n]
|
|
1402
|
+
List worktrees with dirty, age, GC, and cmux status
|
|
1403
|
+
status [--json] Alias for list
|
|
1404
|
+
open [target] [--agent name] [--remotes] Open picker or target with the AIW cmux layout
|
|
1405
|
+
switch [target] Alias for open
|
|
1406
|
+
done [target] [--no-close-cmux] Merge the current feature worktree, cleanup, then close cmux workspace
|
|
1407
|
+
remove [wt-remove-args...] Remove worktrees after dirty check
|
|
1408
|
+
gc|clean [--dry-run] [--apply|--yes] [--json] [--stale-seconds n]
|
|
1409
|
+
Preview or remove safe worktrees; stale warnings are not removed
|
|
1410
|
+
states Explain workspace state values
|
|
1411
|
+
|
|
1412
|
+
Short aliases:
|
|
1413
|
+
aiw ws list aiw workspace list
|
|
1414
|
+
aiw list aiw workspace list
|
|
1415
|
+
aiw ws open [target] aiw workspace open [target]
|
|
1416
|
+
aiw open [target] aiw workspace open [target]
|
|
1417
|
+
aiw switch [target] aiw workspace open [target]
|
|
1418
|
+
aiw done aiw workspace done
|
|
1419
|
+
aiw remove aiw workspace remove
|
|
1420
|
+
aiw gc aiw workspace gc
|
|
1421
|
+
aiw clean aiw workspace gc`);
|
|
1422
|
+
}
|