vde-worktree 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -750,22 +750,9 @@ const resolveExistingConfigFiles = async ({ cwd, repoRoot }) => {
750
750
  }
751
751
  return [...deduped.values()].sort((a, b) => a.order - b.order).map((entry) => entry.path);
752
752
  };
753
- const isPathInsideOrEqual$1 = ({ parentPath, childPath }) => {
754
- const rel = relative(parentPath, childPath);
755
- if (rel.length === 0) return true;
756
- return rel !== ".." && rel.startsWith(`..${sep}`) !== true;
757
- };
758
753
  const validateWorktreeRoot = async ({ repoRoot, config }) => {
759
754
  const rawWorktreeRoot = config.paths.worktreeRoot;
760
755
  const resolvedWorktreeRoot = isAbsolute(rawWorktreeRoot) ? resolve(rawWorktreeRoot) : resolve(repoRoot, rawWorktreeRoot);
761
- if (isPathInsideOrEqual$1({
762
- parentPath: resolve(repoRoot, ".git"),
763
- childPath: resolvedWorktreeRoot
764
- })) throwInvalidConfig({
765
- file: "<resolved>",
766
- keyPath: "paths.worktreeRoot",
767
- reason: "must not point inside .git"
768
- });
769
756
  try {
770
757
  if ((await lstat(resolvedWorktreeRoot)).isDirectory() !== true) throwInvalidConfig({
771
758
  file: "<resolved>",
@@ -989,11 +976,10 @@ const appendHookLog = async ({ repoRoot, action, branch, content }) => {
989
976
  branch
990
977
  })), content, "utf8");
991
978
  };
992
- const runHook = async ({ phase, hookName, args, context, requireExists = false }) => {
993
- if (context.enabled !== true) return;
994
- const path = hookPath(context.repoRoot, hookName);
979
+ const ensureHookExists = async ({ path, hookName, requireExists }) => {
995
980
  try {
996
981
  await access(path, constants.F_OK);
982
+ return true;
997
983
  } catch {
998
984
  if (requireExists) throw createCliError("HOOK_NOT_FOUND", {
999
985
  message: `Hook not found: ${hookName}`,
@@ -1002,8 +988,10 @@ const runHook = async ({ phase, hookName, args, context, requireExists = false }
1002
988
  path
1003
989
  }
1004
990
  });
1005
- return;
991
+ return false;
1006
992
  }
993
+ };
994
+ const ensureHookExecutable = async ({ path, hookName }) => {
1007
995
  try {
1008
996
  await access(path, constants.X_OK);
1009
997
  } catch {
@@ -1015,43 +1003,102 @@ const runHook = async ({ phase, hookName, args, context, requireExists = false }
1015
1003
  }
1016
1004
  });
1017
1005
  }
