sync-worktrees 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +135 -55
  2. package/dist/components/App.d.ts.map +1 -1
  3. package/dist/components/BranchCreationWizard.d.ts.map +1 -1
  4. package/dist/components/OpenEditorWizard.d.ts.map +1 -1
  5. package/dist/components/StatusBar.d.ts +1 -0
  6. package/dist/components/StatusBar.d.ts.map +1 -1
  7. package/dist/components/WorktreeStatusView.d.ts.map +1 -1
  8. package/dist/constants.d.ts +22 -0
  9. package/dist/constants.d.ts.map +1 -1
  10. package/dist/errors/index.d.ts +7 -0
  11. package/dist/errors/index.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2201 -510
  15. package/dist/index.js.map +4 -4
  16. package/dist/mcp/context.d.ts +1 -1
  17. package/dist/mcp/context.d.ts.map +1 -1
  18. package/dist/mcp/handlers.d.ts +0 -5
  19. package/dist/mcp/handlers.d.ts.map +1 -1
  20. package/dist/mcp/server.d.ts.map +1 -1
  21. package/dist/mcp/worktree-summary.d.ts.map +1 -1
  22. package/dist/mcp-server.js +2117 -519
  23. package/dist/mcp-server.js.map +4 -4
  24. package/dist/services/InteractiveUIService.d.ts.map +1 -1
  25. package/dist/services/clone-sync.service.d.ts +13 -2
  26. package/dist/services/clone-sync.service.d.ts.map +1 -1
  27. package/dist/services/config-loader.service.d.ts +2 -0
  28. package/dist/services/config-loader.service.d.ts.map +1 -1
  29. package/dist/services/git-maintenance.service.d.ts +44 -0
  30. package/dist/services/git-maintenance.service.d.ts.map +1 -0
  31. package/dist/services/git.service.d.ts +19 -1
  32. package/dist/services/git.service.d.ts.map +1 -1
  33. package/dist/services/removal-audit.service.d.ts +19 -0
  34. package/dist/services/removal-audit.service.d.ts.map +1 -0
  35. package/dist/services/sync-outcome.d.ts +1 -1
  36. package/dist/services/sync-outcome.d.ts.map +1 -1
  37. package/dist/services/trash-migration.service.d.ts +18 -0
  38. package/dist/services/trash-migration.service.d.ts.map +1 -0
  39. package/dist/services/trash-reaper.service.d.ts +18 -0
  40. package/dist/services/trash-reaper.service.d.ts.map +1 -0
  41. package/dist/services/trash.service.d.ts +91 -0
  42. package/dist/services/trash.service.d.ts.map +1 -0
  43. package/dist/services/worktree-metadata.service.d.ts +7 -0
  44. package/dist/services/worktree-metadata.service.d.ts.map +1 -1
  45. package/dist/services/worktree-mode-sync-runner.d.ts +11 -1
  46. package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -1
  47. package/dist/services/worktree-status.service.d.ts +5 -2
  48. package/dist/services/worktree-status.service.d.ts.map +1 -1
  49. package/dist/services/worktree-sync.service.d.ts +21 -2
  50. package/dist/services/worktree-sync.service.d.ts.map +1 -1
  51. package/dist/types/index.d.ts +60 -2
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types/sync-metadata.d.ts +6 -0
  54. package/dist/types/sync-metadata.d.ts.map +1 -1
  55. package/dist/utils/atomic-write.d.ts +2 -0
  56. package/dist/utils/atomic-write.d.ts.map +1 -0
  57. package/dist/utils/file-exists.d.ts +2 -0
  58. package/dist/utils/file-exists.d.ts.map +1 -1
  59. package/dist/utils/filename-timestamp.d.ts +2 -0
  60. package/dist/utils/filename-timestamp.d.ts.map +1 -0
  61. package/dist/utils/lock-path.d.ts +1 -0
  62. package/dist/utils/lock-path.d.ts.map +1 -1
  63. package/dist/utils/quarantine.d.ts +2 -0
  64. package/dist/utils/quarantine.d.ts.map +1 -0
  65. package/package.json +28 -27
