@chlrc/aiw 0.1.0 → 0.1.1

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/src/workspace.mjs CHANGED
@@ -23,10 +23,12 @@ const ANSI = {
23
23
  };
24
24
 
25
25
  export async function commandWorkspace(config, argv) {
26
- const subcommand = argv[0] || "list";
26
+ const subcommand = normalizeCommand(argv[0] || "list");
27
27
  const rest = argv.slice(1);
28
28
  switch (subcommand) {
29
29
  case "list":
30
+ case "ls":
31
+ case "als":
30
32
  case "status":
31
33
  await workspaceList(config, rest);
32
34
  return;
@@ -255,19 +257,232 @@ async function workspaceDone(config, argv) {
255
257
  }
256
258
  const closeTarget = flags.closeCmux ? cmuxWorkspaceRefForPath(repo) : "";
257
259
  const mergeArgs = await withSelectedMergeTarget(repo, flags.passthrough);
260
+ const target = mergeTarget(mergeArgs) || defaultDoneTarget(repo);
261
+ assertTargetWorktreeClean(repo, target);
262
+ const retries = doneRetryCount(config, flags);
263
+ const restorePlan = createDoneRestorePlan(repo, currentBranch(repo), target);
264
+ const mergeEnv = worktrunkMergeEnv(config, repo, flags);
258
265
  await runWorkspaceHook(config, "pre_remove", {
259
266
  repo,
260
267
  cwd: repo,
261
268
  workspacePath: repo,
262
- branch: currentBranch(repo),
263
- target: mergeTarget(mergeArgs)
269
+ branch: restorePlan.sourceBranch,
270
+ target
264
271
  });
265
- await runInherit("wt", ["merge", ...mergeArgs], { cwd: repo });
272
+ await runMergeWithRetry(repo, mergeArgs, retries, restorePlan, mergeEnv);
266
273
  if (closeTarget) {
267
274
  closeCmuxWorkspace(closeTarget);
268
275
  }
269
276
  }
270
277
 
278
+ async function runMergeWithRetry(repo, mergeArgs, retries, restorePlan, mergeEnv) {
279
+ let lastError = null;
280
+ let lastRestore = null;
281
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
282
+ console.log(`[aiw done] merge attempt ${attempt}/${retries}`);
283
+ try {
284
+ await runInherit("wt", ["merge", ...mergeArgs], { cwd: repo, env: mergeEnv });
285
+ return;
286
+ } catch (error) {
287
+ lastError = error;
288
+ lastRestore = restoreDoneAttempt(restorePlan);
289
+ if (!lastRestore.ok) {
290
+ throw withExit(
291
+ [
292
+ `aiw workspace done failed and rollback was incomplete after attempt ${attempt}/${retries}`,
293
+ lastRestore.summary
294
+ ].filter(Boolean).join("\n"),
295
+ error.exitCode || 1
296
+ );
297
+ }
298
+ if (attempt < retries) {
299
+ console.error(`[aiw done] merge failed; restored workspace state and retrying (${attempt + 1}/${retries})`);
300
+ }
301
+ }
302
+ }
303
+
304
+ throw withExit(
305
+ [
306
+ `aiw workspace done failed after ${retries} attempt(s)`,
307
+ lastRestore?.summary || "",
308
+ lastError?.message ? `last error: ${lastError.message}` : ""
309
+ ].filter(Boolean).join("\n"),
310
+ lastError?.exitCode || 1
311
+ );
312
+ }
313
+
314
+ function assertTargetWorktreeClean(repo, targetBranch) {
315
+ const targetPath = worktreePathForBranch(repo, targetBranch);
316
+ if (!targetPath || normalizePath(targetPath) === normalizePath(repo) || !isDirty(targetPath)) {
317
+ return;
318
+ }
319
+ const error = new Error(`target worktree has uncommitted changes: ${targetPath}; clean or stash it before aiw workspace done`);
320
+ error.exitCode = 5;
321
+ throw error;
322
+ }
323
+
324
+ function doneRetryCount(config, flags) {
325
+ const value = flags.retries === undefined
326
+ ? Number(config.commit?.retries || 3)
327
+ : flags.retries;
328
+ if (!Number.isFinite(value) || value < 1) {
329
+ const error = new Error("--retries must be a positive number");
330
+ error.exitCode = 2;
331
+ throw error;
332
+ }
333
+ return Math.floor(value);
334
+ }
335
+
336
+ function createDoneRestorePlan(repo, sourceBranch, targetBranch) {
337
+ const worktrees = readGitWorktreeList(repo);
338
+ const sourcePath = normalizePath(repo);
339
+ const targetWorktree = worktreePathForBranchFromEntries(worktrees, targetBranch);
340
+ const targetRef = localBranchRef(repo, targetBranch);
341
+ const backupRef = sourceBranch ? `refs/wt-backup/${sourceBranch}` : "";
342
+ const backupHead = backupRef ? gitHead(repo, backupRef) : "";
343
+ return {
344
+ sourcePath,
345
+ sourceBranch,
346
+ sourceHead: gitHead(repo, "HEAD"),
347
+ targetBranch,
348
+ targetRef,
349
+ targetHead: targetRef ? gitHead(repo, targetRef) : "",
350
+ targetWorktree,
351
+ targetWasClean: targetWorktree ? !isDirty(targetWorktree) : true,
352
+ backupRef,
353
+ backupHead,
354
+ backupExisted: Boolean(backupHead),
355
+ managementPath: managementWorktreeFor(worktrees, sourcePath)
356
+ };
357
+ }
358
+
359
+ function restoreDoneAttempt(plan) {
360
+ const actions = [];
361
+ const failures = [];
362
+ restoreSourceWorktree(plan, actions, failures);
363
+ restoreTargetBranch(plan, actions, failures);
364
+ restoreBackupRef(plan, actions, failures);
365
+
366
+ const sourceDirty = gitRootIfExists(plan.sourcePath) ? gitStatusShort(plan.sourcePath) : "";
367
+ if (sourceDirty) {
368
+ failures.push(`source worktree is still dirty:\n${sourceDirty}`);
369
+ }
370
+
371
+ return {
372
+ ok: failures.length === 0,
373
+ summary: [
374
+ actions.length > 0 ? `rollback actions: ${actions.join("; ")}` : "rollback actions: none needed",
375
+ ...failures.map((failure) => `rollback failure: ${failure}`)
376
+ ].join("\n")
377
+ };
378
+ }
379
+
380
+ function restoreSourceWorktree(plan, actions, failures) {
381
+ if (!plan.sourceHead) {
382
+ failures.push("missing source HEAD snapshot");
383
+ return;
384
+ }
385
+
386
+ if (gitRootIfExists(plan.sourcePath)) {
387
+ restoreExistingWorktree(plan.sourcePath, plan.sourceHead, "source worktree", actions, failures);
388
+ return;
389
+ }
390
+
391
+ if (!plan.sourceBranch) {
392
+ failures.push(`source worktree was removed and branch is unknown: ${plan.sourcePath}`);
393
+ return;
394
+ }
395
+
396
+ const cwd = restoreGitCwd(plan);
397
+ if (!cwd) {
398
+ failures.push(`source worktree was removed and no management worktree is available: ${plan.sourcePath}`);
399
+ return;
400
+ }
401
+
402
+ const added = runRestoreGit(cwd, ["worktree", "add", "-B", plan.sourceBranch, plan.sourcePath, plan.sourceHead], actions, failures, `recreate source worktree ${plan.sourcePath}`);
403
+ if (added) {
404
+ restoreExistingWorktree(plan.sourcePath, plan.sourceHead, "source worktree", actions, failures);
405
+ }
406
+ }
407
+
408
+ function restoreTargetBranch(plan, actions, failures) {
409
+ if (!plan.targetRef || !plan.targetHead) {
410
+ return;
411
+ }
412
+
413
+ if (plan.targetWorktree && normalizePath(plan.targetWorktree) !== normalizePath(plan.sourcePath)) {
414
+ if (!plan.targetWasClean) {
415
+ actions.push(`left pre-existing dirty target worktree untouched: ${plan.targetWorktree}`);
416
+ return;
417
+ }
418
+ if (gitRootIfExists(plan.targetWorktree)) {
419
+ restoreExistingWorktree(plan.targetWorktree, plan.targetHead, `target worktree ${plan.targetBranch}`, actions, failures);
420
+ return;
421
+ }
422
+ }
423
+
424
+ restoreRef(plan, plan.targetRef, plan.targetHead, `target branch ${plan.targetBranch}`, actions, failures);
425
+ }
426
+
427
+ function restoreBackupRef(plan, actions, failures) {
428
+ if (!plan.backupRef) {
429
+ return;
430
+ }
431
+ if (plan.backupExisted) {
432
+ restoreRef(plan, plan.backupRef, plan.backupHead, "worktrunk backup ref", actions, failures);
433
+ return;
434
+ }
435
+ if (gitHead(restoreGitCwd(plan), plan.backupRef)) {
436
+ deleteRef(plan, plan.backupRef, "worktrunk backup ref", actions, failures);
437
+ }
438
+ }
439
+
440
+ function restoreExistingWorktree(worktreePath, head, label, actions, failures) {
441
+ abortGitOperations(worktreePath);
442
+ runRestoreGit(worktreePath, ["reset", "--hard", head], actions, failures, `reset ${label}`);
443
+ runRestoreGit(worktreePath, ["clean", "-fd"], actions, failures, `clean ${label}`);
444
+ }
445
+
446
+ function restoreRef(plan, ref, head, label, actions, failures) {
447
+ const cwd = restoreGitCwd(plan);
448
+ if (!cwd) {
449
+ failures.push(`cannot restore ${label}; no management worktree is available`);
450
+ return;
451
+ }
452
+ runRestoreGit(cwd, ["update-ref", ref, head], actions, failures, `restore ${label}`);
453
+ }
454
+
455
+ function deleteRef(plan, ref, label, actions, failures) {
456
+ const cwd = restoreGitCwd(plan);
457
+ if (!cwd) {
458
+ failures.push(`cannot delete ${label}; no management worktree is available`);
459
+ return;
460
+ }
461
+ runRestoreGit(cwd, ["update-ref", "-d", ref], actions, failures, `delete ${label}`);
462
+ }
463
+
464
+ function restoreGitCwd(plan) {
465
+ return gitRootIfExists(plan.sourcePath) ||
466
+ gitRootIfExists(plan.managementPath) ||
467
+ gitRootIfExists(plan.targetWorktree);
468
+ }
469
+
470
+ function runRestoreGit(cwd, args, actions, failures, label) {
471
+ const result = tryCapture("git", args, { cwd });
472
+ if (result.ok) {
473
+ actions.push(label);
474
+ return true;
475
+ }
476
+ failures.push(`${label}: ${result.stderr || result.stdout || "git failed"}`);
477
+ return false;
478
+ }
479
+
480
+ function abortGitOperations(worktreePath) {
481
+ tryCapture("git", ["rebase", "--abort"], { cwd: worktreePath });
482
+ tryCapture("git", ["merge", "--abort"], { cwd: worktreePath });
483
+ tryCapture("git", ["cherry-pick", "--abort"], { cwd: worktreePath });
484
+ }
485
+
271
486
  async function workspaceRemove(config, argv) {
272
487
  assertGate("workspace", config);
273
488
  if (hasHelpFlag(argv)) {
@@ -643,6 +858,47 @@ function currentBranch(repo) {
643
858
  return result.ok ? result.stdout : "";
644
859
  }
645
860
 
861
+ function gitHead(repo, rev) {
862
+ if (!repo || !rev) {
863
+ return "";
864
+ }
865
+ const result = tryCapture("git", ["rev-parse", "--verify", rev], { cwd: repo });
866
+ return result.ok ? result.stdout : "";
867
+ }
868
+
869
+ function gitStatusShort(repo) {
870
+ const result = tryCapture("git", ["status", "--porcelain"], { cwd: repo });
871
+ return result.ok ? result.stdout : "";
872
+ }
873
+
874
+ function localBranchRef(repo, branch) {
875
+ if (!branch) {
876
+ return "";
877
+ }
878
+ const ref = `refs/heads/${branch}`;
879
+ const result = tryCapture("git", ["show-ref", "--verify", "--quiet", ref], { cwd: repo });
880
+ return result.ok ? ref : "";
881
+ }
882
+
883
+ function worktreePathForBranch(repo, branch) {
884
+ return worktreePathForBranchFromEntries(readGitWorktreeList(repo), branch);
885
+ }
886
+
887
+ function worktreePathForBranchFromEntries(worktrees, branch) {
888
+ if (!branch) {
889
+ return "";
890
+ }
891
+ return normalizePath(worktrees.find((worktree) => worktree.branch === branch)?.path || "");
892
+ }
893
+
894
+ function managementWorktreeFor(worktrees, sourcePath) {
895
+ const normalizedSource = normalizePath(sourcePath);
896
+ const candidate = worktrees.find((worktree) => {
897
+ return worktree.path && normalizePath(worktree.path) !== normalizedSource;
898
+ });
899
+ return normalizePath(candidate?.path || sourcePath);
900
+ }
901
+
646
902
  function mergedIntoTargets(repo, branch, targets) {
647
903
  return targets.filter((target) => {
648
904
  if (!target || target === branch) {
@@ -1098,6 +1354,70 @@ function hasDryRunFlag(argv) {
1098
1354
  return argv.includes("--dry-run");
1099
1355
  }
1100
1356
 
1357
+ function worktrunkMergeEnv(config, repo, flags) {
1358
+ if (!mergeNeedsCommitGeneration(flags.passthrough)) {
1359
+ return process.env;
1360
+ }
1361
+ if (process.env.WORKTRUNK_COMMIT__GENERATION__COMMAND) {
1362
+ return process.env;
1363
+ }
1364
+ if (worktrunkCommitGenerationConfigured(repo, flags.passthrough)) {
1365
+ return process.env;
1366
+ }
1367
+
1368
+ const agent = resolveAgent(config, flags.agent || config.commit.agent || config.defaults.agent);
1369
+ assertGate("commit", config, agent);
1370
+ const command = `${quoteShell(aiwBinPath())} commit-message --agent ${quoteShell(agent.name)}`;
1371
+ return {
1372
+ ...process.env,
1373
+ WORKTRUNK_COMMIT__GENERATION__COMMAND: command
1374
+ };
1375
+ }
1376
+
1377
+ function mergeNeedsCommitGeneration(argv) {
1378
+ return !argv.includes("--no-squash") && !argv.includes("--no-commit");
1379
+ }
1380
+
1381
+ function worktrunkCommitGenerationConfigured(repo, argv) {
1382
+ const result = tryCapture("wt", ["config", "show", "--format", "json", ...worktrunkConfigArgs(argv)], { cwd: repo });
1383
+ if (!result.ok || !result.stdout) {
1384
+ return false;
1385
+ }
1386
+ try {
1387
+ const config = JSON.parse(result.stdout);
1388
+ return Boolean(
1389
+ commitGenerationCommand(config.user?.config) ||
1390
+ commitGenerationCommand(config.project?.config) ||
1391
+ commitGenerationCommand(config.system?.config)
1392
+ );
1393
+ } catch {
1394
+ return false;
1395
+ }
1396
+ }
1397
+
1398
+ function worktrunkConfigArgs(argv) {
1399
+ const args = [];
1400
+ for (let index = 0; index < argv.length; index += 1) {
1401
+ const arg = argv[index];
1402
+ if (arg === "--config" && argv[index + 1]) {
1403
+ args.push("--config", argv[index + 1]);
1404
+ index += 1;
1405
+ } else if (arg.startsWith("--config=")) {
1406
+ args.push("--config", arg.slice("--config=".length));
1407
+ }
1408
+ }
1409
+ return args;
1410
+ }
1411
+
1412
+ function commitGenerationCommand(config) {
1413
+ if (!config || typeof config !== "object") {
1414
+ return "";
1415
+ }
1416
+ return stringValue(config.commit?.generation?.command) ||
1417
+ stringValue(config["commit.generation"]?.command) ||
1418
+ stringValue(config["commit.generation.command"]);
1419
+ }
1420
+
1101
1421
  async function withSelectedMergeTarget(repo, argv) {
1102
1422
  if (mergeTarget(argv)) {
1103
1423
  return argv;
@@ -1142,6 +1462,24 @@ function defaultMergeTarget(branches) {
1142
1462
  : branches.find((branch) => branch === "develop") || branches[0];
1143
1463
  }
1144
1464
 
1465
+ function defaultDoneTarget(repo) {
1466
+ const current = currentBranch(repo);
1467
+ const branches = listLocalBranches(repo).filter((branch) => branch !== current);
1468
+ const remoteDefault = defaultRemoteBranch(repo);
1469
+ if (remoteDefault && branches.includes(remoteDefault)) {
1470
+ return remoteDefault;
1471
+ }
1472
+ return ["main", "master", "dev", "develop"].find((branch) => branches.includes(branch)) || "";
1473
+ }
1474
+
1475
+ function defaultRemoteBranch(repo) {
1476
+ const result = tryCapture("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repo });
1477
+ if (!result.ok || !result.stdout) {
1478
+ return "";
1479
+ }
1480
+ return result.stdout.startsWith("origin/") ? result.stdout.slice("origin/".length) : result.stdout;
1481
+ }
1482
+
1145
1483
  function dirtyRemoveTargets(repo, argv) {
1146
1484
  const targets = removeTargets(argv);
1147
1485
  if (targets.length === 0) {
@@ -1345,13 +1683,24 @@ function parseWorkspaceFlags(argv) {
1345
1683
  function parseDoneFlags(argv) {
1346
1684
  const flags = {
1347
1685
  closeCmux: true,
1686
+ agent: "",
1687
+ retries: undefined,
1348
1688
  passthrough: []
1349
1689
  };
1350
- for (const arg of argv) {
1690
+ for (let index = 0; index < argv.length; index += 1) {
1691
+ const arg = argv[index];
1351
1692
  if (arg === "--no-close-cmux") {
1352
1693
  flags.closeCmux = false;
1353
1694
  } else if (arg === "--close-cmux") {
1354
1695
  flags.closeCmux = true;
1696
+ } else if (arg === "--agent") {
1697
+ flags.agent = argv[++index] || "";
1698
+ } else if (arg.startsWith("--agent=")) {
1699
+ flags.agent = arg.slice("--agent=".length);
1700
+ } else if (arg === "--retries") {
1701
+ flags.retries = Number(argv[++index]);
1702
+ } else if (arg.startsWith("--retries=")) {
1703
+ flags.retries = Number(arg.slice("--retries=".length));
1355
1704
  } else {
1356
1705
  flags.passthrough.push(arg);
1357
1706
  }
@@ -1403,7 +1752,8 @@ Commands:
1403
1752
  status [--json] Alias for list
1404
1753
  open [target] [--agent name] [--remotes] Open picker or target with the AIW cmux layout
1405
1754
  switch [target] Alias for open
1406
- done [target] [--no-close-cmux] Merge the current feature worktree, cleanup, then close cmux workspace
1755
+ done [target] [--agent name] [--retries n] [--no-close-cmux]
1756
+ Merge the current feature worktree, cleanup, then close cmux workspace
1407
1757
  remove [wt-remove-args...] Remove worktrees after dirty check
1408
1758
  gc|clean [--dry-run] [--apply|--yes] [--json] [--stale-seconds n]
1409
1759
  Preview or remove safe worktrees; stale warnings are not removed
@@ -1411,6 +1761,8 @@ Commands:
1411
1761
 
1412
1762
  Short aliases:
1413
1763
  aiw ws list aiw workspace list
1764
+ aiw ws ls aiw workspace list
1765
+ aiw als aiw workspace list
1414
1766
  aiw list aiw workspace list
1415
1767
  aiw ws open [target] aiw workspace open [target]
1416
1768
  aiw open [target] aiw workspace open [target]
@@ -1420,3 +1772,13 @@ Short aliases:
1420
1772
  aiw gc aiw workspace gc
1421
1773
  aiw clean aiw workspace gc`);
1422
1774
  }
1775
+
1776
+ function normalizeCommand(command) {
1777
+ return String(command || "").toLowerCase();
1778
+ }
1779
+
1780
+ function withExit(message, exitCode) {
1781
+ const error = new Error(message);
1782
+ error.exitCode = exitCode;
1783
+ return error;
1784
+ }