1006
+ };
1007
+ const executeHookProcess = async ({ path, args, context }) => {
1018
1008
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1019
- try {
1020
- const result = await execa(path, [...args], {
1021
- cwd: context.worktreePath ?? context.repoRoot,
1022
- env: {
1023
- ...process.env,
1024
- WT_REPO_ROOT: context.repoRoot,
1025
- WT_ACTION: context.action,
1026
- WT_BRANCH: context.branch ?? "",
1027
- WT_WORKTREE_PATH: context.worktreePath ?? "",
1028
- WT_IS_TTY: process.stdout.isTTY === true ? "1" : "0",
1029
- WT_TOOL: "vde-worktree",
1030
- ...context.extraEnv ?? {}
1031
- },
1032
- timeout: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
1033
- reject: false
1034
- });
1035
- const endedAt = (/* @__PURE__ */ new Date()).toISOString();
1036
- const logContent = [
1009
+ const result = await execa(path, [...args], {
1010
+ cwd: context.worktreePath ?? context.repoRoot,
1011
+ env: {
1012
+ ...process.env,
1013
+ WT_REPO_ROOT: context.repoRoot,
1014
+ WT_ACTION: context.action,
1015
+ WT_BRANCH: context.branch ?? "",
1016
+ WT_WORKTREE_PATH: context.worktreePath ?? "",
1017
+ WT_IS_TTY: process.stdout.isTTY === true ? "1" : "0",
1018
+ WT_TOOL: "vde-worktree",
1019
+ ...context.extraEnv ?? {}
1020
+ },
1021
+ timeout: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
1022
+ reject: false
1023
+ });
1024
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
1025
+ return {
1026
+ exitCode: result.exitCode ?? 0,
1027
+ stderr: result.stderr ?? "",
1028
+ timedOut: result.timedOut === true,
1029
+ startedAt,
1030
+ endedAt
1031
+ };
1032
+ };
1033
+ const writeHookLog = async ({ repoRoot, action, branch, hookName, phase, result }) => {
1034
+ await appendHookLog({
1035
+ repoRoot,
1036
+ action,
1037
+ branch,
1038
+ content: [
1037
1039
  `hook=${hookName}`,
1038
1040
  `phase=${phase}`,
1039
- `start=${startedAt}`,
1040
- `end=${endedAt}`,
1041
- `exitCode=${String(result.exitCode ?? 0)}`,
1042
- `stderr=${result.stderr ?? ""}`,
1041
+ `start=${result.startedAt}`,
1042
+ `end=${result.endedAt}`,
1043
+ `exitCode=${String(result.exitCode)}`,
1044
+ `timedOut=${result.timedOut ? "1" : "0"}`,
1045
+ `stderr=${result.stderr}`,
1043
1046
  ""
1044
- ].join("\n");
1045
- await appendHookLog({
1047
+ ].join("\n")
1048
+ });
1049
+ };
1050
+ const shouldIgnorePostHookFailure = ({ phase, context }) => {
1051
+ return phase === "post" && context.strictPostHooks !== true;
1052
+ };
1053
+ const handleIgnoredPostHookFailure = ({ context, hookName, message }) => {
1054
+ context.stderr(message ?? `Hook failed: ${hookName}`);
1055
+ };
1056
+ const runHook = async ({ phase, hookName, args, context, requireExists = false }) => {
1057
+ if (context.enabled !== true) return;
1058
+ const path = hookPath(context.repoRoot, hookName);
1059
+ if (await ensureHookExists({
1060
+ path,
1061
+ hookName,
1062
+ requireExists
1063
+ }) !== true) return;
1064
+ await ensureHookExecutable({
1065
+ path,
1066
+ hookName
1067
+ });
1068
+ try {
1069
+ const result = await executeHookProcess({
1070
+ path,
1071
+ args,
1072
+ context
1073
+ });
1074
+ await writeHookLog({
1046
1075
  repoRoot: context.repoRoot,
1047
1076
  action: context.action,
1048
1077
  branch: context.branch,
1049
- content: logContent
1078
+ hookName,
1079
+ phase,
1080
+ result
1081
+ });
1082
+ if (result.timedOut) throw createCliError("HOOK_TIMEOUT", {
1083
+ message: `Hook timed out: ${hookName}`,
1084
+ details: {
1085
+ hook: hookName,
1086
+ timeoutMs: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
1087
+ exitCode: result.exitCode,
1088
+ stderr: result.stderr
1089
+ }
1050
1090
  });
1051
- if ((result.exitCode ?? 0) === 0) return;
1052
- const message = `Hook failed: ${hookName} (exitCode=${String(result.exitCode ?? 1)})`;
1053
- if (phase === "post" && context.strictPostHooks !== true) {
1054
- context.stderr(message);
1091
+ if (result.exitCode === 0) return;
1092
+ const message = `Hook failed: ${hookName} (exitCode=${String(result.exitCode)})`;
1093
+ if (shouldIgnorePostHookFailure({
1094
+ phase,
1095
+ context
1096
+ })) {
1097
+ handleIgnoredPostHookFailure({
1098
+ context,
1099
+ hookName,
1100
+ message
1101
+ });
1055
1102
  return;
1056
1103
  }
1057
1104
  throw createCliError("HOOK_FAILED", {
@@ -1074,8 +1121,14 @@ const runHook = async ({ phase, hookName, args, context, requireExists = false }
1074
1121
  },
1075
1122
  cause: error
1076
1123
  });
1077
- if (phase === "post" && context.strictPostHooks !== true) {
1078
- context.stderr(`Hook failed: ${hookName}`);
1124
+ if (shouldIgnorePostHookFailure({
1125
+ phase,
1126
+ context
1127
+ })) {
1128
+ handleIgnoredPostHookFailure({
1129
+ context,
1130
+ hookName
1131
+ });
1079
1132
  return;
1080
1133
  }
1081
1134
  throw createCliError("HOOK_FAILED", {
@@ -1204,6 +1257,79 @@ const initializeRepository = async ({ repoRoot, managedWorktreeRoot }) => {
1204
1257
  return { alreadyInitialized: wasInitialized };
1205
1258
  };
1206
1259
 
1260
+ //#endregion
1261
+ //#region src/core/json-storage.ts
1262
+ const parseJsonRecord = ({ content, schemaVersion, validate }) => {
1263
+ try {
1264
+ const parsed = JSON.parse(content);
1265
+ if (parsed.schemaVersion !== schemaVersion || validate(parsed) !== true) return {
1266
+ valid: false,
1267
+ record: null
1268
+ };
1269
+ return {
1270
+ valid: true,
1271
+ record: parsed
1272
+ };
1273
+ } catch {
1274
+ return {
1275
+ valid: false,
1276
+ record: null
1277
+ };
1278
+ }
1279
+ };
1280
+ const readJsonRecord = async ({ path, schemaVersion, validate }) => {
1281
+ try {
1282
+ return {
1283
+ path,
1284
+ exists: true,
1285
+ ...parseJsonRecord({
1286
+ content: await readFile(path, "utf8"),
1287
+ schemaVersion,
1288
+ validate
1289
+ })
1290
+ };
1291
+ } catch (error) {
1292
+ if (error.code === "ENOENT") return {
1293
+ path,
1294
+ exists: false,
1295
+ valid: true,
1296
+ record: null
1297
+ };
1298
+ return {
1299
+ path,
1300
+ exists: true,
1301
+ valid: false,
1302
+ record: null
1303
+ };
1304
+ }
1305
+ };
1306
+ const writeJsonAtomically = async ({ filePath, payload, ensureDir = false }) => {
1307
+ if (ensureDir) await mkdir(dirname(filePath), { recursive: true });
1308
+ const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
1309
+ try {
1310
+ await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
1311
+ await rename(tmpPath, filePath);
1312
+ } catch (error) {
1313
+ try {
1314
+ await rm(tmpPath, { force: true });
1315
+ } catch {}
1316
+ throw error;
1317
+ }
1318
+ };
1319
+ const writeJsonExclusively = async ({ path, payload }) => {
1320
+ let handle;
1321
+ try {
1322
+ handle = await open(path, "wx");
1323
+ await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
1324
+ return true;
1325
+ } catch (error) {
1326
+ if (error.code === "EEXIST") return false;
1327
+ throw error;
1328
+ } finally {
1329
+ if (handle !== void 0) await handle.close();
1330
+ }
1331
+ };
1332
+
1207
1333
  //#endregion
1208
1334
  //#region src/core/repo-lock.ts
1209
1335
  const sleep = async (ms) => {
@@ -1221,16 +1347,8 @@ const isProcessAlive = (pid) => {
1221
1347
  return true;
1222
1348
  }
1223
1349
  };
1224
- const safeParseLockFile = (content) => {
1225
- try {
1226
- const parsed = JSON.parse(content);
1227
- if (parsed.schemaVersion !== 1) return null;
1228
- if (typeof parsed.command !== "string" || typeof parsed.owner !== "string") return null;
1229
- if (typeof parsed.pid !== "number" || typeof parsed.host !== "string" || typeof parsed.startedAt !== "string") return null;
1230
- return parsed;
1231
- } catch {
1232
- return null;
1233
- }
1350
+ const isRepoLockFileSchema = (parsed) => {
1351
+ return typeof parsed.owner === "string" && typeof parsed.command === "string" && typeof parsed.pid === "number" && typeof parsed.host === "string" && typeof parsed.startedAt === "string";
1234
1352
  };
1235
1353
  const lockFilePath$1 = async (repoRoot) => {
1236
1354
  const stateDir = getStateDirectoryPath(repoRoot);
@@ -1259,23 +1377,15 @@ const canRecoverStaleLock = ({ lock, staleLockTTLSeconds }) => {
1259
1377
  if (lock.host === hostname() && isProcessAlive(lock.pid)) return false;
1260
1378
  return true;
1261
1379
  };
1262
- const writeNewLockFile = async (path, payload) => {
1263
- try {
1264
- const handle = await open(path, "wx");
1265
- await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
1266
- await handle.close();
1267
- return true;
1268
- } catch (error) {
1269
- if (error.code === "EEXIST") return false;
1270
- throw error;
1271
- }
1272
- };
1273
1380
  const acquireRepoLock = async ({ repoRoot, command, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, staleLockTTLSeconds = DEFAULT_STALE_LOCK_TTL_SECONDS }) => {
1274
1381
  const path = await lockFilePath$1(repoRoot);
1275
1382
  const startAt = Date.now();
1276
1383
  const payload = buildLockPayload(command);
1277
1384
  while (Date.now() - startAt <= timeoutMs) {
1278
- if (await writeNewLockFile(path, payload)) return { release: async () => {
1385
+ if (await writeJsonExclusively({
1386
+ path,
1387
+ payload
1388
+ })) return { release: async () => {
1279
1389
  try {
1280
1390
  await rm(path, { force: true });
1281
1391
  } catch {
@@ -1290,7 +1400,11 @@ const acquireRepoLock = async ({ repoRoot, command, timeoutMs = DEFAULT_LOCK_TIM
1290
1400
  continue;
1291
1401
  }
1292
1402
  if (canRecoverStaleLock({
1293
- lock: safeParseLockFile(lockContent),
1403
+ lock: parseJsonRecord({
1404
+ content: lockContent,
1405
+ schemaVersion: 1,
1406
+ validate: isRepoLockFileSchema
1407
+ }).record,
1294
1408
  staleLockTTLSeconds
1295
1409
  })) {
1296
1410
  try {
@@ -1339,57 +1453,16 @@ const hasStateDirectory = async (repoRoot) => {
1339
1453
  return false;
1340
1454
  }
1341
1455
  };
1342
- const parseLifecycle = (content) => {
1343
- try {
1344
- const parsed = JSON.parse(content);
1345
- const isLastDivergedHeadValid = parsed.lastDivergedHead === null || typeof parsed.lastDivergedHead === "string" && parsed.lastDivergedHead.length > 0;
1346
- if (parsed.schemaVersion !== 2 || typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.baseBranch !== "string" || typeof parsed.everDiverged !== "boolean" || isLastDivergedHeadValid !== true || typeof parsed.createdAt !== "string" || typeof parsed.updatedAt !== "string") return {
1347
- valid: false,
1348
- record: null
1349
- };
1350
- return {
1351
- valid: true,
1352
- record: parsed
1353
- };
1354
- } catch {
1355
- return {
1356
- valid: false,
1357
- record: null
1358
- };
1359
- }
1360
- };
1361
- const writeJsonAtomically$1 = async ({ filePath, payload }) => {
1362
- await mkdir(dirname(filePath), { recursive: true });
1363
- const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
1364
- await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
1365
- await rename(tmpPath, filePath);
1456
+ const isWorktreeMergeLifecycleRecord = (parsed) => {
1457
+ const isLastDivergedHeadValid = parsed.lastDivergedHead === null || typeof parsed.lastDivergedHead === "string" && parsed.lastDivergedHead.length > 0;
1458
+ return typeof parsed.branch === "string" && typeof parsed.worktreeId === "string" && typeof parsed.baseBranch === "string" && typeof parsed.everDiverged === "boolean" && isLastDivergedHeadValid && typeof parsed.createdAt === "string" && typeof parsed.updatedAt === "string";
1366
1459
  };
1367
1460
  const readWorktreeMergeLifecycle = async ({ repoRoot, branch }) => {
1368
- const path = lifecycleFilePath(repoRoot, branch);
1369
- try {
1370
- await access(path, constants.F_OK);
1371
- } catch {
1372
- return {
1373
- path,
1374
- exists: false,
1375
- valid: true,
1376
- record: null
1377
- };
1378
- }
1379
- try {
1380
- return {
1381
- path,
1382
- exists: true,
1383
- ...parseLifecycle(await readFile(path, "utf8"))
1384
- };
1385
- } catch {
1386
- return {
1387
- path,
1388
- exists: true,
1389
- valid: false,
1390
- record: null
1391
- };
1392
- }
1461
+ return readJsonRecord({
1462
+ path: lifecycleFilePath(repoRoot, branch),
1463
+ schemaVersion: 2,
1464
+ validate: isWorktreeMergeLifecycleRecord
1465
+ });
1393
1466
  };
1394
1467
  const upsertWorktreeMergeLifecycle = async ({ repoRoot, branch, baseBranch, observedDivergedHead }) => {
1395
1468
  const normalizedObservedHead = typeof observedDivergedHead === "string" && observedDivergedHead.length > 0 ? observedDivergedHead : null;
@@ -1424,9 +1497,10 @@ const upsertWorktreeMergeLifecycle = async ({ repoRoot, branch, baseBranch, obse
1424
1497
  createdAt: current.record?.createdAt ?? now,
1425
1498
  updatedAt: now
1426
1499
  };
1427
- await writeJsonAtomically$1({
1500
+ await writeJsonAtomically({
1428
1501
  filePath: current.path,
1429
- payload: next
1502
+ payload: next,
1503
+ ensureDir: true
1430
1504
  });
1431
1505
  return next;
1432
1506
  };
@@ -1463,9 +1537,10 @@ const moveWorktreeMergeLifecycle = async ({ repoRoot, fromBranch, toBranch, base
1463
1537
  createdAt: source.record?.createdAt ?? now,
1464
1538
  updatedAt: now
1465
1539
  };
1466
- await writeJsonAtomically$1({
1540
+ await writeJsonAtomically({
1467
1541
  filePath: targetPath,
1468
- payload: next
1542
+ payload: next,
1543
+ ensureDir: true
1469
1544
  });
1470
1545
  if (source.path !== targetPath) await rm(source.path, { force: true });
1471
1546
  return next;
@@ -1476,58 +1551,18 @@ const deleteWorktreeMergeLifecycle = async ({ repoRoot, branch }) => {
1476
1551
 
1477
1552
  //#endregion
1478
1553
  //#region src/core/worktree-lock.ts
1479
- const parseLock = (content) => {
1480
- try {
1481
- const parsed = JSON.parse(content);
1482
- if (parsed.schemaVersion !== 1 || typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.reason !== "string" || typeof parsed.owner !== "string" || typeof parsed.host !== "string" || typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string" || typeof parsed.updatedAt !== "string") return {
1483
- valid: false,
1484
- record: null
1485
- };
1486
- return {
1487
- valid: true,
1488
- record: parsed
1489
- };
1490
- } catch {
1491
- return {
1492
- valid: false,
1493
- record: null
1494
- };
1495
- }
1496
- };
1497
- const writeJsonAtomically = async ({ filePath, payload }) => {
1498
- const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
1499
- await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
1500
- await rename(tmpPath, filePath);
1554
+ const isWorktreeLockRecord = (parsed) => {
1555
+ return parsed.schemaVersion === 1 && typeof parsed.branch === "string" && typeof parsed.worktreeId === "string" && typeof parsed.reason === "string" && typeof parsed.owner === "string" && typeof parsed.host === "string" && typeof parsed.pid === "number" && typeof parsed.createdAt === "string" && typeof parsed.updatedAt === "string";
1501
1556
  };
1502
1557
  const lockFilePath = (repoRoot, branch) => {
1503
1558
  return join(getLocksDirectoryPath(repoRoot), `${branchToWorktreeId(branch)}.json`);
1504
1559
  };
1505
1560
  const readWorktreeLock = async ({ repoRoot, branch }) => {
1506
- const path = lockFilePath(repoRoot, branch);
1507
- try {
1508
- await access(path, constants.F_OK);
1509
- } catch {
1510
- return {
1511
- path,
1512
- exists: false,
1513
- valid: true,
1514
- record: null
1515
- };
1516
- }
1517
- try {
1518
- return {
1519
- path,
1520
- exists: true,
1521
- ...parseLock(await readFile(path, "utf8"))
1522
- };
1523
- } catch {
1524
- return {
1525
- path,
1526
- exists: true,
1527
- valid: false,
1528
- record: null
1529
- };
1530
- }
1561
+ return readJsonRecord({
1562
+ path: lockFilePath(repoRoot, branch),
1563
+ schemaVersion: 1,
1564
+ validate: isWorktreeLockRecord
1565
+ });
1531
1566
  };
1532
1567
  const upsertWorktreeLock = async ({ repoRoot, branch, reason, owner }) => {
1533
1568
  const { path, record } = await readWorktreeLock({
@@ -1558,16 +1593,40 @@ const deleteWorktreeLock = async ({ repoRoot, branch }) => {
1558
1593
 
1559
1594
  //#endregion
1560
1595
  //#region src/integrations/gh.ts
1596
+ var GhUnavailableError = class extends Error {
1597
+ code = "GH_UNAVAILABLE";
1598
+ constructor(message = "gh command is unavailable") {
1599
+ super(message);
1600
+ this.name = "GhUnavailableError";
1601
+ }
1602
+ };
1603
+ var GhCommandError = class extends Error {
1604
+ code = "GH_COMMAND_FAILED";
1605
+ details;
1606
+ constructor({ exitCode, stderr }) {
1607
+ super(`gh command failed with exitCode=${String(exitCode)}`);
1608
+ this.name = "GhCommandError";
1609
+ this.details = {
1610
+ exitCode,
1611
+ stderr
1612
+ };
1613
+ }
1614
+ };
1561
1615
  const defaultRunGh = async ({ cwd, args }) => {
1562
- const result = await execa("gh", [...args], {
1563
- cwd,
1564
- reject: false
1565
- });
1566
- return {
1567
- exitCode: result.exitCode ?? 0,
1568
- stdout: result.stdout,
1569
- stderr: result.stderr
1570
- };
1616
+ try {
1617
+ const result = await execa("gh", [...args], {
1618
+ cwd,
1619
+ reject: false
1620
+ });
1621
+ return {
1622
+ exitCode: result.exitCode ?? 0,
1623
+ stdout: result.stdout,
1624
+ stderr: result.stderr
1625
+ };
1626
+ } catch (error) {
1627
+ if (error.code === "ENOENT") throw new GhUnavailableError("gh command not found");
1628
+ throw error;
1629
+ }
1571
1630
  };
1572
1631
  const toTargetBranches = ({ branches, baseBranch }) => {
1573
1632
  const uniqueBranches = /* @__PURE__ */ new Set();
@@ -1669,7 +1728,10 @@ const resolvePrStateByBranchBatch = async ({ repoRoot, baseBranch, branches, ena
1669
1728
  "headRefName,state,mergedAt,updatedAt,url"
1670
1729
  ]
1671
1730
  });
1672
- if (result.exitCode !== 0) return buildUnknownPrStateMap(targetBranches);
1731
+ if (result.exitCode !== 0) throw new GhCommandError({
1732
+ exitCode: result.exitCode,
1733
+ stderr: result.stderr
1734
+ });
1673
1735
  const prStatusByBranch = parsePrStateByBranch({
1674
1736
  raw: result.stdout,
1675
1737
  targetBranches
@@ -1677,6 +1739,7 @@ const resolvePrStateByBranchBatch = async ({ repoRoot, baseBranch, branches, ena
1677
1739
  if (prStatusByBranch === null) return buildUnknownPrStateMap(targetBranches);
1678
1740
  return prStatusByBranch;
1679
1741
  } catch (error) {
1742
+ if (error instanceof GhUnavailableError || error instanceof GhCommandError) return buildUnknownPrStateMap(targetBranches);
1680
1743
  if (error.code === "ENOENT") return buildUnknownPrStateMap(targetBranches);
1681
1744
  return buildUnknownPrStateMap(targetBranches);
1682
1745
  }
@@ -1743,6 +1806,9 @@ const listGitWorktrees = async (repoRoot) => {
1743
1806
 
1744
1807
  //#endregion
1745
1808
  //#region src/core/worktree-state.ts
1809
+ const isLockPayload = (parsed) => {
1810
+ return typeof parsed.branch === "string" && typeof parsed.worktreeId === "string" && typeof parsed.reason === "string" && parsed.reason.length > 0 && (typeof parsed.owner === "undefined" || typeof parsed.owner === "string");
1811
+ };
1746
1812
  const resolveDirty = async (worktreePath) => {
1747
1813
  return (await runGitCommand({
1748
1814
  cwd: worktreePath,
@@ -1750,16 +1816,6 @@ const resolveDirty = async (worktreePath) => {
1750
1816
  reject: false
1751
1817
  })).stdout.trim().length > 0;
1752
1818
  };
1753
- const parseLockPayload = (content) => {
1754
- try {
1755
- const parsed = JSON.parse(content);
1756
- if (parsed.schemaVersion !== 1) return null;
1757
- if (typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.reason !== "string" || parsed.reason.length === 0) return null;
1758
- return parsed;
1759
- } catch {
1760
- return null;
1761
- }
1762
- };
1763
1819
  const resolveLockState = async ({ repoRoot, branch }) => {
1764
1820
  if (branch === null) return {
1765
1821
  value: false,
@@ -1767,38 +1823,62 @@ const resolveLockState = async ({ repoRoot, branch }) => {
1767
1823
  owner: null
1768
1824
  };
1769
1825
  const id = branchToWorktreeId(branch);
1770
- const lockPath = join(getLocksDirectoryPath(repoRoot), `${id}.json`);
1771
- try {
1772
- await access(lockPath, constants.F_OK);
1773
- } catch {
1774
- return {
1775
- value: false,
1776
- reason: null,
1777
- owner: null
1778
- };
1779
- }
1780
- try {
1781
- const lock = parseLockPayload(await readFile(lockPath, "utf8"));
1782
- if (lock === null) return {
1783
- value: true,
1784
- reason: "invalid lock metadata",
1785
- owner: null
1786
- };
1787
- return {
1788
- value: true,
1789
- reason: lock.reason,
1790
- owner: typeof lock.owner === "string" && lock.owner.length > 0 ? lock.owner : null
1791
- };
1792
- } catch {
1793
- return {
1794
- value: true,
1795
- reason: "invalid lock metadata",
1796
- owner: null
1797
- };
1798
- }
1826
+ const lock = await readJsonRecord({
1827
+ path: join(getLocksDirectoryPath(repoRoot), `${id}.json`),
1828
+ schemaVersion: 1,
1829
+ validate: isLockPayload
1830
+ });
1831
+ if (lock.exists !== true) return {
1832
+ value: false,
1833
+ reason: null,
1834
+ owner: null
1835
+ };
1836
+ if (lock.valid !== true || lock.record === null) return {
1837
+ value: true,
1838
+ reason: "invalid lock metadata",
1839
+ owner: null
1840
+ };
1841
+ return {
1842
+ value: true,
1843
+ reason: lock.record.reason,
1844
+ owner: typeof lock.record.owner === "string" && lock.record.owner.length > 0 ? lock.record.owner : null
1845
+ };
1799
1846
  };
1800
1847
  const WORK_REFLOG_MESSAGE_PATTERN = /^(commit(?: \([^)]*\))?|cherry-pick|revert|rebase \(pick\)|merge):/;
1801
- const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1848
+ const resolveAncestryFromExitCode = (exitCode) => {
1849
+ if (exitCode === 0) return true;
1850
+ if (exitCode === 1) return false;
1851
+ return null;
1852
+ };
1853
+ const resolveMergedByPr = ({ branch, baseBranch, prStateByBranch }) => {
1854
+ const prStatus = branch === baseBranch ? null : prStateByBranch.get(branch)?.status ?? null;
1855
+ if (prStatus === "merged") return true;
1856
+ if (prStatus === "none" || prStatus === "open" || prStatus === "closed_unmerged") return false;
1857
+ return null;
1858
+ };
1859
+ const hasLifecycleDivergedHead = (lifecycle) => {
1860
+ return lifecycle.everDiverged === true && lifecycle.lastDivergedHead !== null;
1861
+ };
1862
+ const parseWorkReflogHeads = (reflogOutput) => {
1863
+ const heads = [];
1864
+ let latestHead = null;
1865
+ for (const line of reflogOutput.split("\n")) {
1866
+ const trimmed = line.trim();
1867
+ if (trimmed.length === 0) continue;
1868
+ const separatorIndex = trimmed.indexOf(" ");
1869
+ if (separatorIndex <= 0) continue;
1870
+ const head = trimmed.slice(0, separatorIndex).trim();
1871
+ const message = trimmed.slice(separatorIndex + 1).trim();
1872
+ if (head.length === 0 || WORK_REFLOG_MESSAGE_PATTERN.test(message) !== true) continue;
1873
+ if (latestHead === null) latestHead = head;
1874
+ heads.push(head);
1875
+ }
1876
+ return {
1877
+ heads,
1878
+ latestHead
1879
+ };
1880
+ };
1881
+ const probeLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1802
1882
  const reflog = await runGitCommand({
1803
1883
  cwd: repoRoot,
1804
1884
  args: [
@@ -1813,17 +1893,13 @@ const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1813
1893
  merged: null,
1814
1894
  divergedHead: null
1815
1895
  };
1816
- let latestWorkHead = null;
1817
- for (const line of reflog.stdout.split("\n")) {
1818
- const trimmed = line.trim();
1819
- if (trimmed.length === 0) continue;
1820
- const separatorIndex = trimmed.indexOf(" ");
1821
- if (separatorIndex <= 0) continue;
1822
- const head = trimmed.slice(0, separatorIndex).trim();
1823
- const message = trimmed.slice(separatorIndex + 1).trim();
1824
- if (head.length === 0 || WORK_REFLOG_MESSAGE_PATTERN.test(message) !== true) continue;
1825
- if (latestWorkHead === null) latestWorkHead = head;
1826
- const result = await runGitCommand({
1896
+ const parsedHeads = parseWorkReflogHeads(reflog.stdout);
1897
+ if (parsedHeads.heads.length === 0) return {
1898
+ merged: null,
1899
+ divergedHead: null
1900
+ };
1901
+ for (const head of parsedHeads.heads) {
1902
+ const merged = resolveAncestryFromExitCode((await runGitCommand({
1827
1903
  cwd: repoRoot,
1828
1904
  args: [
1829
1905
  "merge-base",
@@ -1832,19 +1908,52 @@ const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
1832
1908
  baseBranch
1833
1909
  ],
1834
1910
  reject: false
1835
- });
1836
- if (result.exitCode === 0) return {
1911
+ })).exitCode);
1912
+ if (merged === true) return {
1837
1913
  merged: true,
1838
1914
  divergedHead: head
1839
1915
  };
1840
- if (result.exitCode !== 1) return {
1916
+ if (merged === null) return {
1841
1917
  merged: null,
1842
- divergedHead: latestWorkHead
1918
+ divergedHead: parsedHeads.latestHead
1843
1919
  };
1844
1920
  }
1845
1921
  return {
1846
1922
  merged: false,
1847
- divergedHead: latestWorkHead
1923
+ divergedHead: parsedHeads.latestHead
1924
+ };
1925
+ };
1926
+ const createMergeLifecycleRepository = ({ repoRoot }) => {
1927
+ return { upsert: async ({ branch, baseBranch, observedDivergedHead }) => {
1928
+ return upsertWorktreeMergeLifecycle({
1929
+ repoRoot,
1930
+ branch,
1931
+ baseBranch,
1932
+ observedDivergedHead
1933
+ });
1934
+ } };
1935
+ };
1936
+ const createMergeProbeRepository = ({ repoRoot }) => {
1937
+ return {
1938
+ probeAncestry: async ({ branch, baseBranch }) => {
1939
+ return resolveAncestryFromExitCode((await runGitCommand({
1940
+ cwd: repoRoot,
1941
+ args: [
1942
+ "merge-base",
1943
+ "--is-ancestor",
1944
+ branch,
1945
+ baseBranch
1946
+ ],
1947
+ reject: false
1948
+ })).exitCode);
1949
+ },
1950
+ probeLifecycleFromReflog: async ({ branch, baseBranch }) => {
1951
+ return probeLifecycleFromReflog({
1952
+ repoRoot,
1953
+ branch,
1954
+ baseBranch
1955
+ });
1956
+ }
1848
1957
  };
1849
1958
  };
1850
1959
  const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStateByBranch }) => {
@@ -1853,64 +1962,42 @@ const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStateB
1853
1962
  byPR: null,
1854
1963
  overall: null
1855
1964
  };
1856
- let byAncestry = null;
1857
- if (baseBranch !== null) {
1858
- const result = await runGitCommand({
1859
- cwd: repoRoot,
1860
- args: [
1861
- "merge-base",
1862
- "--is-ancestor",
1863
- branch,
1864
- baseBranch
1865
- ],
1866
- reject: false
1867
- });
1868
- if (result.exitCode === 0) byAncestry = true;
1869
- else if (result.exitCode === 1) byAncestry = false;
1870
- }
1871
- const prStatus = branch === baseBranch ? null : prStateByBranch.get(branch)?.status ?? null;
1872
- let byPR = null;
1873
- if (prStatus === "merged") byPR = true;
1874
- else if (prStatus === "none" || prStatus === "open" || prStatus === "closed_unmerged") byPR = false;
1965
+ const mergeProbeRepository = createMergeProbeRepository({ repoRoot });
1966
+ const mergeLifecycleRepository = createMergeLifecycleRepository({ repoRoot });
1967
+ const byAncestry = baseBranch === null ? null : await mergeProbeRepository.probeAncestry({
1968
+ branch,
1969
+ baseBranch
1970
+ });
1971
+ const byPR = resolveMergedByPr({
1972
+ branch,
1973
+ baseBranch,
1974
+ prStateByBranch
1975
+ });
1875
1976
  let byLifecycle = null;
1876
1977
  if (baseBranch !== null) {
1877
- const lifecycle = await upsertWorktreeMergeLifecycle({
1878
- repoRoot,
1978
+ const lifecycle = await mergeLifecycleRepository.upsert({
1879
1979
  branch,
1880
1980
  baseBranch,
1881
1981
  observedDivergedHead: byAncestry === false ? head : null
1882
1982
  });
1883
1983
  if (byAncestry === false) byLifecycle = false;
1884
- else if (byAncestry === true) if (lifecycle.everDiverged !== true || lifecycle.lastDivergedHead === null) if (byPR === true) byLifecycle = null;
1984
+ else if (byAncestry === true) if (hasLifecycleDivergedHead(lifecycle)) byLifecycle = await mergeProbeRepository.probeAncestry({
1985
+ branch: lifecycle.lastDivergedHead,
1986
+ baseBranch
1987
+ });
1988
+ else if (byPR === true) byLifecycle = null;
1885
1989
  else {
1886
- const probe = await resolveLifecycleFromReflog({
1887
- repoRoot,
1990
+ const probe = await mergeProbeRepository.probeLifecycleFromReflog({
1888
1991
  branch,
1889
1992
  baseBranch
1890
1993
  });
1891
1994
  byLifecycle = probe.merged;
1892
- if (probe.divergedHead !== null) await upsertWorktreeMergeLifecycle({
1893
- repoRoot,
1995
+ if (probe.divergedHead !== null) await mergeLifecycleRepository.upsert({
1894
1996
  branch,
1895
1997
  baseBranch,
1896
1998
  observedDivergedHead: probe.divergedHead
1897
1999
  });
1898
2000
  }
1899
- else {
1900
- const lifecycleResult = await runGitCommand({
1901
- cwd: repoRoot,
1902
- args: [
1903
- "merge-base",
1904
- "--is-ancestor",
1905
- lifecycle.lastDivergedHead,
1906
- baseBranch
1907
- ],
1908
- reject: false
1909
- });
1910
- if (lifecycleResult.exitCode === 0) byLifecycle = true;
1911
- else if (lifecycleResult.exitCode === 1) byLifecycle = false;
1912
- else byLifecycle = null;
1913
- }
1914
2001
  }
1915
2002
  return {
1916
2003
  byAncestry,
@@ -2046,6 +2133,50 @@ const RESERVED_FZF_ARGS = new Set([
2046
2133
  ]);
2047
2134
  const ANSI_ESCAPE_SEQUENCE_PATTERN = String.raw`\u001B\[[0-?]*[ -/]*[@-~]`;
2048
2135
  const ANSI_ESCAPE_SEQUENCE_REGEX = new RegExp(ANSI_ESCAPE_SEQUENCE_PATTERN, "g");
2136
+ var FzfError = class extends Error {
2137
+ code;
2138
+ constructor(options) {
2139
+ super(options.message);
2140
+ this.name = "FzfError";
2141
+ this.code = options.code;
2142
+ }
2143
+ };
2144
+ var FzfDependencyError = class extends FzfError {
2145
+ constructor(message = "fzf is required for interactive selection") {
2146
+ super({
2147
+ code: "FZF_DEPENDENCY_MISSING",
2148
+ message
2149
+ });
2150
+ this.name = "FzfDependencyError";
2151
+ }
2152
+ };
2153
+ var FzfInteractiveRequiredError = class extends FzfError {
2154
+ constructor(message = "fzf selection requires an interactive terminal") {
2155
+ super({
2156
+ code: "FZF_INTERACTIVE_REQUIRED",
2157
+ message
2158
+ });
2159
+ this.name = "FzfInteractiveRequiredError";
2160
+ }
2161
+ };
2162
+ var FzfInvalidArgumentError = class extends FzfError {
2163
+ constructor(message) {
2164
+ super({
2165
+ code: "FZF_INVALID_ARGUMENT",
2166
+ message
2167
+ });
2168
+ this.name = "FzfInvalidArgumentError";
2169
+ }
2170
+ };
2171
+ var FzfInvalidSelectionError = class extends FzfError {
2172
+ constructor(message) {
2173
+ super({
2174
+ code: "FZF_INVALID_SELECTION",
2175
+ message
2176
+ });
2177
+ this.name = "FzfInvalidSelectionError";
2178
+ }
2179
+ };
2049
2180
  const sanitizeCandidate = (value) => value.replace(/[\r\n]+/g, " ").trim();
2050
2181
  const stripAnsi = (value) => value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, "");
2051
2182
  const stripTrailingNewlines = (value) => value.replace(/[\r\n]+$/g, "");
@@ -2054,12 +2185,12 @@ const buildFzfInput = (candidates) => {
2054
2185
  };
2055
2186
  const validateExtraFzfArgs = (fzfExtraArgs) => {
2056
2187
  for (const arg of fzfExtraArgs) {
2057
- if (typeof arg !== "string" || arg.length === 0) throw new Error("Empty value is not allowed for --fzf-arg");
2188
+ if (typeof arg !== "string" || arg.length === 0) throw new FzfInvalidArgumentError("Empty value is not allowed for --fzf-arg");
2058
2189
  if (!arg.startsWith("--")) continue;
2059
2190
  const withoutPrefix = arg.slice(2);
2060
2191
  if (withoutPrefix.length === 0) continue;
2061
2192
  const optionName = withoutPrefix.split("=")[0];
2062
- if (optionName !== void 0 && RESERVED_FZF_ARGS.has(optionName)) throw new Error(`--fzf-arg cannot override reserved fzf option: --${optionName}`);
2193
+ if (optionName !== void 0 && RESERVED_FZF_ARGS.has(optionName)) throw new FzfInvalidArgumentError(`--fzf-arg cannot override reserved fzf option: --${optionName}`);
2063
2194
  }
2064
2195
  };
2065
2196
  const buildFzfArgs = ({ prompt, fzfExtraArgs }) => {
@@ -2099,7 +2230,7 @@ const defaultRunFzf = async ({ args, input, cwd, env }) => {
2099
2230
  };
2100
2231
  const ensureFzfAvailable = async (checkFzfAvailability) => {
2101
2232
  if (await checkFzfAvailability()) return;
2102
- throw new Error("fzf is required for interactive selection");
2233
+ throw new FzfDependencyError();
2103
2234
  };
2104
2235
  const shouldTryTmuxPopup = async ({ surface, env, checkFzfTmuxSupport }) => {
2105
2236
  if (surface === "inline") return false;
@@ -2122,8 +2253,8 @@ const isTmuxUnknownOptionError = (error) => {
2122
2253
  return /unknown option.*--tmux|--tmux.*unknown option/i.test(text);
2123
2254
  };
2124
2255
  const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", surface = "inline", tmuxPopupOpts = "80%,70%", fzfExtraArgs = [], cwd = process.cwd(), env = process.env, isInteractive = () => process.stdout.isTTY === true && process.stderr.isTTY === true, checkFzfAvailability = defaultCheckFzfAvailability, checkFzfTmuxSupport = defaultCheckFzfTmuxSupport, runFzf = defaultRunFzf }) => {
2125
- if (candidates.length === 0) throw new Error("No candidates provided for fzf selection");
2126
- if (isInteractive() !== true) throw new Error("fzf selection requires an interactive terminal");
2256
+ if (candidates.length === 0) throw new FzfInvalidArgumentError("No candidates provided for fzf selection");
2257
+ if (isInteractive() !== true) throw new FzfInteractiveRequiredError();
2127
2258
  await ensureFzfAvailable(checkFzfAvailability);
2128
2259
  const baseArgs = buildFzfArgs({
2129
2260
  prompt,
@@ -2136,7 +2267,7 @@ const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", surface =
2136
2267
  });
2137
2268
  const args = tryTmuxPopup ? [...baseArgs, `--tmux=${tmuxPopupOpts}`] : baseArgs;
2138
2269
  const input = buildFzfInput(candidates);
2139
- if (input.length === 0) throw new Error("All candidates are empty after sanitization");
2270
+ if (input.length === 0) throw new FzfInvalidArgumentError("All candidates are empty after sanitization");
2140
2271
  const candidateSet = new Set(input.split("\n").map((candidate) => stripAnsi(candidate)));
2141
2272
  const runWithValidation = async (fzfArgs) => {
2142
2273
  const selectedPath = stripAnsi(stripTrailingNewlines((await runFzf({
@@ -2146,7 +2277,7 @@ const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", surface =
2146
2277
  env
2147
2278
  })).stdout));
2148
2279
  if (selectedPath.length === 0) return { status: "cancelled" };
2149
- if (!candidateSet.has(selectedPath)) throw new Error("fzf returned a value that is not in the candidate list");
2280
+ if (!candidateSet.has(selectedPath)) throw new FzfInvalidSelectionError("fzf returned a value that is not in the candidate list");
2150
2281
  return {
2151
2282
  status: "selected",
2152
2283
  path: selectedPath
@@ -2217,6 +2348,157 @@ const createLogger = (options = {}) => {
2217
2348
  return build(prefix, level);
2218
2349
  };
2219
2350
 
2351
+ //#endregion
2352
+ //#region src/cli/commands/handler-groups.ts
2353
+ const createHandlerMap = (entries) => {
2354
+ return new Map(entries);
2355
+ };
2356
+ const dispatchCommandHandler = async ({ command, handlers }) => {
2357
+ const handler = handlers.get(command);
2358
+ if (handler === void 0) return;
2359
+ return await handler();
2360
+ };
2361
+ const createEarlyRepoCommandHandlers = ({ initHandler, listHandler, statusHandler, pathHandler }) => {
2362
+ return createHandlerMap([
2363
+ ["init", initHandler],
2364
+ ["list", listHandler],
2365
+ ["status", statusHandler],
2366
+ ["path", pathHandler]
2367
+ ]);
2368
+ };
2369
+ const createWriteCommandHandlers = ({ newHandler, switchHandler }) => {
2370
+ return createHandlerMap([["new", newHandler], ["switch", switchHandler]]);
2371
+ };
2372
+ const createWriteMutationHandlers = ({ mvHandler, delHandler }) => {
2373
+ return createHandlerMap([["mv", mvHandler], ["del", delHandler]]);
2374
+ };
2375
+ const createWorktreeActionHandlers = ({ goneHandler, getHandler, extractHandler }) => {
2376
+ return createHandlerMap([
2377
+ ["gone", goneHandler],
2378
+ ["get", getHandler],
2379
+ ["extract", extractHandler]
2380
+ ]);
2381
+ };
2382
+ const createSynchronizationHandlers = ({ absorbHandler, unabsorbHandler, useHandler }) => {
2383
+ return createHandlerMap([
2384
+ ["absorb", absorbHandler],
2385
+ ["unabsorb", unabsorbHandler],
2386
+ ["use", useHandler]
2387
+ ]);
2388
+ };
2389
+ const createMiscCommandHandlers = ({ execHandler, invokeHandler, copyHandler, linkHandler, lockHandler, unlockHandler, cdHandler }) => {
2390
+ return createHandlerMap([
2391
+ ["exec", execHandler],
2392
+ ["invoke", invokeHandler],
2393
+ ["copy", copyHandler],
2394
+ ["link", linkHandler],
2395
+ ["lock", lockHandler],
2396
+ ["unlock", unlockHandler],
2397
+ ["cd", cdHandler]
2398
+ ]);
2399
+ };
2400
+
2401
+ //#endregion
2402
+ //#region src/cli/commands/read/dispatcher.ts
2403
+ const handled = (exitCode) => {
2404
+ return {
2405
+ handled: true,
2406
+ exitCode
2407
+ };
2408
+ };
2409
+ const NOT_HANDLED = { handled: false };
2410
+ const dispatchReadOnlyCommands = async (input) => {
2411
+ if (input.parsedArgs.help === true) {
2412
+ const commandHelpTarget = input.command !== "unknown" && input.command !== "help" ? input.command : null;
2413
+ if (commandHelpTarget !== null) {
2414
+ const entry = input.findCommandHelp(commandHelpTarget);
2415
+ if (entry !== void 0) {
2416
+ input.stdout(`${input.renderCommandHelpText({ entry })}\n`);
2417
+ return handled(EXIT_CODE.OK);
2418
+ }
2419
+ }
2420
+ input.stdout(`${input.renderGeneralHelpText({ version: input.version })}\n`);
2421
+ return handled(EXIT_CODE.OK);
2422
+ }
2423
+ if (input.parsedArgs.version === true) {
2424
+ input.stdout(input.version);
2425
+ return handled(EXIT_CODE.OK);
2426
+ }
2427
+ if (input.positionals.length === 0) {
2428
+ input.stdout(`${input.renderGeneralHelpText({ version: input.version })}\n`);
2429
+ return handled(EXIT_CODE.OK);
2430
+ }
2431
+ if (input.command === "help") {
2432
+ const helpTarget = input.positionals[1];
2433
+ if (typeof helpTarget !== "string" || helpTarget.length === 0) {
2434
+ input.stdout(`${input.renderGeneralHelpText({ version: input.version })}\n`);
2435
+ return handled(EXIT_CODE.OK);
2436
+ }
2437
+ const entry = input.findCommandHelp(helpTarget);
2438
+ if (entry === void 0) throw createCliError("INVALID_ARGUMENT", {
2439
+ message: `Unknown command for help: ${helpTarget}`,
2440
+ details: {
2441
+ requested: helpTarget,
2442
+ availableCommands: input.availableCommandNames
2443
+ }
2444
+ });
2445
+ input.stdout(`${input.renderCommandHelpText({ entry })}\n`);
2446
+ return handled(EXIT_CODE.OK);
2447
+ }
2448
+ if (input.command === "completion") {
2449
+ input.ensureArgumentCount({
2450
+ command: input.command,
2451
+ args: input.commandArgs,
2452
+ min: 1,
2453
+ max: 1
2454
+ });
2455
+ const shell = input.resolveCompletionShell(input.commandArgs[0]);
2456
+ const script = await input.loadCompletionScript(shell);
2457
+ if (input.parsedArgs.install === true) {
2458
+ const destinationPath = input.resolveCompletionInstallPath({
2459
+ shell,
2460
+ requestedPath: input.readStringOption(input.parsedArgs, "path")
2461
+ });
2462
+ await input.installCompletionScript({
2463
+ content: script,
2464
+ destinationPath
2465
+ });
2466
+ if (input.jsonEnabled) {
2467
+ input.stdout(JSON.stringify(input.buildJsonSuccess({
2468
+ command: input.command,
2469
+ status: "ok",
2470
+ repoRoot: null,
2471
+ details: {
2472
+ shell,
2473
+ installed: true,
2474
+ path: destinationPath
2475
+ }
2476
+ })));
2477
+ return handled(EXIT_CODE.OK);
2478
+ }
2479
+ input.stdout(`installed completion: ${destinationPath}`);
2480
+ if (shell === "zsh") input.stdout("zsh note: ensure completion path is in fpath, then run: autoload -Uz compinit && compinit");
2481
+ return handled(EXIT_CODE.OK);
2482
+ }
2483
+ if (input.jsonEnabled) {
2484
+ input.stdout(JSON.stringify(input.buildJsonSuccess({
2485
+ command: input.command,
2486
+ status: "ok",
2487
+ repoRoot: null,
2488
+ details: {
2489
+ shell,
2490
+ installed: false,
2491
+ script
2492
+ }
2493
+ })));
2494
+ return handled(EXIT_CODE.OK);
2495
+ }
2496
+ input.stdout(script);
2497
+ return handled(EXIT_CODE.OK);
2498
+ }
2499
+ return NOT_HANDLED;
2500
+ };
2501
+
2220
2502
  //#endregion
2221
2503
  //#region src/cli/package-version.ts
2222
2504
  const CANDIDATE_PATHS = ["../package.json", "../../package.json"];
@@ -2584,6 +2866,7 @@ const commandHelpEntries = [
2584
2866
  options: ["--install", "--path <file>"]
2585
2867
  }
2586
2868
  ];
2869
+ const commandHelpNames = commandHelpEntries.map((entry) => entry.name);
2587
2870
  const splitRawArgsByDoubleDash = (args) => {
2588
2871
  const separatorIndex = args.indexOf("--");
2589
2872
  if (separatorIndex < 0) return {
@@ -2601,7 +2884,8 @@ const toKebabCase = (value) => {
2601
2884
  const toOptionSpec = (kind, optionName) => {
2602
2885
  return {
2603
2886
  kind,
2604
- allowOptionLikeValue: optionNamesAllowOptionLikeValue.has(optionName)
2887
+ allowOptionLikeValue: optionNamesAllowOptionLikeValue.has(optionName),
2888
+ allowNegation: kind === "boolean"
2605
2889
  };
2606
2890
  };
2607
2891
  const buildOptionSpecs = (argsDef) => {
@@ -2629,6 +2913,69 @@ const buildOptionSpecs = (argsDef) => {
2629
2913
  shortOptions
2630
2914
  };
2631
2915
  };
2916
+ const ensureOptionValueToken = ({ valueToken, optionLabel, optionSpec }) => {
2917
+ if (valueToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: ${optionLabel}` });
2918
+ if (valueToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: ${optionLabel}` });
2919
+ };
2920
+ const resolveLongOption = ({ rawOptionName, optionSpecs }) => {
2921
+ const directOptionSpec = optionSpecs.longOptions.get(rawOptionName);
2922
+ if (directOptionSpec !== void 0) return {
2923
+ optionSpec: directOptionSpec,
2924
+ optionName: rawOptionName
2925
+ };
2926
+ if (rawOptionName.startsWith("no-")) {
2927
+ const optionName = rawOptionName.slice(3);
2928
+ const negatedOptionSpec = optionSpecs.longOptions.get(optionName);
2929
+ if (negatedOptionSpec?.allowNegation === true) return {
2930
+ optionSpec: negatedOptionSpec,
2931
+ optionName
2932
+ };
2933
+ }
2934
+ };
2935
+ const validateLongOptionToken = ({ args, index, token, optionSpecs }) => {
2936
+ const value = token.slice(2);
2937
+ if (value.length === 0) return index;
2938
+ const separatorIndex = value.indexOf("=");
2939
+ const rawOptionName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
2940
+ const resolved = resolveLongOption({
2941
+ rawOptionName,
2942
+ optionSpecs
2943
+ });
2944
+ if (resolved === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: --${rawOptionName}` });
2945
+ if (resolved.optionSpec.kind !== "value") return index;
2946
+ if (separatorIndex >= 0) {
2947
+ if (value.slice(separatorIndex + 1).length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
2948
+ return index;
2949
+ }
2950
+ const nextToken = args[index + 1];
2951
+ if (typeof nextToken !== "string") throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
2952
+ ensureOptionValueToken({
2953
+ valueToken: nextToken,
2954
+ optionLabel: `--${rawOptionName}`,
2955
+ optionSpec: resolved.optionSpec
2956
+ });
2957
+ return index + 1;
2958
+ };
2959
+ const validateShortOptionToken = ({ args, index, token, optionSpecs }) => {
2960
+ const shortFlags = token.slice(1);
2961
+ for (let flagIndex = 0; flagIndex < shortFlags.length; flagIndex += 1) {
2962
+ const option = shortFlags[flagIndex];
2963
+ if (typeof option !== "string" || option.length === 0) continue;
2964
+ const optionSpec = optionSpecs.shortOptions.get(option);
2965
+ if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: -${option}` });
2966
+ if (optionSpec.kind !== "value") continue;
2967
+ if (flagIndex < shortFlags.length - 1) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
2968
+ const nextToken = args[index + 1];
2969
+ if (typeof nextToken !== "string") throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
2970
+ ensureOptionValueToken({
2971
+ valueToken: nextToken,
2972
+ optionLabel: `-${option}`,
2973
+ optionSpec
2974
+ });
2975
+ return index + 1;
2976
+ }
2977
+ return index;
2978
+ };
2632
2979
  const validateRawOptions = (args, optionSpecs) => {
2633
2980
  for (let index = 0; index < args.length; index += 1) {
2634
2981
  const token = args[index];
@@ -2636,39 +2983,20 @@ const validateRawOptions = (args, optionSpecs) => {
2636
2983
  if (token === "--") break;
2637
2984
  if (!token.startsWith("-") || token === "-") continue;
2638
2985
  if (token.startsWith("--")) {
2639
- const value = token.slice(2);
2640
- if (value.length === 0) continue;
2641
- const separatorIndex = value.indexOf("=");
2642
- const rawOptionName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
2643
- const directOptionSpec = optionSpecs.longOptions.get(rawOptionName);
2644
- const optionNameForNegation = rawOptionName.startsWith("no-") ? rawOptionName.slice(3) : rawOptionName;
2645
- const optionSpec = directOptionSpec ?? optionSpecs.longOptions.get(optionNameForNegation);
2646
- if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: --${rawOptionName}` });
2647
- if (optionSpec.kind === "value") if (separatorIndex >= 0) {
2648
- if (value.slice(separatorIndex + 1).length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
2649
- } else {
2650
- const nextToken = args[index + 1];
2651
- if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
2652
- if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
2653
- index += 1;
2654
- }
2986
+ index = validateLongOptionToken({
2987
+ args,
2988
+ index,
2989
+ token,
2990
+ optionSpecs
2991
+ });
2655
2992
  continue;
2656
2993
  }
2657
- const shortFlags = token.slice(1);
2658
- for (let flagIndex = 0; flagIndex < shortFlags.length; flagIndex += 1) {
2659
- const option = shortFlags[flagIndex];
2660
- if (typeof option !== "string" || option.length === 0) continue;
2661
- const optionSpec = optionSpecs.shortOptions.get(option);
2662
- if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: -${option}` });
2663
- if (optionSpec.kind === "value") {
2664
- if (flagIndex < shortFlags.length - 1) break;
2665
- const nextToken = args[index + 1];
2666
- if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
2667
- if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
2668
- index += 1;
2669
- break;
2670
- }
2671
- }
2994
+ index = validateShortOptionToken({
2995
+ args,
2996
+ index,
2997
+ token,
2998
+ optionSpecs
2999
+ });
2672
3000
  }
2673
3001
  };
2674
3002
  const getPositionals = (args) => {
@@ -3629,97 +3957,30 @@ const createCli = (options = {}) => {
3629
3957
  const parsedArgsRecord = parsedArgs;
3630
3958
  const positionals = getPositionals(parsedArgs);
3631
3959
  command = positionals[0] ?? "unknown";
3960
+ const commandArgs = positionals.slice(1);
3632
3961
  jsonEnabled = parsedArgs.json === true;
3633
- if (parsedArgs.help === true) {
3634
- const commandHelpTarget = typeof command === "string" && command !== "unknown" && command !== "help" ? command : null;
3635
- if (commandHelpTarget !== null) {
3636
- const entry = findCommandHelp(commandHelpTarget);
3637
- if (entry !== void 0) {
3638
- stdout(`${renderCommandHelpText({ entry })}\n`);
3639
- return EXIT_CODE.OK;
3640
- }
3641
- }
3642
- stdout(`${renderGeneralHelpText({ version })}\n`);
3643
- return EXIT_CODE.OK;
3644
- }
3645
- if (parsedArgs.version === true) {
3646
- stdout(version);
3647
- return EXIT_CODE.OK;
3648
- }
3962
+ const readOnlyDispatch = await dispatchReadOnlyCommands({
3963
+ command,
3964
+ commandArgs,
3965
+ positionals,
3966
+ parsedArgs: parsedArgsRecord,
3967
+ jsonEnabled,
3968
+ version,
3969
+ availableCommandNames: commandHelpNames,
3970
+ stdout,
3971
+ findCommandHelp,
3972
+ renderGeneralHelpText,
3973
+ renderCommandHelpText,
3974
+ ensureArgumentCount,
3975
+ resolveCompletionShell,
3976
+ loadCompletionScript,
3977
+ resolveCompletionInstallPath,
3978
+ installCompletionScript,
3979
+ readStringOption,
3980
+ buildJsonSuccess
3981
+ });
3982
+ if (readOnlyDispatch.handled) return readOnlyDispatch.exitCode;
3649
3983
  logger = parsedArgs.verbose === true ? createLogger({ level: LogLevel.INFO }) : createLogger();
3650
- if (positionals.length === 0) {
3651
- stdout(`${renderGeneralHelpText({ version })}\n`);
3652
- return EXIT_CODE.OK;
3653
- }
3654
- if (command === "help") {
3655
- const helpTarget = positionals[1];
3656
- if (typeof helpTarget !== "string" || helpTarget.length === 0) {
3657
- stdout(`${renderGeneralHelpText({ version })}\n`);
3658
- return EXIT_CODE.OK;
3659
- }
3660
- const entry = findCommandHelp(helpTarget);
3661
- if (entry === void 0) throw createCliError("INVALID_ARGUMENT", {
3662
- message: `Unknown command for help: ${helpTarget}`,
3663
- details: {
3664
- requested: helpTarget,
3665
- availableCommands: commandHelpEntries.map((item) => item.name)
3666
- }
3667
- });
3668
- stdout(`${renderCommandHelpText({ entry })}\n`);
3669
- return EXIT_CODE.OK;
3670
- }
3671
- const commandArgs = positionals.slice(1);
3672
- if (command === "completion") {
3673
- ensureArgumentCount({
3674
- command,
3675
- args: commandArgs,
3676
- min: 1,
3677
- max: 1
3678
- });
3679
- const shell = resolveCompletionShell(commandArgs[0]);
3680
- const script = await loadCompletionScript(shell);
3681
- if (parsedArgs.install === true) {
3682
- const destinationPath = resolveCompletionInstallPath({
3683
- shell,
3684
- requestedPath: readStringOption(parsedArgsRecord, "path")
3685
- });
3686
- await installCompletionScript({
3687
- content: script,
3688
- destinationPath
3689
- });
3690
- if (jsonEnabled) {
3691
- stdout(JSON.stringify(buildJsonSuccess({
3692
- command,
3693
- status: "ok",
3694
- repoRoot: null,
3695
- details: {
3696
- shell,
3697
- installed: true,
3698
- path: destinationPath
3699
- }
3700
- })));
3701
- return EXIT_CODE.OK;
3702
- }
3703
- stdout(`installed completion: ${destinationPath}`);
3704
- if (shell === "zsh") stdout("zsh note: ensure completion path is in fpath, then run: autoload -Uz compinit && compinit");
3705
- return EXIT_CODE.OK;
3706
- }
3707
- if (jsonEnabled) {
3708
- stdout(JSON.stringify(buildJsonSuccess({
3709
- command,
3710
- status: "ok",
3711
- repoRoot: null,
3712
- details: {
3713
- shell,
3714
- installed: false,
3715
- script
3716
- }
3717
- })));
3718
- return EXIT_CODE.OK;
3719
- }
3720
- stdout(script);
3721
- return EXIT_CODE.OK;
3722
- }
3723
3984
  const allowUnsafe = parsedArgs.allowUnsafe === true;
3724
3985
  if (parsedArgs.hooks === false && allowUnsafe !== true) throw createCliError("UNSAFE_FLAG_REQUIRED", { message: "UNSAFE_FLAG_REQUIRED: --no-hooks requires --allow-unsafe" });
3725
3986
  const repoContext = await resolveRepoContext(runtimeCwd);
@@ -3777,7 +4038,30 @@ const createCli = (options = {}) => {
3777
4038
  staleLockTTLSeconds
3778
4039
  }, task);
3779
4040
  };
3780
- if (command === "init") {
4041
+ const executeWorktreeMutation = async ({ name, branch, worktreePath, extraEnv, precheck, runGit, finalize }) => {
4042
+ const precheckResult = await precheck();
4043
+ const hookContext = createHookContext({
4044
+ runtime,
4045
+ repoRoot,
4046
+ action: name,
4047
+ branch,
4048
+ worktreePath,
4049
+ stderr,
4050
+ extraEnv
4051
+ });
4052
+ await runPreHook({
4053
+ name,
4054
+ context: hookContext
4055
+ });
4056
+ const result = await runGit(precheckResult);
4057
+ if (finalize !== void 0) await finalize(precheckResult, result);
4058
+ await runPostHook({
4059
+ name,
4060
+ context: hookContext
4061
+ });
4062
+ return result;
4063
+ };
4064
+ const handleInit = async () => {
3781
4065
  ensureArgumentCount({
3782
4066
  command,
3783
4067
  args: commandArgs,
@@ -3807,21 +4091,18 @@ const createCli = (options = {}) => {
3807
4091
  });
3808
4092
  return initialized;
3809
4093
  });
3810
- if (runtime.json) {
3811
- stdout(JSON.stringify(buildJsonSuccess({
3812
- command,
3813
- status: "ok",
3814
- repoRoot,
3815
- details: {
3816
- initialized: true,
3817
- alreadyInitialized: result.alreadyInitialized
3818
- }
3819
- })));
3820
- return EXIT_CODE.OK;
3821
- }
4094
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
4095
+ command,
4096
+ status: "ok",
4097
+ repoRoot,
4098
+ details: {
4099
+ initialized: true,
4100
+ alreadyInitialized: result.alreadyInitialized
4101
+ }
4102
+ })));
3822
4103
  return EXIT_CODE.OK;
3823
- }
3824
- if (command === "list") {
4104
+ };
4105
+ const handleList = async () => {
3825
4106
  ensureArgumentCount({
3826
4107
  command,
3827
4108
  args: commandArgs,
@@ -3893,8 +4174,8 @@ const createCli = (options = {}) => {
3893
4174
  }) : rendered.trimEnd();
3894
4175
  for (const line of colorized.split("\n")) stdout(line);
3895
4176
  return EXIT_CODE.OK;
3896
- }
3897
- if (command === "status") {
4177
+ };
4178
+ const handleStatus = async () => {
3898
4179
  ensureArgumentCount({
3899
4180
  command,
3900
4181
  args: commandArgs,
@@ -3924,8 +4205,8 @@ const createCli = (options = {}) => {
3924
4205
  stdout(`dirty: ${targetWorktree.dirty ? "true" : "false"}`);
3925
4206
  stdout(`locked: ${targetWorktree.locked.value ? "true" : "false"}`);
3926
4207
  return EXIT_CODE.OK;
3927
- }
3928
- if (command === "path") {
4208
+ };
4209
+ const handlePath = async () => {
3929
4210
  ensureArgumentCount({
3930
4211
  command,
3931
4212
  args: commandArgs,
@@ -3951,8 +4232,18 @@ const createCli = (options = {}) => {
3951
4232
  }
3952
4233
  stdout(target.path);
3953
4234
  return EXIT_CODE.OK;
3954
- }
3955
- if (command === "new") {
4235
+ };
4236
+ const earlyRepoExitCode = await dispatchCommandHandler({
4237
+ command,
4238
+ handlers: createEarlyRepoCommandHandlers({
4239
+ initHandler: handleInit,
4240
+ listHandler: handleList,
4241
+ statusHandler: handleStatus,
4242
+ pathHandler: handlePath
4243
+ })
4244
+ });
4245
+ if (earlyRepoExitCode !== void 0) return earlyRepoExitCode;
4246
+ const handleNew = async () => {
3956
4247
  ensureArgumentCount({
3957
4248
  command,
3958
4249
  args: commandArgs,
@@ -3960,61 +4251,56 @@ const createCli = (options = {}) => {
3960
4251
  max: 1
3961
4252
  });
3962
4253
  const branch = commandArgs[0] ?? randomWipBranchName();
4254
+ const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3963
4255
  const result = await runWriteOperation(async () => {
3964
- if (containsBranch({
3965
- branch,
3966
- worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees
3967
- })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
3968
- message: `Branch is already attached to a worktree: ${branch}`,
3969
- details: { branch }
3970
- });
3971
- if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
3972
- message: `Branch already exists locally: ${branch}`,
3973
- details: { branch }
3974
- });
3975
- const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
3976
- await ensureTargetPathWritable(targetPath);
3977
- const baseBranch = await resolveBaseBranch({
3978
- repoRoot,
3979
- config: resolvedConfig
3980
- });
3981
- const hookContext = createHookContext({
3982
- runtime,
3983
- repoRoot,
3984
- action: "new",
3985
- branch,
3986
- worktreePath: targetPath,
3987
- stderr
3988
- });
3989
- await runPreHook({
4256
+ return executeWorktreeMutation({
3990
4257
  name: "new",
3991
- context: hookContext
3992
- });
3993
- await runGitCommand({
3994
- cwd: repoRoot,
3995
- args: [
3996
- "worktree",
3997
- "add",
3998
- "-b",
3999
- branch,
4000
- targetPath,
4001
- baseBranch
4002
- ]
4003
- });
4004
- await upsertWorktreeMergeLifecycle({
4005
- repoRoot,
4006
4258
  branch,
4007
- baseBranch,
4008
- observedDivergedHead: null
4009
- });
4010
- await runPostHook({
4011
- name: "new",
4012
- context: hookContext
4259
+ worktreePath: targetPath,
4260
+ precheck: async () => {
4261
+ if (containsBranch({
4262
+ branch,
4263
+ worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees
4264
+ })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
4265
+ message: `Branch is already attached to a worktree: ${branch}`,
4266
+ details: { branch }
4267
+ });
4268
+ if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
4269
+ message: `Branch already exists locally: ${branch}`,
4270
+ details: { branch }
4271
+ });
4272
+ await ensureTargetPathWritable(targetPath);
4273
+ return { baseBranch: await resolveBaseBranch({
4274
+ repoRoot,
4275
+ config: resolvedConfig
4276
+ }) };
4277
+ },
4278
+ runGit: async ({ baseBranch }) => {
4279
+ await runGitCommand({
4280
+ cwd: repoRoot,
4281
+ args: [
4282
+ "worktree",
4283
+ "add",
4284
+ "-b",
4285
+ branch,
4286
+ targetPath,
4287
+ baseBranch
4288
+ ]
4289
+ });
4290
+ return {
4291
+ branch,
4292
+ path: targetPath
4293
+ };
4294
+ },
4295
+ finalize: async ({ baseBranch }) => {
4296
+ await upsertWorktreeMergeLifecycle({
4297
+ repoRoot,
4298
+ branch,
4299
+ baseBranch,
4300
+ observedDivergedHead: null
4301
+ });
4302
+ }
4013
4303
  });
4014
- return {
4015
- branch,
4016
- path: targetPath
4017
- };
4018
4304
  });
4019
4305
  if (runtime.json) {
4020
4306
  stdout(JSON.stringify(buildJsonSuccess({
@@ -4027,8 +4313,8 @@ const createCli = (options = {}) => {
4027
4313
  }
4028
4314
  stdout(result.path);
4029
4315
  return EXIT_CODE.OK;
4030
- }
4031
- if (command === "switch") {
4316
+ };
4317
+ const handleSwitch = async () => {
4032
4318
  ensureArgumentCount({
4033
4319
  command,
4034
4320
  args: commandArgs,
@@ -4053,62 +4339,57 @@ const createCli = (options = {}) => {
4053
4339
  };
4054
4340
  }
4055
4341
  const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
4056
- await ensureTargetPathWritable(targetPath);
4057
- const hookContext = createHookContext({
4058
- runtime,
4059
- repoRoot,
4060
- action: "switch",
4342
+ return executeWorktreeMutation({
4343
+ name: "switch",
4061
4344
  branch,
4062
4345
  worktreePath: targetPath,
4063
- stderr
4064
- });
4065
- await runPreHook({
4066
- name: "switch",
4067
- context: hookContext
4068
- });
4069
- let lifecycleBaseBranch = snapshot.baseBranch;
4070
- if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) await runGitCommand({
4071
- cwd: repoRoot,
4072
- args: [
4073
- "worktree",
4074
- "add",
4075
- targetPath,
4076
- branch
4077
- ]
4078
- });
4079
- else {
4080
- const baseBranch = await resolveBaseBranch({
4081
- repoRoot,
4082
- config: resolvedConfig
4083
- });
4084
- lifecycleBaseBranch = baseBranch;
4085
- await runGitCommand({
4086
- cwd: repoRoot,
4087
- args: [
4088
- "worktree",
4089
- "add",
4090
- "-b",
4346
+ precheck: async () => {
4347
+ await ensureTargetPathWritable(targetPath);
4348
+ if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) return {
4349
+ gitArgs: [
4350
+ "worktree",
4351
+ "add",
4352
+ targetPath,
4353
+ branch
4354
+ ],
4355
+ lifecycleBaseBranch: snapshot.baseBranch
4356
+ };
4357
+ const baseBranch = await resolveBaseBranch({
4358
+ repoRoot,
4359
+ config: resolvedConfig
4360
+ });
4361
+ return {
4362
+ gitArgs: [
4363
+ "worktree",
4364
+ "add",
4365
+ "-b",
4366
+ branch,
4367
+ targetPath,
4368
+ baseBranch
4369
+ ],
4370
+ lifecycleBaseBranch: baseBranch
4371
+ };
4372
+ },
4373
+ runGit: async ({ gitArgs }) => {
4374
+ await runGitCommand({
4375
+ cwd: repoRoot,
4376
+ args: [...gitArgs]
4377
+ });
4378
+ return {
4379
+ status: "created",
4091
4380
  branch,
4092
- targetPath,
4093
- baseBranch
4094
- ]
4095
- });
4096
- }
4097
- if (lifecycleBaseBranch !== null) await upsertWorktreeMergeLifecycle({
4098
- repoRoot,
4099
- branch,
4100
- baseBranch: lifecycleBaseBranch,
4101
- observedDivergedHead: null
4102
- });
4103
- await runPostHook({
4104
- name: "switch",
4105
- context: hookContext
4381
+ path: targetPath
4382
+ };
4383
+ },
4384
+ finalize: async ({ lifecycleBaseBranch }) => {
4385
+ if (lifecycleBaseBranch !== null) await upsertWorktreeMergeLifecycle({
4386
+ repoRoot,
4387
+ branch,
4388
+ baseBranch: lifecycleBaseBranch,
4389
+ observedDivergedHead: null
4390
+ });
4391
+ }
4106
4392
  });
4107
- return {
4108
- status: "created",
4109
- branch,
4110
- path: targetPath
4111
- };
4112
4393
  });
4113
4394
  if (runtime.json) {
4114
4395
  stdout(JSON.stringify(buildJsonSuccess({
@@ -4124,8 +4405,16 @@ const createCli = (options = {}) => {
4124
4405
  }
4125
4406
  stdout(result.path);
4126
4407
  return EXIT_CODE.OK;
4127
- }
4128
- if (command === "mv") {
4408
+ };
4409
+ const writeCommandExitCode = await dispatchCommandHandler({
4410
+ command,
4411
+ handlers: createWriteCommandHandlers({
4412
+ newHandler: handleNew,
4413
+ switchHandler: handleSwitch
4414
+ })
4415
+ });
4416
+ if (writeCommandExitCode !== void 0) return writeCommandExitCode;
4417
+ const handleMv = async () => {
4129
4418
  ensureArgumentCount({
4130
4419
  command,
4131
4420
  args: commandArgs,
@@ -4152,68 +4441,68 @@ const createCli = (options = {}) => {
4152
4441
  branch: newBranch,
4153
4442
  path: current.path
4154
4443
  };
4155
- if (containsBranch({
4156
- branch: newBranch,
4157
- worktrees: snapshot.worktrees
4158
- })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
4159
- message: `Branch is already attached to another worktree: ${newBranch}`,
4160
- details: { branch: newBranch }
4161
- });
4162
- if (await doesGitRefExist(repoRoot, `refs/heads/${newBranch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
4163
- message: `Branch already exists locally: ${newBranch}`,
4164
- details: { branch: newBranch }
4165
- });
4166
4444
  const newPath = branchToWorktreePath(repoRoot, newBranch, resolvedConfig.paths.worktreeRoot);
4167
- await ensureTargetPathWritable(newPath);
4168
- const hookContext = createHookContext({
4169
- runtime,
4170
- repoRoot,
4171
- action: "mv",
4445
+ return executeWorktreeMutation({
4446
+ name: "mv",
4172
4447
  branch: newBranch,
4173
4448
  worktreePath: newPath,
4174
- stderr,
4175
4449
  extraEnv: {
4176
4450
  WT_OLD_BRANCH: oldBranch,
4177
4451
  WT_NEW_BRANCH: newBranch
4452
+ },
4453
+ precheck: async () => {
4454
+ if (containsBranch({
4455
+ branch: newBranch,
4456
+ worktrees: snapshot.worktrees
4457
+ })) throw createCliError("BRANCH_ALREADY_ATTACHED", {
4458
+ message: `Branch is already attached to another worktree: ${newBranch}`,
4459
+ details: { branch: newBranch }
4460
+ });
4461
+ if (await doesGitRefExist(repoRoot, `refs/heads/${newBranch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
4462
+ message: `Branch already exists locally: ${newBranch}`,
4463
+ details: { branch: newBranch }
4464
+ });
4465
+ await ensureTargetPathWritable(newPath);
4466
+ return {
4467
+ oldBranch,
4468
+ currentPath: current.path,
4469
+ baseBranch: snapshot.baseBranch
4470
+ };
4471
+ },
4472
+ runGit: async ({ oldBranch: resolvedOldBranch, currentPath }) => {
4473
+ await runGitCommand({
4474
+ cwd: currentPath,
4475
+ args: [
4476
+ "branch",
4477
+ "-m",
4478
+ resolvedOldBranch,
4479
+ newBranch
4480
+ ]
4481
+ });
4482
+ await runGitCommand({
4483
+ cwd: repoRoot,
4484
+ args: [
4485
+ "worktree",
4486
+ "move",
4487
+ currentPath,
4488
+ newPath
4489
+ ]
4490
+ });
4491
+ return {
4492
+ branch: newBranch,
4493
+ path: newPath
4494
+ };
4495
+ },
4496
+ finalize: async ({ oldBranch: resolvedOldBranch, baseBranch }) => {
4497
+ if (baseBranch !== null) await moveWorktreeMergeLifecycle({
4498
+ repoRoot,
4499
+ fromBranch: resolvedOldBranch,
4500
+ toBranch: newBranch,
4501
+ baseBranch,
4502
+ observedDivergedHead: null
4503
+ });
4178
4504
  }
4179
4505
  });
4180
- await runPreHook({
4181
- name: "mv",
4182
- context: hookContext
4183
- });
4184
- await runGitCommand({
4185
- cwd: current.path,
4186
- args: [
4187
- "branch",
4188
- "-m",
4189
- oldBranch,
4190
- newBranch
4191
- ]
4192
- });
4193
- await runGitCommand({
4194
- cwd: repoRoot,
4195
- args: [
4196
- "worktree",
4197
- "move",
4198
- current.path,
4199
- newPath
4200
- ]
4201
- });
4202
- if (snapshot.baseBranch !== null) await moveWorktreeMergeLifecycle({
4203
- repoRoot,
4204
- fromBranch: oldBranch,
4205
- toBranch: newBranch,
4206
- baseBranch: snapshot.baseBranch,
4207
- observedDivergedHead: null
4208
- });
4209
- await runPostHook({
4210
- name: "mv",
4211
- context: hookContext
4212
- });
4213
- return {
4214
- branch: newBranch,
4215
- path: newPath
4216
- };
4217
4506
  });
4218
4507
  if (runtime.json) {
4219
4508
  stdout(JSON.stringify(buildJsonSuccess({
@@ -4226,8 +4515,8 @@ const createCli = (options = {}) => {
4226
4515
  }
4227
4516
  stdout(result.path);
4228
4517
  return EXIT_CODE.OK;
4229
- }
4230
- if (command === "del") {
4518
+ };
4519
+ const handleDel = async () => {
4231
4520
  ensureArgumentCount({
4232
4521
  command,
4233
4522
  args: commandArgs,
@@ -4268,56 +4557,58 @@ const createCli = (options = {}) => {
4268
4557
  managedWorktreeRoot
4269
4558
  }
4270
4559
  });
4271
- validateDeleteSafety({
4272
- target,
4273
- forceFlags
4274
- });
4275
- const hookContext = createHookContext({
4276
- runtime,
4277
- repoRoot,
4278
- action: "del",
4279
- branch: target.branch,
4280
- worktreePath: target.path,
4281
- stderr
4282
- });
4283
- await runPreHook({
4284
- name: "del",
4285
- context: hookContext
4286
- });
4287
- const removeArgs = [
4288
- "worktree",
4289
- "remove",
4290
- target.path
4291
- ];
4292
- if (forceFlags.forceDirty) removeArgs.push("--force");
4293
- await runGitCommand({
4294
- cwd: repoRoot,
4295
- args: removeArgs
4296
- });
4297
- await runGitCommand({
4298
- cwd: repoRoot,
4299
- args: [
4300
- "branch",
4301
- resolveBranchDeleteMode(forceFlags),
4302
- target.branch
4303
- ]
4304
- });
4305
- await deleteWorktreeLock({
4306
- repoRoot,
4307
- branch: target.branch
4308
- });
4309
- await deleteWorktreeMergeLifecycle({
4310
- repoRoot,
4311
- branch: target.branch
4312
- });
4313
- await runPostHook({
4560
+ const targetBranch = target.branch;
4561
+ return executeWorktreeMutation({
4314
4562
  name: "del",
4315
- context: hookContext
4563
+ branch: targetBranch,
4564
+ worktreePath: target.path,
4565
+ precheck: async () => {
4566
+ validateDeleteSafety({
4567
+ target,
4568
+ forceFlags
4569
+ });
4570
+ const removeArgs = [
4571
+ "worktree",
4572
+ "remove",
4573
+ target.path
4574
+ ];
4575
+ if (forceFlags.forceDirty) removeArgs.push("--force");
4576
+ return {
4577
+ branch: targetBranch,
4578
+ path: target.path,
4579
+ removeArgs,
4580
+ branchDeleteMode: resolveBranchDeleteMode(forceFlags)
4581
+ };
4582
+ },
4583
+ runGit: async ({ branch: targetBranch, removeArgs, branchDeleteMode, path }) => {
4584
+ await runGitCommand({
4585
+ cwd: repoRoot,
4586
+ args: removeArgs
4587
+ });
4588
+ await runGitCommand({
4589
+ cwd: repoRoot,
4590
+ args: [
4591
+ "branch",
4592
+ branchDeleteMode,
4593
+ targetBranch
4594
+ ]
4595
+ });
4596
+ return {
4597
+ branch: targetBranch,
4598
+ path
4599
+ };
4600
+ },
4601
+ finalize: async ({ branch: targetBranch }) => {
4602
+ await deleteWorktreeLock({
4603
+ repoRoot,
4604
+ branch: targetBranch
4605
+ });
4606
+ await deleteWorktreeMergeLifecycle({
4607
+ repoRoot,
4608
+ branch: targetBranch
4609
+ });
4610
+ }
4316
4611
  });
4317
- return {
4318
- branch: target.branch,
4319
- path: target.path
4320
- };
4321
4612
  });
4322
4613
  if (runtime.json) {
4323
4614
  stdout(JSON.stringify(buildJsonSuccess({
@@ -4330,8 +4621,16 @@ const createCli = (options = {}) => {
4330
4621
  }
4331
4622
  stdout(result.path);
4332
4623
  return EXIT_CODE.OK;
4333
- }
4334
- if (command === "gone") {
4624
+ };
4625
+ const writeMutationExitCode = await dispatchCommandHandler({
4626
+ command,
4627
+ handlers: createWriteMutationHandlers({
4628
+ mvHandler: handleMv,
4629
+ delHandler: handleDel
4630
+ })
4631
+ });
4632
+ if (writeMutationExitCode !== void 0) return writeMutationExitCode;
4633
+ const handleGone = async () => {
4335
4634
  ensureArgumentCount({
4336
4635
  command,
4337
4636
  args: commandArgs,
@@ -4421,8 +4720,8 @@ const createCli = (options = {}) => {
4421
4720
  const branches = result.dryRun ? result.candidates : result.deleted;
4422
4721
  for (const branch of branches) stdout(`${label}: ${branch}`);
4423
4722
  return EXIT_CODE.OK;
4424
- }
4425
- if (command === "get") {
4723
+ };
4724
+ const handleGet = async () => {
4426
4725
  ensureArgumentCount({
4427
4726
  command,
4428
4727
  args: commandArgs,
@@ -4541,8 +4840,8 @@ const createCli = (options = {}) => {
4541
4840
  }
4542
4841
  stdout(result.path);
4543
4842
  return EXIT_CODE.OK;
4544
- }
4545
- if (command === "extract") {
4843
+ };
4844
+ const handleExtract = async () => {
4546
4845
  ensureArgumentCount({
4547
4846
  command,
4548
4847
  args: commandArgs,
@@ -4674,8 +4973,17 @@ const createCli = (options = {}) => {
4674
4973
  }
4675
4974
  stdout(result.path);
4676
4975
  return EXIT_CODE.OK;
4677
- }
4678
- if (command === "absorb") {
4976
+ };
4977
+ const worktreeActionExitCode = await dispatchCommandHandler({
4978
+ command,
4979
+ handlers: createWorktreeActionHandlers({
4980
+ goneHandler: handleGone,
4981
+ getHandler: handleGet,
4982
+ extractHandler: handleExtract
4983
+ })
4984
+ });
4985
+ if (worktreeActionExitCode !== void 0) return worktreeActionExitCode;
4986
+ const handleAbsorb = async () => {
4679
4987
  ensureArgumentCount({
4680
4988
  command,
4681
4989
  args: commandArgs,
@@ -4797,8 +5105,8 @@ const createCli = (options = {}) => {
4797
5105
  }
4798
5106
  stdout(result.path);
4799
5107
  return EXIT_CODE.OK;
4800
- }
4801
- if (command === "unabsorb") {
5108
+ };
5109
+ const handleUnabsorb = async () => {
4802
5110
  ensureArgumentCount({
4803
5111
  command,
4804
5112
  args: commandArgs,
@@ -4930,8 +5238,8 @@ const createCli = (options = {}) => {
4930
5238
  }
4931
5239
  stdout(result.path);
4932
5240
  return EXIT_CODE.OK;
4933
- }
4934
- if (command === "use") {
5241
+ };
5242
+ const handleUse = async () => {
4935
5243
  ensureArgumentCount({
4936
5244
  command,
4937
5245
  args: commandArgs,
@@ -5024,8 +5332,17 @@ const createCli = (options = {}) => {
5024
5332
  }
5025
5333
  stdout(result.path);
5026
5334
  return EXIT_CODE.OK;
5027
- }
5028
- if (command === "exec") {
5335
+ };
5336
+ const synchronizationExitCode = await dispatchCommandHandler({
5337
+ command,
5338
+ handlers: createSynchronizationHandlers({
5339
+ absorbHandler: handleAbsorb,
5340
+ unabsorbHandler: handleUnabsorb,
5341
+ useHandler: handleUse
5342
+ })
5343
+ });
5344
+ if (synchronizationExitCode !== void 0) return synchronizationExitCode;
5345
+ const handleExec = async () => {
5029
5346
  ensureArgumentCount({
5030
5347
  command,
5031
5348
  args: commandArgs,
@@ -5080,8 +5397,8 @@ const createCli = (options = {}) => {
5080
5397
  return EXIT_CODE.CHILD_PROCESS_FAILED;
5081
5398
  }
5082
5399
  return childExitCode === 0 ? EXIT_CODE.OK : EXIT_CODE.CHILD_PROCESS_FAILED;
5083
- }
5084
- if (command === "invoke") {
5400
+ };
5401
+ const handleInvoke = async () => {
5085
5402
  ensureArgumentCount({
5086
5403
  command,
5087
5404
  args: commandArgs,
@@ -5105,21 +5422,18 @@ const createCli = (options = {}) => {
5105
5422
  stderr
5106
5423
  })
5107
5424
  });
5108
- if (runtime.json) {
5109
- stdout(JSON.stringify(buildJsonSuccess({
5110
- command,
5111
- status: "ok",
5112
- repoRoot,
5113
- details: {
5114
- hook: hookName,
5115
- exitCode: 0
5116
- }
5117
- })));
5118
- return EXIT_CODE.OK;
5119
- }
5425
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5426
+ command,
5427
+ status: "ok",
5428
+ repoRoot,
5429
+ details: {
5430
+ hook: hookName,
5431
+ exitCode: 0
5432
+ }
5433
+ })));
5120
5434
  return EXIT_CODE.OK;
5121
- }
5122
- if (command === "copy") {
5435
+ };
5436
+ const handleCopy = async () => {
5123
5437
  ensureArgumentCount({
5124
5438
  command,
5125
5439
  args: commandArgs,
@@ -5145,21 +5459,18 @@ const createCli = (options = {}) => {
5145
5459
  dereference: false
5146
5460
  });
5147
5461
  }
5148
- if (runtime.json) {
5149
- stdout(JSON.stringify(buildJsonSuccess({
5150
- command,
5151
- status: "ok",
5152
- repoRoot,
5153
- details: {
5154
- copied: commandArgs,
5155
- worktreePath: targetWorktreeRoot
5156
- }
5157
- })));
5158
- return EXIT_CODE.OK;
5159
- }
5462
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5463
+ command,
5464
+ status: "ok",
5465
+ repoRoot,
5466
+ details: {
5467
+ copied: commandArgs,
5468
+ worktreePath: targetWorktreeRoot
5469
+ }
5470
+ })));
5160
5471
  return EXIT_CODE.OK;
5161
- }
5162
- if (command === "link") {
5472
+ };
5473
+ const handleLink = async () => {
5163
5474
  ensureArgumentCount({
5164
5475
  command,
5165
5476
  args: commandArgs,
@@ -5205,22 +5516,19 @@ const createCli = (options = {}) => {
5205
5516
  });
5206
5517
  }
5207
5518
  }
5208
- if (runtime.json) {
5209
- stdout(JSON.stringify(buildJsonSuccess({
5210
- command,
5211
- status: "ok",
5212
- repoRoot,
5213
- details: {
5214
- linked: commandArgs,
5215
- worktreePath: targetWorktreeRoot,
5216
- fallback: fallbackEnabled
5217
- }
5218
- })));
5219
- return EXIT_CODE.OK;
5220
- }
5519
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5520
+ command,
5521
+ status: "ok",
5522
+ repoRoot,
5523
+ details: {
5524
+ linked: commandArgs,
5525
+ worktreePath: targetWorktreeRoot,
5526
+ fallback: fallbackEnabled
5527
+ }
5528
+ })));
5221
5529
  return EXIT_CODE.OK;
5222
- }
5223
- if (command === "lock") {
5530
+ };
5531
+ const handleLock = async () => {
5224
5532
  ensureArgumentCount({
5225
5533
  command,
5226
5534
  args: commandArgs,
@@ -5262,25 +5570,22 @@ const createCli = (options = {}) => {
5262
5570
  owner
5263
5571
  });
5264
5572
  });
5265
- if (runtime.json) {
5266
- stdout(JSON.stringify(buildJsonSuccess({
5267
- command,
5268
- status: "ok",
5269
- repoRoot,
5270
- details: {
5271
- branch,
5272
- locked: {
5273
- value: true,
5274
- reason: result.reason,
5275
- owner: result.owner
5276
- }
5573
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5574
+ command,
5575
+ status: "ok",
5576
+ repoRoot,
5577
+ details: {
5578
+ branch,
5579
+ locked: {
5580
+ value: true,
5581
+ reason: result.reason,
5582
+ owner: result.owner
5277
5583
  }
5278
- })));
5279
- return EXIT_CODE.OK;
5280
- }
5584
+ }
5585
+ })));
5281
5586
  return EXIT_CODE.OK;
5282
- }
5283
- if (command === "unlock") {
5587
+ };
5588
+ const handleUnlock = async () => {
5284
5589
  ensureArgumentCount({
5285
5590
  command,
5286
5591
  args: commandArgs,
@@ -5325,24 +5630,21 @@ const createCli = (options = {}) => {
5325
5630
  branch
5326
5631
  });
5327
5632
  });
5328
- if (runtime.json) {
5329
- stdout(JSON.stringify(buildJsonSuccess({
5330
- command,
5331
- status: "ok",
5332
- repoRoot,
5333
- details: {
5334
- branch,
5335
- locked: {
5336
- value: false,
5337
- reason: null
5338
- }
5633
+ if (runtime.json) stdout(JSON.stringify(buildJsonSuccess({
5634
+ command,
5635
+ status: "ok",
5636
+ repoRoot,
5637
+ details: {
5638
+ branch,
5639
+ locked: {
5640
+ value: false,
5641
+ reason: null
5339
5642
  }
5340
- })));
5341
- return EXIT_CODE.OK;
5342
- }
5643
+ }
5644
+ })));
5343
5645
  return EXIT_CODE.OK;
5344
- }
5345
- if (command === "cd") {
5646
+ };
5647
+ const handleCd = async () => {
5346
5648
  ensureArgumentCount({
5347
5649
  command,
5348
5650
  args: commandArgs,
@@ -5389,8 +5691,7 @@ const createCli = (options = {}) => {
5389
5691
  cwd: repoRoot,
5390
5692
  isInteractive: () => runtime.isInteractive || process.stderr.isTTY === true
5391
5693
  }).catch((error) => {
5392
- const message = error instanceof Error ? error.message : String(error);
5393
- if (message.includes("interactive terminal") || message.includes("fzf is required")) throw createCliError("DEPENDENCY_MISSING", { message: `DEPENDENCY_MISSING: ${message}` });
5694
+ if (error instanceof FzfDependencyError || error instanceof FzfInteractiveRequiredError) throw createCliError("DEPENDENCY_MISSING", { message: `DEPENDENCY_MISSING: ${error.message}` });
5394
5695
  throw error;
5395
5696
  });
5396
5697
  if (selection.status === "cancelled") return EXIT_CODE_CANCELLED;
@@ -5406,7 +5707,20 @@ const createCli = (options = {}) => {
5406
5707
  }
5407
5708
  stdout(selectedPath);
5408
5709
  return EXIT_CODE.OK;
5409
- }
5710
+ };
5711
+ const miscCommandExitCode = await dispatchCommandHandler({
5712
+ command,
5713
+ handlers: createMiscCommandHandlers({
5714
+ execHandler: handleExec,
5715
+ invokeHandler: handleInvoke,
5716
+ copyHandler: handleCopy,
5717
+ linkHandler: handleLink,
5718
+ lockHandler: handleLock,
5719
+ unlockHandler: handleUnlock,
5720
+ cdHandler: handleCd
5721
+ })
5722
+ });
5723
+ if (miscCommandExitCode !== void 0) return miscCommandExitCode;
5410
5724
  throw createCliError("UNKNOWN_COMMAND", { message: `Unknown command: ${command}` });
5411
5725
  } catch (error) {
5412
5726
  const cliError = ensureCliError(error);