sync-worktrees 4.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +133 -55
  2. package/dist/components/WorktreeStatusView.d.ts.map +1 -1
  3. package/dist/constants.d.ts +22 -0
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/errors/index.d.ts +7 -0
  6. package/dist/errors/index.d.ts.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +2052 -448
  10. package/dist/index.js.map +4 -4
  11. package/dist/mcp/context.d.ts +1 -1
  12. package/dist/mcp/context.d.ts.map +1 -1
  13. package/dist/mcp/handlers.d.ts +0 -5
  14. package/dist/mcp/handlers.d.ts.map +1 -1
  15. package/dist/mcp/server.d.ts.map +1 -1
  16. package/dist/mcp/worktree-summary.d.ts.map +1 -1
  17. package/dist/mcp-server.js +2068 -499
  18. package/dist/mcp-server.js.map +4 -4
  19. package/dist/services/InteractiveUIService.d.ts.map +1 -1
  20. package/dist/services/clone-sync.service.d.ts +13 -2
  21. package/dist/services/clone-sync.service.d.ts.map +1 -1
  22. package/dist/services/config-loader.service.d.ts +2 -0
  23. package/dist/services/config-loader.service.d.ts.map +1 -1
  24. package/dist/services/git-maintenance.service.d.ts +44 -0
  25. package/dist/services/git-maintenance.service.d.ts.map +1 -0
  26. package/dist/services/git.service.d.ts +19 -1
  27. package/dist/services/git.service.d.ts.map +1 -1
  28. package/dist/services/removal-audit.service.d.ts +19 -0
  29. package/dist/services/removal-audit.service.d.ts.map +1 -0
  30. package/dist/services/sync-outcome.d.ts +1 -1
  31. package/dist/services/sync-outcome.d.ts.map +1 -1
  32. package/dist/services/trash-migration.service.d.ts +18 -0
  33. package/dist/services/trash-migration.service.d.ts.map +1 -0
  34. package/dist/services/trash-reaper.service.d.ts +18 -0
  35. package/dist/services/trash-reaper.service.d.ts.map +1 -0
  36. package/dist/services/trash.service.d.ts +91 -0
  37. package/dist/services/trash.service.d.ts.map +1 -0
  38. package/dist/services/worktree-metadata.service.d.ts +7 -0
  39. package/dist/services/worktree-metadata.service.d.ts.map +1 -1
  40. package/dist/services/worktree-mode-sync-runner.d.ts +11 -1
  41. package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -1
  42. package/dist/services/worktree-status.service.d.ts +5 -2
  43. package/dist/services/worktree-status.service.d.ts.map +1 -1
  44. package/dist/services/worktree-sync.service.d.ts +16 -0
  45. package/dist/services/worktree-sync.service.d.ts.map +1 -1
  46. package/dist/types/index.d.ts +60 -2
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/sync-metadata.d.ts +6 -0
  49. package/dist/types/sync-metadata.d.ts.map +1 -1
  50. package/dist/utils/atomic-write.d.ts +2 -0
  51. package/dist/utils/atomic-write.d.ts.map +1 -0
  52. package/dist/utils/file-exists.d.ts +2 -0
  53. package/dist/utils/file-exists.d.ts.map +1 -1
  54. package/dist/utils/filename-timestamp.d.ts +2 -0
  55. package/dist/utils/filename-timestamp.d.ts.map +1 -0
  56. package/dist/utils/lock-path.d.ts +1 -0
  57. package/dist/utils/lock-path.d.ts.map +1 -1
  58. package/dist/utils/quarantine.d.ts +2 -0
  59. package/dist/utils/quarantine.d.ts.map +1 -0
  60. package/package.json +1 -1
@@ -4,10 +4,10 @@
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
6
  // src/mcp/context.ts
7
- import * as fs10 from "fs/promises";
8
- import * as path14 from "path";
7
+ import * as fs17 from "fs/promises";
8
+ import * as path20 from "path";
9
9
  import pLimit3 from "p-limit";
10
- import simpleGit6 from "simple-git";
10
+ import simpleGit7 from "simple-git";
11
11
 
12
12
  // src/constants.ts
