@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.
@@ -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
+ }