sync-worktrees 4.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +133 -55
  2. package/dist/components/WorktreeStatusView.d.ts.map +1 -1
  3. package/dist/constants.d.ts +22 -0
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/errors/index.d.ts +7 -0
  6. package/dist/errors/index.d.ts.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +2052 -448
  10. package/dist/index.js.map +4 -4
  11. package/dist/mcp/context.d.ts +1 -1
  12. package/dist/mcp/context.d.ts.map +1 -1
  13. package/dist/mcp/handlers.d.ts +0 -5
  14. package/dist/mcp/handlers.d.ts.map +1 -1
  15. package/dist/mcp/server.d.ts.map +1 -1
  16. package/dist/mcp/worktree-summary.d.ts.map +1 -1
  17. package/dist/mcp-server.js +2068 -499
  18. package/dist/mcp-server.js.map +4 -4
  19. package/dist/services/InteractiveUIService.d.ts.map +1 -1
  20. package/dist/services/clone-sync.service.d.ts +13 -2
  21. package/dist/services/clone-sync.service.d.ts.map +1 -1
  22. package/dist/services/config-loader.service.d.ts +2 -0
  23. package/dist/services/config-loader.service.d.ts.map +1 -1
  24. package/dist/services/git-maintenance.service.d.ts +44 -0
  25. package/dist/services/git-maintenance.service.d.ts.map +1 -0
  26. package/dist/services/git.service.d.ts +19 -1
  27. package/dist/services/git.service.d.ts.map +1 -1
  28. package/dist/services/removal-audit.service.d.ts +19 -0
  29. package/dist/services/removal-audit.service.d.ts.map +1 -0
  30. package/dist/services/sync-outcome.d.ts +1 -1
  31. package/dist/services/sync-outcome.d.ts.map +1 -1
  32. package/dist/services/trash-migration.service.d.ts +18 -0
  33. package/dist/services/trash-migration.service.d.ts.map +1 -0
  34. package/dist/services/trash-reaper.service.d.ts +18 -0
  35. package/dist/services/trash-reaper.service.d.ts.map +1 -0
  36. package/dist/services/trash.service.d.ts +91 -0
  37. package/dist/services/trash.service.d.ts.map +1 -0
  38. package/dist/services/worktree-metadata.service.d.ts +7 -0
  39. package/dist/services/worktree-metadata.service.d.ts.map +1 -1
  40. package/dist/services/worktree-mode-sync-runner.d.ts +11 -1
  41. package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -1
  42. package/dist/services/worktree-status.service.d.ts +5 -2
  43. package/dist/services/worktree-status.service.d.ts.map +1 -1
  44. package/dist/services/worktree-sync.service.d.ts +16 -0
  45. package/dist/services/worktree-sync.service.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +60 -2
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/sync-metadata.d.ts +6 -0
  49. package/dist/types/sync-metadata.d.ts.map +1 -1
  50. package/dist/utils/atomic-write.d.ts +2 -0
  51. package/dist/utils/atomic-write.d.ts.map +1 -0
  52. package/dist/utils/file-exists.d.ts +2 -0
  53. package/dist/utils/file-exists.d.ts.map +1 -1
  54. package/dist/utils/filename-timestamp.d.ts +2 -0
  55. package/dist/utils/filename-timestamp.d.ts.map +1 -0
  56. package/dist/utils/lock-path.d.ts +1 -0
  57. package/dist/utils/lock-path.d.ts.map +1 -1
  58. package/dist/utils/quarantine.d.ts +2 -0
  59. package/dist/utils/quarantine.d.ts.map +1 -0
  60. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { realpathSync as realpathSync2 } from "fs";
5
- import * as path17 from "path";
5
+ import * as path23 from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import pLimit4 from "p-limit";
8
8
 