13
13
  var GIT_CONSTANTS = {
@@ -18,6 +18,10 @@ var GIT_CONSTANTS = {
18
18
  COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
19
19
  BARE_DIR_NAME: ".bare",
20
20
  DIVERGED_DIR_NAME: ".diverged",
21
+ REMOVED_DIR_NAME: ".removed",
22
+ TRASH_DIR_NAME: ".trash",
23
+ TRASH_REF_PREFIX: "refs/sync-worktrees/trash/",
24
+ KEEP_REF_PREFIX: "refs/sync-worktrees/keep/",
21
25
  LFS_HEADER: "version https://git-lfs.github.com/spec/",
22
26
  SUBMODULE_STATUS_ADDED: "+",
23
27
  SUBMODULE_STATUS_REMOVED: "-",
@@ -63,7 +67,16 @@ var DEFAULT_CONFIG = {
63
67
  FETCH_TIMEOUT_MS: 3e5,
64
68
  CLONE_TIMEOUT_MS: 9e5,
65
69
  LOCK_STALE_MS: 6e5,
66
- LOCK_UPDATE_MS: 3e4
70
+ LOCK_UPDATE_MS: 3e4,
71
+ MAINTENANCE: {
72
+ ENABLED: true,
73
+ INTERVAL: "7d"
74
+ },
75
+ TRASH: {
76
+ ENABLED: true,
77
+ RETENTION_DAYS: 30,
78
+ MIGRATE_LEGACY: true
79
+ }
67
80
  };
68
81
  var ERROR_MESSAGES = {
69
82
  GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
@@ -98,6 +111,15 @@ var CONFIG_FILE_NAMES = [
98
111
  "sync-worktrees.config.mjs",
99
112
  "sync-worktrees.config.cjs"
100
113
  ];
114
+ var MAINTENANCE_CONSTANTS = {
115
+ STATE_FILENAME: "sync-worktrees-maintenance.json"
116
+ };
117
+ var TRASH_CONSTANTS = {
118
+ MANIFEST_FILENAME: "manifest.json",
119
+ PAYLOAD_DIRNAME: "payload",
120
+ BUNDLE_FILENAME: "commits.bundle",
121
+ SCHEMA_VERSION: 1
122
+ };
101
123
  var METADATA_CONSTANTS = {
102
124
  MAX_HISTORY_ENTRIES: 10,
103
125
  METADATA_FILENAME: "sync-metadata.json",
@@ -140,15 +162,22 @@ var GitOperationError = class extends GitError {
140
162
  super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
141
163
  }
142
164
  };
165
+ var FastForwardError = class extends GitError {
166
+ constructor(branchName, cause) {
167
+ super(`Cannot fast-forward branch '${branchName}'`, "FAST_FORWARD_FAILED", cause);
168
+ this.branchName = branchName;
169
+ }
170
+ branchName;
171
+ };
143
172
  var WorktreeError = class extends SyncWorktreesError {
144
173
  constructor(message, code, cause) {
145
174
  super(message, `WORKTREE_${code}`, cause);
146
175
  }
147
176
  };
148
177
  var WorktreeNotCleanError = class extends WorktreeError {
149
- constructor(path16, reasons) {
150
- super(`Worktree at '${path16}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
151
- this.path = path16;
178
+ constructor(path22, reasons) {
179
+ super(`Worktree at '${path22}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
180
+ this.path = path22;
152
181
  this.reasons = reasons;
153
182
  }
154
183
  path;
@@ -175,6 +204,18 @@ var ConfigFileNotFoundError = class extends ConfigError {
175
204
  }
176
205
  configPath;
177
206
  };
207
+ var TrashError = class extends SyncWorktreesError {
208
+ constructor(message, code, cause) {
209
+ super(message, `TRASH_${code}`, cause);
210
+ }
211
+ };
212
+ var TrashOperationError = class extends TrashError {
213
+ constructor(operation, details, cause) {
214
+ super(`Trash operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
215
+ this.operation = operation;
216
+ }
217
+ operation;
218
+ };
178
219
 
179
220
  // src/utils/branch-filter.ts
180
221
  function matchesPattern(name, pattern) {
@@ -196,16 +237,73 @@ function filterBranchesByName(branches, include, exclude) {
196
237
  return result;
197
238
  }
198
239
 
240
+ // src/utils/date-filter.ts
241
+ function parseDuration(durationStr) {
242
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
243
+ if (!match) {
244
+ return null;
245
+ }
246
+ const value = parseInt(match[1], 10);
247
+ const unit = match[2];
248
+ const multipliers = {
249
+ h: 60 * 60 * 1e3,
250
+ // hours
251
+ d: 24 * 60 * 60 * 1e3,
252
+ // days
253
+ w: 7 * 24 * 60 * 60 * 1e3,
254
+ // weeks
255
+ m: 30 * 24 * 60 * 60 * 1e3,
256
+ // months (approximate)
257
+ y: 365 * 24 * 60 * 60 * 1e3
258
+ // years (approximate)
259
+ };
260
+ return value * multipliers[unit];
261
+ }
262
+ function filterBranchesByAge(branches, maxAge) {
263
+ const maxAgeMs = parseDuration(maxAge);
264
+ if (maxAgeMs === null) {
265
+ console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
266
+ return branches;
267
+ }
268
+ const cutoffDate = new Date(Date.now() - maxAgeMs);
269
+ return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
270
+ }
271
+ function formatDuration(durationStr) {
272
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
273
+ if (!match) {
274
+ return durationStr;
275
+ }
276
+ const value = parseInt(match[1], 10);
277
+ const unit = match[2];
278
+ const unitNames = {
279
+ h: value === 1 ? "hour" : "hours",
280
+ d: value === 1 ? "day" : "days",
281
+ w: value === 1 ? "week" : "weeks",
282
+ m: value === 1 ? "month" : "months",
283
+ y: value === 1 ? "year" : "years"
284
+ };
285
+ return `${value} ${unitNames[unit]}`;
286
+ }
287
+
199
288
  // src/utils/file-exists.ts
200
289
  import * as fs from "fs/promises";
201
- async function fileExists(path16) {
290
+ async function fileExists(path22) {
202
291
  try {
203
- await fs.access(path16);
292
+ await fs.access(path22);
204
293
  return true;
205
294
  } catch {
206
295
  return false;
207
296
  }
208
297
  }
298
+ async function probePathExists(path22) {
299
+ try {
300
+ await fs.access(path22);
301
+ return "exists";
302
+ } catch (error) {
303
+ const code = error.code;
304
+ return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
305
+ }
306
+ }
209
307
 
210
308
  // src/utils/git-url.ts
211
309
  function extractRepoNameFromUrl(gitUrl) {
@@ -296,7 +394,8 @@ var CLONE_MODE_CONFLICTING_FIELDS = [
296
394
  "branchExclude",
297
395
  "branchMaxAge",
298
396
  "updateExistingWorktrees",
299
- "bareRepoDir"
397
+ "bareRepoDir",
398
+ "trash"
300
399
  ];
301
400
  var ConfigLoaderService = class {
302
401
  async findConfigUpward(startDir) {
@@ -399,6 +498,12 @@ var ConfigLoaderService = class {
399
498
  if (repoObj.sparseCheckout !== void 0) {
400
499
  this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
401
500
  }
501
+ if (repoObj.maintenance !== void 0) {
502
+ this.validateMaintenanceConfig(repoObj.maintenance, `Repository '${repoObj.name}'`);
503
+ }
504
+ if (repoObj.trash !== void 0) {
505
+ this.validateTrashConfig(repoObj.trash, `Repository '${repoObj.name}'`);
506
+ }
402
507
  this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
403
508
  this.validateRepositoryMode(repoObj, configObj.defaults);
404
509
  });
@@ -435,6 +540,12 @@ var ConfigLoaderService = class {
435
540
  if (defaults.sparseCheckout !== void 0) {
436
541
  this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
437
542
  }
543
+ if (defaults.maintenance !== void 0) {
544
+ this.validateMaintenanceConfig(defaults.maintenance, "defaults");
545
+ }
546
+ if (defaults.trash !== void 0) {
547
+ this.validateTrashConfig(defaults.trash, "defaults");
548
+ }
438
549
  this.validateDepth(defaults.depth, "defaults.depth");
439
550
  if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
440
551
  throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
@@ -462,6 +573,46 @@ var ConfigLoaderService = class {
462
573
  throw new ConfigValidationError(field, "must be a positive safe integer");
463
574
  }
464
575
  }
576
+ validateMaintenanceConfig(value, context) {
577
+ if (value === void 0) return;
578
+ if (typeof value !== "object" || value === null) {
579
+ throw new Error(`'maintenance' in ${context} must be an object`);
580
+ }
581
+ const maintenance = value;
582
+ if (maintenance.enabled !== void 0 && typeof maintenance.enabled !== "boolean") {
583
+ throw new Error(`'maintenance.enabled' in ${context} must be a boolean`);
584
+ }
585
+ if (maintenance.aggressive !== void 0 && typeof maintenance.aggressive !== "boolean") {
586
+ throw new Error(`'maintenance.aggressive' in ${context} must be a boolean`);
587
+ }
588
+ if (maintenance.interval !== void 0) {
589
+ const parsed = typeof maintenance.interval === "string" ? parseDuration(maintenance.interval) : null;
590
+ if (parsed === null || parsed <= 0) {
591
+ throw new Error(
592
+ `'maintenance.interval' in ${context} must be a positive duration string like '7d', '24h', or '2w'`
593
+ );
594
+ }
595
+ }
596
+ }
597
+ validateTrashConfig(value, context) {
598
+ if (value === void 0) return;
599
+ if (typeof value !== "object" || value === null) {
600
+ throw new Error(`'trash' in ${context} must be an object`);
601
+ }
602
+ const trash = value;
603
+ if (trash.enabled !== void 0 && typeof trash.enabled !== "boolean") {
604
+ throw new Error(`'trash.enabled' in ${context} must be a boolean`);
605
+ }
606
+ if (trash.migrateLegacy !== void 0 && typeof trash.migrateLegacy !== "boolean") {
607
+ throw new Error(`'trash.migrateLegacy' in ${context} must be a boolean`);
608
+ }
609
+ if (trash.retentionDays !== void 0 && (typeof trash.retentionDays !== "number" || !Number.isFinite(trash.retentionDays) || trash.retentionDays <= 0)) {
610
+ throw new Error(`'trash.retentionDays' in ${context} must be a positive number`);
611
+ }
612
+ if (trash.warnSizeBytes !== void 0 && (typeof trash.warnSizeBytes !== "number" || !Number.isFinite(trash.warnSizeBytes) || trash.warnSizeBytes <= 0)) {
613
+ throw new Error(`'trash.warnSizeBytes' in ${context} must be a positive number`);
614
+ }
615
+ }
465
616
  validateRetryConfig(value, context) {
466
617
  if (typeof value !== "object" || value === null) {
467
618
  throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
@@ -733,6 +884,18 @@ var ConfigLoaderService = class {
733
884
  if (sparse) {
734
885
  resolved.sparseCheckout = sparse;
735
886
  }
887
+ if (repo.maintenance || defaults?.maintenance) {
888
+ resolved.maintenance = {
889
+ ...defaults?.maintenance || {},
890
+ ...repo.maintenance || {}
891
+ };
892
+ }
893
+ if (repo.trash || defaults?.trash) {
894
+ resolved.trash = {
895
+ ...defaults?.trash || {},
896
+ ...repo.trash || {}
897
+ };
898
+ }
736
899
  return resolved;
737
900
  }
738
901
  isDuplicateRepoUrl(repo, all, defaults) {
@@ -926,6 +1089,31 @@ function isMissingRemoteRefError(errorMessage) {
926
1089
  return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
927
1090
  }
928
1091
 
1092
+ // src/utils/lock-path.ts
1093
+ import { createHash } from "crypto";
1094
+ import * as os from "os";
1095
+ import * as path3 from "path";
1096
+ function getCloneModeLockTarget(config) {
1097
+ const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
1098
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
1099
+ const dir = path3.join(stateBase, "sync-worktrees", "locks");
1100
+ return { dir, file: `${hash}.lock` };
1101
+ }
1102
+ function getRemovalAuditLogPath(config) {
1103
+ const name = config.name;
1104
+ const configDir = config.__configFileDir;
1105
+ const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
1106
+ if (configDir) {
1107
+ return path3.join(
1108
+ configDir,
1109
+ ".sync-worktrees-state",
1110
+ `${sanitizeNameForPath(name ?? "repo", "removal audit log name")}-${hash}-removals.jsonl`
1111
+ );
1112
+ }
1113
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
1114
+ return path3.join(stateBase, "sync-worktrees", "removals", `${hash}.jsonl`);
1115
+ }
1116
+
929
1117
  // src/utils/retry.ts
930
1118
  var DEFAULT_OPTIONS = {
931
1119
  maxAttempts: "unlimited",
@@ -996,7 +1184,7 @@ async function retry(fn, options = {}) {
996
1184
  const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
997
1185
  const delay = baseDelay + jitter;
998
1186
  opts.onRetry(error, attempt, lfsContext);
999
- await new Promise((resolve11) => setTimeout(resolve11, delay));
1187
+ await new Promise((resolve13) => setTimeout(resolve13, delay));
1000
1188
  attempt++;
1001
1189
  }
1002
1190
  }
@@ -1067,7 +1255,7 @@ var PhaseTimer = class {
1067
1255
  return results;
1068
1256
  }
1069
1257
  };
1070
- function formatDuration(ms) {
1258
+ function formatDuration2(ms) {
1071
1259
  if (ms < 1e3) {
1072
1260
  return `${ms}ms`;
1073
1261
  }
@@ -1089,7 +1277,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
1089
1277
  }
1090
1278
  });
1091
1279
  table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
1092
- table.push(["Total Sync", formatDuration(totalDuration), ""]);
1280
+ table.push(["Total Sync", formatDuration2(totalDuration), ""]);
1093
1281
  for (let i = 0; i < phaseResults.length; i++) {
1094
1282
  const result = phaseResults[i];
1095
1283
  const isLast = i === phaseResults.length - 1;
@@ -1097,14 +1285,14 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
1097
1285
  const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
1098
1286
  const name = ` ${prefix} ${result.name}${countStr}`;
1099
1287
  const efficiency = result.efficiency ? `${result.efficiency}%` : "";
1100
- table.push([name, formatDuration(result.duration), efficiency]);
1288
+ table.push([name, formatDuration2(result.duration), efficiency]);
1101
1289
  }
1102
1290
  return table.toString();
1103
1291
  }
1104
1292
 
1105
1293
  // src/services/clone-sync.service.ts
1106
1294
  import * as fs3 from "fs/promises";
1107
- import * as path4 from "path";
1295
+ import * as path5 from "path";
1108
1296
  import simpleGit from "simple-git";
1109
1297
 
1110
1298
  // src/utils/git-progress.ts
@@ -1133,7 +1321,7 @@ function makeGitProgressHandler(logger, emitProgress) {
1133
1321
 
1134
1322
  // src/services/file-copy.service.ts
1135
1323
  import * as fs2 from "fs/promises";
1136
- import * as path3 from "path";
1324
+ import * as path4 from "path";
1137
1325
  import { glob } from "glob";
1138
1326
  var DEFAULT_IGNORE_PATTERNS = [
1139
1327
  "**/node_modules/**",
@@ -1160,8 +1348,8 @@ var FileCopyService = class {
1160
1348
  }
1161
1349
  const filesToCopy = await this.expandPatterns(sourceDir, patterns);
1162
1350
  for (const relativePath of filesToCopy) {
1163
- const sourcePath = path3.join(sourceDir, relativePath);
1164
- const destPath = path3.join(destDir, relativePath);
1351
+ const sourcePath = path4.join(sourceDir, relativePath);
1352
+ const destPath = path4.join(destDir, relativePath);
1165
1353
  try {
1166
1354
  const copied = await this.copyFile(sourcePath, destPath);
1167
1355
  if (copied) {
@@ -1200,7 +1388,7 @@ var FileCopyService = class {
1200
1388
  if (await fileExists(destPath)) {
1201
1389
  return false;
1202
1390
  }
1203
- const destDir = path3.dirname(destPath);
1391
+ const destDir = path4.dirname(destPath);
1204
1392
  await fs2.mkdir(destDir, { recursive: true });
1205
1393
  await fs2.copyFile(sourcePath, destPath);
1206
1394
  return true;
@@ -1262,7 +1450,7 @@ var BranchCreatedActionsService = class {
1262
1450
  function formatCloneSkipReason(reason) {
1263
1451
  switch (reason.kind) {
1264
1452
  case "branch_mismatch":
1265
- return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
1453
+ 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`;
1266
1454
  case "head_unreadable":
1267
1455
  return `could not read HEAD: ${reason.error}`;
1268
1456
  case "dirty_tree":
@@ -1333,14 +1521,14 @@ var SyncOutcomeAccumulator = class {
1333
1521
  this.actions.push(action);
1334
1522
  this.counts[countKeyFor(action)]++;
1335
1523
  }
1336
- recordCreated(branch, path16) {
1337
- this.add({ kind: "created", branch, path: path16 });
1524
+ recordCreated(branch, path22) {
1525
+ this.add({ kind: "created", branch, path: path22 });
1338
1526
  }
1339
- recordRemoved(branch, path16) {
1340
- this.add({ kind: "removed", branch, path: path16 });
1527
+ recordRemoved(branch, path22, warning) {
1528
+ this.add({ kind: "removed", branch, path: path22, ...warning !== void 0 && { warning } });
1341
1529
  }
1342
- recordUpdated(branch, path16, reason) {
1343
- this.add({ kind: "updated", branch, path: path16, reason });
1530
+ recordUpdated(branch, path22, reason) {
1531
+ this.add({ kind: "updated", branch, path: path22, reason });
1344
1532
  }
1345
1533
  recordNoop(scope, reason, details) {
1346
1534
  this.add({ kind: "noop", scope, reason, ...details });
@@ -1348,8 +1536,8 @@ var SyncOutcomeAccumulator = class {
1348
1536
  recordSkipped(scope, reason, details) {
1349
1537
  this.add({ kind: "skipped", scope, reason, ...details });
1350
1538
  }
1351
- recordPreservedDiverged(branch, path16, preservedPath) {
1352
- this.add({ kind: "preserved-diverged", branch, path: path16, preservedPath });
1539
+ recordPreservedDiverged(branch, path22, preservedPath) {
1540
+ this.add({ kind: "preserved-diverged", branch, path: path22, preservedPath });
1353
1541
  }
1354
1542
  recordFailed(scope, error, details = {}) {
1355
1543
  this.add({ kind: "failed", scope, error, ...details });
@@ -1402,7 +1590,6 @@ function cloneSkipToOutcomeAction(reason, details = {}) {
1402
1590
  }
1403
1591
 
1404
1592
  // src/services/clone-sync.service.ts
1405
- var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
1406
1593
  var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
1407
1594
  var CloneSyncService = class {
1408
1595
  constructor(config, gitService, logger, options = {}) {
@@ -1436,8 +1623,8 @@ var CloneSyncService = class {
1436
1623
  this.pendingInitSkip = null;
1437
1624
  }
1438
1625
  async getWorktrees() {
1439
- const worktreeDir = path4.resolve(this.config.worktreeDir);
1440
- if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
1626
+ const worktreeDir = path5.resolve(this.config.worktreeDir);
1627
+ if (!await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
1441
1628
  return [];
1442
1629
  }
1443
1630
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
@@ -1521,40 +1708,27 @@ var CloneSyncService = class {
1521
1708
  return env;
1522
1709
  }
1523
1710
  buildCloneArgs(branch) {
1524
- const args = ["--branch", branch, "--progress"];
1711
+ const args = ["--branch", branch, "--single-branch", "--no-tags", "--progress"];
1525
1712
  if (this.config.depth !== void 0) {
1526
- args.push("--depth", String(this.config.depth), "--no-single-branch");
1713
+ args.push("--depth", String(this.config.depth));
1527
1714
  }
1528
1715
  return args;
1529
1716
  }
1530
- async buildFetchArgs(git) {
1531
- const args = ["origin", "--prune", "--progress"];
1717
+ getBranchRefspec(branch) {
1718
+ return `+refs/heads/${branch}:refs/remotes/origin/${branch}`;
1719
+ }
1720
+ async buildFetchArgs(git, branch) {
1721
+ const args = ["origin", "--prune", "--no-tags", "--progress"];
1532
1722
  if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
1533
1723
  args.push("--depth", String(this.config.depth));
1534
1724
  }
1725
+ args.push(this.getBranchRefspec(branch));
1535
1726
  return args;
1536
1727
  }
1537
- async ensureAllRemoteBranchesRefspec(git) {
1538
- let fetchRefspecs = [];
1539
- try {
1540
- const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
1541
- fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1542
- } catch {
1543
- fetchRefspecs = [];
1544
- }
1545
- if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
1546
- const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
1547
- this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
1548
- await git.raw(["remote", "set-branches", "origin", "*"]);
1549
- for (const refspec of customRefspecs) {
1550
- await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
1551
- }
1552
- }
1553
- isOriginRemoteBranchTrackingRefspec(refspec) {
1554
- const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
1555
- if (withoutForce.startsWith("^")) return false;
1556
- const [source, destination] = withoutForce.split(":");
1557
- return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
1728
+ async configureSingleBranchRemote(git, branch) {
1729
+ await git.raw(["config", "--replace-all", "remote.origin.fetch", this.getBranchRefspec(branch)]);
1730
+ await git.raw(["config", "--replace-all", "remote.origin.tagOpt", "--no-tags"]);
1731
+ await this.deleteStaleRemoteTrackingRefs(git, branch);
1558
1732
  }
1559
1733
  recordMissingRemoteRefSkip(branch) {
1560
1734
  this.recordSkip(
@@ -1563,7 +1737,10 @@ var CloneSyncService = class {
1563
1737
  `Skipping '${this.repoName}': origin/${branch} is missing`
1564
1738
  );
1565
1739
  }
1566
- async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
1740
+ async fetchWithRecovery(git, fetchArgs, worktreeDir, branch, recordSkip = true) {
1741
+ const recordMissing = () => {
1742
+ if (recordSkip) this.recordMissingRemoteRefSkip(branch);
1743
+ };
1567
1744
  try {
1568
1745
  await git.fetch(fetchArgs);
1569
1746
  return { skipped: false };
@@ -1580,14 +1757,14 @@ var CloneSyncService = class {
1580
1757
  return { skipped: false };
1581
1758
  } catch (retryError) {
1582
1759
  if (isMissingRemoteRefError(getErrorMessage(retryError))) {
1583
- this.recordMissingRemoteRefSkip(branch);
1760
+ recordMissing();
1584
1761
  return { skipped: true };
1585
1762
  }
1586
1763
  throw retryError;
1587
1764
  }
1588
1765
  }
1589
1766
  if (isMissingRemoteRefError(message)) {
1590
- this.recordMissingRemoteRefSkip(branch);
1767
+ recordMissing();
1591
1768
  return { skipped: true };
1592
1769
  }
1593
1770
  throw fetchError;
@@ -1615,7 +1792,7 @@ var CloneSyncService = class {
1615
1792
  this.logger.info(
1616
1793
  `[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
1617
1794
  );
1618
- await git.fetch(["--unshallow"]);
1795
+ await git.fetch(["--unshallow", "--no-tags"]);
1619
1796
  }
1620
1797
  getDeepenTargets() {
1621
1798
  const configuredDepth = this.config.depth;
@@ -1635,8 +1812,9 @@ var CloneSyncService = class {
1635
1812
  "--depth",
1636
1813
  String(targetDepth),
1637
1814
  "--prune",
1815
+ "--no-tags",
1638
1816
  "--progress",
1639
- `+refs/heads/${branch}:refs/remotes/origin/${branch}`
1817
+ this.getBranchRefspec(branch)
1640
1818
  ]);
1641
1819
  }
1642
1820
  async resolveBranch() {
@@ -1653,6 +1831,153 @@ var CloneSyncService = class {
1653
1831
  this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
1654
1832
  return this.resolvedBranch;
1655
1833
  }
1834
+ parseLsRemoteHeads(output) {
1835
+ 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);
1836
+ }
1837
+ async getRemoteBranches() {
1838
+ const worktreeDir = path5.resolve(this.config.worktreeDir);
1839
+ const repoArg = await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR)) ? "origin" : this.config.repoUrl;
1840
+ const git = repoArg === "origin" ? this.clientFor(worktreeDir, this.getFetchTimeoutMs()) : simpleGit(this.buildGitOptions(this.getFetchTimeoutMs())).env(this.buildGitEnv());
1841
+ const output = await git.raw(["ls-remote", "--heads", repoArg]);
1842
+ return this.parseLsRemoteHeads(output);
1843
+ }
1844
+ async localBranchExists(git, branch) {
1845
+ try {
1846
+ await git.raw(["show-ref", "--verify", `refs/heads/${branch}`]);
1847
+ return true;
1848
+ } catch {
1849
+ return false;
1850
+ }
1851
+ }
1852
+ async localBranchCanFastForward(git, branch) {
1853
+ const localRef = `refs/heads/${branch}`;
1854
+ const remoteRef = `refs/remotes/origin/${branch}`;
1855
+ let localSha;
1856
+ let remoteSha;
1857
+ try {
1858
+ localSha = (await git.raw(["rev-parse", localRef])).trim();
1859
+ remoteSha = (await git.raw(["rev-parse", remoteRef])).trim();
1860
+ } catch {
1861
+ return false;
1862
+ }
1863
+ if (localSha === remoteSha) return true;
1864
+ try {
1865
+ const mergeBase = (await git.raw(["merge-base", localRef, remoteRef])).trim();
1866
+ return mergeBase === localSha;
1867
+ } catch {
1868
+ return false;
1869
+ }
1870
+ }
1871
+ async deleteRemoteTrackingRef(git, refName) {
1872
+ try {
1873
+ await git.raw(["update-ref", "-d", refName]);
1874
+ } catch {
1875
+ }
1876
+ }
1877
+ async deleteStaleRemoteTrackingRefs(git, branch) {
1878
+ let refsOutput;
1879
+ try {
1880
+ refsOutput = await git.raw(["for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
1881
+ } catch {
1882
+ return;
1883
+ }
1884
+ const keepRef = `refs/remotes/origin/${branch}`;
1885
+ const refsToDelete = refsOutput.split(/\r?\n/).map((ref) => ref.trim()).filter((ref) => ref && ref !== keepRef && ref !== "refs/remotes/origin/HEAD");
1886
+ for (const ref of refsToDelete) {
1887
+ await this.deleteRemoteTrackingRef(git, ref);
1888
+ }
1889
+ }
1890
+ async restoreBranchAfterCheckoutFailure(git, previousBranch, attemptedBranch) {
1891
+ if (!previousBranch || previousBranch === "HEAD" || previousBranch === attemptedBranch) return;
1892
+ try {
1893
+ await git.raw(["switch", previousBranch]);
1894
+ } catch (error) {
1895
+ this.logger.warn(
1896
+ `Failed to restore '${this.repoName}' to '${previousBranch}' after checkout failure: ${getErrorMessage(error)}`
1897
+ );
1898
+ }
1899
+ }
1900
+ async checkoutBranch(branch, options = {}) {
1901
+ if (!this.initialized) {
1902
+ await this.initialize();
1903
+ }
1904
+ const targetBranch = await this.resolveBranch();
1905
+ if (branch !== targetBranch && !options.allowConfigDrift) {
1906
+ throw new ConfigError(
1907
+ 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.`,
1908
+ "CLONE_BRANCH_MISMATCH"
1909
+ );
1910
+ }
1911
+ const worktreeDir = this.config.worktreeDir;
1912
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1913
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
1914
+ if (originMismatch) {
1915
+ throw new ConfigError(
1916
+ `Cannot switch '${this.repoName}' to '${branch}': ${originMismatch.progressDetail}.`,
1917
+ "ORIGIN_MISMATCH"
1918
+ );
1919
+ }
1920
+ const currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
1921
+ if (currentBranch === "HEAD") {
1922
+ throw new GitOperationError(
1923
+ "checkout",
1924
+ `'${this.repoName}' is on a detached HEAD; check out a branch manually (preserving any local commits) before switching the tracked branch`
1925
+ );
1926
+ }
1927
+ if (currentBranch === branch) {
1928
+ await this.configureSingleBranchRemote(git, branch);
1929
+ this.resolvedBranch = branch;
1930
+ this.pendingInitSkip = null;
1931
+ this.warnConfigDriftAfterCheckout(branch, targetBranch);
1932
+ return;
1933
+ }
1934
+ const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
1935
+ if (!isClean) {
1936
+ throw new WorktreeNotCleanError(worktreeDir, ["working tree has local changes"]);
1937
+ }
1938
+ await this.unshallowIfDepthRemoved(git);
1939
+ const fetchArgs = await this.buildFetchArgs(git, branch);
1940
+ if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch, false)).skipped) {
1941
+ throw new GitOperationError("checkout", `origin/${branch} is missing for '${this.repoName}'`);
1942
+ }
1943
+ if (!await this.hasRemoteBranch(git, branch)) {
1944
+ throw new GitOperationError(
1945
+ "checkout",
1946
+ `origin/${branch} did not materialize after fetch for '${this.repoName}'`
1947
+ );
1948
+ }
1949
+ if (await this.localBranchExists(git, branch)) {
1950
+ if (!await this.localBranchCanFastForward(git, branch)) {
1951
+ throw new FastForwardError(branch);
1952
+ }
1953
+ let switched = false;
1954
+ try {
1955
+ await git.raw(["switch", branch]);
1956
+ switched = true;
1957
+ await git.merge([`origin/${branch}`, "--ff-only"]);
1958
+ } catch (error) {
1959
+ if (switched) {
1960
+ await this.restoreBranchAfterCheckoutFailure(git, currentBranch, branch);
1961
+ }
1962
+ throw error;
1963
+ }
1964
+ } else {
1965
+ await git.raw(["switch", "-c", branch, "--track", `origin/${branch}`]);
1966
+ }
1967
+ await this.configureSingleBranchRemote(git, branch);
1968
+ this.resolvedBranch = branch;
1969
+ this.pendingInitSkip = null;
1970
+ this.warnConfigDriftAfterCheckout(branch, targetBranch);
1971
+ }
1972
+ // resolvedBranch keeps in-session syncs on the new branch, but the config
1973
+ // file still names the old one: the next process start will soft-skip with
1974
+ // branch_mismatch on every tick until the config is updated.
1975
+ warnConfigDriftAfterCheckout(branch, targetBranch) {
1976
+ if (branch === targetBranch) return;
1977
+ this.logger.warn(
1978
+ `\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.`
1979
+ );
1980
+ }
1656
1981
  async initialize(outcome) {
1657
1982
  return this.withOutcome(outcome, () => this.initializeInternal());
1658
1983
  }
@@ -1676,7 +2001,7 @@ var CloneSyncService = class {
1676
2001
  return;
1677
2002
  }
1678
2003
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1679
- await this.ensureAllRemoteBranchesRefspec(git);
2004
+ await this.configureSingleBranchRemote(git, branch);
1680
2005
  this.initialized = true;
1681
2006
  this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
1682
2007
  return;
@@ -1704,7 +2029,7 @@ var CloneSyncService = class {
1704
2029
  throw error;
1705
2030
  }
1706
2031
  const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1707
- await this.ensureAllRemoteBranchesRefspec(worktreeGit);
2032
+ await this.configureSingleBranchRemote(worktreeGit, branch);
1708
2033
  this.logger.info(`\u2705 Clone successful.`);
1709
2034
  this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
1710
2035
  if (this.config.sparseCheckout) {
@@ -1792,7 +2117,7 @@ var CloneSyncService = class {
1792
2117
  return;
1793
2118
  }
1794
2119
  const looksIncomplete = entries.every((e) => e.startsWith("."));
1795
- const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
2120
+ const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
1796
2121
  if (looksIncomplete && !hasUsableGit) {
1797
2122
  try {
1798
2123
  await fs3.rm(worktreeDir, { recursive: true, force: true });
@@ -1807,7 +2132,7 @@ var CloneSyncService = class {
1807
2132
  }
1808
2133
  }
1809
2134
  getInitMarkerPath(worktreeDir) {
1810
- return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
2135
+ return path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
1811
2136
  }
1812
2137
  async runInitialFileCopy(worktreeDir, branch) {
1813
2138
  const marker = this.getInitMarkerPath(worktreeDir);
@@ -1859,7 +2184,7 @@ var CloneSyncService = class {
1859
2184
  if (currentBranch !== branch) {
1860
2185
  this.recordSkip(
1861
2186
  { kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
1862
- `Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
2187
+ `Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge. Update 'branch' in the config or switch the clone back.`,
1863
2188
  `Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
1864
2189
  );
1865
2190
  return;
@@ -1874,13 +2199,13 @@ var CloneSyncService = class {
1874
2199
  return;
1875
2200
  }
1876
2201
  await this.unshallowIfDepthRemoved(git);
1877
- await this.ensureAllRemoteBranchesRefspec(git);
1878
- const fetchArgs = await this.buildFetchArgs(git);
1879
- this.emitProgress({ phase: "fetch", message: `Fetching origin branches for '${this.repoName}'` });
2202
+ await this.configureSingleBranchRemote(git, branch);
2203
+ const fetchArgs = await this.buildFetchArgs(git, branch);
2204
+ this.emitProgress({ phase: "fetch", message: `Fetching origin/${branch} for '${this.repoName}'` });
1880
2205
  if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
1881
2206
  return;
1882
2207
  }
1883
- this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
2208
+ this.emitProgress({ phase: "fetch", message: `Fetched origin/${branch} for '${this.repoName}'` });
1884
2209
  if (!await this.hasRemoteBranch(git, branch)) {
1885
2210
  this.recordSkip(
1886
2211
  { kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
@@ -1970,63 +2295,233 @@ var CloneSyncService = class {
1970
2295
  }
1971
2296
  };
1972
2297
 
1973
- // src/services/git.service.ts
1974
- import * as fs6 from "fs/promises";
1975
- import * as path8 from "path";
1976
- import simpleGit5 from "simple-git";
2298
+ // src/services/git-maintenance.service.ts
2299
+ import * as fs5 from "fs/promises";
2300
+ import * as path6 from "path";
2301
+ import simpleGit2 from "simple-git";
1977
2302
 
1978
- // src/utils/worktree-list-parser.ts
1979
- function parseWorktreeListPorcelain(output) {
1980
- const worktrees = [];
1981
- let current = {};
1982
- const flush = () => {
1983
- if (!current.path) {
1984
- current = {};
1985
- return;
2303
+ // src/utils/atomic-write.ts
2304
+ import * as fs4 from "fs/promises";
2305
+ async function atomicWriteFile(filePath, content) {
2306
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
2307
+ let renamed = false;
2308
+ try {
2309
+ const handle = await fs4.open(tmpPath, "w");
2310
+ try {
2311
+ await handle.writeFile(content, "utf-8");
2312
+ await handle.sync();
2313
+ } finally {
2314
+ await handle.close();
1986
2315
  }
1987
- worktrees.push({
1988
- path: current.path,
1989
- branch: current.branch ?? null,
1990
- head: current.head ?? null,
1991
- detached: current.detached ?? false,
1992
- prunable: current.prunable ?? false,
1993
- locked: current.locked ?? false
1994
- });
1995
- current = {};
1996
- };
1997
- for (const line of output.split("\n")) {
1998
- if (line.startsWith("worktree ")) {
1999
- flush();
2000
- current.path = line.substring("worktree ".length);
2001
- } else if (line.startsWith("branch ")) {
2002
- current.branch = line.substring("branch ".length).replace("refs/heads/", "");
2003
- } else if (line.startsWith("HEAD ")) {
2004
- current.head = line.substring("HEAD ".length);
2005
- } else if (line === "detached") {
2006
- current.detached = true;
2007
- } else if (line === "prunable" || line.startsWith("prunable ")) {
2008
- current.prunable = true;
2009
- } else if (line === "locked" || line.startsWith("locked ")) {
2010
- current.locked = true;
2011
- } else if (line.trim() === "") {
2012
- flush();
2316
+ try {
2317
+ await fs4.rename(tmpPath, filePath);
2318
+ renamed = true;
2319
+ } catch (err) {
2320
+ if (err.code === ERROR_MESSAGES.EXDEV) {
2321
+ await fs4.copyFile(tmpPath, filePath);
2322
+ } else {
2323
+ throw err;
2324
+ }
2325
+ }
2326
+ } finally {
2327
+ if (!renamed) {
2328
+ await fs4.unlink(tmpPath).catch(() => void 0);
2013
2329
  }
2014
2330
  }
2015
- flush();
2016
- return worktrees;
2017
2331
  }
2018
2332
 
2019
- // src/services/sparse-checkout.service.ts
2020
- import * as path5 from "path";
2021
- import simpleGit2 from "simple-git";
2022
- var SparseCheckoutService = class {
2023
- logger;
2024
- gitFactory;
2025
- warnedConfigs = /* @__PURE__ */ new WeakSet();
2026
- matcherCache = /* @__PURE__ */ new WeakMap();
2027
- constructor(logger, gitFactory) {
2333
+ // src/services/git-maintenance.service.ts
2334
+ var GitMaintenanceService = class {
2335
+ constructor(config, gitService, logger, gitFactory = (cwd) => simpleGit2(cwd)) {
2336
+ this.config = config;
2337
+ this.gitService = gitService;
2338
+ this.logger = logger ?? Logger.createDefault();
2339
+ this.gitFactory = gitFactory;
2340
+ }
2341
+ config;
2342
+ gitService;
2343
+ logger;
2344
+ gitFactory;
2345
+ updateLogger(logger) {
2346
+ this.logger = logger;
2347
+ }
2348
+ isEnabled() {
2349
+ return this.config.maintenance?.enabled ?? DEFAULT_CONFIG.MAINTENANCE.ENABLED;
2350
+ }
2351
+ getIntervalMs() {
2352
+ const fallback = parseDuration(DEFAULT_CONFIG.MAINTENANCE.INTERVAL);
2353
+ const raw = this.config.maintenance?.interval;
2354
+ if (raw === void 0) {
2355
+ return fallback;
2356
+ }
2357
+ const parsed = parseDuration(raw);
2358
+ if (parsed === null || parsed <= 0) {
2359
+ this.logger.warn(`Invalid maintenance.interval '${raw}', using default ${DEFAULT_CONFIG.MAINTENANCE.INTERVAL}.`);
2360
+ return fallback;
2361
+ }
2362
+ return parsed;
2363
+ }
2364
+ resolveTarget() {
2365
+ if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
2366
+ const cwd = path6.resolve(this.config.worktreeDir);
2367
+ return { cwd, gitDir: path6.join(cwd, PATH_CONSTANTS.GIT_DIR) };
2368
+ }
2369
+ const bare = this.gitService.getBareRepoPath();
2370
+ return { cwd: bare, gitDir: bare };
2371
+ }
2372
+ getStatePath(gitDir) {
2373
+ return path6.join(gitDir, MAINTENANCE_CONSTANTS.STATE_FILENAME);
2374
+ }
2375
+ async readState(statePath) {
2376
+ try {
2377
+ const parsed = JSON.parse(await fs5.readFile(statePath, "utf-8"));
2378
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2379
+ return {};
2380
+ }
2381
+ return { ...parsed };
2382
+ } catch {
2383
+ return {};
2384
+ }
2385
+ }
2386
+ async writeState(statePath, state) {
2387
+ try {
2388
+ await atomicWriteFile(statePath, JSON.stringify(state, null, 2));
2389
+ } catch (error) {
2390
+ this.logger.warn(`Failed to persist maintenance state: ${getErrorMessage(error)}`);
2391
+ }
2392
+ }
2393
+ isDue(state, now) {
2394
+ if (!state.lastAttemptAt) {
2395
+ return true;
2396
+ }
2397
+ const last = new Date(state.lastAttemptAt).getTime();
2398
+ if (Number.isNaN(last)) {
2399
+ return true;
2400
+ }
2401
+ return now - last >= this.getIntervalMs();
2402
+ }
2403
+ /**
2404
+ * Run `git gc` if maintenance is enabled and due. MUST be called while the
2405
+ * repository operation lock is already held. Never throws: a gc failure is
2406
+ * recorded and warned so it cannot fail the surrounding sync. The attempt
2407
+ * timestamp is persisted even on failure, so a perpetually-failing gc is
2408
+ * throttled instead of retried every tick.
2409
+ */
2410
+ async runIfDueUnlocked(now = Date.now()) {
2411
+ if (!this.isEnabled()) {
2412
+ return;
2413
+ }
2414
+ try {
2415
+ const { cwd, gitDir } = this.resolveTarget();
2416
+ try {
2417
+ await fs5.access(gitDir);
2418
+ } catch {
2419
+ return;
2420
+ }
2421
+ const statePath = this.getStatePath(gitDir);
2422
+ const state = await this.readState(statePath);
2423
+ if (!this.isDue(state, now)) {
2424
+ return;
2425
+ }
2426
+ const aggressive = this.config.maintenance?.aggressive ?? false;
2427
+ const args = aggressive ? ["gc", "--prune=now"] : ["gc"];
2428
+ const nowIso = new Date(now).toISOString();
2429
+ state.lastAttemptAt = nowIso;
2430
+ this.logger.info(`\u{1F9F9} Running git ${args.join(" ")} (maintenance)...`);
2431
+ try {
2432
+ await this.gitFactory(cwd).raw(args);
2433
+ state.lastSuccessAt = nowIso;
2434
+ delete state.lastError;
2435
+ this.logger.info("\u{1F9F9} Maintenance complete.");
2436
+ } catch (error) {
2437
+ state.lastFailureAt = nowIso;
2438
+ state.lastError = getErrorMessage(error);
2439
+ this.logger.warn(`\u26A0\uFE0F Maintenance (git ${args.join(" ")}) failed: ${state.lastError}`);
2440
+ } finally {
2441
+ await this.writeState(statePath, state);
2442
+ }
2443
+ } catch (error) {
2444
+ this.logger.warn(`\u26A0\uFE0F Maintenance skipped due to an unexpected error: ${getErrorMessage(error)}`);
2445
+ }
2446
+ }
2447
+ };
2448
+
2449
+ // src/services/git.service.ts
2450
+ import * as fs9 from "fs/promises";
2451
+ import * as path11 from "path";
2452
+ import simpleGit6 from "simple-git";
2453
+
2454
+ // src/utils/quarantine.ts
2455
+ import * as fs6 from "fs/promises";
2456
+ import * as path7 from "path";
2457
+
2458
+ // src/utils/filename-timestamp.ts
2459
+ function filenameTimestamp(date = /* @__PURE__ */ new Date()) {
2460
+ return date.toISOString().replace(/[:.]/g, "-");
2461
+ }
2462
+
2463
+ // src/utils/quarantine.ts
2464
+ async function quarantineDirectory(dirPath) {
2465
+ const baseDir = path7.join(path7.dirname(dirPath), GIT_CONSTANTS.REMOVED_DIR_NAME);
2466
+ await fs6.mkdir(baseDir, { recursive: true });
2467
+ const timestamp = filenameTimestamp();
2468
+ const quarantinePath = path7.join(baseDir, `${timestamp}-${path7.basename(dirPath)}`);
2469
+ await fs6.rename(dirPath, quarantinePath);
2470
+ return quarantinePath;
2471
+ }
2472
+
2473
+ // src/utils/worktree-list-parser.ts
2474
+ function parseWorktreeListPorcelain(output) {
2475
+ const worktrees = [];
2476
+ let current = {};
2477
+ const flush = () => {
2478
+ if (!current.path) {
2479
+ current = {};
2480
+ return;
2481
+ }
2482
+ worktrees.push({
2483
+ path: current.path,
2484
+ branch: current.branch ?? null,
2485
+ head: current.head ?? null,
2486
+ detached: current.detached ?? false,
2487
+ prunable: current.prunable ?? false,
2488
+ locked: current.locked ?? false
2489
+ });
2490
+ current = {};
2491
+ };
2492
+ for (const line of output.split("\n")) {
2493
+ if (line.startsWith("worktree ")) {
2494
+ flush();
2495
+ current.path = line.substring("worktree ".length);
2496
+ } else if (line.startsWith("branch ")) {
2497
+ current.branch = line.substring("branch ".length).replace("refs/heads/", "");
2498
+ } else if (line.startsWith("HEAD ")) {
2499
+ current.head = line.substring("HEAD ".length);
2500
+ } else if (line === "detached") {
2501
+ current.detached = true;
2502
+ } else if (line === "prunable" || line.startsWith("prunable ")) {
2503
+ current.prunable = true;
2504
+ } else if (line === "locked" || line.startsWith("locked ")) {
2505
+ current.locked = true;
2506
+ } else if (line.trim() === "") {
2507
+ flush();
2508
+ }
2509
+ }
2510
+ flush();
2511
+ return worktrees;
2512
+ }
2513
+
2514
+ // src/services/sparse-checkout.service.ts
2515
+ import * as path8 from "path";
2516
+ import simpleGit3 from "simple-git";
2517
+ var SparseCheckoutService = class {
2518
+ logger;
2519
+ gitFactory;
2520
+ warnedConfigs = /* @__PURE__ */ new WeakSet();
2521
+ matcherCache = /* @__PURE__ */ new WeakMap();
2522
+ constructor(logger, gitFactory) {
2028
2523
  this.logger = logger ?? Logger.createDefault();
2029
- this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
2524
+ this.gitFactory = gitFactory ?? ((p) => simpleGit3(p));
2030
2525
  }
2031
2526
  updateLogger(logger) {
2032
2527
  this.logger = logger;
@@ -2152,7 +2647,7 @@ var SparseCheckoutService = class {
2152
2647
  for (const pat of matcher.patterns) {
2153
2648
  if (p === pat || p.startsWith(pat + "/")) return true;
2154
2649
  }
2155
- return matcher.ancestorDirs.has(path5.posix.dirname(p));
2650
+ return matcher.ancestorDirs.has(path8.posix.dirname(p));
2156
2651
  });
2157
2652
  }
2158
2653
  getMatcher(cfg) {
@@ -2179,9 +2674,9 @@ var SparseCheckoutService = class {
2179
2674
  };
2180
2675
 
2181
2676
  // src/services/worktree-metadata.service.ts
2182
- import * as fs4 from "fs/promises";
2183
- import * as path6 from "path";
2184
- import simpleGit3 from "simple-git";
2677
+ import * as fs7 from "fs/promises";
2678
+ import * as path9 from "path";
2679
+ import simpleGit4 from "simple-git";
2185
2680
  var WorktreeMetadataService = class {
2186
2681
  logger;
2187
2682
  constructor(logger) {
@@ -2193,7 +2688,7 @@ var WorktreeMetadataService = class {
2193
2688
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
2194
2689
  */
2195
2690
  getWorktreeDirectoryName(worktreePath) {
2196
- return path6.basename(worktreePath);
2691
+ return path9.basename(worktreePath);
2197
2692
  }
2198
2693
  async getMetadataPath(bareRepoPath, worktreeName) {
2199
2694
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -2201,7 +2696,7 @@ var WorktreeMetadataService = class {
2201
2696
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
2202
2697
  );
2203
2698
  }
2204
- return path6.join(
2699
+ return path9.join(
2205
2700
  bareRepoPath,
2206
2701
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
2207
2702
  worktreeName,
@@ -2214,31 +2709,13 @@ var WorktreeMetadataService = class {
2214
2709
  }
2215
2710
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
2216
2711
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2217
- await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
2218
- const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
2219
- let renamed = false;
2220
- try {
2221
- await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
2222
- try {
2223
- await fs4.rename(tmpPath, metadataPath);
2224
- renamed = true;
2225
- } catch (err) {
2226
- if (err.code === ERROR_MESSAGES.EXDEV) {
2227
- await fs4.copyFile(tmpPath, metadataPath);
2228
- } else {
2229
- throw err;
2230
- }
2231
- }
2232
- } finally {
2233
- if (!renamed) {
2234
- await fs4.unlink(tmpPath).catch(() => void 0);
2235
- }
2236
- }
2712
+ await fs7.mkdir(path9.dirname(metadataPath), { recursive: true });
2713
+ await atomicWriteFile(metadataPath, JSON.stringify(metadata, null, 2));
2237
2714
  }
2238
2715
  async loadMetadata(bareRepoPath, worktreeName) {
2239
2716
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2240
2717
  try {
2241
- const content = await fs4.readFile(metadataPath, "utf-8");
2718
+ const content = await fs7.readFile(metadataPath, "utf-8");
2242
2719
  return JSON.parse(content);
2243
2720
  } catch {
2244
2721
  return null;
@@ -2247,7 +2724,7 @@ var WorktreeMetadataService = class {
2247
2724
  async loadMetadataFromPath(bareRepoPath, worktreePath) {
2248
2725
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2249
2726
  try {
2250
- const content = await fs4.readFile(metadataPath, "utf-8");
2727
+ const content = await fs7.readFile(metadataPath, "utf-8");
2251
2728
  const metadata = JSON.parse(content);
2252
2729
  if (!await this.validateMetadata(metadata)) {
2253
2730
  this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
@@ -2261,7 +2738,7 @@ var WorktreeMetadataService = class {
2261
2738
  async deleteMetadata(bareRepoPath, worktreeName) {
2262
2739
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2263
2740
  try {
2264
- await fs4.unlink(metadataPath);
2741
+ await fs7.unlink(metadataPath);
2265
2742
  } catch (error) {
2266
2743
  if (error.code !== "ENOENT") {
2267
2744
  throw error;
@@ -2271,7 +2748,7 @@ var WorktreeMetadataService = class {
2271
2748
  async deleteMetadataFromPath(bareRepoPath, worktreePath) {
2272
2749
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2273
2750
  try {
2274
- await fs4.unlink(metadataPath);
2751
+ await fs7.unlink(metadataPath);
2275
2752
  } catch (error) {
2276
2753
  if (error.code !== "ENOENT") {
2277
2754
  throw error;
@@ -2305,7 +2782,7 @@ var WorktreeMetadataService = class {
2305
2782
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
2306
2783
  this.logger.info(` Attempting to create initial metadata...`);
2307
2784
  try {
2308
- const worktreeGit = simpleGit3(worktreePath);
2785
+ const worktreeGit = simpleGit4(worktreePath);
2309
2786
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
2310
2787
  const branchSummary = await worktreeGit.branch();
2311
2788
  const actualBranchName = branchSummary.current;
@@ -2352,6 +2829,25 @@ var WorktreeMetadataService = class {
2352
2829
  }
2353
2830
  await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
2354
2831
  }
2832
+ /**
2833
+ * Records the upstream tip observed during this sync. This is what later
2834
+ * proves "HEAD was fully pushed" after the remote branch is deleted, so it
2835
+ * must only ever be overwritten with a live observation — callers must not
2836
+ * invoke this once the upstream ref is gone.
2837
+ */
2838
+ async recordRemoteTip(bareRepoPath, worktreePath, ref, oid) {
2839
+ const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
2840
+ const existing = await this.loadMetadataFromPath(bareRepoPath, worktreePath);
2841
+ if (!existing) {
2842
+ this.logger.debug(`No metadata found for worktree ${worktreeDirName}; skipping remote tip recording`);
2843
+ return;
2844
+ }
2845
+ if (existing.lastKnownRemoteTip?.ref === ref && existing.lastKnownRemoteTip.oid === oid) {
2846
+ return;
2847
+ }
2848
+ existing.lastKnownRemoteTip = { ref, oid, recordedAt: (/* @__PURE__ */ new Date()).toISOString() };
2849
+ await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
2850
+ }
2355
2851
  async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
2356
2852
  const metadata = {
2357
2853
  lastSyncCommit: commit,
@@ -2406,9 +2902,9 @@ var WorktreeMetadataService = class {
2406
2902
  };
2407
2903
 
2408
2904
  // src/services/worktree-status.service.ts
2409
- import * as fs5 from "fs/promises";
2410
- import * as path7 from "path";
2411
- import simpleGit4 from "simple-git";
2905
+ import * as fs8 from "fs/promises";
2906
+ import * as path10 from "path";
2907
+ import simpleGit5 from "simple-git";
2412
2908
  var OPERATION_FILES = [
2413
2909
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
2414
2910
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -2439,8 +2935,9 @@ var WorktreeStatusService = class {
2439
2935
  }
2440
2936
  return true;
2441
2937
  }
2442
- async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
2443
- if (!await fileExists(worktreePath)) {
2938
+ async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit, lastKnownRemoteTip) {
2939
+ const pathProbe = await probePathExists(worktreePath);
2940
+ if (pathProbe === "missing") {
2444
2941
  return {
2445
2942
  isClean: true,
2446
2943
  hasUnpushedCommits: false,
@@ -2448,25 +2945,44 @@ var WorktreeStatusService = class {
2448
2945
  hasOperationInProgress: false,
2449
2946
  hasModifiedSubmodules: false,
2450
2947
  upstreamGone: false,
2948
+ fullyPushedUpstreamDeleted: false,
2451
2949
  canRemove: true,
2452
2950
  reasons: []
2453
2951
  };
2454
2952
  }
2455
- const snap = await this.collectSnapshot(worktreePath, lastSyncCommit);
2953
+ if (pathProbe === "unknown") {
2954
+ return {
2955
+ isClean: false,
2956
+ hasUnpushedCommits: true,
2957
+ hasStashedChanges: true,
2958
+ hasOperationInProgress: true,
2959
+ hasModifiedSubmodules: true,
2960
+ upstreamGone: false,
2961
+ fullyPushedUpstreamDeleted: false,
2962
+ canRemove: false,
2963
+ reasons: ["cannot verify worktree path (filesystem probe failed)"]
2964
+ };
2965
+ }
2966
+ const snap = await this.collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip);
2456
2967
  const isClean = this.deriveIsClean(snap);
2457
- const hasUnpushedCommits = !snap.detached && (snap.unpushedCount ?? 1) > 0;
2968
+ const anyRemoteUnpushed = (snap.unpushedAnyRemoteCount ?? 1) > 0;
2969
+ const sinceSyncUnpushed = snap.sinceSyncChecked && (snap.sinceSyncCount ?? 1) > 0;
2970
+ const hasUnpushedCommits = !snap.detached && (anyRemoteUnpushed || sinceSyncUnpushed);
2971
+ const recordedRefGone = lastKnownRemoteTip !== void 0 && snap.remoteBranches.length > 0 && !snap.remoteBranches.includes(lastKnownRemoteTip.ref);
2972
+ const fullyPushedUpstreamDeleted = hasUnpushedCommits && recordedRefGone && snap.headPushedToRecordedTip === true;
2458
2973
  const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
2459
- const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
2974
+ const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null || snap.operationProbeUnknown;
2460
2975
  const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
2461
2976
  const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
2462
2977
  const reasons = [];
2463
2978
  if (!isClean) reasons.push("uncommitted changes");
2464
- if (hasUnpushedCommits) reasons.push("unpushed commits");
2979
+ if (hasUnpushedCommits && !fullyPushedUpstreamDeleted) reasons.push("unpushed commits");
2465
2980
  if (hasStashedChanges) reasons.push("stashed changes");
2466
2981
  if (hasOperationInProgress) reasons.push("operation in progress");
2467
2982
  if (hasModifiedSubmodules) reasons.push("modified submodules");
2468
2983
  if (upstreamGone) reasons.push("upstream gone");
2469
- const canRemove = isClean && !hasUnpushedCommits && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules;
2984
+ if (snap.detached) reasons.push("detached HEAD");
2985
+ const canRemove = isClean && (!hasUnpushedCommits || fullyPushedUpstreamDeleted) && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules && !snap.detached;
2470
2986
  const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
2471
2987
  return {
2472
2988
  isClean,
@@ -2475,12 +2991,13 @@ var WorktreeStatusService = class {
2475
2991
  hasOperationInProgress,
2476
2992
  hasModifiedSubmodules,
2477
2993
  upstreamGone,
2994
+ fullyPushedUpstreamDeleted,
2478
2995
  canRemove,
2479
2996
  reasons,
2480
2997
  details
2481
2998
  };
2482
2999
  }
2483
- async collectSnapshot(worktreePath, lastSyncCommit) {
3000
+ async collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
2484
3001
  const git = this.createGitInstance(worktreePath);
2485
3002
  const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
2486
3003
  git.status().catch((e) => {
@@ -2505,19 +3022,33 @@ var WorktreeStatusService = class {
2505
3022
  const currentBranch = branchResult?.current ?? null;
2506
3023
  const detached = !branchResult?.current || Boolean(branchResult?.detached);
2507
3024
  let upstream = null;
2508
- let unpushedCount = null;
3025
+ let unpushedAnyRemoteCount = null;
3026
+ let sinceSyncCount = null;
3027
+ let headPushedToRecordedTip = null;
2509
3028
  if (!detached && currentBranch) {
2510
- const revListArgs = lastSyncCommit ? ["rev-list", "--count", `${lastSyncCommit}..HEAD`] : ["rev-list", "--count", currentBranch, "--not", "--remotes"];
2511
- const [upstreamResult, unpushedResult] = await Promise.all([
3029
+ const [upstreamResult, anyRemoteResult, sinceSyncResult, recordedTipResult] = await Promise.all([
2512
3030
  git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
2513
3031
  (raw) => ({ ok: true, value: raw }),
2514
3032
  (error) => ({ ok: false, error })
2515
3033
  ),
2516
- git.raw(revListArgs).then(
3034
+ git.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]).then(
2517
3035
  (raw) => ({ ok: true, value: raw }),
2518
3036
  (error) => ({ ok: false, error })
2519
- )
3037
+ ),
3038
+ lastSyncCommit ? git.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]).then(
3039
+ (raw) => ({ ok: true, value: raw }),
3040
+ (error) => ({ ok: false, error })
3041
+ ) : Promise.resolve(null),
3042
+ // Zero commits in <tip>..HEAD ⟺ HEAD is the recorded tip or behind it.
3043
+ // NOT merge-base --is-ancestor: simple-git resolves its silent exit-1
3044
+ // ("not an ancestor") as success because nothing is written to stderr.
3045
+ // Any failure (e.g. the recorded oid was gc'd) reads as "not proven".
3046
+ lastKnownRemoteTip ? git.raw(["rev-list", "--count", `${lastKnownRemoteTip.oid}..HEAD`]).then(
3047
+ (raw) => this.parseCount(raw) === 0,
3048
+ () => false
3049
+ ) : Promise.resolve(null)
2520
3050
  ]);
3051
+ headPushedToRecordedTip = recordedTipResult;
2521
3052
  if (upstreamResult.ok) {
2522
3053
  upstream = upstreamResult.value.trim() || null;
2523
3054
  } else {
@@ -2526,13 +3057,20 @@ var WorktreeStatusService = class {
2526
3057
  this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
2527
3058
  }
2528
3059
  }
2529
- if (unpushedResult.ok) {
2530
- unpushedCount = parseInt(unpushedResult.value.trim(), 10);
3060
+ if (anyRemoteResult.ok) {
3061
+ unpushedAnyRemoteCount = this.parseCount(anyRemoteResult.value);
2531
3062
  } else {
2532
- this.logger.error(`Error checking unpushed commits`, unpushedResult.error);
3063
+ this.logger.error(`Error checking unpushed commits`, anyRemoteResult.error);
3064
+ }
3065
+ if (sinceSyncResult) {
3066
+ if (sinceSyncResult.ok) {
3067
+ sinceSyncCount = this.parseCount(sinceSyncResult.value);
3068
+ } else {
3069
+ this.logger.error(`Error checking commits since last sync`, sinceSyncResult.error);
3070
+ }
2533
3071
  }
2534
3072
  }
2535
- const operationFile = gitDirResult ? await this.detectOperationFile(gitDirResult) : null;
3073
+ const operationProbe = gitDirResult ? await this.detectOperationFile(gitDirResult) : { file: null, unknown: false };
2536
3074
  let untrackedNotIgnored = [];
2537
3075
  if (status && status.not_added.length > 0) {
2538
3076
  try {
@@ -2548,14 +3086,22 @@ var WorktreeStatusService = class {
2548
3086
  detached,
2549
3087
  remoteBranches: remoteBranchesResult?.all ?? [],
2550
3088
  upstream,
2551
- unpushedCount,
3089
+ unpushedAnyRemoteCount,
3090
+ sinceSyncCount,
3091
+ sinceSyncChecked: lastSyncCommit !== void 0,
3092
+ headPushedToRecordedTip,
2552
3093
  stashTotal: stashResult?.total ?? null,
2553
3094
  submoduleStatus: submoduleResult,
2554
- operationFile,
3095
+ operationFile: operationProbe.file,
3096
+ operationProbeUnknown: operationProbe.unknown,
2555
3097
  gitDir: gitDirResult,
2556
3098
  untrackedNotIgnored
2557
3099
  };
2558
3100
  }
3101
+ parseCount(raw) {
3102
+ const count = parseInt(raw.trim(), 10);
3103
+ return Number.isNaN(count) ? null : count;
3104
+ }
2559
3105
  deriveIsClean(snap) {
2560
3106
  const status = snap.status;
2561
3107
  if (!status) return false;
@@ -2595,7 +3141,8 @@ var WorktreeStatusService = class {
2595
3141
  if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
2596
3142
  }
2597
3143
  if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
2598
- if (!snap.detached && snap.unpushedCount !== null) details.unpushedCommitCount = snap.unpushedCount;
3144
+ const unpushedCount = snap.unpushedAnyRemoteCount ?? snap.sinceSyncCount;
3145
+ if (!snap.detached && unpushedCount !== null) details.unpushedCommitCount = unpushedCount;
2599
3146
  if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
2600
3147
  const opType = this.operationTypeFromFile(snap.operationFile);
2601
3148
  if (opType) details.operationType = opType;
@@ -2610,34 +3157,37 @@ var WorktreeStatusService = class {
2610
3157
  async detectOperationFile(gitDir) {
2611
3158
  const results = await Promise.all(
2612
3159
  OPERATION_FILES.map(
2613
- ({ file }) => fs5.access(path7.join(gitDir, file)).then(
2614
- () => true,
2615
- () => false
3160
+ ({ file }) => fs8.access(path10.join(gitDir, file)).then(
3161
+ () => "present",
3162
+ (error) => error.code === "ENOENT" ? "absent" : "unknown"
2616
3163
  )
2617
3164
  )
2618
3165
  );
2619
- const idx = results.findIndex(Boolean);
2620
- return idx >= 0 ? OPERATION_FILES[idx].file : null;
3166
+ const idx = results.findIndex((result) => result === "present");
3167
+ if (idx >= 0) return { file: OPERATION_FILES[idx].file, unknown: false };
3168
+ return { file: null, unknown: results.includes("unknown") };
2621
3169
  }
2622
3170
  async hasUnpushedCommits(worktreePath, lastSyncCommit) {
2623
3171
  const worktreeGit = this.createGitInstance(worktreePath);
2624
3172
  try {
2625
3173
  if (await this.isDetachedHead(worktreeGit)) {
2626
- return false;
3174
+ return true;
2627
3175
  }
2628
3176
  const branchSummary = await worktreeGit.branch();
2629
3177
  const currentBranch = branchSummary.current;
3178
+ const anyRemoteResult = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
3179
+ const anyRemoteCount = this.parseCount(anyRemoteResult);
3180
+ if (anyRemoteCount === null || anyRemoteCount > 0) {
3181
+ return true;
3182
+ }
2630
3183
  if (lastSyncCommit) {
2631
- try {
2632
- const newCommitsResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
2633
- const newCommitsCount = parseInt(newCommitsResult.trim(), 10);
2634
- return newCommitsCount > 0;
2635
- } catch {
3184
+ const sinceSyncResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
3185
+ const sinceSyncCount = this.parseCount(sinceSyncResult);
3186
+ if (sinceSyncCount === null || sinceSyncCount > 0) {
3187
+ return true;
2636
3188
  }
2637
3189
  }
2638
- const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
2639
- const unpushedCount = parseInt(result.trim(), 10);
2640
- return unpushedCount > 0;
3190
+ return false;
2641
3191
  } catch (error) {
2642
3192
  this.logger.error(`Error checking unpushed commits`, error);
2643
3193
  return true;
@@ -2693,14 +3243,15 @@ var WorktreeStatusService = class {
2693
3243
  async hasOperationInProgress(worktreePath) {
2694
3244
  try {
2695
3245
  const gitDir = await this.resolveGitDir(worktreePath);
2696
- return await this.detectOperationFile(gitDir) !== null;
3246
+ const probe = await this.detectOperationFile(gitDir);
3247
+ return probe.unknown || probe.file !== null;
2697
3248
  } catch (error) {
2698
3249
  this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
2699
3250
  return true;
2700
3251
  }
2701
3252
  }
2702
- async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
2703
- const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
3253
+ async validateWorktreeForRemoval(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
3254
+ const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit, lastKnownRemoteTip);
2704
3255
  if (!status.canRemove) {
2705
3256
  throw new WorktreeNotCleanError(worktreePath, status.reasons);
2706
3257
  }
@@ -2731,14 +3282,14 @@ var WorktreeStatusService = class {
2731
3282
  }
2732
3283
  }
2733
3284
  async resolveGitDir(worktreePath) {
2734
- const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
3285
+ const gitPath = path10.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
2735
3286
  try {
2736
- const stat4 = await fs5.stat(gitPath);
3287
+ const stat4 = await fs8.stat(gitPath);
2737
3288
  if (stat4.isFile()) {
2738
- const content = await fs5.readFile(gitPath, "utf-8");
3289
+ const content = await fs8.readFile(gitPath, "utf-8");
2739
3290
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
2740
3291
  if (gitdirMatch) {
2741
- return path7.resolve(worktreePath, gitdirMatch[1].trim());
3292
+ return path10.resolve(worktreePath, gitdirMatch[1].trim());
2742
3293
  }
2743
3294
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
2744
3295
  }
@@ -2752,10 +3303,10 @@ var WorktreeStatusService = class {
2752
3303
  }
2753
3304
  }
2754
3305
  createGitInstance(worktreePath) {
2755
- const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
3306
+ const key = `${path10.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
2756
3307
  let git = this.gitInstances.get(key);
2757
3308
  if (!git) {
2758
- git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
3309
+ git = this.config.skipLfs ? simpleGit5(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit5(worktreePath);
2759
3310
  this.gitInstances.set(key, git);
2760
3311
  }
2761
3312
  return git;
@@ -2776,7 +3327,7 @@ var GitService = class {
2776
3327
  this.progressEmitter = progressEmitter;
2777
3328
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
2778
3329
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
2779
- this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3330
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
2780
3331
  this.metadataService = new WorktreeMetadataService(this.logger);
2781
3332
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
2782
3333
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -2806,10 +3357,10 @@ var GitService = class {
2806
3357
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
2807
3358
  }
2808
3359
  getCachedGit(dirPath, useLfsSkip = false) {
2809
- const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3360
+ const key = `${path11.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
2810
3361
  let git = this.gitInstances.get(key);
2811
3362
  if (!git) {
2812
- const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
3363
+ const base = simpleGit6(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2813
3364
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
2814
3365
  this.gitInstances.set(key, git);
2815
3366
  }
@@ -2829,11 +3380,11 @@ var GitService = class {
2829
3380
  async initialize() {
2830
3381
  const { repoUrl } = this.config;
2831
3382
  try {
2832
- await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
3383
+ await fs9.access(path11.join(this.bareRepoPath, "HEAD"));
2833
3384
  } catch {
2834
3385
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
2835
- await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
2836
- const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
3386
+ await fs9.mkdir(path11.dirname(this.bareRepoPath), { recursive: true });
3387
+ const cloneBase = simpleGit6(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
2837
3388
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
2838
3389
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
2839
3390
  this.logger.info("\u2705 Clone successful.");
@@ -2851,17 +3402,17 @@ var GitService = class {
2851
3402
  this.logger.info("Fetching remote branches...");
2852
3403
  await bareGit.fetch(["--all", "--progress"]);
2853
3404
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
2854
- this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
3405
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, this.defaultBranch);
2855
3406
  let needsMainWorktree = true;
2856
3407
  try {
2857
3408
  const worktrees = await this.getWorktreesFromBare(bareGit);
2858
- needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
3409
+ needsMainWorktree = !worktrees.some((w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath));
2859
3410
  } catch {
2860
3411
  }
2861
3412
  if (needsMainWorktree) {
2862
3413
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
2863
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
2864
- const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
3414
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
3415
+ const absoluteWorktreePath = path11.resolve(this.mainWorktreePath);
2865
3416
  const branches = await bareGit.branch();
2866
3417
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
2867
3418
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -2897,7 +3448,7 @@ var GitService = class {
2897
3448
  }
2898
3449
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
2899
3450
  const mainWorktreeRegistered = updatedWorktrees.some(
2900
- (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
3451
+ (w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath)
2901
3452
  );
2902
3453
  if (!mainWorktreeRegistered) {
2903
3454
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -2924,7 +3475,7 @@ var GitService = class {
2924
3475
  return this.bareRepoPath;
2925
3476
  }
2926
3477
  async getRemoteDefaultBranch(repoUrl) {
2927
- const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
3478
+ const git = simpleGit6(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2928
3479
  try {
2929
3480
  const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
2930
3481
  const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
@@ -3008,7 +3559,7 @@ var GitService = class {
3008
3559
  return branches;
3009
3560
  }
3010
3561
  async verifyLfsFilesDownloaded(worktreePath, branchName) {
3011
- const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
3562
+ const worktreeGit = this.config.sparseCheckout ? simpleGit6(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
3012
3563
  try {
3013
3564
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
3014
3565
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -3019,7 +3570,7 @@ var GitService = class {
3019
3570
  const existence = await Promise.all(
3020
3571
  lfsFileList.map(async (f) => {
3021
3572
  try {
3022
- await fs6.access(path8.join(worktreePath, f));
3573
+ await fs9.access(path11.join(worktreePath, f));
3023
3574
  return f;
3024
3575
  } catch {
3025
3576
  return null;
@@ -3047,9 +3598,9 @@ var GitService = class {
3047
3598
  let allDownloaded = true;
3048
3599
  const notDownloaded = [];
3049
3600
  for (const file of samplesToCheck) {
3050
- const filePath = path8.join(worktreePath, file);
3601
+ const filePath = path11.join(worktreePath, file);
3051
3602
  try {
3052
- const handle = await fs6.open(filePath, "r");
3603
+ const handle = await fs9.open(filePath, "r");
3053
3604
  try {
3054
3605
  const buffer = Buffer.alloc(200);
3055
3606
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -3074,7 +3625,7 @@ var GitService = class {
3074
3625
  }
3075
3626
  retries++;
3076
3627
  if (retries < maxRetries) {
3077
- await new Promise((resolve11) => setTimeout(resolve11, retryDelay));
3628
+ await new Promise((resolve13) => setTimeout(resolve13, retryDelay));
3078
3629
  }
3079
3630
  }
3080
3631
  this.logger.warn(
@@ -3136,20 +3687,23 @@ var GitService = class {
3136
3687
  }
3137
3688
  async addWorktree(branchName, worktreePath) {
3138
3689
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
3139
- const absoluteWorktreePath = path8.resolve(worktreePath);
3140
- await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
3690
+ const absoluteWorktreePath = path11.resolve(worktreePath);
3691
+ await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
3141
3692
  try {
3142
- await fs6.access(absoluteWorktreePath);
3693
+ await fs9.access(absoluteWorktreePath);
3143
3694
  const worktrees = await this.getWorktreesFromBare(bareGit);
3144
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3695
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
3145
3696
  if (isValidWorktree) {
3146
3697
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3147
3698
  return;
3148
3699
  } else {
3149
3700
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
3150
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3701
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
3702
+ }
3703
+ } catch (error) {
3704
+ if (error instanceof GitOperationError || error instanceof WorktreeError) {
3705
+ throw error;
3151
3706
  }
3152
- } catch {
3153
3707
  }
3154
3708
  let createdNewBranch = false;
3155
3709
  try {
@@ -3186,17 +3740,14 @@ var GitService = class {
3186
3740
  }
3187
3741
  if (errorMessage.includes("already registered worktree")) {
3188
3742
  const worktrees = await this.getWorktreesFromBare(bareGit);
3189
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3743
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
3190
3744
  if (existingWorktree && !existingWorktree.isPrunable) {
3191
3745
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
3192
3746
  return;
3193
3747
  }
3194
3748
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
3195
3749
  await bareGit.raw(["worktree", "prune"]);
3196
- try {
3197
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3198
- } catch {
3199
- }
3750
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
3200
3751
  let retryCreatedNewBranch = false;
3201
3752
  try {
3202
3753
  const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
@@ -3230,17 +3781,20 @@ var GitService = class {
3230
3781
  }
3231
3782
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
3232
3783
  try {
3233
- await fs6.access(absoluteWorktreePath);
3784
+ await fs9.access(absoluteWorktreePath);
3234
3785
  const worktrees = await this.getWorktreesFromBare(bareGit);
3235
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3786
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
3236
3787
  if (isValidWorktree) {
3237
3788
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3238
3789
  return;
3239
3790
  } else {
3240
3791
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
3241
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3792
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
3793
+ }
3794
+ } catch (error2) {
3795
+ if (error2 instanceof GitOperationError || error2 instanceof WorktreeError) {
3796
+ throw error2;
3242
3797
  }
3243
- } catch {
3244
3798
  }
3245
3799
  try {
3246
3800
  const useNoCheckout = !!this.config.sparseCheckout;
@@ -3262,7 +3816,7 @@ var GitService = class {
3262
3816
  const fallbackErrorMessage = getErrorMessage(fallbackError);
3263
3817
  if (fallbackErrorMessage.includes("already registered worktree")) {
3264
3818
  const worktrees = await this.getWorktreesFromBare(bareGit);
3265
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3819
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
3266
3820
  if (existingWorktree && !existingWorktree.isPrunable) {
3267
3821
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
3268
3822
  return;
@@ -3331,9 +3885,19 @@ var GitService = class {
3331
3885
  wrapped.isUpstreamSetupFailure = true;
3332
3886
  return wrapped;
3333
3887
  }
3334
- async removeWorktree(worktreePath) {
3888
+ async removeWorktree(worktreePath, options) {
3335
3889
  const bareGit = this.getCachedGit(this.bareRepoPath);
3336
- await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
3890
+ const args = ["worktree", "remove", worktreePath];
3891
+ if (options?.force) args.push("--force");
3892
+ try {
3893
+ await bareGit.raw(args);
3894
+ } catch (error) {
3895
+ const message = getErrorMessage(error);
3896
+ if (!options?.force && /contains modified or untracked files|use --force/i.test(message)) {
3897
+ throw new WorktreeNotCleanError(worktreePath, [`git refused removal: ${message}`]);
3898
+ }
3899
+ throw error;
3900
+ }
3337
3901
  this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
3338
3902
  try {
3339
3903
  await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
@@ -3346,6 +3910,111 @@ var GitService = class {
3346
3910
  await bareGit.raw(["worktree", "prune"]);
3347
3911
  this.logger.info("Pruned worktree metadata.");
3348
3912
  }
3913
+ async updateRef(refName, sha) {
3914
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3915
+ await bareGit.raw(["update-ref", refName, sha]);
3916
+ }
3917
+ async deleteRef(refName) {
3918
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3919
+ await bareGit.raw(["update-ref", "-d", refName]);
3920
+ }
3921
+ async listRefs(prefix) {
3922
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3923
+ const raw = await bareGit.raw(["for-each-ref", "--format=%(refname)", prefix]);
3924
+ return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
3925
+ }
3926
+ async localBranchExists(branchName) {
3927
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3928
+ try {
3929
+ await bareGit.raw(["show-ref", "--verify", "--quiet", `${GIT_CONSTANTS.REFS.HEADS}${branchName}`]);
3930
+ return true;
3931
+ } catch {
3932
+ return false;
3933
+ }
3934
+ }
3935
+ async getLocalBranchCommit(branchName) {
3936
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3937
+ try {
3938
+ return (await bareGit.raw(["rev-parse", `${GIT_CONSTANTS.REFS.HEADS}${branchName}^{commit}`])).trim();
3939
+ } catch {
3940
+ return null;
3941
+ }
3942
+ }
3943
+ async createBranchAt(branchName, sha) {
3944
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3945
+ await bareGit.raw(["branch", branchName, sha]);
3946
+ }
3947
+ async deleteLocalBranch(branchName) {
3948
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3949
+ await bareGit.raw(["branch", "-D", branchName]);
3950
+ }
3951
+ // Bundles only commits not reachable from any remote — for fully-pushed
3952
+ // refs that set is empty and `bundle create` would fail. Emptiness is
3953
+ // pre-checked with rev-list (locale-independent) instead of parsing git's
3954
+ // localized "empty bundle" stderr; after the pre-check, any bundle-create
3955
+ // error is a real failure the caller must treat as fail-closed.
3956
+ async createBundleFromRef(bundlePath, refName) {
3957
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3958
+ const count = (await bareGit.raw(["rev-list", "--count", refName, "--not", "--remotes"])).trim();
3959
+ if (count === "0") {
3960
+ return false;
3961
+ }
3962
+ await bareGit.raw(["bundle", "create", bundlePath, refName, "--not", "--remotes"]);
3963
+ return true;
3964
+ }
3965
+ // Registers the worktree and writes its .git link without populating files —
3966
+ // restore overlays the preserved payload instead of a fresh checkout.
3967
+ async addWorktreeNoCheckout(branchName, worktreePath) {
3968
+ const bareGit = this.getCachedGit(this.bareRepoPath);
3969
+ const absoluteWorktreePath = path11.resolve(worktreePath);
3970
+ await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
3971
+ await bareGit.raw(["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName]);
3972
+ }
3973
+ // Mixed reset: points the index at HEAD without touching working files, so
3974
+ // overlaid payload content shows up as ordinary uncommitted changes.
3975
+ async resetWorktreeIndex(worktreePath) {
3976
+ const worktreeGit = this.getCachedGit(worktreePath);
3977
+ await worktreeGit.raw(["reset"]);
3978
+ }
3979
+ // Injected by WorktreeSyncService when trash is enabled, so stale-directory
3980
+ // cleanup follows the same reversible-removal pipeline as everything else.
3981
+ // GitService cannot own a TrashService directly (TrashService depends on it).
3982
+ staleDirectoryTrasher = null;
3983
+ setStaleDirectoryTrasher(trasher) {
3984
+ this.staleDirectoryTrasher = trasher;
3985
+ }
3986
+ // A stale directory that contains a .git may be a live checkout that git
3987
+ // failed to report; quarantine it instead of deleting.
3988
+ async clearStaleWorktreeDirectory(absoluteWorktreePath) {
3989
+ const gitProbe = await probePathExists(path11.join(absoluteWorktreePath, PATH_CONSTANTS.GIT_DIR));
3990
+ if (gitProbe === "unknown") {
3991
+ throw new GitOperationError(
3992
+ "clear-stale-directory",
3993
+ `Cannot verify whether '${absoluteWorktreePath}' is a live checkout; refusing to clear it`
3994
+ );
3995
+ }
3996
+ if (this.staleDirectoryTrasher) {
3997
+ try {
3998
+ const trashPath = await this.staleDirectoryTrasher(absoluteWorktreePath);
3999
+ this.logger.info(` - Moved stale directory at '${absoluteWorktreePath}' to trash ('${trashPath}')`);
4000
+ return;
4001
+ } catch (error) {
4002
+ throw new GitOperationError(
4003
+ "clear-stale-directory",
4004
+ `Cannot move stale directory '${absoluteWorktreePath}' to trash: ${getErrorMessage(error)}`,
4005
+ error instanceof Error ? error : void 0
4006
+ );
4007
+ }
4008
+ }
4009
+ if (gitProbe === "exists") {
4010
+ const quarantinePath = await quarantineDirectory(absoluteWorktreePath);
4011
+ this.logger.warn(
4012
+ ` - \u26A0\uFE0F Directory at '${absoluteWorktreePath}' contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
4013
+ );
4014
+ return;
4015
+ }
4016
+ await fs9.rm(absoluteWorktreePath, { recursive: true, force: true });
4017
+ }
3349
4018
  async checkWorktreeStatus(worktreePath) {
3350
4019
  return this.statusService.checkWorktreeStatus(worktreePath);
3351
4020
  }
@@ -3361,7 +4030,37 @@ var GitService = class {
3361
4030
  }
3362
4031
  async getFullWorktreeStatus(worktreePath, includeDetails = false) {
3363
4032
  const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
3364
- return this.statusService.getFullWorktreeStatus(worktreePath, includeDetails, metadata?.lastSyncCommit);
4033
+ return this.statusService.getFullWorktreeStatus(
4034
+ worktreePath,
4035
+ includeDetails,
4036
+ metadata?.lastSyncCommit,
4037
+ metadata?.lastKnownRemoteTip
4038
+ );
4039
+ }
4040
+ /** Map of remote branch name (without "origin/") → tip oid, from the bare repo. */
4041
+ async getRemoteBranchTips() {
4042
+ const git = this.getGit();
4043
+ const raw = await git.raw(["for-each-ref", "--format=%(refname:short) %(objectname)", GIT_CONSTANTS.REFS.REMOTES]);
4044
+ const tips = /* @__PURE__ */ new Map();
4045
+ for (const line of raw.split("\n")) {
4046
+ const trimmed = line.trim();
4047
+ if (!trimmed) continue;
4048
+ const spaceIdx = trimmed.lastIndexOf(" ");
4049
+ if (spaceIdx <= 0) continue;
4050
+ const ref = trimmed.slice(0, spaceIdx);
4051
+ const oid = trimmed.slice(spaceIdx + 1);
4052
+ if (!ref.startsWith(GIT_CONSTANTS.REMOTE_PREFIX) || ref === `${GIT_CONSTANTS.REMOTE_PREFIX}HEAD`) continue;
4053
+ tips.set(ref.slice(GIT_CONSTANTS.REMOTE_PREFIX.length), oid);
4054
+ }
4055
+ return tips;
4056
+ }
4057
+ async recordRemoteTip(worktreePath, branchName, oid) {
4058
+ await this.metadataService.recordRemoteTip(
4059
+ this.bareRepoPath,
4060
+ worktreePath,
4061
+ `${GIT_CONSTANTS.REMOTE_PREFIX}${branchName}`,
4062
+ oid
4063
+ );
3365
4064
  }
3366
4065
  async hasModifiedSubmodules(worktreePath) {
3367
4066
  return this.statusService.hasModifiedSubmodules(worktreePath);
@@ -3646,31 +4345,32 @@ var ProgressEmitter = class {
3646
4345
  }
3647
4346
  };
3648
4347
 
3649
- // src/services/repo-operation-lock.ts
3650
- import * as fs7 from "fs/promises";
3651
- import * as path10 from "path";
3652
- import * as lockfile from "proper-lockfile";
3653
-
3654
- // src/utils/lock-path.ts
3655
- import { createHash } from "crypto";
3656
- import * as os from "os";
3657
- import * as path9 from "path";
3658
- function getCloneModeLockTarget(config) {
3659
- const name = config.name;
3660
- const configDir = config.__configFileDir;
3661
- const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
3662
- if (configDir) {
3663
- return {
3664
- dir: path9.join(configDir, ".sync-worktrees-state"),
3665
- file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
3666
- };
4348
+ // src/services/removal-audit.service.ts
4349
+ import * as fs10 from "fs/promises";
4350
+ import * as path12 from "path";
4351
+ var RemovalAuditService = class {
4352
+ constructor(logFilePath) {
4353
+ this.logFilePath = logFilePath;
4354
+ }
4355
+ logFilePath;
4356
+ async record(entry) {
4357
+ await fs10.mkdir(path12.dirname(this.logFilePath), { recursive: true });
4358
+ const line = JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
4359
+ const handle = await fs10.open(this.logFilePath, "a");
4360
+ try {
4361
+ await handle.appendFile(`${line}
4362
+ `, "utf-8");
4363
+ await handle.sync();
4364
+ } finally {
4365
+ await handle.close();
4366
+ }
3667
4367
  }
3668
- const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
3669
- const dir = path9.join(stateBase, "sync-worktrees", "locks");
3670
- return { dir, file: `${hash}.lock` };
3671
- }
4368
+ };
3672
4369
 
3673
4370
  // src/services/repo-operation-lock.ts
4371
+ import * as fs11 from "fs/promises";
4372
+ import * as path13 from "path";
4373
+ import * as lockfile from "proper-lockfile";
3674
4374
  var RepoOperationLock = class {
3675
4375
  constructor(config, gitService, logger = Logger.createDefault()) {
3676
4376
  this.config = config;
@@ -3695,10 +4395,10 @@ var RepoOperationLock = class {
3695
4395
  }
3696
4396
  async acquireCloneModeLock() {
3697
4397
  const target = getCloneModeLockTarget(this.config);
3698
- const lockTarget = path10.join(target.dir, target.file);
4398
+ const lockTarget = path13.join(target.dir, target.file);
3699
4399
  try {
3700
- await fs7.mkdir(target.dir, { recursive: true });
3701
- await fs7.writeFile(lockTarget, "", { flag: "a" });
4400
+ await fs11.mkdir(target.dir, { recursive: true });
4401
+ await fs11.writeFile(lockTarget, "", { flag: "a" });
3702
4402
  } catch {
3703
4403
  return null;
3704
4404
  }
@@ -3707,7 +4407,7 @@ var RepoOperationLock = class {
3707
4407
  async acquireWorktreeModeLock() {
3708
4408
  const barePath = this.gitService.getBareRepoPath();
3709
4409
  try {
3710
- await fs7.mkdir(barePath, { recursive: true });
4410
+ await fs11.mkdir(barePath, { recursive: true });
3711
4411
  } catch {
3712
4412
  return null;
3713
4413
  }
@@ -3768,80 +4468,735 @@ var SyncRetryPolicy = class {
3768
4468
  this.logger.info(`\u{1F504} Retrying synchronization...
3769
4469
  `);
3770
4470
  }
3771
- },
3772
- lfsRetryHandler: () => {
3773
- if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
3774
- this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
3775
- this.gitService.setLfsSkipEnabled(true);
3776
- syncContext.lfsSkipEnabled = true;
4471
+ },
4472
+ lfsRetryHandler: () => {
4473
+ if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
4474
+ this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
4475
+ this.gitService.setLfsSkipEnabled(true);
4476
+ syncContext.lfsSkipEnabled = true;
4477
+ }
4478
+ }
4479
+ };
4480
+ }
4481
+ resetLfsSkipIfNeeded(syncContext) {
4482
+ if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
4483
+ this.gitService.setLfsSkipEnabled(false);
4484
+ }
4485
+ }
4486
+ };
4487
+
4488
+ // src/services/trash-migration.service.ts
4489
+ import * as fs12 from "fs/promises";
4490
+ import * as path14 from "path";
4491
+ var REMOVED_ENTRY_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-(.+)$/;
4492
+ var TrashMigrationService = class {
4493
+ constructor(config, trashService, logger) {
4494
+ this.config = config;
4495
+ this.trashService = trashService;
4496
+ this.logger = logger;
4497
+ }
4498
+ config;
4499
+ trashService;
4500
+ logger;
4501
+ updateLogger(logger) {
4502
+ this.logger = logger;
4503
+ }
4504
+ isEnabled() {
4505
+ return this.trashService.isEnabled() && (this.config.trash?.migrateLegacy ?? DEFAULT_CONFIG.TRASH.MIGRATE_LEGACY);
4506
+ }
4507
+ async migrateLegacyUnlocked() {
4508
+ if (!this.isEnabled()) return;
4509
+ await this.migrateRemovedDir();
4510
+ await this.migrateDivergedDir();
4511
+ }
4512
+ async migrateRemovedDir() {
4513
+ const removedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.REMOVED_DIR_NAME);
4514
+ const names = await this.listDirectories(removedDir);
4515
+ for (const name of names) {
4516
+ const match = REMOVED_ENTRY_RE.exec(name);
4517
+ const quarantinedAt = match ? this.parseQuarantineTimestamp(match[1]) : null;
4518
+ if (!match || !quarantinedAt) {
4519
+ this.logger.warn(`\u26A0\uFE0F Leaving unrecognized entry '${name}' in ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ alone`);
4520
+ continue;
4521
+ }
4522
+ try {
4523
+ const entry = await this.trashService.trashDirectory({
4524
+ dirPath: path14.join(removedDir, name),
4525
+ reason: "legacy-adopt",
4526
+ source: ".removed",
4527
+ legacyOriginalName: name,
4528
+ legacyQuarantinedAt: quarantinedAt,
4529
+ headOid: null,
4530
+ originalPath: path14.join(this.config.worktreeDir, match[2]),
4531
+ auditAction: "trash_adopt"
4532
+ });
4533
+ this.logger.info(
4534
+ `\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
4535
+ );
4536
+ } catch (error) {
4537
+ this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
4538
+ }
4539
+ }
4540
+ await fs12.rmdir(removedDir).catch(() => void 0);
4541
+ }
4542
+ async migrateDivergedDir() {
4543
+ const divergedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4544
+ const names = await this.listDirectories(divergedDir);
4545
+ for (const name of names) {
4546
+ const dirPath = path14.join(divergedDir, name);
4547
+ const info = await this.readDivergedInfo(dirPath);
4548
+ const quarantinedAt = info?.divergedAt ? new Date(info.divergedAt) : null;
4549
+ const hasOriginalPath = typeof info?.originalPath === "string" && info.originalPath.length > 0;
4550
+ if (!info || !info.originalBranch || !hasOriginalPath || !quarantinedAt || Number.isNaN(quarantinedAt.getTime())) {
4551
+ this.logger.warn(
4552
+ `\u26A0\uFE0F Leaving entry '${name}' in ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ alone (no parseable ${METADATA_CONSTANTS.DIVERGED_INFO_FILE})`
4553
+ );
4554
+ continue;
4555
+ }
4556
+ try {
4557
+ const entry = await this.trashService.trashDirectory({
4558
+ dirPath,
4559
+ reason: "legacy-adopt",
4560
+ source: ".diverged",
4561
+ branch: info.originalBranch,
4562
+ legacyOriginalName: name,
4563
+ legacyQuarantinedAt: quarantinedAt,
4564
+ headOid: info.localCommit ?? null,
4565
+ originalPath: info.originalPath,
4566
+ auditAction: "trash_adopt",
4567
+ keepPinOnReap: true
4568
+ });
4569
+ this.logger.info(
4570
+ `\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
4571
+ );
4572
+ } catch (error) {
4573
+ this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
4574
+ }
4575
+ }
4576
+ await fs12.rmdir(divergedDir).catch(() => void 0);
4577
+ }
4578
+ async listDirectories(dirPath) {
4579
+ try {
4580
+ const dirents = await fs12.readdir(dirPath, { withFileTypes: true });
4581
+ return dirents.filter((dirent) => dirent.isDirectory() && !dirent.isSymbolicLink()).map((dirent) => dirent.name);
4582
+ } catch (error) {
4583
+ if (error.code !== "ENOENT") {
4584
+ this.logger.warn(`\u26A0\uFE0F Cannot scan '${dirPath}' for legacy trash adoption: ${getErrorMessage(error)}`);
4585
+ }
4586
+ return [];
4587
+ }
4588
+ }
4589
+ async readDivergedInfo(dirPath) {
4590
+ try {
4591
+ const raw = await fs12.readFile(path14.join(dirPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE), "utf-8");
4592
+ return JSON.parse(raw);
4593
+ } catch {
4594
+ return null;
4595
+ }
4596
+ }
4597
+ // quarantine timestamps replaced [:.] with "-": 2026-06-06T18-34-18-123Z
4598
+ parseQuarantineTimestamp(raw) {
4599
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(raw);
4600
+ if (!match) return null;
4601
+ const date = /* @__PURE__ */ new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`);
4602
+ return Number.isNaN(date.getTime()) ? null : date;
4603
+ }
4604
+ };
4605
+
4606
+ // src/services/trash-reaper.service.ts
4607
+ import * as fs14 from "fs/promises";
4608
+ import * as path16 from "path";
4609
+
4610
+ // src/utils/disk-space.ts
4611
+ import fastFolderSize from "fast-folder-size";
4612
+ async function calculateDirectorySize(dirPath) {
4613
+ return new Promise((resolve13, reject) => {
4614
+ fastFolderSize(dirPath, (err, bytes) => {
4615
+ if (err) {
4616
+ reject(err);
4617
+ return;
4618
+ }
4619
+ if (bytes === void 0) {
4620
+ reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
4621
+ return;
4622
+ }
4623
+ resolve13(bytes);
4624
+ });
4625
+ });
4626
+ }
4627
+ function formatBytes(bytes) {
4628
+ if (bytes === 0) return "0 B";
4629
+ const units = ["B", "KB", "MB", "GB", "TB"];
4630
+ const k = 1024;
4631
+ const decimals = 2;
4632
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
4633
+ const value = bytes / Math.pow(k, i);
4634
+ return `${value.toFixed(decimals)} ${units[i]}`;
4635
+ }
4636
+
4637
+ // src/services/trash.service.ts
4638
+ import { randomBytes } from "crypto";
4639
+ import * as fs13 from "fs/promises";
4640
+ import * as path15 from "path";
4641
+ function isWorktreeRestorable(manifest) {
4642
+ return manifest.branch !== null && manifest.headOid !== null && manifest.pinRef !== null;
4643
+ }
4644
+ function summarizeTrashEntries(entries) {
4645
+ let totalSizeBytes = 0;
4646
+ let unknownSizeCount = 0;
4647
+ let soonest = null;
4648
+ for (const { manifest } of entries) {
4649
+ if (manifest.sizeBytes === null) {
4650
+ unknownSizeCount++;
4651
+ } else {
4652
+ totalSizeBytes += manifest.sizeBytes;
4653
+ }
4654
+ if (soonest === null || manifest.expiresAt < soonest) {
4655
+ soonest = manifest.expiresAt;
4656
+ }
4657
+ }
4658
+ return { itemCount: entries.length, totalSizeBytes, unknownSizeCount, soonestExpiresAt: soonest };
4659
+ }
4660
+ var TrashService = class {
4661
+ constructor(config, gitService, logger, removalAudit) {
4662
+ this.config = config;
4663
+ this.gitService = gitService;
4664
+ this.logger = logger;
4665
+ this.removalAudit = removalAudit;
4666
+ }
4667
+ config;
4668
+ gitService;
4669
+ logger;
4670
+ removalAudit;
4671
+ updateLogger(logger) {
4672
+ this.logger = logger;
4673
+ }
4674
+ isEnabled() {
4675
+ return this.config.trash?.enabled ?? DEFAULT_CONFIG.TRASH.ENABLED;
4676
+ }
4677
+ getTrashRoot() {
4678
+ return path15.join(this.config.worktreeDir, GIT_CONSTANTS.TRASH_DIR_NAME);
4679
+ }
4680
+ getRetentionDays() {
4681
+ return this.config.trash?.retentionDays ?? DEFAULT_CONFIG.TRASH.RETENTION_DAYS;
4682
+ }
4683
+ async trashDirectory(options) {
4684
+ const deletedAt = /* @__PURE__ */ new Date();
4685
+ const expiresAt = new Date(deletedAt.getTime() + this.getRetentionDays() * 864e5);
4686
+ const keepPinOnReap = options.keepPinOnReap ?? false;
4687
+ const headOid = options.headOid !== void 0 ? options.headOid : await this.resolveHeadOid(options);
4688
+ if (keepPinOnReap && !headOid) {
4689
+ throw new TrashOperationError(
4690
+ "trash-directory",
4691
+ `cannot create keep-on-reap trash entry for '${options.dirPath}': HEAD commit could not be resolved`
4692
+ );
4693
+ }
4694
+ const sizeBytes = await calculateDirectorySize(options.dirPath).catch(() => null);
4695
+ await fs13.mkdir(this.getTrashRoot(), { recursive: true });
4696
+ const { id, containerPath } = await this.createContainer(deletedAt, path15.basename(options.dirPath));
4697
+ const manifest = {
4698
+ schemaVersion: TRASH_CONSTANTS.SCHEMA_VERSION,
4699
+ id,
4700
+ deletedAt: deletedAt.toISOString(),
4701
+ expiresAt: expiresAt.toISOString(),
4702
+ originalPath: path15.resolve(options.originalPath ?? options.dirPath),
4703
+ branch: options.branch ?? null,
4704
+ reason: options.reason,
4705
+ sizeBytes,
4706
+ headOid,
4707
+ pinRef: null,
4708
+ bundleFile: null,
4709
+ source: options.source ?? "worktree",
4710
+ legacyOriginalName: options.legacyOriginalName ?? null,
4711
+ legacyQuarantinedAt: options.legacyQuarantinedAt?.toISOString() ?? null,
4712
+ keepPinOnReap
4713
+ };
4714
+ try {
4715
+ await this.writeManifest(containerPath, manifest);
4716
+ } catch (error) {
4717
+ await this.undoPartialTrash(containerPath, null);
4718
+ throw new TrashOperationError(
4719
+ "trash-directory",
4720
+ `cannot write trash manifest for '${options.dirPath}': ${getErrorMessage(error)}`,
4721
+ error instanceof Error ? error : void 0
4722
+ );
4723
+ }
4724
+ const pinRef = headOid ? await this.createPinRef(id, headOid) : null;
4725
+ if (keepPinOnReap && !pinRef) {
4726
+ await this.undoPartialTrash(containerPath, pinRef);
4727
+ throw new TrashOperationError(
4728
+ "trash-directory",
4729
+ `cannot create keep-on-reap trash entry '${id}' for '${options.dirPath}': pin ref could not be created`
4730
+ );
4731
+ }
4732
+ let bundleFile = null;
4733
+ if (keepPinOnReap && pinRef) {
4734
+ try {
4735
+ const created = await this.gitService.createBundleFromRef(
4736
+ path15.join(containerPath, TRASH_CONSTANTS.BUNDLE_FILENAME),
4737
+ pinRef
4738
+ );
4739
+ bundleFile = created ? TRASH_CONSTANTS.BUNDLE_FILENAME : null;
4740
+ } catch (error) {
4741
+ await this.undoPartialTrash(containerPath, pinRef);
4742
+ throw new TrashOperationError(
4743
+ "trash-directory",
4744
+ `cannot bundle commits for keep-on-reap trash entry '${id}': ${getErrorMessage(error)}`,
4745
+ error instanceof Error ? error : void 0
4746
+ );
4747
+ }
4748
+ }
4749
+ const payloadPath = path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME);
4750
+ manifest.pinRef = pinRef;
4751
+ manifest.bundleFile = bundleFile;
4752
+ try {
4753
+ await this.writeManifest(containerPath, manifest);
4754
+ await fs13.rename(options.dirPath, payloadPath);
4755
+ } catch (error) {
4756
+ await this.undoPartialTrash(containerPath, pinRef);
4757
+ 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)" : "";
4758
+ throw new TrashOperationError(
4759
+ "trash-directory",
4760
+ `cannot move '${options.dirPath}' to trash${hint}: ${getErrorMessage(error)}`,
4761
+ error instanceof Error ? error : void 0
4762
+ );
4763
+ }
4764
+ await this.removalAudit.record({
4765
+ action: options.auditAction ?? "trash_create",
4766
+ result: "success",
4767
+ path: manifest.originalPath,
4768
+ branch: manifest.branch ?? void 0,
4769
+ trashId: id,
4770
+ trashPath: payloadPath
4771
+ }).catch(
4772
+ (auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
4773
+ );
4774
+ return { manifest, containerPath, payloadPath };
4775
+ }
4776
+ async listEntries() {
4777
+ const root = this.getTrashRoot();
4778
+ const entries = [];
4779
+ const invalid = [];
4780
+ let dirents;
4781
+ try {
4782
+ dirents = await fs13.readdir(root, { withFileTypes: true });
4783
+ } catch (error) {
4784
+ if (error.code === "ENOENT") {
4785
+ return { entries, invalid };
4786
+ }
4787
+ throw error;
4788
+ }
4789
+ for (const dirent of dirents) {
4790
+ const containerPath = path15.join(root, dirent.name);
4791
+ if (dirent.isSymbolicLink()) {
4792
+ invalid.push(containerPath);
4793
+ continue;
4794
+ }
4795
+ if (!dirent.isDirectory()) {
4796
+ continue;
4797
+ }
4798
+ const manifest = await this.readManifest(containerPath);
4799
+ if (manifest === null) {
4800
+ invalid.push(containerPath);
4801
+ continue;
4802
+ }
4803
+ entries.push({
4804
+ manifest,
4805
+ containerPath,
4806
+ payloadPath: path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME)
4807
+ });
4808
+ }
4809
+ return { entries, invalid };
4810
+ }
4811
+ // The full reversible-removal sequence shared by prune and manual removal:
4812
+ // payload to trash, dangling registration cleared, branch ref deleted.
4813
+ // A ref-delete failure is a hygiene problem, not a failed removal — the
4814
+ // payload and pin ref already capture everything restore needs, and restore
4815
+ // tolerates a leftover ref at the trashed commit.
4816
+ async trashAndUnregisterWorktree(options) {
4817
+ const entry = await this.trashDirectory(options);
4818
+ await this.gitService.removeWorktree(options.dirPath, { force: true });
4819
+ let branchRefError;
4820
+ try {
4821
+ await this.deleteTrashedBranchRef(entry.manifest);
4822
+ } catch (refError) {
4823
+ branchRefError = getErrorMessage(refError);
4824
+ this.logger.warn(
4825
+ `\u26A0\uFE0F Leftover branch ref '${entry.manifest.branch}' after trashing '${entry.manifest.id}': ${branchRefError}`
4826
+ );
4827
+ }
4828
+ return { entry, branchRefError };
4829
+ }
4830
+ async restore(id) {
4831
+ const { entries } = await this.listEntries();
4832
+ const entry = entries.find((candidate) => candidate.manifest.id === id);
4833
+ if (!entry) {
4834
+ throw new TrashOperationError("restore", `no trash entry with id '${id}'`);
4835
+ }
4836
+ const { manifest, containerPath, payloadPath } = entry;
4837
+ if (await probePathExists(payloadPath) !== "exists") {
4838
+ throw new TrashOperationError("restore", `payload missing or unverifiable for '${id}' at '${payloadPath}'`);
4839
+ }
4840
+ const destinationProbe = await probePathExists(manifest.originalPath);
4841
+ if (destinationProbe !== "missing") {
4842
+ const why = destinationProbe === "exists" ? "already exists" : "cannot be verified";
4843
+ 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" : "";
4844
+ throw new TrashOperationError("restore", `destination '${manifest.originalPath}' ${why}${hint}`);
4845
+ }
4846
+ if (isWorktreeRestorable(manifest)) {
4847
+ await this.restoreAsWorktree(manifest, payloadPath);
4848
+ } else {
4849
+ if (manifest.branch) {
4850
+ this.logger.warn(
4851
+ `\u26A0\uFE0F Trash entry '${id}' has no pinned commit; restoring files only \u2014 the directory will not be a registered worktree.`
4852
+ );
4853
+ }
4854
+ await fs13.rename(payloadPath, manifest.originalPath);
4855
+ }
4856
+ await fs13.rm(containerPath, { recursive: true, force: true }).catch(
4857
+ (error) => this.logger.warn(`\u26A0\uFE0F Failed to remove restored trash container '${containerPath}': ${getErrorMessage(error)}`)
4858
+ );
4859
+ if (manifest.pinRef) {
4860
+ await this.gitService.deleteRef(manifest.pinRef).catch(
4861
+ (error) => this.logger.warn(`\u26A0\uFE0F Failed to delete pin ref '${manifest.pinRef}': ${getErrorMessage(error)}`)
4862
+ );
4863
+ }
4864
+ await this.removalAudit.record({
4865
+ action: "trash_restore",
4866
+ result: "success",
4867
+ path: manifest.originalPath,
4868
+ branch: manifest.branch ?? void 0,
4869
+ trashId: id
4870
+ }).catch(
4871
+ (auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
4872
+ );
4873
+ return manifest;
4874
+ }
4875
+ async deleteTrashedBranchRef(manifest) {
4876
+ if (!manifest.branch) return;
4877
+ if (!manifest.pinRef) {
4878
+ this.logger.warn(
4879
+ `\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`
4880
+ );
4881
+ return;
4882
+ }
4883
+ try {
4884
+ await this.gitService.deleteLocalBranch(manifest.branch);
4885
+ } catch (error) {
4886
+ throw new TrashOperationError(
4887
+ "trash-branch-ref",
4888
+ `cannot delete branch ref '${manifest.branch}' after trashing '${manifest.id}': ${getErrorMessage(error)}`,
4889
+ error instanceof Error ? error : void 0
4890
+ );
4891
+ }
4892
+ }
4893
+ async restoreAsWorktree(manifest, payloadPath) {
4894
+ const branch = manifest.branch;
4895
+ const headOid = manifest.headOid;
4896
+ const existingBranchOid = await this.gitService.getLocalBranchCommit(branch);
4897
+ let createdBranch = false;
4898
+ if (existingBranchOid !== null && existingBranchOid !== headOid) {
4899
+ throw new TrashOperationError(
4900
+ "restore",
4901
+ `branch '${branch}' already exists at ${existingBranchOid}; expected trashed commit ${headOid}. Restore the files manually from '${payloadPath}' or move that branch first`
4902
+ );
4903
+ }
4904
+ if (existingBranchOid === null) {
4905
+ await this.gitService.createBranchAt(branch, headOid);
4906
+ createdBranch = true;
4907
+ }
4908
+ try {
4909
+ await this.gitService.addWorktreeNoCheckout(branch, manifest.originalPath);
4910
+ await this.copyPayloadOver(payloadPath, manifest.originalPath);
4911
+ await this.gitService.resetWorktreeIndex(manifest.originalPath);
4912
+ if (this.config.sparseCheckout) {
4913
+ await this.gitService.getSparseCheckoutService().applyToWorktree(manifest.originalPath, this.config.sparseCheckout);
4914
+ }
4915
+ } catch (error) {
4916
+ await this.gitService.removeWorktree(manifest.originalPath, { force: true }).catch(
4917
+ (rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (worktree) failed: ${getErrorMessage(rollbackError)}`)
4918
+ );
4919
+ if (createdBranch) {
4920
+ await this.gitService.deleteLocalBranch(branch).catch(
4921
+ (rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (branch) failed: ${getErrorMessage(rollbackError)}`)
4922
+ );
4923
+ }
4924
+ throw new TrashOperationError(
4925
+ "restore",
4926
+ `failed to recreate worktree for '${manifest.id}'; trash entry left intact: ${getErrorMessage(error)}`,
4927
+ error instanceof Error ? error : void 0
4928
+ );
4929
+ }
4930
+ }
4931
+ // The payload's top-level .git link points at a pruned admin dir; the fresh
4932
+ // one written by `worktree add --no-checkout` must survive the overlay.
4933
+ async copyPayloadOver(payloadPath, destination) {
4934
+ await fs13.cp(payloadPath, destination, {
4935
+ recursive: true,
4936
+ force: true,
4937
+ filter: (source) => !(path15.dirname(source) === payloadPath && path15.basename(source) === PATH_CONSTANTS.GIT_DIR)
4938
+ });
4939
+ }
4940
+ async resolveHeadOid(options) {
4941
+ if (!options.branch) return null;
4942
+ try {
4943
+ return (await this.gitService.getCurrentCommit(options.dirPath)).trim();
4944
+ } catch (error) {
4945
+ this.logger.warn(
4946
+ `\u26A0\uFE0F Could not resolve HEAD for '${options.dirPath}'; trash entry will preserve files only: ${getErrorMessage(error)}`
4947
+ );
4948
+ return null;
4949
+ }
4950
+ }
4951
+ // Pin failure degrades to a files-only trash entry rather than blocking the
4952
+ // removal — the payload itself is still fully preserved either way.
4953
+ async createPinRef(id, headOid) {
4954
+ const refName = `${GIT_CONSTANTS.TRASH_REF_PREFIX}${id}`;
4955
+ try {
4956
+ await this.gitService.updateRef(refName, headOid);
4957
+ return refName;
4958
+ } catch (error) {
4959
+ this.logger.warn(
4960
+ `\u26A0\uFE0F Could not pin '${headOid}' for trash entry '${id}'; git gc may collect its objects: ${getErrorMessage(error)}`
4961
+ );
4962
+ return null;
4963
+ }
4964
+ }
4965
+ async writeManifest(containerPath, manifest) {
4966
+ const manifestPath = path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME);
4967
+ await atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2));
4968
+ }
4969
+ async readManifest(containerPath) {
4970
+ try {
4971
+ const raw = await fs13.readFile(path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME), "utf-8");
4972
+ const parsed = JSON.parse(raw);
4973
+ if (typeof parsed.id !== "string" || typeof parsed.expiresAt !== "string" || typeof parsed.originalPath !== "string") {
4974
+ return null;
4975
+ }
4976
+ return parsed;
4977
+ } catch {
4978
+ return null;
4979
+ }
4980
+ }
4981
+ async undoPartialTrash(containerPath, pinRef) {
4982
+ await fs13.rm(containerPath, { recursive: true, force: true }).catch(() => void 0);
4983
+ if (pinRef) {
4984
+ await this.gitService.deleteRef(pinRef).catch(() => void 0);
4985
+ }
4986
+ }
4987
+ async createContainer(deletedAt, baseName) {
4988
+ let lastError;
4989
+ for (let attempt = 0; attempt < 3; attempt++) {
4990
+ const id = this.generateId(deletedAt, baseName);
4991
+ const containerPath = path15.join(this.getTrashRoot(), id);
4992
+ try {
4993
+ await fs13.mkdir(containerPath);
4994
+ return { id, containerPath };
4995
+ } catch (error) {
4996
+ lastError = error;
4997
+ if (error.code !== "EEXIST") break;
4998
+ }
4999
+ }
5000
+ throw new TrashOperationError(
5001
+ "trash-directory",
5002
+ `cannot create trash container for '${baseName}': ${getErrorMessage(lastError)}`,
5003
+ lastError instanceof Error ? lastError : void 0
5004
+ );
5005
+ }
5006
+ // The id doubles as a refname component (refs/sync-worktrees/trash/<id>).
5007
+ // The timestamp prefix and hex suffix rule out leading dots and ".lock"
5008
+ // endings, but ".." inside the name would still make the ref invalid and
5009
+ // silently degrade the entry to files-only.
5010
+ generateId(deletedAt, baseName) {
5011
+ const timestamp = filenameTimestamp(deletedAt);
5012
+ const safeName = baseName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
5013
+ return `${timestamp}-${safeName}-${randomBytes(3).toString("hex")}`;
5014
+ }
5015
+ };
5016
+
5017
+ // src/services/trash-reaper.service.ts
5018
+ var TrashReaperService = class {
5019
+ constructor(config, trashService, logger, removalAudit, gitService) {
5020
+ this.config = config;
5021
+ this.trashService = trashService;
5022
+ this.logger = logger;
5023
+ this.removalAudit = removalAudit;
5024
+ this.gitService = gitService;
5025
+ }
5026
+ config;
5027
+ trashService;
5028
+ logger;
5029
+ removalAudit;
5030
+ gitService;
5031
+ updateLogger(logger) {
5032
+ this.logger = logger;
5033
+ }
5034
+ // Disabled trash means "don't touch my trash" — existing entries are left
5035
+ // alone rather than aged out behind the user's back.
5036
+ async reapExpiredUnlocked(now = /* @__PURE__ */ new Date()) {
5037
+ if (!this.trashService.isEnabled()) return;
5038
+ let realRoot;
5039
+ try {
5040
+ realRoot = await fs14.realpath(this.trashService.getTrashRoot());
5041
+ } catch (error) {
5042
+ if (error.code === "ENOENT") {
5043
+ this.logger.debug(`Trash reaper: no trash root; skipping pin-ref sweep`);
5044
+ return;
5045
+ }
5046
+ this.logger.warn(`\u26A0\uFE0F Trash reaper skipped: cannot resolve trash root: ${getErrorMessage(error)}`);
5047
+ return;
5048
+ }
5049
+ const { entries, invalid } = await this.trashService.listEntries();
5050
+ for (const invalidPath of invalid) {
5051
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unrecognized entry '${invalidPath}' alone (no valid manifest)`);
5052
+ }
5053
+ const reapedIds = /* @__PURE__ */ new Set();
5054
+ for (const entry of entries) {
5055
+ const expiresAt = new Date(entry.manifest.expiresAt);
5056
+ if (Number.isNaN(expiresAt.getTime())) {
5057
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' has an unparseable expiry; skipping`);
5058
+ continue;
5059
+ }
5060
+ if (expiresAt.getTime() > now.getTime()) continue;
5061
+ try {
5062
+ const realEntry = await fs14.realpath(entry.containerPath);
5063
+ if (!realEntry.startsWith(realRoot + path16.sep)) {
5064
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' resolves outside the trash root; skipping`);
5065
+ continue;
5066
+ }
5067
+ } catch (error) {
5068
+ this.logger.warn(
5069
+ `\u26A0\uFE0F Trash reaper: cannot verify path of entry '${entry.manifest.id}'; skipping: ${getErrorMessage(error)}`
5070
+ );
5071
+ continue;
5072
+ }
5073
+ let keepRef = null;
5074
+ if (entry.manifest.keepPinOnReap && entry.manifest.headOid) {
5075
+ keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}${entry.manifest.id}`;
5076
+ try {
5077
+ await this.gitService.updateRef(keepRef, entry.manifest.headOid);
5078
+ } catch (error) {
5079
+ this.logger.warn(
5080
+ `\u26A0\uFE0F Trash reaper: cannot create keep ref '${keepRef}' for '${entry.manifest.id}'; deferring reap: ${getErrorMessage(error)}`
5081
+ );
5082
+ continue;
3777
5083
  }
3778
5084
  }
3779
- };
5085
+ try {
5086
+ await this.removalAudit.record({
5087
+ action: "trash_reap",
5088
+ result: "attempt",
5089
+ path: entry.manifest.originalPath,
5090
+ branch: entry.manifest.branch ?? void 0,
5091
+ trashId: entry.manifest.id,
5092
+ trashPath: entry.payloadPath
5093
+ });
5094
+ } catch (auditError) {
5095
+ this.logger.warn(
5096
+ `\u26A0\uFE0F Trash reaper: cannot write audit log; skipping '${entry.manifest.id}': ${getErrorMessage(auditError)}`
5097
+ );
5098
+ continue;
5099
+ }
5100
+ try {
5101
+ await fs14.rm(entry.containerPath, { recursive: true, force: true });
5102
+ } catch (error) {
5103
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete '${entry.manifest.id}': ${getErrorMessage(error)}`);
5104
+ await this.removalAudit.record({
5105
+ action: "trash_reap",
5106
+ result: "failure",
5107
+ path: entry.manifest.originalPath,
5108
+ trashId: entry.manifest.id,
5109
+ error: getErrorMessage(error)
5110
+ }).catch(() => void 0);
5111
+ continue;
5112
+ }
5113
+ if (entry.manifest.pinRef) {
5114
+ await this.gitService.deleteRef(entry.manifest.pinRef).catch(
5115
+ (error) => this.logger.warn(
5116
+ `\u26A0\uFE0F Trash reaper: failed to delete pin ref '${entry.manifest.pinRef}': ${getErrorMessage(error)}`
5117
+ )
5118
+ );
5119
+ }
5120
+ reapedIds.add(entry.manifest.id);
5121
+ this.logger.info(
5122
+ `\u{1F5D1}\uFE0F Trash reaper: deleted expired entry '${entry.manifest.id}' (trashed ${entry.manifest.deletedAt})`
5123
+ );
5124
+ if (keepRef) {
5125
+ this.logger.info(
5126
+ ` Commits remain recoverable at '${keepRef}' (${entry.manifest.headOid}) \u2014 recover with: git branch <name> ${entry.manifest.headOid}`
5127
+ );
5128
+ }
5129
+ await this.removalAudit.record({
5130
+ action: "trash_reap",
5131
+ result: "success",
5132
+ path: entry.manifest.originalPath,
5133
+ trashId: entry.manifest.id
5134
+ }).catch(
5135
+ (auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
5136
+ );
5137
+ }
5138
+ let containerNames = null;
5139
+ try {
5140
+ containerNames = new Set(await fs14.readdir(realRoot));
5141
+ } catch (error) {
5142
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot scan trash root for pin-ref sweep: ${getErrorMessage(error)}`);
5143
+ }
5144
+ if (containerNames !== null) {
5145
+ await this.reapOrphanedPinRefs(containerNames);
5146
+ }
5147
+ this.warnIfOverThreshold(entries.filter((entry) => !reapedIds.has(entry.manifest.id)));
3780
5148
  }
3781
- resetLfsSkipIfNeeded(syncContext) {
3782
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
3783
- this.gitService.setLfsSkipEnabled(false);
5149
+ // Pin refs whose trash container is gone would pin objects forever (failed
5150
+ // ref delete during restore, manually emptied trash). Keyed on container
5151
+ // existence, NOT manifest validity — an invalid-manifest entry still owns
5152
+ // its pin because the reaper refuses to delete its payload. Deliberately
5153
+ // any dirent name counts (files, symlinks): deleting a pin is irreversible
5154
+ // once gc runs, while a stray name collision merely keeps one ref alive.
5155
+ async reapOrphanedPinRefs(containerNames) {
5156
+ let refs;
5157
+ try {
5158
+ refs = await this.gitService.listRefs(GIT_CONSTANTS.TRASH_REF_PREFIX.replace(/\/$/, ""));
5159
+ } catch (error) {
5160
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot list pin refs: ${getErrorMessage(error)}`);
5161
+ return;
5162
+ }
5163
+ for (const ref of refs) {
5164
+ if (!ref.startsWith(GIT_CONSTANTS.TRASH_REF_PREFIX)) continue;
5165
+ const id = ref.slice(GIT_CONSTANTS.TRASH_REF_PREFIX.length);
5166
+ if (id.length === 0 || id.includes("/")) {
5167
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unexpected ref '${ref}' alone`);
5168
+ continue;
5169
+ }
5170
+ if (containerNames.has(id)) continue;
5171
+ try {
5172
+ await this.gitService.deleteRef(ref);
5173
+ this.logger.info(`\u{1F5D1}\uFE0F Trash reaper: deleted orphaned pin ref '${ref}'`);
5174
+ } catch (error) {
5175
+ this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete orphaned pin ref '${ref}': ${getErrorMessage(error)}`);
5176
+ }
5177
+ }
5178
+ }
5179
+ warnIfOverThreshold(remaining) {
5180
+ const warnSizeBytes = this.config.trash?.warnSizeBytes;
5181
+ if (warnSizeBytes === void 0) return;
5182
+ const summary = summarizeTrashEntries(remaining);
5183
+ if (summary.totalSizeBytes > warnSizeBytes) {
5184
+ this.logger.warn(
5185
+ `\u26A0\uFE0F Trash holds ${formatBytes(summary.totalSizeBytes)} across ${summary.itemCount} entries (threshold ${formatBytes(warnSizeBytes)}). Entries expire ${this.trashService.getRetentionDays()} days after removal.`
5186
+ );
3784
5187
  }
3785
5188
  }
3786
5189
  };
3787
5190
 
3788
5191
  // src/services/worktree-mode-sync-runner.ts
3789
- import * as fs9 from "fs/promises";
3790
- import * as path13 from "path";
5192
+ import * as fs16 from "fs/promises";
5193
+ import * as path19 from "path";
3791
5194
  import pLimit from "p-limit";
3792
5195
 
3793
- // src/utils/date-filter.ts
3794
- function parseDuration(durationStr) {
3795
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
3796
- if (!match) {
3797
- return null;
3798
- }
3799
- const value = parseInt(match[1], 10);
3800
- const unit = match[2];
3801
- const multipliers = {
3802
- h: 60 * 60 * 1e3,
3803
- // hours
3804
- d: 24 * 60 * 60 * 1e3,
3805
- // days
3806
- w: 7 * 24 * 60 * 60 * 1e3,
3807
- // weeks
3808
- m: 30 * 24 * 60 * 60 * 1e3,
3809
- // months (approximate)
3810
- y: 365 * 24 * 60 * 60 * 1e3
3811
- // years (approximate)
3812
- };
3813
- return value * multipliers[unit];
3814
- }
3815
- function filterBranchesByAge(branches, maxAge) {
3816
- const maxAgeMs = parseDuration(maxAge);
3817
- if (maxAgeMs === null) {
3818
- console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
3819
- return branches;
3820
- }
3821
- const cutoffDate = new Date(Date.now() - maxAgeMs);
3822
- return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
3823
- }
3824
- function formatDuration2(durationStr) {
3825
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
3826
- if (!match) {
3827
- return durationStr;
3828
- }
3829
- const value = parseInt(match[1], 10);
3830
- const unit = match[2];
3831
- const unitNames = {
3832
- h: value === 1 ? "hour" : "hours",
3833
- d: value === 1 ? "day" : "days",
3834
- w: value === 1 ? "week" : "weeks",
3835
- m: value === 1 ? "month" : "months",
3836
- y: value === 1 ? "year" : "years"
3837
- };
3838
- return `${value} ${unitNames[unit]}`;
3839
- }
3840
-
3841
5196
  // src/services/path-resolution.service.ts
3842
5197
  import { createHash as createHash2 } from "crypto";
3843
- import * as fs8 from "fs";
3844
- import * as path11 from "path";
5198
+ import * as fs15 from "fs";
5199
+ import * as path17 from "path";
3845
5200
  var BRANCH_STEM_MAX = 80;
3846
5201
  var BRANCH_HASH_LEN = 8;
3847
5202
  var PathResolutionService = class {
@@ -3851,22 +5206,22 @@ var PathResolutionService = class {
3851
5206
  return `${stem}-${hash}`;
3852
5207
  }
3853
5208
  getBranchWorktreePath(worktreeDir, branchName) {
3854
- return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
5209
+ return path17.join(worktreeDir, this.sanitizeBranchName(branchName));
3855
5210
  }
3856
5211
  resolveRealPath(inputPath) {
3857
- const absolute = path11.resolve(inputPath);
5212
+ const absolute = path17.resolve(inputPath);
3858
5213
  const missing = [];
3859
5214
  let current = absolute;
3860
- while (!fs8.existsSync(current)) {
3861
- const parent = path11.dirname(current);
5215
+ while (!fs15.existsSync(current)) {
5216
+ const parent = path17.dirname(current);
3862
5217
  if (parent === current) {
3863
5218
  return absolute;
3864
5219
  }
3865
- missing.unshift(path11.basename(current));
5220
+ missing.unshift(path17.basename(current));
3866
5221
  current = parent;
3867
5222
  }
3868
5223
  try {
3869
- return path11.join(fs8.realpathSync(current), ...missing);
5224
+ return path17.join(fs15.realpathSync(current), ...missing);
3870
5225
  } catch {
3871
5226
  return absolute;
3872
5227
  }
@@ -3876,7 +5231,7 @@ var PathResolutionService = class {
3876
5231
  const a = fold(resolved);
3877
5232
  const b = fold(resolvedBase);
3878
5233
  if (a === b) return true;
3879
- return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
5234
+ return a.length > b.length && a.charAt(b.length) === path17.sep && a.startsWith(b);
3880
5235
  }
3881
5236
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
3882
5237
  const resolved = this.resolveRealPath(worktreePath);
@@ -3884,7 +5239,7 @@ var PathResolutionService = class {
3884
5239
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
3885
5240
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
3886
5241
  }
3887
- return path11.relative(resolvedBase, resolved);
5242
+ return path17.relative(resolvedBase, resolved);
3888
5243
  }
3889
5244
  isPathInsideBaseDir(targetPath, baseDir) {
3890
5245
  const resolved = this.resolveRealPath(targetPath);
@@ -3897,7 +5252,7 @@ var PathResolutionService = class {
3897
5252
  };
3898
5253
 
3899
5254
  // src/services/worktree-sync-planner.ts
3900
- import * as path12 from "path";
5255
+ import * as path18 from "path";
3901
5256
  function createWorktreeSyncPlan(inventory, options = {}) {
3902
5257
  return {
3903
5258
  create: planCreateActions(inventory, options),
@@ -3915,12 +5270,12 @@ function planCreateActions(inventory, options = {}) {
3915
5270
  );
3916
5271
  const reservedPaths = /* @__PURE__ */ new Map();
3917
5272
  for (const worktree of inventory.existingWorktrees) {
3918
- reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
5273
+ reservedPaths.set(path18.resolve(worktree.path), worktree.branch);
3919
5274
  }
3920
5275
  const actions = [];
3921
5276
  for (const branch of newBranches) {
3922
5277
  const worktreePath = pathResolution2.getBranchWorktreePath(inventory.worktreeDir, branch);
3923
- const resolved = path12.resolve(worktreePath);
5278
+ const resolved = path18.resolve(worktreePath);
3924
5279
  const conflictingBranch = reservedPaths.get(resolved);
3925
5280
  if (conflictingBranch && conflictingBranch !== branch) {
3926
5281
  actions.push({
@@ -3958,25 +5313,30 @@ function planSparseActions(inventory, sparseCheckout) {
3958
5313
 
3959
5314
  // src/services/worktree-mode-sync-runner.ts
3960
5315
  var WorktreeModeSyncRunner = class {
3961
- constructor(config, gitService, logger, progressEmitter) {
5316
+ constructor(config, gitService, logger, progressEmitter, services) {
3962
5317
  this.config = config;
3963
5318
  this.gitService = gitService;
3964
5319
  this.logger = logger;
3965
5320
  this.progressEmitter = progressEmitter;
5321
+ this.removalAudit = services?.removalAudit ?? new RemovalAuditService(getRemovalAuditLogPath(config));
5322
+ this.trashService = services?.trashService ?? new TrashService(config, gitService, logger, this.removalAudit);
3966
5323
  }
3967
5324
  config;
3968
5325
  gitService;
3969
5326
  logger;
3970
5327
  progressEmitter;
3971
5328
  pathResolution = new PathResolutionService();
5329
+ removalAudit;
5330
+ trashService;
3972
5331
  updateLogger(logger) {
3973
5332
  this.logger = logger;
5333
+ this.trashService.updateLogger(logger);
3974
5334
  }
3975
5335
  async runSyncAttempt(phaseTimer, syncContext, outcome) {
3976
5336
  await this.gitService.pruneWorktrees();
3977
5337
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
3978
5338
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
3979
- await fs9.mkdir(this.config.worktreeDir, { recursive: true });
5339
+ await fs16.mkdir(this.config.worktreeDir, { recursive: true });
3980
5340
  const worktrees = await this.gitService.getWorktrees();
3981
5341
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
3982
5342
  await this.cleanupOrphanedDirectories(worktrees);
@@ -3994,6 +5354,7 @@ var WorktreeModeSyncRunner = class {
3994
5354
  }
3995
5355
  );
3996
5356
  await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
5357
+ await this.recordRemoteBranchTips([...worktrees, ...syncPlan.create.filter((action) => action.kind === "create")]);
3997
5358
  await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
3998
5359
  if (this.config.updateExistingWorktrees !== false) {
3999
5360
  await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
@@ -4016,7 +5377,7 @@ var WorktreeModeSyncRunner = class {
4016
5377
  if (action.kind !== "check-sparse") return;
4017
5378
  try {
4018
5379
  try {
4019
- await fs9.access(action.path);
5380
+ await fs16.access(action.path);
4020
5381
  } catch {
4021
5382
  return;
4022
5383
  }
@@ -4102,7 +5463,7 @@ var WorktreeModeSyncRunner = class {
4102
5463
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
4103
5464
  const remoteBranches = filteredBranches.map((b) => b.branch);
4104
5465
  this.logger.info(
4105
- `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
5466
+ `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
4106
5467
  );
4107
5468
  if (filteredByName.length > remoteBranches.length) {
4108
5469
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -4179,6 +5540,37 @@ var WorktreeModeSyncRunner = class {
4179
5540
  const successCount = results.filter((r) => r.status === "fulfilled").length;
4180
5541
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
4181
5542
  }
5543
+ // Persist each worktree's upstream tip while the remote ref still exists.
5544
+ // This is the proof consulted after a squash-merge deletes the branch:
5545
+ // "HEAD was on the remote before the deletion" — without it every such
5546
+ // worktree reads as having unpushed commits forever. Best-effort: a failed
5547
+ // recording only means that worktree stays conservatively preserved.
5548
+ async recordRemoteBranchTips(worktrees) {
5549
+ try {
5550
+ const tips = await this.gitService.getRemoteBranchTips();
5551
+ if (tips.size === 0) return;
5552
+ const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5553
+ await Promise.all(
5554
+ worktrees.map(
5555
+ (wt) => limit(async () => {
5556
+ const oid = tips.get(wt.branch);
5557
+ if (!oid) return;
5558
+ await this.gitService.recordRemoteTip(wt.path, wt.branch, oid).catch(
5559
+ (error) => this.logger.warn(` - \u26A0\uFE0F Could not record remote tip for '${wt.branch}': ${getErrorMessage(error)}`)
5560
+ );
5561
+ })
5562
+ )
5563
+ );
5564
+ } catch (error) {
5565
+ this.logger.warn(`\u26A0\uFE0F Could not record remote branch tips: ${getErrorMessage(error)}`);
5566
+ }
5567
+ }
5568
+ // A removal authorized only by the fully-pushed proof must stay reversible:
5569
+ // without trash it would be a permanent delete of commits whose remote
5570
+ // branch may have been deleted unmerged.
5571
+ blockedByDisabledTrash(status) {
5572
+ return status.fullyPushedUpstreamDeleted && !this.trashService.isEnabled();
5573
+ }
4182
5574
  async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
4183
5575
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4184
5576
  phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
@@ -4208,7 +5600,18 @@ var WorktreeModeSyncRunner = class {
4208
5600
  if (result.status === "fulfilled") {
4209
5601
  const { branchName, worktreePath, status } = result.value;
4210
5602
  if (status.canRemove) {
4211
- toRemove.push({ branchName, worktreePath });
5603
+ if (this.blockedByDisabledTrash(status)) {
5604
+ this.logger.warn(
5605
+ ` - \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.`
5606
+ );
5607
+ outcome.recordSkipped("worktree", "fully_pushed_trash_disabled", {
5608
+ branch: branchName,
5609
+ path: worktreePath,
5610
+ message: "fully pushed before upstream deletion; trash disabled"
5611
+ });
5612
+ } else {
5613
+ toRemove.push({ branchName, worktreePath });
5614
+ }
4212
5615
  } else {
4213
5616
  toSkip.push({ branchName, worktreePath, status });
4214
5617
  }
@@ -4231,7 +5634,7 @@ var WorktreeModeSyncRunner = class {
4231
5634
  ({ branchName, worktreePath }) => removeLimit(async () => {
4232
5635
  try {
4233
5636
  const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
4234
- if (!recheck.canRemove) {
5637
+ if (!recheck.canRemove || this.blockedByDisabledTrash(recheck)) {
4235
5638
  this.logger.warn(
4236
5639
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
4237
5640
  );
@@ -4242,10 +5645,76 @@ var WorktreeModeSyncRunner = class {
4242
5645
  });
4243
5646
  return;
4244
5647
  }
4245
- await this.gitService.removeWorktree(worktreePath);
4246
- this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
4247
- outcome.recordRemoved(branchName, worktreePath);
5648
+ try {
5649
+ await this.removalAudit.record({
5650
+ action: "prune_remove",
5651
+ result: "attempt",
5652
+ path: worktreePath,
5653
+ branch: branchName,
5654
+ status: recheck
5655
+ });
5656
+ } catch (auditError) {
5657
+ this.logger.warn(
5658
+ ` \u26A0\uFE0F Skipping removal of '${branchName}' - cannot write removal audit log: ${getErrorMessage(auditError)}`
5659
+ );
5660
+ outcome.recordSkipped("worktree", "audit_log_unavailable", {
5661
+ branch: branchName,
5662
+ path: worktreePath,
5663
+ message: getErrorMessage(auditError)
5664
+ });
5665
+ return;
5666
+ }
5667
+ if (await probePathExists(worktreePath) === "missing") {
5668
+ await this.gitService.removeWorktree(worktreePath, { force: true });
5669
+ this.logger.info(` \u2705 Cleared dangling registration for '${branchName}' (directory already gone)`);
5670
+ outcome.recordRemoved(branchName, worktreePath);
5671
+ await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
5672
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
5673
+ );
5674
+ return;
5675
+ }
5676
+ let refWarning;
5677
+ if (this.trashService.isEnabled()) {
5678
+ const { entry, branchRefError } = await this.trashService.trashAndUnregisterWorktree({
5679
+ dirPath: worktreePath,
5680
+ branch: branchName,
5681
+ reason: "prune",
5682
+ keepPinOnReap: recheck.fullyPushedUpstreamDeleted
5683
+ });
5684
+ if (branchRefError !== void 0) {
5685
+ refWarning = `leftover_branch_ref: could not delete branch ref '${branchName}': ${branchRefError}`;
5686
+ }
5687
+ const pushedNote = recheck.fullyPushedUpstreamDeleted ? " \u2014 was fully pushed before its remote branch was deleted" : "";
5688
+ this.logger.info(
5689
+ ` \u2705 Moved worktree for '${branchName}' to trash (id: ${entry.manifest.id})${pushedNote}`
5690
+ );
5691
+ } else {
5692
+ await this.gitService.removeWorktree(worktreePath);
5693
+ this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
5694
+ }
5695
+ outcome.recordRemoved(branchName, worktreePath, refWarning);
5696
+ await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
5697
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
5698
+ );
4248
5699
  } catch (error) {
5700
+ if (error instanceof WorktreeNotCleanError) {
5701
+ this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - git refused: ${getErrorMessage(error)}`);
5702
+ outcome.recordSkipped("worktree", "git_refused_removal", {
5703
+ branch: branchName,
5704
+ path: worktreePath,
5705
+ message: getErrorMessage(error)
5706
+ });
5707
+ return;
5708
+ }
5709
+ if (error instanceof TrashOperationError) {
5710
+ this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - ${getErrorMessage(error)}`);
5711
+ outcome.recordSkipped("worktree", "trash_failed", {
5712
+ branch: branchName,
5713
+ path: worktreePath,
5714
+ message: getErrorMessage(error)
5715
+ });
5716
+ return;
5717
+ }
4249
5718
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
4250
5719
  outcome.recordFailed("worktree", getErrorMessage(error), {
4251
5720
  reason: "remove_failed",
@@ -4371,12 +5840,12 @@ var WorktreeModeSyncRunner = class {
4371
5840
  }
4372
5841
  async updateExistingWorktrees(actions, outcome) {
4373
5842
  this.logger.info("Step 4: Checking for worktrees that need updates...");
4374
- const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5843
+ const divergedDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4375
5844
  try {
4376
- const diverged = await fs9.readdir(divergedDir);
5845
+ const diverged = await fs16.readdir(divergedDir);
4377
5846
  if (diverged.length > 0) {
4378
5847
  this.logger.info(
4379
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
5848
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path19.relative(process.cwd(), divergedDir)}`
4380
5849
  );
4381
5850
  }
4382
5851
  } catch {
@@ -4388,7 +5857,7 @@ var WorktreeModeSyncRunner = class {
4388
5857
  (action) => limit(async () => {
4389
5858
  const worktree = { path: action.path, branch: action.branch };
4390
5859
  try {
4391
- await fs9.access(worktree.path);
5860
+ await fs16.access(worktree.path);
4392
5861
  } catch {
4393
5862
  return { action: "skip", worktree, reason: "missing_worktree_path" };
4394
5863
  }
@@ -4528,13 +5997,13 @@ var WorktreeModeSyncRunner = class {
4528
5997
  }
4529
5998
  async cleanupOrphanedDirectories(worktrees) {
4530
5999
  try {
4531
- const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
4532
- const allDirs = await fs9.readdir(this.config.worktreeDir);
6000
+ const worktreeRelativePaths = worktrees.map((w) => path19.relative(this.config.worktreeDir, w.path));
6001
+ const allDirs = await fs16.readdir(this.config.worktreeDir);
4533
6002
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
4534
6003
  const orphanedDirs = [];
4535
6004
  for (const dir of regularDirs) {
4536
6005
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
4537
- return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
6006
+ return worktreePath === dir || worktreePath.startsWith(dir + path19.sep);
4538
6007
  });
4539
6008
  if (!isPartOfWorktree) {
4540
6009
  orphanedDirs.push(dir);
@@ -4543,13 +6012,46 @@ var WorktreeModeSyncRunner = class {
4543
6012
  if (orphanedDirs.length > 0) {
4544
6013
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
4545
6014
  for (const dir of orphanedDirs) {
4546
- const dirPath = path13.join(this.config.worktreeDir, dir);
6015
+ const dirPath = path19.join(this.config.worktreeDir, dir);
4547
6016
  try {
4548
- const stat4 = await fs9.stat(dirPath);
4549
- if (stat4.isDirectory()) {
4550
- await fs9.rm(dirPath, { recursive: true, force: true });
4551
- this.logger.info(` - Removed orphaned directory: ${dir}`);
6017
+ const stat4 = await fs16.stat(dirPath);
6018
+ if (!stat4.isDirectory()) {
6019
+ continue;
6020
+ }
6021
+ const gitProbe = await probePathExists(path19.join(dirPath, PATH_CONSTANTS.GIT_DIR));
6022
+ if (gitProbe === "unknown") {
6023
+ this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir}: cannot verify it is not a live checkout`);
6024
+ continue;
6025
+ }
6026
+ if (this.trashService.isEnabled()) {
6027
+ try {
6028
+ const entry = await this.trashService.trashDirectory({ dirPath, reason: "orphan" });
6029
+ this.logger.info(` - Moved orphaned directory '${dir}' to trash (id: ${entry.manifest.id})`);
6030
+ } catch (trashError) {
6031
+ this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir} - ${getErrorMessage(trashError)}`);
6032
+ }
6033
+ continue;
4552
6034
  }
6035
+ if (gitProbe === "exists") {
6036
+ const quarantinePath = await quarantineDirectory(dirPath);
6037
+ this.logger.warn(
6038
+ ` - \u26A0\uFE0F Orphaned directory ${dir} contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
6039
+ );
6040
+ await this.removalAudit.record({ action: "orphan_quarantine", result: "success", path: dirPath, quarantinePath }).catch(
6041
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
6042
+ );
6043
+ continue;
6044
+ }
6045
+ try {
6046
+ await this.removalAudit.record({ action: "orphan_delete", result: "attempt", path: dirPath });
6047
+ } catch (auditError) {
6048
+ this.logger.warn(
6049
+ ` - \u26A0\uFE0F Skipping orphaned directory ${dir} - cannot write removal audit log: ${getErrorMessage(auditError)}`
6050
+ );
6051
+ continue;
6052
+ }
6053
+ await fs16.rm(dirPath, { recursive: true, force: true });
6054
+ this.logger.info(` - Removed orphaned directory: ${dir}`);
4553
6055
  } catch (error) {
4554
6056
  this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
4555
6057
  }
@@ -4578,14 +6080,37 @@ var WorktreeModeSyncRunner = class {
4578
6080
  outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
4579
6081
  } else {
4580
6082
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
4581
- const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
4582
- const relativePath = path13.relative(process.cwd(), divergedPath);
6083
+ let keepRef = null;
6084
+ if (!this.trashService.isEnabled()) {
6085
+ const localCommit = (await this.gitService.getCurrentCommit(worktree.path)).trim();
6086
+ keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}diverged-${Date.now().toString(36)}-${this.pathResolution.sanitizeBranchName(worktree.branch)}`;
6087
+ await this.gitService.updateRef(keepRef, localCommit);
6088
+ }
6089
+ const { divergedPath, manifest } = await this.divergeWorktree(worktree.path, worktree.branch);
6090
+ const relativePath = path19.relative(process.cwd(), divergedPath);
4583
6091
  outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
4584
6092
  this.logger.info(` Moved to: ${relativePath}`);
4585
6093
  this.logger.info(` Your local changes are preserved. To review:`);
4586
6094
  this.logger.info(` cd ${relativePath}`);
4587
6095
  this.logger.info(` git diff origin/${worktree.branch}`);
4588
- await this.gitService.removeWorktree(worktree.path);
6096
+ await this.gitService.removeWorktree(worktree.path, { force: true });
6097
+ if (manifest !== null) {
6098
+ await this.trashService.deleteTrashedBranchRef(manifest);
6099
+ } else {
6100
+ await this.gitService.deleteLocalBranch(worktree.branch);
6101
+ this.logger.info(
6102
+ ` Never-pushed commits remain recoverable at '${keepRef}' \u2014 recover with: git branch <name> ${keepRef}`
6103
+ );
6104
+ }
6105
+ await this.removalAudit.record({
6106
+ action: "diverged_replace",
6107
+ result: "success",
6108
+ path: worktree.path,
6109
+ branch: worktree.branch,
6110
+ quarantinePath: divergedPath
6111
+ }).catch(
6112
+ (auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
6113
+ );
4589
6114
  await this.gitService.addWorktree(worktree.branch, worktree.path);
4590
6115
  this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
4591
6116
  }
@@ -4604,42 +6129,55 @@ var WorktreeModeSyncRunner = class {
4604
6129
  }
4605
6130
  }
4606
6131
  async divergeWorktree(worktreePath, branchName) {
4607
- const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6132
+ if (this.trashService.isEnabled()) {
6133
+ const entry = await this.trashService.trashDirectory({
6134
+ dirPath: worktreePath,
6135
+ branch: branchName,
6136
+ reason: "diverged-replace",
6137
+ keepPinOnReap: true
6138
+ });
6139
+ await this.writeDivergedInfoFile(entry.payloadPath, worktreePath, branchName, entry.manifest.headOid);
6140
+ return { divergedPath: entry.payloadPath, manifest: entry.manifest };
6141
+ }
6142
+ const divergedBaseDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4608
6143
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4609
6144
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
4610
6145
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
4611
6146
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
4612
- const divergedPath = path13.join(divergedBaseDir, divergedName);
4613
- await fs9.mkdir(divergedBaseDir, { recursive: true });
6147
+ const divergedPath = path19.join(divergedBaseDir, divergedName);
6148
+ await fs16.mkdir(divergedBaseDir, { recursive: true });
4614
6149
  try {
4615
- await fs9.rename(worktreePath, divergedPath);
6150
+ await fs16.rename(worktreePath, divergedPath);
4616
6151
  } catch (err) {
4617
6152
  if (err.code === ERROR_MESSAGES.EXDEV) {
4618
- await fs9.cp(worktreePath, divergedPath, { recursive: true });
4619
- await fs9.rm(worktreePath, { recursive: true, force: true });
6153
+ await fs16.cp(worktreePath, divergedPath, { recursive: true });
6154
+ await fs16.rm(worktreePath, { recursive: true, force: true });
4620
6155
  } else {
4621
6156
  throw err;
4622
6157
  }
4623
6158
  }
6159
+ await this.writeDivergedInfoFile(divergedPath, worktreePath, branchName, null);
6160
+ return { divergedPath, manifest: null };
6161
+ }
6162
+ async writeDivergedInfoFile(preservedPath, originalPath, branchName, knownLocalCommit) {
4624
6163
  const metadata = {
4625
6164
  originalBranch: branchName,
4626
6165
  divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
4627
6166
  reason: METADATA_CONSTANTS.DIVERGED_REASON,
4628
- originalPath: worktreePath,
4629
- localCommit: await this.gitService.getCurrentCommit(divergedPath),
6167
+ originalPath,
6168
+ localCommit: knownLocalCommit ?? await this.gitService.getCurrentCommit(preservedPath),
4630
6169
  remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
4631
6170
  instruction: `To preserve your changes:
4632
6171
  1. Review: git diff origin/${branchName}
4633
6172
  2. Keep changes: git push --force-with-lease origin ${branchName}
4634
6173
  3. Discard changes: rm -rf this directory
4635
6174
 
4636
- Original worktree location: ${worktreePath}`
6175
+ Original worktree location: ${originalPath}`
4637
6176
  };
4638
- await fs9.writeFile(
4639
- path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
6177
+ await fs16.writeFile(
6178
+ path19.join(preservedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4640
6179
  JSON.stringify(metadata, null, 2)
4641
6180
  );
4642
- return divergedPath;
4643
6181
  }
4644
6182
  };
4645
6183
 
@@ -4650,12 +6188,26 @@ var WorktreeSyncService = class {
4650
6188
  this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
4651
6189
  this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
4652
6190
  this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
6191
+ this.maintenanceService = new GitMaintenanceService(config, this.gitService, this.logger);
4653
6192
  this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
6193
+ const removalAudit = new RemovalAuditService(getRemovalAuditLogPath(config));
6194
+ this.trashService = new TrashService(config, this.gitService, this.logger, removalAudit);
6195
+ this.trashReaper = new TrashReaperService(config, this.trashService, this.logger, removalAudit, this.gitService);
6196
+ this.trashMigration = new TrashMigrationService(config, this.trashService, this.logger);
6197
+ if (this.trashService.isEnabled()) {
6198
+ this.gitService.setStaleDirectoryTrasher(
6199
+ async (dirPath) => (await this.trashService.trashDirectory({ dirPath, reason: "orphan" })).payloadPath
6200
+ );
6201
+ }
4654
6202
  this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
4655
6203
  config,
4656
6204
  this.gitService,
4657
6205
  this.logger,
4658
- this.progressEmitter
6206
+ this.progressEmitter,
6207
+ {
6208
+ trashService: this.trashService,
6209
+ removalAudit
6210
+ }
4659
6211
  );
4660
6212
  if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
4661
6213
  this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
@@ -4677,8 +6229,12 @@ var WorktreeSyncService = class {
4677
6229
  repoMutex = pLimit2(1);
4678
6230
  progressEmitter = new ProgressEmitter();
4679
6231
  repoOperationLock;
6232
+ maintenanceService;
4680
6233
  retryPolicy;
4681
6234
  worktreeModeSyncRunner;
6235
+ trashService;
6236
+ trashReaper;
6237
+ trashMigration;
4682
6238
  skipsAccumulator = [];
4683
6239
  lastOutcome = null;
4684
6240
  getRecordedSkips() {
@@ -4702,6 +6258,18 @@ var WorktreeSyncService = class {
4702
6258
  }
4703
6259
  return this.gitService.getWorktrees();
4704
6260
  }
6261
+ async getRemoteBranches() {
6262
+ if (this.cloneSyncService) {
6263
+ return this.cloneSyncService.getRemoteBranches();
6264
+ }
6265
+ return this.gitService.getRemoteBranches();
6266
+ }
6267
+ async checkoutBranch(branchName, options = {}) {
6268
+ if (!this.cloneSyncService) {
6269
+ throw new ConfigError("checkoutBranch is only available for clone-mode repositories", "CLONE_MODE_REQUIRED");
6270
+ }
6271
+ await this.cloneSyncService.checkoutBranch(branchName, options);
6272
+ }
4705
6273
  async initialize() {
4706
6274
  if (this.isInitialized()) return;
4707
6275
  const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
@@ -4731,6 +6299,23 @@ var WorktreeSyncService = class {
4731
6299
  getGitService() {
4732
6300
  return this.gitService;
4733
6301
  }
6302
+ // Restore must hold the repo lock: the reaper, prune, and gc all mutate the
6303
+ // same trash entries and refs at the tail of a sync. wait:true queues behind
6304
+ // an in-flight sync instead of failing fast — restores are explicit user
6305
+ // actions, not periodic work.
6306
+ async restoreFromTrash(id) {
6307
+ const result = await this.runExclusiveRepoOperation(() => this.trashService.restore(id), { wait: true });
6308
+ if (!result.started) {
6309
+ throw new TrashOperationError(
6310
+ "restore",
6311
+ `cannot restore trash entry '${id}': another process holds the repo lock`
6312
+ );
6313
+ }
6314
+ return result.value;
6315
+ }
6316
+ async listTrashEntries() {
6317
+ return this.trashService.listEntries();
6318
+ }
4734
6319
  updateLogger(logger) {
4735
6320
  this.logger = logger;
4736
6321
  this.gitService.updateLogger(logger);
@@ -4738,6 +6323,37 @@ var WorktreeSyncService = class {
4738
6323
  this.retryPolicy.updateLogger(logger);
4739
6324
  this.worktreeModeSyncRunner.updateLogger(logger);
4740
6325
  this.repoOperationLock.updateLogger(logger);
6326
+ this.maintenanceService.updateLogger(logger);
6327
+ this.trashService.updateLogger(logger);
6328
+ this.trashReaper.updateLogger(logger);
6329
+ this.trashMigration.updateLogger(logger);
6330
+ }
6331
+ // Runs git gc when due, inside the already-held repo lock (mirrors
6332
+ // initializeUnlocked — must NOT re-acquire runExclusiveRepoOperation or it
6333
+ // would self-deadlock/skip). Skipped under NODE_ENV=test so unit suites don't
6334
+ // shell out to real git; GitMaintenanceService is covered by its own tests.
6335
+ async runMaintenanceIfDueUnlocked() {
6336
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
6337
+ return;
6338
+ }
6339
+ await this.maintenanceService.runIfDueUnlocked();
6340
+ }
6341
+ // Same contract as runMaintenanceIfDueUnlocked: tail of a successful sync,
6342
+ // inside the held lock, never fails the sync. Runs before gc so freshly
6343
+ // reaped pin refs can be collected in the same maintenance window.
6344
+ async runTrashMaintenanceUnlocked() {
6345
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
6346
+ return;
6347
+ }
6348
+ if (this.cloneSyncService) {
6349
+ return;
6350
+ }
6351
+ try {
6352
+ await this.trashMigration.migrateLegacyUnlocked();
6353
+ await this.trashReaper.reapExpiredUnlocked();
6354
+ } catch (error) {
6355
+ this.logger.warn(`\u26A0\uFE0F Trash maintenance failed: ${getErrorMessage(error)}`);
6356
+ }
4741
6357
  }
4742
6358
  onProgress(listener) {
4743
6359
  return this.progressEmitter.onProgress(listener);
@@ -4773,6 +6389,7 @@ var WorktreeSyncService = class {
4773
6389
  }
4774
6390
  async sync() {
4775
6391
  const result = await this.runExclusiveRepoOperation(async () => {
6392
+ this.clearRecordedSkips();
4776
6393
  const totalTimer = new Timer();
4777
6394
  const phaseTimer = new PhaseTimer();
4778
6395
  const outcome = new SyncOutcomeAccumulator({
@@ -4821,7 +6438,9 @@ var WorktreeSyncService = class {
4821
6438
  const repoName = this.config.name;
4822
6439
  this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
4823
6440
  }
6441
+ await this.runTrashMaintenanceUnlocked();
4824
6442
  }
6443
+ await this.runMaintenanceIfDueUnlocked();
4825
6444
  return this.lastOutcome ?? outcome.toOutcome(durationMs);
4826
6445
  });
4827
6446
  return result.started ? { started: true, outcome: result.value } : result;
@@ -4837,7 +6456,6 @@ function emptyCapabilities(reason) {
4837
6456
  listWorktrees: { ...state },
4838
6457
  getStatus: { ...state },
4839
6458
  createWorktree: { ...state },
4840
- removeWorktree: { ...state },
4841
6459
  updateWorktree: { ...state },
4842
6460
  sync: { ...state },
4843
6461
  initialize: { ...state }
@@ -4876,16 +6494,19 @@ var RepositoryContext = class {
4876
6494
  discoveryCache = /* @__PURE__ */ new Map();
4877
6495
  launchCwd;
4878
6496
  constructor(options = {}) {
4879
- this.launchCwd = path14.resolve(options.launchCwd ?? process.cwd());
6497
+ this.launchCwd = path20.resolve(options.launchCwd ?? process.cwd());
4880
6498
  }
4881
6499
  getLaunchCwd() {
4882
6500
  return this.launchCwd;
4883
6501
  }
6502
+ async findConfigUpward(startDir) {
6503
+ return this.configLoader.findConfigUpward(startDir);
6504
+ }
4884
6505
  async loadConfig(configPath, options = {}) {
4885
6506
  const setDefaultCurrent = options.setDefaultCurrent ?? true;
4886
- const absolutePath = path14.resolve(configPath);
6507
+ const absolutePath = path20.resolve(configPath);
4887
6508
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
4888
- const configDir = path14.dirname(absolutePath);
6509
+ const configDir = path20.dirname(absolutePath);
4889
6510
  const globalDefaults = configFile.defaults;
4890
6511
  const resolvedAll = [];
4891
6512
  for (const repo of configFile.repositories) {
@@ -4922,7 +6543,7 @@ var RepositoryContext = class {
4922
6543
  return configFile.repositories;
4923
6544
  }
4924
6545
  async detectFromPath(dirPath) {
4925
- const absolutePath = path14.resolve(dirPath);
6546
+ const absolutePath = path20.resolve(dirPath);
4926
6547
  const cached = this.discoveryCache.get(absolutePath);
4927
6548
  if (cached && await this.isCacheFresh(cached)) {
4928
6549
  return cached.result;
@@ -4941,8 +6562,8 @@ var RepositoryContext = class {
4941
6562
  const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
4942
6563
  if (result.isWorktree && result.bareRepoPath && adminDir) {
4943
6564
  const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
4944
- safeMtimeMs(path14.join(adminDir, "HEAD")),
4945
- safeMtimeMs(path14.join(result.bareRepoPath, "worktrees"))
6565
+ safeMtimeMs(path20.join(adminDir, "HEAD")),
6566
+ safeMtimeMs(path20.join(result.bareRepoPath, "worktrees"))
4946
6567
  ]);
4947
6568
  this.discoveryCache.set(absolutePath, {
4948
6569
  result,
@@ -4982,7 +6603,7 @@ var RepositoryContext = class {
4982
6603
  const results = /* @__PURE__ */ new Map();
4983
6604
  const byName = (a, b) => a.name.localeCompare(b.name);
4984
6605
  const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
4985
- const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
6606
+ const bareRepoPath = path20.resolve(entry.config.bareRepoDir);
4986
6607
  return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
4987
6608
  }).filter((c) => c.foldedBare !== currentBare);
4988
6609
  const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
@@ -4990,7 +6611,7 @@ var RepositoryContext = class {
4990
6611
  const sibling = {
4991
6612
  name: entry.name,
4992
6613
  bareRepoPath,
4993
- worktreeDir: path14.resolve(entry.config.worktreeDir),
6614
+ worktreeDir: path20.resolve(entry.config.worktreeDir),
4994
6615
  repoUrl: entry.config.repoUrl,
4995
6616
  present: configPresence[i],
4996
6617
  configMatched: true
@@ -5000,24 +6621,24 @@ var RepositoryContext = class {
5000
6621
  }
5001
6622
  results.set(foldedBare, sibling);
5002
6623
  });
5003
- const repoDir = path14.dirname(currentBareRepoPath);
5004
- const workspaceRoot = path14.dirname(repoDir);
6624
+ const repoDir = path20.dirname(currentBareRepoPath);
6625
+ const workspaceRoot = path20.dirname(repoDir);
5005
6626
  if (workspaceRoot === repoDir) {
5006
6627
  return Array.from(results.values()).sort(byName);
5007
6628
  }
5008
6629
  let entries;
5009
6630
  try {
5010
- entries = await fs10.readdir(workspaceRoot);
6631
+ entries = await fs17.readdir(workspaceRoot);
5011
6632
  } catch {
5012
6633
  return Array.from(results.values()).sort(byName);
5013
6634
  }
5014
6635
  const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
5015
6636
  await Promise.all(
5016
6637
  entries.map(async (entry) => {
5017
- const candidate = path14.join(workspaceRoot, entry);
5018
- const bareCandidate = path14.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
6638
+ const candidate = path20.join(workspaceRoot, entry);
6639
+ const bareCandidate = path20.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
5019
6640
  if (!await isDirectory(bareCandidate)) return;
5020
- const resolvedBare = path14.resolve(bareCandidate);
6641
+ const resolvedBare = path20.resolve(bareCandidate);
5021
6642
  const foldedBare = normalizePathForCompare(resolvedBare);
5022
6643
  if (foldedBare === currentBare || results.has(foldedBare)) return;
5023
6644
  const matchedName = configBares.get(foldedBare);
@@ -5043,8 +6664,8 @@ var RepositoryContext = class {
5043
6664
  if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
5044
6665
  if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
5045
6666
  const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
5046
- safeMtimeMs(path14.join(cached.worktreeAdminDir, "HEAD")),
5047
- safeMtimeMs(path14.join(cached.result.bareRepoPath, "worktrees"))
6667
+ safeMtimeMs(path20.join(cached.worktreeAdminDir, "HEAD")),
6668
+ safeMtimeMs(path20.join(cached.result.bareRepoPath, "worktrees"))
5048
6669
  ]);
5049
6670
  return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
5050
6671
  }
@@ -5092,18 +6713,18 @@ var RepositoryContext = class {
5092
6713
  return unsupported("Invalid .git file format (missing gitdir line)");
5093
6714
  }
5094
6715
  const gitdir = gitdirMatch[1].trim();
5095
- const resolvedGitdir = path14.isAbsolute(gitdir) ? gitdir : path14.resolve(worktreeRoot, gitdir);
6716
+ const resolvedGitdir = path20.isAbsolute(gitdir) ? gitdir : path20.resolve(worktreeRoot, gitdir);
5096
6717
  const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
5097
6718
  if (!worktreesMatch) {
5098
6719
  return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
5099
6720
  }
5100
- const bareRepoPath = path14.resolve(worktreesMatch[1]);
5101
- const adminDir = path14.resolve(resolvedGitdir);
6721
+ const bareRepoPath = path20.resolve(worktreesMatch[1]);
6722
+ const adminDir = path20.resolve(resolvedGitdir);
5102
6723
  let repoUrl = null;
5103
6724
  let worktrees = [];
5104
6725
  let currentBranch = null;
5105
6726
  try {
5106
- const bareGit = simpleGit6(bareRepoPath);
6727
+ const bareGit = simpleGit7(bareRepoPath);
5107
6728
  try {
5108
6729
  const remoteResult = await bareGit.remote(["get-url", "origin"]);
5109
6730
  const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
@@ -5139,13 +6760,12 @@ var RepositoryContext = class {
5139
6760
  adminDir
5140
6761
  };
5141
6762
  }
5142
- const worktreeDir = path14.dirname(worktreeRoot);
6763
+ const worktreeDir = path20.dirname(worktreeRoot);
5143
6764
  const noUrlReason = "no remote origin URL detected";
5144
6765
  const capabilities = {
5145
6766
  listWorktrees: { available: true },
5146
6767
  getStatus: { available: true },
5147
6768
  createWorktree: repoUrl !== null ? { available: true } : { available: false, reason: noUrlReason },
5148
- removeWorktree: { available: true },
5149
6769
  updateWorktree: { available: true },
5150
6770
  sync: { available: false, reason: "no config and no remote URL" },
5151
6771
  initialize: { available: false, reason: "no config and no remote URL" }
@@ -5175,7 +6795,7 @@ var RepositoryContext = class {
5175
6795
  cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
5176
6796
  runOnce: true
5177
6797
  };
5178
- const detectedKey = `${AUTO_DETECT_PREFIX}${path14.basename(bareRepoPath)}@${bareRepoPath}`;
6798
+ const detectedKey = `${AUTO_DETECT_PREFIX}${path20.basename(bareRepoPath)}@${bareRepoPath}`;
5179
6799
  if (!this.repos.has(detectedKey)) {
5180
6800
  this.repos.set(detectedKey, {
5181
6801
  name: detectedKey,
@@ -5362,9 +6982,9 @@ var RepositoryContext = class {
5362
6982
  const mode = resolveMode(entry.config);
5363
6983
  const isCurrent = entry.name === currentRepo;
5364
6984
  if (mode === REPOSITORY_MODES.CLONE) {
5365
- return { name: entry.name, mode: "clone", checkoutPath: path14.resolve(entry.config.worktreeDir), isCurrent };
6985
+ return { name: entry.name, mode: "clone", checkoutPath: path20.resolve(entry.config.worktreeDir), isCurrent };
5366
6986
  }
5367
- return { name: entry.name, mode: "worktree", worktreeDir: path14.resolve(entry.config.worktreeDir), isCurrent };
6987
+ return { name: entry.name, mode: "worktree", worktreeDir: path20.resolve(entry.config.worktreeDir), isCurrent };
5368
6988
  };
5369
6989
  if (!options.detailed) {
5370
6990
  return entries.map(buildLean);
@@ -5389,7 +7009,7 @@ var RepositoryContext = class {
5389
7009
  return summary;
5390
7010
  }
5391
7011
  if (entry.config.bareRepoDir) {
5392
- summary.bareRepoDir = path14.resolve(entry.config.bareRepoDir);
7012
+ summary.bareRepoDir = path20.resolve(entry.config.bareRepoDir);
5393
7013
  summary.localReady = await isDirectory(summary.bareRepoDir);
5394
7014
  } else {
5395
7015
  summary.localReady = false;
@@ -5433,27 +7053,27 @@ var RepositoryContext = class {
5433
7053
  return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
5434
7054
  }
5435
7055
  if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
5436
- const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
7056
+ const bareRepoPath = path20.resolve(entry.config.bareRepoDir);
5437
7057
  if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
5438
7058
  try {
5439
- const output = await simpleGit6(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
7059
+ const output = await simpleGit7(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
5440
7060
  return { worktrees: parseWorktreeList(output, currentWorktreePath) };
5441
7061
  } catch (err) {
5442
7062
  return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
5443
7063
  }
5444
7064
  }
5445
7065
  findConfiguredCloneEntry(worktreeRoot) {
5446
- const foldedRoot = normalizePathForCompare(path14.resolve(worktreeRoot));
7066
+ const foldedRoot = normalizePathForCompare(path20.resolve(worktreeRoot));
5447
7067
  for (const entry of this.repos.values()) {
5448
7068
  if (entry.source !== "config" || resolveMode(entry.config) !== REPOSITORY_MODES.CLONE) continue;
5449
- if (normalizePathForCompare(path14.resolve(entry.config.worktreeDir)) === foldedRoot) {
7069
+ if (normalizePathForCompare(path20.resolve(entry.config.worktreeDir)) === foldedRoot) {
5450
7070
  return entry;
5451
7071
  }
5452
7072
  }
5453
7073
  return null;
5454
7074
  }
5455
7075
  async buildCloneModeContext(entry, worktreeRoot, notes) {
5456
- const resolvedRoot = path14.resolve(worktreeRoot);
7076
+ const resolvedRoot = path20.resolve(worktreeRoot);
5457
7077
  let currentBranch = null;
5458
7078
  try {
5459
7079
  currentBranch = await readCurrentBranch(resolvedRoot);
@@ -5466,7 +7086,6 @@ var RepositoryContext = class {
5466
7086
  listWorktrees: { available: true },
5467
7087
  getStatus: { available: true },
5468
7088
  createWorktree: { available: false, reason: cloneModeReason },
5469
- removeWorktree: { available: false, reason: cloneModeReason },
5470
7089
  updateWorktree: { available: false, reason: cloneModeReason },
5471
7090
  sync: { available: true },
5472
7091
  initialize: { available: true }
@@ -5491,7 +7110,7 @@ var RepositoryContext = class {
5491
7110
  return discovered;
5492
7111
  }
5493
7112
  async readConfiguredCloneWorktree(entry, currentWorktreePath) {
5494
- const worktreePath = path14.resolve(entry.config.worktreeDir);
7113
+ const worktreePath = path20.resolve(entry.config.worktreeDir);
5495
7114
  if (!await isDirectory(worktreePath) || !await hasGitMetadata(worktreePath)) {
5496
7115
  return { worktrees: [] };
5497
7116
  }
@@ -5515,7 +7134,7 @@ function parseWorktreeList(output, currentPath) {
5515
7134
  const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
5516
7135
  const results = [];
5517
7136
  for (const wt of parseWorktreeListPorcelain(output)) {
5518
- const resolved = path14.resolve(wt.path);
7137
+ const resolved = path20.resolve(wt.path);
5519
7138
  const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
5520
7139
  if (!branch) continue;
5521
7140
  results.push({
@@ -5528,7 +7147,7 @@ function parseWorktreeList(output, currentPath) {
5528
7147
  }
5529
7148
  async function safeMtimeMs(filePath) {
5530
7149
  try {
5531
- const stat4 = await fs10.stat(filePath);
7150
+ const stat4 = await fs17.stat(filePath);
5532
7151
  return stat4.mtimeMs;
5533
7152
  } catch {
5534
7153
  return null;
@@ -5536,7 +7155,7 @@ async function safeMtimeMs(filePath) {
5536
7155
  }
5537
7156
  async function isDirectory(filePath) {
5538
7157
  try {
5539
- const stat4 = await fs10.stat(filePath);
7158
+ const stat4 = await fs17.stat(filePath);
5540
7159
  return stat4.isDirectory();
5541
7160
  } catch {
5542
7161
  return false;
@@ -5544,7 +7163,7 @@ async function isDirectory(filePath) {
5544
7163
  }
5545
7164
  async function hasGitMetadata(worktreePath) {
5546
7165
  try {
5547
- await fs10.stat(path14.join(worktreePath, ".git"));
7166
+ await fs17.stat(path20.join(worktreePath, ".git"));
5548
7167
  return true;
5549
7168
  } catch {
5550
7169
  return false;
@@ -5553,14 +7172,14 @@ async function hasGitMetadata(worktreePath) {
5553
7172
  async function isGitCheckout(checkoutPath) {
5554
7173
  if (!await isDirectory(checkoutPath)) return false;
5555
7174
  try {
5556
- const inside = (await simpleGit6(checkoutPath).raw(["rev-parse", "--is-inside-work-tree"])).trim();
7175
+ const inside = (await simpleGit7(checkoutPath).raw(["rev-parse", "--is-inside-work-tree"])).trim();
5557
7176
  return inside === "true";
5558
7177
  } catch {
5559
7178
  return false;
5560
7179
  }
5561
7180
  }
5562
7181
  async function readCurrentBranch(worktreePath) {
5563
- const git = simpleGit6(worktreePath);
7182
+ const git = simpleGit7(worktreePath);
5564
7183
  const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
5565
7184
  if (branch && branch !== "HEAD") {
5566
7185
  return branch;
@@ -5569,12 +7188,12 @@ async function readCurrentBranch(worktreePath) {
5569
7188
  return head ? `(detached ${head})` : "(detached)";
5570
7189
  }
5571
7190
  async function findWorktreeRoot(startPath) {
5572
- let current = path14.resolve(startPath);
5573
- const root = path14.parse(current).root;
7191
+ let current = path20.resolve(startPath);
7192
+ const root = path20.parse(current).root;
5574
7193
  while (true) {
5575
- const gitPath = path14.join(current, ".git");
7194
+ const gitPath = path20.join(current, ".git");
5576
7195
  try {
5577
- const content = await fs10.readFile(gitPath, "utf-8");
7196
+ const content = await fs17.readFile(gitPath, "utf-8");
5578
7197
  return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
5579
7198
  } catch (err) {
5580
7199
  const code = err.code;
@@ -5586,7 +7205,7 @@ async function findWorktreeRoot(startPath) {
5586
7205
  }
5587
7206
  }
5588
7207
  if (current === root) return null;
5589
- const parent = path14.dirname(current);
7208
+ const parent = path20.dirname(current);
5590
7209
  if (parent === current) return null;
5591
7210
  current = parent;
5592
7211
  }
@@ -5597,27 +7216,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5597
7216
  import { z } from "zod";
5598
7217
 
5599
7218
  // src/mcp/handlers.ts
5600
- import * as path15 from "path";
7219
+ import * as path21 from "path";
5601
7220
  import pLimit4 from "p-limit";
5602
7221
 
5603
- // src/utils/disk-space.ts
5604
- import fastFolderSize from "fast-folder-size";
5605
- async function calculateDirectorySize(dirPath) {
5606
- return new Promise((resolve11, reject) => {
5607
- fastFolderSize(dirPath, (err, bytes) => {
5608
- if (err) {
5609
- reject(err);
5610
- return;
5611
- }
5612
- if (bytes === void 0) {
5613
- reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
5614
- return;
5615
- }
5616
- resolve11(bytes);
5617
- });
5618
- });
5619
- }
5620
-
5621
7222
  // src/utils/git-validation.ts
5622
7223
  function isValidGitBranchName(name) {
5623
7224
  if (!name.trim()) {
@@ -5727,14 +7328,18 @@ function wrapHandler(fn) {
5727
7328
  }
5728
7329
 
5729
7330
  // src/mcp/worktree-summary.ts
5730
- import simpleGit7 from "simple-git";
7331
+ import simpleGit8 from "simple-git";
5731
7332
  function deriveLabel(status, isCurrent) {
5732
7333
  if (isCurrent) return "current";
5733
- if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
5734
- if (status.upstreamGone) return "stale";
7334
+ const unpushedBlocks = status.hasUnpushedCommits && !status.fullyPushedUpstreamDeleted;
7335
+ if (!status.isClean || unpushedBlocks || status.hasStashedChanges) return "dirty";
7336
+ if (status.upstreamGone || status.fullyPushedUpstreamDeleted) return "stale";
5735
7337
  return "clean";
5736
7338
  }
5737
7339
  function deriveSafeToRemove(status) {
7340
+ if (status.canRemove && status.fullyPushedUpstreamDeleted) {
7341
+ return { safe: true, reason: "fully pushed before its remote branch was deleted" };
7342
+ }
5738
7343
  if (status.canRemove && !status.upstreamGone) {
5739
7344
  return { safe: true, reason: "clean tree, no unpushed commits" };
5740
7345
  }
@@ -5748,7 +7353,7 @@ function deriveSafeToRemove(status) {
5748
7353
  }
5749
7354
  async function getDivergence(worktreePath) {
5750
7355
  try {
5751
- const git = simpleGit7(worktreePath);
7356
+ const git = simpleGit8(worktreePath);
5752
7357
  const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
5753
7358
  const [aheadStr, behindStr] = output.trim().split(/\s+/);
5754
7359
  return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
@@ -5796,7 +7401,7 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
5796
7401
  }
5797
7402
  async function ensureRepoWorktreePath(ctx, params, service, git) {
5798
7403
  await ensurePathBelongsToRepo(ctx, params.path, params.repoName, service, git);
5799
- return path15.resolve(params.path);
7404
+ return path21.resolve(params.path);
5800
7405
  }
5801
7406
  async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
5802
7407
  const discovered = ctx.getDiscoveredContext(repoName);
@@ -5887,12 +7492,8 @@ async function handleListWorktrees(ctx, params, _extra) {
5887
7492
  configuredRepoNames.map(
5888
7493
  (repoName) => limit(async () => {
5889
7494
  try {
5890
- return [
5891
- repoName,
5892
- {
5893
- worktrees: await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit)
5894
- }
5895
- ];
7495
+ const worktrees2 = await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit);
7496
+ return [repoName, { worktrees: worktrees2 }];
5896
7497
  } catch (err) {
5897
7498
  return [
5898
7499
  repoName,
@@ -5907,8 +7508,8 @@ async function handleListWorktrees(ctx, params, _extra) {
5907
7508
  );
5908
7509
  return formatToolResponse({ repositories: Object.fromEntries(repositories) });
5909
7510
  }
5910
- const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
5911
- return formatToolResponse({ worktrees: results });
7511
+ const worktrees = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
7512
+ return formatToolResponse({ worktrees });
5912
7513
  }
5913
7514
  async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
5914
7515
  const { discovered, service, git } = await getReadyService(ctx, repoName, {
@@ -5929,7 +7530,7 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit4(
5929
7530
  const results = await Promise.all(
5930
7531
  worktrees.map(
5931
7532
  (wt) => limit(async () => {
5932
- const resolvedPath = path15.resolve(wt.path);
7533
+ const resolvedPath = path21.resolve(wt.path);
5933
7534
  const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
5934
7535
  const [status, divergence, metadata, sizeBytes] = await Promise.all([
5935
7536
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
@@ -6013,37 +7614,12 @@ async function handleCreateWorktree(ctx, params, _extra) {
6013
7614
  return formatToolResponse({
6014
7615
  success: true,
6015
7616
  branchName,
6016
- worktreePath: path15.resolve(worktreePath),
7617
+ worktreePath: path21.resolve(worktreePath),
6017
7618
  created,
6018
7619
  pushed
6019
7620
  });
6020
7621
  });
6021
7622
  }
6022
- async function handleRemoveWorktree(ctx, params, _extra) {
6023
- const { service, git } = await getReadyService(ctx, params.repoName, {
6024
- capability: "removeWorktree",
6025
- toolName: "remove_worktree"
6026
- });
6027
- ensureWorktreeModeService(service, "remove_worktree");
6028
- return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
6029
- if (!service.isInitialized()) {
6030
- await service.initializeUnlocked();
6031
- }
6032
- const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
6033
- if (!params.force) {
6034
- const status = await git.getFullWorktreeStatus(params.path, false);
6035
- if (!status.canRemove) {
6036
- throw new Error(`Cannot remove worktree: ${status.reasons.join(", ")}. Use force=true to override.`);
6037
- }
6038
- }
6039
- await git.removeWorktree(params.path);
6040
- ctx.invalidateDiscovered();
6041
- return formatToolResponse({
6042
- success: true,
6043
- removedPath
6044
- });
6045
- });
6046
- }
6047
7623
  async function handleSync(ctx, params, extra) {
6048
7624
  const { service } = await getReadyService(ctx, params.repoName, {
6049
7625
  capability: "sync",
@@ -6052,7 +7628,6 @@ async function handleSync(ctx, params, extra) {
6052
7628
  const dispose = attachProgressReporter(service, extra);
6053
7629
  try {
6054
7630
  const start = Date.now();
6055
- service.clearRecordedSkips();
6056
7631
  const result = await service.sync();
6057
7632
  if (!result.started) {
6058
7633
  throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
@@ -6123,17 +7698,28 @@ async function handleInitialize(ctx, params, extra) {
6123
7698
  }
6124
7699
  }
6125
7700
  async function handleLoadConfig(ctx, params, _extra) {
6126
- const configPath = params.configPath ?? process.env.SYNC_WORKTREES_CONFIG;
7701
+ const configPath = params.configPath ?? process.env.SYNC_WORKTREES_CONFIG ?? ctx.getConfigPath() ?? await detectConfigFromLaunchCwd(ctx);
6127
7702
  if (!configPath) {
6128
- throw new Error("configPath required (or set SYNC_WORKTREES_CONFIG env var)");
7703
+ throw new Error(
7704
+ "configPath required (or set SYNC_WORKTREES_CONFIG env var, call detect_context with a path, or launch from a sync-worktrees workspace)"
7705
+ );
6129
7706
  }
6130
7707
  await ctx.loadConfig(configPath);
6131
7708
  return formatToolResponse({
6132
- configPath: path15.resolve(configPath),
7709
+ configPath: path21.resolve(configPath),
6133
7710
  currentRepository: ctx.getCurrentRepo(),
6134
7711
  repositories: ctx.getRepositoryList()
6135
7712
  });
6136
7713
  }
7714
+ async function detectConfigFromLaunchCwd(ctx) {
7715
+ try {
7716
+ const discovered = await ctx.detectFromPath(ctx.getLaunchCwd());
7717
+ if (discovered.configPath) return discovered.configPath;
7718
+ return await ctx.findConfigUpward(ctx.getLaunchCwd());
7719
+ } catch {
7720
+ return null;
7721
+ }
7722
+ }
6137
7723
  async function handleSetCurrentRepository(ctx, params, _extra) {
6138
7724
  ctx.setCurrentRepo(params.repoName);
6139
7725
  return formatToolResponse({
@@ -6229,7 +7815,7 @@ function createServer(context, snapshot) {
6229
7815
  detailed: z.boolean().optional().default(false).describe("Expand configuredRepositories with repoUrl, branch, sparseCheckout, localReady, bareRepoDir."),
6230
7816
  includeAllWorktrees: z.boolean().optional().describe("Include allWorktreesByRepo + allWorktreeErrorsByRepo for each configured repo. Default: false."),
6231
7817
  includeStatus: z.boolean().optional().describe(
6232
- "Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Default: false."
7818
+ "Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Labels here are metadata-blind (no sync metadata is loaded), so a fully-pushed branch whose remote was deleted shows 'dirty'; list_worktrees gives the authoritative label/safeToRemove. Default: false."
6233
7819
  )
6234
7820
  },
6235
7821
  annotations: {
@@ -6300,25 +7886,6 @@ function createServer(context, snapshot) {
6300
7886
  },
6301
7887
  wrapHandler((params, extra) => handleCreateWorktree(context, params, extra))
6302
7888
  );
6303
- server.registerTool(
6304
- "remove_worktree",
6305
- {
6306
- description: "Remove worktree. Safety checks reject if dirty, unpushed commits, stashes, or op in progress (merge/rebase/cherry-pick/revert/bisect). force=true: `git worktree remove --force` DELETES uncommitted/untracked files in dir; branch ref + stashes + remote preserved. Returns: {success, removedPath}.",
6307
- inputSchema: {
6308
- path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
6309
- force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
6310
- repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
6311
- },
6312
- annotations: {
6313
- title: "Remove worktree",
6314
- readOnlyHint: false,
6315
- destructiveHint: true,
6316
- idempotentHint: false,
6317
- openWorldHint: false
6318
- }
6319
- },
6320
- wrapHandler((params, extra) => handleRemoveWorktree(context, params, extra))
6321
- );
6322
7889
  server.registerTool(
6323
7890
  "sync",
6324
7891
  {
@@ -6374,9 +7941,11 @@ function createServer(context, snapshot) {
6374
7941
  server.registerTool(
6375
7942
  "load_config",
6376
7943
  {
6377
- description: "Load/reload sync-worktrees JS config into session. Replaces previously loaded repos. Call before sync/initialize/create_worktree in config-driven workflow. Returns: {configPath, currentRepository, repositories: [{name, repoUrl, worktreeDir, source}]}.",
7944
+ description: "Load/reload sync-worktrees JS config into session. Replaces previously loaded repos. Uses configPath, SYNC_WORKTREES_CONFIG, an already detected config, or a launch-CWD auto-detect fallback. For first discovery from an arbitrary project path, call detect_context with path. Returns: {configPath, currentRepository, repositories: [{name, repoUrl, worktreeDir, source}]}.",
6378
7945
  inputSchema: {
6379
- configPath: z.string().optional().describe("Config file path. Falls back to SYNC_WORKTREES_CONFIG env var. Errors if neither set.")
7946
+ configPath: z.string().optional().describe(
7947
+ "Config file path. Falls back to SYNC_WORKTREES_CONFIG, an already detected config, or launch-CWD auto-detect."
7948
+ )
6380
7949
  },
6381
7950
  annotations: {
6382
7951
  title: "Load sync-worktrees config",