sync-worktrees 4.1.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 (65) hide show
  1. package/README.md +135 -55
  2. package/dist/components/App.d.ts.map +1 -1
  3. package/dist/components/BranchCreationWizard.d.ts.map +1 -1
  4. package/dist/components/OpenEditorWizard.d.ts.map +1 -1
  5. package/dist/components/StatusBar.d.ts +1 -0
  6. package/dist/components/StatusBar.d.ts.map +1 -1
  7. package/dist/components/WorktreeStatusView.d.ts.map +1 -1
  8. package/dist/constants.d.ts +22 -0
  9. package/dist/constants.d.ts.map +1 -1
  10. package/dist/errors/index.d.ts +7 -0
  11. package/dist/errors/index.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2201 -510
  15. package/dist/index.js.map +4 -4
  16. package/dist/mcp/context.d.ts +1 -1
  17. package/dist/mcp/context.d.ts.map +1 -1
  18. package/dist/mcp/handlers.d.ts +0 -5
  19. package/dist/mcp/handlers.d.ts.map +1 -1
  20. package/dist/mcp/server.d.ts.map +1 -1
  21. package/dist/mcp/worktree-summary.d.ts.map +1 -1
  22. package/dist/mcp-server.js +2117 -519
  23. package/dist/mcp-server.js.map +4 -4
  24. package/dist/services/InteractiveUIService.d.ts.map +1 -1
  25. package/dist/services/clone-sync.service.d.ts +13 -2
  26. package/dist/services/clone-sync.service.d.ts.map +1 -1
  27. package/dist/services/config-loader.service.d.ts +2 -0
  28. package/dist/services/config-loader.service.d.ts.map +1 -1
  29. package/dist/services/git-maintenance.service.d.ts +44 -0
  30. package/dist/services/git-maintenance.service.d.ts.map +1 -0
  31. package/dist/services/git.service.d.ts +19 -1
  32. package/dist/services/git.service.d.ts.map +1 -1
  33. package/dist/services/removal-audit.service.d.ts +19 -0
  34. package/dist/services/removal-audit.service.d.ts.map +1 -0
  35. package/dist/services/sync-outcome.d.ts +1 -1
  36. package/dist/services/sync-outcome.d.ts.map +1 -1
  37. package/dist/services/trash-migration.service.d.ts +18 -0
  38. package/dist/services/trash-migration.service.d.ts.map +1 -0
  39. package/dist/services/trash-reaper.service.d.ts +18 -0
  40. package/dist/services/trash-reaper.service.d.ts.map +1 -0
  41. package/dist/services/trash.service.d.ts +91 -0
  42. package/dist/services/trash.service.d.ts.map +1 -0
  43. package/dist/services/worktree-metadata.service.d.ts +7 -0
  44. package/dist/services/worktree-metadata.service.d.ts.map +1 -1
  45. package/dist/services/worktree-mode-sync-runner.d.ts +11 -1
  46. package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -1
  47. package/dist/services/worktree-status.service.d.ts +5 -2
  48. package/dist/services/worktree-status.service.d.ts.map +1 -1
  49. package/dist/services/worktree-sync.service.d.ts +21 -2
  50. package/dist/services/worktree-sync.service.d.ts.map +1 -1
  51. package/dist/types/index.d.ts +60 -2
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types/sync-metadata.d.ts +6 -0
  54. package/dist/types/sync-metadata.d.ts.map +1 -1
  55. package/dist/utils/atomic-write.d.ts +2 -0
  56. package/dist/utils/atomic-write.d.ts.map +1 -0
  57. package/dist/utils/file-exists.d.ts +2 -0
  58. package/dist/utils/file-exists.d.ts.map +1 -1
  59. package/dist/utils/filename-timestamp.d.ts +2 -0
  60. package/dist/utils/filename-timestamp.d.ts.map +1 -0
  61. package/dist/utils/lock-path.d.ts +1 -0
  62. package/dist/utils/lock-path.d.ts.map +1 -1
  63. package/dist/utils/quarantine.d.ts +2 -0
  64. package/dist/utils/quarantine.d.ts.map +1 -0
  65. package/package.json +28 -27
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
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
- import pLimit3 from "p-limit";
7
+ import pLimit4 from "p-limit";
8
8
 
9
9
  // src/constants.ts