@@ -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";
9
- import pLimit2 from "p-limit";
10
- import simpleGit6 from "simple-git";
7
+ import * as fs17 from "fs/promises";
8
+ import * as path20 from "path";
9
+ import pLimit3 from "p-limit";
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",
@@ -127,6 +149,8 @@ var SyncWorktreesError = class extends Error {
127
149
  Caused by: ${cause.stack}`;
128
150
  }
129
151
  }
152
+ code;
153
+ cause;
130
154
  };
131
155
  var GitError = class extends SyncWorktreesError {
132
156
  constructor(message, code, cause) {
@@ -138,17 +162,26 @@ var GitOperationError = class extends GitError {
138
162
  super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
139
163
  }
140
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
+ };
141
172
  var WorktreeError = class extends SyncWorktreesError {
142
173
  constructor(message, code, cause) {
143
174
  super(message, `WORKTREE_${code}`, cause);
144
175
  }
145
176
  };
146
177
  var WorktreeNotCleanError = class extends WorktreeError {
147
- constructor(path16, reasons) {
148
- super(`Worktree at '${path16}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
149
- this.path = path16;
178
+ constructor(path22, reasons) {
179
+ super(`Worktree at '${path22}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
180
+ this.path = path22;
150
181
  this.reasons = reasons;
151
182
  }
183
+ path;
184
+ reasons;
152
185
  };
153
186
  var ConfigError = class extends SyncWorktreesError {
154
187
  constructor(message, code, cause) {
@@ -161,12 +194,27 @@ var ConfigValidationError = class extends ConfigError {
161
194
  this.field = field;
162
195
  this.reason = reason;
163
196
  }
197
+ field;
198
+ reason;
164
199
  };
165
200
  var ConfigFileNotFoundError = class extends ConfigError {
166
201
  constructor(configPath) {
167
202
  super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
168
203
  this.configPath = configPath;
169
204
  }
205
+ configPath;
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;
170
218
  };
171
219
 
172
220
  // src/utils/branch-filter.ts
@@ -189,16 +237,73 @@ function filterBranchesByName(branches, include, exclude) {
189
237
  return result;
190
238
  }
191
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
+
192
288
  // src/utils/file-exists.ts
193
289
  import * as fs from "fs/promises";
194
- async function fileExists(path16) {
290
+ async function fileExists(path22) {
195
291
  try {
196
- await fs.access(path16);
292
+ await fs.access(path22);
197
293
  return true;
198
294
  } catch {
199
295
  return false;
200
296
  }
201
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
+ }
202
307
 
203
308
  // src/utils/git-url.ts
204
309
  function extractRepoNameFromUrl(gitUrl) {
@@ -289,7 +394,8 @@ var CLONE_MODE_CONFLICTING_FIELDS = [
289
394
  "branchExclude",
290
395
  "branchMaxAge",
291
396
  "updateExistingWorktrees",
292
- "bareRepoDir"
397
+ "bareRepoDir",
398
+ "trash"
293
399
  ];
294
400
  var ConfigLoaderService = class {
295
401
  async findConfigUpward(startDir) {
@@ -392,6 +498,12 @@ var ConfigLoaderService = class {
392
498
  if (repoObj.sparseCheckout !== void 0) {
393
499
  this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
394
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
+ }
395
507
  this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
396
508
  this.validateRepositoryMode(repoObj, configObj.defaults);
397
509
  });
@@ -428,6 +540,12 @@ var ConfigLoaderService = class {
428
540
  if (defaults.sparseCheckout !== void 0) {
429
541
  this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
430
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
+ }
431
549
  this.validateDepth(defaults.depth, "defaults.depth");
432
550
  if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
433
551
  throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
@@ -455,6 +573,46 @@ var ConfigLoaderService = class {
455
573
  throw new ConfigValidationError(field, "must be a positive safe integer");
456
574
  }
457
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
+ }
458
616
  validateRetryConfig(value, context) {
459
617
  if (typeof value !== "object" || value === null) {
460
618
  throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
@@ -726,6 +884,18 @@ var ConfigLoaderService = class {
726
884
  if (sparse) {
727
885
  resolved.sparseCheckout = sparse;
728
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
+ }
729
899
  return resolved;
730
900
  }
731
901
  isDuplicateRepoUrl(repo, all, defaults) {
@@ -886,6 +1056,9 @@ function defaultConsoleOutput(msg, level) {
886
1056
  else console.log(msg);
887
1057
  }
888
1058
 
1059
+ // src/services/worktree-sync.service.ts
1060
+ import pLimit2 from "p-limit";
1061
+
889
1062
  // src/utils/lfs-error.ts
890
1063
  function getErrorMessage(error) {
891
1064
  if (error instanceof Error) {
@@ -916,6 +1089,31 @@ function isMissingRemoteRefError(errorMessage) {
916
1089
  return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
917
1090
  }
918
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
+
919
1117
  // src/utils/retry.ts
920
1118
  var DEFAULT_OPTIONS = {
921
1119
  maxAttempts: "unlimited",
@@ -986,7 +1184,7 @@ async function retry(fn, options = {}) {
986
1184
  const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
987
1185
  const delay = baseDelay + jitter;
988
1186
  opts.onRetry(error, attempt, lfsContext);
989
- await new Promise((resolve11) => setTimeout(resolve11, delay));
1187
+ await new Promise((resolve13) => setTimeout(resolve13, delay));
990
1188
  attempt++;
991
1189
  }
992
1190
  }
@@ -1057,7 +1255,7 @@ var PhaseTimer = class {
1057
1255
  return results;
1058
1256
  }
1059
1257
  };
1060
- function formatDuration(ms) {
1258
+ function formatDuration2(ms) {
1061
1259
  if (ms < 1e3) {
1062
1260
  return `${ms}ms`;
1063
1261
  }
@@ -1079,7 +1277,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
1079
1277
  }
1080
1278
  });
1081
1279
  table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
1082
- table.push(["Total Sync", formatDuration(totalDuration), ""]);
1280
+ table.push(["Total Sync", formatDuration2(totalDuration), ""]);
1083
1281
  for (let i = 0; i < phaseResults.length; i++) {
1084
1282
  const result = phaseResults[i];
1085
1283
  const isLast = i === phaseResults.length - 1;
@@ -1087,14 +1285,14 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
1087
1285
  const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
1088
1286
  const name = ` ${prefix} ${result.name}${countStr}`;
1089
1287
  const efficiency = result.efficiency ? `${result.efficiency}%` : "";
1090
- table.push([name, formatDuration(result.duration), efficiency]);
1288
+ table.push([name, formatDuration2(result.duration), efficiency]);
1091
1289
  }
1092
1290
  return table.toString();
1093
1291
  }
1094
1292
 
1095
1293
  // src/services/clone-sync.service.ts
1096
1294
  import * as fs3 from "fs/promises";
1097
- import * as path4 from "path";
1295
+ import * as path5 from "path";
1098
1296
  import simpleGit from "simple-git";
1099
1297
 
1100
1298
  // src/utils/git-progress.ts
@@ -1123,7 +1321,7 @@ function makeGitProgressHandler(logger, emitProgress) {
1123
1321
 
1124
1322
  // src/services/file-copy.service.ts
1125
1323
  import * as fs2 from "fs/promises";
1126
- import * as path3 from "path";
1324
+ import * as path4 from "path";
1127
1325
  import { glob } from "glob";
1128
1326
  var DEFAULT_IGNORE_PATTERNS = [
1129
1327
  "**/node_modules/**",
@@ -1150,8 +1348,8 @@ var FileCopyService = class {
1150
1348
  }
1151
1349
  const filesToCopy = await this.expandPatterns(sourceDir, patterns);
1152
1350
  for (const relativePath of filesToCopy) {
1153
- const sourcePath = path3.join(sourceDir, relativePath);
1154
- const destPath = path3.join(destDir, relativePath);
1351
+ const sourcePath = path4.join(sourceDir, relativePath);
1352
+ const destPath = path4.join(destDir, relativePath);
1155
1353
  try {
1156
1354
  const copied = await this.copyFile(sourcePath, destPath);
1157
1355
  if (copied) {
@@ -1190,7 +1388,7 @@ var FileCopyService = class {
1190
1388
  if (await fileExists(destPath)) {
1191
1389
  return false;
1192
1390
  }
1193
- const destDir = path3.dirname(destPath);
1391
+ const destDir = path4.dirname(destPath);
1194
1392
  await fs2.mkdir(destDir, { recursive: true });
1195
1393
  await fs2.copyFile(sourcePath, destPath);
1196
1394
  return true;
@@ -1252,7 +1450,7 @@ var BranchCreatedActionsService = class {
1252
1450
  function formatCloneSkipReason(reason) {
1253
1451
  switch (reason.kind) {
1254
1452
  case "branch_mismatch":
1255
- 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`;
1256
1454
  case "head_unreadable":
1257
1455
  return `could not read HEAD: ${reason.error}`;
1258
1456
  case "dirty_tree":
@@ -1316,20 +1514,21 @@ var SyncOutcomeAccumulator = class {
1316
1514
  constructor(options) {
1317
1515
  this.options = options;
1318
1516
  }
1517
+ options;
1319
1518
  counts = cloneCounts(EMPTY_COUNTS);
1320
1519
  actions = [];
1321
1520
  add(action) {
1322
1521
  this.actions.push(action);
1323
1522
  this.counts[countKeyFor(action)]++;
1324
1523
  }
1325
- recordCreated(branch, path16) {
1326
- this.add({ kind: "created", branch, path: path16 });
1524
+ recordCreated(branch, path22) {
1525
+ this.add({ kind: "created", branch, path: path22 });
1327
1526
  }
1328
- recordRemoved(branch, path16) {
1329
- this.add({ kind: "removed", branch, path: path16 });
1527
+ recordRemoved(branch, path22, warning) {
1528
+ this.add({ kind: "removed", branch, path: path22, ...warning !== void 0 && { warning } });
1330
1529
  }
1331
- recordUpdated(branch, path16, reason) {
1332
- this.add({ kind: "updated", branch, path: path16, reason });
1530
+ recordUpdated(branch, path22, reason) {
1531
+ this.add({ kind: "updated", branch, path: path22, reason });
1333
1532
  }
1334
1533
  recordNoop(scope, reason, details) {
1335
1534
  this.add({ kind: "noop", scope, reason, ...details });
@@ -1337,8 +1536,8 @@ var SyncOutcomeAccumulator = class {
1337
1536
  recordSkipped(scope, reason, details) {
1338
1537
  this.add({ kind: "skipped", scope, reason, ...details });
1339
1538
  }
1340
- recordPreservedDiverged(branch, path16, preservedPath) {
1341
- this.add({ kind: "preserved-diverged", branch, path: path16, preservedPath });
1539
+ recordPreservedDiverged(branch, path22, preservedPath) {
1540
+ this.add({ kind: "preserved-diverged", branch, path: path22, preservedPath });
1342
1541
  }
1343
1542
  recordFailed(scope, error, details = {}) {
1344
1543
  this.add({ kind: "failed", scope, error, ...details });
@@ -1391,7 +1590,6 @@ function cloneSkipToOutcomeAction(reason, details = {}) {
1391
1590
  }
1392
1591
 
1393
1592
  // src/services/clone-sync.service.ts
1394
- var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
1395
1593
  var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
1396
1594
  var CloneSyncService = class {
1397
1595
  constructor(config, gitService, logger, options = {}) {
@@ -1402,6 +1600,9 @@ var CloneSyncService = class {
1402
1600
  this.progressEmitter = options.progressEmitter;
1403
1601
  this.onSkip = options.onSkip;
1404
1602
  }
1603
+ config;
1604
+ gitService;
1605
+ logger;
1405
1606
  initialized = false;
1406
1607
  resolvedBranch = null;
1407
1608
  branchCreatedActions;
@@ -1422,8 +1623,8 @@ var CloneSyncService = class {
1422
1623
  this.pendingInitSkip = null;
1423
1624
  }
1424
1625
  async getWorktrees() {
1425
- const worktreeDir = path4.resolve(this.config.worktreeDir);
1426
- 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))) {
1427
1628
  return [];
1428
1629
  }
1429
1630
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
@@ -1507,40 +1708,27 @@ var CloneSyncService = class {
1507
1708
  return env;
1508
1709
  }
1509
1710
  buildCloneArgs(branch) {
1510
- const args = ["--branch", branch, "--progress"];
1711
+ const args = ["--branch", branch, "--single-branch", "--no-tags", "--progress"];
1511
1712
  if (this.config.depth !== void 0) {
1512
- args.push("--depth", String(this.config.depth), "--no-single-branch");
1713
+ args.push("--depth", String(this.config.depth));
1513
1714
  }
1514
1715
  return args;
1515
1716
  }
1516
- async buildFetchArgs(git) {
1517
- 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"];
1518
1722
  if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
1519
1723
  args.push("--depth", String(this.config.depth));
1520
1724
  }
1725
+ args.push(this.getBranchRefspec(branch));
1521
1726
  return args;
1522
1727
  }
1523
- async ensureAllRemoteBranchesRefspec(git) {
1524
- let fetchRefspecs = [];
1525
- try {
1526
- const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
1527
- fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1528
- } catch {
1529
- fetchRefspecs = [];
1530
- }
1531
- if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
1532
- const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
1533
- this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
1534
- await git.raw(["remote", "set-branches", "origin", "*"]);
1535
- for (const refspec of customRefspecs) {
1536
- await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
1537
- }
1538
- }
1539
- isOriginRemoteBranchTrackingRefspec(refspec) {
1540
- const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
1541
- if (withoutForce.startsWith("^")) return false;
1542
- const [source, destination] = withoutForce.split(":");
1543
- 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);
1544
1732
  }
1545
1733
  recordMissingRemoteRefSkip(branch) {
1546
1734
  this.recordSkip(
@@ -1549,7 +1737,10 @@ var CloneSyncService = class {
1549
1737
  `Skipping '${this.repoName}': origin/${branch} is missing`
1550
1738
  );
1551
1739
  }
1552
- 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
+ };
1553
1744
  try {
1554
1745
  await git.fetch(fetchArgs);
1555
1746
  return { skipped: false };
@@ -1566,14 +1757,14 @@ var CloneSyncService = class {
1566
1757
  return { skipped: false };
1567
1758
  } catch (retryError) {
1568
1759
  if (isMissingRemoteRefError(getErrorMessage(retryError))) {
1569
- this.recordMissingRemoteRefSkip(branch);
1760
+ recordMissing();
1570
1761
  return { skipped: true };
1571
1762
  }
1572
1763
  throw retryError;
1573
1764
  }
1574
1765
  }
1575
1766
  if (isMissingRemoteRefError(message)) {
1576
- this.recordMissingRemoteRefSkip(branch);
1767
+ recordMissing();
1577
1768
  return { skipped: true };
1578
1769
  }
1579
1770
  throw fetchError;
@@ -1601,7 +1792,7 @@ var CloneSyncService = class {
1601
1792
  this.logger.info(
1602
1793
  `[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
1603
1794
  );
1604
- await git.fetch(["--unshallow"]);
1795
+ await git.fetch(["--unshallow", "--no-tags"]);
1605
1796
  }
1606
1797
  getDeepenTargets() {
1607
1798
  const configuredDepth = this.config.depth;
@@ -1621,8 +1812,9 @@ var CloneSyncService = class {
1621
1812
  "--depth",
1622
1813
  String(targetDepth),
1623
1814
  "--prune",
1815
+ "--no-tags",
1624
1816
  "--progress",
1625
- `+refs/heads/${branch}:refs/remotes/origin/${branch}`
1817
+ this.getBranchRefspec(branch)
1626
1818
  ]);
1627
1819
  }
1628
1820
  async resolveBranch() {
@@ -1639,6 +1831,153 @@ var CloneSyncService = class {
1639
1831
  this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
1640
1832
  return this.resolvedBranch;
1641
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
+ }
1642
1981
  async initialize(outcome) {
1643
1982
  return this.withOutcome(outcome, () => this.initializeInternal());
1644
1983
  }
@@ -1662,7 +2001,7 @@ var CloneSyncService = class {
1662
2001
  return;
1663
2002
  }
1664
2003
  const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1665
- await this.ensureAllRemoteBranchesRefspec(git);
2004
+ await this.configureSingleBranchRemote(git, branch);
1666
2005
  this.initialized = true;
1667
2006
  this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
1668
2007
  return;
@@ -1690,7 +2029,7 @@ var CloneSyncService = class {
1690
2029
  throw error;
1691
2030
  }
1692
2031
  const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1693
- await this.ensureAllRemoteBranchesRefspec(worktreeGit);
2032
+ await this.configureSingleBranchRemote(worktreeGit, branch);
1694
2033
  this.logger.info(`\u2705 Clone successful.`);
1695
2034
  this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
1696
2035
  if (this.config.sparseCheckout) {
@@ -1778,7 +2117,7 @@ var CloneSyncService = class {
1778
2117
  return;
1779
2118
  }
1780
2119
  const looksIncomplete = entries.every((e) => e.startsWith("."));
1781
- 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"));
1782
2121
  if (looksIncomplete && !hasUsableGit) {
1783
2122
  try {
1784
2123
  await fs3.rm(worktreeDir, { recursive: true, force: true });
@@ -1793,7 +2132,7 @@ var CloneSyncService = class {
1793
2132
  }
1794
2133
  }
1795
2134
  getInitMarkerPath(worktreeDir) {
1796
- 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);
1797
2136
  }
1798
2137
  async runInitialFileCopy(worktreeDir, branch) {
1799
2138
  const marker = this.getInitMarkerPath(worktreeDir);
@@ -1845,7 +2184,7 @@ var CloneSyncService = class {
1845
2184
  if (currentBranch !== branch) {
1846
2185
  this.recordSkip(
1847
2186
  { kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
1848
- `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.`,
1849
2188
  `Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
1850
2189
  );
1851
2190
  return;
@@ -1860,13 +2199,13 @@ var CloneSyncService = class {
1860
2199
  return;
1861
2200
  }
1862
2201
  await this.unshallowIfDepthRemoved(git);
1863
- await this.ensureAllRemoteBranchesRefspec(git);
1864
- const fetchArgs = await this.buildFetchArgs(git);
1865
- 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}'` });
1866
2205
  if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
1867
2206
  return;
1868
2207
  }
1869
- this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
2208
+ this.emitProgress({ phase: "fetch", message: `Fetched origin/${branch} for '${this.repoName}'` });
1870
2209
  if (!await this.hasRemoteBranch(git, branch)) {
1871
2210
  this.recordSkip(
1872
2211
  { kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
@@ -1956,55 +2295,225 @@ var CloneSyncService = class {
1956
2295
  }
1957
2296
  };
1958
2297
 
1959
- // src/services/git.service.ts
1960
- import * as fs6 from "fs/promises";
1961
- import * as path8 from "path";
1962
- 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";
1963
2302
 
1964
- // src/utils/worktree-list-parser.ts
1965
- function parseWorktreeListPorcelain(output) {
1966
- const worktrees = [];
1967
- let current = {};
1968
- const flush = () => {
1969
- if (!current.path) {
1970
- current = {};
1971
- 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();
1972
2315
  }
1973
- worktrees.push({
1974
- path: current.path,
1975
- branch: current.branch ?? null,
1976
- head: current.head ?? null,
1977
- detached: current.detached ?? false,
1978
- prunable: current.prunable ?? false,
1979
- locked: current.locked ?? false
1980
- });
1981
- current = {};
1982
- };
1983
- for (const line of output.split("\n")) {
1984
- if (line.startsWith("worktree ")) {
1985
- flush();
1986
- current.path = line.substring("worktree ".length);
1987
- } else if (line.startsWith("branch ")) {
1988
- current.branch = line.substring("branch ".length).replace("refs/heads/", "");
1989
- } else if (line.startsWith("HEAD ")) {
1990
- current.head = line.substring("HEAD ".length);
1991
- } else if (line === "detached") {
1992
- current.detached = true;
1993
- } else if (line === "prunable" || line.startsWith("prunable ")) {
1994
- current.prunable = true;
1995
- } else if (line === "locked" || line.startsWith("locked ")) {
1996
- current.locked = true;
1997
- } else if (line.trim() === "") {
1998
- 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);
1999
2329
  }
2000
2330
  }
2001
- flush();
2002
- return worktrees;
2003
2331
  }
2004
2332
 
2005
- // src/services/sparse-checkout.service.ts
2006
- import * as path5 from "path";
2007
- import simpleGit2 from "simple-git";
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";
2008
2517
  var SparseCheckoutService = class {
2009
2518
  logger;
2010
2519
  gitFactory;
@@ -2012,7 +2521,7 @@ var SparseCheckoutService = class {
2012
2521
  matcherCache = /* @__PURE__ */ new WeakMap();
2013
2522
  constructor(logger, gitFactory) {
2014
2523
  this.logger = logger ?? Logger.createDefault();
2015
- this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
2524
+ this.gitFactory = gitFactory ?? ((p) => simpleGit3(p));
2016
2525
  }
2017
2526
  updateLogger(logger) {
2018
2527
  this.logger = logger;
@@ -2138,7 +2647,7 @@ var SparseCheckoutService = class {
2138
2647
  for (const pat of matcher.patterns) {
2139
2648
  if (p === pat || p.startsWith(pat + "/")) return true;
2140
2649
  }
2141
- return matcher.ancestorDirs.has(path5.posix.dirname(p));
2650
+ return matcher.ancestorDirs.has(path8.posix.dirname(p));
2142
2651
  });
2143
2652
  }
2144
2653
  getMatcher(cfg) {
@@ -2165,9 +2674,9 @@ var SparseCheckoutService = class {
2165
2674
  };
2166
2675
 
2167
2676
  // src/services/worktree-metadata.service.ts
2168
- import * as fs4 from "fs/promises";
2169
- import * as path6 from "path";
2170
- import simpleGit3 from "simple-git";
2677
+ import * as fs7 from "fs/promises";
2678
+ import * as path9 from "path";
2679
+ import simpleGit4 from "simple-git";
2171
2680
  var WorktreeMetadataService = class {
2172
2681
  logger;
2173
2682
  constructor(logger) {
@@ -2179,7 +2688,7 @@ var WorktreeMetadataService = class {
2179
2688
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
2180
2689
  */
2181
2690
  getWorktreeDirectoryName(worktreePath) {
2182
- return path6.basename(worktreePath);
2691
+ return path9.basename(worktreePath);
2183
2692
  }
2184
2693
  async getMetadataPath(bareRepoPath, worktreeName) {
2185
2694
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -2187,7 +2696,7 @@ var WorktreeMetadataService = class {
2187
2696
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
2188
2697
  );
2189
2698
  }
2190
- return path6.join(
2699
+ return path9.join(
2191
2700
  bareRepoPath,
2192
2701
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
2193
2702
  worktreeName,
@@ -2200,31 +2709,13 @@ var WorktreeMetadataService = class {
2200
2709
  }
2201
2710
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
2202
2711
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2203
- await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
2204
- const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
2205
- let renamed = false;
2206
- try {
2207
- await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
2208
- try {
2209
- await fs4.rename(tmpPath, metadataPath);
2210
- renamed = true;
2211
- } catch (err) {
2212
- if (err.code === ERROR_MESSAGES.EXDEV) {
2213
- await fs4.copyFile(tmpPath, metadataPath);
2214
- } else {
2215
- throw err;
2216
- }
2217
- }
2218
- } finally {
2219
- if (!renamed) {
2220
- await fs4.unlink(tmpPath).catch(() => void 0);
2221
- }
2222
- }
2712
+ await fs7.mkdir(path9.dirname(metadataPath), { recursive: true });
2713
+ await atomicWriteFile(metadataPath, JSON.stringify(metadata, null, 2));
2223
2714
  }
2224
2715
  async loadMetadata(bareRepoPath, worktreeName) {
2225
2716
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2226
2717
  try {
2227
- const content = await fs4.readFile(metadataPath, "utf-8");
2718
+ const content = await fs7.readFile(metadataPath, "utf-8");
2228
2719
  return JSON.parse(content);
2229
2720
  } catch {
2230
2721
  return null;
@@ -2233,7 +2724,7 @@ var WorktreeMetadataService = class {
2233
2724
  async loadMetadataFromPath(bareRepoPath, worktreePath) {
2234
2725
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2235
2726
  try {
2236
- const content = await fs4.readFile(metadataPath, "utf-8");
2727
+ const content = await fs7.readFile(metadataPath, "utf-8");
2237
2728
  const metadata = JSON.parse(content);
2238
2729
  if (!await this.validateMetadata(metadata)) {
2239
2730
  this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
@@ -2247,7 +2738,7 @@ var WorktreeMetadataService = class {
2247
2738
  async deleteMetadata(bareRepoPath, worktreeName) {
2248
2739
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2249
2740
  try {
2250
- await fs4.unlink(metadataPath);
2741
+ await fs7.unlink(metadataPath);
2251
2742
  } catch (error) {
2252
2743
  if (error.code !== "ENOENT") {
2253
2744
  throw error;
@@ -2257,7 +2748,7 @@ var WorktreeMetadataService = class {
2257
2748
  async deleteMetadataFromPath(bareRepoPath, worktreePath) {
2258
2749
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2259
2750
  try {
2260
- await fs4.unlink(metadataPath);
2751
+ await fs7.unlink(metadataPath);
2261
2752
  } catch (error) {
2262
2753
  if (error.code !== "ENOENT") {
2263
2754
  throw error;
@@ -2291,7 +2782,7 @@ var WorktreeMetadataService = class {
2291
2782
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
2292
2783
  this.logger.info(` Attempting to create initial metadata...`);
2293
2784
  try {
2294
- const worktreeGit = simpleGit3(worktreePath);
2785
+ const worktreeGit = simpleGit4(worktreePath);
2295
2786
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
2296
2787
  const branchSummary = await worktreeGit.branch();
2297
2788
  const actualBranchName = branchSummary.current;
@@ -2338,6 +2829,25 @@ var WorktreeMetadataService = class {
2338
2829
  }
2339
2830
  await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
2340
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
+ }
2341
2851
  async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
2342
2852
  const metadata = {
2343
2853
  lastSyncCommit: commit,
@@ -2392,9 +2902,9 @@ var WorktreeMetadataService = class {
2392
2902
  };
2393
2903
 
2394
2904
  // src/services/worktree-status.service.ts
2395
- import * as fs5 from "fs/promises";
2396
- import * as path7 from "path";
2397
- import simpleGit4 from "simple-git";
2905
+ import * as fs8 from "fs/promises";
2906
+ import * as path10 from "path";
2907
+ import simpleGit5 from "simple-git";
2398
2908
  var OPERATION_FILES = [
2399
2909
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
2400
2910
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -2408,6 +2918,7 @@ var WorktreeStatusService = class {
2408
2918
  this.config = config;
2409
2919
  this.logger = logger ?? Logger.createDefault();
2410
2920
  }
2921
+ config;
2411
2922
  gitInstances = /* @__PURE__ */ new Map();
2412
2923
  logger;
2413
2924
  async checkWorktreeStatus(worktreePath) {
@@ -2424,8 +2935,9 @@ var WorktreeStatusService = class {
2424
2935
  }
2425
2936
  return true;
2426
2937
  }
2427
- async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
2428
- if (!await fileExists(worktreePath)) {
2938
+ async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit, lastKnownRemoteTip) {
2939
+ const pathProbe = await probePathExists(worktreePath);
2940
+ if (pathProbe === "missing") {
2429
2941
  return {
2430
2942
  isClean: true,
2431
2943
  hasUnpushedCommits: false,
@@ -2433,25 +2945,44 @@ var WorktreeStatusService = class {
2433
2945
  hasOperationInProgress: false,
2434
2946
  hasModifiedSubmodules: false,
2435
2947
  upstreamGone: false,
2948
+ fullyPushedUpstreamDeleted: false,
2436
2949
  canRemove: true,
2437
2950
  reasons: []
2438
2951
  };
2439
2952
  }
2440
- 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);
2441
2967
  const isClean = this.deriveIsClean(snap);
2442
- 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;
2443
2973
  const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
2444
- const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
2974
+ const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null || snap.operationProbeUnknown;
2445
2975
  const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
2446
2976
  const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
2447
2977
  const reasons = [];
2448
2978
  if (!isClean) reasons.push("uncommitted changes");
2449
- if (hasUnpushedCommits) reasons.push("unpushed commits");
2979
+ if (hasUnpushedCommits && !fullyPushedUpstreamDeleted) reasons.push("unpushed commits");
2450
2980
  if (hasStashedChanges) reasons.push("stashed changes");
2451
2981
  if (hasOperationInProgress) reasons.push("operation in progress");
2452
2982
  if (hasModifiedSubmodules) reasons.push("modified submodules");
2453
2983
  if (upstreamGone) reasons.push("upstream gone");
2454
- 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;
2455
2986
  const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
2456
2987
  return {
2457
2988
  isClean,
@@ -2460,12 +2991,13 @@ var WorktreeStatusService = class {
2460
2991
  hasOperationInProgress,
2461
2992
  hasModifiedSubmodules,
2462
2993
  upstreamGone,
2994
+ fullyPushedUpstreamDeleted,
2463
2995
  canRemove,
2464
2996
  reasons,
2465
2997
  details
2466
2998
  };
2467
2999
  }
2468
- async collectSnapshot(worktreePath, lastSyncCommit) {
3000
+ async collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
2469
3001
  const git = this.createGitInstance(worktreePath);
2470
3002
  const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
2471
3003
  git.status().catch((e) => {
@@ -2490,19 +3022,33 @@ var WorktreeStatusService = class {
2490
3022
  const currentBranch = branchResult?.current ?? null;
2491
3023
  const detached = !branchResult?.current || Boolean(branchResult?.detached);
2492
3024
  let upstream = null;
2493
- let unpushedCount = null;
3025
+ let unpushedAnyRemoteCount = null;
3026
+ let sinceSyncCount = null;
3027
+ let headPushedToRecordedTip = null;
2494
3028
  if (!detached && currentBranch) {
2495
- const revListArgs = lastSyncCommit ? ["rev-list", "--count", `${lastSyncCommit}..HEAD`] : ["rev-list", "--count", currentBranch, "--not", "--remotes"];
2496
- const [upstreamResult, unpushedResult] = await Promise.all([
3029
+ const [upstreamResult, anyRemoteResult, sinceSyncResult, recordedTipResult] = await Promise.all([
2497
3030
  git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
2498
3031
  (raw) => ({ ok: true, value: raw }),
2499
3032
  (error) => ({ ok: false, error })
2500
3033
  ),
2501
- git.raw(revListArgs).then(
3034
+ git.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]).then(
2502
3035
  (raw) => ({ ok: true, value: raw }),
2503
3036
  (error) => ({ ok: false, error })
2504
- )
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)
2505
3050
  ]);
3051
+ headPushedToRecordedTip = recordedTipResult;
2506
3052
  if (upstreamResult.ok) {
2507
3053
  upstream = upstreamResult.value.trim() || null;
2508
3054
  } else {
@@ -2511,13 +3057,20 @@ var WorktreeStatusService = class {
2511
3057
  this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
2512
3058
  }
2513
3059
  }
2514
- if (unpushedResult.ok) {
2515
- unpushedCount = parseInt(unpushedResult.value.trim(), 10);
3060
+ if (anyRemoteResult.ok) {
3061
+ unpushedAnyRemoteCount = this.parseCount(anyRemoteResult.value);
2516
3062
  } else {
2517
- 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
+ }
2518
3071
  }
2519
3072
  }
2520
- const operationFile = gitDirResult ? await this.detectOperationFile(gitDirResult) : null;
3073
+ const operationProbe = gitDirResult ? await this.detectOperationFile(gitDirResult) : { file: null, unknown: false };
2521
3074
  let untrackedNotIgnored = [];
2522
3075
  if (status && status.not_added.length > 0) {
2523
3076
  try {
@@ -2533,14 +3086,22 @@ var WorktreeStatusService = class {
2533
3086
  detached,
2534
3087
  remoteBranches: remoteBranchesResult?.all ?? [],
2535
3088
  upstream,
2536
- unpushedCount,
3089
+ unpushedAnyRemoteCount,
3090
+ sinceSyncCount,
3091
+ sinceSyncChecked: lastSyncCommit !== void 0,
3092
+ headPushedToRecordedTip,
2537
3093
  stashTotal: stashResult?.total ?? null,
2538
3094
  submoduleStatus: submoduleResult,
2539
- operationFile,
3095
+ operationFile: operationProbe.file,
3096
+ operationProbeUnknown: operationProbe.unknown,
2540
3097
  gitDir: gitDirResult,
2541
3098
  untrackedNotIgnored
2542
3099
  };
2543
3100
  }
3101
+ parseCount(raw) {
3102
+ const count = parseInt(raw.trim(), 10);
3103
+ return Number.isNaN(count) ? null : count;
3104
+ }
2544
3105
  deriveIsClean(snap) {
2545
3106
  const status = snap.status;
2546
3107
  if (!status) return false;
@@ -2580,7 +3141,8 @@ var WorktreeStatusService = class {
2580
3141
  if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
2581
3142
  }
2582
3143
  if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
2583
- 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;
2584
3146
  if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
2585
3147
  const opType = this.operationTypeFromFile(snap.operationFile);
2586
3148
  if (opType) details.operationType = opType;
@@ -2595,34 +3157,37 @@ var WorktreeStatusService = class {
2595
3157
  async detectOperationFile(gitDir) {
2596
3158
  const results = await Promise.all(
2597
3159
  OPERATION_FILES.map(
2598
- ({ file }) => fs5.access(path7.join(gitDir, file)).then(
2599
- () => true,
2600
- () => false
3160
+ ({ file }) => fs8.access(path10.join(gitDir, file)).then(
3161
+ () => "present",
3162
+ (error) => error.code === "ENOENT" ? "absent" : "unknown"
2601
3163
  )
2602
3164
  )
2603
3165
  );
2604
- const idx = results.findIndex(Boolean);
2605
- 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") };
2606
3169
  }
2607
3170
  async hasUnpushedCommits(worktreePath, lastSyncCommit) {
2608
3171
  const worktreeGit = this.createGitInstance(worktreePath);
2609
3172
  try {
2610
3173
  if (await this.isDetachedHead(worktreeGit)) {
2611
- return false;
3174
+ return true;
2612
3175
  }
2613
3176
  const branchSummary = await worktreeGit.branch();
2614
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
+ }
2615
3183
  if (lastSyncCommit) {
2616
- try {
2617
- const newCommitsResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
2618
- const newCommitsCount = parseInt(newCommitsResult.trim(), 10);
2619
- return newCommitsCount > 0;
2620
- } 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;
2621
3188
  }
2622
3189
  }
2623
- const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
2624
- const unpushedCount = parseInt(result.trim(), 10);
2625
- return unpushedCount > 0;
3190
+ return false;
2626
3191
  } catch (error) {
2627
3192
  this.logger.error(`Error checking unpushed commits`, error);
2628
3193
  return true;
@@ -2678,14 +3243,15 @@ var WorktreeStatusService = class {
2678
3243
  async hasOperationInProgress(worktreePath) {
2679
3244
  try {
2680
3245
  const gitDir = await this.resolveGitDir(worktreePath);
2681
- return await this.detectOperationFile(gitDir) !== null;
3246
+ const probe = await this.detectOperationFile(gitDir);
3247
+ return probe.unknown || probe.file !== null;
2682
3248
  } catch (error) {
2683
3249
  this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
2684
3250
  return true;
2685
3251
  }
2686
3252
  }
2687
- async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
2688
- const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
3253
+ async validateWorktreeForRemoval(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
3254
+ const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit, lastKnownRemoteTip);
2689
3255
  if (!status.canRemove) {
2690
3256
  throw new WorktreeNotCleanError(worktreePath, status.reasons);
2691
3257
  }
@@ -2716,14 +3282,14 @@ var WorktreeStatusService = class {
2716
3282
  }
2717
3283
  }
2718
3284
  async resolveGitDir(worktreePath) {
2719
- const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
3285
+ const gitPath = path10.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
2720
3286
  try {
2721
- const stat4 = await fs5.stat(gitPath);
3287
+ const stat4 = await fs8.stat(gitPath);
2722
3288
  if (stat4.isFile()) {
2723
- const content = await fs5.readFile(gitPath, "utf-8");
3289
+ const content = await fs8.readFile(gitPath, "utf-8");
2724
3290
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
2725
3291
  if (gitdirMatch) {
2726
- return path7.resolve(worktreePath, gitdirMatch[1].trim());
3292
+ return path10.resolve(worktreePath, gitdirMatch[1].trim());
2727
3293
  }
2728
3294
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
2729
3295
  }
@@ -2737,10 +3303,10 @@ var WorktreeStatusService = class {
2737
3303
  }
2738
3304
  }
2739
3305
  createGitInstance(worktreePath) {
2740
- const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
3306
+ const key = `${path10.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
2741
3307
  let git = this.gitInstances.get(key);
2742
3308
  if (!git) {
2743
- 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);
2744
3310
  this.gitInstances.set(key, git);
2745
3311
  }
2746
3312
  return git;
@@ -2761,11 +3327,13 @@ var GitService = class {
2761
3327
  this.progressEmitter = progressEmitter;
2762
3328
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
2763
3329
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
2764
- this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3330
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
2765
3331
  this.metadataService = new WorktreeMetadataService(this.logger);
2766
3332
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
2767
3333
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
2768
3334
  }
3335
+ config;
3336
+ progressEmitter;
2769
3337
  git = null;
2770
3338
  bareRepoPath;
2771
3339
  mainWorktreePath;
@@ -2789,10 +3357,10 @@ var GitService = class {
2789
3357
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
2790
3358
  }
2791
3359
  getCachedGit(dirPath, useLfsSkip = false) {
2792
- const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3360
+ const key = `${path11.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
2793
3361
  let git = this.gitInstances.get(key);
2794
3362
  if (!git) {
2795
- const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
3363
+ const base = simpleGit6(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2796
3364
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
2797
3365
  this.gitInstances.set(key, git);
2798
3366
  }
@@ -2812,11 +3380,11 @@ var GitService = class {
2812
3380
  async initialize() {
2813
3381
  const { repoUrl } = this.config;
2814
3382
  try {
2815
- await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
3383
+ await fs9.access(path11.join(this.bareRepoPath, "HEAD"));
2816
3384
  } catch {
2817
3385
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
2818
- await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
2819
- 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()));
2820
3388
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
2821
3389
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
2822
3390
  this.logger.info("\u2705 Clone successful.");
@@ -2834,17 +3402,17 @@ var GitService = class {
2834
3402
  this.logger.info("Fetching remote branches...");
2835
3403
  await bareGit.fetch(["--all", "--progress"]);
2836
3404
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
2837
- this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
3405
+ this.mainWorktreePath = path11.join(this.config.worktreeDir, this.defaultBranch);
2838
3406
  let needsMainWorktree = true;
2839
3407
  try {
2840
3408
  const worktrees = await this.getWorktreesFromBare(bareGit);
2841
- 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));
2842
3410
  } catch {
2843
3411
  }
2844
3412
  if (needsMainWorktree) {
2845
3413
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
2846
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
2847
- const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
3414
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
3415
+ const absoluteWorktreePath = path11.resolve(this.mainWorktreePath);
2848
3416
  const branches = await bareGit.branch();
2849
3417
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
2850
3418
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -2880,7 +3448,7 @@ var GitService = class {
2880
3448
  }
2881
3449
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
2882
3450
  const mainWorktreeRegistered = updatedWorktrees.some(
2883
- (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
3451
+ (w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath)
2884
3452
  );
2885
3453
  if (!mainWorktreeRegistered) {
2886
3454
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -2907,7 +3475,7 @@ var GitService = class {
2907
3475
  return this.bareRepoPath;
2908
3476
  }
2909
3477
  async getRemoteDefaultBranch(repoUrl) {
2910
- const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
3478
+ const git = simpleGit6(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2911
3479
  try {
2912
3480
  const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
2913
3481
  const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
@@ -2991,7 +3559,7 @@ var GitService = class {
2991
3559
  return branches;
2992
3560
  }
2993
3561
  async verifyLfsFilesDownloaded(worktreePath, branchName) {
2994
- 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);
2995
3563
  try {
2996
3564
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
2997
3565
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -3002,7 +3570,7 @@ var GitService = class {
3002
3570
  const existence = await Promise.all(
3003
3571
  lfsFileList.map(async (f) => {
3004
3572
  try {
3005
- await fs6.access(path8.join(worktreePath, f));
3573
+ await fs9.access(path11.join(worktreePath, f));
3006
3574
  return f;
3007
3575
  } catch {
3008
3576
  return null;
@@ -3030,9 +3598,9 @@ var GitService = class {
3030
3598
  let allDownloaded = true;
3031
3599
  const notDownloaded = [];
3032
3600
  for (const file of samplesToCheck) {
3033
- const filePath = path8.join(worktreePath, file);
3601
+ const filePath = path11.join(worktreePath, file);
3034
3602
  try {
3035
- const handle = await fs6.open(filePath, "r");
3603
+ const handle = await fs9.open(filePath, "r");
3036
3604
  try {
3037
3605
  const buffer = Buffer.alloc(200);
3038
3606
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -3057,7 +3625,7 @@ var GitService = class {
3057
3625
  }
3058
3626
  retries++;
3059
3627
  if (retries < maxRetries) {
3060
- await new Promise((resolve11) => setTimeout(resolve11, retryDelay));
3628
+ await new Promise((resolve13) => setTimeout(resolve13, retryDelay));
3061
3629
  }
3062
3630
  }
3063
3631
  this.logger.warn(
@@ -3119,20 +3687,23 @@ var GitService = class {
3119
3687
  }
3120
3688
  async addWorktree(branchName, worktreePath) {
3121
3689
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
3122
- const absoluteWorktreePath = path8.resolve(worktreePath);
3123
- await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
3690
+ const absoluteWorktreePath = path11.resolve(worktreePath);
3691
+ await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
3124
3692
  try {
3125
- await fs6.access(absoluteWorktreePath);
3693
+ await fs9.access(absoluteWorktreePath);
3126
3694
  const worktrees = await this.getWorktreesFromBare(bareGit);
3127
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3695
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
3128
3696
  if (isValidWorktree) {
3129
3697
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3130
3698
  return;
3131
3699
  } else {
3132
3700
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
3133
- 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;
3134
3706
  }
3135
- } catch {
3136
3707
  }
3137
3708
  let createdNewBranch = false;
3138
3709
  try {
@@ -3169,17 +3740,14 @@ var GitService = class {
3169
3740
  }
3170
3741
  if (errorMessage.includes("already registered worktree")) {
3171
3742
  const worktrees = await this.getWorktreesFromBare(bareGit);
3172
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3743
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
3173
3744
  if (existingWorktree && !existingWorktree.isPrunable) {
3174
3745
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
3175
3746
  return;
3176
3747
  }
3177
3748
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
3178
3749
  await bareGit.raw(["worktree", "prune"]);
3179
- try {
3180
- await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3181
- } catch {
3182
- }
3750
+ await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
3183
3751
  let retryCreatedNewBranch = false;
3184
3752
  try {
3185
3753
  const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
@@ -3213,17 +3781,20 @@ var GitService = class {
3213
3781
  }
3214
3782
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
3215
3783
  try {
3216
- await fs6.access(absoluteWorktreePath);
3784
+ await fs9.access(absoluteWorktreePath);
3217
3785
  const worktrees = await this.getWorktreesFromBare(bareGit);
3218
- const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3786
+ const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
3219
3787
  if (isValidWorktree) {
3220
3788
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3221
3789
  return;
3222
3790
  } else {
3223
3791
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
3224
- 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;
3225
3797
  }
3226
- } catch {
3227
3798
  }
3228
3799
  try {
3229
3800
  const useNoCheckout = !!this.config.sparseCheckout;
@@ -3245,7 +3816,7 @@ var GitService = class {
3245
3816
  const fallbackErrorMessage = getErrorMessage(fallbackError);
3246
3817
  if (fallbackErrorMessage.includes("already registered worktree")) {
3247
3818
  const worktrees = await this.getWorktreesFromBare(bareGit);
3248
- const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3819
+ const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
3249
3820
  if (existingWorktree && !existingWorktree.isPrunable) {
3250
3821
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
3251
3822
  return;
@@ -3314,9 +3885,19 @@ var GitService = class {
3314
3885
  wrapped.isUpstreamSetupFailure = true;
3315
3886
  return wrapped;
3316
3887
  }
3317
- async removeWorktree(worktreePath) {
3888
+ async removeWorktree(worktreePath, options) {
3318
3889
  const bareGit = this.getCachedGit(this.bareRepoPath);
3319
- 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
+ }
3320
3901
  this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
3321
3902
  try {
3322
3903
  await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
@@ -3329,6 +3910,111 @@ var GitService = class {
3329
3910
  await bareGit.raw(["worktree", "prune"]);
3330
3911
  this.logger.info("Pruned worktree metadata.");
3331
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
+ }
3332
4018
  async checkWorktreeStatus(worktreePath) {
3333
4019
  return this.statusService.checkWorktreeStatus(worktreePath);
3334
4020
  }
@@ -3344,7 +4030,37 @@ var GitService = class {
3344
4030
  }
3345
4031
  async getFullWorktreeStatus(worktreePath, includeDetails = false) {
3346
4032
  const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
3347
- 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
+ );
3348
4064
  }
3349
4065
  async hasModifiedSubmodules(worktreePath) {
3350
4066
  return this.statusService.hasModifiedSubmodules(worktreePath);
@@ -3629,37 +4345,41 @@ var ProgressEmitter = class {
3629
4345
  }
3630
4346
  };
3631
4347
 
3632
- // src/services/repo-operation-lock.ts
3633
- import * as fs7 from "fs/promises";
3634
- import * as path10 from "path";
3635
- import * as lockfile from "proper-lockfile";
3636
-
3637
- // src/utils/lock-path.ts
3638
- import { createHash } from "crypto";
3639
- import * as os from "os";
3640
- import * as path9 from "path";
3641
- function getCloneModeLockTarget(config) {
3642
- const name = config.name;
3643
- const configDir = config.__configFileDir;
3644
- const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
3645
- if (configDir) {
3646
- return {
3647
- dir: path9.join(configDir, ".sync-worktrees-state"),
3648
- file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
3649
- };
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
+ }
3650
4367
  }
3651
- const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
3652
- const dir = path9.join(stateBase, "sync-worktrees", "locks");
3653
- return { dir, file: `${hash}.lock` };
3654
- }
4368
+ };
3655
4369
 
3656
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";
3657
4374
  var RepoOperationLock = class {
3658
4375
  constructor(config, gitService, logger = Logger.createDefault()) {
3659
4376
  this.config = config;
3660
4377
  this.gitService = gitService;
3661
4378
  this.logger = logger;
3662
4379
  }
4380
+ config;
4381
+ gitService;
4382
+ logger;
3663
4383
  updateLogger(logger) {
3664
4384
  this.logger = logger;
3665
4385
  }
@@ -3675,10 +4395,10 @@ var RepoOperationLock = class {
3675
4395
  }
3676
4396
  async acquireCloneModeLock() {
3677
4397
  const target = getCloneModeLockTarget(this.config);
3678
- const lockTarget = path10.join(target.dir, target.file);
4398
+ const lockTarget = path13.join(target.dir, target.file);
3679
4399
  try {
3680
- await fs7.mkdir(target.dir, { recursive: true });
3681
- await fs7.writeFile(lockTarget, "", { flag: "a" });
4400
+ await fs11.mkdir(target.dir, { recursive: true });
4401
+ await fs11.writeFile(lockTarget, "", { flag: "a" });
3682
4402
  } catch {
3683
4403
  return null;
3684
4404
  }
@@ -3687,7 +4407,7 @@ var RepoOperationLock = class {
3687
4407
  async acquireWorktreeModeLock() {
3688
4408
  const barePath = this.gitService.getBareRepoPath();
3689
4409
  try {
3690
- await fs7.mkdir(barePath, { recursive: true });
4410
+ await fs11.mkdir(barePath, { recursive: true });
3691
4411
  } catch {
3692
4412
  return null;
3693
4413
  }
@@ -3721,6 +4441,9 @@ var SyncRetryPolicy = class {
3721
4441
  this.gitService = gitService;
3722
4442
  this.logger = logger;
3723
4443
  }
4444
+ config;
4445
+ gitService;
4446
+ logger;
3724
4447
  updateLogger(logger) {
3725
4448
  this.logger = logger;
3726
4449
  }
@@ -3753,72 +4476,727 @@ var SyncRetryPolicy = class {
3753
4476
  syncContext.lfsSkipEnabled = true;
3754
4477
  }
3755
4478
  }
3756
- };
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;
5083
+ }
5084
+ }
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)));
3757
5148
  }
3758
- resetLfsSkipIfNeeded(syncContext) {
3759
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
3760
- 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
+ );
3761
5187
  }
3762
5188
  }
3763
5189
  };
3764
5190
 
3765
5191
  // src/services/worktree-mode-sync-runner.ts
3766
- import * as fs9 from "fs/promises";
3767
- import * as path13 from "path";
5192
+ import * as fs16 from "fs/promises";
5193
+ import * as path19 from "path";
3768
5194
  import pLimit from "p-limit";
3769
5195
 
3770
- // src/utils/date-filter.ts
3771
- function parseDuration(durationStr) {
3772
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
3773
- if (!match) {
3774
- return null;
3775
- }
3776
- const value = parseInt(match[1], 10);
3777
- const unit = match[2];
3778
- const multipliers = {
3779
- h: 60 * 60 * 1e3,
3780
- // hours
3781
- d: 24 * 60 * 60 * 1e3,
3782
- // days
3783
- w: 7 * 24 * 60 * 60 * 1e3,
3784
- // weeks
3785
- m: 30 * 24 * 60 * 60 * 1e3,
3786
- // months (approximate)
3787
- y: 365 * 24 * 60 * 60 * 1e3
3788
- // years (approximate)
3789
- };
3790
- return value * multipliers[unit];
3791
- }
3792
- function filterBranchesByAge(branches, maxAge) {
3793
- const maxAgeMs = parseDuration(maxAge);
3794
- if (maxAgeMs === null) {
3795
- console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
3796
- return branches;
3797
- }
3798
- const cutoffDate = new Date(Date.now() - maxAgeMs);
3799
- return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
3800
- }
3801
- function formatDuration2(durationStr) {
3802
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
3803
- if (!match) {
3804
- return durationStr;
3805
- }
3806
- const value = parseInt(match[1], 10);
3807
- const unit = match[2];
3808
- const unitNames = {
3809
- h: value === 1 ? "hour" : "hours",
3810
- d: value === 1 ? "day" : "days",
3811
- w: value === 1 ? "week" : "weeks",
3812
- m: value === 1 ? "month" : "months",
3813
- y: value === 1 ? "year" : "years"
3814
- };
3815
- return `${value} ${unitNames[unit]}`;
3816
- }
3817
-
3818
5196
  // src/services/path-resolution.service.ts
3819
5197
  import { createHash as createHash2 } from "crypto";
3820
- import * as fs8 from "fs";
3821
- import * as path11 from "path";
5198
+ import * as fs15 from "fs";
5199
+ import * as path17 from "path";
3822
5200
  var BRANCH_STEM_MAX = 80;
3823
5201
  var BRANCH_HASH_LEN = 8;
3824
5202
  var PathResolutionService = class {
@@ -3828,22 +5206,22 @@ var PathResolutionService = class {
3828
5206
  return `${stem}-${hash}`;
3829
5207
  }
3830
5208
  getBranchWorktreePath(worktreeDir, branchName) {
3831
- return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
5209
+ return path17.join(worktreeDir, this.sanitizeBranchName(branchName));
3832
5210
  }
3833
5211
  resolveRealPath(inputPath) {
3834
- const absolute = path11.resolve(inputPath);
5212
+ const absolute = path17.resolve(inputPath);
3835
5213
  const missing = [];
3836
5214
  let current = absolute;
3837
- while (!fs8.existsSync(current)) {
3838
- const parent = path11.dirname(current);
5215
+ while (!fs15.existsSync(current)) {
5216
+ const parent = path17.dirname(current);
3839
5217
  if (parent === current) {
3840
5218
  return absolute;
3841
5219
  }
3842
- missing.unshift(path11.basename(current));
5220
+ missing.unshift(path17.basename(current));
3843
5221
  current = parent;
3844
5222
  }
3845
5223
  try {
3846
- return path11.join(fs8.realpathSync(current), ...missing);
5224
+ return path17.join(fs15.realpathSync(current), ...missing);
3847
5225
  } catch {
3848
5226
  return absolute;
3849
5227
  }
@@ -3853,7 +5231,7 @@ var PathResolutionService = class {
3853
5231
  const a = fold(resolved);
3854
5232
  const b = fold(resolvedBase);
3855
5233
  if (a === b) return true;
3856
- 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);
3857
5235
  }
3858
5236
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
3859
5237
  const resolved = this.resolveRealPath(worktreePath);
@@ -3861,7 +5239,7 @@ var PathResolutionService = class {
3861
5239
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
3862
5240
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
3863
5241
  }
3864
- return path11.relative(resolvedBase, resolved);
5242
+ return path17.relative(resolvedBase, resolved);
3865
5243
  }
3866
5244
  isPathInsideBaseDir(targetPath, baseDir) {
3867
5245
  const resolved = this.resolveRealPath(targetPath);
@@ -3874,7 +5252,7 @@ var PathResolutionService = class {
3874
5252
  };
3875
5253
 
3876
5254
  // src/services/worktree-sync-planner.ts
3877
- import * as path12 from "path";
5255
+ import * as path18 from "path";
3878
5256
  function createWorktreeSyncPlan(inventory, options = {}) {
3879
5257
  return {
3880
5258
  create: planCreateActions(inventory, options),
@@ -3892,12 +5270,12 @@ function planCreateActions(inventory, options = {}) {
3892
5270
  );
3893
5271
  const reservedPaths = /* @__PURE__ */ new Map();
3894
5272
  for (const worktree of inventory.existingWorktrees) {
3895
- reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
5273
+ reservedPaths.set(path18.resolve(worktree.path), worktree.branch);
3896
5274
  }
3897
5275
  const actions = [];
3898
5276
  for (const branch of newBranches) {
3899
5277
  const worktreePath = pathResolution2.getBranchWorktreePath(inventory.worktreeDir, branch);
3900
- const resolved = path12.resolve(worktreePath);
5278
+ const resolved = path18.resolve(worktreePath);
3901
5279
  const conflictingBranch = reservedPaths.get(resolved);
3902
5280
  if (conflictingBranch && conflictingBranch !== branch) {
3903
5281
  actions.push({
@@ -3935,21 +5313,30 @@ function planSparseActions(inventory, sparseCheckout) {
3935
5313
 
3936
5314
  // src/services/worktree-mode-sync-runner.ts
3937
5315
  var WorktreeModeSyncRunner = class {
3938
- constructor(config, gitService, logger, progressEmitter) {
5316
+ constructor(config, gitService, logger, progressEmitter, services) {
3939
5317
  this.config = config;
3940
5318
  this.gitService = gitService;
3941
5319
  this.logger = logger;
3942
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);
3943
5323
  }
5324
+ config;
5325
+ gitService;
5326
+ logger;
5327
+ progressEmitter;
3944
5328
  pathResolution = new PathResolutionService();
5329
+ removalAudit;
5330
+ trashService;
3945
5331
  updateLogger(logger) {
3946
5332
  this.logger = logger;
5333
+ this.trashService.updateLogger(logger);
3947
5334
  }
3948
5335
  async runSyncAttempt(phaseTimer, syncContext, outcome) {
3949
5336
  await this.gitService.pruneWorktrees();
3950
5337
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
3951
5338
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
3952
- await fs9.mkdir(this.config.worktreeDir, { recursive: true });
5339
+ await fs16.mkdir(this.config.worktreeDir, { recursive: true });
3953
5340
  const worktrees = await this.gitService.getWorktrees();
3954
5341
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
3955
5342
  await this.cleanupOrphanedDirectories(worktrees);
@@ -3967,6 +5354,7 @@ var WorktreeModeSyncRunner = class {
3967
5354
  }
3968
5355
  );
3969
5356
  await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
5357
+ await this.recordRemoteBranchTips([...worktrees, ...syncPlan.create.filter((action) => action.kind === "create")]);
3970
5358
  await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
3971
5359
  if (this.config.updateExistingWorktrees !== false) {
3972
5360
  await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
@@ -3989,7 +5377,7 @@ var WorktreeModeSyncRunner = class {
3989
5377
  if (action.kind !== "check-sparse") return;
3990
5378
  try {
3991
5379
  try {
3992
- await fs9.access(action.path);
5380
+ await fs16.access(action.path);
3993
5381
  } catch {
3994
5382
  return;
3995
5383
  }
@@ -4075,7 +5463,7 @@ var WorktreeModeSyncRunner = class {
4075
5463
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
4076
5464
  const remoteBranches = filteredBranches.map((b) => b.branch);
4077
5465
  this.logger.info(
4078
- `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
5466
+ `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
4079
5467
  );
4080
5468
  if (filteredByName.length > remoteBranches.length) {
4081
5469
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -4152,6 +5540,37 @@ var WorktreeModeSyncRunner = class {
4152
5540
  const successCount = results.filter((r) => r.status === "fulfilled").length;
4153
5541
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
4154
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
+ }
4155
5574
  async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
4156
5575
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4157
5576
  phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
@@ -4181,7 +5600,18 @@ var WorktreeModeSyncRunner = class {
4181
5600
  if (result.status === "fulfilled") {
4182
5601
  const { branchName, worktreePath, status } = result.value;
4183
5602
  if (status.canRemove) {
4184
- 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
+ }
4185
5615
  } else {
4186
5616
  toSkip.push({ branchName, worktreePath, status });
4187
5617
  }
@@ -4204,7 +5634,7 @@ var WorktreeModeSyncRunner = class {
4204
5634
  ({ branchName, worktreePath }) => removeLimit(async () => {
4205
5635
  try {
4206
5636
  const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
4207
- if (!recheck.canRemove) {
5637
+ if (!recheck.canRemove || this.blockedByDisabledTrash(recheck)) {
4208
5638
  this.logger.warn(
4209
5639
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
4210
5640
  );
@@ -4215,10 +5645,76 @@ var WorktreeModeSyncRunner = class {
4215
5645
  });
4216
5646
  return;
4217
5647
  }
4218
- await this.gitService.removeWorktree(worktreePath);
4219
- this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
4220
- 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
+ );
4221
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
+ }
4222
5718
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
4223
5719
  outcome.recordFailed("worktree", getErrorMessage(error), {
4224
5720
  reason: "remove_failed",
@@ -4344,12 +5840,12 @@ var WorktreeModeSyncRunner = class {
4344
5840
  }
4345
5841
  async updateExistingWorktrees(actions, outcome) {
4346
5842
  this.logger.info("Step 4: Checking for worktrees that need updates...");
4347
- 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);
4348
5844
  try {
4349
- const diverged = await fs9.readdir(divergedDir);
5845
+ const diverged = await fs16.readdir(divergedDir);
4350
5846
  if (diverged.length > 0) {
4351
5847
  this.logger.info(
4352
- `\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)}`
4353
5849
  );
4354
5850
  }
4355
5851
  } catch {
@@ -4361,7 +5857,7 @@ var WorktreeModeSyncRunner = class {
4361
5857
  (action) => limit(async () => {
4362
5858
  const worktree = { path: action.path, branch: action.branch };
4363
5859
  try {
4364
- await fs9.access(worktree.path);
5860
+ await fs16.access(worktree.path);
4365
5861
  } catch {
4366
5862
  return { action: "skip", worktree, reason: "missing_worktree_path" };
4367
5863
  }
@@ -4501,13 +5997,13 @@ var WorktreeModeSyncRunner = class {
4501
5997
  }
4502
5998
  async cleanupOrphanedDirectories(worktrees) {
4503
5999
  try {
4504
- const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
4505
- 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);
4506
6002
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
4507
6003
  const orphanedDirs = [];
4508
6004
  for (const dir of regularDirs) {
4509
6005
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
4510
- return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
6006
+ return worktreePath === dir || worktreePath.startsWith(dir + path19.sep);
4511
6007
  });
4512
6008
  if (!isPartOfWorktree) {
4513
6009
  orphanedDirs.push(dir);
@@ -4516,13 +6012,46 @@ var WorktreeModeSyncRunner = class {
4516
6012
  if (orphanedDirs.length > 0) {
4517
6013
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
4518
6014
  for (const dir of orphanedDirs) {
4519
- const dirPath = path13.join(this.config.worktreeDir, dir);
6015
+ const dirPath = path19.join(this.config.worktreeDir, dir);
4520
6016
  try {
4521
- const stat4 = await fs9.stat(dirPath);
4522
- if (stat4.isDirectory()) {
4523
- await fs9.rm(dirPath, { recursive: true, force: true });
4524
- 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;
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;
4525
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}`);
4526
6055
  } catch (error) {
4527
6056
  this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
4528
6057
  }
@@ -4551,14 +6080,37 @@ var WorktreeModeSyncRunner = class {
4551
6080
  outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
4552
6081
  } else {
4553
6082
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
4554
- const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
4555
- 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);
4556
6091
  outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
4557
6092
  this.logger.info(` Moved to: ${relativePath}`);
4558
6093
  this.logger.info(` Your local changes are preserved. To review:`);
4559
6094
  this.logger.info(` cd ${relativePath}`);
4560
6095
  this.logger.info(` git diff origin/${worktree.branch}`);
4561
- 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
+ );
4562
6114
  await this.gitService.addWorktree(worktree.branch, worktree.path);
4563
6115
  this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
4564
6116
  }
@@ -4577,42 +6129,55 @@ var WorktreeModeSyncRunner = class {
4577
6129
  }
4578
6130
  }
4579
6131
  async divergeWorktree(worktreePath, branchName) {
4580
- 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);
4581
6143
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4582
6144
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
4583
6145
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
4584
6146
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
4585
- const divergedPath = path13.join(divergedBaseDir, divergedName);
4586
- await fs9.mkdir(divergedBaseDir, { recursive: true });
6147
+ const divergedPath = path19.join(divergedBaseDir, divergedName);
6148
+ await fs16.mkdir(divergedBaseDir, { recursive: true });
4587
6149
  try {
4588
- await fs9.rename(worktreePath, divergedPath);
6150
+ await fs16.rename(worktreePath, divergedPath);
4589
6151
  } catch (err) {
4590
6152
  if (err.code === ERROR_MESSAGES.EXDEV) {
4591
- await fs9.cp(worktreePath, divergedPath, { recursive: true });
4592
- 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 });
4593
6155
  } else {
4594
6156
  throw err;
4595
6157
  }
4596
6158
  }
6159
+ await this.writeDivergedInfoFile(divergedPath, worktreePath, branchName, null);
6160
+ return { divergedPath, manifest: null };
6161
+ }
6162
+ async writeDivergedInfoFile(preservedPath, originalPath, branchName, knownLocalCommit) {
4597
6163
  const metadata = {
4598
6164
  originalBranch: branchName,
4599
6165
  divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
4600
6166
  reason: METADATA_CONSTANTS.DIVERGED_REASON,
4601
- originalPath: worktreePath,
4602
- localCommit: await this.gitService.getCurrentCommit(divergedPath),
6167
+ originalPath,
6168
+ localCommit: knownLocalCommit ?? await this.gitService.getCurrentCommit(preservedPath),
4603
6169
  remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
4604
6170
  instruction: `To preserve your changes:
4605
6171
  1. Review: git diff origin/${branchName}
4606
6172
  2. Keep changes: git push --force-with-lease origin ${branchName}
4607
6173
  3. Discard changes: rm -rf this directory
4608
6174
 
4609
- Original worktree location: ${worktreePath}`
6175
+ Original worktree location: ${originalPath}`
4610
6176
  };
4611
- await fs9.writeFile(
4612
- path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
6177
+ await fs16.writeFile(
6178
+ path19.join(preservedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4613
6179
  JSON.stringify(metadata, null, 2)
4614
6180
  );
4615
- return divergedPath;
4616
6181
  }
4617
6182
  };
4618
6183
 
@@ -4623,12 +6188,26 @@ var WorktreeSyncService = class {
4623
6188
  this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
4624
6189
  this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
4625
6190
  this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
6191
+ this.maintenanceService = new GitMaintenanceService(config, this.gitService, this.logger);
4626
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
+ }
4627
6202
  this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
4628
6203
  config,
4629
6204
  this.gitService,
4630
6205
  this.logger,
4631
- this.progressEmitter
6206
+ this.progressEmitter,
6207
+ {
6208
+ trashService: this.trashService,
6209
+ removalAudit
6210
+ }
4632
6211
  );
4633
6212
  if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
4634
6213
  this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
@@ -4639,14 +6218,23 @@ var WorktreeSyncService = class {
4639
6218
  });
4640
6219
  }
4641
6220
  }
6221
+ config;
4642
6222
  gitService;
4643
6223
  cloneSyncService = null;
4644
6224
  logger;
4645
- syncInProgress = false;
6225
+ // In-process FIFO serializer for all bare-repo-mutating operations (sync, init,
6226
+ // interactive create). One per repo. wait:true callers queue behind an in-flight op;
6227
+ // wait:false callers fail fast. The cross-process file lock (RepoOperationLock) is
6228
+ // acquired inside the mutex body for multi-process safety.
6229
+ repoMutex = pLimit2(1);
4646
6230
  progressEmitter = new ProgressEmitter();
4647
6231
  repoOperationLock;
6232
+ maintenanceService;
4648
6233
  retryPolicy;
4649
6234
  worktreeModeSyncRunner;
6235
+ trashService;
6236
+ trashReaper;
6237
+ trashMigration;
4650
6238
  skipsAccumulator = [];
4651
6239
  lastOutcome = null;
4652
6240
  getRecordedSkips() {
@@ -4670,6 +6258,18 @@ var WorktreeSyncService = class {
4670
6258
  }
4671
6259
  return this.gitService.getWorktrees();
4672
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
+ }
4673
6273
  async initialize() {
4674
6274
  if (this.isInitialized()) return;
4675
6275
  const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
@@ -4694,11 +6294,28 @@ var WorktreeSyncService = class {
4694
6294
  return this.gitService.isInitialized();
4695
6295
  }
4696
6296
  isSyncInProgress() {
4697
- return this.syncInProgress;
6297
+ return this.repoMutex.activeCount + this.repoMutex.pendingCount > 0;
4698
6298
  }
4699
6299
  getGitService() {
4700
6300
  return this.gitService;
4701
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
+ }
4702
6319
  updateLogger(logger) {
4703
6320
  this.logger = logger;
4704
6321
  this.gitService.updateLogger(logger);
@@ -4706,44 +6323,73 @@ var WorktreeSyncService = class {
4706
6323
  this.retryPolicy.updateLogger(logger);
4707
6324
  this.worktreeModeSyncRunner.updateLogger(logger);
4708
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
+ }
4709
6357
  }
4710
6358
  onProgress(listener) {
4711
6359
  return this.progressEmitter.onProgress(listener);
4712
6360
  }
4713
- async runExclusiveRepoOperation(operation) {
4714
- if (this.syncInProgress) {
6361
+ async runExclusiveRepoOperation(operation, options = {}) {
6362
+ if (!options.wait && this.repoMutex.activeCount + this.repoMutex.pendingCount > 0) {
4715
6363
  this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
4716
6364
  return { started: false, reason: "in_progress" };
4717
6365
  }
4718
- this.syncInProgress = true;
4719
- let release;
4720
- try {
4721
- release = await this.repoOperationLock.acquire();
4722
- } catch (error) {
4723
- this.syncInProgress = false;
4724
- throw error;
4725
- }
4726
- if (release === null) {
4727
- this.syncInProgress = false;
4728
- this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
4729
- return { started: false, reason: "locked" };
4730
- }
4731
- try {
4732
- return { started: true, value: await operation() };
4733
- } finally {
6366
+ return this.repoMutex(async () => {
6367
+ const release = await this.repoOperationLock.acquire();
6368
+ if (release === null) {
6369
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
6370
+ return { started: false, reason: "locked" };
6371
+ }
4734
6372
  try {
4735
- await release();
4736
- } catch (releaseError) {
4737
- this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
6373
+ return { started: true, value: await operation() };
6374
+ } finally {
6375
+ try {
6376
+ await release();
6377
+ } catch (releaseError) {
6378
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
6379
+ }
4738
6380
  }
4739
- this.syncInProgress = false;
4740
- }
6381
+ });
6382
+ }
6383
+ // Interactive variant: queues behind any in-flight sync/op instead of failing fast.
6384
+ async runQueuedRepoOperation(operation) {
6385
+ return this.runExclusiveRepoOperation(operation, { wait: true });
4741
6386
  }
4742
6387
  emitProgress(event) {
4743
6388
  this.progressEmitter.emit(event);
4744
6389
  }
4745
6390
  async sync() {
4746
6391
  const result = await this.runExclusiveRepoOperation(async () => {
6392
+ this.clearRecordedSkips();
4747
6393
  const totalTimer = new Timer();
4748
6394
  const phaseTimer = new PhaseTimer();
4749
6395
  const outcome = new SyncOutcomeAccumulator({
@@ -4792,7 +6438,9 @@ var WorktreeSyncService = class {
4792
6438
  const repoName = this.config.name;
4793
6439
  this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
4794
6440
  }
6441
+ await this.runTrashMaintenanceUnlocked();
4795
6442
  }
6443
+ await this.runMaintenanceIfDueUnlocked();
4796
6444
  return this.lastOutcome ?? outcome.toOutcome(durationMs);
4797
6445
  });
4798
6446
  return result.started ? { started: true, outcome: result.value } : result;
@@ -4808,7 +6456,6 @@ function emptyCapabilities(reason) {
4808
6456
  listWorktrees: { ...state },
4809
6457
  getStatus: { ...state },
4810
6458
  createWorktree: { ...state },
4811
- removeWorktree: { ...state },
4812
6459
  updateWorktree: { ...state },
4813
6460
  sync: { ...state },
4814
6461
  initialize: { ...state }
@@ -4847,16 +6494,19 @@ var RepositoryContext = class {
4847
6494
  discoveryCache = /* @__PURE__ */ new Map();
4848
6495
  launchCwd;
4849
6496
  constructor(options = {}) {
4850
- this.launchCwd = path14.resolve(options.launchCwd ?? process.cwd());
6497
+ this.launchCwd = path20.resolve(options.launchCwd ?? process.cwd());
4851
6498
  }
4852
6499
  getLaunchCwd() {
4853
6500
  return this.launchCwd;
4854
6501
  }
6502
+ async findConfigUpward(startDir) {
6503
+ return this.configLoader.findConfigUpward(startDir);
6504
+ }
4855
6505
  async loadConfig(configPath, options = {}) {
4856
6506
  const setDefaultCurrent = options.setDefaultCurrent ?? true;
4857
- const absolutePath = path14.resolve(configPath);
6507
+ const absolutePath = path20.resolve(configPath);
4858
6508
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
4859
- const configDir = path14.dirname(absolutePath);
6509
+ const configDir = path20.dirname(absolutePath);
4860
6510
  const globalDefaults = configFile.defaults;
4861
6511
  const resolvedAll = [];
4862
6512
  for (const repo of configFile.repositories) {
@@ -4893,7 +6543,7 @@ var RepositoryContext = class {
4893
6543
  return configFile.repositories;
4894
6544
  }
4895
6545
  async detectFromPath(dirPath) {
4896
- const absolutePath = path14.resolve(dirPath);
6546
+ const absolutePath = path20.resolve(dirPath);
4897
6547
  const cached = this.discoveryCache.get(absolutePath);
4898
6548
  if (cached && await this.isCacheFresh(cached)) {
4899
6549
  return cached.result;
@@ -4912,8 +6562,8 @@ var RepositoryContext = class {
4912
6562
  const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
4913
6563
  if (result.isWorktree && result.bareRepoPath && adminDir) {
4914
6564
  const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
4915
- safeMtimeMs(path14.join(adminDir, "HEAD")),
4916
- safeMtimeMs(path14.join(result.bareRepoPath, "worktrees"))
6565
+ safeMtimeMs(path20.join(adminDir, "HEAD")),
6566
+ safeMtimeMs(path20.join(result.bareRepoPath, "worktrees"))
4917
6567
  ]);
4918
6568
  this.discoveryCache.set(absolutePath, {
4919
6569
  result,
@@ -4953,7 +6603,7 @@ var RepositoryContext = class {
4953
6603
  const results = /* @__PURE__ */ new Map();
4954
6604
  const byName = (a, b) => a.name.localeCompare(b.name);
4955
6605
  const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
4956
- const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
6606
+ const bareRepoPath = path20.resolve(entry.config.bareRepoDir);
4957
6607
  return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
4958
6608
  }).filter((c) => c.foldedBare !== currentBare);
4959
6609
  const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
@@ -4961,7 +6611,7 @@ var RepositoryContext = class {
4961
6611
  const sibling = {
4962
6612
  name: entry.name,
4963
6613
  bareRepoPath,
4964
- worktreeDir: path14.resolve(entry.config.worktreeDir),
6614
+ worktreeDir: path20.resolve(entry.config.worktreeDir),
4965
6615
  repoUrl: entry.config.repoUrl,
4966
6616
  present: configPresence[i],
4967
6617
  configMatched: true
@@ -4971,24 +6621,24 @@ var RepositoryContext = class {
4971
6621
  }
4972
6622
  results.set(foldedBare, sibling);
4973
6623
  });
4974
- const repoDir = path14.dirname(currentBareRepoPath);
4975
- const workspaceRoot = path14.dirname(repoDir);
6624
+ const repoDir = path20.dirname(currentBareRepoPath);
6625
+ const workspaceRoot = path20.dirname(repoDir);
4976
6626
  if (workspaceRoot === repoDir) {
4977
6627
  return Array.from(results.values()).sort(byName);
4978
6628
  }
4979
6629
  let entries;
4980
6630
  try {
4981
- entries = await fs10.readdir(workspaceRoot);
6631
+ entries = await fs17.readdir(workspaceRoot);
4982
6632
  } catch {
4983
6633
  return Array.from(results.values()).sort(byName);
4984
6634
  }
4985
6635
  const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
4986
6636
  await Promise.all(
4987
6637
  entries.map(async (entry) => {
4988
- const candidate = path14.join(workspaceRoot, entry);
4989
- 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);
4990
6640
  if (!await isDirectory(bareCandidate)) return;
4991
- const resolvedBare = path14.resolve(bareCandidate);
6641
+ const resolvedBare = path20.resolve(bareCandidate);
4992
6642
  const foldedBare = normalizePathForCompare(resolvedBare);
4993
6643
  if (foldedBare === currentBare || results.has(foldedBare)) return;
4994
6644
  const matchedName = configBares.get(foldedBare);
@@ -5014,8 +6664,8 @@ var RepositoryContext = class {
5014
6664
  if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
5015
6665
  if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
5016
6666
  const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
5017
- safeMtimeMs(path14.join(cached.worktreeAdminDir, "HEAD")),
5018
- safeMtimeMs(path14.join(cached.result.bareRepoPath, "worktrees"))
6667
+ safeMtimeMs(path20.join(cached.worktreeAdminDir, "HEAD")),
6668
+ safeMtimeMs(path20.join(cached.result.bareRepoPath, "worktrees"))
5019
6669
  ]);
5020
6670
  return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
5021
6671
  }
@@ -5063,18 +6713,18 @@ var RepositoryContext = class {
5063
6713
  return unsupported("Invalid .git file format (missing gitdir line)");
5064
6714
  }
5065
6715
  const gitdir = gitdirMatch[1].trim();
5066
- const resolvedGitdir = path14.isAbsolute(gitdir) ? gitdir : path14.resolve(worktreeRoot, gitdir);
6716
+ const resolvedGitdir = path20.isAbsolute(gitdir) ? gitdir : path20.resolve(worktreeRoot, gitdir);
5067
6717
  const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
5068
6718
  if (!worktreesMatch) {
5069
6719
  return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
5070
6720
  }
5071
- const bareRepoPath = path14.resolve(worktreesMatch[1]);
5072
- const adminDir = path14.resolve(resolvedGitdir);
6721
+ const bareRepoPath = path20.resolve(worktreesMatch[1]);
6722
+ const adminDir = path20.resolve(resolvedGitdir);
5073
6723
  let repoUrl = null;
5074
6724
  let worktrees = [];
5075
6725
  let currentBranch = null;
5076
6726
  try {
5077
- const bareGit = simpleGit6(bareRepoPath);
6727
+ const bareGit = simpleGit7(bareRepoPath);
5078
6728
  try {
5079
6729
  const remoteResult = await bareGit.remote(["get-url", "origin"]);
5080
6730
  const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
@@ -5110,13 +6760,12 @@ var RepositoryContext = class {
5110
6760
  adminDir
5111
6761
  };
5112
6762
  }
5113
- const worktreeDir = path14.dirname(worktreeRoot);
6763
+ const worktreeDir = path20.dirname(worktreeRoot);
5114
6764
  const noUrlReason = "no remote origin URL detected";
5115
6765
  const capabilities = {
5116
6766
  listWorktrees: { available: true },
5117
6767
  getStatus: { available: true },
5118
6768
  createWorktree: repoUrl !== null ? { available: true } : { available: false, reason: noUrlReason },
5119
- removeWorktree: { available: true },
5120
6769
  updateWorktree: { available: true },
5121
6770
  sync: { available: false, reason: "no config and no remote URL" },
5122
6771
  initialize: { available: false, reason: "no config and no remote URL" }
@@ -5146,7 +6795,7 @@ var RepositoryContext = class {
5146
6795
  cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
5147
6796
  runOnce: true
5148
6797
  };
5149
- const detectedKey = `${AUTO_DETECT_PREFIX}${path14.basename(bareRepoPath)}@${bareRepoPath}`;
6798
+ const detectedKey = `${AUTO_DETECT_PREFIX}${path20.basename(bareRepoPath)}@${bareRepoPath}`;
5150
6799
  if (!this.repos.has(detectedKey)) {
5151
6800
  this.repos.set(detectedKey, {
5152
6801
  name: detectedKey,
@@ -5333,14 +6982,14 @@ var RepositoryContext = class {
5333
6982
  const mode = resolveMode(entry.config);
5334
6983
  const isCurrent = entry.name === currentRepo;
5335
6984
  if (mode === REPOSITORY_MODES.CLONE) {
5336
- 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 };
5337
6986
  }
5338
- 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 };
5339
6988
  };
5340
6989
  if (!options.detailed) {
5341
6990
  return entries.map(buildLean);
5342
6991
  }
5343
- const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
6992
+ const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5344
6993
  return Promise.all(
5345
6994
  entries.map(
5346
6995
  (entry) => limit(async () => {
@@ -5360,7 +7009,7 @@ var RepositoryContext = class {
5360
7009
  return summary;
5361
7010
  }
5362
7011
  if (entry.config.bareRepoDir) {
5363
- summary.bareRepoDir = path14.resolve(entry.config.bareRepoDir);
7012
+ summary.bareRepoDir = path20.resolve(entry.config.bareRepoDir);
5364
7013
  summary.localReady = await isDirectory(summary.bareRepoDir);
5365
7014
  } else {
5366
7015
  summary.localReady = false;
@@ -5404,27 +7053,27 @@ var RepositoryContext = class {
5404
7053
  return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
5405
7054
  }
5406
7055
  if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
5407
- const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
7056
+ const bareRepoPath = path20.resolve(entry.config.bareRepoDir);
5408
7057
  if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
5409
7058
  try {
5410
- const output = await simpleGit6(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
7059
+ const output = await simpleGit7(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
5411
7060
  return { worktrees: parseWorktreeList(output, currentWorktreePath) };
5412
7061
  } catch (err) {
5413
7062
  return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
5414
7063
  }
5415
7064
  }
5416
7065
  findConfiguredCloneEntry(worktreeRoot) {
5417
- const foldedRoot = normalizePathForCompare(path14.resolve(worktreeRoot));
7066
+ const foldedRoot = normalizePathForCompare(path20.resolve(worktreeRoot));
5418
7067
  for (const entry of this.repos.values()) {
5419
7068
  if (entry.source !== "config" || resolveMode(entry.config) !== REPOSITORY_MODES.CLONE) continue;
5420
- if (normalizePathForCompare(path14.resolve(entry.config.worktreeDir)) === foldedRoot) {
7069
+ if (normalizePathForCompare(path20.resolve(entry.config.worktreeDir)) === foldedRoot) {
5421
7070
  return entry;
5422
7071
  }
5423
7072
  }
5424
7073
  return null;
5425
7074
  }
5426
7075
  async buildCloneModeContext(entry, worktreeRoot, notes) {
5427
- const resolvedRoot = path14.resolve(worktreeRoot);
7076
+ const resolvedRoot = path20.resolve(worktreeRoot);
5428
7077
  let currentBranch = null;
5429
7078
  try {
5430
7079
  currentBranch = await readCurrentBranch(resolvedRoot);
@@ -5437,7 +7086,6 @@ var RepositoryContext = class {
5437
7086
  listWorktrees: { available: true },
5438
7087
  getStatus: { available: true },
5439
7088
  createWorktree: { available: false, reason: cloneModeReason },
5440
- removeWorktree: { available: false, reason: cloneModeReason },
5441
7089
  updateWorktree: { available: false, reason: cloneModeReason },
5442
7090
  sync: { available: true },
5443
7091
  initialize: { available: true }
@@ -5462,7 +7110,7 @@ var RepositoryContext = class {
5462
7110
  return discovered;
5463
7111
  }
5464
7112
  async readConfiguredCloneWorktree(entry, currentWorktreePath) {
5465
- const worktreePath = path14.resolve(entry.config.worktreeDir);
7113
+ const worktreePath = path20.resolve(entry.config.worktreeDir);
5466
7114
  if (!await isDirectory(worktreePath) || !await hasGitMetadata(worktreePath)) {
5467
7115
  return { worktrees: [] };
5468
7116
  }
@@ -5486,7 +7134,7 @@ function parseWorktreeList(output, currentPath) {
5486
7134
  const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
5487
7135
  const results = [];
5488
7136
  for (const wt of parseWorktreeListPorcelain(output)) {
5489
- const resolved = path14.resolve(wt.path);
7137
+ const resolved = path20.resolve(wt.path);
5490
7138
  const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
5491
7139
  if (!branch) continue;
5492
7140
  results.push({
@@ -5499,7 +7147,7 @@ function parseWorktreeList(output, currentPath) {
5499
7147
  }
5500
7148
  async function safeMtimeMs(filePath) {
5501
7149
  try {
5502
- const stat4 = await fs10.stat(filePath);
7150
+ const stat4 = await fs17.stat(filePath);
5503
7151
  return stat4.mtimeMs;
5504
7152
  } catch {
5505
7153
  return null;
@@ -5507,7 +7155,7 @@ async function safeMtimeMs(filePath) {
5507
7155
  }
5508
7156
  async function isDirectory(filePath) {
5509
7157
  try {
5510
- const stat4 = await fs10.stat(filePath);
7158
+ const stat4 = await fs17.stat(filePath);
5511
7159
  return stat4.isDirectory();
5512
7160
  } catch {
5513
7161
  return false;
@@ -5515,7 +7163,7 @@ async function isDirectory(filePath) {
5515
7163
  }
5516
7164
  async function hasGitMetadata(worktreePath) {
5517
7165
  try {
5518
- await fs10.stat(path14.join(worktreePath, ".git"));
7166
+ await fs17.stat(path20.join(worktreePath, ".git"));
5519
7167
  return true;
5520
7168
  } catch {
5521
7169
  return false;
@@ -5524,14 +7172,14 @@ async function hasGitMetadata(worktreePath) {
5524
7172
  async function isGitCheckout(checkoutPath) {
5525
7173
  if (!await isDirectory(checkoutPath)) return false;
5526
7174
  try {
5527
- 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();
5528
7176
  return inside === "true";
5529
7177
  } catch {
5530
7178
  return false;
5531
7179
  }
5532
7180
  }
5533
7181
  async function readCurrentBranch(worktreePath) {
5534
- const git = simpleGit6(worktreePath);
7182
+ const git = simpleGit7(worktreePath);
5535
7183
  const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
5536
7184
  if (branch && branch !== "HEAD") {
5537
7185
  return branch;
@@ -5540,12 +7188,12 @@ async function readCurrentBranch(worktreePath) {
5540
7188
  return head ? `(detached ${head})` : "(detached)";
5541
7189
  }
5542
7190
  async function findWorktreeRoot(startPath) {
5543
- let current = path14.resolve(startPath);
5544
- const root = path14.parse(current).root;
7191
+ let current = path20.resolve(startPath);
7192
+ const root = path20.parse(current).root;
5545
7193
  while (true) {
5546
- const gitPath = path14.join(current, ".git");
7194
+ const gitPath = path20.join(current, ".git");
5547
7195
  try {
5548
- const content = await fs10.readFile(gitPath, "utf-8");
7196
+ const content = await fs17.readFile(gitPath, "utf-8");
5549
7197
  return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
5550
7198
  } catch (err) {
5551
7199
  const code = err.code;
@@ -5557,7 +7205,7 @@ async function findWorktreeRoot(startPath) {
5557
7205
  }
5558
7206
  }
5559
7207
  if (current === root) return null;
5560
- const parent = path14.dirname(current);
7208
+ const parent = path20.dirname(current);
5561
7209
  if (parent === current) return null;
5562
7210
  current = parent;
5563
7211
  }
@@ -5568,26 +7216,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5568
7216
  import { z } from "zod";
5569
7217
 
5570
7218
  // src/mcp/handlers.ts
5571
- import * as path15 from "path";
5572
- import pLimit3 from "p-limit";
5573
-
5574
- // src/utils/disk-space.ts
5575
- import fastFolderSize from "fast-folder-size";
5576
- async function calculateDirectorySize(dirPath) {
5577
- return new Promise((resolve11, reject) => {
5578
- fastFolderSize(dirPath, (err, bytes) => {
5579
- if (err) {
5580
- reject(err);
5581
- return;
5582
- }
5583
- if (bytes === void 0) {
5584
- reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
5585
- return;
5586
- }
5587
- resolve11(bytes);
5588
- });
5589
- });
5590
- }
7219
+ import * as path21 from "path";
7220
+ import pLimit4 from "p-limit";
5591
7221
 
5592
7222
  // src/utils/git-validation.ts
5593
7223
  function isValidGitBranchName(name) {
@@ -5698,14 +7328,18 @@ function wrapHandler(fn) {
5698
7328
  }
5699
7329
 
5700
7330
  // src/mcp/worktree-summary.ts
5701
- import simpleGit7 from "simple-git";
7331
+ import simpleGit8 from "simple-git";
5702
7332
  function deriveLabel(status, isCurrent) {
5703
7333
  if (isCurrent) return "current";
5704
- if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
5705
- 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";
5706
7337
  return "clean";
5707
7338
  }
5708
7339
  function deriveSafeToRemove(status) {
7340
+ if (status.canRemove && status.fullyPushedUpstreamDeleted) {
7341
+ return { safe: true, reason: "fully pushed before its remote branch was deleted" };
7342
+ }
5709
7343
  if (status.canRemove && !status.upstreamGone) {
5710
7344
  return { safe: true, reason: "clean tree, no unpushed commits" };
5711
7345
  }
@@ -5719,7 +7353,7 @@ function deriveSafeToRemove(status) {
5719
7353
  }
5720
7354
  async function getDivergence(worktreePath) {
5721
7355
  try {
5722
- const git = simpleGit7(worktreePath);
7356
+ const git = simpleGit8(worktreePath);
5723
7357
  const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
5724
7358
  const [aheadStr, behindStr] = output.trim().split(/\s+/);
5725
7359
  return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
@@ -5767,7 +7401,7 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
5767
7401
  }
5768
7402
  async function ensureRepoWorktreePath(ctx, params, service, git) {
5769
7403
  await ensurePathBelongsToRepo(ctx, params.path, params.repoName, service, git);
5770
- return path15.resolve(params.path);
7404
+ return path21.resolve(params.path);
5771
7405
  }
5772
7406
  async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
5773
7407
  const discovered = ctx.getDiscoveredContext(repoName);
@@ -5816,7 +7450,7 @@ async function handleDetectContext(ctx, params, _extra) {
5816
7450
  return formatToolResponse(response);
5817
7451
  }
5818
7452
  const statusService = new WorktreeStatusService();
5819
- const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
7453
+ const statusLimit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5820
7454
  const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
5821
7455
  let allWorktreesByRepo = response.allWorktreesByRepo;
5822
7456
  if (allWorktreesByRepo) {
@@ -5852,18 +7486,14 @@ async function enrichDetectedWorktrees(worktrees, statusService, limit) {
5852
7486
  async function handleListWorktrees(ctx, params, _extra) {
5853
7487
  const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
5854
7488
  if (configuredRepoNames.length > 0) {
5855
- const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
5856
- const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
7489
+ const limit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
7490
+ const statusLimit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5857
7491
  const repositories = await Promise.all(
5858
7492
  configuredRepoNames.map(
5859
7493
  (repoName) => limit(async () => {
5860
7494
  try {
5861
- return [
5862
- repoName,
5863
- {
5864
- worktrees: await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit)
5865
- }
5866
- ];
7495
+ const worktrees2 = await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit);
7496
+ return [repoName, { worktrees: worktrees2 }];
5867
7497
  } catch (err) {
5868
7498
  return [
5869
7499
  repoName,
@@ -5878,10 +7508,10 @@ async function handleListWorktrees(ctx, params, _extra) {
5878
7508
  );
5879
7509
  return formatToolResponse({ repositories: Object.fromEntries(repositories) });
5880
7510
  }
5881
- const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
5882
- return formatToolResponse({ worktrees: results });
7511
+ const worktrees = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
7512
+ return formatToolResponse({ worktrees });
5883
7513
  }
5884
- async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
7514
+ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
5885
7515
  const { discovered, service, git } = await getReadyService(ctx, repoName, {
5886
7516
  capability: "listWorktrees",
5887
7517
  toolName: "list_worktrees"
@@ -5900,7 +7530,7 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(
5900
7530
  const results = await Promise.all(
5901
7531
  worktrees.map(
5902
7532
  (wt) => limit(async () => {
5903
- const resolvedPath = path15.resolve(wt.path);
7533
+ const resolvedPath = path21.resolve(wt.path);
5904
7534
  const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
5905
7535
  const [status, divergence, metadata, sizeBytes] = await Promise.all([
5906
7536
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
@@ -5984,37 +7614,12 @@ async function handleCreateWorktree(ctx, params, _extra) {
5984
7614
  return formatToolResponse({
5985
7615
  success: true,
5986
7616
  branchName,
5987
- worktreePath: path15.resolve(worktreePath),
7617
+ worktreePath: path21.resolve(worktreePath),
5988
7618
  created,
5989
7619
  pushed
5990
7620
  });
5991
7621
  });
5992
7622
  }
5993
- async function handleRemoveWorktree(ctx, params, _extra) {
5994
- const { service, git } = await getReadyService(ctx, params.repoName, {
5995
- capability: "removeWorktree",
5996
- toolName: "remove_worktree"
5997
- });
5998
- ensureWorktreeModeService(service, "remove_worktree");
5999
- return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
6000
- if (!service.isInitialized()) {
6001
- await service.initializeUnlocked();
6002
- }
6003
- const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
6004
- if (!params.force) {
6005
- const status = await git.getFullWorktreeStatus(params.path, false);
6006
- if (!status.canRemove) {
6007
- throw new Error(`Cannot remove worktree: ${status.reasons.join(", ")}. Use force=true to override.`);
6008
- }
6009
- }
6010
- await git.removeWorktree(params.path);
6011
- ctx.invalidateDiscovered();
6012
- return formatToolResponse({
6013
- success: true,
6014
- removedPath
6015
- });
6016
- });
6017
- }
6018
7623
  async function handleSync(ctx, params, extra) {
6019
7624
  const { service } = await getReadyService(ctx, params.repoName, {
6020
7625
  capability: "sync",
@@ -6023,7 +7628,6 @@ async function handleSync(ctx, params, extra) {
6023
7628
  const dispose = attachProgressReporter(service, extra);
6024
7629
  try {
6025
7630
  const start = Date.now();
6026
- service.clearRecordedSkips();
6027
7631
  const result = await service.sync();
6028
7632
  if (!result.started) {
6029
7633
  throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
@@ -6094,17 +7698,28 @@ async function handleInitialize(ctx, params, extra) {
6094
7698
  }
6095
7699
  }
6096
7700
  async function handleLoadConfig(ctx, params, _extra) {
6097
- const configPath = params.configPath ?? process.env.SYNC_WORKTREES_CONFIG;
7701
+ const configPath = params.configPath ?? process.env.SYNC_WORKTREES_CONFIG ?? ctx.getConfigPath() ?? await detectConfigFromLaunchCwd(ctx);
6098
7702
  if (!configPath) {
6099
- 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
+ );
6100
7706
  }
6101
7707
  await ctx.loadConfig(configPath);
6102
7708
  return formatToolResponse({
6103
- configPath: path15.resolve(configPath),
7709
+ configPath: path21.resolve(configPath),
6104
7710
  currentRepository: ctx.getCurrentRepo(),
6105
7711
  repositories: ctx.getRepositoryList()
6106
7712
  });
6107
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
+ }
6108
7723
  async function handleSetCurrentRepository(ctx, params, _extra) {
6109
7724
  ctx.setCurrentRepo(params.repoName);
6110
7725
  return formatToolResponse({
@@ -6200,7 +7815,7 @@ function createServer(context, snapshot) {
6200
7815
  detailed: z.boolean().optional().default(false).describe("Expand configuredRepositories with repoUrl, branch, sparseCheckout, localReady, bareRepoDir."),
6201
7816
  includeAllWorktrees: z.boolean().optional().describe("Include allWorktreesByRepo + allWorktreeErrorsByRepo for each configured repo. Default: false."),
6202
7817
  includeStatus: z.boolean().optional().describe(
6203
- "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."
6204
7819
  )
6205
7820
  },
6206
7821
  annotations: {
@@ -6271,25 +7886,6 @@ function createServer(context, snapshot) {
6271
7886
  },
6272
7887
  wrapHandler((params, extra) => handleCreateWorktree(context, params, extra))
6273
7888
  );
6274
- server.registerTool(
6275
- "remove_worktree",
6276
- {
6277
- 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}.",
6278
- inputSchema: {
6279
- path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
6280
- force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
6281
- repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
6282
- },
6283
- annotations: {
6284
- title: "Remove worktree",
6285
- readOnlyHint: false,
6286
- destructiveHint: true,
6287
- idempotentHint: false,
6288
- openWorldHint: false
6289
- }
6290
- },
6291
- wrapHandler((params, extra) => handleRemoveWorktree(context, params, extra))
6292
- );
6293
7889
  server.registerTool(
6294
7890
  "sync",
6295
7891
  {
@@ -6345,9 +7941,11 @@ function createServer(context, snapshot) {
6345
7941
  server.registerTool(
6346
7942
  "load_config",
6347
7943
  {
6348
- 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}]}.",
6349
7945
  inputSchema: {
6350
- 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
+ )
6351
7949
  },
6352
7950
  annotations: {
6353
7951
  title: "Load sync-worktrees config",