@@ -15,6 +15,10 @@ var GIT_CONSTANTS = {
15
15
  COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
16
16
  BARE_DIR_NAME: ".bare",
17
17
  DIVERGED_DIR_NAME: ".diverged",
18
+ REMOVED_DIR_NAME: ".removed",
19
+ TRASH_DIR_NAME: ".trash",
20
+ TRASH_REF_PREFIX: "refs/sync-worktrees/trash/",
21
+ KEEP_REF_PREFIX: "refs/sync-worktrees/keep/",
18
22
  LFS_HEADER: "version https://git-lfs.github.com/spec/",
19
23
  SUBMODULE_STATUS_ADDED: "+",
20
24
  SUBMODULE_STATUS_REMOVED: "-",
@@ -60,7 +64,16 @@ var DEFAULT_CONFIG = {
60
64
  FETCH_TIMEOUT_MS: 3e5,
61
65
  CLONE_TIMEOUT_MS: 9e5,
62
66
  LOCK_STALE_MS: 6e5,
63
- LOCK_UPDATE_MS: 3e4
67
+ LOCK_UPDATE_MS: 3e4,
68
+ MAINTENANCE: {
69
+ ENABLED: true,
70
+ INTERVAL: "7d"
71
+ },
72
+ TRASH: {
73
+ ENABLED: true,
74
+ RETENTION_DAYS: 30,
75
+ MIGRATE_LEGACY: true
76
+ }
64
77
  };
65
78
  var ERROR_MESSAGES = {
66
79
  GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
@@ -95,6 +108,15 @@ var CONFIG_FILE_NAMES = [
95
108
  "sync-worktrees.config.mjs",
96
109
  "sync-worktrees.config.cjs"
97
110
  ];
111
+ var MAINTENANCE_CONSTANTS = {
112
+ STATE_FILENAME: "sync-worktrees-maintenance.json"
113
+ };
114
+ var TRASH_CONSTANTS = {
115
+ MANIFEST_FILENAME: "manifest.json",
116
+ PAYLOAD_DIRNAME: "payload",
117
+ BUNDLE_FILENAME: "commits.bundle",
118
+ SCHEMA_VERSION: 1
119
+ };
98
120
  var METADATA_CONSTANTS = {
99
121
  MAX_HISTORY_ENTRIES: 10,
100
122
  METADATA_FILENAME: "sync-metadata.json",
@@ -154,15 +176,22 @@ var GitOperationError = class extends GitError {
154
176
  super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
155
177
  }
156
178
  };
179
+ var FastForwardError = class extends GitError {
180
+ constructor(branchName, cause) {
181
+ super(`Cannot fast-forward branch '${branchName}'`, "FAST_FORWARD_FAILED", cause);
182
+ this.branchName = branchName;
183
+ }
184
+ branchName;
185
+ };
157
186
  var WorktreeError = class extends SyncWorktreesError {
158
187
  constructor(message, code, cause) {
159
188
  super(message, `WORKTREE_${code}`, cause);
160
189
  }
161
190
  };
162
191
  var WorktreeNotCleanError = class extends WorktreeError {
163
- constructor(path18, reasons) {
164
- super(`Worktree at '${path18}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
165
- this.path = path18;
192
+ constructor(path24, reasons) {
193
+ super(`Worktree at '${path24}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
194
+ this.path = path24;
166
195
  this.reasons = reasons;
167
196
  }
168
197
  path;
@@ -196,6 +225,18 @@ var ConfigFileExistsError = class extends ConfigError {
196
225
  }
197
226
  configPath;
198
227
  };
228
+ var TrashError = class extends SyncWorktreesError {
229
+ constructor(message, code, cause) {
230
+ super(message, `TRASH_${code}`, cause);
231
+ }
232
+ };
233
+ var TrashOperationError = class extends TrashError {
234
+ constructor(operation, details, cause) {
235
+ super(`Trash operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
236
+ this.operation = operation;
237
+ }
238
+ operation;
239
+ };
199
240
 
200
241
  // src/services/config-loader.service.ts
201
242
  import * as path2 from "path";
@@ -222,16 +263,73 @@ function filterBranchesByName(branches, include, exclude) {
222
263
  return result;
223
264
  }
224
265
 
266
+ // src/utils/date-filter.ts
267
+ function parseDuration(durationStr) {
268
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
269
+ if (!match) {
270
+ return null;
271
+ }
272
+ const value = parseInt(match[1], 10);
273
+ const unit = match[2];
274
+ const multipliers = {
275
+ h: 60 * 60 * 1e3,
276
+ // hours
277
+ d: 24 * 60 * 60 * 1e3,
278
+ // days
279
+ w: 7 * 24 * 60 * 60 * 1e3,
280
+ // weeks
281
+ m: 30 * 24 * 60 * 60 * 1e3,
282
+ // months (approximate)
283
+ y: 365 * 24 * 60 * 60 * 1e3
284
+ // years (approximate)
285
+ };
286
+ return value * multipliers[unit];
287
+ }
288
+ function filterBranchesByAge(branches, maxAge) {
289
+ const maxAgeMs = parseDuration(maxAge);
290
+ if (maxAgeMs === null) {
291
+ console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
292
+ return branches;
293
+ }
294
+ const cutoffDate = new Date(Date.now() - maxAgeMs);
295
+ return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
296
+ }
297
+ function formatDuration(durationStr) {
298
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
299
+ if (!match) {
300
+ return durationStr;
301
+ }
302
+ const value = parseInt(match[1], 10);
303
+ const unit = match[2];
304
+ const unitNames = {
305
+ h: value === 1 ? "hour" : "hours",
306
+ d: value === 1 ? "day" : "days",
307
+ w: value === 1 ? "week" : "weeks",
308
+ m: value === 1 ? "month" : "months",
309
+ y: value === 1 ? "year" : "years"
310
+ };
311
+ return `${value} ${unitNames[unit]}`;
312
+ }
313
+
225
314
  // src/utils/file-exists.ts
226
315
  import * as fs from "fs/promises";
227
- async function fileExists(path18) {
316
+ async function fileExists(path24) {
228
317
  try {
229
- await fs.access(path18);
318
+ await fs.access(path24);
230
319
  return true;
231
320
  } catch {
232
321
  return false;
233
322
  }
234
323
  }
324
+ async function probePathExists(path24) {
325
+ try {
326
+ await fs.access(path24);
327
+ return "exists";
328
+ } catch (error) {
329
+ const code = error.code;
330
+ return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
331
+ }
332
+ }
235
333
 
236
334
  // src/utils/git-url.ts
237
335
  function extractRepoNameFromUrl(gitUrl) {
@@ -319,7 +417,8 @@ var CLONE_MODE_CONFLICTING_FIELDS = [
319
417
  "branchExclude",
320
418
  "branchMaxAge",
321
419
  "updateExistingWorktrees",
322
- "bareRepoDir"
420
+ "bareRepoDir",
421
+ "trash"
323
422
  ];
324
423
  var ConfigLoaderService = class {
325
424
  async findConfigUpward(startDir) {
@@ -422,6 +521,12 @@ var ConfigLoaderService = class {
422
521
  if (repoObj.sparseCheckout !== void 0) {
423
522
  this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
424
523
  }
524
+ if (repoObj.maintenance !== void 0) {
525
+ this.validateMaintenanceConfig(repoObj.maintenance, `Repository '${repoObj.name}'`);
526
+ }
527
+ if (repoObj.trash !== void 0) {
528
+ this.validateTrashConfig(repoObj.trash, `Repository '${repoObj.name}'`);
529
+ }
425
530
  this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
426
531
  this.validateRepositoryMode(repoObj, configObj.defaults);
427
532
  });
@@ -458,6 +563,12 @@ var ConfigLoaderService = class {
458
563
  if (defaults.sparseCheckout !== void 0) {
459
564
  this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
460
565
  }
566
+ if (defaults.maintenance !== void 0) {
567
+ this.validateMaintenanceConfig(defaults.maintenance, "defaults");
568
+ }
569
+ if (defaults.trash !== void 0) {
570
+ this.validateTrashConfig(defaults.trash, "defaults");
571
+ }
461
572
  this.validateDepth(defaults.depth, "defaults.depth");
462
573
  if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
463
574
  throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
@@ -485,6 +596,46 @@ var ConfigLoaderService = class {
485
596
  throw new ConfigValidationError(field, "must be a positive safe integer");
486
597
  }
487
598
  }
599
+ validateMaintenanceConfig(value, context) {
600
+ if (value === void 0) return;
601
+ if (typeof value !== "object" || value === null) {
602
+ throw new Error(`'maintenance' in ${context} must be an object`);
603
+ }
604
+ const maintenance = value;
605
+ if (maintenance.enabled !== void 0 && typeof maintenance.enabled !== "boolean") {
606
+ throw new Error(`'maintenance.enabled' in ${context} must be a boolean`);
607
+ }
608
+ if (maintenance.aggressive !== void 0 && typeof maintenance.aggressive !== "boolean") {
609
+ throw new Error(`'maintenance.aggressive' in ${context} must be a boolean`);
610
+ }
611
+ if (maintenance.interval !== void 0) {
612
+ const parsed = typeof maintenance.interval === "string" ? parseDuration(maintenance.interval) : null;
613
+ if (parsed === null || parsed <= 0) {
614
+ throw new Error(
615
+ `'maintenance.interval' in ${context} must be a positive duration string like '7d', '24h', or '2w'`
616
+ );
617
+ }
618
+ }
619
+ }
620
+ validateTrashConfig(value, context) {
621
+ if (value === void 0) return;
622
+ if (typeof value !== "object" || value === null) {
623
+ throw new Error(`'trash' in ${context} must be an object`);
624
+ }
625
+ const trash = value;
626
+ if (trash.enabled !== void 0 && typeof trash.enabled !== "boolean") {
627
+ throw new Error(`'trash.enabled' in ${context} must be a boolean`);
628
+ }
629
+ if (trash.migrateLegacy !== void 0 && typeof trash.migrateLegacy !== "boolean") {
630
+ throw new Error(`'trash.migrateLegacy' in ${context} must be a boolean`);
631
+ }
632
+ if (trash.retentionDays !== void 0 && (typeof trash.retentionDays !== "number" || !Number.isFinite(trash.retentionDays) || trash.retentionDays <= 0)) {
633
+ throw new Error(`'trash.retentionDays' in ${context} must be a positive number`);
634
+ }
635
+ if (trash.warnSizeBytes !== void 0 && (typeof trash.warnSizeBytes !== "number" || !Number.isFinite(trash.warnSizeBytes) || trash.warnSizeBytes <= 0)) {
636
+ throw new Error(`'trash.warnSizeBytes' in ${context} must be a positive number`);
637
+ }
638
+ }
488
639
  validateRetryConfig(value, context) {
489
640
  if (typeof value !== "object" || value === null) {
490
641
  throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
@@ -756,6 +907,18 @@ var ConfigLoaderService = class {
756
907
  if (sparse) {
757
908
  resolved.sparseCheckout = sparse;
758
909
  }
910
+ if (repo.maintenance || defaults?.maintenance) {
911
+ resolved.maintenance = {
912
+ ...defaults?.maintenance || {},
913
+ ...repo.maintenance || {}
914
+ };
915
+ }
916
+ if (repo.trash || defaults?.trash) {
917
+ resolved.trash = {
918
+ ...defaults?.trash || {},
919
+ ...repo.trash || {}
920
+ };
921
+ }
759
922
  return resolved;
760
923
  }
761
924
  isDuplicateRepoUrl(repo, all, defaults) {
@@ -819,7 +982,7 @@ var ConfigLoaderService = class {
819
982
 
820
983
  // src/services/InteractiveUIService.tsx
821
984
  import React8 from "react";
822
- import * as path14 from "path";
985
+ import * as path20 from "path";
823
986
  import { render } from "ink";
824
987
  import * as cron2 from "node-cron";
825
988
  import pLimit3 from "p-limit";
@@ -1512,7 +1675,7 @@ var getStatusFlags = (status) => {
1512
1675
  }
1513
1676
  if (status.hasUnpushedCommits) {
1514
1677
  flags.push(
1515
- /* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "cyan" }, "\u2191")
1678
+ status.fullyPushedUpstreamDeleted ? /* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "green" }, "\u21E1") : /* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "cyan" }, "\u2191")
1516
1679
  );
1517
1680
  }
1518
1681
  if (status.hasStashedChanges) {
@@ -1545,7 +1708,9 @@ var getStatusSummary = (status) => {
1545
1708
  if (fileCount > 0) parts.push(`${fileCount} changed`);
1546
1709
  }
1547
1710
  if (status.hasUnpushedCommits && details?.unpushedCommitCount) {
1548
- parts.push(`${details.unpushedCommitCount} unpushed`);
1711
+ parts.push(
1712
+ status.fullyPushedUpstreamDeleted ? "pushed, remote branch deleted" : `${details.unpushedCommitCount} unpushed`
1713
+ );
1549
1714
  }
1550
1715
  if (status.hasStashedChanges && details?.stashCount) {
1551
1716
  parts.push(`${details.stashCount} stash`);
@@ -1807,7 +1972,7 @@ var WorktreeStatusView = ({
1807
1972
  const renderDetailPanel = (entry) => {
1808
1973
  const { status } = entry;
1809
1974
  const details = status.details;
1810
- return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), details && /* @__PURE__ */ React5.createElement(React5.Fragment, null, details.modifiedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified: ", details.modifiedFiles), details.deletedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Deleted: ", details.deletedFiles), details.createdFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " Created: ", details.createdFiles), details.renamedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " Renamed: ", details.renamedFiles), details.untrackedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, " Untracked: ", details.untrackedFiles), details.conflictedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Conflicted: ", details.conflictedFiles), (details.unpushedCommitCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, " Unpushed commits: ", details.unpushedCommitCount), (details.stashCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " Stashes: ", details.stashCount), details.operationType && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Operation: ", details.operationType), details.modifiedSubmodules && details.modifiedSubmodules.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified submodules: ", details.modifiedSubmodules.join(", "))), status.upstreamGone && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Remote branch has been deleted"), status.reasons.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Reasons: ", status.reasons.join(", ")));
1975
+ return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), details && /* @__PURE__ */ React5.createElement(React5.Fragment, null, details.modifiedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified: ", details.modifiedFiles), details.deletedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Deleted: ", details.deletedFiles), details.createdFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " Created: ", details.createdFiles), details.renamedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " Renamed: ", details.renamedFiles), details.untrackedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, " Untracked: ", details.untrackedFiles), details.conflictedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Conflicted: ", details.conflictedFiles), (details.unpushedCommitCount ?? 0) > 0 && (status.fullyPushedUpstreamDeleted ? /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " ", "Fully pushed before remote branch deletion (", details.unpushedCommitCount, " commit", details.unpushedCommitCount === 1 ? "" : "s", " not on any remote \u2014 likely squash-merged)") : /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, " Unpushed commits: ", details.unpushedCommitCount)), (details.stashCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " Stashes: ", details.stashCount), details.operationType && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Operation: ", details.operationType), details.modifiedSubmodules && details.modifiedSubmodules.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified submodules: ", details.modifiedSubmodules.join(", "))), status.upstreamGone && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Remote branch has been deleted"), status.reasons.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Reasons: ", status.reasons.join(", ")));
1811
1976
  };
1812
1977
  const renderDivergedDetailPanel = (entry) => {
1813
1978
  return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Original branch: ", entry.originalBranch), entry.divergedAt && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Diverged: ", entry.divergedAt), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Size: ", entry.sizeFormatted));
@@ -2221,6 +2386,31 @@ var App_default = App;
2221
2386
  // src/services/worktree-sync.service.ts
2222
2387
  import pLimit2 from "p-limit";
2223
2388
 
2389
+ // src/utils/lock-path.ts
2390
+ import { createHash } from "crypto";
2391
+ import * as os from "os";
2392
+ import * as path3 from "path";
2393
+ function getCloneModeLockTarget(config) {
2394
+ const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
2395
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
2396
+ const dir = path3.join(stateBase, "sync-worktrees", "locks");
2397
+ return { dir, file: `${hash}.lock` };
2398
+ }
2399
+ function getRemovalAuditLogPath(config) {
2400
+ const name = config.name;
2401
+ const configDir = config.__configFileDir;
2402
+ const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
2403
+ if (configDir) {
2404
+ return path3.join(
2405
+ configDir,
2406
+ ".sync-worktrees-state",
2407
+ `${sanitizeNameForPath(name ?? "repo", "removal audit log name")}-${hash}-removals.jsonl`
2408
+ );
2409
+ }
2410
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
2411
+ return path3.join(stateBase, "sync-worktrees", "removals", `${hash}.jsonl`);
2412
+ }
2413
+
2224
2414
  // src/utils/retry.ts
2225
2415
  var DEFAULT_OPTIONS = {
2226
2416
  maxAttempts: "unlimited",
@@ -2291,7 +2481,7 @@ async function retry(fn, options = {}) {
2291
2481
  const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
2292
2482
  const delay = baseDelay + jitter;
2293
2483
  opts.onRetry(error, attempt, lfsContext);
2294
- await new Promise((resolve12) => setTimeout(resolve12, delay));
2484
+ await new Promise((resolve14) => setTimeout(resolve14, delay));
2295
2485
  attempt++;
2296
2486
  }
2297
2487
  }
@@ -2362,7 +2552,7 @@ var PhaseTimer = class {
2362
2552
  return results;
2363
2553
  }
2364
2554
  };
2365
- function formatDuration(ms) {
2555
+ function formatDuration2(ms) {
2366
2556
  if (ms < 1e3) {
2367
2557
  return `${ms}ms`;
2368
2558
  }
@@ -2384,7 +2574,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2384
2574
  }
2385
2575
  });
2386
2576
  table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
2387
- table.push(["Total Sync", formatDuration(totalDuration), ""]);
2577
+ table.push(["Total Sync", formatDuration2(totalDuration), ""]);
2388
2578
  for (let i = 0; i < phaseResults.length; i++) {
2389
2579
  const result = phaseResults[i];
2390
2580
  const isLast = i === phaseResults.length - 1;
@@ -2392,14 +2582,14 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2392
2582
  const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
2393
2583
  const name = ` ${prefix} ${result.name}${countStr}`;
2394
2584
  const efficiency = result.efficiency ? `${result.efficiency}%` : "";
2395
- table.push([name, formatDuration(result.duration), efficiency]);
2585
+ table.push([name, formatDuration2(result.duration), efficiency]);
2396
2586
  }
2397
2587
  return table.toString();
2398
2588
  }
2399
2589
 
2400
2590
  // src/services/clone-sync.service.ts
2401
2591
  import * as fs3 from "fs/promises";
2402
- import * as path4 from "path";
2592
+ import * as path5 from "path";
2403
2593
  import simpleGit from "simple-git";
2404
2594
 
2405
2595
  // src/utils/git-progress.ts
@@ -2428,7 +2618,7 @@ function makeGitProgressHandler(logger, emitProgress) {
2428
2618
 
2429
2619
  // src/services/file-copy.service.ts
2430
2620
  import * as fs2 from "fs/promises";
2431
- import * as path3 from "path";
2621
+ import * as path4 from "path";
2432
2622
  import { glob } from "glob";
2433
2623
  var DEFAULT_IGNORE_PATTERNS = [
2434
2624
  "**/node_modules/**",
@@ -2455,8 +2645,8 @@ var FileCopyService = class {
2455
2645
  }
2456
2646
  const filesToCopy = await this.expandPatterns(sourceDir, patterns);
2457
2647
  for (const relativePath of filesToCopy) {
2458
- const sourcePath = path3.join(sourceDir, relativePath);
2459
- const destPath = path3.join(destDir, relativePath);
2648
+ const sourcePath = path4.join(sourceDir, relativePath);
2649
+ const destPath = path4.join(destDir, relativePath);
2460
2650
  try {
2461
2651
  const copied = await this.copyFile(sourcePath, destPath);
2462
2652
  if (copied) {
@@ -2495,7 +2685,7 @@ var FileCopyService = class {
2495
2685
  if (await fileExists(destPath)) {
2496
2686
  return false;
2497
2687
  }
2498
- const destDir = path3.dirname(destPath);
2688
+ const destDir = path4.dirname(destPath);
2499
2689
  await fs2.mkdir(destDir, { recursive: true });
2500
2690
  await fs2.copyFile(sourcePath, destPath);
2501
2691
  return true;
@@ -2557,7 +2747,7 @@ var BranchCreatedActionsService = class {
2557
2747
  function formatCloneSkipReason(reason) {
2558
2748
  switch (reason.kind) {
2559
2749
  case "branch_mismatch":
2560
- return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
2750
+ return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start) \u2014 update 'branch' in the config or switch the clone back` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' \u2014 update 'branch' in the config or switch the clone back`;
2561
2751
  case "head_unreadable":
2562
2752
  return `could not read HEAD: ${reason.error}`;
2563
2753
  case "dirty_tree":
@@ -2628,14 +2818,14 @@ var SyncOutcomeAccumulator = class {
2628
2818
  this.actions.push(action);
2629
2819
  this.counts[countKeyFor(action)]++;
2630
2820
  }
2631
- recordCreated(branch, path18) {
2632
- this.add({ kind: "created", branch, path: path18 });
2821
+ recordCreated(branch, path24) {
2822
+ this.add({ kind: "created", branch, path: path24 });
2633
2823
  }
2634
- recordRemoved(branch, path18) {
2635
- this.add({ kind: "removed", branch, path: path18 });
2824
+ recordRemoved(branch, path24, warning) {
2825
+ this.add({ kind: "removed", branch, path: path24, ...warning !== void 0 && { warning } });
2636
2826
  }
2637
- recordUpdated(branch, path18, reason) {
2638
- this.add({ kind: "updated", branch, path: path18, reason });
2827
+ recordUpdated(branch, path24, reason) {
2828
+ this.add({ kind: "updated", branch, path: path24, reason });
2639
2829
  }
2640
2830
  recordNoop(scope, reason, details) {
2641
2831
  this.add({ kind: "noop", scope, reason, ...details });
@@ -2643,8 +2833,8 @@ var SyncOutcomeAccumulator = class {
2643
2833
  recordSkipped(scope, reason, details) {
2644
2834
  this.add({ kind: "skipped", scope, reason, ...details });
2645
2835
  }
2646
- recordPreservedDiverged(branch, path18, preservedPath) {
2647
- this.add({ kind: "preserved-diverged", branch, path: path18, preservedPath });
2836
+ recordPreservedDiverged(branch, path24, preservedPath) {
2837
+ this.add({ kind: "preserved-diverged", branch, path: path24, preservedPath });
2648
2838
  }
2649
2839
  recordFailed(scope, error, details = {}) {
2650
2840
  this.add({ kind: "failed", scope, error, ...details });
@@ -2687,7 +2877,6 @@ function cloneSkipToOutcomeAction(reason, details = {}) {
2687
2877
  }
2688
2878
 
2689
2879
  // src/services/clone-sync.service.ts
2690
- var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
2691
2880
  var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
2692
2881
  var CloneSyncService = class {
2693
2882
  constructor(config, gitService, logger, options = {}) {
@@ -2721,8 +2910,8 @@ var CloneSyncService = class {
2721
2910
  this.pendingInitSkip = null;
2722
2911
  }
2723
2912
  async getWorktrees() {
2724
- const worktreeDir = path4.resolve(this.config.worktreeDir);
2725
- if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
2913
+ const worktreeDir = path5.resolve(this.config.worktreeDir);
2914
+ if (!await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
2726
2915
  return [];
2727
2916
  }
2728
2917
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
@@ -2806,40 +2995,27 @@ var CloneSyncService = class {
2806
2995
  return env;
2807
2996
  }
2808
2997
  buildCloneArgs(branch) {
2809
- const args = ["--branch", branch, "--progress"];
2998
+ const args = ["--branch", branch, "--single-branch", "--no-tags", "--progress"];
2810
2999
  if (this.config.depth !== void 0) {
2811
- args.push("--depth", String(this.config.depth), "--no-single-branch");
3000
+ args.push("--depth", String(this.config.depth));
2812
3001
  }
2813
3002
  return args;
2814
3003
  }
2815
- async buildFetchArgs(git) {
2816
- const args = ["origin", "--prune", "--progress"];
3004
+ getBranchRefspec(branch) {
3005
+ return `+refs/heads/${branch}:refs/remotes/origin/${branch}`;
3006
+ }
3007
+ async buildFetchArgs(git, branch) {
3008
+ const args = ["origin", "--prune", "--no-tags", "--progress"];
2817
3009
  if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
2818
3010
  args.push("--depth", String(this.config.depth));
2819
3011
  }
3012
+ args.push(this.getBranchRefspec(branch));
2820
3013
  return args;
2821
3014
  }
2822
- async ensureAllRemoteBranchesRefspec(git) {
2823
- let fetchRefspecs = [];
2824
- try {
2825
- const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
2826
- fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2827
- } catch {
2828
- fetchRefspecs = [];
2829
- }
2830
- if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
2831
- const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
2832
- this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
2833
- await git.raw(["remote", "set-branches", "origin", "*"]);
2834
- for (const refspec of customRefspecs) {
2835
- await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
2836
- }
2837
- }
2838
- isOriginRemoteBranchTrackingRefspec(refspec) {
2839
- const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
2840
- if (withoutForce.startsWith("^")) return false;
2841
- const [source, destination] = withoutForce.split(":");
2842
- return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
3015
+ async configureSingleBranchRemote(git, branch) {
3016
+ await git.raw(["config", "--replace-all", "remote.origin.fetch", this.getBranchRefspec(branch)]);
3017
+ await git.raw(["config", "--replace-all", "remote.origin.tagOpt", "--no-tags"]);
3018
+ await this.deleteStaleRemoteTrackingRefs(git, branch);
2843
3019
  }
2844
3020
  recordMissingRemoteRefSkip(branch) {
2845
3021
  this.recordSkip(
@@ -2848,7 +3024,10 @@ var CloneSyncService = class {
2848
3024
  `Skipping '${this.repoName}': origin/${branch} is missing`
2849
3025
  );
2850
3026
  }
2851
- async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
3027
+ async fetchWithRecovery(git, fetchArgs, worktreeDir, branch, recordSkip = true) {
3028
+ const recordMissing = () => {
3029
+ if (recordSkip) this.recordMissingRemoteRefSkip(branch);
3030
+ };
2852
3031
  try {
2853
3032
  await git.fetch(fetchArgs);
2854
3033
  return { skipped: false };
@@ -2865,14 +3044,14 @@ var CloneSyncService = class {
2865
3044
  return { skipped: false };
2866
3045
  } catch (retryError) {
2867
3046
  if (isMissingRemoteRefError(getErrorMessage(retryError))) {
2868
- this.recordMissingRemoteRefSkip(branch);
3047
+ recordMissing();
2869
3048
  return { skipped: true };
2870
3049
  }
2871
3050
  throw retryError;
2872
3051
  }
2873
3052
  }
2874
3053
  if (isMissingRemoteRefError(message)) {
2875
- this.recordMissingRemoteRefSkip(branch);
3054
+ recordMissing();
2876
3055
  return { skipped: true };
2877
3056
  }
2878
3057
  throw fetchError;
@@ -2900,7 +3079,7 @@ var CloneSyncService = class {
2900
3079
  this.logger.info(
2901
3080
  `[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
2902
3081
  );
2903
- await git.fetch(["--unshallow"]);
3082
+ await git.fetch(["--unshallow", "--no-tags"]);
2904
3083
  }
2905
3084
  getDeepenTargets() {
2906
3085
  const configuredDepth = this.config.depth;
@@ -2920,8 +3099,9 @@ var CloneSyncService = class {
2920
3099
  "--depth",
2921
3100
  String(targetDepth),
2922
3101
  "--prune",
3102
+ "--no-tags",
2923
3103
  "--progress",
2924
- `+refs/heads/${branch}:refs/remotes/origin/${branch}`
3104
+ this.getBranchRefspec(branch)
2925
3105
  ]);
2926
3106
  }
2927
3107
  async resolveBranch() {
@@ -2938,6 +3118,153 @@ var CloneSyncService = class {
2938
3118
  this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
2939
3119
  return this.resolvedBranch;
2940
3120
  }
3121
+ parseLsRemoteHeads(output) {
3122
+ return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => line.split(/\s+/)[1] ?? "").filter((ref) => ref.startsWith("refs/heads/")).map((ref) => ref.slice("refs/heads/".length)).filter((branch) => branch.length > 0);
3123
+ }
3124
+ async getRemoteBranches() {
3125
+ const worktreeDir = path5.resolve(this.config.worktreeDir);
3126
+ const repoArg = await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR)) ? "origin" : this.config.repoUrl;
3127
+ const git = repoArg === "origin" ? this.clientFor(worktreeDir, this.getFetchTimeoutMs()) : simpleGit(this.buildGitOptions(this.getFetchTimeoutMs())).env(this.buildGitEnv());
3128
+ const output = await git.raw(["ls-remote", "--heads", repoArg]);
3129
+ return this.parseLsRemoteHeads(output);
3130
+ }
3131
+ async localBranchExists(git, branch) {
3132
+ try {
3133
+ await git.raw(["show-ref", "--verify", `refs/heads/${branch}`]);
3134
+ return true;
3135
+ } catch {
3136
+ return false;
3137
+ }
3138
+ }
3139
+ async localBranchCanFastForward(git, branch) {
3140
+ const localRef = `refs/heads/${branch}`;
3141
+ const remoteRef = `refs/remotes/origin/${branch}`;
3142
+ let localSha;
3143
+ let remoteSha;
3144
+ try {
3145
+ localSha = (await git.raw(["rev-parse", localRef])).trim();
3146
+ remoteSha = (await git.raw(["rev-parse", remoteRef])).trim();
3147
+ } catch {
3148
+ return false;
3149
+ }
3150
+ if (localSha === remoteSha) return true;
3151
+ try {
3152
+ const mergeBase = (await git.raw(["merge-base", localRef, remoteRef])).trim();
3153
+ return mergeBase === localSha;
3154
+ } catch {
3155
+ return false;
3156
+ }
3157
+ }
3158
+ async deleteRemoteTrackingRef(git, refName) {
3159
+ try {
3160
+ await git.raw(["update-ref", "-d", refName]);
3161
+ } catch {
3162
+ }
3163
+ }
3164
+ async deleteStaleRemoteTrackingRefs(git, branch) {
3165
+ let refsOutput;
3166
+ try {
3167
+ refsOutput = await git.raw(["for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
3168
+ } catch {
3169
+ return;
3170
+ }
3171
+ const keepRef = `refs/remotes/origin/${branch}`;
3172
+ const refsToDelete = refsOutput.split(/\r?\n/).map((ref) => ref.trim()).filter((ref) => ref && ref !== keepRef && ref !== "refs/remotes/origin/HEAD");
3173
+ for (const ref of refsToDelete) {
3174
+ await this.deleteRemoteTrackingRef(git, ref);
3175
+ }
3176
+ }
3177
+ async restoreBranchAfterCheckoutFailure(git, previousBranch, attemptedBranch) {
3178
+ if (!previousBranch || previousBranch === "HEAD" || previousBranch === attemptedBranch) return;
3179
+ try {
3180
+ await git.raw(["switch", previousBranch]);
3181
+ } catch (error) {
3182
+ this.logger.warn(
3183
+ `Failed to restore '${this.repoName}' to '${previousBranch}' after checkout failure: ${getErrorMessage(error)}`
3184
+ );
3185
+ }
3186
+ }
3187
+ async checkoutBranch(branch, options = {}) {
3188
+ if (!this.initialized) {
3189
+ await this.initialize();
3190
+ }
3191
+ const targetBranch = await this.resolveBranch();
3192
+ if (branch !== targetBranch && !options.allowConfigDrift) {
3193
+ throw new ConfigError(
3194
+ this.config.branch ? `Cannot switch '${this.repoName}' to '${branch}': clone mode tracks the configured branch '${targetBranch}'. Update 'branch' in the config file first, then run checkout to converge.` : `Cannot switch '${this.repoName}' to '${branch}': no 'branch' is configured, so this clone tracks the remote default branch '${targetBranch}'. Set branch: "${branch}" in the config file first.`,
3195
+ "CLONE_BRANCH_MISMATCH"
3196
+ );
3197
+ }
3198
+ const worktreeDir = this.config.worktreeDir;
3199
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
3200
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
3201
+ if (originMismatch) {
3202
+ throw new ConfigError(
3203
+ `Cannot switch '${this.repoName}' to '${branch}': ${originMismatch.progressDetail}.`,
3204
+ "ORIGIN_MISMATCH"
3205
+ );
3206
+ }
3207
+ const currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
3208
+ if (currentBranch === "HEAD") {
3209
+ throw new GitOperationError(
3210
+ "checkout",
3211
+ `'${this.repoName}' is on a detached HEAD; check out a branch manually (preserving any local commits) before switching the tracked branch`
3212
+ );
3213
+ }
3214
+ if (currentBranch === branch) {
3215
+ await this.configureSingleBranchRemote(git, branch);
3216
+ this.resolvedBranch = branch;
3217
+ this.pendingInitSkip = null;
3218
+ this.warnConfigDriftAfterCheckout(branch, targetBranch);
3219
+ return;
3220
+ }
3221
+ const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
3222
+ if (!isClean) {
3223
+ throw new WorktreeNotCleanError(worktreeDir, ["working tree has local changes"]);
3224
+ }
3225
+ await this.unshallowIfDepthRemoved(git);
3226
+ const fetchArgs = await this.buildFetchArgs(git, branch);
3227
+ if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch, false)).skipped) {
3228
+ throw new GitOperationError("checkout", `origin/${branch} is missing for '${this.repoName}'`);
3229
+ }
3230
+ if (!await this.hasRemoteBranch(git, branch)) {
3231
+ throw new GitOperationError(
3232
+ "checkout",
3233
+ `origin/${branch} did not materialize after fetch for '${this.repoName}'`
3234
+ );
3235
+ }
3236
+ if (await this.localBranchExists(git, branch)) {
3237
+ if (!await this.localBranchCanFastForward(git, branch)) {
3238
+ throw new FastForwardError(branch);
3239
+ }
3240
+ let switched = false;
3241
+ try {
3242
+ await git.raw(["switch", branch]);
3243
+ switched = true;
3244
+ await git.merge([`origin/${branch}`, "--ff-only"]);
3245
+ } catch (error) {
3246
+ if (switched) {
3247
+ await this.restoreBranchAfterCheckoutFailure(git, currentBranch, branch);
3248
+ }
3249
+ throw error;
3250
+ }
3251
+ } else {
3252
+ await git.raw(["switch", "-c", branch, "--track", `origin/${branch}`]);
3253
+ }
3254
+ await this.configureSingleBranchRemote(git, branch);
3255
+ this.resolvedBranch = branch;
3256
+ this.pendingInitSkip = null;
3257
+ this.warnConfigDriftAfterCheckout(branch, targetBranch);
3258
+ }
3259
+ // resolvedBranch keeps in-session syncs on the new branch, but the config
3260
+ // file still names the old one: the next process start will soft-skip with
3261
+ // branch_mismatch on every tick until the config is updated.
3262
+ warnConfigDriftAfterCheckout(branch, targetBranch) {
3263
+ if (branch === targetBranch) return;
3264
+ this.logger.warn(
3265
+ `\u26A0\uFE0F '${this.repoName}' now tracks '${branch}', but the config ${this.config.branch ? `still says branch '${targetBranch}'` : `resolves the remote default '${targetBranch}'`}. Set branch: "${branch}" in the config file \u2014 after a restart every sync will soft-skip with branch_mismatch until it matches.`
3266
+ );
3267
+ }
2941
3268
  async initialize(outcome) {
2942
3269
  return this.withOutcome(outcome, () => this.initializeInternal());
2943
3270
  }
@@ -2961,7 +3288,7 @@ var CloneSyncService = class {
2961
3288
  return;
2962
3289
  }
2963
3290
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2964
- await this.ensureAllRemoteBranchesRefspec(git);
3291
+ await this.configureSingleBranchRemote(git, branch);
2965
3292
  this.initialized = true;
2966
3293
  this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
2967
3294
  return;
@@ -2989,7 +3316,7 @@ var CloneSyncService = class {
2989
3316
  throw error;
2990
3317
  }
2991
3318
  const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2992
- await this.ensureAllRemoteBranchesRefspec(worktreeGit);
3319
+ await this.configureSingleBranchRemote(worktreeGit, branch);
2993
3320
  this.logger.info(`\u2705 Clone successful.`);
2994
3321
  this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
2995
3322
  if (this.config.sparseCheckout) {
@@ -3077,7 +3404,7 @@ var CloneSyncService = class {
3077
3404
  return;
3078
3405
  }
3079
3406
  const looksIncomplete = entries.every((e) => e.startsWith("."));
3080
- const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
3407
+ const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
3081
3408
  if (looksIncomplete && !hasUsableGit) {
3082
3409
  try {
3083
3410
  await fs3.rm(worktreeDir, { recursive: true, force: true });
@@ -3092,7 +3419,7 @@ var CloneSyncService = class {
3092
3419
  }
3093
3420
  }
3094
3421
  getInitMarkerPath(worktreeDir) {
3095
- return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
3422
+ return path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
3096
3423
  }
3097
3424
  async runInitialFileCopy(worktreeDir, branch) {
3098
3425
  const marker = this.getInitMarkerPath(worktreeDir);
@@ -3144,7 +3471,7 @@ var CloneSyncService = class {
3144
3471
  if (currentBranch !== branch) {
3145
3472
  this.recordSkip(
3146
3473
  { kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
3147
- `Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
3474
+ `Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge. Update 'branch' in the config or switch the clone back.`,
3148
3475
  `Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
3149
3476
  );
3150
3477
  return;
@@ -3159,13 +3486,13 @@ var CloneSyncService = class {
3159
3486
  return;
3160
3487
  }
3161
3488
  await this.unshallowIfDepthRemoved(git);
3162
- await this.ensureAllRemoteBranchesRefspec(git);
3163
- const fetchArgs = await this.buildFetchArgs(git);
3164
- this.emitProgress({ phase: "fetch", message: `Fetching origin branches for '${this.repoName}'` });
3489
+ await this.configureSingleBranchRemote(git, branch);
3490
+ const fetchArgs = await this.buildFetchArgs(git, branch);
3491
+ this.emitProgress({ phase: "fetch", message: `Fetching origin/${branch} for '${this.repoName}'` });
3165
3492
  if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
3166
3493
  return;
3167
3494
  }
3168
- this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
3495
+ this.emitProgress({ phase: "fetch", message: `Fetched origin/${branch} for '${this.repoName}'` });
3169
3496
  if (!await this.hasRemoteBranch(git, branch)) {
3170
3497
  this.recordSkip(
3171
3498
  { kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
@@ -3255,50 +3582,39 @@ var CloneSyncService = class {
3255
3582
  }
3256
3583
  };
3257
3584
 
3258
- // src/services/git.service.ts
3259
- import * as fs6 from "fs/promises";
3260
- import * as path8 from "path";
3261
- import simpleGit5 from "simple-git";
3585
+ // src/services/git-maintenance.service.ts
3586
+ import * as fs5 from "fs/promises";
3587
+ import * as path6 from "path";
3588
+ import simpleGit2 from "simple-git";
3262
3589
 
3263
- // src/utils/worktree-list-parser.ts
3264
- function parseWorktreeListPorcelain(output) {
3265
- const worktrees = [];
3266
- let current = {};
3267
- const flush = () => {
3268
- if (!current.path) {
3269
- current = {};
3270
- return;
3590
+ // src/utils/atomic-write.ts
3591
+ import * as fs4 from "fs/promises";
3592
+ async function atomicWriteFile(filePath, content) {
3593
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3594
+ let renamed = false;
3595
+ try {
3596
+ const handle = await fs4.open(tmpPath, "w");
3597
+ try {
3598
+ await handle.writeFile(content, "utf-8");
3599
+ await handle.sync();
3600
+ } finally {
3601
+ await handle.close();
3271
3602
  }
3272
- worktrees.push({
3273
- path: current.path,
3274
- branch: current.branch ?? null,
3275
- head: current.head ?? null,
3276
- detached: current.detached ?? false,
3277
- prunable: current.prunable ?? false,
3278
- locked: current.locked ?? false
3279
- });
3280
- current = {};
3281
- };
3282
- for (const line of output.split("\n")) {
3283
- if (line.startsWith("worktree ")) {
3284
- flush();
3285
- current.path = line.substring("worktree ".length);
3286
- } else if (line.startsWith("branch ")) {
3287
- current.branch = line.substring("branch ".length).replace("refs/heads/", "");
3288
- } else if (line.startsWith("HEAD ")) {
3289
- current.head = line.substring("HEAD ".length);
3290
- } else if (line === "detached") {
3291
- current.detached = true;
3292
- } else if (line === "prunable" || line.startsWith("prunable ")) {
3293
- current.prunable = true;
3294
- } else if (line === "locked" || line.startsWith("locked ")) {
3295
- current.locked = true;
3296
- } else if (line.trim() === "") {
3297
- flush();
3603
+ try {
3604
+ await fs4.rename(tmpPath, filePath);
3605
+ renamed = true;
3606
+ } catch (err) {
3607
+ if (err.code === ERROR_MESSAGES.EXDEV) {
3608
+ await fs4.copyFile(tmpPath, filePath);
3609
+ } else {
3610
+ throw err;
3611
+ }
3612
+ }
3613
+ } finally {
3614
+ if (!renamed) {
3615
+ await fs4.unlink(tmpPath).catch(() => void 0);
3298
3616
  }
3299
3617
  }
3300
- flush();
3301
- return worktrees;
3302
3618
  }
3303
3619
 
3304
3620
  // src/services/logger.service.ts
@@ -3400,17 +3716,198 @@ function defaultConsoleOutput(msg, level) {
3400
3716
  else console.log(msg);
3401
3717
  }
3402
3718
 
3403
- // src/services/sparse-checkout.service.ts
3404
- import * as path5 from "path";
3405
- import simpleGit2 from "simple-git";
3406
- var SparseCheckoutService = class {
3719
+ // src/services/git-maintenance.service.ts
3720
+ var GitMaintenanceService = class {
3721
+ constructor(config, gitService, logger, gitFactory = (cwd) => simpleGit2(cwd)) {
3722
+ this.config = config;
3723
+ this.gitService = gitService;
3724
+ this.logger = logger ?? Logger.createDefault();
3725
+ this.gitFactory = gitFactory;
3726
+ }
3727
+ config;
3728
+ gitService;
3729
+ logger;
3730
+ gitFactory;
3731
+ updateLogger(logger) {
3732
+ this.logger = logger;
3733
+ }
3734
+ isEnabled() {
3735
+ return this.config.maintenance?.enabled ?? DEFAULT_CONFIG.MAINTENANCE.ENABLED;
3736
+ }
3737
+ getIntervalMs() {
3738
+ const fallback = parseDuration(DEFAULT_CONFIG.MAINTENANCE.INTERVAL);
3739
+ const raw = this.config.maintenance?.interval;
3740
+ if (raw === void 0) {
3741
+ return fallback;
3742
+ }
3743
+ const parsed = parseDuration(raw);
3744
+ if (parsed === null || parsed <= 0) {
3745
+ this.logger.warn(`Invalid maintenance.interval '${raw}', using default ${DEFAULT_CONFIG.MAINTENANCE.INTERVAL}.`);
3746
+ return fallback;
3747
+ }
3748
+ return parsed;
3749
+ }
3750
+ resolveTarget() {
3751
+ if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
3752
+ const cwd = path6.resolve(this.config.worktreeDir);
3753
+ return { cwd, gitDir: path6.join(cwd, PATH_CONSTANTS.GIT_DIR) };
3754
+ }
3755
+ const bare = this.gitService.getBareRepoPath();
3756
+ return { cwd: bare, gitDir: bare };
3757
+ }
3758
+ getStatePath(gitDir) {
3759
+ return path6.join(gitDir, MAINTENANCE_CONSTANTS.STATE_FILENAME);
3760
+ }
3761
+ async readState(statePath) {
3762
+ try {
3763
+ const parsed = JSON.parse(await fs5.readFile(statePath, "utf-8"));
3764
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3765
+ return {};
3766
+ }
3767
+ return { ...parsed };
3768
+ } catch {
3769
+ return {};
3770
+ }
3771
+ }
3772
+ async writeState(statePath, state) {
3773
+ try {
3774
+ await atomicWriteFile(statePath, JSON.stringify(state, null, 2));
3775
+ } catch (error) {
3776
+ this.logger.warn(`Failed to persist maintenance state: ${getErrorMessage(error)}`);
3777
+ }
3778
+ }
3779
+ isDue(state, now) {
3780
+ if (!state.lastAttemptAt) {
3781
+ return true;
3782
+ }
3783
+ const last = new Date(state.lastAttemptAt).getTime();
3784
+ if (Number.isNaN(last)) {
3785
+ return true;
3786
+ }
3787
+ return now - last >= this.getIntervalMs();
3788
+ }
3789
+ /**
3790
+ * Run `git gc` if maintenance is enabled and due. MUST be called while the
3791
+ * repository operation lock is already held. Never throws: a gc failure is
3792
+ * recorded and warned so it cannot fail the surrounding sync. The attempt
3793
+ * timestamp is persisted even on failure, so a perpetually-failing gc is
3794
+ * throttled instead of retried every tick.
3795
+ */
3796
+ async runIfDueUnlocked(now = Date.now()) {
3797
+ if (!this.isEnabled()) {
3798
+ return;
3799
+ }
3800
+ try {
3801
+ const { cwd, gitDir } = this.resolveTarget();
3802
+ try {
3803
+ await fs5.access(gitDir);
3804
+ } catch {
3805
+ return;
3806
+ }
3807
+ const statePath = this.getStatePath(gitDir);
3808
+ const state = await this.readState(statePath);
3809
+ if (!this.isDue(state, now)) {
3810
+ return;
3811
+ }
3812
+ const aggressive = this.config.maintenance?.aggressive ?? false;
3813
+ const args = aggressive ? ["gc", "--prune=now"] : ["gc"];
3814
+ const nowIso = new Date(now).toISOString();
3815
+ state.lastAttemptAt = nowIso;
3816
+ this.logger.info(`\u{1F9F9} Running git ${args.join(" ")} (maintenance)...`);
3817
+ try {
3818
+ await this.gitFactory(cwd).raw(args);
3819
+ state.lastSuccessAt = nowIso;
3820
+ delete state.lastError;
3821
+ this.logger.info("\u{1F9F9} Maintenance complete.");
3822
+ } catch (error) {
3823
+ state.lastFailureAt = nowIso;
3824
+ state.lastError = getErrorMessage(error);
3825
+ this.logger.warn(`\u26A0\uFE0F Maintenance (git ${args.join(" ")}) failed: ${state.lastError}`);
3826
+ } finally {
3827
+ await this.writeState(statePath, state);
3828
+ }
3829
+ } catch (error) {
3830
+ this.logger.warn(`\u26A0\uFE0F Maintenance skipped due to an unexpected error: ${getErrorMessage(error)}`);
3831
+ }
3832
+ }
3833
+ };
3834
+
3835
+ // src/services/git.service.ts
3836
+ import * as fs9 from "fs/promises";
3837
+ import * as path11 from "path";
3838
+ import simpleGit6 from "simple-git";
3839
+
3840
+ // src/utils/quarantine.ts
3841
+ import * as fs6 from "fs/promises";
3842
+ import * as path7 from "path";
3843
+
3844
+ // src/utils/filename-timestamp.ts
3845
+ function filenameTimestamp(date = /* @__PURE__ */ new Date()) {
3846
+ return date.toISOString().replace(/[:.]/g, "-");
3847
+ }
3848
+
3849
+ // src/utils/quarantine.ts
3850
+ async function quarantineDirectory(dirPath) {
3851
+ const baseDir = path7.join(path7.dirname(dirPath), GIT_CONSTANTS.REMOVED_DIR_NAME);
3852
+ await fs6.mkdir(baseDir, { recursive: true });
3853
+ const timestamp = filenameTimestamp();
3854
+ const quarantinePath = path7.join(baseDir, `${timestamp}-${path7.basename(dirPath)}`);
3855
+ await fs6.rename(dirPath, quarantinePath);
3856
+ return quarantinePath;
3857
+ }
3858
+
3859
+ // src/utils/worktree-list-parser.ts
3860
+ function parseWorktreeListPorcelain(output) {
3861
+ const worktrees = [];
3862
+ let current = {};
3863
+ const flush = () => {
3864
+ if (!current.path) {
3865
+ current = {};
3866
+ return;
3867
+ }
3868
+ worktrees.push({
3869
+ path: current.path,
3870
+ branch: current.branch ?? null,
3871
+ head: current.head ?? null,
3872
+ detached: current.detached ?? false,
3873
+ prunable: current.prunable ?? false,
3874
+ locked: current.locked ?? false
3875
+ });
3876
+ current = {};
3877
+ };
3878
+ for (const line of output.split("\n")) {
3879
+ if (line.startsWith("worktree ")) {
3880
+ flush();
3881
+ current.path = line.substring("worktree ".length);
3882
+ } else if (line.startsWith("branch ")) {
3883
+ current.branch = line.substring("branch ".length).replace("refs/heads/", "");
3884
+ } else if (line.startsWith("HEAD ")) {
3885
+ current.head = line.substring("HEAD ".length);
3886
+ } else if (line === "detached") {
3887
+ current.detached = true;
3888
+ } else if (line === "prunable" || line.startsWith("prunable ")) {
3889
+ current.prunable = true;
3890
+ } else if (line === "locked" || line.startsWith("locked ")) {
3891
+ current.locked = true;
3892
+ } else if (line.trim() === "") {
3893
+ flush();
3894
+ }
3895
+ }
3896
+ flush();
3897
+ return worktrees;
3898
+ }
3899
+
3900
+ // src/services/sparse-checkout.service.ts
3901
+ import * as path8 from "path";
3902
+ import simpleGit3 from "simple-git";
3903
+ var SparseCheckoutService = class {
3407
3904
  logger;
3408
3905
  gitFactory;
3409
3906
  warnedConfigs = /* @__PURE__ */ new WeakSet();
3410
3907
  matcherCache = /* @__PURE__ */ new WeakMap();
3411
3908
  constructor(logger, gitFactory) {
3412
3909
  this.logger = logger ?? Logger.createDefault();
3413
- this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
3910
+ this.gitFactory = gitFactory ?? ((p) => simpleGit3(p));
3414
3911
  }
3415
3912
  updateLogger(logger) {
3416
3913
  this.logger = logger;
@@ -3536,7 +4033,7 @@ var SparseCheckoutService = class {
3536
4033
  for (const pat of matcher.patterns) {
3537
4034
  if (p === pat || p.startsWith(pat + "/")) return true;
3538
4035
  }
3539
- return matcher.ancestorDirs.has(path5.posix.dirname(p));
4036
+ return matcher.ancestorDirs.has(path8.posix.dirname(p));
3540
4037
  });
3541
4038
  }
3542
4039
  getMatcher(cfg) {
@@ -3563,9 +4060,9 @@ var SparseCheckoutService = class {
3563
4060
  };
3564
4061
 
3565
4062
  // src/services/worktree-metadata.service.ts
3566
- import * as fs4 from "fs/promises";
3567
- import * as path6 from "path";
3568
- import simpleGit3 from "simple-git";
4063
+ import * as fs7 from "fs/promises";
4064
+ import * as path9 from "path";
4065
+ import simpleGit4 from "simple-git";
3569
4066
  var WorktreeMetadataService = class {
3570
4067
  logger;
3571
4068
  constructor(logger) {
@@ -3577,7 +4074,7 @@ var WorktreeMetadataService = class {
3577
4074
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
3578
4075
  */
3579
4076
  getWorktreeDirectoryName(worktreePath) {
3580
- return path6.basename(worktreePath);
4077
+ return path9.basename(worktreePath);
3581
4078
  }
3582
4079
  async getMetadataPath(bareRepoPath, worktreeName) {
3583
4080
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -3585,7 +4082,7 @@ var WorktreeMetadataService = class {
3585
4082
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
3586
4083
  );
3587
4084
  }
3588
- return path6.join(
4085
+ return path9.join(
3589
4086
  bareRepoPath,
3590
4087
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
3591
4088
  worktreeName,
@@ -3598,31 +4095,13 @@ var WorktreeMetadataService = class {
3598
4095
  }
3599
4096
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
3600
4097
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
3601
- await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
3602
- const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
3603
- let renamed = false;
3604
- try {
3605
- await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
3606
- try {
3607
- await fs4.rename(tmpPath, metadataPath);
3608
- renamed = true;
3609
- } catch (err) {
3610
- if (err.code === ERROR_MESSAGES.EXDEV) {
3611
- await fs4.copyFile(tmpPath, metadataPath);
3612
- } else {
3613
- throw err;
3614
- }
3615
- }
3616
- } finally {
3617
- if (!renamed) {
3618
- await fs4.unlink(tmpPath).catch(() => void 0);
3619
- }
3620
- }
4098
+ await fs7.mkdir(path9.dirname(metadataPath), { recursive: true });
4099
+ await atomicWriteFile(metadataPath, JSON.stringify(metadata, null, 2));
3621
4100
  }
3622
4101
  async loadMetadata(bareRepoPath, worktreeName) {
3623
4102
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
3624
4103
  try {
3625
- const content = await fs4.readFile(metadataPath, "utf-8");
4104
+ const content = await fs7.readFile(metadataPath, "utf-8");
3626
4105
  return JSON.parse(content);
3627
4106
  } catch {
3628
4107
  return null;
@@ -3631,7 +4110,7 @@ var WorktreeMetadataService = class {
3631
4110
  async loadMetadataFromPath(bareRepoPath, worktreePath) {
3632
4111
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
3633
4112
  try {
3634
- const content = await fs4.readFile(metadataPath, "utf-8");
4113
+ const content = await fs7.readFile(metadataPath, "utf-8");
3635
4114
  const metadata = JSON.parse(content);
3636
4115
  if (!await this.validateMetadata(metadata)) {
3637
4116
  this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
@@ -3645,7 +4124,7 @@ var WorktreeMetadataService = class {
3645
4124
  async deleteMetadata(bareRepoPath, worktreeName) {
3646
4125
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
3647
4126
  try {
3648
- await fs4.unlink(metadataPath);
4127
+ await fs7.unlink(metadataPath);
3649
4128
  } catch (error) {
3650
4129
  if (error.code !== "ENOENT") {
3651
4130
  throw error;
@@ -3655,7 +4134,7 @@ var WorktreeMetadataService = class {
3655
4134
  async deleteMetadataFromPath(bareRepoPath, worktreePath) {
3656
4135
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
3657
4136
  try {
3658
- await fs4.unlink(metadataPath);
4137
+ await fs7.unlink(metadataPath);
3659
4138
  } catch (error) {
3660
4139
  if (error.code !== "ENOENT") {
3661
4140
  throw error;
@@ -3689,7 +4168,7 @@ var WorktreeMetadataService = class {
3689
4168
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
3690
4169
  this.logger.info(` Attempting to create initial metadata...`);
3691
4170
  try {
3692
- const worktreeGit = simpleGit3(worktreePath);
4171
+ const worktreeGit = simpleGit4(worktreePath);
3693
4172
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
3694
4173
  const branchSummary = await worktreeGit.branch();
3695
4174
  const actualBranchName = branchSummary.current;
@@ -3736,6 +4215,25 @@ var WorktreeMetadataService = class {
3736
4215
  }
3737
4216
  await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
3738
4217
  }
4218
+ /**
4219
+ * Records the upstream tip observed during this sync. This is what later
4220
+ * proves "HEAD was fully pushed" after the remote branch is deleted, so it
4221
+ * must only ever be overwritten with a live observation — callers must not
4222
+ * invoke this once the upstream ref is gone.
4223
+ */
4224
+ async recordRemoteTip(bareRepoPath, worktreePath, ref, oid) {
4225
+ const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
4226
+ const existing = await this.loadMetadataFromPath(bareRepoPath, worktreePath);
4227
+ if (!existing) {
4228
+ this.logger.debug(`No metadata found for worktree ${worktreeDirName}; skipping remote tip recording`);
4229
+ return;
4230
+ }
4231
+ if (existing.lastKnownRemoteTip?.ref === ref && existing.lastKnownRemoteTip.oid === oid) {
4232
+ return;
4233
+ }
4234
+ existing.lastKnownRemoteTip = { ref, oid, recordedAt: (/* @__PURE__ */ new Date()).toISOString() };
4235
+ await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
4236
+ }
3739
4237
  async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
3740
4238
  const metadata = {
3741
4239
  lastSyncCommit: commit,
@@ -3790,9 +4288,9 @@ var WorktreeMetadataService = class {
3790
4288
  };
3791
4289
 
3792
4290
  // src/services/worktree-status.service.ts
3793
- import * as fs5 from "fs/promises";
3794
- import * as path7 from "path";
3795
- import simpleGit4 from "simple-git";
4291
+ import * as fs8 from "fs/promises";
4292
+ import * as path10 from "path";
4293
+ import simpleGit5 from "simple-git";
3796
4294
  var OPERATION_FILES = [
3797
4295
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
3798
4296
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -3823,8 +4321,9 @@ var WorktreeStatusService = class {
3823
4321
  }
3824
4322
  return true;
3825
4323
  }
3826
- async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
3827
- if (!await fileExists(worktreePath)) {
4324
+ async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit, lastKnownRemoteTip) {
4325
+ const pathProbe = await probePathExists(worktreePath);
4326
+ if (pathProbe === "missing") {
3828
4327
  return {
3829
4328
  isClean: true,
3830
4329
  hasUnpushedCommits: false,
@@ -3832,25 +4331,44 @@ var WorktreeStatusService = class {
3832
4331
  hasOperationInProgress: false,
3833
4332
  hasModifiedSubmodules: false,
3834
4333
  upstreamGone: false,
4334
+ fullyPushedUpstreamDeleted: false,
3835
4335
  canRemove: true,
3836
4336
  reasons: []
3837
4337
  };
3838
4338
  }
3839
- const snap = await this.collectSnapshot(worktreePath, lastSyncCommit);
4339
+ if (pathProbe === "unknown") {
4340
+ return {
4341
+ isClean: false,
4342
+ hasUnpushedCommits: true,
4343
+ hasStashedChanges: true,
4344
+ hasOperationInProgress: true,
4345
+ hasModifiedSubmodules: true,
4346
+ upstreamGone: false,
4347
+ fullyPushedUpstreamDeleted: false,
4348
+ canRemove: false,
4349
+ reasons: ["cannot verify worktree path (filesystem probe failed)"]
4350
+ };
4351
+ }
4352
+ const snap = await this.collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip);
3840
4353
  const isClean = this.deriveIsClean(snap);
3841
- const hasUnpushedCommits = !snap.detached && (snap.unpushedCount ?? 1) > 0;
4354
+ const anyRemoteUnpushed = (snap.unpushedAnyRemoteCount ?? 1) > 0;
4355
+ const sinceSyncUnpushed = snap.sinceSyncChecked && (snap.sinceSyncCount ?? 1) > 0;
4356
+ const hasUnpushedCommits = !snap.detached && (anyRemoteUnpushed || sinceSyncUnpushed);
4357
+ const recordedRefGone = lastKnownRemoteTip !== void 0 && snap.remoteBranches.length > 0 && !snap.remoteBranches.includes(lastKnownRemoteTip.ref);
4358
+ const fullyPushedUpstreamDeleted = hasUnpushedCommits && recordedRefGone && snap.headPushedToRecordedTip === true;
3842
4359
  const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
3843
- const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
4360
+ const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null || snap.operationProbeUnknown;
3844
4361
  const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
3845
4362
  const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
3846
4363
  const reasons = [];
3847
4364
  if (!isClean) reasons.push("uncommitted changes");
3848
- if (hasUnpushedCommits) reasons.push("unpushed commits");
4365
+ if (hasUnpushedCommits && !fullyPushedUpstreamDeleted) reasons.push("unpushed commits");
3849
4366
  if (hasStashedChanges) reasons.push("stashed changes");
3850
4367
  if (hasOperationInProgress) reasons.push("operation in progress");
3851
4368
  if (hasModifiedSubmodules) reasons.push("modified submodules");
3852
4369
  if (upstreamGone) reasons.push("upstream gone");
3853
- const canRemove = isClean && !hasUnpushedCommits && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules;
4370
+ if (snap.detached) reasons.push("detached HEAD");
4371
+ const canRemove = isClean && (!hasUnpushedCommits || fullyPushedUpstreamDeleted) && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules && !snap.detached;
3854
4372
  const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
3855
4373
  return {
3856
4374
  isClean,
@@ -3859,12 +4377,13 @@ var WorktreeStatusService = class {
3859
4377
  hasOperationInProgress,
3860
4378
  hasModifiedSubmodules,
3861
4379
  upstreamGone,
4380
+ fullyPushedUpstreamDeleted,
3862
4381
  canRemove,
3863
4382
  reasons,
3864
4383
  details
3865
4384
  };
3866
4385
  }
3867
- async collectSnapshot(worktreePath, lastSyncCommit) {
4386
+ async collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
3868
4387
  const git = this.createGitInstance(worktreePath);
3869
4388
  const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
3870
4389
  git.status().catch((e) => {
@@ -3889,19 +4408,33 @@ var WorktreeStatusService = class {
3889
4408
  const currentBranch = branchResult?.current ?? null;
3890
4409
  const detached = !branchResult?.current || Boolean(branchResult?.detached);
3891
4410
  let upstream = null;
3892
- let unpushedCount = null;
4411
+ let unpushedAnyRemoteCount = null;
4412
+ let sinceSyncCount = null;
4413
+ let headPushedToRecordedTip = null;
3893
4414
  if (!detached && currentBranch) {
3894
- const revListArgs = lastSyncCommit ? ["rev-list", "--count", `${lastSyncCommit}..HEAD`] : ["rev-list", "--count", currentBranch, "--not", "--remotes"];
3895
- const [upstreamResult, unpushedResult] = await Promise.all([
4415
+ const [upstreamResult, anyRemoteResult, sinceSyncResult, recordedTipResult] = await Promise.all([
3896
4416
  git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
3897
4417
  (raw) => ({ ok: true, value: raw }),
3898
4418
  (error) => ({ ok: false, error })
3899
4419
  ),
3900
- git.raw(revListArgs).then(
4420
+ git.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]).then(
3901
4421
  (raw) => ({ ok: true, value: raw }),
3902
4422
  (error) => ({ ok: false, error })
3903
- )
4423
+ ),
4424
+ lastSyncCommit ? git.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]).then(
4425
+ (raw) => ({ ok: true, value: raw }),
4426
+ (error) => ({ ok: false, error })
4427
+ ) : Promise.resolve(null),
4428
+ // Zero commits in <tip>..HEAD ⟺ HEAD is the recorded tip or behind it.
4429
+ // NOT merge-base --is-ancestor: simple-git resolves its silent exit-1
4430
+ // ("not an ancestor") as success because nothing is written to stderr.
4431
+ // Any failure (e.g. the recorded oid was gc'd) reads as "not proven".
4432
+ lastKnownRemoteTip ? git.raw(["rev-list", "--count", `${lastKnownRemoteTip.oid}..HEAD`]).then(
4433
+ (raw) => this.parseCount(raw) === 0,
4434
+ () => false
4435
+ ) : Promise.resolve(null)
3904
4436
  ]);
4437
+ headPushedToRecordedTip = recordedTipResult;
3905
4438
  if (upstreamResult.ok) {
3906
4439
  upstream = upstreamResult.value.trim() || null;
3907
4440
  } else {
@@ -3910,13 +4443,20 @@ var WorktreeStatusService = class {
3910
4443
  this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
3911
4444
  }
3912
4445
  }
3913
- if (unpushedResult.ok) {
3914
- unpushedCount = parseInt(unpushedResult.value.trim(), 10);
4446
+ if (anyRemoteResult.ok) {
4447
+ unpushedAnyRemoteCount = this.parseCount(anyRemoteResult.value);
3915
4448
  } else {
3916
- this.logger.error(`Error checking unpushed commits`, unpushedResult.error);
4449
+ this.logger.error(`Error checking unpushed commits`, anyRemoteResult.error);
4450
+ }
4451
+ if (sinceSyncResult) {
4452
+ if (sinceSyncResult.ok) {
4453
+ sinceSyncCount = this.parseCount(sinceSyncResult.value);
4454
+ } else {
4455
+ this.logger.error(`Error checking commits since last sync`, sinceSyncResult.error);
4456
+ }
3917
4457
  }
3918
4458
  }
3919
- const operationFile = gitDirResult ? await this.detectOperationFile(gitDirResult) : null;
4459
+ const operationProbe = gitDirResult ? await this.detectOperationFile(gitDirResult) : { file: null, unknown: false };
3920
4460
  let untrackedNotIgnored = [];
3921
4461
  if (status && status.not_added.length > 0) {
3922
4462
  try {
@@ -3932,14 +4472,22 @@ var WorktreeStatusService = class {
3932
4472
  detached,
3933
4473
  remoteBranches: remoteBranchesResult?.all ?? [],
3934
4474
  upstream,
3935
- unpushedCount,
4475
+ unpushedAnyRemoteCount,
4476
+ sinceSyncCount,
4477
+ sinceSyncChecked: lastSyncCommit !== void 0,
4478
+ headPushedToRecordedTip,
3936
4479
  stashTotal: stashResult?.total ?? null,
3937
4480
  submoduleStatus: submoduleResult,
3938
- operationFile,
4481
+ operationFile: operationProbe.file,
4482
+ operationProbeUnknown: operationProbe.unknown,
3939
4483
  gitDir: gitDirResult,
3940
4484
  untrackedNotIgnored
3941
4485
  };
3942
4486
  }
4487
+ parseCount(raw) {
4488
+ const count = parseInt(raw.trim(), 10);
4489
+ return Number.isNaN(count) ? null : count;
4490
+ }
3943
4491
  deriveIsClean(snap) {
3944
4492
  const status = snap.status;
3945
4493
  if (!status) return false;
@@ -3979,7 +4527,8 @@ var WorktreeStatusService = class {
3979
4527
  if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
3980
4528
  }
3981
4529
  if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
3982
- if (!snap.detached && snap.unpushedCount !== null) details.unpushedCommitCount = snap.unpushedCount;
4530
+ const unpushedCount = snap.unpushedAnyRemoteCount ?? snap.sinceSyncCount;
4531
+ if (!snap.detached && unpushedCount !== null) details.unpushedCommitCount = unpushedCount;
3983
4532
  if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
3984
4533
  const opType = this.operationTypeFromFile(snap.operationFile);
3985
4534
  if (opType) details.operationType = opType;
@@ -3994,34 +4543,37 @@ var WorktreeStatusService = class {
3994
4543
  async detectOperationFile(gitDir) {
3995
4544
  const results = await Promise.all(
3996
4545
  OPERATION_FILES.map(
3997
- ({ file }) => fs5.access(path7.join(gitDir, file)).then(
3998
- () => true,
3999
- () => false
4546
+ ({ file }) => fs8.access(path10.join(gitDir, file)).then(
4547
+ () => "present",
4548
+ (error) => error.code === "ENOENT" ? "absent" : "unknown"
4000
4549
  )
4001
4550
  )
4002
4551
  );
4003
- const idx = results.findIndex(Boolean);
4004
- return idx >= 0 ? OPERATION_FILES[idx].file : null;
4552
+ const idx = results.findIndex((result) => result === "present");
4553
+ if (idx >= 0) return { file: OPERATION_FILES[idx].file, unknown: false };
4554
+ return { file: null, unknown: results.includes("unknown") };
4005
4555
  }
4006
4556
  async hasUnpushedCommits(worktreePath, lastSyncCommit) {
4007
4557
  const worktreeGit = this.createGitInstance(worktreePath);
4008
4558
  try {
4009
4559
  if (await this.isDetachedHead(worktreeGit)) {
4010
- return false;
4560
+ return true;
4011
4561
  }
4012
4562
  const branchSummary = await worktreeGit.branch();
4013
4563
  const currentBranch = branchSummary.current;
4564
+ const anyRemoteResult = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
4565
+ const anyRemoteCount = this.parseCount(anyRemoteResult);
4566
+ if (anyRemoteCount === null || anyRemoteCount > 0) {
4567
+ return true;
4568
+ }
4014
4569
  if (lastSyncCommit) {
4015
- try {
4016
- const newCommitsResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
4017
- const newCommitsCount = parseInt(newCommitsResult.trim(), 10);
4018
- return newCommitsCount > 0;
4019
- } catch {
4570
+ const sinceSyncResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
4571
+ const sinceSyncCount = this.parseCount(sinceSyncResult);
4572
+ if (sinceSyncCount === null || sinceSyncCount > 0) {
4573
+ return true;
4020
4574
  }
4021
4575
  }
4022
- const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
4023
- const unpushedCount = parseInt(result.trim(), 10);
4024
- return unpushedCount > 0;
4576
+ return false;
4025
4577
  } catch (error) {
4026
4578
  this.logger.error(`Error checking unpushed commits`, error);
4027
4579
  return true;
@@ -4077,14 +4629,15 @@ var WorktreeStatusService = class {
4077
4629
  async hasOperationInProgress(worktreePath) {
4078
4630
  try {
4079
4631
  const gitDir = await this.resolveGitDir(worktreePath);
4080
- return await this.detectOperationFile(gitDir) !== null;
4632
+ const probe = await this.detectOperationFile(gitDir);
4633
+ return probe.unknown || probe.file !== null;
4081
4634
  } catch (error) {
4082
4635
  this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
4083
4636
  return true;
4084
4637
  }
4085
4638
  }
4086
- async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
4087
- const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
4639
+ async validateWorktreeForRemoval(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
4640
+ const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit, lastKnownRemoteTip);
4088
4641
  if (!status.canRemove) {
4089
4642
  throw new WorktreeNotCleanError(worktreePath, status.reasons);
4090
4643
  }
@@ -4115,14 +4668,14 @@ var WorktreeStatusService = class {
4115
4668
  }
4116
4669
  }
4117
4670
  async resolveGitDir(worktreePath) {
4118
- const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
4671
+ const gitPath = path10.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
4119
4672
  try {
4120
- const stat3 = await fs5.stat(gitPath);
4673
+ const stat3 = await fs8.stat(gitPath);
4121
4674
  if (stat3.isFile()) {
4122
- const content = await fs5.readFile(gitPath, "utf-8");
4675
+ const content = await fs8.readFile(gitPath, "utf-8");
4123
4676
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
4124
4677
  if (gitdirMatch) {
4125
- return path7.resolve(worktreePath, gitdirMatch[1].trim());
4678
+ return path10.resolve(worktreePath, gitdirMatch[1].trim());
4126
4679
  }
4127
4680
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
4128
4681
  }
@@ -4136,10 +4689,10 @@ var WorktreeStatusService = class {
4136
4689
  }
4137
4690
  }
4138
4691
  createGitInstance(worktreePath) {
4139
- const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
4692
+ const key = `${path10.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
4140
4693
  let git = this.gitInstances.get(key);
4141
4694
  if (!git) {
4142
- git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
4695
+ git = this.config.skipLfs ? simpleGit5(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit5(worktreePath);
4143
4696
  this.gitInstances.set(key, git);
4144
4697
  }
4145
4698
  return git;
@@ -4160,7 +4713,7 @@ var GitService = class {
4160
4713
  this.progressEmitter = progressEmitter;
4161
4714
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
4162
4715
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
4163
- this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
4716
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
4164
4717
  this.metadataService = new WorktreeMetadataService(this.logger);
4165
4718
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
4166
4719
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -4190,10 +4743,10 @@ var GitService = class {
4190
4743
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
4191
4744
  }
4192
4745
  getCachedGit(dirPath, useLfsSkip = false) {
4193
- const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
4746
+ const key = `${path11.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
4194
4747
  let git = this.gitInstances.get(key);
4195
4748
  if (!git) {
4196
- const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4749
+ const base = simpleGit6(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4197
4750
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
4198
4751
  this.gitInstances.set(key, git);
4199
4752
  }
@@ -4213,11 +4766,11 @@ var GitService = class {
4213
4766
  async initialize() {
4214
4767
  const { repoUrl } = this.config;
4215
4768
  try {
4216
- await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
4769
+ await fs9.access(path11.join(this.bareRepoPath, "HEAD"));
4217
4770
  } catch {
4218
4771
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
4219
- await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
4220
- const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
4772
+ await fs9.mkdir(path11.dirname(this.bareRepoPath), { recursive: true });
4773
+ const cloneBase = simpleGit6(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
4221
4774
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
4222
4775
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
4223
4776
  this.logger.info("\u2705 Clone successful.");
@@ -4235,17 +4788,17 @@ var GitService = class {
4235
4788
  this.logger.info("Fetching remote branches...");
4236
4789
  await bareGit.fetch(["--all", "--progress"]);
4237
4790
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
4238
- this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
4791
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, this.defaultBranch);
4239
4792
  let needsMainWorktree = true;
4240
4793
  try {
4241
4794
  const worktrees = await this.getWorktreesFromBare(bareGit);
4242
- needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
4795
+ needsMainWorktree = !worktrees.some((w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath));
4243
4796
  } catch {
4244
4797
  }
4245
4798
  if (needsMainWorktree) {
4246
4799
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
4247
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
4248
- const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
4800
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
4801
+ const absoluteWorktreePath = path11.resolve(this.mainWorktreePath);
4249
4802
  const branches = await bareGit.branch();
4250
4803
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
4251
4804
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -4281,7 +4834,7 @@ var GitService = class {
4281
4834
  }
4282
4835
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
4283
4836
  const mainWorktreeRegistered = updatedWorktrees.some(
4284
- (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
4837
+ (w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath)
4285
4838
  );
4286
4839
  if (!mainWorktreeRegistered) {
4287
4840
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -4308,7 +4861,7 @@ var GitService = class {
4308
4861
  return this.bareRepoPath;
4309
4862
  }
4310
4863
  async getRemoteDefaultBranch(repoUrl) {
4311
- const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4864
+ const git = simpleGit6(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4312
4865
  try {
4313
4866
  const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
4314
4867
  const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
@@ -4392,7 +4945,7 @@ var GitService = class {
4392
4945
  return branches;
4393
4946
  }
4394
4947
  async verifyLfsFilesDownloaded(worktreePath, branchName) {
4395
- const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
4948
+ const worktreeGit = this.config.sparseCheckout ? simpleGit6(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
4396
4949
  try {
4397
4950
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
4398
4951
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -4403,7 +4956,7 @@ var GitService = class {
4403
4956
  const existence = await Promise.all(
4404
4957
  lfsFileList.map(async (f) => {
4405
4958
  try {
4406
- await fs6.access(path8.join(worktreePath, f));
4959
+ await fs9.access(path11.join(worktreePath, f));
4407
4960
  return f;
4408
4961
  } catch {
4409
4962
  return null;
@@ -4431,9 +4984,9 @@ var GitService = class {
4431
4984
  let allDownloaded = true;
4432
4985
  const notDownloaded = [];
4433
4986
  for (const file of samplesToCheck) {
4434
- const filePath = path8.join(worktreePath, file);
4987
+ const filePath = path11.join(worktreePath, file);
4435
4988
  try {
4436
- const handle = await fs6.open(filePath, "r");
4989
+ const handle = await fs9.open(filePath, "r");
4437
4990
  try {
4438
4991
  const buffer = Buffer.alloc(200);
4439
4992
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -4458,7 +5011,7 @@ var GitService = class {
4458
5011
  }
4459
5012
  retries++;
4460
5013
  if (retries < maxRetries) {
4461
- await new Promise((resolve12) => setTimeout(resolve12, retryDelay));
5014
+ await new Promise((resolve14) => setTimeout(resolve14, retryDelay));
4462
5015
  }
4463
5016
  }
4464
5017
  this.logger.warn(
@@ -4520,20 +5073,23 @@ var GitService = class {
4520
5073
  }
4521
5074
  async addWorktree(branchName, worktreePath) {
4522
5075
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
4523
- const absoluteWorktreePath = path8.resolve(worktreePath);
4524
- await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
5076
+ const absoluteWorktreePath = path11.resolve(worktreePath);
5077
+ await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
4525
5078
  try {
4526
- await fs6.access(absoluteWorktreePath);
5079
+ await fs9.access(absoluteWorktreePath);
4527
5080
  const worktrees = await this.getWorktreesFromBare(bareGit);
4528
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
5081
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
4529
5082
  if (isValidWorktree) {
4530
5083
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
4531
5084
  return;
4532
5085
  } else {
4533
5086
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
4534
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
5087
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
5088
+ }
5089
+ } catch (error) {
5090
+ if (error instanceof GitOperationError || error instanceof WorktreeError) {
5091
+ throw error;
4535
5092
  }
4536
- } catch {
4537
5093
  }
4538
5094
  let createdNewBranch = false;
4539
5095
  try {
@@ -4570,17 +5126,14 @@ var GitService = class {
4570
5126
  }
4571
5127
  if (errorMessage.includes("already registered worktree")) {
4572
5128
  const worktrees = await this.getWorktreesFromBare(bareGit);
4573
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
5129
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
4574
5130
  if (existingWorktree && !existingWorktree.isPrunable) {
4575
5131
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
4576
5132
  return;
4577
5133
  }
4578
5134
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
4579
5135
  await bareGit.raw(["worktree", "prune"]);
4580
- try {
4581
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
4582
- } catch {
4583
- }
5136
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
4584
5137
  let retryCreatedNewBranch = false;
4585
5138
  try {
4586
5139
  const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
@@ -4614,17 +5167,20 @@ var GitService = class {
4614
5167
  }
4615
5168
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
4616
5169
  try {
4617
- await fs6.access(absoluteWorktreePath);
5170
+ await fs9.access(absoluteWorktreePath);
4618
5171
  const worktrees = await this.getWorktreesFromBare(bareGit);
4619
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
5172
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
4620
5173
  if (isValidWorktree) {
4621
5174
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
4622
5175
  return;
4623
5176
  } else {
4624
5177
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
4625
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
5178
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
5179
+ }
5180
+ } catch (error2) {
5181
+ if (error2 instanceof GitOperationError || error2 instanceof WorktreeError) {
5182
+ throw error2;
4626
5183
  }
4627
- } catch {
4628
5184
  }
4629
5185
  try {
4630
5186
  const useNoCheckout = !!this.config.sparseCheckout;
@@ -4646,7 +5202,7 @@ var GitService = class {
4646
5202
  const fallbackErrorMessage = getErrorMessage(fallbackError);
4647
5203
  if (fallbackErrorMessage.includes("already registered worktree")) {
4648
5204
  const worktrees = await this.getWorktreesFromBare(bareGit);
4649
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
5205
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
4650
5206
  if (existingWorktree && !existingWorktree.isPrunable) {
4651
5207
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
4652
5208
  return;
@@ -4715,9 +5271,19 @@ var GitService = class {
4715
5271
  wrapped.isUpstreamSetupFailure = true;
4716
5272
  return wrapped;
4717
5273
  }
4718
- async removeWorktree(worktreePath) {
5274
+ async removeWorktree(worktreePath, options) {
4719
5275
  const bareGit = this.getCachedGit(this.bareRepoPath);
4720
- await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
5276
+ const args = ["worktree", "remove", worktreePath];
5277
+ if (options?.force) args.push("--force");
5278
+ try {
5279
+ await bareGit.raw(args);
5280
+ } catch (error) {
5281
+ const message = getErrorMessage(error);
5282
+ if (!options?.force && /contains modified or untracked files|use --force/i.test(message)) {
5283
+ throw new WorktreeNotCleanError(worktreePath, [`git refused removal: ${message}`]);
5284
+ }
5285
+ throw error;
5286
+ }
4721
5287
  this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
4722
5288
  try {
4723
5289
  await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
@@ -4730,6 +5296,111 @@ var GitService = class {
4730
5296
  await bareGit.raw(["worktree", "prune"]);
4731
5297
  this.logger.info("Pruned worktree metadata.");
4732
5298
  }
5299
+ async updateRef(refName, sha) {
5300
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5301
+ await bareGit.raw(["update-ref", refName, sha]);
5302
+ }
5303
+ async deleteRef(refName) {
5304
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5305
+ await bareGit.raw(["update-ref", "-d", refName]);
5306
+ }
5307
+ async listRefs(prefix) {
5308
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5309
+ const raw = await bareGit.raw(["for-each-ref", "--format=%(refname)", prefix]);
5310
+ return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
5311
+ }
5312
+ async localBranchExists(branchName) {
5313
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5314
+ try {
5315
+ await bareGit.raw(["show-ref", "--verify", "--quiet", `${GIT_CONSTANTS.REFS.HEADS}${branchName}`]);
5316
+ return true;
5317
+ } catch {
5318
+ return false;
5319
+ }
5320
+ }
5321
+ async getLocalBranchCommit(branchName) {
5322
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5323
+ try {
5324
+ return (await bareGit.raw(["rev-parse", `${GIT_CONSTANTS.REFS.HEADS}${branchName}^{commit}`])).trim();
5325
+ } catch {
5326
+ return null;
5327
+ }
5328
+ }
5329
+ async createBranchAt(branchName, sha) {
5330
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5331
+ await bareGit.raw(["branch", branchName, sha]);
5332
+ }
5333
+ async deleteLocalBranch(branchName) {
5334
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5335
+ await bareGit.raw(["branch", "-D", branchName]);
5336
+ }
5337
+ // Bundles only commits not reachable from any remote — for fully-pushed
5338
+ // refs that set is empty and `bundle create` would fail. Emptiness is
5339
+ // pre-checked with rev-list (locale-independent) instead of parsing git's
5340
+ // localized "empty bundle" stderr; after the pre-check, any bundle-create
5341
+ // error is a real failure the caller must treat as fail-closed.
5342
+ async createBundleFromRef(bundlePath, refName) {
5343
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5344
+ const count = (await bareGit.raw(["rev-list", "--count", refName, "--not", "--remotes"])).trim();
5345
+ if (count === "0") {
5346
+ return false;
5347
+ }
5348
+ await bareGit.raw(["bundle", "create", bundlePath, refName, "--not", "--remotes"]);
5349
+ return true;
5350
+ }
5351
+ // Registers the worktree and writes its .git link without populating files —
5352
+ // restore overlays the preserved payload instead of a fresh checkout.
5353
+ async addWorktreeNoCheckout(branchName, worktreePath) {
5354
+ const bareGit = this.getCachedGit(this.bareRepoPath);
5355
+ const absoluteWorktreePath = path11.resolve(worktreePath);
5356
+ await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
5357
+ await bareGit.raw(["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName]);
5358
+ }
5359
+ // Mixed reset: points the index at HEAD without touching working files, so
5360
+ // overlaid payload content shows up as ordinary uncommitted changes.
5361
+ async resetWorktreeIndex(worktreePath) {
5362
+ const worktreeGit = this.getCachedGit(worktreePath);
5363
+ await worktreeGit.raw(["reset"]);
5364
+ }
5365
+ // Injected by WorktreeSyncService when trash is enabled, so stale-directory
5366
+ // cleanup follows the same reversible-removal pipeline as everything else.
5367
+ // GitService cannot own a TrashService directly (TrashService depends on it).
5368
+ staleDirectoryTrasher = null;
5369
+ setStaleDirectoryTrasher(trasher) {
5370
+ this.staleDirectoryTrasher = trasher;
5371
+ }
5372
+ // A stale directory that contains a .git may be a live checkout that git
5373
+ // failed to report; quarantine it instead of deleting.
5374
+ async clearStaleWorktreeDirectory(absoluteWorktreePath) {
5375
+ const gitProbe = await probePathExists(path11.join(absoluteWorktreePath, PATH_CONSTANTS.GIT_DIR));
5376
+ if (gitProbe === "unknown") {
5377
+ throw new GitOperationError(
5378
+ "clear-stale-directory",
5379
+ `Cannot verify whether '${absoluteWorktreePath}' is a live checkout; refusing to clear it`
5380
+ );
5381
+ }
5382
+ if (this.staleDirectoryTrasher) {
5383
+ try {
5384
+ const trashPath = await this.staleDirectoryTrasher(absoluteWorktreePath);
5385
+ this.logger.info(` - Moved stale directory at '${absoluteWorktreePath}' to trash ('${trashPath}')`);
5386
+ return;
5387
+ } catch (error) {
5388
+ throw new GitOperationError(
5389
+ "clear-stale-directory",
5390
+ `Cannot move stale directory '${absoluteWorktreePath}' to trash: ${getErrorMessage(error)}`,
5391
+ error instanceof Error ? error : void 0
5392
+ );
5393
+ }
5394
+ }
5395
+ if (gitProbe === "exists") {
5396
+ const quarantinePath = await quarantineDirectory(absoluteWorktreePath);
5397
+ this.logger.warn(
5398
+ ` - \u26A0\uFE0F Directory at '${absoluteWorktreePath}' contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
5399
+ );
5400
+ return;
5401
+ }
5402
+ await fs9.rm(absoluteWorktreePath, { recursive: true, force: true });
5403
+ }
4733
5404
  async checkWorktreeStatus(worktreePath) {
4734
5405
  return this.statusService.checkWorktreeStatus(worktreePath);
4735
5406
  }
@@ -4745,7 +5416,37 @@ var GitService = class {
4745
5416
  }
4746
5417
  async getFullWorktreeStatus(worktreePath, includeDetails = false) {
4747
5418
  const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
4748
- return this.statusService.getFullWorktreeStatus(worktreePath, includeDetails, metadata?.lastSyncCommit);
5419
+ return this.statusService.getFullWorktreeStatus(
5420
+ worktreePath,
5421
+ includeDetails,
5422
+ metadata?.lastSyncCommit,
5423
+ metadata?.lastKnownRemoteTip
5424
+ );
5425
+ }
5426
+ /** Map of remote branch name (without "origin/") → tip oid, from the bare repo. */
5427
+ async getRemoteBranchTips() {
5428
+ const git = this.getGit();
5429
+ const raw = await git.raw(["for-each-ref", "--format=%(refname:short) %(objectname)", GIT_CONSTANTS.REFS.REMOTES]);
5430
+ const tips = /* @__PURE__ */ new Map();
5431
+ for (const line of raw.split("\n")) {
5432
+ const trimmed = line.trim();
5433
+ if (!trimmed) continue;
5434
+ const spaceIdx = trimmed.lastIndexOf(" ");
5435
+ if (spaceIdx <= 0) continue;
5436
+ const ref = trimmed.slice(0, spaceIdx);
5437
+ const oid = trimmed.slice(spaceIdx + 1);
5438
+ if (!ref.startsWith(GIT_CONSTANTS.REMOTE_PREFIX) || ref === `${GIT_CONSTANTS.REMOTE_PREFIX}HEAD`) continue;
5439
+ tips.set(ref.slice(GIT_CONSTANTS.REMOTE_PREFIX.length), oid);
5440
+ }
5441
+ return tips;
5442
+ }
5443
+ async recordRemoteTip(worktreePath, branchName, oid) {
5444
+ await this.metadataService.recordRemoteTip(
5445
+ this.bareRepoPath,
5446
+ worktreePath,
5447
+ `${GIT_CONSTANTS.REMOTE_PREFIX}${branchName}`,
5448
+ oid
5449
+ );
4749
5450
  }
4750
5451
  async hasModifiedSubmodules(worktreePath) {
4751
5452
  return this.statusService.hasModifiedSubmodules(worktreePath);
@@ -5030,31 +5731,32 @@ var ProgressEmitter = class {
5030
5731
  }
5031
5732
  };
5032
5733
 
5033
- // src/services/repo-operation-lock.ts
5034
- import * as fs7 from "fs/promises";
5035
- import * as path10 from "path";
5036
- import * as lockfile from "proper-lockfile";
5037
-
5038
- // src/utils/lock-path.ts
5039
- import { createHash } from "crypto";
5040
- import * as os from "os";
5041
- import * as path9 from "path";
5042
- function getCloneModeLockTarget(config) {
5043
- const name = config.name;
5044
- const configDir = config.__configFileDir;
5045
- const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
5046
- if (configDir) {
5047
- return {
5048
- dir: path9.join(configDir, ".sync-worktrees-state"),
5049
- file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
5050
- };
5734
+ // src/services/removal-audit.service.ts
5735
+ import * as fs10 from "fs/promises";
5736
+ import * as path12 from "path";
5737
+ var RemovalAuditService = class {
5738
+ constructor(logFilePath) {
5739
+ this.logFilePath = logFilePath;
5740
+ }
5741
+ logFilePath;
5742
+ async record(entry) {
5743
+ await fs10.mkdir(path12.dirname(this.logFilePath), { recursive: true });
5744
+ const line = JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
5745
+ const handle = await fs10.open(this.logFilePath, "a");
5746
+ try {
5747
+ await handle.appendFile(`${line}
5748
+ `, "utf-8");
5749
+ await handle.sync();
5750
+ } finally {
5751
+ await handle.close();
5752
+ }
5051
5753
  }
5052
- const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
5053
- const dir = path9.join(stateBase, "sync-worktrees", "locks");
5054
- return { dir, file: `${hash}.lock` };
5055
- }
5754
+ };
5056
5755
 
5057
5756
  // src/services/repo-operation-lock.ts
5757
+ import * as fs11 from "fs/promises";
5758
+ import * as path13 from "path";
5759
+ import * as lockfile from "proper-lockfile";
5058
5760
  var RepoOperationLock = class {
5059
5761
  constructor(config, gitService, logger = Logger.createDefault()) {
5060
5762
  this.config = config;
@@ -5079,10 +5781,10 @@ var RepoOperationLock = class {
5079
5781
  }
5080
5782
  async acquireCloneModeLock() {
5081
5783
  const target = getCloneModeLockTarget(this.config);
5082
- const lockTarget = path10.join(target.dir, target.file);
5784
+ const lockTarget = path13.join(target.dir, target.file);
5083
5785
  try {
5084
- await fs7.mkdir(target.dir, { recursive: true });
5085
- await fs7.writeFile(lockTarget, "", { flag: "a" });
5786
+ await fs11.mkdir(target.dir, { recursive: true });
5787
+ await fs11.writeFile(lockTarget, "", { flag: "a" });
5086
5788
  } catch {
5087
5789
  return null;
5088
5790
  }
@@ -5091,7 +5793,7 @@ var RepoOperationLock = class {
5091
5793
  async acquireWorktreeModeLock() {
5092
5794
  const barePath = this.gitService.getBareRepoPath();
5093
5795
  try {
5094
- await fs7.mkdir(barePath, { recursive: true });
5796
+ await fs11.mkdir(barePath, { recursive: true });
5095
5797
  } catch {
5096
5798
  return null;
5097
5799
  }
@@ -5152,80 +5854,756 @@ var SyncRetryPolicy = class {
5152
5854
  this.logger.info(`\u{1F504} Retrying synchronization...
5153
5855
  `);
5154
5856
  }
5155
- },
5156
- lfsRetryHandler: () => {
5157
- if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
5158
- this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
5159
- this.gitService.setLfsSkipEnabled(true);
5160
- syncContext.lfsSkipEnabled = true;
5857
+ },
5858
+ lfsRetryHandler: () => {
5859
+ if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
5860
+ this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
5861
+ this.gitService.setLfsSkipEnabled(true);
5862
+ syncContext.lfsSkipEnabled = true;
5863
+ }
5864
+ }
5865
+ };
5866
+ }
5867
+ resetLfsSkipIfNeeded(syncContext) {
5868
+ if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
5869
+ this.gitService.setLfsSkipEnabled(false);
5870
+ }
5871
+ }
5872
+ };
5873
+
5874
+ // src/services/trash-migration.service.ts
5875
+ import * as fs12 from "fs/promises";
5876
+ import * as path14 from "path";
5877
+ var REMOVED_ENTRY_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-(.+)$/;
5878
+ var TrashMigrationService = class {
5879
+ constructor(config, trashService, logger) {
5880
+ this.config = config;
5881
+ this.trashService = trashService;
5882
+ this.logger = logger;
5883
+ }
5884
+ config;
5885
+ trashService;
5886
+ logger;
5887
+ updateLogger(logger) {
5888
+ this.logger = logger;
5889
+ }
5890
+ isEnabled() {
5891
+ return this.trashService.isEnabled() && (this.config.trash?.migrateLegacy ?? DEFAULT_CONFIG.TRASH.MIGRATE_LEGACY);
5892
+ }
5893
+ async migrateLegacyUnlocked() {
5894
+ if (!this.isEnabled()) return;
5895
+ await this.migrateRemovedDir();
5896
+ await this.migrateDivergedDir();
5897
+ }
5898
+ async migrateRemovedDir() {
5899
+ const removedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.REMOVED_DIR_NAME);
5900
+ const names = await this.listDirectories(removedDir);
5901
+ for (const name of names) {
5902
+ const match = REMOVED_ENTRY_RE.exec(name);
5903
+ const quarantinedAt = match ? this.parseQuarantineTimestamp(match[1]) : null;
5904
+ if (!match || !quarantinedAt) {
5905
+ this.logger.warn(`\u26A0\uFE0F Leaving unrecognized entry '${name}' in ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ alone`);
5906
+ continue;
5907
+ }
5908
+ try {
5909
+ const entry = await this.trashService.trashDirectory({
5910
+ dirPath: path14.join(removedDir, name),
5911
+ reason: "legacy-adopt",
5912
+ source: ".removed",
5913
+ legacyOriginalName: name,
5914
+ legacyQuarantinedAt: quarantinedAt,
5915
+ headOid: null,
5916
+ originalPath: path14.join(this.config.worktreeDir, match[2]),
5917
+ auditAction: "trash_adopt"
5918
+ });
5919
+ this.logger.info(
5920
+ `\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
5921
+ );
5922
+ } catch (error) {
5923
+ this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
5924
+ }
5925
+ }
5926
+ await fs12.rmdir(removedDir).catch(() => void 0);
5927
+ }
5928
+ async migrateDivergedDir() {
5929
+ const divergedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5930
+ const names = await this.listDirectories(divergedDir);
5931
+ for (const name of names) {
5932
+ const dirPath = path14.join(divergedDir, name);
5933
+ const info = await this.readDivergedInfo(dirPath);
5934
+ const quarantinedAt = info?.divergedAt ? new Date(info.divergedAt) : null;
5935
+ const hasOriginalPath = typeof info?.originalPath === "string" && info.originalPath.length > 0;
5936
+ if (!info || !info.originalBranch || !hasOriginalPath || !quarantinedAt || Number.isNaN(quarantinedAt.getTime())) {
5937
+ this.logger.warn(
5938
+ `\u26A0\uFE0F Leaving entry '${name}' in ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ alone (no parseable ${METADATA_CONSTANTS.DIVERGED_INFO_FILE})`
5939
+ );
5940
+ continue;
5941
+ }
5942
+ try {
5943
+ const entry = await this.trashService.trashDirectory({
5944
+ dirPath,
5945
+ reason: "legacy-adopt",
5946
+ source: ".diverged",
5947
+ branch: info.originalBranch,
5948
+ legacyOriginalName: name,
5949
+ legacyQuarantinedAt: quarantinedAt,
5950
+ headOid: info.localCommit ?? null,
5951
+ originalPath: info.originalPath,
5952
+ auditAction: "trash_adopt",
5953
+ keepPinOnReap: true
5954
+ });
5955
+ this.logger.info(
5956
+ `\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
5957
+ );
5958
+ } catch (error) {
5959
+ this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
5960
+ }
5961
+ }
5962
+ await fs12.rmdir(divergedDir).catch(() => void 0);
5963
+ }
5964
+ async listDirectories(dirPath) {
5965
+ try {
5966
+ const dirents = await fs12.readdir(dirPath, { withFileTypes: true });
5967
+ return dirents.filter((dirent) => dirent.isDirectory() && !dirent.isSymbolicLink()).map((dirent) => dirent.name);
5968
+ } catch (error) {
5969
+ if (error.code !== "ENOENT") {
5970
+ this.logger.warn(`\u26A0\uFE0F Cannot scan '${dirPath}' for legacy trash adoption: ${getErrorMessage(error)}`);
5971
+ }
5972
+ return [];
5973
+ }
5974
+ }
5975
+ async readDivergedInfo(dirPath) {
5976
+ try {
5977
+ const raw = await fs12.readFile(path14.join(dirPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE), "utf-8");
5978
+ return JSON.parse(raw);
5979
+ } catch {
5980
+ return null;
5981
+ }
5982
+ }
5983
+ // quarantine timestamps replaced [:.] with "-": 2026-06-06T18-34-18-123Z
5984
+ parseQuarantineTimestamp(raw) {
5985
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(raw);
5986
+ if (!match) return null;
5987
+ const date = /* @__PURE__ */ new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`);
5988
+ return Number.isNaN(date.getTime()) ? null : date;
5989
+ }
5990
+ };
5991
+
5992
+ // src/services/trash-reaper.service.ts
5993
+ import * as fs14 from "fs/promises";
5994
+ import * as path16 from "path";
5995
+
5996
+ // src/utils/disk-space.ts
5997
+ import fastFolderSize from "fast-folder-size";
5998
+ async function calculateDirectorySize(dirPath) {
5999
+ return new Promise((resolve14, reject) => {
6000
+ fastFolderSize(dirPath, (err, bytes) => {
6001
+ if (err) {
6002
+ reject(err);
6003
+ return;
6004
+ }
6005
+ if (bytes === void 0) {
6006
+ reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
6007
+ return;
6008
+ }
6009
+ resolve14(bytes);
6010
+ });
6011
+ });
6012
+ }
6013
+ function formatBytes(bytes) {
6014
+ if (bytes === 0) return "0 B";
6015
+ const units = ["B", "KB", "MB", "GB", "TB"];
6016
+ const k = 1024;
6017
+ const decimals = 2;
6018
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
6019
+ const value = bytes / Math.pow(k, i);
6020
+ return `${value.toFixed(decimals)} ${units[i]}`;
6021
+ }
6022
+ async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
6023
+ try {
6024
+ let totalBytes = 0;
6025
+ for (const repoPath of repoPaths) {
6026
+ try {
6027
+ totalBytes += await calculateDirectorySize(repoPath);
6028
+ } catch {
6029
+ }
6030
+ }
6031
+ for (const worktreeDir of worktreeDirs) {
6032
+ try {
6033
+ totalBytes += await calculateDirectorySize(worktreeDir);
6034
+ } catch {
6035
+ }
6036
+ }
6037
+ return formatBytes(totalBytes);
6038
+ } catch (error) {
6039
+ console.error("Failed to calculate disk space:", error);
6040
+ return "N/A";
6041
+ }
6042
+ }
6043
+
6044
+ // src/services/trash.service.ts
6045
+ import { randomBytes } from "crypto";
6046
+ import * as fs13 from "fs/promises";
6047
+ import * as path15 from "path";
6048
+ function isWorktreeRestorable(manifest) {
6049
+ return manifest.branch !== null && manifest.headOid !== null && manifest.pinRef !== null;
6050
+ }
6051
+ function summarizeTrashEntries(entries) {
6052
+ let totalSizeBytes = 0;
6053
+ let unknownSizeCount = 0;
6054
+ let soonest = null;
6055
+ for (const { manifest } of entries) {
6056
+ if (manifest.sizeBytes === null) {
6057
+ unknownSizeCount++;
6058
+ } else {
6059
+ totalSizeBytes += manifest.sizeBytes;
6060
+ }
6061
+ if (soonest === null || manifest.expiresAt < soonest) {
6062
+ soonest = manifest.expiresAt;
6063
+ }
6064
+ }
6065
+ return { itemCount: entries.length, totalSizeBytes, unknownSizeCount, soonestExpiresAt: soonest };
6066
+ }
6067
+ var TrashService = class {
6068
+ constructor(config, gitService, logger, removalAudit) {
6069
+ this.config = config;
6070
+ this.gitService = gitService;
6071
+ this.logger = logger;
6072
+ this.removalAudit = removalAudit;
6073
+ }
6074
+ config;
6075
+ gitService;
6076
+ logger;
6077
+ removalAudit;
6078
+ updateLogger(logger) {
6079
+ this.logger = logger;
6080
+ }
6081
+ isEnabled() {
6082
+ return this.config.trash?.enabled ?? DEFAULT_CONFIG.TRASH.ENABLED;
6083
+ }
6084
+ getTrashRoot() {
6085
+ return path15.join(this.config.worktreeDir, GIT_CONSTANTS.TRASH_DIR_NAME);
6086
+ }
6087
+ getRetentionDays() {
6088
+ return this.config.trash?.retentionDays ?? DEFAULT_CONFIG.TRASH.RETENTION_DAYS;
6089
+ }
6090
+ async trashDirectory(options) {
6091
+ const deletedAt = /* @__PURE__ */ new Date();
6092
+ const expiresAt = new Date(deletedAt.getTime() + this.getRetentionDays() * 864e5);
6093
+ const keepPinOnReap = options.keepPinOnReap ?? false;
6094
+ const headOid = options.headOid !== void 0 ? options.headOid : await this.resolveHeadOid(options);
6095
+ if (keepPinOnReap && !headOid) {
6096
+ throw new TrashOperationError(
6097
+ "trash-directory",
6098
+ `cannot create keep-on-reap trash entry for '${options.dirPath}': HEAD commit could not be resolved`
6099
+ );
6100
+ }
6101
+ const sizeBytes = await calculateDirectorySize(options.dirPath).catch(() => null);
6102
+ await fs13.mkdir(this.getTrashRoot(), { recursive: true });
6103
+ const { id, containerPath } = await this.createContainer(deletedAt, path15.basename(options.dirPath));
6104
+ const manifest = {
6105
+ schemaVersion: TRASH_CONSTANTS.SCHEMA_VERSION,
6106
+ id,
6107
+ deletedAt: deletedAt.toISOString(),
6108
+ expiresAt: expiresAt.toISOString(),
6109
+ originalPath: path15.resolve(options.originalPath ?? options.dirPath),
6110
+ branch: options.branch ?? null,
6111
+ reason: options.reason,
6112
+ sizeBytes,
6113
+ headOid,
6114
+ pinRef: null,
6115
+ bundleFile: null,
6116
+ source: options.source ?? "worktree",
6117
+ legacyOriginalName: options.legacyOriginalName ?? null,
6118
+ legacyQuarantinedAt: options.legacyQuarantinedAt?.toISOString() ?? null,
6119
+ keepPinOnReap
6120
+ };
6121
+ try {
6122
+ await this.writeManifest(containerPath, manifest);
6123
+ } catch (error) {
6124
+ await this.undoPartialTrash(containerPath, null);
6125
+ throw new TrashOperationError(
6126
+ "trash-directory",
6127
+ `cannot write trash manifest for '${options.dirPath}': ${getErrorMessage(error)}`,
6128
+ error instanceof Error ? error : void 0
6129
+ );
6130
+ }
6131
+ const pinRef = headOid ? await this.createPinRef(id, headOid) : null;
6132
+ if (keepPinOnReap && !pinRef) {
6133
+ await this.undoPartialTrash(containerPath, pinRef);
6134
+ throw new TrashOperationError(
6135
+ "trash-directory",
6136
+ `cannot create keep-on-reap trash entry '${id}' for '${options.dirPath}': pin ref could not be created`
6137
+ );
6138
+ }
6139
+ let bundleFile = null;
6140
+ if (keepPinOnReap && pinRef) {
6141
+ try {
6142
+ const created = await this.gitService.createBundleFromRef(
6143
+ path15.join(containerPath, TRASH_CONSTANTS.BUNDLE_FILENAME),
6144
+ pinRef
6145
+ );
6146
+ bundleFile = created ? TRASH_CONSTANTS.BUNDLE_FILENAME : null;
6147
+ } catch (error) {
6148
+ await this.undoPartialTrash(containerPath, pinRef);
6149
+ throw new TrashOperationError(
6150
+ "trash-directory",
6151
+ `cannot bundle commits for keep-on-reap trash entry '${id}': ${getErrorMessage(error)}`,
6152
+ error instanceof Error ? error : void 0
6153
+ );
6154
+ }
6155
+ }
6156
+ const payloadPath = path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME);
6157
+ manifest.pinRef = pinRef;
6158
+ manifest.bundleFile = bundleFile;
6159
+ try {
6160
+ await this.writeManifest(containerPath, manifest);
6161
+ await fs13.rename(options.dirPath, payloadPath);
6162
+ } catch (error) {
6163
+ await this.undoPartialTrash(containerPath, pinRef);
6164
+ const hint = error.code === "EXDEV" ? " (trash lives inside worktreeDir; a cross-device rename means the directory is on a different filesystem \u2014 co-locate it or set trash.enabled=false)" : "";
6165
+ throw new TrashOperationError(
6166
+ "trash-directory",
6167
+ `cannot move '${options.dirPath}' to trash${hint}: ${getErrorMessage(error)}`,
6168
+ error instanceof Error ? error : void 0
6169
+ );
6170
+ }
6171
+ await this.removalAudit.record({
6172
+ action: options.auditAction ?? "trash_create",
6173
+ result: "success",
6174
+ path: manifest.originalPath,
6175
+ branch: manifest.branch ?? void 0,
6176
+ trashId: id,
6177
+ trashPath: payloadPath
6178
+ }).catch(
6179
+ (auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
6180
+ );
6181
+ return { manifest, containerPath, payloadPath };
6182
+ }
6183
+ async listEntries() {
6184
+ const root = this.getTrashRoot();
6185
+ const entries = [];
6186
+ const invalid = [];
6187
+ let dirents;
6188
+ try {
6189
+ dirents = await fs13.readdir(root, { withFileTypes: true });
6190
+ } catch (error) {
6191
+ if (error.code === "ENOENT") {
6192
+ return { entries, invalid };
6193
+ }
6194
+ throw error;
6195
+ }
6196
+ for (const dirent of dirents) {
6197
+ const containerPath = path15.join(root, dirent.name);
6198
+ if (dirent.isSymbolicLink()) {
6199
+ invalid.push(containerPath);
6200
+ continue;
6201
+ }
6202
+ if (!dirent.isDirectory()) {
6203
+ continue;
6204
+ }
6205
+ const manifest = await this.readManifest(containerPath);
6206
+ if (manifest === null) {
6207
+ invalid.push(containerPath);
6208
+ continue;
6209
+ }
6210
+ entries.push({
6211
+ manifest,
6212
+ containerPath,
6213
+ payloadPath: path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME)
6214
+ });
6215
+ }
6216
+ return { entries, invalid };
6217
+ }
6218
+ // The full reversible-removal sequence shared by prune and manual removal:
6219
+ // payload to trash, dangling registration cleared, branch ref deleted.
6220
+ // A ref-delete failure is a hygiene problem, not a failed removal — the
6221
+ // payload and pin ref already capture everything restore needs, and restore
6222
+ // tolerates a leftover ref at the trashed commit.
6223
+ async trashAndUnregisterWorktree(options) {
6224
+ const entry = await this.trashDirectory(options);
6225
+ await this.gitService.removeWorktree(options.dirPath, { force: true });
6226
+ let branchRefError;
6227
+ try {
6228
+ await this.deleteTrashedBranchRef(entry.manifest);
6229
+ } catch (refError) {
6230
+ branchRefError = getErrorMessage(refError);
6231
+ this.logger.warn(
6232
+ `\u26A0\uFE0F Leftover branch ref '${entry.manifest.branch}' after trashing '${entry.manifest.id}': ${branchRefError}`
6233
+ );
6234
+ }
6235
+ return { entry, branchRefError };
6236
+ }
6237
+ async restore(id) {
6238
+ const { entries } = await this.listEntries();
6239
+ const entry = entries.find((candidate) => candidate.manifest.id === id);
6240
+ if (!entry) {
6241
+ throw new TrashOperationError("restore", `no trash entry with id '${id}'`);
6242
+ }
6243
+ const { manifest, containerPath, payloadPath } = entry;
6244
+ if (await probePathExists(payloadPath) !== "exists") {
6245
+ throw new TrashOperationError("restore", `payload missing or unverifiable for '${id}' at '${payloadPath}'`);
6246
+ }
6247
+ const destinationProbe = await probePathExists(manifest.originalPath);
6248
+ if (destinationProbe !== "missing") {
6249
+ const why = destinationProbe === "exists" ? "already exists" : "cannot be verified";
6250
+ const hint = manifest.reason === "diverged-replace" && destinationProbe === "exists" ? " \u2014 a fresh worktree replaced this one when the branch diverged; remove that worktree first, or copy the files you need out of the trash payload manually" : "";
6251
+ throw new TrashOperationError("restore", `destination '${manifest.originalPath}' ${why}${hint}`);
6252
+ }
6253
+ if (isWorktreeRestorable(manifest)) {
6254
+ await this.restoreAsWorktree(manifest, payloadPath);
6255
+ } else {
6256
+ if (manifest.branch) {
6257
+ this.logger.warn(
6258
+ `\u26A0\uFE0F Trash entry '${id}' has no pinned commit; restoring files only \u2014 the directory will not be a registered worktree.`
6259
+ );
6260
+ }
6261
+ await fs13.rename(payloadPath, manifest.originalPath);
6262
+ }
6263
+ await fs13.rm(containerPath, { recursive: true, force: true }).catch(
6264
+ (error) => this.logger.warn(`\u26A0\uFE0F Failed to remove restored trash container '${containerPath}': ${getErrorMessage(error)}`)
6265
+ );
6266
+ if (manifest.pinRef) {
6267
+ await this.gitService.deleteRef(manifest.pinRef).catch(
6268
+ (error) => this.logger.warn(`\u26A0\uFE0F Failed to delete pin ref '${manifest.pinRef}': ${getErrorMessage(error)}`)
6269
+ );
6270
+ }
6271
+ await this.removalAudit.record({
6272
+ action: "trash_restore",
6273
+ result: "success",
6274
+ path: manifest.originalPath,
6275
+ branch: manifest.branch ?? void 0,
6276
+ trashId: id
6277
+ }).catch(
6278
+ (auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
6279
+ );
6280
+ return manifest;
6281
+ }
6282
+ async deleteTrashedBranchRef(manifest) {
6283
+ if (!manifest.branch) return;
6284
+ if (!manifest.pinRef) {
6285
+ this.logger.warn(
6286
+ `\u26A0\uFE0F Keeping branch ref '${manifest.branch}' after trashing '${manifest.id}': entry has no pin ref, so the ref is the only gc protection left`
6287
+ );
6288
+ return;
6289
+ }
6290
+ try {
6291
+ await this.gitService.deleteLocalBranch(manifest.branch);
6292
+ } catch (error) {
6293
+ throw new TrashOperationError(
6294
+ "trash-branch-ref",
6295
+ `cannot delete branch ref '${manifest.branch}' after trashing '${manifest.id}': ${getErrorMessage(error)}`,
6296
+ error instanceof Error ? error : void 0
6297
+ );
6298
+ }
6299
+ }
6300
+ async restoreAsWorktree(manifest, payloadPath) {
6301
+ const branch = manifest.branch;
6302
+ const headOid = manifest.headOid;
6303
+ const existingBranchOid = await this.gitService.getLocalBranchCommit(branch);
6304
+ let createdBranch = false;
6305
+ if (existingBranchOid !== null && existingBranchOid !== headOid) {
6306
+ throw new TrashOperationError(
6307
+ "restore",
6308
+ `branch '${branch}' already exists at ${existingBranchOid}; expected trashed commit ${headOid}. Restore the files manually from '${payloadPath}' or move that branch first`
6309
+ );
6310
+ }
6311
+ if (existingBranchOid === null) {
6312
+ await this.gitService.createBranchAt(branch, headOid);
6313
+ createdBranch = true;
6314
+ }
6315
+ try {
6316
+ await this.gitService.addWorktreeNoCheckout(branch, manifest.originalPath);
6317
+ await this.copyPayloadOver(payloadPath, manifest.originalPath);
6318
+ await this.gitService.resetWorktreeIndex(manifest.originalPath);
6319
+ if (this.config.sparseCheckout) {
6320
+ await this.gitService.getSparseCheckoutService().applyToWorktree(manifest.originalPath, this.config.sparseCheckout);
6321
+ }
6322
+ } catch (error) {
6323
+ await this.gitService.removeWorktree(manifest.originalPath, { force: true }).catch(
6324
+ (rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (worktree) failed: ${getErrorMessage(rollbackError)}`)
6325
+ );
6326
+ if (createdBranch) {
6327
+ await this.gitService.deleteLocalBranch(branch).catch(
6328
+ (rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (branch) failed: ${getErrorMessage(rollbackError)}`)
6329
+ );
6330
+ }
6331
+ throw new TrashOperationError(
6332
+ "restore",
6333
+ `failed to recreate worktree for '${manifest.id}'; trash entry left intact: ${getErrorMessage(error)}`,
6334
+ error instanceof Error ? error : void 0
6335
+ );
6336
+ }
6337
+ }
6338
+ // The payload's top-level .git link points at a pruned admin dir; the fresh
6339
+ // one written by `worktree add --no-checkout` must survive the overlay.
6340
+ async copyPayloadOver(payloadPath, destination) {
6341
+ await fs13.cp(payloadPath, destination, {
6342
+ recursive: true,
6343
+ force: true,
6344
+ filter: (source) => !(path15.dirname(source) === payloadPath && path15.basename(source) === PATH_CONSTANTS.GIT_DIR)
6345
+ });
6346
+ }
6347
+ async resolveHeadOid(options) {
6348
+ if (!options.branch) return null;
6349
+ try {
6350
+ return (await this.gitService.getCurrentCommit(options.dirPath)).trim();
6351
+ } catch (error) {
6352
+ this.logger.warn(
6353
+ `\u26A0\uFE0F Could not resolve HEAD for '${options.dirPath}'; trash entry will preserve files only: ${getErrorMessage(error)}`
6354
+ );
6355
+ return null;
6356
+ }
6357
+ }
6358
+ // Pin failure degrades to a files-only trash entry rather than blocking the
6359
+ // removal — the payload itself is still fully preserved either way.
6360
+ async createPinRef(id, headOid) {
6361
+ const refName = `${GIT_CONSTANTS.TRASH_REF_PREFIX}${id}`;
6362
+ try {
6363
+ await this.gitService.updateRef(refName, headOid);
6364
+ return refName;
6365
+ } catch (error) {
6366
+ this.logger.warn(
6367
+ `\u26A0\uFE0F Could not pin '${headOid}' for trash entry '${id}'; git gc may collect its objects: ${getErrorMessage(error)}`
6368
+ );
6369
+ return null;
6370
+ }
6371
+ }
6372
+ async writeManifest(containerPath, manifest) {
6373
+ const manifestPath = path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME);
6374
+ await atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2));
6375
+ }
6376
+ async readManifest(containerPath) {
6377
+ try {
6378
+ const raw = await fs13.readFile(path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME), "utf-8");
6379
+ const parsed = JSON.parse(raw);
6380
+ if (typeof parsed.id !== "string" || typeof parsed.expiresAt !== "string" || typeof parsed.originalPath !== "string") {
6381
+ return null;
6382
+ }
6383
+ return parsed;
6384
+ } catch {
6385
+ return null;
6386
+ }
6387
+ }
6388
+ async undoPartialTrash(containerPath, pinRef) {
6389
+ await fs13.rm(containerPath, { recursive: true, force: true }).catch(() => void 0);
6390
+ if (pinRef) {
6391
+ await this.gitService.deleteRef(pinRef).catch(() => void 0);
6392
+ }
6393
+ }
6394
+ async createContainer(deletedAt, baseName) {
6395
+ let lastError;
6396
+ for (let attempt = 0; attempt < 3; attempt++) {
6397
+ const id = this.generateId(deletedAt, baseName);
6398
+ const containerPath = path15.join(this.getTrashRoot(), id);
6399
+ try {
6400
+ await fs13.mkdir(containerPath);
6401
+ return { id, containerPath };
6402
+ } catch (error) {
6403
+ lastError = error;
6404
+ if (error.code !== "EEXIST") break;
6405
+ }
6406
+ }
6407
+ throw new TrashOperationError(
6408
+ "trash-directory",
6409
+ `cannot create trash container for '${baseName}': ${getErrorMessage(lastError)}`,
6410
+ lastError instanceof Error ? lastError : void 0
6411
+ );
6412
+ }
6413
+ // The id doubles as a refname component (refs/sync-worktrees/trash/<id>).
6414
+ // The timestamp prefix and hex suffix rule out leading dots and ".lock"
6415
+ // endings, but ".." inside the name would still make the ref invalid and
6416
+ // silently degrade the entry to files-only.
6417
+ generateId(deletedAt, baseName) {
6418
+ const timestamp = filenameTimestamp(deletedAt);
6419
+ const safeName = baseName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
6420
+ return `${timestamp}-${safeName}-${randomBytes(3).toString("hex")}`;
6421
+ }
6422
+ };
6423
+
6424
+ // src/services/trash-reaper.service.ts
6425
+ var TrashReaperService = class {
6426
+ constructor(config, trashService, logger, removalAudit, gitService) {
6427
+ this.config = config;
6428
+ this.trashService = trashService;
6429
+ this.logger = logger;
6430
+ this.removalAudit = removalAudit;
6431
+ this.gitService = gitService;
6432
+ }
6433
+ config;
6434
+ trashService;
6435
+ logger;
6436
+ removalAudit;
6437
+ gitService;
6438
+ updateLogger(logger) {
6439
+ this.logger = logger;
6440
+ }
6441
+ // Disabled trash means "don't touch my trash" — existing entries are left
6442
+ // alone rather than aged out behind the user's back.
6443
+ async reapExpiredUnlocked(now = /* @__PURE__ */ new Date()) {
6444
+ if (!this.trashService.isEnabled()) return;
6445
+ let realRoot;
6446
+ try {
6447
+ realRoot = await fs14.realpath(this.trashService.getTrashRoot());
6448
+ } catch (error) {
6449
+ if (error.code === "ENOENT") {
6450
+ this.logger.debug(`Trash reaper: no trash root; skipping pin-ref sweep`);
6451
+ return;
6452
+ }
6453
+ this.logger.warn(`\u26A0\uFE0F Trash reaper skipped: cannot resolve trash root: ${getErrorMessage(error)}`);
6454
+ return;
6455
+ }
6456
+ const { entries, invalid } = await this.trashService.listEntries();
6457
+ for (const invalidPath of invalid) {
6458
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unrecognized entry '${invalidPath}' alone (no valid manifest)`);
6459
+ }
6460
+ const reapedIds = /* @__PURE__ */ new Set();
6461
+ for (const entry of entries) {
6462
+ const expiresAt = new Date(entry.manifest.expiresAt);
6463
+ if (Number.isNaN(expiresAt.getTime())) {
6464
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' has an unparseable expiry; skipping`);
6465
+ continue;
6466
+ }
6467
+ if (expiresAt.getTime() > now.getTime()) continue;
6468
+ try {
6469
+ const realEntry = await fs14.realpath(entry.containerPath);
6470
+ if (!realEntry.startsWith(realRoot + path16.sep)) {
6471
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' resolves outside the trash root; skipping`);
6472
+ continue;
6473
+ }
6474
+ } catch (error) {
6475
+ this.logger.warn(
6476
+ `\u26A0\uFE0F Trash reaper: cannot verify path of entry '${entry.manifest.id}'; skipping: ${getErrorMessage(error)}`
6477
+ );
6478
+ continue;
6479
+ }
6480
+ let keepRef = null;
6481
+ if (entry.manifest.keepPinOnReap && entry.manifest.headOid) {
6482
+ keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}${entry.manifest.id}`;
6483
+ try {
6484
+ await this.gitService.updateRef(keepRef, entry.manifest.headOid);
6485
+ } catch (error) {
6486
+ this.logger.warn(
6487
+ `\u26A0\uFE0F Trash reaper: cannot create keep ref '${keepRef}' for '${entry.manifest.id}'; deferring reap: ${getErrorMessage(error)}`
6488
+ );
6489
+ continue;
5161
6490
  }
5162
6491
  }
5163
- };
6492
+ try {
6493
+ await this.removalAudit.record({
6494
+ action: "trash_reap",
6495
+ result: "attempt",
6496
+ path: entry.manifest.originalPath,
6497
+ branch: entry.manifest.branch ?? void 0,
6498
+ trashId: entry.manifest.id,
6499
+ trashPath: entry.payloadPath
6500
+ });
6501
+ } catch (auditError) {
6502
+ this.logger.warn(
6503
+ `\u26A0\uFE0F Trash reaper: cannot write audit log; skipping '${entry.manifest.id}': ${getErrorMessage(auditError)}`
6504
+ );
6505
+ continue;
6506
+ }
6507
+ try {
6508
+ await fs14.rm(entry.containerPath, { recursive: true, force: true });
6509
+ } catch (error) {
6510
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete '${entry.manifest.id}': ${getErrorMessage(error)}`);
6511
+ await this.removalAudit.record({
6512
+ action: "trash_reap",
6513
+ result: "failure",
6514
+ path: entry.manifest.originalPath,
6515
+ trashId: entry.manifest.id,
6516
+ error: getErrorMessage(error)
6517
+ }).catch(() => void 0);
6518
+ continue;
6519
+ }
6520
+ if (entry.manifest.pinRef) {
6521
+ await this.gitService.deleteRef(entry.manifest.pinRef).catch(
6522
+ (error) => this.logger.warn(
6523
+ `\u26A0\uFE0F Trash reaper: failed to delete pin ref '${entry.manifest.pinRef}': ${getErrorMessage(error)}`
6524
+ )
6525
+ );
6526
+ }
6527
+ reapedIds.add(entry.manifest.id);
6528
+ this.logger.info(
6529
+ `\u{1F5D1}\uFE0F Trash reaper: deleted expired entry '${entry.manifest.id}' (trashed ${entry.manifest.deletedAt})`
6530
+ );
6531
+ if (keepRef) {
6532
+ this.logger.info(
6533
+ ` Commits remain recoverable at '${keepRef}' (${entry.manifest.headOid}) \u2014 recover with: git branch <name> ${entry.manifest.headOid}`
6534
+ );
6535
+ }
6536
+ await this.removalAudit.record({
6537
+ action: "trash_reap",
6538
+ result: "success",
6539
+ path: entry.manifest.originalPath,
6540
+ trashId: entry.manifest.id
6541
+ }).catch(
6542
+ (auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
6543
+ );
6544
+ }
6545
+ let containerNames = null;
6546
+ try {
6547
+ containerNames = new Set(await fs14.readdir(realRoot));
6548
+ } catch (error) {
6549
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot scan trash root for pin-ref sweep: ${getErrorMessage(error)}`);
6550
+ }
6551
+ if (containerNames !== null) {
6552
+ await this.reapOrphanedPinRefs(containerNames);
6553
+ }
6554
+ this.warnIfOverThreshold(entries.filter((entry) => !reapedIds.has(entry.manifest.id)));
5164
6555
  }
5165
- resetLfsSkipIfNeeded(syncContext) {
5166
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
5167
- this.gitService.setLfsSkipEnabled(false);
6556
+ // Pin refs whose trash container is gone would pin objects forever (failed
6557
+ // ref delete during restore, manually emptied trash). Keyed on container
6558
+ // existence, NOT manifest validity — an invalid-manifest entry still owns
6559
+ // its pin because the reaper refuses to delete its payload. Deliberately
6560
+ // any dirent name counts (files, symlinks): deleting a pin is irreversible
6561
+ // once gc runs, while a stray name collision merely keeps one ref alive.
6562
+ async reapOrphanedPinRefs(containerNames) {
6563
+ let refs;
6564
+ try {
6565
+ refs = await this.gitService.listRefs(GIT_CONSTANTS.TRASH_REF_PREFIX.replace(/\/$/, ""));
6566
+ } catch (error) {
6567
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot list pin refs: ${getErrorMessage(error)}`);
6568
+ return;
6569
+ }
6570
+ for (const ref of refs) {
6571
+ if (!ref.startsWith(GIT_CONSTANTS.TRASH_REF_PREFIX)) continue;
6572
+ const id = ref.slice(GIT_CONSTANTS.TRASH_REF_PREFIX.length);
6573
+ if (id.length === 0 || id.includes("/")) {
6574
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unexpected ref '${ref}' alone`);
6575
+ continue;
6576
+ }
6577
+ if (containerNames.has(id)) continue;
6578
+ try {
6579
+ await this.gitService.deleteRef(ref);
6580
+ this.logger.info(`\u{1F5D1}\uFE0F Trash reaper: deleted orphaned pin ref '${ref}'`);
6581
+ } catch (error) {
6582
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete orphaned pin ref '${ref}': ${getErrorMessage(error)}`);
6583
+ }
6584
+ }
6585
+ }
6586
+ warnIfOverThreshold(remaining) {
6587
+ const warnSizeBytes = this.config.trash?.warnSizeBytes;
6588
+ if (warnSizeBytes === void 0) return;
6589
+ const summary = summarizeTrashEntries(remaining);
6590
+ if (summary.totalSizeBytes > warnSizeBytes) {
6591
+ this.logger.warn(
6592
+ `\u26A0\uFE0F Trash holds ${formatBytes(summary.totalSizeBytes)} across ${summary.itemCount} entries (threshold ${formatBytes(warnSizeBytes)}). Entries expire ${this.trashService.getRetentionDays()} days after removal.`
6593
+ );
5168
6594
  }
5169
6595
  }
5170
6596
  };
5171
6597
 
5172
6598
  // src/services/worktree-mode-sync-runner.ts
5173
- import * as fs9 from "fs/promises";
5174
- import * as path13 from "path";
6599
+ import * as fs16 from "fs/promises";
6600
+ import * as path19 from "path";
5175
6601
  import pLimit from "p-limit";
5176
6602
 
5177
- // src/utils/date-filter.ts
5178
- function parseDuration(durationStr) {
5179
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
5180
- if (!match) {
5181
- return null;
5182
- }
5183
- const value = parseInt(match[1], 10);
5184
- const unit = match[2];
5185
- const multipliers = {
5186
- h: 60 * 60 * 1e3,
5187
- // hours
5188
- d: 24 * 60 * 60 * 1e3,
5189
- // days
5190
- w: 7 * 24 * 60 * 60 * 1e3,
5191
- // weeks
5192
- m: 30 * 24 * 60 * 60 * 1e3,
5193
- // months (approximate)
5194
- y: 365 * 24 * 60 * 60 * 1e3
5195
- // years (approximate)
5196
- };
5197
- return value * multipliers[unit];
5198
- }
5199
- function filterBranchesByAge(branches, maxAge) {
5200
- const maxAgeMs = parseDuration(maxAge);
5201
- if (maxAgeMs === null) {
5202
- console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
5203
- return branches;
5204
- }
5205
- const cutoffDate = new Date(Date.now() - maxAgeMs);
5206
- return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
5207
- }
5208
- function formatDuration2(durationStr) {
5209
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
5210
- if (!match) {
5211
- return durationStr;
5212
- }
5213
- const value = parseInt(match[1], 10);
5214
- const unit = match[2];
5215
- const unitNames = {
5216
- h: value === 1 ? "hour" : "hours",
5217
- d: value === 1 ? "day" : "days",
5218
- w: value === 1 ? "week" : "weeks",
5219
- m: value === 1 ? "month" : "months",
5220
- y: value === 1 ? "year" : "years"
5221
- };
5222
- return `${value} ${unitNames[unit]}`;
5223
- }
5224
-
5225
6603
  // src/services/path-resolution.service.ts
5226
6604
  import { createHash as createHash2 } from "crypto";
5227
- import * as fs8 from "fs";
5228
- import * as path11 from "path";
6605
+ import * as fs15 from "fs";
6606
+ import * as path17 from "path";
5229
6607
  var BRANCH_STEM_MAX = 80;
5230
6608
  var BRANCH_HASH_LEN = 8;
5231
6609
  var PathResolutionService = class {
@@ -5235,22 +6613,22 @@ var PathResolutionService = class {
5235
6613
  return `${stem}-${hash}`;
5236
6614
  }
5237
6615
  getBranchWorktreePath(worktreeDir, branchName) {
5238
- return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
6616
+ return path17.join(worktreeDir, this.sanitizeBranchName(branchName));
5239
6617
  }
5240
6618
  resolveRealPath(inputPath) {
5241
- const absolute = path11.resolve(inputPath);
6619
+ const absolute = path17.resolve(inputPath);
5242
6620
  const missing = [];
5243
6621
  let current = absolute;
5244
- while (!fs8.existsSync(current)) {
5245
- const parent = path11.dirname(current);
6622
+ while (!fs15.existsSync(current)) {
6623
+ const parent = path17.dirname(current);
5246
6624
  if (parent === current) {
5247
6625
  return absolute;
5248
6626
  }
5249
- missing.unshift(path11.basename(current));
6627
+ missing.unshift(path17.basename(current));
5250
6628
  current = parent;
5251
6629
  }
5252
6630
  try {
5253
- return path11.join(fs8.realpathSync(current), ...missing);
6631
+ return path17.join(fs15.realpathSync(current), ...missing);
5254
6632
  } catch {
5255
6633
  return absolute;
5256
6634
  }
@@ -5260,7 +6638,7 @@ var PathResolutionService = class {
5260
6638
  const a = fold(resolved);
5261
6639
  const b = fold(resolvedBase);
5262
6640
  if (a === b) return true;
5263
- return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
6641
+ return a.length > b.length && a.charAt(b.length) === path17.sep && a.startsWith(b);
5264
6642
  }
5265
6643
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
5266
6644
  const resolved = this.resolveRealPath(worktreePath);
@@ -5268,7 +6646,7 @@ var PathResolutionService = class {
5268
6646
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
5269
6647
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
5270
6648
  }
5271
- return path11.relative(resolvedBase, resolved);
6649
+ return path17.relative(resolvedBase, resolved);
5272
6650
  }
5273
6651
  isPathInsideBaseDir(targetPath, baseDir) {
5274
6652
  const resolved = this.resolveRealPath(targetPath);
@@ -5281,7 +6659,7 @@ var PathResolutionService = class {
5281
6659
  };
5282
6660
 
5283
6661
  // src/services/worktree-sync-planner.ts
5284
- import * as path12 from "path";
6662
+ import * as path18 from "path";
5285
6663
  function createWorktreeSyncPlan(inventory, options = {}) {
5286
6664
  return {
5287
6665
  create: planCreateActions(inventory, options),
@@ -5299,12 +6677,12 @@ function planCreateActions(inventory, options = {}) {
5299
6677
  );
5300
6678
  const reservedPaths = /* @__PURE__ */ new Map();
5301
6679
  for (const worktree of inventory.existingWorktrees) {
5302
- reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
6680
+ reservedPaths.set(path18.resolve(worktree.path), worktree.branch);
5303
6681
  }
5304
6682
  const actions = [];
5305
6683
  for (const branch of newBranches) {
5306
6684
  const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
5307
- const resolved = path12.resolve(worktreePath);
6685
+ const resolved = path18.resolve(worktreePath);
5308
6686
  const conflictingBranch = reservedPaths.get(resolved);
5309
6687
  if (conflictingBranch && conflictingBranch !== branch) {
5310
6688
  actions.push({
@@ -5342,25 +6720,30 @@ function planSparseActions(inventory, sparseCheckout) {
5342
6720
 
5343
6721
  // src/services/worktree-mode-sync-runner.ts
5344
6722
  var WorktreeModeSyncRunner = class {
5345
- constructor(config, gitService, logger, progressEmitter) {
6723
+ constructor(config, gitService, logger, progressEmitter, services) {
5346
6724
  this.config = config;
5347
6725
  this.gitService = gitService;
5348
6726
  this.logger = logger;
5349
6727
  this.progressEmitter = progressEmitter;
6728
+ this.removalAudit = services?.removalAudit ?? new RemovalAuditService(getRemovalAuditLogPath(config));
6729
+ this.trashService = services?.trashService ?? new TrashService(config, gitService, logger, this.removalAudit);
5350
6730
  }
5351
6731
  config;
5352
6732
  gitService;
5353
6733
  logger;
5354
6734
  progressEmitter;
5355
6735
  pathResolution = new PathResolutionService();
6736
+ removalAudit;
6737
+ trashService;
5356
6738
  updateLogger(logger) {
5357
6739
  this.logger = logger;
6740
+ this.trashService.updateLogger(logger);
5358
6741
  }
5359
6742
  async runSyncAttempt(phaseTimer, syncContext, outcome) {
5360
6743
  await this.gitService.pruneWorktrees();
5361
6744
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
5362
6745
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
5363
- await fs9.mkdir(this.config.worktreeDir, { recursive: true });
6746
+ await fs16.mkdir(this.config.worktreeDir, { recursive: true });
5364
6747
  const worktrees = await this.gitService.getWorktrees();
5365
6748
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
5366
6749
  await this.cleanupOrphanedDirectories(worktrees);
@@ -5378,6 +6761,7 @@ var WorktreeModeSyncRunner = class {
5378
6761
  }
5379
6762
  );
5380
6763
  await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
6764
+ await this.recordRemoteBranchTips([...worktrees, ...syncPlan.create.filter((action) => action.kind === "create")]);
5381
6765
  await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
5382
6766
  if (this.config.updateExistingWorktrees !== false) {
5383
6767
  await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
@@ -5400,7 +6784,7 @@ var WorktreeModeSyncRunner = class {
5400
6784
  if (action.kind !== "check-sparse") return;
5401
6785
  try {
5402
6786
  try {
5403
- await fs9.access(action.path);
6787
+ await fs16.access(action.path);
5404
6788
  } catch {
5405
6789
  return;
5406
6790
  }
@@ -5486,7 +6870,7 @@ var WorktreeModeSyncRunner = class {
5486
6870
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
5487
6871
  const remoteBranches = filteredBranches.map((b) => b.branch);
5488
6872
  this.logger.info(
5489
- `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
6873
+ `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
5490
6874
  );
5491
6875
  if (filteredByName.length > remoteBranches.length) {
5492
6876
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -5563,6 +6947,37 @@ var WorktreeModeSyncRunner = class {
5563
6947
  const successCount = results.filter((r) => r.status === "fulfilled").length;
5564
6948
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
5565
6949
  }
6950
+ // Persist each worktree's upstream tip while the remote ref still exists.
6951
+ // This is the proof consulted after a squash-merge deletes the branch:
6952
+ // "HEAD was on the remote before the deletion" — without it every such
6953
+ // worktree reads as having unpushed commits forever. Best-effort: a failed
6954
+ // recording only means that worktree stays conservatively preserved.
6955
+ async recordRemoteBranchTips(worktrees) {
6956
+ try {
6957
+ const tips = await this.gitService.getRemoteBranchTips();
6958
+ if (tips.size === 0) return;
6959
+ const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
6960
+ await Promise.all(
6961
+ worktrees.map(
6962
+ (wt) => limit(async () => {
6963
+ const oid = tips.get(wt.branch);
6964
+ if (!oid) return;
6965
+ await this.gitService.recordRemoteTip(wt.path, wt.branch, oid).catch(
6966
+ (error) => this.logger.warn(` - \u26A0\uFE0F Could not record remote tip for '${wt.branch}': ${getErrorMessage(error)}`)
6967
+ );
6968
+ })
6969
+ )
6970
+ );
6971
+ } catch (error) {
6972
+ this.logger.warn(`\u26A0\uFE0F Could not record remote branch tips: ${getErrorMessage(error)}`);
6973
+ }
6974
+ }
6975
+ // A removal authorized only by the fully-pushed proof must stay reversible:
6976
+ // without trash it would be a permanent delete of commits whose remote
6977
+ // branch may have been deleted unmerged.
6978
+ blockedByDisabledTrash(status) {
6979
+ return status.fullyPushedUpstreamDeleted && !this.trashService.isEnabled();
6980
+ }
5566
6981
  async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
5567
6982
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
5568
6983
  phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
@@ -5592,7 +7007,18 @@ var WorktreeModeSyncRunner = class {
5592
7007
  if (result.status === "fulfilled") {
5593
7008
  const { branchName, worktreePath, status } = result.value;
5594
7009
  if (status.canRemove) {
5595
- toRemove.push({ branchName, worktreePath });
7010
+ if (this.blockedByDisabledTrash(status)) {
7011
+ this.logger.warn(
7012
+ ` - \u26A0\uFE0F '${branchName}' was fully pushed before its remote branch was deleted, but trash is disabled \u2014 keeping worktree. Enable trash for reversible auto-removal, or remove manually.`
7013
+ );
7014
+ outcome.recordSkipped("worktree", "fully_pushed_trash_disabled", {
7015
+ branch: branchName,
7016
+ path: worktreePath,
7017
+ message: "fully pushed before upstream deletion; trash disabled"
7018
+ });
7019
+ } else {
7020
+ toRemove.push({ branchName, worktreePath });
7021
+ }
5596
7022
  } else {
5597
7023
  toSkip.push({ branchName, worktreePath, status });
5598
7024
  }
@@ -5615,7 +7041,7 @@ var WorktreeModeSyncRunner = class {
5615
7041
  ({ branchName, worktreePath }) => removeLimit(async () => {
5616
7042
  try {
5617
7043
  const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
5618
- if (!recheck.canRemove) {
7044
+ if (!recheck.canRemove || this.blockedByDisabledTrash(recheck)) {
5619
7045
  this.logger.warn(
5620
7046
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
5621
7047
  );
@@ -5626,10 +7052,76 @@ var WorktreeModeSyncRunner = class {
5626
7052
  });
5627
7053
  return;
5628
7054
  }
5629
- await this.gitService.removeWorktree(worktreePath);
5630
- this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
5631
- outcome.recordRemoved(branchName, worktreePath);
7055
+ try {
7056
+ await this.removalAudit.record({
7057
+ action: "prune_remove",
7058
+ result: "attempt",
7059
+ path: worktreePath,
7060
+ branch: branchName,
7061
+ status: recheck
7062
+ });
7063
+ } catch (auditError) {
7064
+ this.logger.warn(
7065
+ ` \u26A0\uFE0F Skipping removal of '${branchName}' - cannot write removal audit log: ${getErrorMessage(auditError)}`
7066
+ );
7067
+ outcome.recordSkipped("worktree", "audit_log_unavailable", {
7068
+ branch: branchName,
7069
+ path: worktreePath,
7070
+ message: getErrorMessage(auditError)
7071
+ });
7072
+ return;
7073
+ }
7074
+ if (await probePathExists(worktreePath) === "missing") {
7075
+ await this.gitService.removeWorktree(worktreePath, { force: true });
7076
+ this.logger.info(` \u2705 Cleared dangling registration for '${branchName}' (directory already gone)`);
7077
+ outcome.recordRemoved(branchName, worktreePath);
7078
+ await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
7079
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
7080
+ );
7081
+ return;
7082
+ }
7083
+ let refWarning;
7084
+ if (this.trashService.isEnabled()) {
7085
+ const { entry, branchRefError } = await this.trashService.trashAndUnregisterWorktree({
7086
+ dirPath: worktreePath,
7087
+ branch: branchName,
7088
+ reason: "prune",
7089
+ keepPinOnReap: recheck.fullyPushedUpstreamDeleted
7090
+ });
7091
+ if (branchRefError !== void 0) {
7092
+ refWarning = `leftover_branch_ref: could not delete branch ref '${branchName}': ${branchRefError}`;
7093
+ }
7094
+ const pushedNote = recheck.fullyPushedUpstreamDeleted ? " \u2014 was fully pushed before its remote branch was deleted" : "";
7095
+ this.logger.info(
7096
+ ` \u2705 Moved worktree for '${branchName}' to trash (id: ${entry.manifest.id})${pushedNote}`
7097
+ );
7098
+ } else {
7099
+ await this.gitService.removeWorktree(worktreePath);
7100
+ this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
7101
+ }
7102
+ outcome.recordRemoved(branchName, worktreePath, refWarning);
7103
+ await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
7104
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
7105
+ );
5632
7106
  } catch (error) {
7107
+ if (error instanceof WorktreeNotCleanError) {
7108
+ this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - git refused: ${getErrorMessage(error)}`);
7109
+ outcome.recordSkipped("worktree", "git_refused_removal", {
7110
+ branch: branchName,
7111
+ path: worktreePath,
7112
+ message: getErrorMessage(error)
7113
+ });
7114
+ return;
7115
+ }
7116
+ if (error instanceof TrashOperationError) {
7117
+ this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - ${getErrorMessage(error)}`);
7118
+ outcome.recordSkipped("worktree", "trash_failed", {
7119
+ branch: branchName,
7120
+ path: worktreePath,
7121
+ message: getErrorMessage(error)
7122
+ });
7123
+ return;
7124
+ }
5633
7125
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
5634
7126
  outcome.recordFailed("worktree", getErrorMessage(error), {
5635
7127
  reason: "remove_failed",
@@ -5755,12 +7247,12 @@ var WorktreeModeSyncRunner = class {
5755
7247
  }
5756
7248
  async updateExistingWorktrees(actions, outcome) {
5757
7249
  this.logger.info("Step 4: Checking for worktrees that need updates...");
5758
- const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
7250
+ const divergedDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5759
7251
  try {
5760
- const diverged = await fs9.readdir(divergedDir);
7252
+ const diverged = await fs16.readdir(divergedDir);
5761
7253
  if (diverged.length > 0) {
5762
7254
  this.logger.info(
5763
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
7255
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path19.relative(process.cwd(), divergedDir)}`
5764
7256
  );
5765
7257
  }
5766
7258
  } catch {
@@ -5772,7 +7264,7 @@ var WorktreeModeSyncRunner = class {
5772
7264
  (action) => limit(async () => {
5773
7265
  const worktree = { path: action.path, branch: action.branch };
5774
7266
  try {
5775
- await fs9.access(worktree.path);
7267
+ await fs16.access(worktree.path);
5776
7268
  } catch {
5777
7269
  return { action: "skip", worktree, reason: "missing_worktree_path" };
5778
7270
  }
@@ -5912,13 +7404,13 @@ var WorktreeModeSyncRunner = class {
5912
7404
  }
5913
7405
  async cleanupOrphanedDirectories(worktrees) {
5914
7406
  try {
5915
- const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
5916
- const allDirs = await fs9.readdir(this.config.worktreeDir);
7407
+ const worktreeRelativePaths = worktrees.map((w) => path19.relative(this.config.worktreeDir, w.path));
7408
+ const allDirs = await fs16.readdir(this.config.worktreeDir);
5917
7409
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
5918
7410
  const orphanedDirs = [];
5919
7411
  for (const dir of regularDirs) {
5920
7412
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
5921
- return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
7413
+ return worktreePath === dir || worktreePath.startsWith(dir + path19.sep);
5922
7414
  });
5923
7415
  if (!isPartOfWorktree) {
5924
7416
  orphanedDirs.push(dir);
@@ -5927,13 +7419,46 @@ var WorktreeModeSyncRunner = class {
5927
7419
  if (orphanedDirs.length > 0) {
5928
7420
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
5929
7421
  for (const dir of orphanedDirs) {
5930
- const dirPath = path13.join(this.config.worktreeDir, dir);
7422
+ const dirPath = path19.join(this.config.worktreeDir, dir);
5931
7423
  try {
5932
- const stat3 = await fs9.stat(dirPath);
5933
- if (stat3.isDirectory()) {
5934
- await fs9.rm(dirPath, { recursive: true, force: true });
5935
- this.logger.info(` - Removed orphaned directory: ${dir}`);
7424
+ const stat3 = await fs16.stat(dirPath);
7425
+ if (!stat3.isDirectory()) {
7426
+ continue;
7427
+ }
7428
+ const gitProbe = await probePathExists(path19.join(dirPath, PATH_CONSTANTS.GIT_DIR));
7429
+ if (gitProbe === "unknown") {
7430
+ this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir}: cannot verify it is not a live checkout`);
7431
+ continue;
7432
+ }
7433
+ if (this.trashService.isEnabled()) {
7434
+ try {
7435
+ const entry = await this.trashService.trashDirectory({ dirPath, reason: "orphan" });
7436
+ this.logger.info(` - Moved orphaned directory '${dir}' to trash (id: ${entry.manifest.id})`);
7437
+ } catch (trashError) {
7438
+ this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir} - ${getErrorMessage(trashError)}`);
7439
+ }
7440
+ continue;
7441
+ }
7442
+ if (gitProbe === "exists") {
7443
+ const quarantinePath = await quarantineDirectory(dirPath);
7444
+ this.logger.warn(
7445
+ ` - \u26A0\uFE0F Orphaned directory ${dir} contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
7446
+ );
7447
+ await this.removalAudit.record({ action: "orphan_quarantine", result: "success", path: dirPath, quarantinePath }).catch(
7448
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
7449
+ );
7450
+ continue;
5936
7451
  }
7452
+ try {
7453
+ await this.removalAudit.record({ action: "orphan_delete", result: "attempt", path: dirPath });
7454
+ } catch (auditError) {
7455
+ this.logger.warn(
7456
+ ` - \u26A0\uFE0F Skipping orphaned directory ${dir} - cannot write removal audit log: ${getErrorMessage(auditError)}`
7457
+ );
7458
+ continue;
7459
+ }
7460
+ await fs16.rm(dirPath, { recursive: true, force: true });
7461
+ this.logger.info(` - Removed orphaned directory: ${dir}`);
5937
7462
  } catch (error) {
5938
7463
  this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
5939
7464
  }
@@ -5962,14 +7487,37 @@ var WorktreeModeSyncRunner = class {
5962
7487
  outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
5963
7488
  } else {
5964
7489
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
5965
- const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
5966
- const relativePath = path13.relative(process.cwd(), divergedPath);
7490
+ let keepRef = null;
7491
+ if (!this.trashService.isEnabled()) {
7492
+ const localCommit = (await this.gitService.getCurrentCommit(worktree.path)).trim();
7493
+ keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}diverged-${Date.now().toString(36)}-${this.pathResolution.sanitizeBranchName(worktree.branch)}`;
7494
+ await this.gitService.updateRef(keepRef, localCommit);
7495
+ }
7496
+ const { divergedPath, manifest } = await this.divergeWorktree(worktree.path, worktree.branch);
7497
+ const relativePath = path19.relative(process.cwd(), divergedPath);
5967
7498
  outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
5968
7499
  this.logger.info(` Moved to: ${relativePath}`);
5969
7500
  this.logger.info(` Your local changes are preserved. To review:`);
5970
7501
  this.logger.info(` cd ${relativePath}`);
5971
7502
  this.logger.info(` git diff origin/${worktree.branch}`);
5972
- await this.gitService.removeWorktree(worktree.path);
7503
+ await this.gitService.removeWorktree(worktree.path, { force: true });
7504
+ if (manifest !== null) {
7505
+ await this.trashService.deleteTrashedBranchRef(manifest);
7506
+ } else {
7507
+ await this.gitService.deleteLocalBranch(worktree.branch);
7508
+ this.logger.info(
7509
+ ` Never-pushed commits remain recoverable at '${keepRef}' \u2014 recover with: git branch <name> ${keepRef}`
7510
+ );
7511
+ }
7512
+ await this.removalAudit.record({
7513
+ action: "diverged_replace",
7514
+ result: "success",
7515
+ path: worktree.path,
7516
+ branch: worktree.branch,
7517
+ quarantinePath: divergedPath
7518
+ }).catch(
7519
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
7520
+ );
5973
7521
  await this.gitService.addWorktree(worktree.branch, worktree.path);
5974
7522
  this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
5975
7523
  }
@@ -5988,42 +7536,55 @@ var WorktreeModeSyncRunner = class {
5988
7536
  }
5989
7537
  }
5990
7538
  async divergeWorktree(worktreePath, branchName) {
5991
- const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
7539
+ if (this.trashService.isEnabled()) {
7540
+ const entry = await this.trashService.trashDirectory({
7541
+ dirPath: worktreePath,
7542
+ branch: branchName,
7543
+ reason: "diverged-replace",
7544
+ keepPinOnReap: true
7545
+ });
7546
+ await this.writeDivergedInfoFile(entry.payloadPath, worktreePath, branchName, entry.manifest.headOid);
7547
+ return { divergedPath: entry.payloadPath, manifest: entry.manifest };
7548
+ }
7549
+ const divergedBaseDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5992
7550
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
5993
7551
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
5994
7552
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
5995
7553
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
5996
- const divergedPath = path13.join(divergedBaseDir, divergedName);
5997
- await fs9.mkdir(divergedBaseDir, { recursive: true });
7554
+ const divergedPath = path19.join(divergedBaseDir, divergedName);
7555
+ await fs16.mkdir(divergedBaseDir, { recursive: true });
5998
7556
  try {
5999
- await fs9.rename(worktreePath, divergedPath);
7557
+ await fs16.rename(worktreePath, divergedPath);
6000
7558
  } catch (err) {
6001
7559
  if (err.code === ERROR_MESSAGES.EXDEV) {
6002
- await fs9.cp(worktreePath, divergedPath, { recursive: true });
6003
- await fs9.rm(worktreePath, { recursive: true, force: true });
7560
+ await fs16.cp(worktreePath, divergedPath, { recursive: true });
7561
+ await fs16.rm(worktreePath, { recursive: true, force: true });
6004
7562
  } else {
6005
7563
  throw err;
6006
7564
  }
6007
7565
  }
7566
+ await this.writeDivergedInfoFile(divergedPath, worktreePath, branchName, null);
7567
+ return { divergedPath, manifest: null };
7568
+ }
7569
+ async writeDivergedInfoFile(preservedPath, originalPath, branchName, knownLocalCommit) {
6008
7570
  const metadata = {
6009
7571
  originalBranch: branchName,
6010
7572
  divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
6011
7573
  reason: METADATA_CONSTANTS.DIVERGED_REASON,
6012
- originalPath: worktreePath,
6013
- localCommit: await this.gitService.getCurrentCommit(divergedPath),
7574
+ originalPath,
7575
+ localCommit: knownLocalCommit ?? await this.gitService.getCurrentCommit(preservedPath),
6014
7576
  remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
6015
7577
  instruction: `To preserve your changes:
6016
7578
  1. Review: git diff origin/${branchName}
6017
7579
  2. Keep changes: git push --force-with-lease origin ${branchName}
6018
7580
  3. Discard changes: rm -rf this directory
6019
7581
 
6020
- Original worktree location: ${worktreePath}`
7582
+ Original worktree location: ${originalPath}`
6021
7583
  };
6022
- await fs9.writeFile(
6023
- path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
7584
+ await fs16.writeFile(
7585
+ path19.join(preservedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
6024
7586
  JSON.stringify(metadata, null, 2)
6025
7587
  );
6026
- return divergedPath;
6027
7588
  }
6028
7589
  };
6029
7590
 
@@ -6034,12 +7595,26 @@ var WorktreeSyncService = class {
6034
7595
  this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
6035
7596
  this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
6036
7597
  this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
7598
+ this.maintenanceService = new GitMaintenanceService(config, this.gitService, this.logger);
6037
7599
  this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
7600
+ const removalAudit = new RemovalAuditService(getRemovalAuditLogPath(config));
7601
+ this.trashService = new TrashService(config, this.gitService, this.logger, removalAudit);
7602
+ this.trashReaper = new TrashReaperService(config, this.trashService, this.logger, removalAudit, this.gitService);
7603
+ this.trashMigration = new TrashMigrationService(config, this.trashService, this.logger);
7604
+ if (this.trashService.isEnabled()) {
7605
+ this.gitService.setStaleDirectoryTrasher(
7606
+ async (dirPath) => (await this.trashService.trashDirectory({ dirPath, reason: "orphan" })).payloadPath
7607
+ );
7608
+ }
6038
7609
  this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
6039
7610
  config,
6040
7611
  this.gitService,
6041
7612
  this.logger,
6042
- this.progressEmitter
7613
+ this.progressEmitter,
7614
+ {
7615
+ trashService: this.trashService,
7616
+ removalAudit
7617
+ }
6043
7618
  );
6044
7619
  if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
6045
7620
  this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
@@ -6061,8 +7636,12 @@ var WorktreeSyncService = class {
6061
7636
  repoMutex = pLimit2(1);
6062
7637
  progressEmitter = new ProgressEmitter();
6063
7638
  repoOperationLock;
7639
+ maintenanceService;
6064
7640
  retryPolicy;
6065
7641
  worktreeModeSyncRunner;
7642
+ trashService;
7643
+ trashReaper;
7644
+ trashMigration;
6066
7645
  skipsAccumulator = [];
6067
7646
  lastOutcome = null;
6068
7647
  getRecordedSkips() {
@@ -6086,6 +7665,18 @@ var WorktreeSyncService = class {
6086
7665
  }
6087
7666
  return this.gitService.getWorktrees();
6088
7667
  }
7668
+ async getRemoteBranches() {
7669
+ if (this.cloneSyncService) {
7670
+ return this.cloneSyncService.getRemoteBranches();
7671
+ }
7672
+ return this.gitService.getRemoteBranches();
7673
+ }
7674
+ async checkoutBranch(branchName, options = {}) {
7675
+ if (!this.cloneSyncService) {
7676
+ throw new ConfigError("checkoutBranch is only available for clone-mode repositories", "CLONE_MODE_REQUIRED");
7677
+ }
7678
+ await this.cloneSyncService.checkoutBranch(branchName, options);
7679
+ }
6089
7680
  async initialize() {
6090
7681
  if (this.isInitialized()) return;
6091
7682
  const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
@@ -6115,6 +7706,23 @@ var WorktreeSyncService = class {
6115
7706
  getGitService() {
6116
7707
  return this.gitService;
6117
7708
  }
7709
+ // Restore must hold the repo lock: the reaper, prune, and gc all mutate the
7710
+ // same trash entries and refs at the tail of a sync. wait:true queues behind
7711
+ // an in-flight sync instead of failing fast — restores are explicit user
7712
+ // actions, not periodic work.
7713
+ async restoreFromTrash(id) {
7714
+ const result = await this.runExclusiveRepoOperation(() => this.trashService.restore(id), { wait: true });
7715
+ if (!result.started) {
7716
+ throw new TrashOperationError(
7717
+ "restore",
7718
+ `cannot restore trash entry '${id}': another process holds the repo lock`
7719
+ );
7720
+ }
7721
+ return result.value;
7722
+ }
7723
+ async listTrashEntries() {
7724
+ return this.trashService.listEntries();
7725
+ }
6118
7726
  updateLogger(logger) {
6119
7727
  this.logger = logger;
6120
7728
  this.gitService.updateLogger(logger);
@@ -6122,6 +7730,37 @@ var WorktreeSyncService = class {
6122
7730
  this.retryPolicy.updateLogger(logger);
6123
7731
  this.worktreeModeSyncRunner.updateLogger(logger);
6124
7732
  this.repoOperationLock.updateLogger(logger);
7733
+ this.maintenanceService.updateLogger(logger);
7734
+ this.trashService.updateLogger(logger);
7735
+ this.trashReaper.updateLogger(logger);
7736
+ this.trashMigration.updateLogger(logger);
7737
+ }
7738
+ // Runs git gc when due, inside the already-held repo lock (mirrors
7739
+ // initializeUnlocked — must NOT re-acquire runExclusiveRepoOperation or it
7740
+ // would self-deadlock/skip). Skipped under NODE_ENV=test so unit suites don't
7741
+ // shell out to real git; GitMaintenanceService is covered by its own tests.
7742
+ async runMaintenanceIfDueUnlocked() {
7743
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
7744
+ return;
7745
+ }
7746
+ await this.maintenanceService.runIfDueUnlocked();
7747
+ }
7748
+ // Same contract as runMaintenanceIfDueUnlocked: tail of a successful sync,
7749
+ // inside the held lock, never fails the sync. Runs before gc so freshly
7750
+ // reaped pin refs can be collected in the same maintenance window.
7751
+ async runTrashMaintenanceUnlocked() {
7752
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
7753
+ return;
7754
+ }
7755
+ if (this.cloneSyncService) {
7756
+ return;
7757
+ }
7758
+ try {
7759
+ await this.trashMigration.migrateLegacyUnlocked();
7760
+ await this.trashReaper.reapExpiredUnlocked();
7761
+ } catch (error) {
7762
+ this.logger.warn(`\u26A0\uFE0F Trash maintenance failed: ${getErrorMessage(error)}`);
7763
+ }
6125
7764
  }
6126
7765
  onProgress(listener) {
6127
7766
  return this.progressEmitter.onProgress(listener);
@@ -6157,6 +7796,7 @@ var WorktreeSyncService = class {
6157
7796
  }
6158
7797
  async sync() {
6159
7798
  const result = await this.runExclusiveRepoOperation(async () => {
7799
+ this.clearRecordedSkips();
6160
7800
  const totalTimer = new Timer();
6161
7801
  const phaseTimer = new PhaseTimer();
6162
7802
  const outcome = new SyncOutcomeAccumulator({
@@ -6205,7 +7845,9 @@ var WorktreeSyncService = class {
6205
7845
  const repoName = this.config.name;
6206
7846
  this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
6207
7847
  }
7848
+ await this.runTrashMaintenanceUnlocked();
6208
7849
  }
7850
+ await this.runMaintenanceIfDueUnlocked();
6209
7851
  return this.lastOutcome ?? outcome.toOutcome(durationMs);
6210
7852
  });
6211
7853
  return result.started ? { started: true, outcome: result.value } : result;
@@ -6338,54 +7980,6 @@ var HookExecutionService = class {
6338
7980
  }
6339
7981
  };
6340
7982
 
6341
- // src/utils/disk-space.ts
6342
- import fastFolderSize from "fast-folder-size";
6343
- async function calculateDirectorySize(dirPath) {
6344
- return new Promise((resolve12, reject) => {
6345
- fastFolderSize(dirPath, (err, bytes) => {
6346
- if (err) {
6347
- reject(err);
6348
- return;
6349
- }
6350
- if (bytes === void 0) {
6351
- reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
6352
- return;
6353
- }
6354
- resolve12(bytes);
6355
- });
6356
- });
6357
- }
6358
- function formatBytes(bytes) {
6359
- if (bytes === 0) return "0 B";
6360
- const units = ["B", "KB", "MB", "GB", "TB"];
6361
- const k = 1024;
6362
- const decimals = 2;
6363
- const i = Math.floor(Math.log(bytes) / Math.log(k));
6364
- const value = bytes / Math.pow(k, i);
6365
- return `${value.toFixed(decimals)} ${units[i]}`;
6366
- }
6367
- async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
6368
- try {
6369
- let totalBytes = 0;
6370
- for (const repoPath of repoPaths) {
6371
- try {
6372
- totalBytes += await calculateDirectorySize(repoPath);
6373
- } catch {
6374
- }
6375
- }
6376
- for (const worktreeDir of worktreeDirs) {
6377
- try {
6378
- totalBytes += await calculateDirectorySize(worktreeDir);
6379
- } catch {
6380
- }
6381
- }
6382
- return formatBytes(totalBytes);
6383
- } catch (error) {
6384
- console.error("Failed to calculate disk space:", error);
6385
- return "N/A";
6386
- }
6387
- }
6388
-
6389
7983
  // src/utils/app-events.ts
6390
7984
  var AppEventEmitter = class {
6391
7985
  listeners = /* @__PURE__ */ new Map();
@@ -6422,7 +8016,7 @@ var AppEventEmitter = class {
6422
8016
  };
6423
8017
 
6424
8018
  // src/services/InteractiveUIService.tsx
6425
- import * as fs10 from "fs/promises";
8019
+ import * as fs17 from "fs/promises";
6426
8020
  var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
6427
8021
  var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
6428
8022
  var InteractiveUIService = class {
@@ -6583,8 +8177,8 @@ var InteractiveUIService = class {
6583
8177
  getRepositoryDiskUsage: (index) => this.getRepositoryDiskUsage(index),
6584
8178
  getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
6585
8179
  deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
6586
- openEditorInWorktree: (path18) => this.openEditorInWorktree(path18),
6587
- openTerminalInWorktree: (repoIndex, path18, branchName) => this.openTerminalInWorktree(repoIndex, path18, branchName),
8180
+ openEditorInWorktree: (path24) => this.openEditorInWorktree(path24),
8181
+ openTerminalInWorktree: (repoIndex, path24, branchName) => this.openTerminalInWorktree(repoIndex, path24, branchName),
6588
8182
  copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
6589
8183
  createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
6590
8184
  executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
@@ -6708,14 +8302,14 @@ var InteractiveUIService = class {
6708
8302
  if (Date.now() - startTime > timeoutMs) {
6709
8303
  throw new Error("Timeout waiting for sync operations to complete");
6710
8304
  }
6711
- await new Promise((resolve12) => setTimeout(resolve12, checkInterval));
8305
+ await new Promise((resolve14) => setTimeout(resolve14, checkInterval));
6712
8306
  }
6713
8307
  });
6714
8308
  try {
6715
8309
  await Promise.all(syncChecks);
6716
8310
  } catch {
6717
8311
  this.addLog(
6718
- `Warning: Timeout waiting for sync operations to complete after ${formatDuration(timeoutMs)}. Proceeding with potential data loss risk.`,
8312
+ `Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
6719
8313
  "warn"
6720
8314
  );
6721
8315
  }
@@ -6804,11 +8398,14 @@ var InteractiveUIService = class {
6804
8398
  throw new Error(`Invalid repository index: ${repoIndex}`);
6805
8399
  }
6806
8400
  const service = this.syncServices[repoIndex];
6807
- if (!service.isInitialized()) {
8401
+ if (!service.isInitialized() && !service.isCloneMode()) {
8402
+ return [];
8403
+ }
8404
+ try {
8405
+ return await service.getRemoteBranches();
8406
+ } catch {
6808
8407
  return [];
6809
8408
  }
6810
- const gitService = service.getGitService();
6811
- return gitService.getRemoteBranches();
6812
8409
  }
6813
8410
  getDefaultBranchForRepo(repoIndex) {
6814
8411
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -6827,6 +8424,9 @@ var InteractiveUIService = class {
6827
8424
  if (!service.isInitialized()) {
6828
8425
  await service.initializeUnlocked();
6829
8426
  }
8427
+ if (service.isCloneMode()) {
8428
+ return;
8429
+ }
6830
8430
  await service.getGitService().fetchAll();
6831
8431
  });
6832
8432
  if (!result.started) {
@@ -6904,22 +8504,22 @@ var InteractiveUIService = class {
6904
8504
  }
6905
8505
  const service = this.syncServices[repoIndex];
6906
8506
  const worktreeDir = service.config.worktreeDir;
6907
- const divergedDir = path14.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
8507
+ const divergedDir = path20.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6908
8508
  let dirEntries;
6909
8509
  try {
6910
- dirEntries = await fs10.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
8510
+ dirEntries = await fs17.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
6911
8511
  } catch {
6912
8512
  return [];
6913
8513
  }
6914
8514
  const subdirs = dirEntries.filter((e) => e.isDirectory());
6915
8515
  const results = await Promise.allSettled(
6916
8516
  subdirs.map(async (entry) => {
6917
- const fullPath = path14.join(divergedDir, entry.name);
6918
- const infoFilePath = path14.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
8517
+ const fullPath = path20.join(divergedDir, entry.name);
8518
+ const infoFilePath = path20.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
6919
8519
  let originalBranch = entry.name;
6920
8520
  let divergedAt = "";
6921
8521
  try {
6922
- const infoContent = await fs10.readFile(infoFilePath, "utf-8");
8522
+ const infoContent = await fs17.readFile(infoFilePath, "utf-8");
6923
8523
  const info = JSON.parse(infoContent);
6924
8524
  if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
6925
8525
  if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
@@ -6950,15 +8550,15 @@ var InteractiveUIService = class {
6950
8550
  }
6951
8551
  const service = this.syncServices[repoIndex];
6952
8552
  const worktreeDir = service.config.worktreeDir;
6953
- const divergedBase = path14.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
8553
+ const divergedBase = path20.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6954
8554
  if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
6955
8555
  throw new Error(`Invalid diverged directory name: "${name}"`);
6956
8556
  }
6957
- const targetPath = path14.join(divergedBase, name);
8557
+ const targetPath = path20.join(divergedBase, name);
6958
8558
  if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
6959
8559
  throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
6960
8560
  }
6961
- await fs10.rm(targetPath, { recursive: true, force: true });
8561
+ await fs17.rm(targetPath, { recursive: true, force: true });
6962
8562
  this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
6963
8563
  }
6964
8564
  async createWorktreeForBranch(repoIndex, branchName) {
@@ -6970,6 +8570,10 @@ var InteractiveUIService = class {
6970
8570
  const worktreeDir = service.config.worktreeDir;
6971
8571
  const worktreePath = this.pathResolution.getBranchWorktreePath(worktreeDir, branchName);
6972
8572
  const result = await service.runQueuedRepoOperation(async () => {
8573
+ if (service.isCloneMode()) {
8574
+ await service.checkoutBranch(branchName, { allowConfigDrift: true });
8575
+ return;
8576
+ }
6973
8577
  await gitService.addWorktree(branchName, worktreePath);
6974
8578
  });
6975
8579
  if (!result.started) {
@@ -7316,8 +8920,8 @@ function parseArguments(argv = hideBin(process.argv)) {
7316
8920
  }
7317
8921
 
7318
8922
  // src/utils/config-generator.ts
7319
- import * as fs11 from "fs/promises";
7320
- import * as path15 from "path";
8923
+ import * as fs18 from "fs/promises";
8924
+ import * as path21 from "path";
7321
8925
  function serializeToESM(obj, indent = 0) {
7322
8926
  const spaces = " ".repeat(indent);
7323
8927
  const innerSpaces = " ".repeat(indent + 2);
@@ -7347,9 +8951,9 @@ ${spaces}}`;
7347
8951
  return String(obj);
7348
8952
  }
7349
8953
  async function generateConfigFile(input2, configPath, options = {}) {
7350
- const configDir = path15.dirname(configPath);
7351
- await fs11.mkdir(configDir, { recursive: true });
7352
- const worktreeDirRelative = path15.relative(configDir, input2.worktreeDir);
8954
+ const configDir = path21.dirname(configPath);
8955
+ await fs18.mkdir(configDir, { recursive: true });
8956
+ const worktreeDirRelative = path21.relative(configDir, input2.worktreeDir);
7353
8957
  const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
7354
8958
  const repoName = extractRepoNameFromUrl(input2.repoUrl);
7355
8959
  const repository = {
@@ -7358,7 +8962,7 @@ async function generateConfigFile(input2, configPath, options = {}) {
7358
8962
  worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
7359
8963
  };
7360
8964
  if (input2.bareRepoDir) {
7361
- const bareRepoDirRelative = path15.relative(configDir, input2.bareRepoDir);
8965
+ const bareRepoDirRelative = path21.relative(configDir, input2.bareRepoDir);
7362
8966
  const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
7363
8967
  repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
7364
8968
  }
@@ -7385,7 +8989,7 @@ const config = ${serializeToESM(configObject)};
7385
8989
  export default config;
7386
8990
  `;
7387
8991
  try {
7388
- await fs11.writeFile(configPath, configContent, {
8992
+ await fs18.writeFile(configPath, configContent, {
7389
8993
  encoding: "utf-8",
7390
8994
  flag: options.overwrite ? "w" : "wx"
7391
8995
  });
@@ -7397,11 +9001,11 @@ export default config;
7397
9001
  }
7398
9002
  }
7399
9003
  function getDefaultConfigPath() {
7400
- return path15.join(process.cwd(), "sync-worktrees.config.js");
9004
+ return path21.join(process.cwd(), "sync-worktrees.config.js");
7401
9005
  }
7402
9006
  async function findConfigInCwd(cwd = process.cwd()) {
7403
9007
  for (const name of CONFIG_FILE_NAMES) {
7404
- const full = path15.join(cwd, name);
9008
+ const full = path21.join(cwd, name);
7405
9009
  if (await fileExists(full)) {
7406
9010
  return full;
7407
9011
  }
@@ -7410,7 +9014,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
7410
9014
  }
7411
9015
 
7412
9016
  // src/utils/interactive.ts
7413
- import * as path16 from "path";
9017
+ import * as path22 from "path";
7414
9018
  import { confirm, input, select } from "@inquirer/prompts";
7415
9019
  async function promptForInitConfig() {
7416
9020
  console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
@@ -7441,8 +9045,8 @@ async function promptForInitConfig() {
7441
9045
  if (!worktreeDir.trim() && defaultWorktreeDir) {
7442
9046
  worktreeDir = defaultWorktreeDir;
7443
9047
  }
7444
- if (!path16.isAbsolute(worktreeDir)) {
7445
- worktreeDir = path16.resolve(worktreeDir);
9048
+ if (!path22.isAbsolute(worktreeDir)) {
9049
+ worktreeDir = path22.resolve(worktreeDir);
7446
9050
  }
7447
9051
  let bareRepoDir;
7448
9052
  const askForBareDir = await confirm({
@@ -7460,8 +9064,8 @@ async function promptForInitConfig() {
7460
9064
  return true;
7461
9065
  }
7462
9066
  });
7463
- if (!path16.isAbsolute(bareRepoDir)) {
7464
- bareRepoDir = path16.resolve(bareRepoDir);
9067
+ if (!path22.isAbsolute(bareRepoDir)) {
9068
+ bareRepoDir = path22.resolve(bareRepoDir);
7465
9069
  }
7466
9070
  }
7467
9071
  const runMode = await select({
@@ -7714,7 +9318,7 @@ async function runFromConfigFile(configPath, runOnceOverride = false) {
7714
9318
  await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
7715
9319
  }
7716
9320
  async function resolveConfigOrExit(cliPath) {
7717
- const resolved = cliPath ? path17.resolve(cliPath) : await findConfigInCwd();
9321
+ const resolved = cliPath ? path23.resolve(cliPath) : await findConfigInCwd();
7718
9322
  if (!resolved) {
7719
9323
  console.error(
7720
9324
  "\u274C No config file found. Pass --config <path>, run `sync-worktrees init` to create one, or place a sync-worktrees.config.{js,mjs,cjs} in this directory."
@@ -7730,7 +9334,7 @@ function exitConfigExists(targetPath) {
7730
9334
  process.exit(1);
7731
9335
  }
7732
9336
  async function runInit(configPath, force) {
7733
- const targetPath = configPath ? path17.resolve(configPath) : getDefaultConfigPath();
9337
+ const targetPath = configPath ? path23.resolve(configPath) : getDefaultConfigPath();
7734
9338
  if (!force && await fileExists(targetPath)) {
7735
9339
  exitConfigExists(targetPath);
7736
9340
  }
@@ -7743,7 +9347,7 @@ async function runInit(configPath, force) {
7743
9347
  }
7744
9348
  throw error;
7745
9349
  }
7746
- const displayPath = path17.relative(process.cwd(), targetPath) || targetPath;
9350
+ const displayPath = path23.relative(process.cwd(), targetPath) || targetPath;
7747
9351
  console.log(`
7748
9352
  \u2705 Configuration saved to: ${targetPath}`);
7749
9353
  console.log(`
@@ -7751,7 +9355,7 @@ async function runInit(configPath, force) {
7751
9355
  }
7752
9356
  async function runSync(options) {
7753
9357
  const configPath = await resolveConfigOrExit(options.config);
7754
- const displayPath = path17.relative(process.cwd(), configPath) || configPath;
9358
+ const displayPath = path23.relative(process.cwd(), configPath) || configPath;
7755
9359
  console.log(`\u{1F4C4} Using config: ${displayPath}`);
7756
9360
  try {
7757
9361
  await runFromConfigFile(configPath, options.runOnce);