10
10
  var GIT_CONSTANTS = {
@@ -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",
@@ -141,6 +163,8 @@ var SyncWorktreesError = class extends Error {
141
163
  Caused by: ${cause.stack}`;
142
164
  }
143
165
  }
166
+ code;
167
+ cause;
144
168
  };
145
169
  var GitError = class extends SyncWorktreesError {
146
170
  constructor(message, code, cause) {
@@ -152,17 +176,26 @@ var GitOperationError = class extends GitError {
152
176
  super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
153
177
  }
154
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
+ };
155
186
  var WorktreeError = class extends SyncWorktreesError {
156
187
  constructor(message, code, cause) {
157
188
  super(message, `WORKTREE_${code}`, cause);
158
189
  }
159
190
  };
160
191
  var WorktreeNotCleanError = class extends WorktreeError {
161
- constructor(path18, reasons) {
162
- super(`Worktree at '${path18}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
163
- this.path = path18;
192
+ constructor(path24, reasons) {
193
+ super(`Worktree at '${path24}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
194
+ this.path = path24;
164
195
  this.reasons = reasons;
165
196
  }
197
+ path;
198
+ reasons;
166
199
  };
167
200
  var ConfigError = class extends SyncWorktreesError {
168
201
  constructor(message, code, cause) {
@@ -175,18 +208,34 @@ var ConfigValidationError = class extends ConfigError {
175
208
  this.field = field;
176
209
  this.reason = reason;
177
210
  }
211
+ field;
212
+ reason;
178
213
  };
179
214
  var ConfigFileNotFoundError = class extends ConfigError {
180
215
  constructor(configPath) {
181
216
  super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
182
217
  this.configPath = configPath;
183
218
  }
219
+ configPath;
184
220
  };
185
221
  var ConfigFileExistsError = class extends ConfigError {
186
222
  constructor(configPath) {
187
223
  super(`Config file already exists: ${configPath}`, "FILE_EXISTS");
188
224
  this.configPath = configPath;
189
225
  }
226
+ configPath;
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;
190
239
  };
191
240
 
192
241
  // src/services/config-loader.service.ts
@@ -214,16 +263,73 @@ function filterBranchesByName(branches, include, exclude) {
214
263
  return result;
215
264
  }
216
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
+
217
314
  // src/utils/file-exists.ts
218
315
  import * as fs from "fs/promises";
219
- async function fileExists(path18) {
316
+ async function fileExists(path24) {
220
317
  try {
221
- await fs.access(path18);
318
+ await fs.access(path24);
222
319
  return true;
223
320
  } catch {
224
321
  return false;
225
322
  }
226
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
+ }
227
333
 
228
334
  // src/utils/git-url.ts
229
335
  function extractRepoNameFromUrl(gitUrl) {
@@ -311,7 +417,8 @@ var CLONE_MODE_CONFLICTING_FIELDS = [
311
417
  "branchExclude",
312
418
  "branchMaxAge",
313
419
  "updateExistingWorktrees",
314
- "bareRepoDir"
420
+ "bareRepoDir",
421
+ "trash"
315
422
  ];
316
423
  var ConfigLoaderService = class {
317
424
  async findConfigUpward(startDir) {
@@ -414,6 +521,12 @@ var ConfigLoaderService = class {
414
521
  if (repoObj.sparseCheckout !== void 0) {
415
522
  this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
416
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
+ }
417
530
  this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
418
531
  this.validateRepositoryMode(repoObj, configObj.defaults);
419
532
  });
@@ -450,6 +563,12 @@ var ConfigLoaderService = class {
450
563
  if (defaults.sparseCheckout !== void 0) {
451
564
  this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
452
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
+ }
453
572
  this.validateDepth(defaults.depth, "defaults.depth");
454
573
  if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
455
574
  throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
@@ -477,6 +596,46 @@ var ConfigLoaderService = class {
477
596
  throw new ConfigValidationError(field, "must be a positive safe integer");
478
597
  }
479
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
+ }
480
639
  validateRetryConfig(value, context) {
481
640
  if (typeof value !== "object" || value === null) {
482
641
  throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
@@ -748,6 +907,18 @@ var ConfigLoaderService = class {
748
907
  if (sparse) {
749
908
  resolved.sparseCheckout = sparse;
750
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
+ }
751
922
  return resolved;
752
923
  }
753
924
  isDuplicateRepoUrl(repo, all, defaults) {
@@ -811,16 +982,16 @@ var ConfigLoaderService = class {
811
982
 
812
983
  // src/services/InteractiveUIService.tsx
813
984
  import React8 from "react";
814
- import * as path14 from "path";
985
+ import * as path20 from "path";
815
986
  import { render } from "ink";
816
987
  import * as cron2 from "node-cron";
817
- import pLimit2 from "p-limit";
988
+ import pLimit3 from "p-limit";
818
989
  import { spawn as spawn2, spawnSync } from "child_process";
819
990
  import { existsSync as existsSync2 } from "fs";
820
991
 
821
992
  // src/components/App.tsx
822
993
  import React7, { useState as useState6, useEffect as useEffect6, useCallback as useCallback4, useRef as useRef5 } from "react";
823
- import { Box as Box7, useInput as useInput6, useStdout } from "ink";
994
+ import { Box as Box7, useInput as useInput6, useWindowSize } from "ink";
824
995
 
825
996
  // src/components/StatusBar.tsx
826
997
  import React, { useState, useEffect } from "react";
@@ -829,6 +1000,7 @@ import { CronExpressionParser } from "cron-parser";
829
1000
  var StatusBar = ({
830
1001
  status,
831
1002
  syncProgressEntries = [],
1003
+ activeOps = [],
832
1004
  maxProgressLines = 2,
833
1005
  repositoryCount,
834
1006
  lastSyncTime,
@@ -864,17 +1036,14 @@ var StatusBar = ({
864
1036
  const getStatusIcon = () => {
865
1037
  return status === "syncing" ? "\u27F3" : "\u2713";
866
1038
  };
867
- const formatProgress = (syncProgress) => {
868
- const percent = syncProgress.progress === void 0 || syncProgress.message.includes(`${syncProgress.progress}%`) ? "" : ` ${syncProgress.progress}%`;
869
- return `[${syncProgress.repo}] ${syncProgress.message}${percent}`;
870
- };
1039
+ const formatProgress = (syncProgress) => `[${syncProgress.repo}] ${syncProgress.message}`;
871
1040
  const progressLineCount = Math.max(1, maxProgressLines);
872
1041
  const visibleProgress = syncProgressEntries.slice(-progressLineCount);
873
1042
  return /* @__PURE__ */ React.createElement(Box, { borderStyle: "single", paddingX: 1 }, /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: "100%" }, /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, getStatusIcon(), " Status:", " ", /* @__PURE__ */ React.createElement(Text, { color: getStatusColor() }, status === "syncing" ? "Syncing..." : "Running")), /* @__PURE__ */ React.createElement(Text, null, "Repositories: ", /* @__PURE__ */ React.createElement(Text, { bold: true, color: "cyan" }, repositoryCount))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Last Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(lastSyncTime))), cronSchedule && /* @__PURE__ */ React.createElement(Text, null, "Next Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(nextSyncTime)))), status === "syncing" && Array.from({ length: progressLineCount }).map((_, index) => {
874
1043
  const entry = visibleProgress[index];
875
1044
  const message = entry ? formatProgress(entry) : index === 0 ? "waiting for progress events" : "";
876
1045
  return /* @__PURE__ */ React.createElement(Box, { key: index }, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, message ? "Progress: " : " ", message && /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, message)));
877
- }), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "w"), "tree", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
1046
+ }), activeOps.map((label, index) => /* @__PURE__ */ React.createElement(Box, { key: `op-${index}` }, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "\u23F3 "), /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, label)))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "w"), "tree", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
878
1047
  };
879
1048
  var StatusBar_default = StatusBar;
880
1049
 
@@ -893,7 +1062,7 @@ var HelpModal_default = HelpModal;
893
1062
 
894
1063
  // src/components/BranchCreationWizard.tsx
895
1064
  import React3, { useState as useState2, useEffect as useEffect2, useCallback, useMemo, useRef } from "react";
896
- import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
1065
+ import { Box as Box3, Text as Text3, useInput as useInput2, usePaste } from "ink";
897
1066
 
898
1067
  // src/utils/git-validation.ts
899
1068
  function isValidGitBranchName(name) {
@@ -1159,6 +1328,17 @@ var BranchCreationWizard = ({
1159
1328
  onComplete(result?.success ?? false);
1160
1329
  }
1161
1330
  });
1331
+ usePaste((text) => {
1332
+ if (step === "SELECT_PROJECT") {
1333
+ setProjectFilter((prev) => prev + text);
1334
+ setSelectedProjectIndex(0);
1335
+ } else if (step === "SELECT_BRANCH") {
1336
+ setBranchFilter((prev) => prev + text);
1337
+ setSelectedBranchIndex(0);
1338
+ } else if (step === "ENTER_NAME") {
1339
+ setBranchName((prev) => prev + text.replace(/[^a-zA-Z0-9/._-]/g, ""));
1340
+ }
1341
+ });
1162
1342
  const getStepNumber = () => {
1163
1343
  if (repositories.length === 1) {
1164
1344
  if (step === "SELECT_BRANCH") return 1;
@@ -1252,7 +1432,7 @@ var BranchCreationWizard_default = BranchCreationWizard;
1252
1432
 
1253
1433
  // src/components/OpenEditorWizard.tsx
1254
1434
  import React4, { useState as useState3, useEffect as useEffect3, useMemo as useMemo2, useCallback as useCallback2, useRef as useRef2 } from "react";
1255
- import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
1435
+ import { Box as Box4, Text as Text4, useInput as useInput3, usePaste as usePaste2 } from "ink";
1256
1436
  var OpenEditorWizard = ({
1257
1437
  repositories,
1258
1438
  getWorktreesForRepo,
@@ -1370,6 +1550,15 @@ var OpenEditorWizard = ({
1370
1550
  onClose();
1371
1551
  }
1372
1552
  });
1553
+ usePaste2((text) => {
1554
+ if (step === "SELECT_PROJECT") {
1555
+ setProjectFilter((prev) => prev + text);
1556
+ setSelectedProjectIndex(0);
1557
+ } else if (step === "SELECT_WORKTREE") {
1558
+ setWorktreeFilter((prev) => prev + text);
1559
+ setSelectedWorktreeIndex(0);
1560
+ }
1561
+ });
1373
1562
  const getStepNumber = () => {
1374
1563
  if (repositories.length === 1) {
1375
1564
  return 1;
@@ -1441,7 +1630,7 @@ var OpenEditorWizard_default = OpenEditorWizard;
1441
1630
 
1442
1631
  // src/components/WorktreeStatusView.tsx
1443
1632
  import React5, { useState as useState4, useEffect as useEffect4, useMemo as useMemo3, useCallback as useCallback3, useRef as useRef3 } from "react";
1444
- import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
1633
+ import { Box as Box5, Text as Text5, useInput as useInput4, usePaste as usePaste3 } from "ink";
1445
1634
 
1446
1635
  // src/utils/lfs-error.ts
1447
1636
  function getErrorMessage(error) {
@@ -1486,7 +1675,7 @@ var getStatusFlags = (status) => {
1486
1675
  }
1487
1676
  if (status.hasUnpushedCommits) {
1488
1677
  flags.push(
1489
- /* @__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")
1490
1679
  );
1491
1680
  }
1492
1681
  if (status.hasStashedChanges) {
@@ -1519,7 +1708,9 @@ var getStatusSummary = (status) => {
1519
1708
  if (fileCount > 0) parts.push(`${fileCount} changed`);
1520
1709
  }
1521
1710
  if (status.hasUnpushedCommits && details?.unpushedCommitCount) {
1522
- parts.push(`${details.unpushedCommitCount} unpushed`);
1711
+ parts.push(
1712
+ status.fullyPushedUpstreamDeleted ? "pushed, remote branch deleted" : `${details.unpushedCommitCount} unpushed`
1713
+ );
1523
1714
  }
1524
1715
  if (status.hasStashedChanges && details?.stashCount) {
1525
1716
  parts.push(`${details.stashCount} stash`);
@@ -1747,6 +1938,17 @@ var WorktreeStatusView = ({
1747
1938
  onClose();
1748
1939
  }
1749
1940
  });
1941
+ usePaste3((text) => {
1942
+ if (confirmDelete !== null) return;
1943
+ if (step === "SELECT_PROJECT") {
1944
+ setProjectFilter((prev) => prev + text);
1945
+ setSelectedProjectIndex(0);
1946
+ } else if (step === "VIEW_STATUS" && !loading) {
1947
+ setEntryFilter((prev) => prev + text);
1948
+ setSelectedEntryIndex(0);
1949
+ setExpandedEntry(null);
1950
+ }
1951
+ });
1750
1952
  const getStepNumber = () => {
1751
1953
  if (repositories.length === 1) return 1;
1752
1954
  return step === "SELECT_PROJECT" ? 1 : 2;
@@ -1770,7 +1972,7 @@ var WorktreeStatusView = ({
1770
1972
  const renderDetailPanel = (entry) => {
1771
1973
  const { status } = entry;
1772
1974
  const details = status.details;
1773
- 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(", ")));
1774
1976
  };
1775
1977
  const renderDivergedDetailPanel = (entry) => {
1776
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));
@@ -1971,13 +2173,15 @@ var App = ({
1971
2173
  const [showOpenEditorWizard, setShowOpenEditorWizard] = useState6(false);
1972
2174
  const [showWorktreeStatus, setShowWorktreeStatus] = useState6(false);
1973
2175
  const [status, setStatus] = useState6("idle");
2176
+ const [activeOps, setActiveOps] = useState6([]);
2177
+ const opIdRef = useRef5(0);
1974
2178
  const [syncProgressEntries, setSyncProgressEntries] = useState6([]);
1975
2179
  const [lastSyncTime, setLastSyncTime] = useState6(null);
1976
2180
  const [diskSpaceUsed, setDiskSpaceUsed] = useState6(null);
1977
2181
  const [logs, setLogs] = useState6([]);
1978
2182
  const [repoCount, setRepoCount] = useState6(repositoryCount);
1979
2183
  const [schedule2, setSchedule] = useState6(cronSchedule);
1980
- const { stdout } = useStdout();
2184
+ const { rows } = useWindowSize();
1981
2185
  const addLog = useCallback4((message, level = "info") => {
1982
2186
  setLogs((prev) => {
1983
2187
  const newLogs = [
@@ -2011,11 +2215,11 @@ var App = ({
2011
2215
  onQuit().catch((err) => console.error("Quit failed:", err));
2012
2216
  } else if (input2 === "?" || input2 === "h") {
2013
2217
  setShowHelp(true);
2014
- } else if (input2 === "c" && status === "idle") {
2218
+ } else if (input2 === "c") {
2015
2219
  setShowBranchWizard(true);
2016
- } else if (input2 === "o" && status === "idle") {
2220
+ } else if (input2 === "o") {
2017
2221
  setShowOpenEditorWizard(true);
2018
- } else if (input2 === "w" && status === "idle" && getWorktreeStatusForRepo) {
2222
+ } else if (input2 === "w" && getWorktreeStatusForRepo) {
2019
2223
  setShowWorktreeStatus(true);
2020
2224
  } else if (input2 === "s" && status !== "syncing") {
2021
2225
  setStatus("syncing");
@@ -2092,8 +2296,8 @@ var App = ({
2092
2296
  };
2093
2297
  }, []);
2094
2298
  const progressLineCount = status === "syncing" ? Math.max(1, maxProgressLines) : 0;
2095
- const statusBarHeight = 5 + progressLineCount;
2096
- const terminalRows = stdout.rows ?? 24;
2299
+ const statusBarHeight = 5 + progressLineCount + activeOps.length;
2300
+ const terminalRows = rows ?? 24;
2097
2301
  const logPanelHeight = Math.max(5, terminalRows - statusBarHeight);
2098
2302
  const showModal = showHelp || showBranchWizard || showOpenEditorWizard || showWorktreeStatus;
2099
2303
  return /* @__PURE__ */ React7.createElement(Box7, { flexDirection: "column", minHeight: terminalRows }, !showModal && /* @__PURE__ */ React7.createElement(LogPanel_default, { logs, height: logPanelHeight, isActive: !showModal }), showHelp && /* @__PURE__ */ React7.createElement(HelpModal_default, { onClose: () => setShowHelp(false) }), showBranchWizard && /* @__PURE__ */ React7.createElement(
@@ -2106,7 +2310,8 @@ var App = ({
2106
2310
  createAndPushBranch,
2107
2311
  onClose: () => setShowBranchWizard(false),
2108
2312
  onBranchCreated: (context) => {
2109
- setStatus("syncing");
2313
+ const opId = ++opIdRef.current;
2314
+ setActiveOps((prev) => [...prev, { id: opId, label: `Creating worktree ${context.newBranch}` }]);
2110
2315
  (async () => {
2111
2316
  try {
2112
2317
  await createWorktreeForBranch(context.repoIndex, context.newBranch);
@@ -2135,7 +2340,7 @@ var App = ({
2135
2340
  level: "error"
2136
2341
  });
2137
2342
  } finally {
2138
- setStatus("idle");
2343
+ setActiveOps((prev) => prev.filter((op) => op.id !== opId));
2139
2344
  }
2140
2345
  })().catch((err) => console.error("Branch creation unhandled error:", err));
2141
2346
  },
@@ -2167,6 +2372,7 @@ var App = ({
2167
2372
  {
2168
2373
  status,
2169
2374
  syncProgressEntries,
2375
+ activeOps: activeOps.map((op) => op.label),
2170
2376
  maxProgressLines,
2171
2377
  repositoryCount: repoCount,
2172
2378
  lastSyncTime,
@@ -2177,6 +2383,34 @@ var App = ({
2177
2383
  };
2178
2384
  var App_default = App;
2179
2385
 
2386
+ // src/services/worktree-sync.service.ts
2387
+ import pLimit2 from "p-limit";
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
+
2180
2414
  // src/utils/retry.ts
2181
2415
  var DEFAULT_OPTIONS = {
2182
2416
  maxAttempts: "unlimited",
@@ -2247,7 +2481,7 @@ async function retry(fn, options = {}) {
2247
2481
  const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
2248
2482
  const delay = baseDelay + jitter;
2249
2483
  opts.onRetry(error, attempt, lfsContext);
2250
- await new Promise((resolve12) => setTimeout(resolve12, delay));
2484
+ await new Promise((resolve14) => setTimeout(resolve14, delay));
2251
2485
  attempt++;
2252
2486
  }
2253
2487
  }
@@ -2318,7 +2552,7 @@ var PhaseTimer = class {
2318
2552
  return results;
2319
2553
  }
2320
2554
  };
2321
- function formatDuration(ms) {
2555
+ function formatDuration2(ms) {
2322
2556
  if (ms < 1e3) {
2323
2557
  return `${ms}ms`;
2324
2558
  }
@@ -2340,7 +2574,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2340
2574
  }
2341
2575
  });
2342
2576
  table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
2343
- table.push(["Total Sync", formatDuration(totalDuration), ""]);
2577
+ table.push(["Total Sync", formatDuration2(totalDuration), ""]);
2344
2578
  for (let i = 0; i < phaseResults.length; i++) {
2345
2579
  const result = phaseResults[i];
2346
2580
  const isLast = i === phaseResults.length - 1;
@@ -2348,14 +2582,14 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2348
2582
  const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
2349
2583
  const name = ` ${prefix} ${result.name}${countStr}`;
2350
2584
  const efficiency = result.efficiency ? `${result.efficiency}%` : "";
2351
- table.push([name, formatDuration(result.duration), efficiency]);
2585
+ table.push([name, formatDuration2(result.duration), efficiency]);
2352
2586
  }
2353
2587
  return table.toString();
2354
2588
  }
2355
2589
 
2356
2590
  // src/services/clone-sync.service.ts
2357
2591
  import * as fs3 from "fs/promises";
2358
- import * as path4 from "path";
2592
+ import * as path5 from "path";
2359
2593
  import simpleGit from "simple-git";
2360
2594
 
2361
2595
  // src/utils/git-progress.ts
@@ -2384,7 +2618,7 @@ function makeGitProgressHandler(logger, emitProgress) {
2384
2618
 
2385
2619
  // src/services/file-copy.service.ts
2386
2620
  import * as fs2 from "fs/promises";
2387
- import * as path3 from "path";
2621
+ import * as path4 from "path";
2388
2622
  import { glob } from "glob";
2389
2623
  var DEFAULT_IGNORE_PATTERNS = [
2390
2624
  "**/node_modules/**",
@@ -2411,8 +2645,8 @@ var FileCopyService = class {
2411
2645
  }
2412
2646
  const filesToCopy = await this.expandPatterns(sourceDir, patterns);
2413
2647
  for (const relativePath of filesToCopy) {
2414
- const sourcePath = path3.join(sourceDir, relativePath);
2415
- const destPath = path3.join(destDir, relativePath);
2648
+ const sourcePath = path4.join(sourceDir, relativePath);
2649
+ const destPath = path4.join(destDir, relativePath);
2416
2650
  try {
2417
2651
  const copied = await this.copyFile(sourcePath, destPath);
2418
2652
  if (copied) {
@@ -2451,7 +2685,7 @@ var FileCopyService = class {
2451
2685
  if (await fileExists(destPath)) {
2452
2686
  return false;
2453
2687
  }
2454
- const destDir = path3.dirname(destPath);
2688
+ const destDir = path4.dirname(destPath);
2455
2689
  await fs2.mkdir(destDir, { recursive: true });
2456
2690
  await fs2.copyFile(sourcePath, destPath);
2457
2691
  return true;
@@ -2513,7 +2747,7 @@ var BranchCreatedActionsService = class {
2513
2747
  function formatCloneSkipReason(reason) {
2514
2748
  switch (reason.kind) {
2515
2749
  case "branch_mismatch":
2516
- 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`;
2517
2751
  case "head_unreadable":
2518
2752
  return `could not read HEAD: ${reason.error}`;
2519
2753
  case "dirty_tree":
@@ -2577,20 +2811,21 @@ var SyncOutcomeAccumulator = class {
2577
2811
  constructor(options) {
2578
2812
  this.options = options;
2579
2813
  }
2814
+ options;
2580
2815
  counts = cloneCounts(EMPTY_COUNTS);
2581
2816
  actions = [];
2582
2817
  add(action) {
2583
2818
  this.actions.push(action);
2584
2819
  this.counts[countKeyFor(action)]++;
2585
2820
  }
2586
- recordCreated(branch, path18) {
2587
- this.add({ kind: "created", branch, path: path18 });
2821
+ recordCreated(branch, path24) {
2822
+ this.add({ kind: "created", branch, path: path24 });
2588
2823
  }
2589
- recordRemoved(branch, path18) {
2590
- this.add({ kind: "removed", branch, path: path18 });
2824
+ recordRemoved(branch, path24, warning) {
2825
+ this.add({ kind: "removed", branch, path: path24, ...warning !== void 0 && { warning } });
2591
2826
  }
2592
- recordUpdated(branch, path18, reason) {
2593
- this.add({ kind: "updated", branch, path: path18, reason });
2827
+ recordUpdated(branch, path24, reason) {
2828
+ this.add({ kind: "updated", branch, path: path24, reason });
2594
2829
  }
2595
2830
  recordNoop(scope, reason, details) {
2596
2831
  this.add({ kind: "noop", scope, reason, ...details });
@@ -2598,8 +2833,8 @@ var SyncOutcomeAccumulator = class {
2598
2833
  recordSkipped(scope, reason, details) {
2599
2834
  this.add({ kind: "skipped", scope, reason, ...details });
2600
2835
  }
2601
- recordPreservedDiverged(branch, path18, preservedPath) {
2602
- this.add({ kind: "preserved-diverged", branch, path: path18, preservedPath });
2836
+ recordPreservedDiverged(branch, path24, preservedPath) {
2837
+ this.add({ kind: "preserved-diverged", branch, path: path24, preservedPath });
2603
2838
  }
2604
2839
  recordFailed(scope, error, details = {}) {
2605
2840
  this.add({ kind: "failed", scope, error, ...details });
@@ -2642,7 +2877,6 @@ function cloneSkipToOutcomeAction(reason, details = {}) {
2642
2877
  }
2643
2878
 
2644
2879
  // src/services/clone-sync.service.ts
2645
- var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
2646
2880
  var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
2647
2881
  var CloneSyncService = class {
2648
2882
  constructor(config, gitService, logger, options = {}) {
@@ -2653,6 +2887,9 @@ var CloneSyncService = class {
2653
2887
  this.progressEmitter = options.progressEmitter;
2654
2888
  this.onSkip = options.onSkip;
2655
2889
  }
2890
+ config;
2891
+ gitService;
2892
+ logger;
2656
2893
  initialized = false;
2657
2894
  resolvedBranch = null;
2658
2895
  branchCreatedActions;
@@ -2673,8 +2910,8 @@ var CloneSyncService = class {
2673
2910
  this.pendingInitSkip = null;
2674
2911
  }
2675
2912
  async getWorktrees() {
2676
- const worktreeDir = path4.resolve(this.config.worktreeDir);
2677
- 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))) {
2678
2915
  return [];
2679
2916
  }
2680
2917
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
@@ -2758,40 +2995,27 @@ var CloneSyncService = class {
2758
2995
  return env;
2759
2996
  }
2760
2997
  buildCloneArgs(branch) {
2761
- const args = ["--branch", branch, "--progress"];
2998
+ const args = ["--branch", branch, "--single-branch", "--no-tags", "--progress"];
2762
2999
  if (this.config.depth !== void 0) {
2763
- args.push("--depth", String(this.config.depth), "--no-single-branch");
3000
+ args.push("--depth", String(this.config.depth));
2764
3001
  }
2765
3002
  return args;
2766
3003
  }
2767
- async buildFetchArgs(git) {
2768
- 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"];
2769
3009
  if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
2770
3010
  args.push("--depth", String(this.config.depth));
2771
3011
  }
3012
+ args.push(this.getBranchRefspec(branch));
2772
3013
  return args;
2773
3014
  }
2774
- async ensureAllRemoteBranchesRefspec(git) {
2775
- let fetchRefspecs = [];
2776
- try {
2777
- const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
2778
- fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2779
- } catch {
2780
- fetchRefspecs = [];
2781
- }
2782
- if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
2783
- const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
2784
- this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
2785
- await git.raw(["remote", "set-branches", "origin", "*"]);
2786
- for (const refspec of customRefspecs) {
2787
- await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
2788
- }
2789
- }
2790
- isOriginRemoteBranchTrackingRefspec(refspec) {
2791
- const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
2792
- if (withoutForce.startsWith("^")) return false;
2793
- const [source, destination] = withoutForce.split(":");
2794
- 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);
2795
3019
  }
2796
3020
  recordMissingRemoteRefSkip(branch) {
2797
3021
  this.recordSkip(
@@ -2800,7 +3024,10 @@ var CloneSyncService = class {
2800
3024
  `Skipping '${this.repoName}': origin/${branch} is missing`
2801
3025
  );
2802
3026
  }
2803
- 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
+ };
2804
3031
  try {
2805
3032
  await git.fetch(fetchArgs);
2806
3033
  return { skipped: false };
@@ -2817,14 +3044,14 @@ var CloneSyncService = class {
2817
3044
  return { skipped: false };
2818
3045
  } catch (retryError) {
2819
3046
  if (isMissingRemoteRefError(getErrorMessage(retryError))) {
2820
- this.recordMissingRemoteRefSkip(branch);
3047
+ recordMissing();
2821
3048
  return { skipped: true };
2822
3049
  }
2823
3050
  throw retryError;
2824
3051
  }
2825
3052
  }
2826
3053
  if (isMissingRemoteRefError(message)) {
2827
- this.recordMissingRemoteRefSkip(branch);
3054
+ recordMissing();
2828
3055
  return { skipped: true };
2829
3056
  }
2830
3057
  throw fetchError;
@@ -2852,7 +3079,7 @@ var CloneSyncService = class {
2852
3079
  this.logger.info(
2853
3080
  `[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
2854
3081
  );
2855
- await git.fetch(["--unshallow"]);
3082
+ await git.fetch(["--unshallow", "--no-tags"]);
2856
3083
  }
2857
3084
  getDeepenTargets() {
2858
3085
  const configuredDepth = this.config.depth;
@@ -2872,8 +3099,9 @@ var CloneSyncService = class {
2872
3099
  "--depth",
2873
3100
  String(targetDepth),
2874
3101
  "--prune",
3102
+ "--no-tags",
2875
3103
  "--progress",
2876
- `+refs/heads/${branch}:refs/remotes/origin/${branch}`
3104
+ this.getBranchRefspec(branch)
2877
3105
  ]);
2878
3106
  }
2879
3107
  async resolveBranch() {
@@ -2890,6 +3118,153 @@ var CloneSyncService = class {
2890
3118
  this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
2891
3119
  return this.resolvedBranch;
2892
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
+ }
2893
3268
  async initialize(outcome) {
2894
3269
  return this.withOutcome(outcome, () => this.initializeInternal());
2895
3270
  }
@@ -2913,7 +3288,7 @@ var CloneSyncService = class {
2913
3288
  return;
2914
3289
  }
2915
3290
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2916
- await this.ensureAllRemoteBranchesRefspec(git);
3291
+ await this.configureSingleBranchRemote(git, branch);
2917
3292
  this.initialized = true;
2918
3293
  this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
2919
3294
  return;
@@ -2941,7 +3316,7 @@ var CloneSyncService = class {
2941
3316
  throw error;
2942
3317
  }
2943
3318
  const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2944
- await this.ensureAllRemoteBranchesRefspec(worktreeGit);
3319
+ await this.configureSingleBranchRemote(worktreeGit, branch);
2945
3320
  this.logger.info(`\u2705 Clone successful.`);
2946
3321
  this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
2947
3322
  if (this.config.sparseCheckout) {
@@ -3029,7 +3404,7 @@ var CloneSyncService = class {
3029
3404
  return;
3030
3405
  }
3031
3406
  const looksIncomplete = entries.every((e) => e.startsWith("."));
3032
- 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"));
3033
3408
  if (looksIncomplete && !hasUsableGit) {
3034
3409
  try {
3035
3410
  await fs3.rm(worktreeDir, { recursive: true, force: true });
@@ -3044,7 +3419,7 @@ var CloneSyncService = class {
3044
3419
  }
3045
3420
  }
3046
3421
  getInitMarkerPath(worktreeDir) {
3047
- 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);
3048
3423
  }
3049
3424
  async runInitialFileCopy(worktreeDir, branch) {
3050
3425
  const marker = this.getInitMarkerPath(worktreeDir);
@@ -3096,7 +3471,7 @@ var CloneSyncService = class {
3096
3471
  if (currentBranch !== branch) {
3097
3472
  this.recordSkip(
3098
3473
  { kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
3099
- `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.`,
3100
3475
  `Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
3101
3476
  );
3102
3477
  return;
@@ -3111,13 +3486,13 @@ var CloneSyncService = class {
3111
3486
  return;
3112
3487
  }
3113
3488
  await this.unshallowIfDepthRemoved(git);
3114
- await this.ensureAllRemoteBranchesRefspec(git);
3115
- const fetchArgs = await this.buildFetchArgs(git);
3116
- 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}'` });
3117
3492
  if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
3118
3493
  return;
3119
3494
  }
3120
- this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
3495
+ this.emitProgress({ phase: "fetch", message: `Fetched origin/${branch} for '${this.repoName}'` });
3121
3496
  if (!await this.hasRemoteBranch(git, branch)) {
3122
3497
  this.recordSkip(
3123
3498
  { kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
@@ -3207,50 +3582,39 @@ var CloneSyncService = class {
3207
3582
  }
3208
3583
  };
3209
3584
 
3210
- // src/services/git.service.ts
3211
- import * as fs6 from "fs/promises";
3212
- import * as path8 from "path";
3213
- 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";
3214
3589
 
3215
- // src/utils/worktree-list-parser.ts
3216
- function parseWorktreeListPorcelain(output) {
3217
- const worktrees = [];
3218
- let current = {};
3219
- const flush = () => {
3220
- if (!current.path) {
3221
- current = {};
3222
- 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();
3223
3602
  }
3224
- worktrees.push({
3225
- path: current.path,
3226
- branch: current.branch ?? null,
3227
- head: current.head ?? null,
3228
- detached: current.detached ?? false,
3229
- prunable: current.prunable ?? false,
3230
- locked: current.locked ?? false
3231
- });
3232
- current = {};
3233
- };
3234
- for (const line of output.split("\n")) {
3235
- if (line.startsWith("worktree ")) {
3236
- flush();
3237
- current.path = line.substring("worktree ".length);
3238
- } else if (line.startsWith("branch ")) {
3239
- current.branch = line.substring("branch ".length).replace("refs/heads/", "");
3240
- } else if (line.startsWith("HEAD ")) {
3241
- current.head = line.substring("HEAD ".length);
3242
- } else if (line === "detached") {
3243
- current.detached = true;
3244
- } else if (line === "prunable" || line.startsWith("prunable ")) {
3245
- current.prunable = true;
3246
- } else if (line === "locked" || line.startsWith("locked ")) {
3247
- current.locked = true;
3248
- } else if (line.trim() === "") {
3249
- 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);
3250
3616
  }
3251
3617
  }
3252
- flush();
3253
- return worktrees;
3254
3618
  }
3255
3619
 
3256
3620
  // src/services/logger.service.ts
@@ -3352,9 +3716,190 @@ function defaultConsoleOutput(msg, level) {
3352
3716
  else console.log(msg);
3353
3717
  }
3354
3718
 
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
+
3355
3900
  // src/services/sparse-checkout.service.ts
3356
- import * as path5 from "path";
3357
- import simpleGit2 from "simple-git";
3901
+ import * as path8 from "path";
3902
+ import simpleGit3 from "simple-git";
3358
3903
  var SparseCheckoutService = class {
3359
3904
  logger;
3360
3905
  gitFactory;
@@ -3362,7 +3907,7 @@ var SparseCheckoutService = class {
3362
3907
  matcherCache = /* @__PURE__ */ new WeakMap();
3363
3908
  constructor(logger, gitFactory) {
3364
3909
  this.logger = logger ?? Logger.createDefault();
3365
- this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
3910
+ this.gitFactory = gitFactory ?? ((p) => simpleGit3(p));
3366
3911
  }
3367
3912
  updateLogger(logger) {
3368
3913
  this.logger = logger;
@@ -3488,7 +4033,7 @@ var SparseCheckoutService = class {
3488
4033
  for (const pat of matcher.patterns) {
3489
4034
  if (p === pat || p.startsWith(pat + "/")) return true;
3490
4035
  }
3491
- return matcher.ancestorDirs.has(path5.posix.dirname(p));
4036
+ return matcher.ancestorDirs.has(path8.posix.dirname(p));
3492
4037
  });
3493
4038
  }
3494
4039
  getMatcher(cfg) {
@@ -3515,9 +4060,9 @@ var SparseCheckoutService = class {
3515
4060
  };
3516
4061
 
3517
4062
  // src/services/worktree-metadata.service.ts
3518
- import * as fs4 from "fs/promises";
3519
- import * as path6 from "path";
3520
- import simpleGit3 from "simple-git";
4063
+ import * as fs7 from "fs/promises";
4064
+ import * as path9 from "path";
4065
+ import simpleGit4 from "simple-git";
3521
4066
  var WorktreeMetadataService = class {
3522
4067
  logger;
3523
4068
  constructor(logger) {
@@ -3529,7 +4074,7 @@ var WorktreeMetadataService = class {
3529
4074
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
3530
4075
  */
3531
4076
  getWorktreeDirectoryName(worktreePath) {
3532
- return path6.basename(worktreePath);
4077
+ return path9.basename(worktreePath);
3533
4078
  }
3534
4079
  async getMetadataPath(bareRepoPath, worktreeName) {
3535
4080
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -3537,7 +4082,7 @@ var WorktreeMetadataService = class {
3537
4082
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
3538
4083
  );
3539
4084
  }
3540
- return path6.join(
4085
+ return path9.join(
3541
4086
  bareRepoPath,
3542
4087
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
3543
4088
  worktreeName,
@@ -3550,31 +4095,13 @@ var WorktreeMetadataService = class {
3550
4095
  }
3551
4096
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
3552
4097
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
3553
- await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
3554
- const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
3555
- let renamed = false;
3556
- try {
3557
- await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
3558
- try {
3559
- await fs4.rename(tmpPath, metadataPath);
3560
- renamed = true;
3561
- } catch (err) {
3562
- if (err.code === ERROR_MESSAGES.EXDEV) {
3563
- await fs4.copyFile(tmpPath, metadataPath);
3564
- } else {
3565
- throw err;
3566
- }
3567
- }
3568
- } finally {
3569
- if (!renamed) {
3570
- await fs4.unlink(tmpPath).catch(() => void 0);
3571
- }
3572
- }
4098
+ await fs7.mkdir(path9.dirname(metadataPath), { recursive: true });
4099
+ await atomicWriteFile(metadataPath, JSON.stringify(metadata, null, 2));
3573
4100
  }
3574
4101
  async loadMetadata(bareRepoPath, worktreeName) {
3575
4102
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
3576
4103
  try {
3577
- const content = await fs4.readFile(metadataPath, "utf-8");
4104
+ const content = await fs7.readFile(metadataPath, "utf-8");
3578
4105
  return JSON.parse(content);
3579
4106
  } catch {
3580
4107
  return null;
@@ -3583,7 +4110,7 @@ var WorktreeMetadataService = class {
3583
4110
  async loadMetadataFromPath(bareRepoPath, worktreePath) {
3584
4111
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
3585
4112
  try {
3586
- const content = await fs4.readFile(metadataPath, "utf-8");
4113
+ const content = await fs7.readFile(metadataPath, "utf-8");
3587
4114
  const metadata = JSON.parse(content);
3588
4115
  if (!await this.validateMetadata(metadata)) {
3589
4116
  this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
@@ -3597,7 +4124,7 @@ var WorktreeMetadataService = class {
3597
4124
  async deleteMetadata(bareRepoPath, worktreeName) {
3598
4125
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
3599
4126
  try {
3600
- await fs4.unlink(metadataPath);
4127
+ await fs7.unlink(metadataPath);
3601
4128
  } catch (error) {
3602
4129
  if (error.code !== "ENOENT") {
3603
4130
  throw error;
@@ -3607,7 +4134,7 @@ var WorktreeMetadataService = class {
3607
4134
  async deleteMetadataFromPath(bareRepoPath, worktreePath) {
3608
4135
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
3609
4136
  try {
3610
- await fs4.unlink(metadataPath);
4137
+ await fs7.unlink(metadataPath);
3611
4138
  } catch (error) {
3612
4139
  if (error.code !== "ENOENT") {
3613
4140
  throw error;
@@ -3641,7 +4168,7 @@ var WorktreeMetadataService = class {
3641
4168
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
3642
4169
  this.logger.info(` Attempting to create initial metadata...`);
3643
4170
  try {
3644
- const worktreeGit = simpleGit3(worktreePath);
4171
+ const worktreeGit = simpleGit4(worktreePath);
3645
4172
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
3646
4173
  const branchSummary = await worktreeGit.branch();
3647
4174
  const actualBranchName = branchSummary.current;
@@ -3688,6 +4215,25 @@ var WorktreeMetadataService = class {
3688
4215
  }
3689
4216
  await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
3690
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
+ }
3691
4237
  async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
3692
4238
  const metadata = {
3693
4239
  lastSyncCommit: commit,
@@ -3742,9 +4288,9 @@ var WorktreeMetadataService = class {
3742
4288
  };
3743
4289
 
3744
4290
  // src/services/worktree-status.service.ts
3745
- import * as fs5 from "fs/promises";
3746
- import * as path7 from "path";
3747
- import simpleGit4 from "simple-git";
4291
+ import * as fs8 from "fs/promises";
4292
+ import * as path10 from "path";
4293
+ import simpleGit5 from "simple-git";
3748
4294
  var OPERATION_FILES = [
3749
4295
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
3750
4296
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -3758,6 +4304,7 @@ var WorktreeStatusService = class {
3758
4304
  this.config = config;
3759
4305
  this.logger = logger ?? Logger.createDefault();
3760
4306
  }
4307
+ config;
3761
4308
  gitInstances = /* @__PURE__ */ new Map();
3762
4309
  logger;
3763
4310
  async checkWorktreeStatus(worktreePath) {
@@ -3774,8 +4321,9 @@ var WorktreeStatusService = class {
3774
4321
  }
3775
4322
  return true;
3776
4323
  }
3777
- async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
3778
- if (!await fileExists(worktreePath)) {
4324
+ async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit, lastKnownRemoteTip) {
4325
+ const pathProbe = await probePathExists(worktreePath);
4326
+ if (pathProbe === "missing") {
3779
4327
  return {
3780
4328
  isClean: true,
3781
4329
  hasUnpushedCommits: false,
@@ -3783,25 +4331,44 @@ var WorktreeStatusService = class {
3783
4331
  hasOperationInProgress: false,
3784
4332
  hasModifiedSubmodules: false,
3785
4333
  upstreamGone: false,
4334
+ fullyPushedUpstreamDeleted: false,
3786
4335
  canRemove: true,
3787
4336
  reasons: []
3788
4337
  };
3789
4338
  }
3790
- 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);
3791
4353
  const isClean = this.deriveIsClean(snap);
3792
- 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;
3793
4359
  const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
3794
- const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
4360
+ const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null || snap.operationProbeUnknown;
3795
4361
  const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
3796
4362
  const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
3797
4363
  const reasons = [];
3798
4364
  if (!isClean) reasons.push("uncommitted changes");
3799
- if (hasUnpushedCommits) reasons.push("unpushed commits");
4365
+ if (hasUnpushedCommits && !fullyPushedUpstreamDeleted) reasons.push("unpushed commits");
3800
4366
  if (hasStashedChanges) reasons.push("stashed changes");
3801
4367
  if (hasOperationInProgress) reasons.push("operation in progress");
3802
4368
  if (hasModifiedSubmodules) reasons.push("modified submodules");
3803
4369
  if (upstreamGone) reasons.push("upstream gone");
3804
- 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;
3805
4372
  const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
3806
4373
  return {
3807
4374
  isClean,
@@ -3810,12 +4377,13 @@ var WorktreeStatusService = class {
3810
4377
  hasOperationInProgress,
3811
4378
  hasModifiedSubmodules,
3812
4379
  upstreamGone,
4380
+ fullyPushedUpstreamDeleted,
3813
4381
  canRemove,
3814
4382
  reasons,
3815
4383
  details
3816
4384
  };
3817
4385
  }
3818
- async collectSnapshot(worktreePath, lastSyncCommit) {
4386
+ async collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
3819
4387
  const git = this.createGitInstance(worktreePath);
3820
4388
  const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
3821
4389
  git.status().catch((e) => {
@@ -3840,19 +4408,33 @@ var WorktreeStatusService = class {
3840
4408
  const currentBranch = branchResult?.current ?? null;
3841
4409
  const detached = !branchResult?.current || Boolean(branchResult?.detached);
3842
4410
  let upstream = null;
3843
- let unpushedCount = null;
4411
+ let unpushedAnyRemoteCount = null;
4412
+ let sinceSyncCount = null;
4413
+ let headPushedToRecordedTip = null;
3844
4414
  if (!detached && currentBranch) {
3845
- const revListArgs = lastSyncCommit ? ["rev-list", "--count", `${lastSyncCommit}..HEAD`] : ["rev-list", "--count", currentBranch, "--not", "--remotes"];
3846
- const [upstreamResult, unpushedResult] = await Promise.all([
4415
+ const [upstreamResult, anyRemoteResult, sinceSyncResult, recordedTipResult] = await Promise.all([
3847
4416
  git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
3848
4417
  (raw) => ({ ok: true, value: raw }),
3849
4418
  (error) => ({ ok: false, error })
3850
4419
  ),
3851
- git.raw(revListArgs).then(
4420
+ git.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]).then(
3852
4421
  (raw) => ({ ok: true, value: raw }),
3853
4422
  (error) => ({ ok: false, error })
3854
- )
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)
3855
4436
  ]);
4437
+ headPushedToRecordedTip = recordedTipResult;
3856
4438
  if (upstreamResult.ok) {
3857
4439
  upstream = upstreamResult.value.trim() || null;
3858
4440
  } else {
@@ -3861,13 +4443,20 @@ var WorktreeStatusService = class {
3861
4443
  this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
3862
4444
  }
3863
4445
  }
3864
- if (unpushedResult.ok) {
3865
- unpushedCount = parseInt(unpushedResult.value.trim(), 10);
4446
+ if (anyRemoteResult.ok) {
4447
+ unpushedAnyRemoteCount = this.parseCount(anyRemoteResult.value);
3866
4448
  } else {
3867
- 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
+ }
3868
4457
  }
3869
4458
  }
3870
- const operationFile = gitDirResult ? await this.detectOperationFile(gitDirResult) : null;
4459
+ const operationProbe = gitDirResult ? await this.detectOperationFile(gitDirResult) : { file: null, unknown: false };
3871
4460
  let untrackedNotIgnored = [];
3872
4461
  if (status && status.not_added.length > 0) {
3873
4462
  try {
@@ -3883,14 +4472,22 @@ var WorktreeStatusService = class {
3883
4472
  detached,
3884
4473
  remoteBranches: remoteBranchesResult?.all ?? [],
3885
4474
  upstream,
3886
- unpushedCount,
4475
+ unpushedAnyRemoteCount,
4476
+ sinceSyncCount,
4477
+ sinceSyncChecked: lastSyncCommit !== void 0,
4478
+ headPushedToRecordedTip,
3887
4479
  stashTotal: stashResult?.total ?? null,
3888
4480
  submoduleStatus: submoduleResult,
3889
- operationFile,
4481
+ operationFile: operationProbe.file,
4482
+ operationProbeUnknown: operationProbe.unknown,
3890
4483
  gitDir: gitDirResult,
3891
4484
  untrackedNotIgnored
3892
4485
  };
3893
4486
  }
4487
+ parseCount(raw) {
4488
+ const count = parseInt(raw.trim(), 10);
4489
+ return Number.isNaN(count) ? null : count;
4490
+ }
3894
4491
  deriveIsClean(snap) {
3895
4492
  const status = snap.status;
3896
4493
  if (!status) return false;
@@ -3930,7 +4527,8 @@ var WorktreeStatusService = class {
3930
4527
  if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
3931
4528
  }
3932
4529
  if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
3933
- 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;
3934
4532
  if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
3935
4533
  const opType = this.operationTypeFromFile(snap.operationFile);
3936
4534
  if (opType) details.operationType = opType;
@@ -3945,34 +4543,37 @@ var WorktreeStatusService = class {
3945
4543
  async detectOperationFile(gitDir) {
3946
4544
  const results = await Promise.all(
3947
4545
  OPERATION_FILES.map(
3948
- ({ file }) => fs5.access(path7.join(gitDir, file)).then(
3949
- () => true,
3950
- () => false
4546
+ ({ file }) => fs8.access(path10.join(gitDir, file)).then(
4547
+ () => "present",
4548
+ (error) => error.code === "ENOENT" ? "absent" : "unknown"
3951
4549
  )
3952
4550
  )
3953
4551
  );
3954
- const idx = results.findIndex(Boolean);
3955
- 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") };
3956
4555
  }
3957
4556
  async hasUnpushedCommits(worktreePath, lastSyncCommit) {
3958
4557
  const worktreeGit = this.createGitInstance(worktreePath);
3959
4558
  try {
3960
4559
  if (await this.isDetachedHead(worktreeGit)) {
3961
- return false;
4560
+ return true;
3962
4561
  }
3963
4562
  const branchSummary = await worktreeGit.branch();
3964
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
+ }
3965
4569
  if (lastSyncCommit) {
3966
- try {
3967
- const newCommitsResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
3968
- const newCommitsCount = parseInt(newCommitsResult.trim(), 10);
3969
- return newCommitsCount > 0;
3970
- } 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;
3971
4574
  }
3972
4575
  }
3973
- const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
3974
- const unpushedCount = parseInt(result.trim(), 10);
3975
- return unpushedCount > 0;
4576
+ return false;
3976
4577
  } catch (error) {
3977
4578
  this.logger.error(`Error checking unpushed commits`, error);
3978
4579
  return true;
@@ -4028,14 +4629,15 @@ var WorktreeStatusService = class {
4028
4629
  async hasOperationInProgress(worktreePath) {
4029
4630
  try {
4030
4631
  const gitDir = await this.resolveGitDir(worktreePath);
4031
- return await this.detectOperationFile(gitDir) !== null;
4632
+ const probe = await this.detectOperationFile(gitDir);
4633
+ return probe.unknown || probe.file !== null;
4032
4634
  } catch (error) {
4033
4635
  this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
4034
4636
  return true;
4035
4637
  }
4036
4638
  }
4037
- async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
4038
- const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
4639
+ async validateWorktreeForRemoval(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
4640
+ const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit, lastKnownRemoteTip);
4039
4641
  if (!status.canRemove) {
4040
4642
  throw new WorktreeNotCleanError(worktreePath, status.reasons);
4041
4643
  }
@@ -4066,14 +4668,14 @@ var WorktreeStatusService = class {
4066
4668
  }
4067
4669
  }
4068
4670
  async resolveGitDir(worktreePath) {
4069
- const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
4671
+ const gitPath = path10.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
4070
4672
  try {
4071
- const stat3 = await fs5.stat(gitPath);
4673
+ const stat3 = await fs8.stat(gitPath);
4072
4674
  if (stat3.isFile()) {
4073
- const content = await fs5.readFile(gitPath, "utf-8");
4675
+ const content = await fs8.readFile(gitPath, "utf-8");
4074
4676
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
4075
4677
  if (gitdirMatch) {
4076
- return path7.resolve(worktreePath, gitdirMatch[1].trim());
4678
+ return path10.resolve(worktreePath, gitdirMatch[1].trim());
4077
4679
  }
4078
4680
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
4079
4681
  }
@@ -4087,10 +4689,10 @@ var WorktreeStatusService = class {
4087
4689
  }
4088
4690
  }
4089
4691
  createGitInstance(worktreePath) {
4090
- const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
4692
+ const key = `${path10.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
4091
4693
  let git = this.gitInstances.get(key);
4092
4694
  if (!git) {
4093
- 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);
4094
4696
  this.gitInstances.set(key, git);
4095
4697
  }
4096
4698
  return git;
@@ -4111,11 +4713,13 @@ var GitService = class {
4111
4713
  this.progressEmitter = progressEmitter;
4112
4714
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
4113
4715
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
4114
- this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
4716
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
4115
4717
  this.metadataService = new WorktreeMetadataService(this.logger);
4116
4718
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
4117
4719
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
4118
4720
  }
4721
+ config;
4722
+ progressEmitter;
4119
4723
  git = null;
4120
4724
  bareRepoPath;
4121
4725
  mainWorktreePath;
@@ -4139,10 +4743,10 @@ var GitService = class {
4139
4743
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
4140
4744
  }
4141
4745
  getCachedGit(dirPath, useLfsSkip = false) {
4142
- const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
4746
+ const key = `${path11.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
4143
4747
  let git = this.gitInstances.get(key);
4144
4748
  if (!git) {
4145
- const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4749
+ const base = simpleGit6(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4146
4750
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
4147
4751
  this.gitInstances.set(key, git);
4148
4752
  }
@@ -4162,11 +4766,11 @@ var GitService = class {
4162
4766
  async initialize() {
4163
4767
  const { repoUrl } = this.config;
4164
4768
  try {
4165
- await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
4769
+ await fs9.access(path11.join(this.bareRepoPath, "HEAD"));
4166
4770
  } catch {
4167
4771
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
4168
- await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
4169
- 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()));
4170
4774
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
4171
4775
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
4172
4776
  this.logger.info("\u2705 Clone successful.");
@@ -4184,17 +4788,17 @@ var GitService = class {
4184
4788
  this.logger.info("Fetching remote branches...");
4185
4789
  await bareGit.fetch(["--all", "--progress"]);
4186
4790
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
4187
- this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
4791
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, this.defaultBranch);
4188
4792
  let needsMainWorktree = true;
4189
4793
  try {
4190
4794
  const worktrees = await this.getWorktreesFromBare(bareGit);
4191
- 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));
4192
4796
  } catch {
4193
4797
  }
4194
4798
  if (needsMainWorktree) {
4195
4799
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
4196
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
4197
- const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
4800
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
4801
+ const absoluteWorktreePath = path11.resolve(this.mainWorktreePath);
4198
4802
  const branches = await bareGit.branch();
4199
4803
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
4200
4804
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -4230,7 +4834,7 @@ var GitService = class {
4230
4834
  }
4231
4835
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
4232
4836
  const mainWorktreeRegistered = updatedWorktrees.some(
4233
- (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
4837
+ (w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath)
4234
4838
  );
4235
4839
  if (!mainWorktreeRegistered) {
4236
4840
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -4257,7 +4861,7 @@ var GitService = class {
4257
4861
  return this.bareRepoPath;
4258
4862
  }
4259
4863
  async getRemoteDefaultBranch(repoUrl) {
4260
- const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4864
+ const git = simpleGit6(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4261
4865
  try {
4262
4866
  const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
4263
4867
  const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
@@ -4341,7 +4945,7 @@ var GitService = class {
4341
4945
  return branches;
4342
4946
  }
4343
4947
  async verifyLfsFilesDownloaded(worktreePath, branchName) {
4344
- 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);
4345
4949
  try {
4346
4950
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
4347
4951
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -4352,7 +4956,7 @@ var GitService = class {
4352
4956
  const existence = await Promise.all(
4353
4957
  lfsFileList.map(async (f) => {
4354
4958
  try {
4355
- await fs6.access(path8.join(worktreePath, f));
4959
+ await fs9.access(path11.join(worktreePath, f));
4356
4960
  return f;
4357
4961
  } catch {
4358
4962
  return null;
@@ -4380,9 +4984,9 @@ var GitService = class {
4380
4984
  let allDownloaded = true;
4381
4985
  const notDownloaded = [];
4382
4986
  for (const file of samplesToCheck) {
4383
- const filePath = path8.join(worktreePath, file);
4987
+ const filePath = path11.join(worktreePath, file);
4384
4988
  try {
4385
- const handle = await fs6.open(filePath, "r");
4989
+ const handle = await fs9.open(filePath, "r");
4386
4990
  try {
4387
4991
  const buffer = Buffer.alloc(200);
4388
4992
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -4407,7 +5011,7 @@ var GitService = class {
4407
5011
  }
4408
5012
  retries++;
4409
5013
  if (retries < maxRetries) {
4410
- await new Promise((resolve12) => setTimeout(resolve12, retryDelay));
5014
+ await new Promise((resolve14) => setTimeout(resolve14, retryDelay));
4411
5015
  }
4412
5016
  }
4413
5017
  this.logger.warn(
@@ -4469,20 +5073,23 @@ var GitService = class {
4469
5073
  }
4470
5074
  async addWorktree(branchName, worktreePath) {
4471
5075
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
4472
- const absoluteWorktreePath = path8.resolve(worktreePath);
4473
- await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
5076
+ const absoluteWorktreePath = path11.resolve(worktreePath);
5077
+ await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
4474
5078
  try {
4475
- await fs6.access(absoluteWorktreePath);
5079
+ await fs9.access(absoluteWorktreePath);
4476
5080
  const worktrees = await this.getWorktreesFromBare(bareGit);
4477
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
5081
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
4478
5082
  if (isValidWorktree) {
4479
5083
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
4480
5084
  return;
4481
5085
  } else {
4482
5086
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
4483
- 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;
4484
5092
  }
4485
- } catch {
4486
5093
  }
4487
5094
  let createdNewBranch = false;
4488
5095
  try {
@@ -4519,17 +5126,14 @@ var GitService = class {
4519
5126
  }
4520
5127
  if (errorMessage.includes("already registered worktree")) {
4521
5128
  const worktrees = await this.getWorktreesFromBare(bareGit);
4522
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
5129
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
4523
5130
  if (existingWorktree && !existingWorktree.isPrunable) {
4524
5131
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
4525
5132
  return;
4526
5133
  }
4527
5134
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
4528
5135
  await bareGit.raw(["worktree", "prune"]);
4529
- try {
4530
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
4531
- } catch {
4532
- }
5136
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
4533
5137
  let retryCreatedNewBranch = false;
4534
5138
  try {
4535
5139
  const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
@@ -4563,17 +5167,20 @@ var GitService = class {
4563
5167
  }
4564
5168
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
4565
5169
  try {
4566
- await fs6.access(absoluteWorktreePath);
5170
+ await fs9.access(absoluteWorktreePath);
4567
5171
  const worktrees = await this.getWorktreesFromBare(bareGit);
4568
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
5172
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
4569
5173
  if (isValidWorktree) {
4570
5174
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
4571
5175
  return;
4572
5176
  } else {
4573
5177
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
4574
- 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;
4575
5183
  }
4576
- } catch {
4577
5184
  }
4578
5185
  try {
4579
5186
  const useNoCheckout = !!this.config.sparseCheckout;
@@ -4595,7 +5202,7 @@ var GitService = class {
4595
5202
  const fallbackErrorMessage = getErrorMessage(fallbackError);
4596
5203
  if (fallbackErrorMessage.includes("already registered worktree")) {
4597
5204
  const worktrees = await this.getWorktreesFromBare(bareGit);
4598
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
5205
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
4599
5206
  if (existingWorktree && !existingWorktree.isPrunable) {
4600
5207
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
4601
5208
  return;
@@ -4664,9 +5271,19 @@ var GitService = class {
4664
5271
  wrapped.isUpstreamSetupFailure = true;
4665
5272
  return wrapped;
4666
5273
  }
4667
- async removeWorktree(worktreePath) {
5274
+ async removeWorktree(worktreePath, options) {
4668
5275
  const bareGit = this.getCachedGit(this.bareRepoPath);
4669
- 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
+ }
4670
5287
  this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
4671
5288
  try {
4672
5289
  await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
@@ -4679,6 +5296,111 @@ var GitService = class {
4679
5296
  await bareGit.raw(["worktree", "prune"]);
4680
5297
  this.logger.info("Pruned worktree metadata.");
4681
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
+ }
4682
5404
  async checkWorktreeStatus(worktreePath) {
4683
5405
  return this.statusService.checkWorktreeStatus(worktreePath);
4684
5406
  }
@@ -4694,7 +5416,37 @@ var GitService = class {
4694
5416
  }
4695
5417
  async getFullWorktreeStatus(worktreePath, includeDetails = false) {
4696
5418
  const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
4697
- 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
+ );
4698
5450
  }
4699
5451
  async hasModifiedSubmodules(worktreePath) {
4700
5452
  return this.statusService.hasModifiedSubmodules(worktreePath);
@@ -4979,37 +5731,41 @@ var ProgressEmitter = class {
4979
5731
  }
4980
5732
  };
4981
5733
 
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
+ }
5753
+ }
5754
+ };
5755
+
4982
5756
  // src/services/repo-operation-lock.ts
4983
- import * as fs7 from "fs/promises";
4984
- import * as path10 from "path";
5757
+ import * as fs11 from "fs/promises";
5758
+ import * as path13 from "path";
4985
5759
  import * as lockfile from "proper-lockfile";
4986
-
4987
- // src/utils/lock-path.ts
4988
- import { createHash } from "crypto";
4989
- import * as os from "os";
4990
- import * as path9 from "path";
4991
- function getCloneModeLockTarget(config) {
4992
- const name = config.name;
4993
- const configDir = config.__configFileDir;
4994
- const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
4995
- if (configDir) {
4996
- return {
4997
- dir: path9.join(configDir, ".sync-worktrees-state"),
4998
- file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
4999
- };
5000
- }
5001
- const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
5002
- const dir = path9.join(stateBase, "sync-worktrees", "locks");
5003
- return { dir, file: `${hash}.lock` };
5004
- }
5005
-
5006
- // src/services/repo-operation-lock.ts
5007
5760
  var RepoOperationLock = class {
5008
5761
  constructor(config, gitService, logger = Logger.createDefault()) {
5009
5762
  this.config = config;
5010
5763
  this.gitService = gitService;
5011
5764
  this.logger = logger;
5012
5765
  }
5766
+ config;
5767
+ gitService;
5768
+ logger;
5013
5769
  updateLogger(logger) {
5014
5770
  this.logger = logger;
5015
5771
  }
@@ -5025,10 +5781,10 @@ var RepoOperationLock = class {
5025
5781
  }
5026
5782
  async acquireCloneModeLock() {
5027
5783
  const target = getCloneModeLockTarget(this.config);
5028
- const lockTarget = path10.join(target.dir, target.file);
5784
+ const lockTarget = path13.join(target.dir, target.file);
5029
5785
  try {
5030
- await fs7.mkdir(target.dir, { recursive: true });
5031
- await fs7.writeFile(lockTarget, "", { flag: "a" });
5786
+ await fs11.mkdir(target.dir, { recursive: true });
5787
+ await fs11.writeFile(lockTarget, "", { flag: "a" });
5032
5788
  } catch {
5033
5789
  return null;
5034
5790
  }
@@ -5037,7 +5793,7 @@ var RepoOperationLock = class {
5037
5793
  async acquireWorktreeModeLock() {
5038
5794
  const barePath = this.gitService.getBareRepoPath();
5039
5795
  try {
5040
- await fs7.mkdir(barePath, { recursive: true });
5796
+ await fs11.mkdir(barePath, { recursive: true });
5041
5797
  } catch {
5042
5798
  return null;
5043
5799
  }
@@ -5071,6 +5827,9 @@ var SyncRetryPolicy = class {
5071
5827
  this.gitService = gitService;
5072
5828
  this.logger = logger;
5073
5829
  }
5830
+ config;
5831
+ gitService;
5832
+ logger;
5074
5833
  updateLogger(logger) {
5075
5834
  this.logger = logger;
5076
5835
  }
@@ -5103,72 +5862,748 @@ var SyncRetryPolicy = class {
5103
5862
  syncContext.lfsSkipEnabled = true;
5104
5863
  }
5105
5864
  }
5106
- };
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;
6490
+ }
6491
+ }
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)));
6555
+ }
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
+ }
5107
6585
  }
5108
- resetLfsSkipIfNeeded(syncContext) {
5109
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
5110
- this.gitService.setLfsSkipEnabled(false);
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
+ );
5111
6594
  }
5112
6595
  }
5113
6596
  };
5114
6597
 
5115
6598
  // src/services/worktree-mode-sync-runner.ts
5116
- import * as fs9 from "fs/promises";
5117
- import * as path13 from "path";
6599
+ import * as fs16 from "fs/promises";
6600
+ import * as path19 from "path";
5118
6601
  import pLimit from "p-limit";
5119
6602
 
5120
- // src/utils/date-filter.ts
5121
- function parseDuration(durationStr) {
5122
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
5123
- if (!match) {
5124
- return null;
5125
- }
5126
- const value = parseInt(match[1], 10);
5127
- const unit = match[2];
5128
- const multipliers = {
5129
- h: 60 * 60 * 1e3,
5130
- // hours
5131
- d: 24 * 60 * 60 * 1e3,
5132
- // days
5133
- w: 7 * 24 * 60 * 60 * 1e3,
5134
- // weeks
5135
- m: 30 * 24 * 60 * 60 * 1e3,
5136
- // months (approximate)
5137
- y: 365 * 24 * 60 * 60 * 1e3
5138
- // years (approximate)
5139
- };
5140
- return value * multipliers[unit];
5141
- }
5142
- function filterBranchesByAge(branches, maxAge) {
5143
- const maxAgeMs = parseDuration(maxAge);
5144
- if (maxAgeMs === null) {
5145
- console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
5146
- return branches;
5147
- }
5148
- const cutoffDate = new Date(Date.now() - maxAgeMs);
5149
- return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
5150
- }
5151
- function formatDuration2(durationStr) {
5152
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
5153
- if (!match) {
5154
- return durationStr;
5155
- }
5156
- const value = parseInt(match[1], 10);
5157
- const unit = match[2];
5158
- const unitNames = {
5159
- h: value === 1 ? "hour" : "hours",
5160
- d: value === 1 ? "day" : "days",
5161
- w: value === 1 ? "week" : "weeks",
5162
- m: value === 1 ? "month" : "months",
5163
- y: value === 1 ? "year" : "years"
5164
- };
5165
- return `${value} ${unitNames[unit]}`;
5166
- }
5167
-
5168
6603
  // src/services/path-resolution.service.ts
5169
6604
  import { createHash as createHash2 } from "crypto";
5170
- import * as fs8 from "fs";
5171
- import * as path11 from "path";
6605
+ import * as fs15 from "fs";
6606
+ import * as path17 from "path";
5172
6607
  var BRANCH_STEM_MAX = 80;
5173
6608
  var BRANCH_HASH_LEN = 8;
5174
6609
  var PathResolutionService = class {
@@ -5178,22 +6613,22 @@ var PathResolutionService = class {
5178
6613
  return `${stem}-${hash}`;
5179
6614
  }
5180
6615
  getBranchWorktreePath(worktreeDir, branchName) {
5181
- return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
6616
+ return path17.join(worktreeDir, this.sanitizeBranchName(branchName));
5182
6617
  }
5183
6618
  resolveRealPath(inputPath) {
5184
- const absolute = path11.resolve(inputPath);
6619
+ const absolute = path17.resolve(inputPath);
5185
6620
  const missing = [];
5186
6621
  let current = absolute;
5187
- while (!fs8.existsSync(current)) {
5188
- const parent = path11.dirname(current);
6622
+ while (!fs15.existsSync(current)) {
6623
+ const parent = path17.dirname(current);
5189
6624
  if (parent === current) {
5190
6625
  return absolute;
5191
6626
  }
5192
- missing.unshift(path11.basename(current));
6627
+ missing.unshift(path17.basename(current));
5193
6628
  current = parent;
5194
6629
  }
5195
6630
  try {
5196
- return path11.join(fs8.realpathSync(current), ...missing);
6631
+ return path17.join(fs15.realpathSync(current), ...missing);
5197
6632
  } catch {
5198
6633
  return absolute;
5199
6634
  }
@@ -5203,7 +6638,7 @@ var PathResolutionService = class {
5203
6638
  const a = fold(resolved);
5204
6639
  const b = fold(resolvedBase);
5205
6640
  if (a === b) return true;
5206
- 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);
5207
6642
  }
5208
6643
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
5209
6644
  const resolved = this.resolveRealPath(worktreePath);
@@ -5211,7 +6646,7 @@ var PathResolutionService = class {
5211
6646
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
5212
6647
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
5213
6648
  }
5214
- return path11.relative(resolvedBase, resolved);
6649
+ return path17.relative(resolvedBase, resolved);
5215
6650
  }
5216
6651
  isPathInsideBaseDir(targetPath, baseDir) {
5217
6652
  const resolved = this.resolveRealPath(targetPath);
@@ -5224,7 +6659,7 @@ var PathResolutionService = class {
5224
6659
  };
5225
6660
 
5226
6661
  // src/services/worktree-sync-planner.ts
5227
- import * as path12 from "path";
6662
+ import * as path18 from "path";
5228
6663
  function createWorktreeSyncPlan(inventory, options = {}) {
5229
6664
  return {
5230
6665
  create: planCreateActions(inventory, options),
@@ -5242,12 +6677,12 @@ function planCreateActions(inventory, options = {}) {
5242
6677
  );
5243
6678
  const reservedPaths = /* @__PURE__ */ new Map();
5244
6679
  for (const worktree of inventory.existingWorktrees) {
5245
- reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
6680
+ reservedPaths.set(path18.resolve(worktree.path), worktree.branch);
5246
6681
  }
5247
6682
  const actions = [];
5248
6683
  for (const branch of newBranches) {
5249
6684
  const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
5250
- const resolved = path12.resolve(worktreePath);
6685
+ const resolved = path18.resolve(worktreePath);
5251
6686
  const conflictingBranch = reservedPaths.get(resolved);
5252
6687
  if (conflictingBranch && conflictingBranch !== branch) {
5253
6688
  actions.push({
@@ -5285,21 +6720,30 @@ function planSparseActions(inventory, sparseCheckout) {
5285
6720
 
5286
6721
  // src/services/worktree-mode-sync-runner.ts
5287
6722
  var WorktreeModeSyncRunner = class {
5288
- constructor(config, gitService, logger, progressEmitter) {
6723
+ constructor(config, gitService, logger, progressEmitter, services) {
5289
6724
  this.config = config;
5290
6725
  this.gitService = gitService;
5291
6726
  this.logger = logger;
5292
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);
5293
6730
  }
6731
+ config;
6732
+ gitService;
6733
+ logger;
6734
+ progressEmitter;
5294
6735
  pathResolution = new PathResolutionService();
6736
+ removalAudit;
6737
+ trashService;
5295
6738
  updateLogger(logger) {
5296
6739
  this.logger = logger;
6740
+ this.trashService.updateLogger(logger);
5297
6741
  }
5298
6742
  async runSyncAttempt(phaseTimer, syncContext, outcome) {
5299
6743
  await this.gitService.pruneWorktrees();
5300
6744
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
5301
6745
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
5302
- await fs9.mkdir(this.config.worktreeDir, { recursive: true });
6746
+ await fs16.mkdir(this.config.worktreeDir, { recursive: true });
5303
6747
  const worktrees = await this.gitService.getWorktrees();
5304
6748
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
5305
6749
  await this.cleanupOrphanedDirectories(worktrees);
@@ -5317,6 +6761,7 @@ var WorktreeModeSyncRunner = class {
5317
6761
  }
5318
6762
  );
5319
6763
  await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
6764
+ await this.recordRemoteBranchTips([...worktrees, ...syncPlan.create.filter((action) => action.kind === "create")]);
5320
6765
  await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
5321
6766
  if (this.config.updateExistingWorktrees !== false) {
5322
6767
  await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
@@ -5339,7 +6784,7 @@ var WorktreeModeSyncRunner = class {
5339
6784
  if (action.kind !== "check-sparse") return;
5340
6785
  try {
5341
6786
  try {
5342
- await fs9.access(action.path);
6787
+ await fs16.access(action.path);
5343
6788
  } catch {
5344
6789
  return;
5345
6790
  }
@@ -5425,7 +6870,7 @@ var WorktreeModeSyncRunner = class {
5425
6870
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
5426
6871
  const remoteBranches = filteredBranches.map((b) => b.branch);
5427
6872
  this.logger.info(
5428
- `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
6873
+ `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
5429
6874
  );
5430
6875
  if (filteredByName.length > remoteBranches.length) {
5431
6876
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -5502,6 +6947,37 @@ var WorktreeModeSyncRunner = class {
5502
6947
  const successCount = results.filter((r) => r.status === "fulfilled").length;
5503
6948
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
5504
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
+ }
5505
6981
  async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
5506
6982
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
5507
6983
  phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
@@ -5531,7 +7007,18 @@ var WorktreeModeSyncRunner = class {
5531
7007
  if (result.status === "fulfilled") {
5532
7008
  const { branchName, worktreePath, status } = result.value;
5533
7009
  if (status.canRemove) {
5534
- 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
+ }
5535
7022
  } else {
5536
7023
  toSkip.push({ branchName, worktreePath, status });
5537
7024
  }
@@ -5554,7 +7041,7 @@ var WorktreeModeSyncRunner = class {
5554
7041
  ({ branchName, worktreePath }) => removeLimit(async () => {
5555
7042
  try {
5556
7043
  const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
5557
- if (!recheck.canRemove) {
7044
+ if (!recheck.canRemove || this.blockedByDisabledTrash(recheck)) {
5558
7045
  this.logger.warn(
5559
7046
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
5560
7047
  );
@@ -5565,10 +7052,76 @@ var WorktreeModeSyncRunner = class {
5565
7052
  });
5566
7053
  return;
5567
7054
  }
5568
- await this.gitService.removeWorktree(worktreePath);
5569
- this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
5570
- 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
+ );
5571
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
+ }
5572
7125
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
5573
7126
  outcome.recordFailed("worktree", getErrorMessage(error), {
5574
7127
  reason: "remove_failed",
@@ -5694,12 +7247,12 @@ var WorktreeModeSyncRunner = class {
5694
7247
  }
5695
7248
  async updateExistingWorktrees(actions, outcome) {
5696
7249
  this.logger.info("Step 4: Checking for worktrees that need updates...");
5697
- 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);
5698
7251
  try {
5699
- const diverged = await fs9.readdir(divergedDir);
7252
+ const diverged = await fs16.readdir(divergedDir);
5700
7253
  if (diverged.length > 0) {
5701
7254
  this.logger.info(
5702
- `\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)}`
5703
7256
  );
5704
7257
  }
5705
7258
  } catch {
@@ -5711,7 +7264,7 @@ var WorktreeModeSyncRunner = class {
5711
7264
  (action) => limit(async () => {
5712
7265
  const worktree = { path: action.path, branch: action.branch };
5713
7266
  try {
5714
- await fs9.access(worktree.path);
7267
+ await fs16.access(worktree.path);
5715
7268
  } catch {
5716
7269
  return { action: "skip", worktree, reason: "missing_worktree_path" };
5717
7270
  }
@@ -5851,13 +7404,13 @@ var WorktreeModeSyncRunner = class {
5851
7404
  }
5852
7405
  async cleanupOrphanedDirectories(worktrees) {
5853
7406
  try {
5854
- const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
5855
- 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);
5856
7409
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
5857
7410
  const orphanedDirs = [];
5858
7411
  for (const dir of regularDirs) {
5859
7412
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
5860
- return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
7413
+ return worktreePath === dir || worktreePath.startsWith(dir + path19.sep);
5861
7414
  });
5862
7415
  if (!isPartOfWorktree) {
5863
7416
  orphanedDirs.push(dir);
@@ -5866,13 +7419,46 @@ var WorktreeModeSyncRunner = class {
5866
7419
  if (orphanedDirs.length > 0) {
5867
7420
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
5868
7421
  for (const dir of orphanedDirs) {
5869
- const dirPath = path13.join(this.config.worktreeDir, dir);
7422
+ const dirPath = path19.join(this.config.worktreeDir, dir);
5870
7423
  try {
5871
- const stat3 = await fs9.stat(dirPath);
5872
- if (stat3.isDirectory()) {
5873
- await fs9.rm(dirPath, { recursive: true, force: true });
5874
- 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;
5875
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}`);
5876
7462
  } catch (error) {
5877
7463
  this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
5878
7464
  }
@@ -5901,14 +7487,37 @@ var WorktreeModeSyncRunner = class {
5901
7487
  outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
5902
7488
  } else {
5903
7489
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
5904
- const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
5905
- 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);
5906
7498
  outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
5907
7499
  this.logger.info(` Moved to: ${relativePath}`);
5908
7500
  this.logger.info(` Your local changes are preserved. To review:`);
5909
7501
  this.logger.info(` cd ${relativePath}`);
5910
7502
  this.logger.info(` git diff origin/${worktree.branch}`);
5911
- 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
+ );
5912
7521
  await this.gitService.addWorktree(worktree.branch, worktree.path);
5913
7522
  this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
5914
7523
  }
@@ -5927,42 +7536,55 @@ var WorktreeModeSyncRunner = class {
5927
7536
  }
5928
7537
  }
5929
7538
  async divergeWorktree(worktreePath, branchName) {
5930
- 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);
5931
7550
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
5932
7551
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
5933
7552
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
5934
7553
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
5935
- const divergedPath = path13.join(divergedBaseDir, divergedName);
5936
- await fs9.mkdir(divergedBaseDir, { recursive: true });
7554
+ const divergedPath = path19.join(divergedBaseDir, divergedName);
7555
+ await fs16.mkdir(divergedBaseDir, { recursive: true });
5937
7556
  try {
5938
- await fs9.rename(worktreePath, divergedPath);
7557
+ await fs16.rename(worktreePath, divergedPath);
5939
7558
  } catch (err) {
5940
7559
  if (err.code === ERROR_MESSAGES.EXDEV) {
5941
- await fs9.cp(worktreePath, divergedPath, { recursive: true });
5942
- 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 });
5943
7562
  } else {
5944
7563
  throw err;
5945
7564
  }
5946
7565
  }
7566
+ await this.writeDivergedInfoFile(divergedPath, worktreePath, branchName, null);
7567
+ return { divergedPath, manifest: null };
7568
+ }
7569
+ async writeDivergedInfoFile(preservedPath, originalPath, branchName, knownLocalCommit) {
5947
7570
  const metadata = {
5948
7571
  originalBranch: branchName,
5949
7572
  divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
5950
7573
  reason: METADATA_CONSTANTS.DIVERGED_REASON,
5951
- originalPath: worktreePath,
5952
- localCommit: await this.gitService.getCurrentCommit(divergedPath),
7574
+ originalPath,
7575
+ localCommit: knownLocalCommit ?? await this.gitService.getCurrentCommit(preservedPath),
5953
7576
  remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
5954
7577
  instruction: `To preserve your changes:
5955
7578
  1. Review: git diff origin/${branchName}
5956
7579
  2. Keep changes: git push --force-with-lease origin ${branchName}
5957
7580
  3. Discard changes: rm -rf this directory
5958
7581
 
5959
- Original worktree location: ${worktreePath}`
7582
+ Original worktree location: ${originalPath}`
5960
7583
  };
5961
- await fs9.writeFile(
5962
- path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
7584
+ await fs16.writeFile(
7585
+ path19.join(preservedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
5963
7586
  JSON.stringify(metadata, null, 2)
5964
7587
  );
5965
- return divergedPath;
5966
7588
  }
5967
7589
  };
5968
7590
 
@@ -5973,12 +7595,26 @@ var WorktreeSyncService = class {
5973
7595
  this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
5974
7596
  this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
5975
7597
  this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
7598
+ this.maintenanceService = new GitMaintenanceService(config, this.gitService, this.logger);
5976
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
+ }
5977
7609
  this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
5978
7610
  config,
5979
7611
  this.gitService,
5980
7612
  this.logger,
5981
- this.progressEmitter
7613
+ this.progressEmitter,
7614
+ {
7615
+ trashService: this.trashService,
7616
+ removalAudit
7617
+ }
5982
7618
  );
5983
7619
  if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
5984
7620
  this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
@@ -5989,14 +7625,23 @@ var WorktreeSyncService = class {
5989
7625
  });
5990
7626
  }
5991
7627
  }
7628
+ config;
5992
7629
  gitService;
5993
7630
  cloneSyncService = null;
5994
7631
  logger;
5995
- syncInProgress = false;
7632
+ // In-process FIFO serializer for all bare-repo-mutating operations (sync, init,
7633
+ // interactive create). One per repo. wait:true callers queue behind an in-flight op;
7634
+ // wait:false callers fail fast. The cross-process file lock (RepoOperationLock) is
7635
+ // acquired inside the mutex body for multi-process safety.
7636
+ repoMutex = pLimit2(1);
5996
7637
  progressEmitter = new ProgressEmitter();
5997
7638
  repoOperationLock;
7639
+ maintenanceService;
5998
7640
  retryPolicy;
5999
7641
  worktreeModeSyncRunner;
7642
+ trashService;
7643
+ trashReaper;
7644
+ trashMigration;
6000
7645
  skipsAccumulator = [];
6001
7646
  lastOutcome = null;
6002
7647
  getRecordedSkips() {
@@ -6020,6 +7665,18 @@ var WorktreeSyncService = class {
6020
7665
  }
6021
7666
  return this.gitService.getWorktrees();
6022
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
+ }
6023
7680
  async initialize() {
6024
7681
  if (this.isInitialized()) return;
6025
7682
  const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
@@ -6044,11 +7701,28 @@ var WorktreeSyncService = class {
6044
7701
  return this.gitService.isInitialized();
6045
7702
  }
6046
7703
  isSyncInProgress() {
6047
- return this.syncInProgress;
7704
+ return this.repoMutex.activeCount + this.repoMutex.pendingCount > 0;
6048
7705
  }
6049
7706
  getGitService() {
6050
7707
  return this.gitService;
6051
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
+ }
6052
7726
  updateLogger(logger) {
6053
7727
  this.logger = logger;
6054
7728
  this.gitService.updateLogger(logger);
@@ -6056,44 +7730,73 @@ var WorktreeSyncService = class {
6056
7730
  this.retryPolicy.updateLogger(logger);
6057
7731
  this.worktreeModeSyncRunner.updateLogger(logger);
6058
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
+ }
6059
7764
  }
6060
7765
  onProgress(listener) {
6061
7766
  return this.progressEmitter.onProgress(listener);
6062
7767
  }
6063
- async runExclusiveRepoOperation(operation) {
6064
- if (this.syncInProgress) {
7768
+ async runExclusiveRepoOperation(operation, options = {}) {
7769
+ if (!options.wait && this.repoMutex.activeCount + this.repoMutex.pendingCount > 0) {
6065
7770
  this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
6066
7771
  return { started: false, reason: "in_progress" };
6067
7772
  }
6068
- this.syncInProgress = true;
6069
- let release;
6070
- try {
6071
- release = await this.repoOperationLock.acquire();
6072
- } catch (error) {
6073
- this.syncInProgress = false;
6074
- throw error;
6075
- }
6076
- if (release === null) {
6077
- this.syncInProgress = false;
6078
- this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
6079
- return { started: false, reason: "locked" };
6080
- }
6081
- try {
6082
- return { started: true, value: await operation() };
6083
- } finally {
7773
+ return this.repoMutex(async () => {
7774
+ const release = await this.repoOperationLock.acquire();
7775
+ if (release === null) {
7776
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
7777
+ return { started: false, reason: "locked" };
7778
+ }
6084
7779
  try {
6085
- await release();
6086
- } catch (releaseError) {
6087
- this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
7780
+ return { started: true, value: await operation() };
7781
+ } finally {
7782
+ try {
7783
+ await release();
7784
+ } catch (releaseError) {
7785
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
7786
+ }
6088
7787
  }
6089
- this.syncInProgress = false;
6090
- }
7788
+ });
7789
+ }
7790
+ // Interactive variant: queues behind any in-flight sync/op instead of failing fast.
7791
+ async runQueuedRepoOperation(operation) {
7792
+ return this.runExclusiveRepoOperation(operation, { wait: true });
6091
7793
  }
6092
7794
  emitProgress(event) {
6093
7795
  this.progressEmitter.emit(event);
6094
7796
  }
6095
7797
  async sync() {
6096
7798
  const result = await this.runExclusiveRepoOperation(async () => {
7799
+ this.clearRecordedSkips();
6097
7800
  const totalTimer = new Timer();
6098
7801
  const phaseTimer = new PhaseTimer();
6099
7802
  const outcome = new SyncOutcomeAccumulator({
@@ -6142,7 +7845,9 @@ var WorktreeSyncService = class {
6142
7845
  const repoName = this.config.name;
6143
7846
  this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
6144
7847
  }
7848
+ await this.runTrashMaintenanceUnlocked();
6145
7849
  }
7850
+ await this.runMaintenanceIfDueUnlocked();
6146
7851
  return this.lastOutcome ?? outcome.toOutcome(durationMs);
6147
7852
  });
6148
7853
  return result.started ? { started: true, outcome: result.value } : result;
@@ -6275,54 +7980,6 @@ var HookExecutionService = class {
6275
7980
  }
6276
7981
  };
6277
7982
 
6278
- // src/utils/disk-space.ts
6279
- import fastFolderSize from "fast-folder-size";
6280
- async function calculateDirectorySize(dirPath) {
6281
- return new Promise((resolve12, reject) => {
6282
- fastFolderSize(dirPath, (err, bytes) => {
6283
- if (err) {
6284
- reject(err);
6285
- return;
6286
- }
6287
- if (bytes === void 0) {
6288
- reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
6289
- return;
6290
- }
6291
- resolve12(bytes);
6292
- });
6293
- });
6294
- }
6295
- function formatBytes(bytes) {
6296
- if (bytes === 0) return "0 B";
6297
- const units = ["B", "KB", "MB", "GB", "TB"];
6298
- const k = 1024;
6299
- const decimals = 2;
6300
- const i = Math.floor(Math.log(bytes) / Math.log(k));
6301
- const value = bytes / Math.pow(k, i);
6302
- return `${value.toFixed(decimals)} ${units[i]}`;
6303
- }
6304
- async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
6305
- try {
6306
- let totalBytes = 0;
6307
- for (const repoPath of repoPaths) {
6308
- try {
6309
- totalBytes += await calculateDirectorySize(repoPath);
6310
- } catch {
6311
- }
6312
- }
6313
- for (const worktreeDir of worktreeDirs) {
6314
- try {
6315
- totalBytes += await calculateDirectorySize(worktreeDir);
6316
- } catch {
6317
- }
6318
- }
6319
- return formatBytes(totalBytes);
6320
- } catch (error) {
6321
- console.error("Failed to calculate disk space:", error);
6322
- return "N/A";
6323
- }
6324
- }
6325
-
6326
7983
  // src/utils/app-events.ts
6327
7984
  var AppEventEmitter = class {
6328
7985
  listeners = /* @__PURE__ */ new Map();
@@ -6359,7 +8016,7 @@ var AppEventEmitter = class {
6359
8016
  };
6360
8017
 
6361
8018
  // src/services/InteractiveUIService.tsx
6362
- import * as fs10 from "fs/promises";
8019
+ import * as fs17 from "fs/promises";
6363
8020
  var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
6364
8021
  var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
6365
8022
  var InteractiveUIService = class {
@@ -6393,7 +8050,7 @@ var InteractiveUIService = class {
6393
8050
  this.cronSchedule = cronSchedule;
6394
8051
  this.repositoryCount = syncServices.length;
6395
8052
  this.maxProgressLines = Math.max(1, maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
6396
- this.limit = pLimit2(this.maxProgressLines);
8053
+ this.limit = pLimit3(this.maxProgressLines);
6397
8054
  this.startBufferFlushCheck();
6398
8055
  this.renderUI();
6399
8056
  this.subscribeToServiceProgress();
@@ -6520,13 +8177,17 @@ var InteractiveUIService = class {
6520
8177
  getRepositoryDiskUsage: (index) => this.getRepositoryDiskUsage(index),
6521
8178
  getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
6522
8179
  deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
6523
- openEditorInWorktree: (path18) => this.openEditorInWorktree(path18),
6524
- 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),
6525
8182
  copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
6526
8183
  createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
6527
8184
  executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
6528
8185
  }
6529
- )
8186
+ ),
8187
+ {
8188
+ alternateScreen: true,
8189
+ incrementalRendering: true
8190
+ }
6530
8191
  );
6531
8192
  }
6532
8193
  async handleManualSync() {
@@ -6641,14 +8302,14 @@ var InteractiveUIService = class {
6641
8302
  if (Date.now() - startTime > timeoutMs) {
6642
8303
  throw new Error("Timeout waiting for sync operations to complete");
6643
8304
  }
6644
- await new Promise((resolve12) => setTimeout(resolve12, checkInterval));
8305
+ await new Promise((resolve14) => setTimeout(resolve14, checkInterval));
6645
8306
  }
6646
8307
  });
6647
8308
  try {
6648
8309
  await Promise.all(syncChecks);
6649
8310
  } catch {
6650
8311
  this.addLog(
6651
- `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.`,
6652
8313
  "warn"
6653
8314
  );
6654
8315
  }
@@ -6721,11 +8382,12 @@ var InteractiveUIService = class {
6721
8382
  }
6722
8383
  const sizeBytes = bareSizeBytes + worktreeSizeBytes;
6723
8384
  const failedAllPaths = errors.length === sizeTargets.length;
8385
+ const partialFailure = errors.length > 0 && !failedAllPaths;
6724
8386
  return {
6725
8387
  repoIndex,
6726
8388
  repoName,
6727
8389
  sizeBytes: failedAllPaths ? null : sizeBytes,
6728
- sizeFormatted: failedAllPaths ? "N/A" : formatBytes(sizeBytes),
8390
+ sizeFormatted: failedAllPaths ? "N/A" : partialFailure ? `\u2265${formatBytes(sizeBytes)}` : formatBytes(sizeBytes),
6729
8391
  bareSizeBytes,
6730
8392
  worktreeSizeBytes,
6731
8393
  error: errors.length > 0 ? errors.join("; ") : void 0
@@ -6736,11 +8398,14 @@ var InteractiveUIService = class {
6736
8398
  throw new Error(`Invalid repository index: ${repoIndex}`);
6737
8399
  }
6738
8400
  const service = this.syncServices[repoIndex];
6739
- if (!service.isInitialized()) {
8401
+ if (!service.isInitialized() && !service.isCloneMode()) {
8402
+ return [];
8403
+ }
8404
+ try {
8405
+ return await service.getRemoteBranches();
8406
+ } catch {
6740
8407
  return [];
6741
8408
  }
6742
- const gitService = service.getGitService();
6743
- return gitService.getRemoteBranches();
6744
8409
  }
6745
8410
  getDefaultBranchForRepo(repoIndex) {
6746
8411
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -6755,11 +8420,18 @@ var InteractiveUIService = class {
6755
8420
  throw new Error(`Invalid repository index: ${repoIndex}`);
6756
8421
  }
6757
8422
  const service = this.syncServices[repoIndex];
6758
- if (!service.isInitialized()) {
6759
- await service.initialize();
8423
+ const result = await service.runQueuedRepoOperation(async () => {
8424
+ if (!service.isInitialized()) {
8425
+ await service.initializeUnlocked();
8426
+ }
8427
+ if (service.isCloneMode()) {
8428
+ return;
8429
+ }
8430
+ await service.getGitService().fetchAll();
8431
+ });
8432
+ if (!result.started) {
8433
+ throw new Error("Another process holds the repository lock; fetch skipped. Try again.");
6760
8434
  }
6761
- const gitService = service.getGitService();
6762
- await gitService.fetchAll();
6763
8435
  }
6764
8436
  async createAndPushBranch(repoIndex, baseBranch, branchName) {
6765
8437
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -6767,25 +8439,35 @@ var InteractiveUIService = class {
6767
8439
  }
6768
8440
  const service = this.syncServices[repoIndex];
6769
8441
  const gitService = service.getGitService();
6770
- const maxAttempts = 10;
6771
- let finalName = branchName;
6772
- let suffix = 0;
6773
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
6774
- try {
6775
- await gitService.createBranch(finalName, baseBranch);
6776
- await gitService.pushBranch(finalName);
6777
- return { success: true, finalName };
6778
- } catch (error) {
6779
- const errorMessage = error instanceof Error ? error.message : String(error);
6780
- if (errorMessage.includes("already exists")) {
6781
- suffix++;
6782
- finalName = `${branchName}-${suffix}`;
6783
- continue;
8442
+ const result = await service.runQueuedRepoOperation(async () => {
8443
+ const maxAttempts = 10;
8444
+ let finalName = branchName;
8445
+ let suffix = 0;
8446
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
8447
+ try {
8448
+ await gitService.createBranch(finalName, baseBranch);
8449
+ await gitService.pushBranch(finalName);
8450
+ return { success: true, finalName };
8451
+ } catch (error) {
8452
+ const errorMessage = error instanceof Error ? error.message : String(error);
8453
+ if (errorMessage.includes("already exists")) {
8454
+ suffix++;
8455
+ finalName = `${branchName}-${suffix}`;
8456
+ continue;
8457
+ }
8458
+ return { success: false, finalName: branchName, error: errorMessage };
6784
8459
  }
6785
- return { success: false, finalName: branchName, error: errorMessage };
6786
8460
  }
8461
+ return { success: false, finalName: branchName, error: `Failed to create branch after ${maxAttempts} attempts` };
8462
+ });
8463
+ if (!result.started) {
8464
+ return {
8465
+ success: false,
8466
+ finalName: branchName,
8467
+ error: "Another process holds the repository lock; branch not created. Try again."
8468
+ };
6787
8469
  }
6788
- return { success: false, finalName: branchName, error: `Failed to create branch after ${maxAttempts} attempts` };
8470
+ return result.value;
6789
8471
  }
6790
8472
  async getWorktreesForRepo(repoIndex) {
6791
8473
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -6822,22 +8504,22 @@ var InteractiveUIService = class {
6822
8504
  }
6823
8505
  const service = this.syncServices[repoIndex];
6824
8506
  const worktreeDir = service.config.worktreeDir;
6825
- const divergedDir = path14.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
8507
+ const divergedDir = path20.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6826
8508
  let dirEntries;
6827
8509
  try {
6828
- dirEntries = await fs10.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
8510
+ dirEntries = await fs17.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
6829
8511
  } catch {
6830
8512
  return [];
6831
8513
  }
6832
8514
  const subdirs = dirEntries.filter((e) => e.isDirectory());
6833
8515
  const results = await Promise.allSettled(
6834
8516
  subdirs.map(async (entry) => {
6835
- const fullPath = path14.join(divergedDir, entry.name);
6836
- 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);
6837
8519
  let originalBranch = entry.name;
6838
8520
  let divergedAt = "";
6839
8521
  try {
6840
- const infoContent = await fs10.readFile(infoFilePath, "utf-8");
8522
+ const infoContent = await fs17.readFile(infoFilePath, "utf-8");
6841
8523
  const info = JSON.parse(infoContent);
6842
8524
  if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
6843
8525
  if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
@@ -6868,15 +8550,15 @@ var InteractiveUIService = class {
6868
8550
  }
6869
8551
  const service = this.syncServices[repoIndex];
6870
8552
  const worktreeDir = service.config.worktreeDir;
6871
- const divergedBase = path14.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
8553
+ const divergedBase = path20.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6872
8554
  if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
6873
8555
  throw new Error(`Invalid diverged directory name: "${name}"`);
6874
8556
  }
6875
- const targetPath = path14.join(divergedBase, name);
8557
+ const targetPath = path20.join(divergedBase, name);
6876
8558
  if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
6877
8559
  throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
6878
8560
  }
6879
- await fs10.rm(targetPath, { recursive: true, force: true });
8561
+ await fs17.rm(targetPath, { recursive: true, force: true });
6880
8562
  this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
6881
8563
  }
6882
8564
  async createWorktreeForBranch(repoIndex, branchName) {
@@ -6887,7 +8569,16 @@ var InteractiveUIService = class {
6887
8569
  const gitService = service.getGitService();
6888
8570
  const worktreeDir = service.config.worktreeDir;
6889
8571
  const worktreePath = this.pathResolution.getBranchWorktreePath(worktreeDir, branchName);
6890
- await gitService.addWorktree(branchName, worktreePath);
8572
+ const result = await service.runQueuedRepoOperation(async () => {
8573
+ if (service.isCloneMode()) {
8574
+ await service.checkoutBranch(branchName, { allowConfigDrift: true });
8575
+ return;
8576
+ }
8577
+ await gitService.addWorktree(branchName, worktreePath);
8578
+ });
8579
+ if (!result.started) {
8580
+ throw new Error("Another process holds the repository lock; worktree not created. Try again.");
8581
+ }
6891
8582
  }
6892
8583
  openEditorInWorktree(worktreePath) {
6893
8584
  const editor = process.env.EDITOR || process.env.VISUAL || "code";
@@ -7229,8 +8920,8 @@ function parseArguments(argv = hideBin(process.argv)) {
7229
8920
  }
7230
8921
 
7231
8922
  // src/utils/config-generator.ts
7232
- import * as fs11 from "fs/promises";
7233
- import * as path15 from "path";
8923
+ import * as fs18 from "fs/promises";
8924
+ import * as path21 from "path";
7234
8925
  function serializeToESM(obj, indent = 0) {
7235
8926
  const spaces = " ".repeat(indent);
7236
8927
  const innerSpaces = " ".repeat(indent + 2);
@@ -7260,9 +8951,9 @@ ${spaces}}`;
7260
8951
  return String(obj);
7261
8952
  }
7262
8953
  async function generateConfigFile(input2, configPath, options = {}) {
7263
- const configDir = path15.dirname(configPath);
7264
- await fs11.mkdir(configDir, { recursive: true });
7265
- 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);
7266
8957
  const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
7267
8958
  const repoName = extractRepoNameFromUrl(input2.repoUrl);
7268
8959
  const repository = {
@@ -7271,7 +8962,7 @@ async function generateConfigFile(input2, configPath, options = {}) {
7271
8962
  worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
7272
8963
  };
7273
8964
  if (input2.bareRepoDir) {
7274
- const bareRepoDirRelative = path15.relative(configDir, input2.bareRepoDir);
8965
+ const bareRepoDirRelative = path21.relative(configDir, input2.bareRepoDir);
7275
8966
  const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
7276
8967
  repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
7277
8968
  }
@@ -7298,7 +8989,7 @@ const config = ${serializeToESM(configObject)};
7298
8989
  export default config;
7299
8990
  `;
7300
8991
  try {
7301
- await fs11.writeFile(configPath, configContent, {
8992
+ await fs18.writeFile(configPath, configContent, {
7302
8993
  encoding: "utf-8",
7303
8994
  flag: options.overwrite ? "w" : "wx"
7304
8995
  });
@@ -7310,11 +9001,11 @@ export default config;
7310
9001
  }
7311
9002
  }
7312
9003
  function getDefaultConfigPath() {
7313
- return path15.join(process.cwd(), "sync-worktrees.config.js");
9004
+ return path21.join(process.cwd(), "sync-worktrees.config.js");
7314
9005
  }
7315
9006
  async function findConfigInCwd(cwd = process.cwd()) {
7316
9007
  for (const name of CONFIG_FILE_NAMES) {
7317
- const full = path15.join(cwd, name);
9008
+ const full = path21.join(cwd, name);
7318
9009
  if (await fileExists(full)) {
7319
9010
  return full;
7320
9011
  }
@@ -7323,7 +9014,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
7323
9014
  }
7324
9015
 
7325
9016
  // src/utils/interactive.ts
7326
- import * as path16 from "path";
9017
+ import * as path22 from "path";
7327
9018
  import { confirm, input, select } from "@inquirer/prompts";
7328
9019
  async function promptForInitConfig() {
7329
9020
  console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
@@ -7354,8 +9045,8 @@ async function promptForInitConfig() {
7354
9045
  if (!worktreeDir.trim() && defaultWorktreeDir) {
7355
9046
  worktreeDir = defaultWorktreeDir;
7356
9047
  }
7357
- if (!path16.isAbsolute(worktreeDir)) {
7358
- worktreeDir = path16.resolve(worktreeDir);
9048
+ if (!path22.isAbsolute(worktreeDir)) {
9049
+ worktreeDir = path22.resolve(worktreeDir);
7359
9050
  }
7360
9051
  let bareRepoDir;
7361
9052
  const askForBareDir = await confirm({
@@ -7373,8 +9064,8 @@ async function promptForInitConfig() {
7373
9064
  return true;
7374
9065
  }
7375
9066
  });
7376
- if (!path16.isAbsolute(bareRepoDir)) {
7377
- bareRepoDir = path16.resolve(bareRepoDir);
9067
+ if (!path22.isAbsolute(bareRepoDir)) {
9068
+ bareRepoDir = path22.resolve(bareRepoDir);
7378
9069
  }
7379
9070
  }
7380
9071
  const runMode = await select({
@@ -7465,7 +9156,7 @@ async function runMultipleRepositories(configFile, repositories, configPath) {
7465
9156
  const globalLogger = Logger.createDefault();
7466
9157
  const runOnce = configFile.defaults?.runOnce ?? false;
7467
9158
  const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
7468
- const limit = pLimit3(maxParallel);
9159
+ const limit = pLimit4(maxParallel);
7469
9160
  if (runOnce) {
7470
9161
  globalLogger.info(`
7471
9162
  \u{1F504} Syncing ${repositories.length} repositories...`);
@@ -7627,7 +9318,7 @@ async function runFromConfigFile(configPath, runOnceOverride = false) {
7627
9318
  await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
7628
9319
  }
7629
9320
  async function resolveConfigOrExit(cliPath) {
7630
- const resolved = cliPath ? path17.resolve(cliPath) : await findConfigInCwd();
9321
+ const resolved = cliPath ? path23.resolve(cliPath) : await findConfigInCwd();
7631
9322
  if (!resolved) {
7632
9323
  console.error(
7633
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."
@@ -7643,7 +9334,7 @@ function exitConfigExists(targetPath) {
7643
9334
  process.exit(1);
7644
9335
  }
7645
9336
  async function runInit(configPath, force) {
7646
- const targetPath = configPath ? path17.resolve(configPath) : getDefaultConfigPath();
9337
+ const targetPath = configPath ? path23.resolve(configPath) : getDefaultConfigPath();
7647
9338
  if (!force && await fileExists(targetPath)) {
7648
9339
  exitConfigExists(targetPath);
7649
9340
  }
@@ -7656,7 +9347,7 @@ async function runInit(configPath, force) {
7656
9347
  }
7657
9348
  throw error;
7658
9349
  }
7659
- const displayPath = path17.relative(process.cwd(), targetPath) || targetPath;
9350
+ const displayPath = path23.relative(process.cwd(), targetPath) || targetPath;
7660
9351
  console.log(`
7661
9352
  \u2705 Configuration saved to: ${targetPath}`);
7662
9353
  console.log(`
@@ -7664,7 +9355,7 @@ async function runInit(configPath, force) {
7664
9355
  }
7665
9356
  async function runSync(options) {
7666
9357
  const configPath = await resolveConfigOrExit(options.config);
7667
- const displayPath = path17.relative(process.cwd(), configPath) || configPath;
9358
+ const displayPath = path23.relative(process.cwd(), configPath) || configPath;
7668
9359
  console.log(`\u{1F4C4} Using config: ${displayPath}`);
7669
9360
  try {
7670
9361
  await runFromConfigFile(configPath, options.runOnce);