sync-worktrees 3.6.2 → 4.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 (126) hide show
  1. package/README.md +388 -263
  2. package/dist/components/App.d.ts +52 -0
  3. package/dist/components/App.d.ts.map +1 -0
  4. package/dist/components/BranchCreationWizard.d.ts +26 -0
  5. package/dist/components/BranchCreationWizard.d.ts.map +1 -0
  6. package/dist/components/HelpModal.d.ts +7 -0
  7. package/dist/components/HelpModal.d.ts.map +1 -0
  8. package/dist/components/LogPanel.d.ts +10 -0
  9. package/dist/components/LogPanel.d.ts.map +1 -0
  10. package/dist/components/LogViewer.d.ts +9 -0
  11. package/dist/components/LogViewer.d.ts.map +1 -0
  12. package/dist/components/OpenEditorWizard.d.ts +25 -0
  13. package/dist/components/OpenEditorWizard.d.ts.map +1 -0
  14. package/dist/components/StatusBar.d.ts +11 -0
  15. package/dist/components/StatusBar.d.ts.map +1 -0
  16. package/dist/components/WorktreeStatusView.d.ts +17 -0
  17. package/dist/components/WorktreeStatusView.d.ts.map +1 -0
  18. package/dist/constants.d.ts +112 -0
  19. package/dist/constants.d.ts.map +1 -0
  20. package/dist/errors/index.d.ts +59 -0
  21. package/dist/errors/index.d.ts.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +2327 -1082
  25. package/dist/index.js.map +4 -4
  26. package/dist/mcp/context.d.ts +143 -0
  27. package/dist/mcp/context.d.ts.map +1 -0
  28. package/dist/mcp/handlers.d.ts +46 -0
  29. package/dist/mcp/handlers.d.ts.map +1 -0
  30. package/dist/mcp/index.d.ts +2 -0
  31. package/dist/mcp/index.d.ts.map +1 -0
  32. package/dist/mcp/server.d.ts +9 -0
  33. package/dist/mcp/server.d.ts.map +1 -0
  34. package/dist/mcp/utils.d.ts +14 -0
  35. package/dist/mcp/utils.d.ts.map +1 -0
  36. package/dist/mcp/worktree-summary.d.ts +14 -0
  37. package/dist/mcp/worktree-summary.d.ts.map +1 -0
  38. package/dist/mcp-server.js +2513 -691
  39. package/dist/mcp-server.js.map +4 -4
  40. package/dist/services/InteractiveUIService.d.ts +85 -0
  41. package/dist/services/InteractiveUIService.d.ts.map +1 -0
  42. package/dist/services/branch-created-actions.service.d.ts +27 -0
  43. package/dist/services/branch-created-actions.service.d.ts.map +1 -0
  44. package/dist/services/clone-sync.service.d.ts +93 -0
  45. package/dist/services/clone-sync.service.d.ts.map +1 -0
  46. package/dist/services/config-loader.service.d.ts +28 -0
  47. package/dist/services/config-loader.service.d.ts.map +1 -0
  48. package/dist/services/file-copy.service.d.ts +19 -0
  49. package/dist/services/file-copy.service.d.ts.map +1 -0
  50. package/dist/services/git.service.d.ts +92 -0
  51. package/dist/services/git.service.d.ts.map +1 -0
  52. package/dist/services/hook-execution.service.d.ts +20 -0
  53. package/dist/services/hook-execution.service.d.ts.map +1 -0
  54. package/dist/services/logger.service.d.ts +24 -0
  55. package/dist/services/logger.service.d.ts.map +1 -0
  56. package/dist/services/path-resolution.service.d.ts +10 -0
  57. package/dist/services/path-resolution.service.d.ts.map +1 -0
  58. package/dist/services/progress-emitter.d.ts +14 -0
  59. package/dist/services/progress-emitter.d.ts.map +1 -0
  60. package/dist/services/repo-operation-lock.d.ts +16 -0
  61. package/dist/services/repo-operation-lock.d.ts.map +1 -0
  62. package/dist/services/sparse-checkout.service.d.ts +45 -0
  63. package/dist/services/sparse-checkout.service.d.ts.map +1 -0
  64. package/dist/services/sync-outcome.d.ts +47 -0
  65. package/dist/services/sync-outcome.d.ts.map +1 -0
  66. package/dist/services/sync-retry-policy.d.ts +18 -0
  67. package/dist/services/sync-retry-policy.d.ts.map +1 -0
  68. package/dist/services/worktree-metadata.service.d.ts +25 -0
  69. package/dist/services/worktree-metadata.service.d.ts.map +1 -0
  70. package/dist/services/worktree-mode-sync-runner.d.ts +36 -0
  71. package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -0
  72. package/dist/services/worktree-status.service.d.ts +60 -0
  73. package/dist/services/worktree-status.service.d.ts.map +1 -0
  74. package/dist/services/worktree-sync-planner.d.ts +62 -0
  75. package/dist/services/worktree-sync-planner.d.ts.map +1 -0
  76. package/dist/services/worktree-sync.service.d.ts +49 -0
  77. package/dist/services/worktree-sync.service.d.ts.map +1 -0
  78. package/dist/types/index.d.ts +289 -0
  79. package/dist/types/index.d.ts.map +1 -0
  80. package/dist/types/sync-metadata.d.ts +16 -0
  81. package/dist/types/sync-metadata.d.ts.map +1 -0
  82. package/dist/utils/app-events.d.ts +21 -0
  83. package/dist/utils/app-events.d.ts.map +1 -0
  84. package/dist/utils/branch-filter.d.ts +3 -0
  85. package/dist/utils/branch-filter.d.ts.map +1 -0
  86. package/dist/utils/cli.d.ts +21 -0
  87. package/dist/utils/cli.d.ts.map +1 -0
  88. package/dist/utils/clone-skip-format.d.ts +3 -0
  89. package/dist/utils/clone-skip-format.d.ts.map +1 -0
  90. package/dist/utils/config-generator.d.ts +10 -0
  91. package/dist/utils/config-generator.d.ts.map +1 -0
  92. package/dist/utils/date-filter.d.ts +10 -0
  93. package/dist/utils/date-filter.d.ts.map +1 -0
  94. package/dist/utils/disk-space.d.ts +23 -0
  95. package/dist/utils/disk-space.d.ts.map +1 -0
  96. package/dist/utils/file-exists.d.ts +2 -0
  97. package/dist/utils/file-exists.d.ts.map +1 -0
  98. package/dist/utils/git-progress.d.ts +25 -0
  99. package/dist/utils/git-progress.d.ts.map +1 -0
  100. package/dist/utils/git-url.d.ts +23 -0
  101. package/dist/utils/git-url.d.ts.map +1 -0
  102. package/dist/utils/git-validation.d.ts +5 -0
  103. package/dist/utils/git-validation.d.ts.map +1 -0
  104. package/dist/utils/interactive.d.ts +3 -0
  105. package/dist/utils/interactive.d.ts.map +1 -0
  106. package/dist/utils/lfs-error.d.ts +35 -0
  107. package/dist/utils/lfs-error.d.ts.map +1 -0
  108. package/dist/utils/lock-path.d.ts +9 -0
  109. package/dist/utils/lock-path.d.ts.map +1 -0
  110. package/dist/utils/path-compare.d.ts +16 -0
  111. package/dist/utils/path-compare.d.ts.map +1 -0
  112. package/dist/utils/repo-mode.d.ts +8 -0
  113. package/dist/utils/repo-mode.d.ts.map +1 -0
  114. package/dist/utils/retry.d.ts +24 -0
  115. package/dist/utils/retry.d.ts.map +1 -0
  116. package/dist/utils/sanitize-name.d.ts +2 -0
  117. package/dist/utils/sanitize-name.d.ts.map +1 -0
  118. package/dist/utils/shell-escape.d.ts +5 -0
  119. package/dist/utils/shell-escape.d.ts.map +1 -0
  120. package/dist/utils/signal-handlers.d.ts +14 -0
  121. package/dist/utils/signal-handlers.d.ts.map +1 -0
  122. package/dist/utils/timing.d.ts +24 -0
  123. package/dist/utils/timing.d.ts.map +1 -0
  124. package/dist/utils/worktree-list-parser.d.ts +10 -0
  125. package/dist/utils/worktree-list-parser.d.ts.map +1 -0
  126. package/package.json +5 -2
@@ -4,9 +4,10 @@
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
6
  // src/mcp/context.ts
7
- import * as fs7 from "fs/promises";
8
- import * as path9 from "path";
9
- import simpleGit5 from "simple-git";
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";
10
11
 
11
12
  // src/constants.ts
12
13
  var GIT_CONSTANTS = {
@@ -89,7 +90,8 @@ var ENV_CONSTANTS = {
89
90
  };
90
91
  var PATH_CONSTANTS = {
91
92
  GIT_DIR: ".git",
92
- README: "README"
93
+ README: "README",
94
+ CLONE_INIT_MARKER: ".sync-worktrees-clone-init"
93
95
  };
94
96
  var CONFIG_FILE_NAMES = [
95
97
  "sync-worktrees.config.js",
@@ -108,11 +110,65 @@ var METADATA_CONSTANTS = {
108
110
  };
109
111
 
110
112
  // src/services/config-loader.service.ts
111
- import * as fs from "fs/promises";
112
113
  import * as path2 from "path";
113
114
  import { pathToFileURL } from "url";
114
115
  import * as cron from "node-cron";
115
116
 
117
+ // src/errors/index.ts
118
+ var SyncWorktreesError = class extends Error {
119
+ constructor(message, code, cause) {
120
+ super(message);
121
+ this.code = code;
122
+ this.cause = cause;
123
+ this.name = this.constructor.name;
124
+ Object.setPrototypeOf(this, new.target.prototype);
125
+ if (cause && cause.stack) {
126
+ this.stack = `${this.stack}
127
+ Caused by: ${cause.stack}`;
128
+ }
129
+ }
130
+ };
131
+ var GitError = class extends SyncWorktreesError {
132
+ constructor(message, code, cause) {
133
+ super(message, `GIT_${code}`, cause);
134
+ }
135
+ };
136
+ var GitOperationError = class extends GitError {
137
+ constructor(operation, details, cause) {
138
+ super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
139
+ }
140
+ };
141
+ var WorktreeError = class extends SyncWorktreesError {
142
+ constructor(message, code, cause) {
143
+ super(message, `WORKTREE_${code}`, cause);
144
+ }
145
+ };
146
+ 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;
150
+ this.reasons = reasons;
151
+ }
152
+ };
153
+ var ConfigError = class extends SyncWorktreesError {
154
+ constructor(message, code, cause) {
155
+ super(message, `CONFIG_${code}`, cause);
156
+ }
157
+ };
158
+ var ConfigValidationError = class extends ConfigError {
159
+ constructor(field, reason) {
160
+ super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
161
+ this.field = field;
162
+ this.reason = reason;
163
+ }
164
+ };
165
+ var ConfigFileNotFoundError = class extends ConfigError {
166
+ constructor(configPath) {
167
+ super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
168
+ this.configPath = configPath;
169
+ }
170
+ };
171
+
116
172
  // src/utils/branch-filter.ts
117
173
  function matchesPattern(name, pattern) {
118
174
  if (pattern.includes("*")) {
@@ -133,6 +189,17 @@ function filterBranchesByName(branches, include, exclude) {
133
189
  return result;
134
190
  }
135
191
 
192
+ // src/utils/file-exists.ts
193
+ import * as fs from "fs/promises";
194
+ async function fileExists(path16) {
195
+ try {
196
+ await fs.access(path16);
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
136
203
  // src/utils/git-url.ts
137
204
  function extractRepoNameFromUrl(gitUrl) {
138
205
  const url = gitUrl.trim();
@@ -158,6 +225,16 @@ function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
158
225
  const repoName = extractRepoNameFromUrl(repoUrl);
159
226
  return `${baseDir}/${repoName}`;
160
227
  }
228
+ function normalizeRepoUrlForComparison(url) {
229
+ let normalized = url.trim();
230
+ const isForgeUrl = /^(https?|ssh|git):\/\//i.test(normalized) || /^[\w.-]+@[^/]+:/.test(normalized);
231
+ normalized = normalized.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+/, (prefix) => prefix.toLowerCase());
232
+ normalized = normalized.replace(/\/+$/, "");
233
+ if (isForgeUrl) {
234
+ normalized = normalized.replace(/\.git$/, "");
235
+ }
236
+ return normalized;
237
+ }
161
238
 
162
239
  // src/utils/path-compare.ts
163
240
  import * as path from "path";
@@ -173,54 +250,17 @@ function pathsEqual(a, b, platform = process.platform) {
173
250
  return normalizePathForCompare(a, platform) === normalizePathForCompare(b, platform);
174
251
  }
175
252
 
176
- // src/errors/index.ts
177
- var SyncWorktreesError = class extends Error {
178
- constructor(message, code, cause) {
179
- super(message);
180
- this.code = code;
181
- this.cause = cause;
182
- this.name = this.constructor.name;
183
- Object.setPrototypeOf(this, new.target.prototype);
184
- if (cause && cause.stack) {
185
- this.stack = `${this.stack}
186
- Caused by: ${cause.stack}`;
187
- }
188
- }
189
- };
190
- var GitError = class extends SyncWorktreesError {
191
- constructor(message, code, cause) {
192
- super(message, `GIT_${code}`, cause);
193
- }
194
- };
195
- var GitOperationError = class extends GitError {
196
- constructor(operation, details, cause) {
197
- super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
198
- }
199
- };
200
- var WorktreeError = class extends SyncWorktreesError {
201
- constructor(message, code, cause) {
202
- super(message, `WORKTREE_${code}`, cause);
203
- }
204
- };
205
- var WorktreeNotCleanError = class extends WorktreeError {
206
- constructor(path11, reasons) {
207
- super(`Worktree at '${path11}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
208
- this.path = path11;
209
- this.reasons = reasons;
210
- }
211
- };
212
- var ConfigError = class extends SyncWorktreesError {
213
- constructor(message, code, cause) {
214
- super(message, `CONFIG_${code}`, cause);
215
- }
216
- };
217
- var ConfigValidationError = class extends ConfigError {
218
- constructor(field, reason) {
219
- super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
220
- this.field = field;
221
- this.reason = reason;
222
- }
253
+ // src/utils/repo-mode.ts
254
+ var REPOSITORY_MODES = {
255
+ CLONE: "clone",
256
+ WORKTREE: "worktree"
223
257
  };
258
+ function isRepositoryMode(value) {
259
+ return value === REPOSITORY_MODES.CLONE || value === REPOSITORY_MODES.WORKTREE;
260
+ }
261
+ function resolveMode(cfg) {
262
+ return cfg.mode ?? REPOSITORY_MODES.WORKTREE;
263
+ }
224
264
 
225
265
  // src/utils/sanitize-name.ts
226
266
  var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
@@ -244,6 +284,13 @@ function sanitizeNameForPath(name, fieldContext = "name") {
244
284
  }
245
285
 
246
286
  // src/services/config-loader.service.ts
287
+ var CLONE_MODE_CONFLICTING_FIELDS = [
288
+ "branchInclude",
289
+ "branchExclude",
290
+ "branchMaxAge",
291
+ "updateExistingWorktrees",
292
+ "bareRepoDir"
293
+ ];
247
294
  var ConfigLoaderService = class {
248
295
  async findConfigUpward(startDir) {
249
296
  let current = path2.resolve(startDir);
@@ -251,10 +298,8 @@ var ConfigLoaderService = class {
251
298
  while (true) {
252
299
  for (const name of CONFIG_FILE_NAMES) {
253
300
  const candidate = path2.join(current, name);
254
- try {
255
- await fs.access(candidate);
301
+ if (await fileExists(candidate)) {
256
302
  return candidate;
257
- } catch {
258
303
  }
259
304
  }
260
305
  if (current === root) return null;
@@ -265,10 +310,8 @@ var ConfigLoaderService = class {
265
310
  }
266
311
  async loadConfigFile(configPath) {
267
312
  const absolutePath = path2.resolve(configPath);
268
- try {
269
- await fs.access(absolutePath);
270
- } catch {
271
- throw new Error(`Config file not found: ${absolutePath}`);
313
+ if (!await fileExists(absolutePath)) {
314
+ throw new ConfigFileNotFoundError(absolutePath);
272
315
  }
273
316
  try {
274
317
  const fileUrl = pathToFileURL(absolutePath);
@@ -281,7 +324,7 @@ var ConfigLoaderService = class {
281
324
  this.validateConfigFile(config);
282
325
  return config;
283
326
  } catch (error) {
284
- if (error instanceof Error && error.message.includes("Config file not found")) {
327
+ if (error instanceof SyncWorktreesError) {
285
328
  throw error;
286
329
  }
287
330
  throw new Error(`Failed to load config file: ${error.message}`);
@@ -334,6 +377,12 @@ var ConfigLoaderService = class {
334
377
  if (repoObj.runOnce !== void 0 && typeof repoObj.runOnce !== "boolean") {
335
378
  throw new Error(`Repository '${repoObj.name}' has invalid 'runOnce' property`);
336
379
  }
380
+ if (repoObj.debug !== void 0 && typeof repoObj.debug !== "boolean") {
381
+ throw new Error(`Repository '${repoObj.name}' has invalid 'debug' property`);
382
+ }
383
+ if (repoObj.retry !== void 0) {
384
+ this.validateRetryConfig(repoObj.retry, `Repository '${repoObj.name}' retry config`);
385
+ }
337
386
  if (repoObj.filesToCopyOnBranchCreate !== void 0) {
338
387
  this.validateFilesToCopyConfig(repoObj.filesToCopyOnBranchCreate, `Repository '${repoObj.name}'`);
339
388
  }
@@ -343,6 +392,8 @@ var ConfigLoaderService = class {
343
392
  if (repoObj.sparseCheckout !== void 0) {
344
393
  this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
345
394
  }
395
+ this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
396
+ this.validateRepositoryMode(repoObj, configObj.defaults);
346
397
  });
347
398
  this.warnOnDuplicateRepoUrls(configObj.repositories);
348
399
  if (configObj.defaults) {
@@ -359,9 +410,15 @@ var ConfigLoaderService = class {
359
410
  if (defaults.runOnce !== void 0 && typeof defaults.runOnce !== "boolean") {
360
411
  throw new Error("Invalid 'runOnce' in defaults");
361
412
  }
413
+ if (defaults.debug !== void 0 && typeof defaults.debug !== "boolean") {
414
+ throw new Error("Invalid 'debug' in defaults");
415
+ }
362
416
  if (defaults.retry !== void 0 && typeof defaults.retry !== "object") {
363
417
  throw new Error("Invalid 'retry' in defaults");
364
418
  }
419
+ if (defaults.retry !== void 0) {
420
+ this.validateRetryConfig(defaults.retry, "defaults retry config");
421
+ }
365
422
  if (defaults.filesToCopyOnBranchCreate !== void 0) {
366
423
  this.validateFilesToCopyConfig(defaults.filesToCopyOnBranchCreate, "defaults");
367
424
  }
@@ -371,39 +428,17 @@ var ConfigLoaderService = class {
371
428
  if (defaults.sparseCheckout !== void 0) {
372
429
  this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
373
430
  }
374
- }
375
- if (configObj.retry !== void 0) {
376
- if (typeof configObj.retry !== "object") {
377
- throw new Error("'retry' must be an object");
378
- }
379
- const retry2 = configObj.retry;
380
- if (retry2.maxAttempts !== void 0) {
381
- if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
382
- throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
383
- }
384
- }
385
- if (retry2.maxLfsRetries !== void 0) {
386
- if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
387
- throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
388
- }
389
- }
390
- if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
391
- throw new Error("Invalid 'initialDelayMs' in retry config");
392
- }
393
- if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
394
- throw new Error("Invalid 'maxDelayMs' in retry config");
431
+ this.validateDepth(defaults.depth, "defaults.depth");
432
+ if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
433
+ throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
395
434
  }
396
- if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
397
- throw new Error("Invalid 'backoffMultiplier' in retry config");
398
- }
399
- const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
400
- const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
401
- if (initialDelay > maxDelay) {
402
- throw new Error(
403
- `Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
404
- );
435
+ if (defaults.branch !== void 0 && (typeof defaults.branch !== "string" || defaults.branch.trim() === "")) {
436
+ throw new ConfigValidationError("defaults.branch", "must be a non-empty string");
405
437
  }
406
438
  }
439
+ if (configObj.retry !== void 0) {
440
+ this.validateRetryConfig(configObj.retry, "retry config");
441
+ }
407
442
  if (configObj.parallelism !== void 0) {
408
443
  this.validateParallelismConfig(configObj.parallelism, "global");
409
444
  }
@@ -414,6 +449,47 @@ var ConfigLoaderService = class {
414
449
  }
415
450
  }
416
451
  }
452
+ validateDepth(value, field) {
453
+ if (value === void 0) return;
454
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
455
+ throw new ConfigValidationError(field, "must be a positive safe integer");
456
+ }
457
+ }
458
+ validateRetryConfig(value, context) {
459
+ if (typeof value !== "object" || value === null) {
460
+ throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
461
+ }
462
+ const retry2 = value;
463
+ if (retry2.maxAttempts !== void 0) {
464
+ if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
465
+ throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
466
+ }
467
+ }
468
+ if (retry2.maxLfsRetries !== void 0) {
469
+ if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
470
+ throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
471
+ }
472
+ }
473
+ if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
474
+ throw new Error("Invalid 'initialDelayMs' in retry config");
475
+ }
476
+ if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
477
+ throw new Error("Invalid 'maxDelayMs' in retry config");
478
+ }
479
+ if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
480
+ throw new Error("Invalid 'backoffMultiplier' in retry config");
481
+ }
482
+ if (retry2.jitterMs !== void 0 && (typeof retry2.jitterMs !== "number" || retry2.jitterMs < 0)) {
483
+ throw new Error("Invalid 'jitterMs' in retry config");
484
+ }
485
+ const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
486
+ const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
487
+ if (initialDelay > maxDelay) {
488
+ throw new Error(
489
+ `Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
490
+ );
491
+ }
492
+ }
417
493
  validateParallelismConfig(parallelism, context) {
418
494
  if (typeof parallelism !== "object" || parallelism === null) {
419
495
  throw new Error(`'parallelism' in ${context} must be an object`);
@@ -514,6 +590,50 @@ var ConfigLoaderService = class {
514
590
  }
515
591
  }
516
592
  }
593
+ validateRepositoryMode(repoObj, defaults) {
594
+ const repoName = repoObj.name;
595
+ const repoMode = repoObj.mode;
596
+ if (repoMode !== void 0 && !isRepositoryMode(repoMode)) {
597
+ throw new ConfigValidationError(`Repository '${repoName}' mode`, "must be 'clone' or 'worktree'");
598
+ }
599
+ if (repoObj.branch !== void 0 && (typeof repoObj.branch !== "string" || repoObj.branch.trim() === "")) {
600
+ throw new ConfigValidationError(`Repository '${repoName}' branch`, "must be a non-empty string");
601
+ }
602
+ const effectiveMode = repoMode ?? defaults?.mode;
603
+ if (effectiveMode !== REPOSITORY_MODES.CLONE) {
604
+ const depthFromRepo = repoObj.depth;
605
+ const depthFromDefaults = defaults?.depth;
606
+ if (depthFromRepo !== void 0 || depthFromDefaults !== void 0) {
607
+ const source = depthFromRepo !== void 0 ? "repository" : "defaults";
608
+ throw new ConfigValidationError(
609
+ `Repository '${repoName}' depth`,
610
+ `only supported when mode is 'clone' (set on ${source})`
611
+ );
612
+ }
613
+ const branchFromRepo = repoObj.branch;
614
+ const branchFromDefaults = defaults?.branch;
615
+ if (branchFromRepo !== void 0 || branchFromDefaults !== void 0) {
616
+ const source = branchFromRepo !== void 0 ? "repository" : "defaults";
617
+ throw new ConfigValidationError(
618
+ `Repository '${repoName}' branch`,
619
+ `only supported when mode is 'clone' (set on ${source})`
620
+ );
621
+ }
622
+ return;
623
+ }
624
+ for (const field of CLONE_MODE_CONFLICTING_FIELDS) {
625
+ const fromRepo = repoObj[field];
626
+ const fromDefaults = defaults?.[field];
627
+ const present = fromRepo !== void 0 || fromDefaults !== void 0;
628
+ if (present) {
629
+ const source = fromRepo !== void 0 ? "repository" : "defaults";
630
+ throw new ConfigValidationError(
631
+ `Repository '${repoName}' ${field}`,
632
+ `not supported when mode is 'clone' (set on ${source})`
633
+ );
634
+ }
635
+ }
636
+ }
517
637
  validateHooksConfig(hooks, context) {
518
638
  if (typeof hooks !== "object" || hooks === null) {
519
639
  throw new Error(`'hooks' in ${context} must be an object`);
@@ -534,29 +654,47 @@ var ConfigLoaderService = class {
534
654
  }
535
655
  }
536
656
  resolveRepositoryConfig(repo, defaults, configDir, globalRetry, allRepositories) {
657
+ const mode = repo.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
537
658
  const resolved = {
538
659
  name: repo.name,
539
660
  repoUrl: repo.repoUrl,
540
661
  worktreeDir: this.resolvePath(repo.worktreeDir, configDir),
541
662
  cronSchedule: repo.cronSchedule ?? defaults?.cronSchedule ?? DEFAULT_CONFIG.CRON_SCHEDULE,
542
- runOnce: repo.runOnce ?? defaults?.runOnce ?? false
663
+ runOnce: repo.runOnce ?? defaults?.runOnce ?? false,
664
+ debug: repo.debug ?? defaults?.debug,
665
+ mode
543
666
  };
544
- if (repo.bareRepoDir) {
545
- resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
546
- } else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories)) {
547
- const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
548
- resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
549
- } else {
550
- resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
667
+ if (configDir) {
668
+ resolved.__configFileDir = configDir;
551
669
  }
552
- if (repo.branchMaxAge || defaults?.branchMaxAge) {
553
- resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
554
- }
555
- if (repo.branchInclude || defaults?.branchInclude) {
556
- resolved.branchInclude = repo.branchInclude ?? defaults?.branchInclude;
557
- }
558
- if (repo.branchExclude || defaults?.branchExclude) {
559
- resolved.branchExclude = repo.branchExclude ?? defaults?.branchExclude;
670
+ if (mode === REPOSITORY_MODES.CLONE) {
671
+ if (repo.branch ?? defaults?.branch) {
672
+ resolved.branch = repo.branch ?? defaults?.branch;
673
+ }
674
+ if (repo.depth !== void 0 || defaults?.depth !== void 0) {
675
+ resolved.depth = repo.depth ?? defaults?.depth;
676
+ }
677
+ } else {
678
+ if (repo.bareRepoDir) {
679
+ resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
680
+ } else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories, defaults)) {
681
+ const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
682
+ resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
683
+ } else {
684
+ resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
685
+ }
686
+ if (repo.branchMaxAge || defaults?.branchMaxAge) {
687
+ resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
688
+ }
689
+ if (repo.branchInclude || defaults?.branchInclude) {
690
+ resolved.branchInclude = repo.branchInclude ?? defaults?.branchInclude;
691
+ }
692
+ if (repo.branchExclude || defaults?.branchExclude) {
693
+ resolved.branchExclude = repo.branchExclude ?? defaults?.branchExclude;
694
+ }
695
+ if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
696
+ resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
697
+ }
560
698
  }
561
699
  if (repo.skipLfs !== void 0 || defaults?.skipLfs !== void 0) {
562
700
  resolved.skipLfs = repo.skipLfs ?? defaults?.skipLfs ?? false;
@@ -574,9 +712,6 @@ var ConfigLoaderService = class {
574
712
  ...repo.parallelism || {}
575
713
  };
576
714
  }
577
- if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
578
- resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
579
- }
580
715
  if (repo.filesToCopyOnBranchCreate || defaults?.filesToCopyOnBranchCreate) {
581
716
  const files = repo.filesToCopyOnBranchCreate ?? defaults?.filesToCopyOnBranchCreate;
582
717
  resolved.filesToCopyOnBranchCreate = files?.map((f) => this.resolvePath(f, configDir));
@@ -593,8 +728,11 @@ var ConfigLoaderService = class {
593
728
  }
594
729
  return resolved;
595
730
  }
596
- isDuplicateRepoUrl(repo, all) {
597
- const firstIndex = all.findIndex((r) => r.repoUrl === repo.repoUrl);
731
+ isDuplicateRepoUrl(repo, all, defaults) {
732
+ const firstIndex = all.findIndex((r) => {
733
+ const mode = r.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
734
+ return r.repoUrl === repo.repoUrl && mode === REPOSITORY_MODES.WORKTREE;
735
+ });
598
736
  const myIndex = all.indexOf(repo);
599
737
  return firstIndex !== -1 && myIndex !== -1 && myIndex !== firstIndex;
600
738
  }
@@ -645,12 +783,6 @@ var ConfigLoaderService = class {
645
783
  if (overrides?.filter) {
646
784
  repositories = this.filterRepositories(repositories, overrides.filter);
647
785
  }
648
- if (overrides?.noUpdateExisting) {
649
- repositories = repositories.map((repo) => ({ ...repo, updateExistingWorktrees: false }));
650
- }
651
- if (overrides?.debug) {
652
- repositories = repositories.map((repo) => ({ ...repo, debug: true }));
653
- }
654
786
  return { repositories, configFile, configDir };
655
787
  }
656
788
  };
@@ -754,60 +886,6 @@ function defaultConsoleOutput(msg, level) {
754
886
  else console.log(msg);
755
887
  }
756
888
 
757
- // src/services/worktree-sync.service.ts
758
- import * as fs6 from "fs/promises";
759
- import * as path8 from "path";
760
- import pLimit from "p-limit";
761
- import * as lockfile from "proper-lockfile";
762
-
763
- // src/utils/date-filter.ts
764
- function parseDuration(durationStr) {
765
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
766
- if (!match) {
767
- return null;
768
- }
769
- const value = parseInt(match[1], 10);
770
- const unit = match[2];
771
- const multipliers = {
772
- h: 60 * 60 * 1e3,
773
- // hours
774
- d: 24 * 60 * 60 * 1e3,
775
- // days
776
- w: 7 * 24 * 60 * 60 * 1e3,
777
- // weeks
778
- m: 30 * 24 * 60 * 60 * 1e3,
779
- // months (approximate)
780
- y: 365 * 24 * 60 * 60 * 1e3
781
- // years (approximate)
782
- };
783
- return value * multipliers[unit];
784
- }
785
- function filterBranchesByAge(branches, maxAge) {
786
- const maxAgeMs = parseDuration(maxAge);
787
- if (maxAgeMs === null) {
788
- console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
789
- return branches;
790
- }
791
- const cutoffDate = new Date(Date.now() - maxAgeMs);
792
- return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
793
- }
794
- function formatDuration(durationStr) {
795
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
796
- if (!match) {
797
- return durationStr;
798
- }
799
- const value = parseInt(match[1], 10);
800
- const unit = match[2];
801
- const unitNames = {
802
- h: value === 1 ? "hour" : "hours",
803
- d: value === 1 ? "day" : "days",
804
- w: value === 1 ? "week" : "weeks",
805
- m: value === 1 ? "month" : "months",
806
- y: value === 1 ? "year" : "years"
807
- };
808
- return `${value} ${unitNames[unit]}`;
809
- }
810
-
811
889
  // src/utils/lfs-error.ts
812
890
  function getErrorMessage(error) {
813
891
  if (error instanceof Error) {
@@ -829,6 +907,14 @@ function isLfsError(errorMessage) {
829
907
  function isLfsErrorFromError(error) {
830
908
  return isLfsError(getErrorMessage(error));
831
909
  }
910
+ var MISSING_REMOTE_REF_PATTERNS = Object.freeze([
911
+ "couldn't find remote ref",
912
+ "Couldn't find remote ref",
913
+ "not our ref"
914
+ ]);
915
+ function isMissingRemoteRefError(errorMessage) {
916
+ return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
917
+ }
832
918
 
833
919
  // src/utils/retry.ts
834
920
  var DEFAULT_OPTIONS = {
@@ -900,7 +986,7 @@ async function retry(fn, options = {}) {
900
986
  const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
901
987
  const delay = baseDelay + jitter;
902
988
  opts.onRetry(error, attempt, lfsContext);
903
- await new Promise((resolve9) => setTimeout(resolve9, delay));
989
+ await new Promise((resolve11) => setTimeout(resolve11, delay));
904
990
  attempt++;
905
991
  }
906
992
  }
@@ -971,7 +1057,7 @@ var PhaseTimer = class {
971
1057
  return results;
972
1058
  }
973
1059
  };
974
- function formatDuration2(ms) {
1060
+ function formatDuration(ms) {
975
1061
  if (ms < 1e3) {
976
1062
  return `${ms}ms`;
977
1063
  }
@@ -993,7 +1079,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
993
1079
  }
994
1080
  });
995
1081
  table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
996
- table.push(["Total Sync", formatDuration2(totalDuration), ""]);
1082
+ table.push(["Total Sync", formatDuration(totalDuration), ""]);
997
1083
  for (let i = 0; i < phaseResults.length; i++) {
998
1084
  const result = phaseResults[i];
999
1085
  const isLast = i === phaseResults.length - 1;
@@ -1001,37 +1087,901 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
1001
1087
  const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
1002
1088
  const name = ` ${prefix} ${result.name}${countStr}`;
1003
1089
  const efficiency = result.efficiency ? `${result.efficiency}%` : "";
1004
- table.push([name, formatDuration2(result.duration), efficiency]);
1090
+ table.push([name, formatDuration(result.duration), efficiency]);
1005
1091
  }
1006
1092
  return table.toString();
1007
1093
  }
1008
1094
 
1009
- // src/services/git.service.ts
1010
- import * as fs4 from "fs/promises";
1011
- import * as path6 from "path";
1012
- import simpleGit4 from "simple-git";
1095
+ // src/services/clone-sync.service.ts
1096
+ import * as fs3 from "fs/promises";
1097
+ import * as path4 from "path";
1098
+ import simpleGit from "simple-git";
1013
1099
 
1014
- // src/utils/worktree-list-parser.ts
1015
- function parseWorktreeListPorcelain(output) {
1016
- const worktrees = [];
1017
- let current = {};
1018
- const flush = () => {
1019
- if (!current.path) {
1020
- current = {};
1021
- return;
1022
- }
1023
- worktrees.push({
1024
- path: current.path,
1025
- branch: current.branch ?? null,
1026
- head: current.head ?? null,
1027
- detached: current.detached ?? false,
1028
- prunable: current.prunable ?? false,
1029
- locked: current.locked ?? false
1100
+ // src/utils/git-progress.ts
1101
+ function makeGitProgressHandler(logger, emitProgress) {
1102
+ const lastBucket = /* @__PURE__ */ new Map();
1103
+ return (event) => {
1104
+ if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
1105
+ const key = `${event.method}:${event.stage}`;
1106
+ const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
1107
+ let last = lastBucket.get(key) ?? -1;
1108
+ if (bucket < last) last = -1;
1109
+ if (bucket <= last && event.progress < 100) return;
1110
+ lastBucket.set(key, bucket);
1111
+ const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
1112
+ const message = `${event.method} ${event.stage}: ${event.progress}% (${total})`;
1113
+ logger.info(` \u21B3 ${message}`);
1114
+ emitProgress?.({
1115
+ phase: event.method,
1116
+ message,
1117
+ progress: event.progress,
1118
+ processed: event.processed,
1119
+ total: event.total
1030
1120
  });
1031
- current = {};
1032
1121
  };
1033
- for (const line of output.split("\n")) {
1034
- if (line.startsWith("worktree ")) {
1122
+ }
1123
+
1124
+ // src/services/file-copy.service.ts
1125
+ import * as fs2 from "fs/promises";
1126
+ import * as path3 from "path";
1127
+ import { glob } from "glob";
1128
+ var DEFAULT_IGNORE_PATTERNS = [
1129
+ "**/node_modules/**",
1130
+ "**/.git/**",
1131
+ "**/dist/**",
1132
+ "**/build/**",
1133
+ "**/.next/**",
1134
+ "**/coverage/**"
1135
+ ];
1136
+ var FileCopyService = class {
1137
+ /**
1138
+ * Copy files matching patterns from source to destination directory.
1139
+ * Skips files that already exist at destination.
1140
+ * Preserves directory structure relative to source.
1141
+ */
1142
+ async copyFiles(sourceDir, destDir, patterns) {
1143
+ const result = {
1144
+ copied: [],
1145
+ skipped: [],
1146
+ errors: []
1147
+ };
1148
+ if (!patterns || patterns.length === 0) {
1149
+ return result;
1150
+ }
1151
+ const filesToCopy = await this.expandPatterns(sourceDir, patterns);
1152
+ for (const relativePath of filesToCopy) {
1153
+ const sourcePath = path3.join(sourceDir, relativePath);
1154
+ const destPath = path3.join(destDir, relativePath);
1155
+ try {
1156
+ const copied = await this.copyFile(sourcePath, destPath);
1157
+ if (copied) {
1158
+ result.copied.push(relativePath);
1159
+ } else {
1160
+ result.skipped.push(relativePath);
1161
+ }
1162
+ } catch (error) {
1163
+ result.errors.push({
1164
+ file: relativePath,
1165
+ error: error instanceof Error ? error.message : String(error)
1166
+ });
1167
+ }
1168
+ }
1169
+ return result;
1170
+ }
1171
+ async expandPatterns(sourceDir, patterns) {
1172
+ const allFiles = /* @__PURE__ */ new Set();
1173
+ for (const pattern of patterns) {
1174
+ try {
1175
+ const matches = await glob(pattern, {
1176
+ cwd: sourceDir,
1177
+ nodir: true,
1178
+ dot: true,
1179
+ ignore: DEFAULT_IGNORE_PATTERNS
1180
+ });
1181
+ for (const match of matches) {
1182
+ allFiles.add(match);
1183
+ }
1184
+ } catch {
1185
+ }
1186
+ }
1187
+ return Array.from(allFiles);
1188
+ }
1189
+ async copyFile(sourcePath, destPath) {
1190
+ if (await fileExists(destPath)) {
1191
+ return false;
1192
+ }
1193
+ const destDir = path3.dirname(destPath);
1194
+ await fs2.mkdir(destDir, { recursive: true });
1195
+ await fs2.copyFile(sourcePath, destPath);
1196
+ return true;
1197
+ }
1198
+ };
1199
+
1200
+ // src/services/branch-created-actions.service.ts
1201
+ var BranchCreatedActionsService = class {
1202
+ fileCopyService;
1203
+ constructor(fileCopyService) {
1204
+ this.fileCopyService = fileCopyService ?? new FileCopyService();
1205
+ }
1206
+ async copyFiles(params) {
1207
+ const { config, sourceDir, worktreePath, branchName, logger } = params;
1208
+ const patterns = config.filesToCopyOnBranchCreate;
1209
+ if (!patterns?.length) return;
1210
+ try {
1211
+ const result = await this.fileCopyService.copyFiles(sourceDir, worktreePath, patterns);
1212
+ if (result.copied.length > 0) {
1213
+ logger.info(`\u{1F4CB} Copied ${result.copied.length} file(s) to '${branchName}': ${result.copied.join(", ")}`);
1214
+ }
1215
+ if (result.errors.length > 0) {
1216
+ logger.warn(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s) to '${branchName}':`);
1217
+ for (const err of result.errors) {
1218
+ logger.warn(` - ${err.file}: ${err.error}`);
1219
+ }
1220
+ }
1221
+ } catch (error) {
1222
+ logger.error(`Failed to copy files to '${branchName}': ${error}`);
1223
+ }
1224
+ }
1225
+ runHooks(params) {
1226
+ const { config, branchName, worktreePath, repoName, baseBranch, logger, hookExecutionService } = params;
1227
+ if (!config.hooks?.onBranchCreated?.length) return;
1228
+ const context = {
1229
+ branchName,
1230
+ worktreePath,
1231
+ repoName,
1232
+ baseBranch,
1233
+ repoUrl: config.repoUrl
1234
+ };
1235
+ logger.info(`Running ${config.hooks.onBranchCreated.length} hook(s) for branch '${branchName}'...`);
1236
+ hookExecutionService.executeOnBranchCreated(config.hooks, context, {
1237
+ onStdout: (data) => logger.info(`[hook] ${data}`),
1238
+ onStderr: (data) => logger.warn(`[hook] ${data}`),
1239
+ onError: (command, error) => logger.error(`[hook] Failed to execute '${command}': ${error.message}`),
1240
+ onComplete: (command, exitCode) => {
1241
+ if (exitCode === 0) {
1242
+ logger.info(`[hook] Command completed successfully`);
1243
+ } else if (exitCode !== null) {
1244
+ logger.warn(`[hook] Command exited with code ${exitCode}`);
1245
+ }
1246
+ }
1247
+ });
1248
+ }
1249
+ };
1250
+
1251
+ // src/utils/clone-skip-format.ts
1252
+ function formatCloneSkipReason(reason) {
1253
+ switch (reason.kind) {
1254
+ 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}'`;
1256
+ case "head_unreadable":
1257
+ return `could not read HEAD: ${reason.error}`;
1258
+ case "dirty_tree":
1259
+ return `working tree has local changes`;
1260
+ case "diverged":
1261
+ return `diverged from origin/${reason.branch}`;
1262
+ case "ahead_unpushed":
1263
+ return `unpushed commits ahead of origin/${reason.branch}`;
1264
+ case "missing_remote_ref":
1265
+ return reason.source === "fetch_error" ? `origin/${reason.branch} missing on remote (fetch error)` : `origin/${reason.branch} pruned after fetch`;
1266
+ case "indeterminate_shallow":
1267
+ return reason.deepenedTo === null ? `unable to classify origin/${reason.branch} (no deepening attempted \u2014 configured depth already at or above all deepen targets) \u2014 remove 'depth' to unshallow` : `unable to classify origin/${reason.branch} after deepening shallow history to ${reason.deepenedTo} commits \u2014 remove or raise 'depth' to unshallow`;
1268
+ case "origin_mismatch":
1269
+ return `clone origin is '${reason.actual}', expected '${reason.expected}'`;
1270
+ default: {
1271
+ const _exhaustive = reason;
1272
+ return _exhaustive;
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ // src/services/sync-outcome.ts
1278
+ var EMPTY_COUNTS = {
1279
+ created: 0,
1280
+ removed: 0,
1281
+ updated: 0,
1282
+ skipped: 0,
1283
+ preserved: 0,
1284
+ failed: 0,
1285
+ noop: 0
1286
+ };
1287
+ function cloneCounts(counts) {
1288
+ return { ...counts };
1289
+ }
1290
+ function cloneAction(action) {
1291
+ return { ...action };
1292
+ }
1293
+ function countKeyFor(action) {
1294
+ switch (action.kind) {
1295
+ case "created":
1296
+ return "created";
1297
+ case "removed":
1298
+ return "removed";
1299
+ case "updated":
1300
+ return "updated";
1301
+ case "skipped":
1302
+ return "skipped";
1303
+ case "preserved-diverged":
1304
+ return "preserved";
1305
+ case "failed":
1306
+ return "failed";
1307
+ case "noop":
1308
+ return "noop";
1309
+ default: {
1310
+ const _exhaustive = action;
1311
+ return _exhaustive;
1312
+ }
1313
+ }
1314
+ }
1315
+ var SyncOutcomeAccumulator = class {
1316
+ constructor(options) {
1317
+ this.options = options;
1318
+ }
1319
+ counts = cloneCounts(EMPTY_COUNTS);
1320
+ actions = [];
1321
+ add(action) {
1322
+ this.actions.push(action);
1323
+ this.counts[countKeyFor(action)]++;
1324
+ }
1325
+ recordCreated(branch, path16) {
1326
+ this.add({ kind: "created", branch, path: path16 });
1327
+ }
1328
+ recordRemoved(branch, path16) {
1329
+ this.add({ kind: "removed", branch, path: path16 });
1330
+ }
1331
+ recordUpdated(branch, path16, reason) {
1332
+ this.add({ kind: "updated", branch, path: path16, reason });
1333
+ }
1334
+ recordNoop(scope, reason, details) {
1335
+ this.add({ kind: "noop", scope, reason, ...details });
1336
+ }
1337
+ recordSkipped(scope, reason, details) {
1338
+ this.add({ kind: "skipped", scope, reason, ...details });
1339
+ }
1340
+ recordPreservedDiverged(branch, path16, preservedPath) {
1341
+ this.add({ kind: "preserved-diverged", branch, path: path16, preservedPath });
1342
+ }
1343
+ recordFailed(scope, error, details = {}) {
1344
+ this.add({ kind: "failed", scope, error, ...details });
1345
+ }
1346
+ getCounts() {
1347
+ return cloneCounts(this.counts);
1348
+ }
1349
+ snapshot() {
1350
+ return {
1351
+ counts: cloneCounts(this.counts),
1352
+ actions: this.actions.map(cloneAction)
1353
+ };
1354
+ }
1355
+ restore(snapshot) {
1356
+ this.counts = cloneCounts(snapshot.counts);
1357
+ this.actions = snapshot.actions.map(cloneAction);
1358
+ }
1359
+ toOutcome(durationMs) {
1360
+ return {
1361
+ repoName: this.options.repoName,
1362
+ mode: this.options.mode,
1363
+ started: true,
1364
+ counts: cloneCounts(this.counts),
1365
+ actions: this.actions.map(cloneAction),
1366
+ durationMs
1367
+ };
1368
+ }
1369
+ };
1370
+ function createEmptySyncOutcome(mode, repoName, durationMs) {
1371
+ return {
1372
+ repoName,
1373
+ mode,
1374
+ started: true,
1375
+ counts: cloneCounts(EMPTY_COUNTS),
1376
+ actions: [],
1377
+ durationMs
1378
+ };
1379
+ }
1380
+ function cloneSkipToOutcomeAction(reason, details = {}) {
1381
+ const message = formatCloneSkipReason(reason);
1382
+ const branch = "branch" in reason ? reason.branch : reason.kind === "branch_mismatch" ? reason.expectedBranch : details.branch;
1383
+ return {
1384
+ kind: "skipped",
1385
+ scope: "repo",
1386
+ reason: `clone_${reason.kind}`,
1387
+ branch,
1388
+ path: details.path,
1389
+ message
1390
+ };
1391
+ }
1392
+
1393
+ // src/services/clone-sync.service.ts
1394
+ var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
1395
+ var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
1396
+ var CloneSyncService = class {
1397
+ constructor(config, gitService, logger, options = {}) {
1398
+ this.config = config;
1399
+ this.gitService = gitService;
1400
+ this.logger = logger;
1401
+ this.branchCreatedActions = options.branchCreatedActions ?? new BranchCreatedActionsService();
1402
+ this.progressEmitter = options.progressEmitter;
1403
+ this.onSkip = options.onSkip;
1404
+ }
1405
+ initialized = false;
1406
+ resolvedBranch = null;
1407
+ branchCreatedActions;
1408
+ progressEmitter;
1409
+ onSkip;
1410
+ outcomeAccumulator;
1411
+ // One-shot suppression token. When init records a wrong-branch / unreadable-HEAD
1412
+ // skip for an existing clone, it sets this so the immediately following
1413
+ // runSyncAttempt (same sync operation) does not record the identical skip again.
1414
+ pendingInitSkip = null;
1415
+ updateLogger(logger) {
1416
+ this.logger = logger;
1417
+ }
1418
+ isInitialized() {
1419
+ return this.initialized;
1420
+ }
1421
+ clearPendingInitSkip() {
1422
+ this.pendingInitSkip = null;
1423
+ }
1424
+ async getWorktrees() {
1425
+ const worktreeDir = path4.resolve(this.config.worktreeDir);
1426
+ if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
1427
+ return [];
1428
+ }
1429
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1430
+ let branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
1431
+ if (!branch || branch === "HEAD") {
1432
+ const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
1433
+ branch = head ? `(detached ${head})` : "(detached)";
1434
+ }
1435
+ return [{ path: worktreeDir, branch }];
1436
+ }
1437
+ get repoName() {
1438
+ return this.config.name ?? this.config.repoUrl;
1439
+ }
1440
+ getCloneTimeoutMs() {
1441
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
1442
+ return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
1443
+ }
1444
+ getFetchTimeoutMs() {
1445
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
1446
+ return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
1447
+ }
1448
+ isLfsSkipEnabled() {
1449
+ return this.config.skipLfs === true;
1450
+ }
1451
+ buildGitOptions(blockMs) {
1452
+ const options = {
1453
+ progress: makeGitProgressHandler(this.logger, (event) => this.emitProgress(event))
1454
+ };
1455
+ if (blockMs > 0) options.timeout = { block: blockMs };
1456
+ return options;
1457
+ }
1458
+ emitProgress(event) {
1459
+ try {
1460
+ this.progressEmitter?.(event);
1461
+ } catch {
1462
+ }
1463
+ }
1464
+ async withOutcome(outcome, operation) {
1465
+ const previousOutcome = this.outcomeAccumulator;
1466
+ if (outcome) {
1467
+ this.outcomeAccumulator = outcome;
1468
+ }
1469
+ try {
1470
+ return await operation();
1471
+ } finally {
1472
+ if (outcome) {
1473
+ this.outcomeAccumulator = previousOutcome;
1474
+ }
1475
+ }
1476
+ }
1477
+ recordSkip(reason, logMessage, progressMessage, logLevel = "warn") {
1478
+ if (logLevel === "warn") {
1479
+ this.logger.warn(logMessage);
1480
+ } else {
1481
+ this.logger.info(logMessage);
1482
+ }
1483
+ this.emitProgress({ phase: "skip", message: progressMessage ?? logMessage });
1484
+ try {
1485
+ this.onSkip?.(reason);
1486
+ } catch {
1487
+ }
1488
+ this.outcomeAccumulator?.add(
1489
+ cloneSkipToOutcomeAction(reason, {
1490
+ branch: this.resolvedBranch ?? this.config.branch,
1491
+ path: this.config.worktreeDir
1492
+ })
1493
+ );
1494
+ }
1495
+ clientFor(dir, blockMs) {
1496
+ return simpleGit(dir, this.buildGitOptions(blockMs)).env(this.buildGitEnv());
1497
+ }
1498
+ // Force a stable C locale so git's stderr is deterministic English. The
1499
+ // missing-remote-ref and LFS error classification matches on those strings
1500
+ // and would otherwise misfire under a non-English LANG/LC_ALL. simple-git's
1501
+ // .env() merges this object with process.env (PATH etc. preserved).
1502
+ buildGitEnv(opts = {}) {
1503
+ const env = { LC_ALL: "C", LANG: "C" };
1504
+ if (opts.forceLfsSkip || this.isLfsSkipEnabled()) {
1505
+ env[ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE] = "1";
1506
+ }
1507
+ return env;
1508
+ }
1509
+ buildCloneArgs(branch) {
1510
+ const args = ["--branch", branch, "--progress"];
1511
+ if (this.config.depth !== void 0) {
1512
+ args.push("--depth", String(this.config.depth), "--no-single-branch");
1513
+ }
1514
+ return args;
1515
+ }
1516
+ async buildFetchArgs(git) {
1517
+ const args = ["origin", "--prune", "--progress"];
1518
+ if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
1519
+ args.push("--depth", String(this.config.depth));
1520
+ }
1521
+ return args;
1522
+ }
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;
1544
+ }
1545
+ recordMissingRemoteRefSkip(branch) {
1546
+ this.recordSkip(
1547
+ { kind: "missing_remote_ref", branch, source: "fetch_error" },
1548
+ `Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
1549
+ `Skipping '${this.repoName}': origin/${branch} is missing`
1550
+ );
1551
+ }
1552
+ async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
1553
+ try {
1554
+ await git.fetch(fetchArgs);
1555
+ return { skipped: false };
1556
+ } catch (fetchError) {
1557
+ const message = getErrorMessage(fetchError);
1558
+ if (isLfsError(message)) {
1559
+ this.logger.info(`\u26A0\uFE0F LFS error during fetch for '${this.repoName}'; retrying with LFS disabled.`);
1560
+ this.emitProgress({ phase: "fetch", message: `Retrying fetch for '${this.repoName}' with LFS disabled` });
1561
+ const lfsSkipGit = simpleGit(worktreeDir, this.buildGitOptions(this.getFetchTimeoutMs())).env(
1562
+ this.buildGitEnv({ forceLfsSkip: true })
1563
+ );
1564
+ try {
1565
+ await lfsSkipGit.fetch(fetchArgs);
1566
+ return { skipped: false };
1567
+ } catch (retryError) {
1568
+ if (isMissingRemoteRefError(getErrorMessage(retryError))) {
1569
+ this.recordMissingRemoteRefSkip(branch);
1570
+ return { skipped: true };
1571
+ }
1572
+ throw retryError;
1573
+ }
1574
+ }
1575
+ if (isMissingRemoteRefError(message)) {
1576
+ this.recordMissingRemoteRefSkip(branch);
1577
+ return { skipped: true };
1578
+ }
1579
+ throw fetchError;
1580
+ }
1581
+ }
1582
+ async hasRemoteBranch(git, branch) {
1583
+ try {
1584
+ await git.raw(["show-ref", "--verify", `refs/remotes/origin/${branch}`]);
1585
+ return true;
1586
+ } catch {
1587
+ return false;
1588
+ }
1589
+ }
1590
+ async isShallowRepository(git) {
1591
+ try {
1592
+ const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
1593
+ return output.trim() === "true";
1594
+ } catch {
1595
+ return false;
1596
+ }
1597
+ }
1598
+ async unshallowIfDepthRemoved(git) {
1599
+ if (this.config.depth !== void 0) return;
1600
+ if (!await this.isShallowRepository(git)) return;
1601
+ this.logger.info(
1602
+ `[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
1603
+ );
1604
+ await git.fetch(["--unshallow"]);
1605
+ }
1606
+ getDeepenTargets() {
1607
+ const configuredDepth = this.config.depth;
1608
+ if (configuredDepth === void 0) return [];
1609
+ return SHALLOW_RELATION_DEEPEN_TARGETS.filter((target) => target > configuredDepth);
1610
+ }
1611
+ async deepenShallowHistoryToDepth(git, branch, targetDepth) {
1612
+ this.logger.info(
1613
+ `[deepen] Shallow clone for '${this.repoName}' lacks enough history to classify origin/${branch}; refetching to depth ${targetDepth} before deciding.`
1614
+ );
1615
+ this.emitProgress({
1616
+ phase: "fetch",
1617
+ message: `Deepening '${this.repoName}' to depth ${targetDepth} before classifying origin/${branch}`
1618
+ });
1619
+ await git.fetch([
1620
+ "origin",
1621
+ "--depth",
1622
+ String(targetDepth),
1623
+ "--prune",
1624
+ "--progress",
1625
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`
1626
+ ]);
1627
+ }
1628
+ async resolveBranch() {
1629
+ if (this.resolvedBranch) return this.resolvedBranch;
1630
+ if (this.config.branch) {
1631
+ this.resolvedBranch = this.config.branch;
1632
+ this.emitProgress({ phase: "branch", message: `Using configured branch '${this.resolvedBranch}'` });
1633
+ return this.resolvedBranch;
1634
+ }
1635
+ this.logger.info(`No branch configured for '${this.repoName}', detecting remote default branch...`);
1636
+ this.emitProgress({ phase: "branch", message: `Resolving remote default branch for '${this.repoName}'` });
1637
+ this.resolvedBranch = await this.gitService.getRemoteDefaultBranch(this.config.repoUrl);
1638
+ this.logger.info(` \u21B3 resolved default branch: ${this.resolvedBranch}`);
1639
+ this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
1640
+ return this.resolvedBranch;
1641
+ }
1642
+ async initialize(outcome) {
1643
+ return this.withOutcome(outcome, () => this.initializeInternal());
1644
+ }
1645
+ async initializeInternal() {
1646
+ this.pendingInitSkip = null;
1647
+ const branch = await this.resolveBranch();
1648
+ const worktreeDir = this.config.worktreeDir;
1649
+ let entries = null;
1650
+ try {
1651
+ entries = await fs3.readdir(worktreeDir);
1652
+ } catch {
1653
+ entries = null;
1654
+ }
1655
+ if (entries?.includes(PATH_CONSTANTS.GIT_DIR)) {
1656
+ this.emitProgress({ phase: "clone", message: `Validating existing clone for '${this.repoName}'` });
1657
+ const result = await this.validateExistingClone(branch);
1658
+ if (!result.valid) {
1659
+ this.recordSkip(result.skip, result.warnMessage, `Skipping '${this.repoName}': ${result.progressDetail}`);
1660
+ this.pendingInitSkip = result.skip;
1661
+ this.initialized = true;
1662
+ return;
1663
+ }
1664
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1665
+ await this.ensureAllRemoteBranchesRefspec(git);
1666
+ this.initialized = true;
1667
+ this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
1668
+ return;
1669
+ }
1670
+ if (entries && entries.length > 0) {
1671
+ throw new ConfigError(
1672
+ `Cannot clone into '${worktreeDir}': directory exists and is not empty. Remove existing contents or point worktreeDir at an empty path.`,
1673
+ "CLONE_DESTINATION_NOT_EMPTY"
1674
+ );
1675
+ }
1676
+ const cloneCreatedDir = entries === null;
1677
+ await fs3.mkdir(worktreeDir, { recursive: true });
1678
+ this.logger.info(`Cloning '${this.config.repoUrl}' (${branch}) into '${worktreeDir}'...`);
1679
+ this.emitProgress({ phase: "clone", message: `Cloning '${this.repoName}' (${branch})` });
1680
+ const cloneClient = simpleGit(this.buildGitOptions(this.getCloneTimeoutMs())).env(this.buildGitEnv());
1681
+ try {
1682
+ await cloneClient.clone(this.config.repoUrl, worktreeDir, this.buildCloneArgs(branch));
1683
+ } catch (error) {
1684
+ await this.maybeCleanupPartialClone(worktreeDir, cloneCreatedDir);
1685
+ this.outcomeAccumulator?.recordFailed("repo", getErrorMessage(error), {
1686
+ reason: "clone_failed",
1687
+ branch,
1688
+ path: worktreeDir
1689
+ });
1690
+ throw error;
1691
+ }
1692
+ const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1693
+ await this.ensureAllRemoteBranchesRefspec(worktreeGit);
1694
+ this.logger.info(`\u2705 Clone successful.`);
1695
+ this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
1696
+ if (this.config.sparseCheckout) {
1697
+ this.logger.info(`Applying sparse-checkout patterns to '${worktreeDir}'...`);
1698
+ this.emitProgress({ phase: "sparse_checkout", message: `Applying sparse-checkout for '${this.repoName}'` });
1699
+ const sparseService = this.gitService.getSparseCheckoutService();
1700
+ await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
1701
+ await worktreeGit.raw(["checkout", "HEAD"]);
1702
+ this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout applied for '${this.repoName}'` });
1703
+ }
1704
+ this.emitProgress({ phase: "lfs", message: `Verifying LFS for '${this.repoName}'` });
1705
+ await this.gitService.verifyLfs(worktreeDir, branch);
1706
+ this.emitProgress({ phase: "lfs", message: `LFS verified for '${this.repoName}'` });
1707
+ await this.runInitialFileCopy(worktreeDir, branch);
1708
+ this.outcomeAccumulator?.recordCreated(branch, worktreeDir);
1709
+ this.initialized = true;
1710
+ }
1711
+ // Detects an on-disk clone whose `origin` no longer matches the configured
1712
+ // repoUrl (e.g. repoUrl was repointed in config). Returns a skip descriptor so
1713
+ // we never fetch/ff-merge from the wrong remote; null when origin matches or
1714
+ // can't be read. Comparison is normalized so https/.git/trailing-slash
1715
+ // variants don't false-positive; the raw URLs are kept in the message.
1716
+ async evaluateOriginMatch(git, worktreeDir) {
1717
+ let originUrl;
1718
+ try {
1719
+ originUrl = (await git.raw(["remote", "get-url", "origin"])).trim();
1720
+ } catch {
1721
+ this.logger.warn(`Could not read 'origin' remote URL from existing clone at '${worktreeDir}'.`);
1722
+ return null;
1723
+ }
1724
+ if (!originUrl || normalizeRepoUrlForComparison(originUrl) === normalizeRepoUrlForComparison(this.config.repoUrl)) {
1725
+ return null;
1726
+ }
1727
+ return {
1728
+ skip: { kind: "origin_mismatch", actual: originUrl, expected: this.config.repoUrl },
1729
+ warnMessage: `Existing clone at '${worktreeDir}' has origin '${originUrl}', expected '${this.config.repoUrl}'. Update the remote ('git remote set-url origin <url>') or point worktreeDir at a fresh path.`,
1730
+ progressDetail: `origin '${originUrl}' is not '${this.config.repoUrl}'`
1731
+ };
1732
+ }
1733
+ async validateExistingClone(expectedBranch) {
1734
+ const worktreeDir = this.config.worktreeDir;
1735
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1736
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
1737
+ if (originMismatch) {
1738
+ return { valid: false, ...originMismatch };
1739
+ }
1740
+ let currentBranch;
1741
+ try {
1742
+ currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
1743
+ } catch (error) {
1744
+ const errorMessage = getErrorMessage(error);
1745
+ return {
1746
+ valid: false,
1747
+ skip: { kind: "head_unreadable", phase: "init", error: errorMessage },
1748
+ warnMessage: `Existing clone at '${worktreeDir}' has a .git folder but reading HEAD failed: ${errorMessage}`,
1749
+ progressDetail: `could not read HEAD (${errorMessage})`
1750
+ };
1751
+ }
1752
+ if (currentBranch !== expectedBranch) {
1753
+ return {
1754
+ valid: false,
1755
+ skip: {
1756
+ kind: "branch_mismatch",
1757
+ phase: "init",
1758
+ currentBranch,
1759
+ expectedBranch
1760
+ },
1761
+ warnMessage: `Existing clone at '${worktreeDir}' is on branch '${currentBranch}', expected '${expectedBranch}'. Switch the working tree to '${expectedBranch}' or update the config.`,
1762
+ progressDetail: `current branch '${currentBranch}' is not '${expectedBranch}'`
1763
+ };
1764
+ }
1765
+ return { valid: true };
1766
+ }
1767
+ async maybeCleanupPartialClone(worktreeDir, cloneCreatedDir) {
1768
+ if (!cloneCreatedDir) {
1769
+ this.logger.warn(
1770
+ `Clone failed; leaving '${worktreeDir}' for manual inspection (directory existed before clone attempt).`
1771
+ );
1772
+ return;
1773
+ }
1774
+ let entries;
1775
+ try {
1776
+ entries = await fs3.readdir(worktreeDir);
1777
+ } catch {
1778
+ return;
1779
+ }
1780
+ 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"));
1782
+ if (looksIncomplete && !hasUsableGit) {
1783
+ try {
1784
+ await fs3.rm(worktreeDir, { recursive: true, force: true });
1785
+ this.logger.info(`Cleaned up incomplete clone at '${worktreeDir}'.`);
1786
+ } catch (rmError) {
1787
+ this.logger.warn(`Failed to clean up incomplete clone at '${worktreeDir}': ${getErrorMessage(rmError)}`);
1788
+ }
1789
+ } else {
1790
+ this.logger.warn(
1791
+ `Clone failed; leaving '${worktreeDir}' for manual inspection (post-failure contents do not look like an empty incomplete clone).`
1792
+ );
1793
+ }
1794
+ }
1795
+ getInitMarkerPath(worktreeDir) {
1796
+ return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
1797
+ }
1798
+ async runInitialFileCopy(worktreeDir, branch) {
1799
+ const marker = this.getInitMarkerPath(worktreeDir);
1800
+ if (await fileExists(marker)) {
1801
+ return;
1802
+ }
1803
+ const sourceDir = this.config.__configFileDir ?? worktreeDir;
1804
+ await this.branchCreatedActions.copyFiles({
1805
+ config: this.config,
1806
+ branchName: branch,
1807
+ worktreePath: worktreeDir,
1808
+ sourceDir,
1809
+ logger: this.logger
1810
+ });
1811
+ try {
1812
+ await fs3.writeFile(marker, (/* @__PURE__ */ new Date()).toISOString());
1813
+ } catch (error) {
1814
+ this.logger.warn(`Could not write clone-init marker: ${getErrorMessage(error)}`);
1815
+ }
1816
+ }
1817
+ async runSyncAttempt(outcome) {
1818
+ return this.withOutcome(outcome, () => this.runSyncAttemptInternal());
1819
+ }
1820
+ async runSyncAttemptInternal() {
1821
+ if (!this.initialized) {
1822
+ await this.initialize();
1823
+ this.pendingInitSkip = null;
1824
+ return;
1825
+ }
1826
+ if (this.pendingInitSkip) {
1827
+ this.pendingInitSkip = null;
1828
+ return;
1829
+ }
1830
+ const branch = await this.resolveBranch();
1831
+ const worktreeDir = this.config.worktreeDir;
1832
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
1833
+ let currentBranch;
1834
+ try {
1835
+ currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
1836
+ } catch (error) {
1837
+ const errorMessage = getErrorMessage(error);
1838
+ this.recordSkip(
1839
+ { kind: "head_unreadable", phase: "sync", error: errorMessage },
1840
+ `Could not read current branch from '${worktreeDir}': ${errorMessage}`,
1841
+ `Skipping '${this.repoName}': could not read current branch`
1842
+ );
1843
+ return;
1844
+ }
1845
+ if (currentBranch !== branch) {
1846
+ this.recordSkip(
1847
+ { kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
1848
+ `Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
1849
+ `Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
1850
+ );
1851
+ return;
1852
+ }
1853
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
1854
+ if (originMismatch) {
1855
+ this.recordSkip(
1856
+ originMismatch.skip,
1857
+ originMismatch.warnMessage,
1858
+ `Skipping '${this.repoName}': ${originMismatch.progressDetail}`
1859
+ );
1860
+ return;
1861
+ }
1862
+ 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}'` });
1866
+ if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
1867
+ return;
1868
+ }
1869
+ this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
1870
+ if (!await this.hasRemoteBranch(git, branch)) {
1871
+ this.recordSkip(
1872
+ { kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
1873
+ `Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
1874
+ `Skipping '${this.repoName}': origin/${branch} is missing`
1875
+ );
1876
+ return;
1877
+ }
1878
+ if (this.config.sparseCheckout) {
1879
+ const sparseService = this.gitService.getSparseCheckoutService();
1880
+ try {
1881
+ if (await sparseService.needsUpdate(worktreeDir, this.config.sparseCheckout)) {
1882
+ this.emitProgress({ phase: "sparse_checkout", message: `Updating sparse-checkout for '${this.repoName}'` });
1883
+ await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
1884
+ this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout updated for '${this.repoName}'` });
1885
+ }
1886
+ } catch (error) {
1887
+ this.logger.warn(`Failed to reapply sparse-checkout for '${this.repoName}': ${getErrorMessage(error)}`);
1888
+ }
1889
+ }
1890
+ const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
1891
+ if (!isClean) {
1892
+ this.recordSkip(
1893
+ { kind: "dirty_tree" },
1894
+ `\u23ED\uFE0F Skipping ff-merge for '${this.repoName}' \u2014 working tree has local changes.`,
1895
+ `Skipping merge for '${this.repoName}': working tree has local changes`,
1896
+ "info"
1897
+ );
1898
+ return;
1899
+ }
1900
+ let relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
1901
+ let lastDeepenedTo = null;
1902
+ if (relationship === "indeterminate_shallow") {
1903
+ for (const target of this.getDeepenTargets()) {
1904
+ await this.deepenShallowHistoryToDepth(git, branch, target);
1905
+ lastDeepenedTo = target;
1906
+ relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
1907
+ if (relationship !== "indeterminate_shallow") break;
1908
+ }
1909
+ }
1910
+ if (relationship === "up_to_date") {
1911
+ this.logger.info(`'${this.repoName}' already up to date with origin/${branch}.`);
1912
+ this.emitProgress({
1913
+ phase: "skip",
1914
+ message: `'${this.repoName}' already up to date with origin/${branch}`
1915
+ });
1916
+ this.outcomeAccumulator?.recordNoop("repo", "already_up_to_date", {
1917
+ branch,
1918
+ path: worktreeDir,
1919
+ message: `Already up to date with origin/${branch}`
1920
+ });
1921
+ return;
1922
+ }
1923
+ if (relationship !== "fast_forward") {
1924
+ if (relationship === "local_ahead") {
1925
+ this.recordSkip(
1926
+ { kind: "ahead_unpushed", branch },
1927
+ `\u23ED\uFE0F '${this.repoName}' has unpushed commits ahead of origin/${branch}. Skipping merge.`,
1928
+ `Skipping merge for '${this.repoName}': unpushed commits ahead of origin/${branch}`,
1929
+ "info"
1930
+ );
1931
+ } else if (relationship === "indeterminate_shallow") {
1932
+ const detail = lastDeepenedTo === null ? `no deepening attempted (configured depth already at or above all deepen targets)` : `deepening to ${lastDeepenedTo} commits`;
1933
+ const progressDetail = lastDeepenedTo === null ? `no deepening attempted (configured depth at/above limits)` : `shallow depth budget exhausted at ${lastDeepenedTo}`;
1934
+ this.recordSkip(
1935
+ { kind: "indeterminate_shallow", branch, deepenedTo: lastDeepenedTo },
1936
+ `\u23ED\uFE0F '${this.repoName}' could not classify origin/${branch} after ${detail}. Skipping merge \u2014 consider removing or raising 'depth' to unshallow.`,
1937
+ `Skipping merge for '${this.repoName}': ${progressDetail}`,
1938
+ "info"
1939
+ );
1940
+ } else {
1941
+ this.recordSkip(
1942
+ { kind: "diverged", branch },
1943
+ `\u23ED\uFE0F '${this.repoName}' has diverged from origin/${branch}. Skipping merge (no auto-reset).`,
1944
+ `Skipping merge for '${this.repoName}': diverged from origin/${branch}`,
1945
+ "info"
1946
+ );
1947
+ }
1948
+ return;
1949
+ }
1950
+ this.logger.info(`Fast-forwarding '${this.repoName}' to origin/${branch}...`);
1951
+ this.emitProgress({ phase: "merge", message: `Fast-forwarding '${this.repoName}' to origin/${branch}` });
1952
+ await git.merge([`origin/${branch}`, "--ff-only"]);
1953
+ this.logger.info(`\u2705 Updated '${this.repoName}' to origin/${branch}.`);
1954
+ this.emitProgress({ phase: "merge", message: `Updated '${this.repoName}' to origin/${branch}` });
1955
+ this.outcomeAccumulator?.recordUpdated(branch, worktreeDir, "fast_forward");
1956
+ }
1957
+ };
1958
+
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";
1963
+
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;
1972
+ }
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 ")) {
1035
1985
  flush();
1036
1986
  current.path = line.substring("worktree ".length);
1037
1987
  } else if (line.startsWith("branch ")) {
@@ -1053,8 +2003,8 @@ function parseWorktreeListPorcelain(output) {
1053
2003
  }
1054
2004
 
1055
2005
  // src/services/sparse-checkout.service.ts
1056
- import * as path3 from "path";
1057
- import simpleGit from "simple-git";
2006
+ import * as path5 from "path";
2007
+ import simpleGit2 from "simple-git";
1058
2008
  var SparseCheckoutService = class {
1059
2009
  logger;
1060
2010
  gitFactory;
@@ -1062,7 +2012,7 @@ var SparseCheckoutService = class {
1062
2012
  matcherCache = /* @__PURE__ */ new WeakMap();
1063
2013
  constructor(logger, gitFactory) {
1064
2014
  this.logger = logger ?? Logger.createDefault();
1065
- this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
2015
+ this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
1066
2016
  }
1067
2017
  updateLogger(logger) {
1068
2018
  this.logger = logger;
@@ -1113,11 +2063,25 @@ var SparseCheckoutService = class {
1113
2063
  return null;
1114
2064
  }
1115
2065
  }
2066
+ async readCurrentMode(worktreePath) {
2067
+ const git = this.gitFactory(worktreePath);
2068
+ try {
2069
+ const out = await git.raw(["config", "--bool", "--get", "core.sparseCheckoutCone"]);
2070
+ const value = out.trim().toLowerCase();
2071
+ if (value === "true") return "cone";
2072
+ if (value === "false") return "no-cone";
2073
+ return null;
2074
+ } catch {
2075
+ return null;
2076
+ }
2077
+ }
1116
2078
  async needsUpdate(worktreePath, cfg) {
2079
+ const desiredMode = this.resolveMode(cfg);
2080
+ const currentMode = await this.readCurrentMode(worktreePath);
2081
+ if (currentMode !== desiredMode) return true;
1117
2082
  const current = await this.readCurrent(worktreePath);
1118
- const desired = this.buildPatterns(cfg);
1119
2083
  if (current === null) return true;
1120
- return !this.patternsEqual(current, desired);
2084
+ return !this.patternsEqual(current, this.buildPatternsForMode(cfg, desiredMode));
1121
2085
  }
1122
2086
  isNarrowing(currentPatterns, nextPatterns) {
1123
2087
  if (!currentPatterns || currentPatterns.length === 0) return false;
@@ -1174,7 +2138,7 @@ var SparseCheckoutService = class {
1174
2138
  for (const pat of matcher.patterns) {
1175
2139
  if (p === pat || p.startsWith(pat + "/")) return true;
1176
2140
  }
1177
- return matcher.ancestorDirs.has(path3.posix.dirname(p));
2141
+ return matcher.ancestorDirs.has(path5.posix.dirname(p));
1178
2142
  });
1179
2143
  }
1180
2144
  getMatcher(cfg) {
@@ -1201,9 +2165,9 @@ var SparseCheckoutService = class {
1201
2165
  };
1202
2166
 
1203
2167
  // src/services/worktree-metadata.service.ts
1204
- import * as fs2 from "fs/promises";
1205
- import * as path4 from "path";
1206
- import simpleGit2 from "simple-git";
2168
+ import * as fs4 from "fs/promises";
2169
+ import * as path6 from "path";
2170
+ import simpleGit3 from "simple-git";
1207
2171
  var WorktreeMetadataService = class {
1208
2172
  logger;
1209
2173
  constructor(logger) {
@@ -1215,7 +2179,7 @@ var WorktreeMetadataService = class {
1215
2179
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
1216
2180
  */
1217
2181
  getWorktreeDirectoryName(worktreePath) {
1218
- return path4.basename(worktreePath);
2182
+ return path6.basename(worktreePath);
1219
2183
  }
1220
2184
  async getMetadataPath(bareRepoPath, worktreeName) {
1221
2185
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -1223,7 +2187,7 @@ var WorktreeMetadataService = class {
1223
2187
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
1224
2188
  );
1225
2189
  }
1226
- return path4.join(
2190
+ return path6.join(
1227
2191
  bareRepoPath,
1228
2192
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
1229
2193
  worktreeName,
@@ -1236,31 +2200,31 @@ var WorktreeMetadataService = class {
1236
2200
  }
1237
2201
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
1238
2202
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
1239
- await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
2203
+ await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
1240
2204
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
1241
2205
  let renamed = false;
1242
2206
  try {
1243
- await fs2.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
2207
+ await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
1244
2208
  try {
1245
- await fs2.rename(tmpPath, metadataPath);
2209
+ await fs4.rename(tmpPath, metadataPath);
1246
2210
  renamed = true;
1247
2211
  } catch (err) {
1248
2212
  if (err.code === ERROR_MESSAGES.EXDEV) {
1249
- await fs2.copyFile(tmpPath, metadataPath);
2213
+ await fs4.copyFile(tmpPath, metadataPath);
1250
2214
  } else {
1251
2215
  throw err;
1252
2216
  }
1253
2217
  }
1254
2218
  } finally {
1255
2219
  if (!renamed) {
1256
- await fs2.unlink(tmpPath).catch(() => void 0);
2220
+ await fs4.unlink(tmpPath).catch(() => void 0);
1257
2221
  }
1258
2222
  }
1259
2223
  }
1260
2224
  async loadMetadata(bareRepoPath, worktreeName) {
1261
2225
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
1262
2226
  try {
1263
- const content = await fs2.readFile(metadataPath, "utf-8");
2227
+ const content = await fs4.readFile(metadataPath, "utf-8");
1264
2228
  return JSON.parse(content);
1265
2229
  } catch {
1266
2230
  return null;
@@ -1269,7 +2233,7 @@ var WorktreeMetadataService = class {
1269
2233
  async loadMetadataFromPath(bareRepoPath, worktreePath) {
1270
2234
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
1271
2235
  try {
1272
- const content = await fs2.readFile(metadataPath, "utf-8");
2236
+ const content = await fs4.readFile(metadataPath, "utf-8");
1273
2237
  const metadata = JSON.parse(content);
1274
2238
  if (!await this.validateMetadata(metadata)) {
1275
2239
  this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
@@ -1283,7 +2247,7 @@ var WorktreeMetadataService = class {
1283
2247
  async deleteMetadata(bareRepoPath, worktreeName) {
1284
2248
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
1285
2249
  try {
1286
- await fs2.unlink(metadataPath);
2250
+ await fs4.unlink(metadataPath);
1287
2251
  } catch (error) {
1288
2252
  if (error.code !== "ENOENT") {
1289
2253
  throw error;
@@ -1293,7 +2257,7 @@ var WorktreeMetadataService = class {
1293
2257
  async deleteMetadataFromPath(bareRepoPath, worktreePath) {
1294
2258
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
1295
2259
  try {
1296
- await fs2.unlink(metadataPath);
2260
+ await fs4.unlink(metadataPath);
1297
2261
  } catch (error) {
1298
2262
  if (error.code !== "ENOENT") {
1299
2263
  throw error;
@@ -1327,7 +2291,7 @@ var WorktreeMetadataService = class {
1327
2291
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
1328
2292
  this.logger.info(` Attempting to create initial metadata...`);
1329
2293
  try {
1330
- const worktreeGit = simpleGit2(worktreePath);
2294
+ const worktreeGit = simpleGit3(worktreePath);
1331
2295
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
1332
2296
  const branchSummary = await worktreeGit.branch();
1333
2297
  const actualBranchName = branchSummary.current;
@@ -1428,9 +2392,9 @@ var WorktreeMetadataService = class {
1428
2392
  };
1429
2393
 
1430
2394
  // src/services/worktree-status.service.ts
1431
- import * as fs3 from "fs/promises";
1432
- import * as path5 from "path";
1433
- import simpleGit3 from "simple-git";
2395
+ import * as fs5 from "fs/promises";
2396
+ import * as path7 from "path";
2397
+ import simpleGit4 from "simple-git";
1434
2398
  var OPERATION_FILES = [
1435
2399
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
1436
2400
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -1461,9 +2425,7 @@ var WorktreeStatusService = class {
1461
2425
  return true;
1462
2426
  }
1463
2427
  async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
1464
- try {
1465
- await fs3.access(worktreePath);
1466
- } catch {
2428
+ if (!await fileExists(worktreePath)) {
1467
2429
  return {
1468
2430
  isClean: true,
1469
2431
  hasUnpushedCommits: false,
@@ -1633,7 +2595,7 @@ var WorktreeStatusService = class {
1633
2595
  async detectOperationFile(gitDir) {
1634
2596
  const results = await Promise.all(
1635
2597
  OPERATION_FILES.map(
1636
- ({ file }) => fs3.access(path5.join(gitDir, file)).then(
2598
+ ({ file }) => fs5.access(path7.join(gitDir, file)).then(
1637
2599
  () => true,
1638
2600
  () => false
1639
2601
  )
@@ -1754,14 +2716,14 @@ var WorktreeStatusService = class {
1754
2716
  }
1755
2717
  }
1756
2718
  async resolveGitDir(worktreePath) {
1757
- const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
2719
+ const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
1758
2720
  try {
1759
- const stat4 = await fs3.stat(gitPath);
2721
+ const stat4 = await fs5.stat(gitPath);
1760
2722
  if (stat4.isFile()) {
1761
- const content = await fs3.readFile(gitPath, "utf-8");
2723
+ const content = await fs5.readFile(gitPath, "utf-8");
1762
2724
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
1763
2725
  if (gitdirMatch) {
1764
- return path5.resolve(worktreePath, gitdirMatch[1].trim());
2726
+ return path7.resolve(worktreePath, gitdirMatch[1].trim());
1765
2727
  }
1766
2728
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
1767
2729
  }
@@ -1775,10 +2737,10 @@ var WorktreeStatusService = class {
1775
2737
  }
1776
2738
  }
1777
2739
  createGitInstance(worktreePath) {
1778
- const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
2740
+ const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
1779
2741
  let git = this.gitInstances.get(key);
1780
2742
  if (!git) {
1781
- git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
2743
+ git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
1782
2744
  this.gitInstances.set(key, git);
1783
2745
  }
1784
2746
  return git;
@@ -1798,7 +2760,7 @@ var GitService = class {
1798
2760
  this.config = config;
1799
2761
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
1800
2762
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
1801
- this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
2763
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1802
2764
  this.metadataService = new WorktreeMetadataService(this.logger);
1803
2765
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
1804
2766
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -1826,36 +2788,20 @@ var GitService = class {
1826
2788
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
1827
2789
  }
1828
2790
  getCachedGit(dirPath, useLfsSkip = false) {
1829
- const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
2791
+ const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1830
2792
  let git = this.gitInstances.get(key);
1831
2793
  if (!git) {
1832
- const base = simpleGit4(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2794
+ const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
1833
2795
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
1834
2796
  this.gitInstances.set(key, git);
1835
2797
  }
1836
2798
  return git;
1837
2799
  }
1838
2800
  buildSimpleGitOptions(blockMs) {
1839
- const options = { progress: this.makeProgressHandler() };
2801
+ const options = { progress: makeGitProgressHandler(this.logger) };
1840
2802
  if (blockMs > 0) options.timeout = { block: blockMs };
1841
2803
  return options;
1842
2804
  }
1843
- makeProgressHandler() {
1844
- const lastBucket = /* @__PURE__ */ new Map();
1845
- return (event) => {
1846
- if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
1847
- const key = `${event.method}:${event.stage}`;
1848
- const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
1849
- let last = lastBucket.get(key) ?? -1;
1850
- if (bucket < last) {
1851
- last = -1;
1852
- }
1853
- if (bucket <= last && event.progress < 100) return;
1854
- lastBucket.set(key, bucket);
1855
- const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
1856
- this.logger.info(` \u21B3 ${event.method} ${event.stage}: ${event.progress}% (${total})`);
1857
- };
1858
- }
1859
2805
  updateLogger(logger) {
1860
2806
  this.logger = logger;
1861
2807
  this.sparseCheckoutService.updateLogger(logger);
@@ -1863,11 +2809,11 @@ var GitService = class {
1863
2809
  async initialize() {
1864
2810
  const { repoUrl } = this.config;
1865
2811
  try {
1866
- await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
2812
+ await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
1867
2813
  } catch {
1868
2814
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
1869
- await fs4.mkdir(path6.dirname(this.bareRepoPath), { recursive: true });
1870
- const cloneBase = simpleGit4(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
2815
+ await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
2816
+ const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
1871
2817
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
1872
2818
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
1873
2819
  this.logger.info("\u2705 Clone successful.");
@@ -1885,17 +2831,17 @@ var GitService = class {
1885
2831
  this.logger.info("Fetching remote branches...");
1886
2832
  await bareGit.fetch(["--all", "--progress"]);
1887
2833
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
1888
- this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
2834
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
1889
2835
  let needsMainWorktree = true;
1890
2836
  try {
1891
2837
  const worktrees = await this.getWorktreesFromBare(bareGit);
1892
- needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
2838
+ needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
1893
2839
  } catch {
1894
2840
  }
1895
2841
  if (needsMainWorktree) {
1896
2842
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
1897
- await fs4.mkdir(this.config.worktreeDir, { recursive: true });
1898
- const absoluteWorktreePath = path6.resolve(this.mainWorktreePath);
2843
+ await fs6.mkdir(this.config.worktreeDir, { recursive: true });
2844
+ const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
1899
2845
  const branches = await bareGit.branch();
1900
2846
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
1901
2847
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -1931,7 +2877,7 @@ var GitService = class {
1931
2877
  }
1932
2878
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
1933
2879
  const mainWorktreeRegistered = updatedWorktrees.some(
1934
- (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
2880
+ (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
1935
2881
  );
1936
2882
  if (!mainWorktreeRegistered) {
1937
2883
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -1957,6 +2903,45 @@ var GitService = class {
1957
2903
  getBareRepoPath() {
1958
2904
  return this.bareRepoPath;
1959
2905
  }
2906
+ async getRemoteDefaultBranch(repoUrl) {
2907
+ const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2908
+ try {
2909
+ const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
2910
+ const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
2911
+ if (match && match[1]) {
2912
+ return match[1];
2913
+ }
2914
+ } catch {
2915
+ }
2916
+ const existing = [];
2917
+ for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
2918
+ try {
2919
+ const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
2920
+ if (out.trim().length > 0) {
2921
+ existing.push(candidate);
2922
+ }
2923
+ } catch {
2924
+ }
2925
+ }
2926
+ if (existing.length === 1) {
2927
+ this.logger.warn(
2928
+ `Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
2929
+ );
2930
+ return existing[0];
2931
+ }
2932
+ if (existing.length > 1) {
2933
+ throw new Error(
2934
+ `Unable to detect default branch for '${repoUrl}': symref HEAD is unavailable and multiple common branches exist (${existing.join(", ")}). Set 'branch' explicitly in the repository config.`
2935
+ );
2936
+ }
2937
+ throw new Error(
2938
+ `Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
2939
+ );
2940
+ }
2941
+ async verifyLfs(worktreePath, label) {
2942
+ if (this.isLfsSkipEnabled()) return;
2943
+ await this.verifyLfsFilesDownloaded(worktreePath, label);
2944
+ }
1960
2945
  async fetchAll() {
1961
2946
  this.assertInitialized();
1962
2947
  this.logger.info("Fetching latest data from remote...");
@@ -2003,7 +2988,7 @@ var GitService = class {
2003
2988
  return branches;
2004
2989
  }
2005
2990
  async verifyLfsFilesDownloaded(worktreePath, branchName) {
2006
- const worktreeGit = this.config.sparseCheckout ? simpleGit4(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
2991
+ const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
2007
2992
  try {
2008
2993
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
2009
2994
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -2014,7 +2999,7 @@ var GitService = class {
2014
2999
  const existence = await Promise.all(
2015
3000
  lfsFileList.map(async (f) => {
2016
3001
  try {
2017
- await fs4.access(path6.join(worktreePath, f));
3002
+ await fs6.access(path8.join(worktreePath, f));
2018
3003
  return f;
2019
3004
  } catch {
2020
3005
  return null;
@@ -2042,9 +3027,9 @@ var GitService = class {
2042
3027
  let allDownloaded = true;
2043
3028
  const notDownloaded = [];
2044
3029
  for (const file of samplesToCheck) {
2045
- const filePath = path6.join(worktreePath, file);
3030
+ const filePath = path8.join(worktreePath, file);
2046
3031
  try {
2047
- const handle = await fs4.open(filePath, "r");
3032
+ const handle = await fs6.open(filePath, "r");
2048
3033
  try {
2049
3034
  const buffer = Buffer.alloc(200);
2050
3035
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -2069,7 +3054,7 @@ var GitService = class {
2069
3054
  }
2070
3055
  retries++;
2071
3056
  if (retries < maxRetries) {
2072
- await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
3057
+ await new Promise((resolve11) => setTimeout(resolve11, retryDelay));
2073
3058
  }
2074
3059
  }
2075
3060
  this.logger.warn(
@@ -2131,18 +3116,18 @@ var GitService = class {
2131
3116
  }
2132
3117
  async addWorktree(branchName, worktreePath) {
2133
3118
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
2134
- const absoluteWorktreePath = path6.resolve(worktreePath);
2135
- await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
3119
+ const absoluteWorktreePath = path8.resolve(worktreePath);
3120
+ await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
2136
3121
  try {
2137
- await fs4.access(absoluteWorktreePath);
3122
+ await fs6.access(absoluteWorktreePath);
2138
3123
  const worktrees = await this.getWorktreesFromBare(bareGit);
2139
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3124
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
2140
3125
  if (isValidWorktree) {
2141
3126
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2142
3127
  return;
2143
3128
  } else {
2144
3129
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
2145
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
3130
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
2146
3131
  }
2147
3132
  } catch {
2148
3133
  }
@@ -2181,7 +3166,7 @@ var GitService = class {
2181
3166
  }
2182
3167
  if (errorMessage.includes("already registered worktree")) {
2183
3168
  const worktrees = await this.getWorktreesFromBare(bareGit);
2184
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3169
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
2185
3170
  if (existingWorktree && !existingWorktree.isPrunable) {
2186
3171
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
2187
3172
  return;
@@ -2189,7 +3174,7 @@ var GitService = class {
2189
3174
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
2190
3175
  await bareGit.raw(["worktree", "prune"]);
2191
3176
  try {
2192
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
3177
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
2193
3178
  } catch {
2194
3179
  }
2195
3180
  let retryCreatedNewBranch = false;
@@ -2225,15 +3210,15 @@ var GitService = class {
2225
3210
  }
2226
3211
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
2227
3212
  try {
2228
- await fs4.access(absoluteWorktreePath);
3213
+ await fs6.access(absoluteWorktreePath);
2229
3214
  const worktrees = await this.getWorktreesFromBare(bareGit);
2230
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3215
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
2231
3216
  if (isValidWorktree) {
2232
3217
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2233
3218
  return;
2234
3219
  } else {
2235
3220
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
2236
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
3221
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
2237
3222
  }
2238
3223
  } catch {
2239
3224
  }
@@ -2257,7 +3242,7 @@ var GitService = class {
2257
3242
  const fallbackErrorMessage = getErrorMessage(fallbackError);
2258
3243
  if (fallbackErrorMessage.includes("already registered worktree")) {
2259
3244
  const worktrees = await this.getWorktreesFromBare(bareGit);
2260
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3245
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
2261
3246
  if (existingWorktree && !existingWorktree.isPrunable) {
2262
3247
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
2263
3248
  return;
@@ -2480,6 +3465,40 @@ var GitService = class {
2480
3465
  return false;
2481
3466
  }
2482
3467
  }
3468
+ async classifyRemoteRelationship(worktreePath, branch) {
3469
+ const worktreeGit = this.getCachedGit(worktreePath);
3470
+ let headSha;
3471
+ let remoteSha;
3472
+ try {
3473
+ headSha = (await worktreeGit.revparse(["HEAD"])).trim();
3474
+ remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
3475
+ } catch {
3476
+ return "diverged";
3477
+ }
3478
+ if (headSha === remoteSha) return "up_to_date";
3479
+ let mergeBase = "";
3480
+ let mergeBaseFailed = false;
3481
+ try {
3482
+ mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
3483
+ } catch {
3484
+ mergeBaseFailed = true;
3485
+ }
3486
+ if (mergeBaseFailed || !mergeBase) {
3487
+ if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
3488
+ return "diverged";
3489
+ }
3490
+ if (mergeBase === headSha) return "fast_forward";
3491
+ if (mergeBase === remoteSha) return "local_ahead";
3492
+ return "diverged";
3493
+ }
3494
+ async isShallowRepository(git) {
3495
+ try {
3496
+ const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
3497
+ return output.trim() === "true";
3498
+ } catch {
3499
+ return false;
3500
+ }
3501
+ }
2483
3502
  async getChangedPathsInRange(worktreePath, fromRef, toRef) {
2484
3503
  const worktreeGit = this.getCachedGit(worktreePath);
2485
3504
  try {
@@ -2569,7 +3588,7 @@ var GitService = class {
2569
3588
  async createBranch(branchName, baseBranch) {
2570
3589
  const bareGit = this.getCachedGit(this.bareRepoPath);
2571
3590
  const baseRef = await this.resolveCreateBranchBaseRef(bareGit, baseBranch);
2572
- await bareGit.raw(["branch", branchName, baseRef]);
3591
+ await bareGit.raw(["branch", "--no-track", branchName, baseRef]);
2573
3592
  this.logger.info(`Created branch '${branchName}' from '${baseRef}'`);
2574
3593
  }
2575
3594
  async pushBranch(branchName) {
@@ -2588,37 +3607,240 @@ var GitService = class {
2588
3607
  isPrunable: w.prunable
2589
3608
  }));
2590
3609
  }
2591
- };
3610
+ };
3611
+
3612
+ // src/services/progress-emitter.ts
3613
+ var ProgressEmitter = class {
3614
+ listeners = /* @__PURE__ */ new Set();
3615
+ onProgress(listener) {
3616
+ this.listeners.add(listener);
3617
+ return () => this.listeners.delete(listener);
3618
+ }
3619
+ emit(event) {
3620
+ for (const listener of [...this.listeners]) {
3621
+ try {
3622
+ listener(event);
3623
+ } catch {
3624
+ }
3625
+ }
3626
+ }
3627
+ };
3628
+
3629
+ // src/services/repo-operation-lock.ts
3630
+ import * as fs7 from "fs/promises";
3631
+ import * as path10 from "path";
3632
+ import * as lockfile from "proper-lockfile";
3633
+
3634
+ // src/utils/lock-path.ts
3635
+ import { createHash } from "crypto";
3636
+ import * as os from "os";
3637
+ import * as path9 from "path";
3638
+ function getCloneModeLockTarget(config) {
3639
+ const name = config.name;
3640
+ const configDir = config.__configFileDir;
3641
+ const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
3642
+ if (configDir) {
3643
+ return {
3644
+ dir: path9.join(configDir, ".sync-worktrees-state"),
3645
+ file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
3646
+ };
3647
+ }
3648
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
3649
+ const dir = path9.join(stateBase, "sync-worktrees", "locks");
3650
+ return { dir, file: `${hash}.lock` };
3651
+ }
3652
+
3653
+ // src/services/repo-operation-lock.ts
3654
+ var RepoOperationLock = class {
3655
+ constructor(config, gitService, logger = Logger.createDefault()) {
3656
+ this.config = config;
3657
+ this.gitService = gitService;
3658
+ this.logger = logger;
3659
+ }
3660
+ updateLogger(logger) {
3661
+ this.logger = logger;
3662
+ }
3663
+ async acquire() {
3664
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
3665
+ return async () => {
3666
+ };
3667
+ }
3668
+ if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
3669
+ return this.acquireCloneModeLock();
3670
+ }
3671
+ return this.acquireWorktreeModeLock();
3672
+ }
3673
+ async acquireCloneModeLock() {
3674
+ const target = getCloneModeLockTarget(this.config);
3675
+ const lockTarget = path10.join(target.dir, target.file);
3676
+ try {
3677
+ await fs7.mkdir(target.dir, { recursive: true });
3678
+ await fs7.writeFile(lockTarget, "", { flag: "a" });
3679
+ } catch {
3680
+ return null;
3681
+ }
3682
+ return this.lockPath(lockTarget);
3683
+ }
3684
+ async acquireWorktreeModeLock() {
3685
+ const barePath = this.gitService.getBareRepoPath();
3686
+ try {
3687
+ await fs7.mkdir(barePath, { recursive: true });
3688
+ } catch {
3689
+ return null;
3690
+ }
3691
+ return this.lockPath(barePath);
3692
+ }
3693
+ async lockPath(lockTarget) {
3694
+ try {
3695
+ return await lockfile.lock(lockTarget, {
3696
+ stale: DEFAULT_CONFIG.LOCK_STALE_MS,
3697
+ update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
3698
+ retries: 0,
3699
+ realpath: false
3700
+ });
3701
+ } catch (error) {
3702
+ const code = error.code;
3703
+ if (code === "ELOCKED") {
3704
+ return null;
3705
+ }
3706
+ this.logger.warn(
3707
+ `Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
3708
+ );
3709
+ return null;
3710
+ }
3711
+ }
3712
+ };
3713
+
3714
+ // src/services/sync-retry-policy.ts
3715
+ var SyncRetryPolicy = class {
3716
+ constructor(config, gitService, logger) {
3717
+ this.config = config;
3718
+ this.gitService = gitService;
3719
+ this.logger = logger;
3720
+ }
3721
+ updateLogger(logger) {
3722
+ this.logger = logger;
3723
+ }
3724
+ createContext() {
3725
+ return { lfsSkipEnabled: false };
3726
+ }
3727
+ createOptions(syncContext) {
3728
+ return {
3729
+ maxAttempts: this.config.retry?.maxAttempts ?? 3,
3730
+ maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
3731
+ initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
3732
+ maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
3733
+ backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
3734
+ jitterMs: this.config.retry?.jitterMs ?? 0,
3735
+ onRetry: (error, attempt, context) => {
3736
+ const errorMessage = getErrorMessage(error);
3737
+ this.logger.info(`
3738
+ \u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
3739
+ if (context?.isLfsError && !this.config.skipLfs) {
3740
+ this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
3741
+ } else {
3742
+ this.logger.info(`\u{1F504} Retrying synchronization...
3743
+ `);
3744
+ }
3745
+ },
3746
+ lfsRetryHandler: () => {
3747
+ if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
3748
+ this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
3749
+ this.gitService.setLfsSkipEnabled(true);
3750
+ syncContext.lfsSkipEnabled = true;
3751
+ }
3752
+ }
3753
+ };
3754
+ }
3755
+ resetLfsSkipIfNeeded(syncContext) {
3756
+ if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
3757
+ this.gitService.setLfsSkipEnabled(false);
3758
+ }
3759
+ }
3760
+ };
3761
+
3762
+ // src/services/worktree-mode-sync-runner.ts
3763
+ import * as fs9 from "fs/promises";
3764
+ import * as path13 from "path";
3765
+ import pLimit from "p-limit";
3766
+
3767
+ // src/utils/date-filter.ts
3768
+ function parseDuration(durationStr) {
3769
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
3770
+ if (!match) {
3771
+ return null;
3772
+ }
3773
+ const value = parseInt(match[1], 10);
3774
+ const unit = match[2];
3775
+ const multipliers = {
3776
+ h: 60 * 60 * 1e3,
3777
+ // hours
3778
+ d: 24 * 60 * 60 * 1e3,
3779
+ // days
3780
+ w: 7 * 24 * 60 * 60 * 1e3,
3781
+ // weeks
3782
+ m: 30 * 24 * 60 * 60 * 1e3,
3783
+ // months (approximate)
3784
+ y: 365 * 24 * 60 * 60 * 1e3
3785
+ // years (approximate)
3786
+ };
3787
+ return value * multipliers[unit];
3788
+ }
3789
+ function filterBranchesByAge(branches, maxAge) {
3790
+ const maxAgeMs = parseDuration(maxAge);
3791
+ if (maxAgeMs === null) {
3792
+ console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
3793
+ return branches;
3794
+ }
3795
+ const cutoffDate = new Date(Date.now() - maxAgeMs);
3796
+ return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
3797
+ }
3798
+ function formatDuration2(durationStr) {
3799
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
3800
+ if (!match) {
3801
+ return durationStr;
3802
+ }
3803
+ const value = parseInt(match[1], 10);
3804
+ const unit = match[2];
3805
+ const unitNames = {
3806
+ h: value === 1 ? "hour" : "hours",
3807
+ d: value === 1 ? "day" : "days",
3808
+ w: value === 1 ? "week" : "weeks",
3809
+ m: value === 1 ? "month" : "months",
3810
+ y: value === 1 ? "year" : "years"
3811
+ };
3812
+ return `${value} ${unitNames[unit]}`;
3813
+ }
2592
3814
 
2593
3815
  // src/services/path-resolution.service.ts
2594
- import { createHash } from "crypto";
2595
- import * as fs5 from "fs";
2596
- import * as path7 from "path";
3816
+ import { createHash as createHash2 } from "crypto";
3817
+ import * as fs8 from "fs";
3818
+ import * as path11 from "path";
2597
3819
  var BRANCH_STEM_MAX = 80;
2598
3820
  var BRANCH_HASH_LEN = 8;
2599
3821
  var PathResolutionService = class {
2600
3822
  sanitizeBranchName(branchName) {
2601
3823
  const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
2602
- const hash = createHash("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
3824
+ const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
2603
3825
  return `${stem}-${hash}`;
2604
3826
  }
2605
3827
  getBranchWorktreePath(worktreeDir, branchName) {
2606
- return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
3828
+ return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
2607
3829
  }
2608
3830
  resolveRealPath(inputPath) {
2609
- const absolute = path7.resolve(inputPath);
3831
+ const absolute = path11.resolve(inputPath);
2610
3832
  const missing = [];
2611
3833
  let current = absolute;
2612
- while (!fs5.existsSync(current)) {
2613
- const parent = path7.dirname(current);
3834
+ while (!fs8.existsSync(current)) {
3835
+ const parent = path11.dirname(current);
2614
3836
  if (parent === current) {
2615
3837
  return absolute;
2616
3838
  }
2617
- missing.unshift(path7.basename(current));
3839
+ missing.unshift(path11.basename(current));
2618
3840
  current = parent;
2619
3841
  }
2620
3842
  try {
2621
- return path7.join(fs5.realpathSync(current), ...missing);
3843
+ return path11.join(fs8.realpathSync(current), ...missing);
2622
3844
  } catch {
2623
3845
  return absolute;
2624
3846
  }
@@ -2628,7 +3850,7 @@ var PathResolutionService = class {
2628
3850
  const a = fold(resolved);
2629
3851
  const b = fold(resolvedBase);
2630
3852
  if (a === b) return true;
2631
- return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
3853
+ return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
2632
3854
  }
2633
3855
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
2634
3856
  const resolved = this.resolveRealPath(worktreePath);
@@ -2636,7 +3858,7 @@ var PathResolutionService = class {
2636
3858
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
2637
3859
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
2638
3860
  }
2639
- return path7.relative(resolvedBase, resolved);
3861
+ return path11.relative(resolvedBase, resolved);
2640
3862
  }
2641
3863
  isPathInsideBaseDir(targetPath, baseDir) {
2642
3864
  const resolved = this.resolveRealPath(targetPath);
@@ -2648,174 +3870,110 @@ var PathResolutionService = class {
2648
3870
  }
2649
3871
  };
2650
3872
 
2651
- // src/services/worktree-sync.service.ts
2652
- var WorktreeSyncService = class {
2653
- constructor(config) {
3873
+ // src/services/worktree-sync-planner.ts
3874
+ import * as path12 from "path";
3875
+ function createWorktreeSyncPlan(inventory, options = {}) {
3876
+ return {
3877
+ create: planCreateActions(inventory, options),
3878
+ prune: planPruneActions(inventory),
3879
+ update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
3880
+ sparse: planSparseActions(inventory, options.sparseCheckout),
3881
+ warnings: []
3882
+ };
3883
+ }
3884
+ function planCreateActions(inventory, options = {}) {
3885
+ const pathResolution2 = options.pathResolution ?? new PathResolutionService();
3886
+ const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
3887
+ const newBranches = inventory.remoteBranches.filter(
3888
+ (branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
3889
+ );
3890
+ const reservedPaths = /* @__PURE__ */ new Map();
3891
+ for (const worktree of inventory.existingWorktrees) {
3892
+ reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
3893
+ }
3894
+ const actions = [];
3895
+ for (const branch of newBranches) {
3896
+ const worktreePath = pathResolution2.getBranchWorktreePath(inventory.worktreeDir, branch);
3897
+ const resolved = path12.resolve(worktreePath);
3898
+ const conflictingBranch = reservedPaths.get(resolved);
3899
+ if (conflictingBranch && conflictingBranch !== branch) {
3900
+ actions.push({
3901
+ kind: "skip-create",
3902
+ branch,
3903
+ path: worktreePath,
3904
+ reason: "path-collision",
3905
+ conflictingBranch
3906
+ });
3907
+ continue;
3908
+ }
3909
+ reservedPaths.set(resolved, branch);
3910
+ actions.push({ kind: "create", branch, path: worktreePath });
3911
+ }
3912
+ return actions;
3913
+ }
3914
+ function planPruneActions(inventory) {
3915
+ const remoteBranches = new Set(inventory.remoteBranches);
3916
+ return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
3917
+ }
3918
+ function planUpdateActions(inventory) {
3919
+ const remoteBranches = new Set(inventory.remoteBranches);
3920
+ return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
3921
+ }
3922
+ function planSparseActions(inventory, sparseCheckout) {
3923
+ if (!sparseCheckout) {
3924
+ return [];
3925
+ }
3926
+ return inventory.existingWorktrees.map((worktree) => ({
3927
+ kind: "check-sparse",
3928
+ branch: worktree.branch,
3929
+ path: worktree.path
3930
+ }));
3931
+ }
3932
+
3933
+ // src/services/worktree-mode-sync-runner.ts
3934
+ var WorktreeModeSyncRunner = class {
3935
+ constructor(config, gitService, logger, progressEmitter) {
2654
3936
  this.config = config;
2655
- this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
2656
- this.gitService = new GitService(config, this.logger);
3937
+ this.gitService = gitService;
3938
+ this.logger = logger;
3939
+ this.progressEmitter = progressEmitter;
2657
3940
  }
2658
- gitService;
2659
- logger;
2660
- syncInProgress = false;
2661
3941
  pathResolution = new PathResolutionService();
2662
- progressListeners = /* @__PURE__ */ new Set();
2663
- async initialize() {
2664
- this.emitProgress({ phase: "initialize", message: "Initializing repository" });
2665
- await this.gitService.initialize();
2666
- this.emitProgress({ phase: "initialize", message: "Repository initialized" });
2667
- }
2668
- isInitialized() {
2669
- return this.gitService.isInitialized();
2670
- }
2671
- isSyncInProgress() {
2672
- return this.syncInProgress;
2673
- }
2674
- getGitService() {
2675
- return this.gitService;
2676
- }
2677
3942
  updateLogger(logger) {
2678
3943
  this.logger = logger;
2679
- this.gitService.updateLogger(logger);
2680
- }
2681
- onProgress(listener) {
2682
- this.progressListeners.add(listener);
2683
- return () => this.progressListeners.delete(listener);
2684
- }
2685
- async runExclusiveRepoOperation(operation) {
2686
- if (this.syncInProgress) {
2687
- this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
2688
- return { started: false, reason: "in_progress" };
2689
- }
2690
- const release = await this.acquireBareLock();
2691
- if (release === null) {
2692
- this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
2693
- return { started: false, reason: "locked" };
2694
- }
2695
- this.syncInProgress = true;
2696
- try {
2697
- return { started: true, value: await operation() };
2698
- } finally {
2699
- this.syncInProgress = false;
2700
- try {
2701
- await release();
2702
- } catch (releaseError) {
2703
- this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
2704
- }
2705
- }
2706
- }
2707
- emitProgress(event) {
2708
- for (const listener of this.progressListeners) {
2709
- try {
2710
- listener(event);
2711
- } catch {
2712
- }
2713
- }
2714
- }
2715
- async sync() {
2716
- const result = await this.runExclusiveRepoOperation(async () => {
2717
- if (!this.isInitialized()) {
2718
- await this.initialize();
2719
- }
2720
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
2721
- const totalTimer = new Timer();
2722
- const phaseTimer = new PhaseTimer();
2723
- const syncContext = { lfsSkipEnabled: false };
2724
- const retryOptions = this.createRetryOptions(syncContext);
2725
- try {
2726
- await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
2727
- } catch (error) {
2728
- this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
2729
- throw error;
2730
- } finally {
2731
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
2732
- this.gitService.setLfsSkipEnabled(false);
2733
- }
2734
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
2735
- `);
2736
- if (this.config.debug) {
2737
- const totalDuration = totalTimer.stop();
2738
- const phaseResults = phaseTimer.getResults();
2739
- const repoName = this.config.name;
2740
- this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
2741
- }
2742
- }
2743
- });
2744
- return result.started ? { started: true } : result;
2745
- }
2746
- async acquireBareLock() {
2747
- if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
2748
- return async () => {
2749
- };
2750
- }
2751
- if (typeof this.gitService.getBareRepoPath !== "function") {
2752
- return async () => {
2753
- };
2754
- }
2755
- const barePath = this.gitService.getBareRepoPath();
2756
- await fs6.mkdir(barePath, { recursive: true });
2757
- try {
2758
- const release = await lockfile.lock(barePath, {
2759
- stale: DEFAULT_CONFIG.LOCK_STALE_MS,
2760
- update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
2761
- retries: 0,
2762
- realpath: false
2763
- });
2764
- return release;
2765
- } catch (error) {
2766
- const code = error.code;
2767
- if (code === "ELOCKED") {
2768
- return null;
2769
- }
2770
- throw error;
2771
- }
2772
- }
2773
- createRetryOptions(syncContext) {
2774
- return {
2775
- maxAttempts: this.config.retry?.maxAttempts ?? 3,
2776
- maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
2777
- initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
2778
- maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
2779
- backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
2780
- onRetry: (error, attempt, context) => {
2781
- const errorMessage = getErrorMessage(error);
2782
- this.logger.info(`
2783
- \u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
2784
- if (context?.isLfsError && !this.config.skipLfs) {
2785
- this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
2786
- } else {
2787
- this.logger.info(`\u{1F504} Retrying synchronization...
2788
- `);
2789
- }
2790
- },
2791
- lfsRetryHandler: () => {
2792
- if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
2793
- this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
2794
- this.gitService.setLfsSkipEnabled(true);
2795
- syncContext.lfsSkipEnabled = true;
2796
- }
2797
- }
2798
- };
2799
3944
  }
2800
- async runSyncAttempt(phaseTimer, syncContext) {
3945
+ async runSyncAttempt(phaseTimer, syncContext, outcome) {
2801
3946
  await this.gitService.pruneWorktrees();
2802
3947
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
2803
3948
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
2804
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
3949
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
2805
3950
  const worktrees = await this.gitService.getWorktrees();
2806
3951
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
2807
3952
  await this.cleanupOrphanedDirectories(worktrees);
2808
- await this.createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer);
2809
- await this.pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer);
3953
+ const syncPlan = createWorktreeSyncPlan(
3954
+ {
3955
+ remoteBranches,
3956
+ defaultBranch,
3957
+ existingWorktrees: worktrees,
3958
+ worktreeDir: this.config.worktreeDir
3959
+ },
3960
+ {
3961
+ pathResolution: this.pathResolution,
3962
+ updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
3963
+ sparseCheckout: this.config.sparseCheckout
3964
+ }
3965
+ );
3966
+ await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
3967
+ await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
2810
3968
  if (this.config.updateExistingWorktrees !== false) {
2811
- await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
3969
+ await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
2812
3970
  }
2813
3971
  if (this.config.sparseCheckout) {
2814
- await this.reapplySparseCheckout(worktrees);
3972
+ await this.reapplySparseCheckout(syncPlan.sparse, outcome);
2815
3973
  }
2816
3974
  await this.finalizeSyncAttempt(phaseTimer);
2817
3975
  }
2818
- async reapplySparseCheckout(worktrees) {
3976
+ async reapplySparseCheckout(actions, outcome) {
2819
3977
  const sparseConfig = this.config.sparseCheckout;
2820
3978
  if (!sparseConfig) return;
2821
3979
  this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
@@ -2823,32 +3981,44 @@ var WorktreeSyncService = class {
2823
3981
  const desired = sparseService.buildPatterns(sparseConfig);
2824
3982
  const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
2825
3983
  await Promise.all(
2826
- worktrees.map(
2827
- (worktree) => limit(async () => {
3984
+ actions.map(
3985
+ (action) => limit(async () => {
3986
+ if (action.kind !== "check-sparse") return;
2828
3987
  try {
2829
3988
  try {
2830
- await fs6.access(worktree.path);
3989
+ await fs9.access(action.path);
2831
3990
  } catch {
2832
3991
  return;
2833
3992
  }
2834
- const current = await sparseService.readCurrent(worktree.path);
3993
+ const current = await sparseService.readCurrent(action.path);
2835
3994
  if (current !== null && sparseService.patternsEqual(current, desired)) return;
2836
3995
  if (sparseService.isNarrowing(current, desired)) {
2837
- const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
3996
+ const status = await this.gitService.getFullWorktreeStatus(action.path, false);
2838
3997
  if (!status.canRemove) {
2839
3998
  this.logger.warn(
2840
- ` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
3999
+ ` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
2841
4000
  );
4001
+ outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
4002
+ branch: action.branch,
4003
+ path: action.path,
4004
+ message: status.reasons.join(", ")
4005
+ });
2842
4006
  return;
2843
4007
  }
2844
4008
  }
2845
- await sparseService.applyToWorktree(worktree.path, sparseConfig);
2846
- await this.gitService.checkoutHead(worktree.path);
2847
- this.logger.info(` - \u2705 Sparse-checkout updated for '${worktree.branch}'`);
4009
+ await sparseService.applyToWorktree(action.path, sparseConfig);
4010
+ await this.gitService.checkoutHead(action.path);
4011
+ this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
4012
+ outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
2848
4013
  } catch (error) {
2849
4014
  this.logger.warn(
2850
- ` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
4015
+ ` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
2851
4016
  );
4017
+ outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
4018
+ reason: "sparse_checkout_failed",
4019
+ branch: action.branch,
4020
+ path: action.path
4021
+ });
2852
4022
  }
2853
4023
  })
2854
4024
  )
@@ -2857,7 +4027,7 @@ var WorktreeSyncService = class {
2857
4027
  async fetchLatestRemoteData(phaseTimer, syncContext) {
2858
4028
  this.logger.info("Step 1: Fetching latest data from remote...");
2859
4029
  phaseTimer.startPhase("Phase 1: Fetch");
2860
- this.emitProgress({ phase: "fetch", message: "Fetching latest data from remote" });
4030
+ this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
2861
4031
  try {
2862
4032
  await this.gitService.fetchAll();
2863
4033
  } catch (fetchError) {
@@ -2902,7 +4072,7 @@ var WorktreeSyncService = class {
2902
4072
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
2903
4073
  const remoteBranches = filteredBranches.map((b) => b.branch);
2904
4074
  this.logger.info(
2905
- `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
4075
+ `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
2906
4076
  );
2907
4077
  if (filteredByName.length > remoteBranches.length) {
2908
4078
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -2921,45 +4091,38 @@ var WorktreeSyncService = class {
2921
4091
  }
2922
4092
  async finalizeSyncAttempt(phaseTimer) {
2923
4093
  phaseTimer.startPhase("Phase 5: Cleanup");
2924
- this.emitProgress({ phase: "cleanup", message: "Pruning worktree metadata" });
4094
+ this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
2925
4095
  await this.gitService.pruneWorktrees();
2926
4096
  this.logger.info("Step 5: Pruned worktree metadata.");
2927
4097
  phaseTimer.endPhase();
2928
4098
  }
2929
- async createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer) {
4099
+ async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
2930
4100
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
2931
4101
  phaseTimer.startPhase("Phase 2: Create", maxConcurrent);
2932
- this.emitProgress({ phase: "create", message: "Creating worktrees for new branches" });
2933
- await this.createNewWorktrees(remoteBranches, worktrees, defaultBranch);
2934
- const existingBranches = new Set(worktrees.map((w) => w.branch));
2935
- const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
2936
- phaseTimer.setPhaseCount("Phase 2: Create", newBranches.length);
4102
+ this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
4103
+ await this.createNewWorktrees(syncPlan.create, outcome);
4104
+ phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
2937
4105
  phaseTimer.endPhase();
2938
4106
  }
2939
- async createNewWorktrees(remoteBranches, worktrees, defaultBranch) {
2940
- const existingBranches = new Set(worktrees.map((w) => w.branch));
2941
- const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
2942
- if (newBranches.length === 0) {
4107
+ async createNewWorktrees(actions, outcome) {
4108
+ if (actions.length === 0) {
2943
4109
  this.logger.info("Step 2: No new branches to create worktrees for.");
2944
4110
  return;
2945
4111
  }
2946
- const reservedPaths = /* @__PURE__ */ new Map();
2947
- for (const w of worktrees) {
2948
- reservedPaths.set(path8.resolve(w.path), w.branch);
2949
- }
2950
4112
  const plan = [];
2951
- for (const branchName of newBranches) {
2952
- const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
2953
- const resolved = path8.resolve(worktreePath);
2954
- const conflict = reservedPaths.get(resolved);
2955
- if (conflict && conflict !== branchName) {
4113
+ for (const action of actions) {
4114
+ if (action.kind === "skip-create") {
2956
4115
  this.logger.error(
2957
- ` \u274C Skipping '${branchName}': sanitized worktree path '${worktreePath}' collides with existing branch '${conflict}'.`
4116
+ ` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
2958
4117
  );
4118
+ outcome.recordSkipped("branch", "path_collision", {
4119
+ branch: action.branch,
4120
+ path: action.path,
4121
+ message: `Path collides with existing branch '${action.conflictingBranch}'`
4122
+ });
2959
4123
  continue;
2960
4124
  }
2961
- reservedPaths.set(resolved, branchName);
2962
- plan.push({ branchName, worktreePath });
4125
+ plan.push({ branchName: action.branch, worktreePath: action.path });
2963
4126
  }
2964
4127
  this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
2965
4128
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
@@ -2970,8 +4133,14 @@ var WorktreeSyncService = class {
2970
4133
  try {
2971
4134
  await this.gitService.addWorktree(branchName, worktreePath);
2972
4135
  this.logger.info(` \u2705 Created worktree for '${branchName}'`);
4136
+ outcome.recordCreated(branchName, worktreePath);
2973
4137
  } catch (error) {
2974
4138
  this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
4139
+ outcome.recordFailed("worktree", getErrorMessage(error), {
4140
+ reason: "create_failed",
4141
+ branch: branchName,
4142
+ path: worktreePath
4143
+ });
2975
4144
  throw error;
2976
4145
  }
2977
4146
  })
@@ -2980,23 +4149,21 @@ var WorktreeSyncService = class {
2980
4149
  const successCount = results.filter((r) => r.status === "fulfilled").length;
2981
4150
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
2982
4151
  }
2983
- async pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer) {
4152
+ async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
2984
4153
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
2985
4154
  phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
2986
- this.emitProgress({ phase: "prune", message: "Pruning stale worktrees" });
2987
- await this.pruneOldWorktrees(remoteBranches, worktrees);
2988
- const deletedWorktrees = worktrees.filter((w) => !remoteBranches.includes(w.branch));
2989
- phaseTimer.setPhaseCount("Phase 3: Prune", deletedWorktrees.length);
4155
+ this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
4156
+ await this.pruneOldWorktrees(actions, outcome);
4157
+ phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
2990
4158
  phaseTimer.endPhase();
2991
4159
  }
2992
- async pruneOldWorktrees(remoteBranches, worktrees) {
2993
- const deletedWorktrees = worktrees.filter((w) => !remoteBranches.includes(w.branch));
2994
- if (deletedWorktrees.length > 0) {
2995
- this.logger.info(`Step 3: Checking ${deletedWorktrees.length} stale worktrees to prune...`);
4160
+ async pruneOldWorktrees(actions, outcome) {
4161
+ if (actions.length > 0) {
4162
+ this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
2996
4163
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
2997
4164
  const limit = pLimit(maxConcurrent);
2998
4165
  const statusResults = await Promise.allSettled(
2999
- deletedWorktrees.map(
4166
+ actions.map(
3000
4167
  ({ branch: branchName, path: worktreePath }) => limit(async () => {
3001
4168
  const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
3002
4169
  return { branchName, worktreePath, status };
@@ -3019,6 +4186,10 @@ var WorktreeSyncService = class {
3019
4186
  const branchName = result.reason?.branchName ?? "unknown";
3020
4187
  this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
3021
4188
  this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
4189
+ outcome.recordSkipped("worktree", "prune_status_check_failed", {
4190
+ branch: branchName,
4191
+ message: getErrorMessage(result.reason)
4192
+ });
3022
4193
  }
3023
4194
  }
3024
4195
  if (toRemove.length > 0) {
@@ -3034,12 +4205,23 @@ var WorktreeSyncService = class {
3034
4205
  this.logger.warn(
3035
4206
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
3036
4207
  );
4208
+ outcome.recordSkipped("worktree", "prune_status_changed", {
4209
+ branch: branchName,
4210
+ path: worktreePath,
4211
+ message: recheck.reasons.join(", ")
4212
+ });
3037
4213
  return;
3038
4214
  }
3039
4215
  await this.gitService.removeWorktree(worktreePath);
3040
4216
  this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
4217
+ outcome.recordRemoved(branchName, worktreePath);
3041
4218
  } catch (error) {
3042
4219
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
4220
+ outcome.recordFailed("worktree", getErrorMessage(error), {
4221
+ reason: "remove_failed",
4222
+ branch: branchName,
4223
+ path: worktreePath
4224
+ });
3043
4225
  throw error;
3044
4226
  }
3045
4227
  })
@@ -3052,6 +4234,11 @@ var WorktreeSyncService = class {
3052
4234
  this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
3053
4235
  }
3054
4236
  for (const { branchName, worktreePath, status } of toSkip) {
4237
+ outcome.recordSkipped("worktree", "unsafe_to_remove", {
4238
+ branch: branchName,
4239
+ path: worktreePath,
4240
+ message: status.reasons.join(", ")
4241
+ });
3055
4242
  if (status.upstreamGone && status.hasUnpushedCommits) {
3056
4243
  this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
3057
4244
  this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
@@ -3144,53 +4331,52 @@ var WorktreeSyncService = class {
3144
4331
  this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
3145
4332
  }
3146
4333
  }
3147
- async updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer) {
4334
+ async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
3148
4335
  const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
3149
4336
  phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
3150
- this.emitProgress({ phase: "update", message: "Updating existing worktrees" });
3151
- await this.updateExistingWorktrees(worktrees, remoteBranches);
3152
- const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
3153
- phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
4337
+ this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
4338
+ await this.updateExistingWorktrees(actions, outcome);
4339
+ phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
3154
4340
  phaseTimer.endPhase();
3155
4341
  }
3156
- async updateExistingWorktrees(worktrees, remoteBranches) {
4342
+ async updateExistingWorktrees(actions, outcome) {
3157
4343
  this.logger.info("Step 4: Checking for worktrees that need updates...");
3158
- const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4344
+ const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3159
4345
  try {
3160
- const diverged = await fs6.readdir(divergedDir);
4346
+ const diverged = await fs9.readdir(divergedDir);
3161
4347
  if (diverged.length > 0) {
3162
4348
  this.logger.info(
3163
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
4349
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
3164
4350
  );
3165
4351
  }
3166
4352
  } catch {
3167
4353
  }
3168
- const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
3169
4354
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
3170
4355
  const limit = pLimit(maxConcurrent);
3171
4356
  const checkResults = await Promise.allSettled(
3172
- activeWorktrees.map(
3173
- (worktree) => limit(async () => {
4357
+ actions.map(
4358
+ (action) => limit(async () => {
4359
+ const worktree = { path: action.path, branch: action.branch };
3174
4360
  try {
3175
- await fs6.access(worktree.path);
4361
+ await fs9.access(worktree.path);
3176
4362
  } catch {
3177
- return null;
4363
+ return { action: "skip", worktree, reason: "missing_worktree_path" };
3178
4364
  }
3179
4365
  const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
3180
- if (hasOp) return null;
4366
+ if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
3181
4367
  const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
3182
- if (!isClean) return null;
4368
+ if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
3183
4369
  const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
3184
4370
  if (!canFastForward) {
3185
4371
  const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
3186
4372
  if (isAhead) {
3187
4373
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
3188
- return null;
4374
+ return { action: "skip", worktree, reason: "local_ahead" };
3189
4375
  }
3190
4376
  return { action: "diverged", worktree };
3191
4377
  }
3192
4378
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
3193
- if (!isBehind) return null;
4379
+ if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
3194
4380
  const sparseCfg = this.config.sparseCheckout;
3195
4381
  if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
3196
4382
  const sparseService = this.gitService.getSparseCheckoutService();
@@ -3202,7 +4388,7 @@ var WorktreeSyncService = class {
3202
4388
  );
3203
4389
  if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
3204
4390
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
3205
- return null;
4391
+ return { action: "skip", worktree, reason: "outside_sparse_checkout" };
3206
4392
  }
3207
4393
  }
3208
4394
  }
@@ -3214,13 +4400,25 @@ var WorktreeSyncService = class {
3214
4400
  const divergedWorktrees = [];
3215
4401
  for (const result of checkResults) {
3216
4402
  if (result.status === "fulfilled" && result.value) {
3217
- if (result.value.action === "update") {
3218
- worktreesToUpdate.push(result.value.worktree);
3219
- } else {
3220
- divergedWorktrees.push(result.value.worktree);
4403
+ switch (result.value.action) {
4404
+ case "update":
4405
+ worktreesToUpdate.push(result.value.worktree);
4406
+ break;
4407
+ case "diverged":
4408
+ divergedWorktrees.push(result.value.worktree);
4409
+ break;
4410
+ case "noop":
4411
+ outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
4412
+ break;
4413
+ case "skip":
4414
+ outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
4415
+ break;
3221
4416
  }
3222
4417
  } else if (result.status === "rejected") {
3223
4418
  this.logger.error(` - Error checking worktree:`, result.reason);
4419
+ outcome.recordSkipped("worktree", "update_check_failed", {
4420
+ message: getErrorMessage(result.reason)
4421
+ });
3224
4422
  }
3225
4423
  }
3226
4424
  const updateLimit = pLimit(
@@ -3234,6 +4432,7 @@ var WorktreeSyncService = class {
3234
4432
  this.logger.info(` - Updating worktree '${worktree.branch}'...`);
3235
4433
  await this.gitService.updateWorktree(worktree.path);
3236
4434
  this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
4435
+ outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
3237
4436
  } catch (error) {
3238
4437
  const errorMessage = getErrorMessage(error);
3239
4438
  if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
@@ -3241,13 +4440,23 @@ var WorktreeSyncService = class {
3241
4440
  ` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
3242
4441
  );
3243
4442
  try {
3244
- await this.handleDivergedBranch(worktree);
4443
+ await this.handleDivergedBranch(worktree, outcome);
3245
4444
  } catch (divergedError) {
3246
4445
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
4446
+ outcome.recordFailed("worktree", getErrorMessage(divergedError), {
4447
+ reason: "diverged_recovery_failed",
4448
+ branch: worktree.branch,
4449
+ path: worktree.path
4450
+ });
3247
4451
  throw divergedError;
3248
4452
  }
3249
4453
  } else {
3250
4454
  this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
4455
+ outcome.recordFailed("worktree", errorMessage, {
4456
+ reason: "update_failed",
4457
+ branch: worktree.branch,
4458
+ path: worktree.path
4459
+ });
3251
4460
  throw error;
3252
4461
  }
3253
4462
  }
@@ -3259,9 +4468,14 @@ var WorktreeSyncService = class {
3259
4468
  mutationTasks.push(
3260
4469
  updateLimit(async () => {
3261
4470
  try {
3262
- await this.handleDivergedBranch(worktree);
4471
+ await this.handleDivergedBranch(worktree, outcome);
3263
4472
  } catch (error) {
3264
4473
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
4474
+ outcome.recordFailed("worktree", getErrorMessage(error), {
4475
+ reason: "diverged_recovery_failed",
4476
+ branch: worktree.branch,
4477
+ path: worktree.path
4478
+ });
3265
4479
  throw error;
3266
4480
  }
3267
4481
  return { type: "diverged", branch: worktree.branch };
@@ -3284,13 +4498,13 @@ var WorktreeSyncService = class {
3284
4498
  }
3285
4499
  async cleanupOrphanedDirectories(worktrees) {
3286
4500
  try {
3287
- const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
3288
- const allDirs = await fs6.readdir(this.config.worktreeDir);
4501
+ const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
4502
+ const allDirs = await fs9.readdir(this.config.worktreeDir);
3289
4503
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
3290
4504
  const orphanedDirs = [];
3291
4505
  for (const dir of regularDirs) {
3292
4506
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
3293
- return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
4507
+ return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
3294
4508
  });
3295
4509
  if (!isPartOfWorktree) {
3296
4510
  orphanedDirs.push(dir);
@@ -3299,11 +4513,11 @@ var WorktreeSyncService = class {
3299
4513
  if (orphanedDirs.length > 0) {
3300
4514
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
3301
4515
  for (const dir of orphanedDirs) {
3302
- const dirPath = path8.join(this.config.worktreeDir, dir);
4516
+ const dirPath = path13.join(this.config.worktreeDir, dir);
3303
4517
  try {
3304
- const stat4 = await fs6.stat(dirPath);
4518
+ const stat4 = await fs9.stat(dirPath);
3305
4519
  if (stat4.isDirectory()) {
3306
- await fs6.rm(dirPath, { recursive: true, force: true });
4520
+ await fs9.rm(dirPath, { recursive: true, force: true });
3307
4521
  this.logger.info(` - Removed orphaned directory: ${dir}`);
3308
4522
  }
3309
4523
  } catch (error) {
@@ -3315,13 +4529,14 @@ var WorktreeSyncService = class {
3315
4529
  this.logger.error("Error during orphaned directory cleanup:", error);
3316
4530
  }
3317
4531
  }
3318
- async handleDivergedBranch(worktree) {
4532
+ async handleDivergedBranch(worktree, outcome) {
3319
4533
  this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
3320
4534
  const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
3321
4535
  if (treesIdentical) {
3322
4536
  this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
3323
4537
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
3324
4538
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
4539
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
3325
4540
  } else {
3326
4541
  const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
3327
4542
  if (!hasLocalChanges) {
@@ -3330,10 +4545,12 @@ var WorktreeSyncService = class {
3330
4545
  );
3331
4546
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
3332
4547
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
4548
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
3333
4549
  } else {
3334
4550
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
3335
4551
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
3336
- const relativePath = path8.relative(process.cwd(), divergedPath);
4552
+ const relativePath = path13.relative(process.cwd(), divergedPath);
4553
+ outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
3337
4554
  this.logger.info(` Moved to: ${relativePath}`);
3338
4555
  this.logger.info(` Your local changes are preserved. To review:`);
3339
4556
  this.logger.info(` cd ${relativePath}`);
@@ -3344,55 +4561,238 @@ var WorktreeSyncService = class {
3344
4561
  }
3345
4562
  }
3346
4563
  }
3347
- async hasLocalChangesSinceLastSync(worktreePath) {
4564
+ async hasLocalChangesSinceLastSync(worktreePath) {
4565
+ try {
4566
+ const metadata = await this.gitService.getWorktreeMetadata(worktreePath);
4567
+ if (!metadata || !metadata.lastSyncCommit) {
4568
+ return true;
4569
+ }
4570
+ const currentCommit = await this.gitService.getCurrentCommit(worktreePath);
4571
+ return currentCommit !== metadata.lastSyncCommit;
4572
+ } catch {
4573
+ return true;
4574
+ }
4575
+ }
4576
+ async divergeWorktree(worktreePath, branchName) {
4577
+ const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4578
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4579
+ const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
4580
+ const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
4581
+ const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
4582
+ const divergedPath = path13.join(divergedBaseDir, divergedName);
4583
+ await fs9.mkdir(divergedBaseDir, { recursive: true });
4584
+ try {
4585
+ await fs9.rename(worktreePath, divergedPath);
4586
+ } catch (err) {
4587
+ if (err.code === ERROR_MESSAGES.EXDEV) {
4588
+ await fs9.cp(worktreePath, divergedPath, { recursive: true });
4589
+ await fs9.rm(worktreePath, { recursive: true, force: true });
4590
+ } else {
4591
+ throw err;
4592
+ }
4593
+ }
4594
+ const metadata = {
4595
+ originalBranch: branchName,
4596
+ divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
4597
+ reason: METADATA_CONSTANTS.DIVERGED_REASON,
4598
+ originalPath: worktreePath,
4599
+ localCommit: await this.gitService.getCurrentCommit(divergedPath),
4600
+ remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
4601
+ instruction: `To preserve your changes:
4602
+ 1. Review: git diff origin/${branchName}
4603
+ 2. Keep changes: git push --force-with-lease origin ${branchName}
4604
+ 3. Discard changes: rm -rf this directory
4605
+
4606
+ Original worktree location: ${worktreePath}`
4607
+ };
4608
+ await fs9.writeFile(
4609
+ path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4610
+ JSON.stringify(metadata, null, 2)
4611
+ );
4612
+ return divergedPath;
4613
+ }
4614
+ };
4615
+
4616
+ // src/services/worktree-sync.service.ts
4617
+ var WorktreeSyncService = class {
4618
+ constructor(config) {
4619
+ this.config = config;
4620
+ this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
4621
+ this.gitService = new GitService(config, this.logger);
4622
+ this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
4623
+ this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
4624
+ this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
4625
+ config,
4626
+ this.gitService,
4627
+ this.logger,
4628
+ this.progressEmitter
4629
+ );
4630
+ if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
4631
+ this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
4632
+ progressEmitter: (event) => this.emitProgress(event),
4633
+ onSkip: (reason) => {
4634
+ this.skipsAccumulator.push(reason);
4635
+ }
4636
+ });
4637
+ }
4638
+ }
4639
+ gitService;
4640
+ cloneSyncService = null;
4641
+ logger;
4642
+ syncInProgress = false;
4643
+ progressEmitter = new ProgressEmitter();
4644
+ repoOperationLock;
4645
+ retryPolicy;
4646
+ worktreeModeSyncRunner;
4647
+ skipsAccumulator = [];
4648
+ lastOutcome = null;
4649
+ getRecordedSkips() {
4650
+ return [...this.skipsAccumulator];
4651
+ }
4652
+ clearRecordedSkips() {
4653
+ this.skipsAccumulator = [];
4654
+ }
4655
+ clearPendingInitSkip() {
4656
+ this.cloneSyncService?.clearPendingInitSkip();
4657
+ }
4658
+ getLastOutcome() {
4659
+ return this.lastOutcome;
4660
+ }
4661
+ isCloneMode() {
4662
+ return this.cloneSyncService !== null;
4663
+ }
4664
+ async getWorktrees() {
4665
+ if (this.cloneSyncService) {
4666
+ return this.cloneSyncService.getWorktrees();
4667
+ }
4668
+ return this.gitService.getWorktrees();
4669
+ }
4670
+ async initialize() {
4671
+ if (this.isInitialized()) return;
4672
+ const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
4673
+ if (!result.started) {
4674
+ const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
4675
+ this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
4676
+ }
4677
+ }
4678
+ async initializeUnlocked(outcome) {
4679
+ this.emitProgress({ phase: "initialize", message: "Initializing repository" });
4680
+ if (this.cloneSyncService) {
4681
+ await this.cloneSyncService.initialize(outcome);
4682
+ } else {
4683
+ await this.gitService.initialize();
4684
+ }
4685
+ this.emitProgress({ phase: "initialize", message: "Repository initialized" });
4686
+ }
4687
+ isInitialized() {
4688
+ if (this.cloneSyncService) {
4689
+ return this.cloneSyncService.isInitialized();
4690
+ }
4691
+ return this.gitService.isInitialized();
4692
+ }
4693
+ isSyncInProgress() {
4694
+ return this.syncInProgress;
4695
+ }
4696
+ getGitService() {
4697
+ return this.gitService;
4698
+ }
4699
+ updateLogger(logger) {
4700
+ this.logger = logger;
4701
+ this.gitService.updateLogger(logger);
4702
+ this.cloneSyncService?.updateLogger(logger);
4703
+ this.retryPolicy.updateLogger(logger);
4704
+ this.worktreeModeSyncRunner.updateLogger(logger);
4705
+ this.repoOperationLock.updateLogger(logger);
4706
+ }
4707
+ onProgress(listener) {
4708
+ return this.progressEmitter.onProgress(listener);
4709
+ }
4710
+ async runExclusiveRepoOperation(operation) {
4711
+ if (this.syncInProgress) {
4712
+ this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
4713
+ return { started: false, reason: "in_progress" };
4714
+ }
4715
+ this.syncInProgress = true;
4716
+ let release;
3348
4717
  try {
3349
- const metadata = await this.gitService.getWorktreeMetadata(worktreePath);
3350
- if (!metadata || !metadata.lastSyncCommit) {
3351
- return true;
3352
- }
3353
- const currentCommit = await this.gitService.getCurrentCommit(worktreePath);
3354
- return currentCommit !== metadata.lastSyncCommit;
3355
- } catch {
3356
- return true;
4718
+ release = await this.repoOperationLock.acquire();
4719
+ } catch (error) {
4720
+ this.syncInProgress = false;
4721
+ throw error;
4722
+ }
4723
+ if (release === null) {
4724
+ this.syncInProgress = false;
4725
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
4726
+ return { started: false, reason: "locked" };
3357
4727
  }
3358
- }
3359
- async divergeWorktree(worktreePath, branchName) {
3360
- const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3361
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3362
- const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
3363
- const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
3364
- const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
3365
- const divergedPath = path8.join(divergedBaseDir, divergedName);
3366
- await fs6.mkdir(divergedBaseDir, { recursive: true });
3367
4728
  try {
3368
- await fs6.rename(worktreePath, divergedPath);
3369
- } catch (err) {
3370
- if (err.code === ERROR_MESSAGES.EXDEV) {
3371
- await fs6.cp(worktreePath, divergedPath, { recursive: true });
3372
- await fs6.rm(worktreePath, { recursive: true, force: true });
3373
- } else {
3374
- throw err;
4729
+ return { started: true, value: await operation() };
4730
+ } finally {
4731
+ try {
4732
+ await release();
4733
+ } catch (releaseError) {
4734
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
3375
4735
  }
4736
+ this.syncInProgress = false;
3376
4737
  }
3377
- const metadata = {
3378
- originalBranch: branchName,
3379
- divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
3380
- reason: METADATA_CONSTANTS.DIVERGED_REASON,
3381
- originalPath: worktreePath,
3382
- localCommit: await this.gitService.getCurrentCommit(divergedPath),
3383
- remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
3384
- instruction: `To preserve your changes:
3385
- 1. Review: git diff origin/${branchName}
3386
- 2. Keep changes: git push --force-with-lease origin ${branchName}
3387
- 3. Discard changes: rm -rf this directory
3388
-
3389
- Original worktree location: ${worktreePath}`
3390
- };
3391
- await fs6.writeFile(
3392
- path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
3393
- JSON.stringify(metadata, null, 2)
3394
- );
3395
- return divergedPath;
4738
+ }
4739
+ emitProgress(event) {
4740
+ this.progressEmitter.emit(event);
4741
+ }
4742
+ async sync() {
4743
+ const result = await this.runExclusiveRepoOperation(async () => {
4744
+ const totalTimer = new Timer();
4745
+ const phaseTimer = new PhaseTimer();
4746
+ const outcome = new SyncOutcomeAccumulator({
4747
+ mode: this.cloneSyncService ? "clone" : "worktree",
4748
+ repoName: this.config.name
4749
+ });
4750
+ const syncContext = this.retryPolicy.createContext();
4751
+ const retryOptions = this.retryPolicy.createOptions(syncContext);
4752
+ let durationMs;
4753
+ try {
4754
+ if (!this.isInitialized()) {
4755
+ await this.initializeUnlocked(outcome);
4756
+ }
4757
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
4758
+ const retryOutcomeBaseline = outcome.snapshot();
4759
+ const retryOptionsWithOutcomeReset = {
4760
+ ...retryOptions,
4761
+ onRetry: (error, attempt, context) => {
4762
+ outcome.restore(retryOutcomeBaseline);
4763
+ retryOptions.onRetry?.(error, attempt, context);
4764
+ }
4765
+ };
4766
+ const cloneSync = this.cloneSyncService;
4767
+ if (cloneSync) {
4768
+ await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
4769
+ } else {
4770
+ await retry(
4771
+ () => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
4772
+ retryOptionsWithOutcomeReset
4773
+ );
4774
+ }
4775
+ } catch (error) {
4776
+ if (outcome.getCounts().failed === 0) {
4777
+ outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
4778
+ }
4779
+ this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
4780
+ throw error;
4781
+ } finally {
4782
+ this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
4783
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
4784
+ `);
4785
+ durationMs = totalTimer.stop();
4786
+ this.lastOutcome = outcome.toOutcome(durationMs);
4787
+ if (this.config.debug) {
4788
+ const phaseResults = phaseTimer.getResults();
4789
+ const repoName = this.config.name;
4790
+ this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
4791
+ }
4792
+ }
4793
+ return this.lastOutcome ?? outcome.toOutcome(durationMs);
4794
+ });
4795
+ return result.started ? { started: true, outcome: result.value } : result;
3396
4796
  }
3397
4797
  };
3398
4798
 
@@ -3442,11 +4842,18 @@ var RepositoryContext = class {
3442
4842
  configPath = null;
3443
4843
  configLoader = new ConfigLoaderService();
3444
4844
  discoveryCache = /* @__PURE__ */ new Map();
4845
+ launchCwd;
4846
+ constructor(options = {}) {
4847
+ this.launchCwd = path14.resolve(options.launchCwd ?? process.cwd());
4848
+ }
4849
+ getLaunchCwd() {
4850
+ return this.launchCwd;
4851
+ }
3445
4852
  async loadConfig(configPath, options = {}) {
3446
4853
  const setDefaultCurrent = options.setDefaultCurrent ?? true;
3447
- const absolutePath = path9.resolve(configPath);
4854
+ const absolutePath = path14.resolve(configPath);
3448
4855
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
3449
- const configDir = path9.dirname(absolutePath);
4856
+ const configDir = path14.dirname(absolutePath);
3450
4857
  const globalDefaults = configFile.defaults;
3451
4858
  const resolvedAll = [];
3452
4859
  for (const repo of configFile.repositories) {
@@ -3483,7 +4890,7 @@ var RepositoryContext = class {
3483
4890
  return configFile.repositories;
3484
4891
  }
3485
4892
  async detectFromPath(dirPath) {
3486
- const absolutePath = path9.resolve(dirPath);
4893
+ const absolutePath = path14.resolve(dirPath);
3487
4894
  const cached = this.discoveryCache.get(absolutePath);
3488
4895
  if (cached && await this.isCacheFresh(cached)) {
3489
4896
  return cached.result;
@@ -3502,8 +4909,8 @@ var RepositoryContext = class {
3502
4909
  const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
3503
4910
  if (result.isWorktree && result.bareRepoPath && adminDir) {
3504
4911
  const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
3505
- safeMtimeMs(path9.join(adminDir, "HEAD")),
3506
- safeMtimeMs(path9.join(result.bareRepoPath, "worktrees"))
4912
+ safeMtimeMs(path14.join(adminDir, "HEAD")),
4913
+ safeMtimeMs(path14.join(result.bareRepoPath, "worktrees"))
3507
4914
  ]);
3508
4915
  this.discoveryCache.set(absolutePath, {
3509
4916
  result,
@@ -3534,44 +4941,65 @@ var RepositoryContext = class {
3534
4941
  __discoveryCacheSizeForTest() {
3535
4942
  return this.discoveryCache.size;
3536
4943
  }
4944
+ /** @internal Test-only helper — exposes the internal selection state. */
4945
+ __getRepositorySelectionStateForTest() {
4946
+ return this.getRepositorySelectionState();
4947
+ }
3537
4948
  async discoverSiblingRepositories(currentBareRepoPath) {
3538
- const repoDir = path9.dirname(currentBareRepoPath);
3539
- const workspaceRoot = path9.dirname(repoDir);
3540
- if (workspaceRoot === repoDir) return [];
4949
+ const currentBare = normalizePathForCompare(currentBareRepoPath);
4950
+ const results = /* @__PURE__ */ new Map();
4951
+ const byName = (a, b) => a.name.localeCompare(b.name);
4952
+ const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
4953
+ const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
4954
+ return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
4955
+ }).filter((c) => c.foldedBare !== currentBare);
4956
+ const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
4957
+ configCandidates.forEach(({ entry, bareRepoPath, foldedBare }, i) => {
4958
+ const sibling = {
4959
+ name: entry.name,
4960
+ bareRepoPath,
4961
+ worktreeDir: path14.resolve(entry.config.worktreeDir),
4962
+ repoUrl: entry.config.repoUrl,
4963
+ present: configPresence[i],
4964
+ configMatched: true
4965
+ };
4966
+ if (entry.config.sparseCheckout) {
4967
+ sibling.sparseCheckout = entry.config.sparseCheckout;
4968
+ }
4969
+ results.set(foldedBare, sibling);
4970
+ });
4971
+ const repoDir = path14.dirname(currentBareRepoPath);
4972
+ const workspaceRoot = path14.dirname(repoDir);
4973
+ if (workspaceRoot === repoDir) {
4974
+ return Array.from(results.values()).sort(byName);
4975
+ }
3541
4976
  let entries;
3542
4977
  try {
3543
- entries = await fs7.readdir(workspaceRoot);
4978
+ entries = await fs10.readdir(workspaceRoot);
3544
4979
  } catch {
3545
- return [];
3546
- }
3547
- const configBares = /* @__PURE__ */ new Map();
3548
- for (const entry of this.repos.values()) {
3549
- if (entry.source === "config" && entry.config.bareRepoDir) {
3550
- configBares.set(normalizePathForCompare(entry.config.bareRepoDir), entry.name);
3551
- }
4980
+ return Array.from(results.values()).sort(byName);
3552
4981
  }
3553
- const results = [];
4982
+ const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
3554
4983
  await Promise.all(
3555
4984
  entries.map(async (entry) => {
3556
- const candidate = path9.join(workspaceRoot, entry);
3557
- const bareCandidate = path9.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3558
- try {
3559
- const stat4 = await fs7.stat(bareCandidate);
3560
- if (!stat4.isDirectory()) return;
3561
- } catch {
3562
- return;
3563
- }
3564
- const resolvedBare = path9.resolve(bareCandidate);
3565
- const matchedName = configBares.get(normalizePathForCompare(resolvedBare));
3566
- results.push({
4985
+ const candidate = path14.join(workspaceRoot, entry);
4986
+ const bareCandidate = path14.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
4987
+ if (!await isDirectory(bareCandidate)) return;
4988
+ const resolvedBare = path14.resolve(bareCandidate);
4989
+ const foldedBare = normalizePathForCompare(resolvedBare);
4990
+ if (foldedBare === currentBare || results.has(foldedBare)) return;
4991
+ const matchedName = configBares.get(foldedBare);
4992
+ results.set(foldedBare, {
3567
4993
  name: matchedName ?? entry,
3568
4994
  bareRepoPath: resolvedBare,
4995
+ worktreeDir: null,
4996
+ repoUrl: null,
4997
+ present: true,
3569
4998
  configMatched: matchedName !== void 0
3570
4999
  });
3571
5000
  })
3572
5001
  );
3573
- results.sort((a, b) => a.name.localeCompare(b.name));
3574
- return results;
5002
+ return Array.from(results.values()).sort(byName);
3575
5003
  }
3576
5004
  bootstrapCurrentRepo(candidate, force = false) {
3577
5005
  if (this.currentRepo !== null) return;
@@ -3583,8 +5011,8 @@ var RepositoryContext = class {
3583
5011
  if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
3584
5012
  if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
3585
5013
  const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
3586
- safeMtimeMs(path9.join(cached.worktreeAdminDir, "HEAD")),
3587
- safeMtimeMs(path9.join(cached.result.bareRepoPath, "worktrees"))
5014
+ safeMtimeMs(path14.join(cached.worktreeAdminDir, "HEAD")),
5015
+ safeMtimeMs(path14.join(cached.result.bareRepoPath, "worktrees"))
3588
5016
  ]);
3589
5017
  return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
3590
5018
  }
@@ -3617,6 +5045,13 @@ var RepositoryContext = class {
3617
5045
  return unsupported("No .git file found in path or any parent directory");
3618
5046
  }
3619
5047
  if (located.kind === "regular-git-dir") {
5048
+ const cloneEntry = this.findConfiguredCloneEntry(worktreeRoot);
5049
+ if (cloneEntry) {
5050
+ return {
5051
+ result: await this.buildCloneModeContext(cloneEntry, worktreeRoot, notes),
5052
+ adminDir: null
5053
+ };
5054
+ }
3620
5055
  return unsupported("Directory has .git folder (regular repo, not a sync-worktrees worktree)");
3621
5056
  }
3622
5057
  const gitFileContent = located.gitFileContent;
@@ -3625,18 +5060,18 @@ var RepositoryContext = class {
3625
5060
  return unsupported("Invalid .git file format (missing gitdir line)");
3626
5061
  }
3627
5062
  const gitdir = gitdirMatch[1].trim();
3628
- const resolvedGitdir = path9.isAbsolute(gitdir) ? gitdir : path9.resolve(worktreeRoot, gitdir);
5063
+ const resolvedGitdir = path14.isAbsolute(gitdir) ? gitdir : path14.resolve(worktreeRoot, gitdir);
3629
5064
  const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
3630
5065
  if (!worktreesMatch) {
3631
5066
  return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
3632
5067
  }
3633
- const bareRepoPath = path9.resolve(worktreesMatch[1]);
3634
- const adminDir = path9.resolve(resolvedGitdir);
5068
+ const bareRepoPath = path14.resolve(worktreesMatch[1]);
5069
+ const adminDir = path14.resolve(resolvedGitdir);
3635
5070
  let repoUrl = null;
3636
5071
  let worktrees = [];
3637
5072
  let currentBranch = null;
3638
5073
  try {
3639
- const bareGit = simpleGit5(bareRepoPath);
5074
+ const bareGit = simpleGit6(bareRepoPath);
3640
5075
  try {
3641
5076
  const remoteResult = await bareGit.remote(["get-url", "origin"]);
3642
5077
  const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
@@ -3672,7 +5107,7 @@ var RepositoryContext = class {
3672
5107
  adminDir
3673
5108
  };
3674
5109
  }
3675
- const worktreeDir = path9.dirname(worktreeRoot);
5110
+ const worktreeDir = path14.dirname(worktreeRoot);
3676
5111
  const noUrlReason = "no remote origin URL detected";
3677
5112
  const capabilities = {
3678
5113
  listWorktrees: { available: true },
@@ -3708,7 +5143,7 @@ var RepositoryContext = class {
3708
5143
  cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
3709
5144
  runOnce: true
3710
5145
  };
3711
- const detectedKey = `${AUTO_DETECT_PREFIX}${path9.basename(bareRepoPath)}@${bareRepoPath}`;
5146
+ const detectedKey = `${AUTO_DETECT_PREFIX}${path14.basename(bareRepoPath)}@${bareRepoPath}`;
3712
5147
  if (!this.repos.has(detectedKey)) {
3713
5148
  this.repos.set(detectedKey, {
3714
5149
  name: detectedKey,
@@ -3749,13 +5184,19 @@ var RepositoryContext = class {
3749
5184
  return { result: discovered, adminDir };
3750
5185
  }
3751
5186
  async getService(repoName) {
5187
+ if (repoName) {
5188
+ const explicit = this.selectExplicitRepository(repoName);
5189
+ if (explicit.kind !== "selected") {
5190
+ throw new Error(this.buildRepoNotFoundError(repoName));
5191
+ }
5192
+ }
3752
5193
  const name = repoName ?? this.currentRepo;
3753
5194
  if (!name) {
3754
- throw new Error("No repository specified and no current repository set");
5195
+ throw new Error(this.buildNoRepoSelectedError());
3755
5196
  }
3756
5197
  const entry = this.repos.get(name);
3757
5198
  if (!entry) {
3758
- throw new Error(`Repository '${name}' not found. Load a config or run detect_context first.`);
5199
+ throw new Error(this.buildRepoNotFoundError(name));
3759
5200
  }
3760
5201
  if (!entry.service) {
3761
5202
  const logger = createStderrLogger(entry.name);
@@ -3766,6 +5207,93 @@ var RepositoryContext = class {
3766
5207
  }
3767
5208
  return entry.service;
3768
5209
  }
5210
+ getRepositorySelectionState() {
5211
+ const configured = this.getConfiguredRepositoryNames();
5212
+ const detected = this.getDetectedRepositoryNames();
5213
+ return {
5214
+ currentRepo: this.currentRepo,
5215
+ configured,
5216
+ detected,
5217
+ defaultDecision: this.selectDefaultRepository(configured, detected)
5218
+ };
5219
+ }
5220
+ selectExplicitRepository(repoName) {
5221
+ if (this.repos.has(repoName)) {
5222
+ return { kind: "selected", repoName, source: "explicit" };
5223
+ }
5224
+ return {
5225
+ kind: "missing",
5226
+ configured: this.getConfiguredRepositoryNames(),
5227
+ detected: this.getDetectedRepositoryNames(),
5228
+ reason: `Repository '${repoName}' not found`
5229
+ };
5230
+ }
5231
+ selectDefaultRepository(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
5232
+ if (this.currentRepo !== null) {
5233
+ return { kind: "selected", repoName: this.currentRepo, source: "current" };
5234
+ }
5235
+ if (this.canAutoSelectSingleConfig(configured, detected)) {
5236
+ return { kind: "selected", repoName: configured[0], source: "single-config" };
5237
+ }
5238
+ if (configured.length === 0 && detected.length === 0) {
5239
+ return {
5240
+ kind: "missing",
5241
+ configured,
5242
+ detected,
5243
+ reason: "no configured or detected repositories are registered"
5244
+ };
5245
+ }
5246
+ return {
5247
+ kind: "ambiguous",
5248
+ configured,
5249
+ detected,
5250
+ reason: "repository selection is ambiguous without currentRepo or explicit repoName"
5251
+ };
5252
+ }
5253
+ canAutoSelectSingleConfig(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
5254
+ return this.currentRepo === null && configured.length === 1 && detected.length === 0;
5255
+ }
5256
+ getDetectedRepositoryNames() {
5257
+ return Array.from(this.repos.values()).filter((entry) => entry.source === "detected").map((entry) => entry.name);
5258
+ }
5259
+ formatDetectedRepositoryNames() {
5260
+ return Array.from(this.repos.values()).filter((e) => e.source === "detected").map((e) => {
5261
+ const location = e.discovered?.currentWorktreePath ?? e.config.bareRepoDir ?? e.config.worktreeDir;
5262
+ return location ? `${e.name} (${location})` : e.name;
5263
+ });
5264
+ }
5265
+ formatKnownRepositoryNames(names) {
5266
+ return names.length === 0 ? "[]" : `[${names.join(", ")}]`;
5267
+ }
5268
+ buildNoRepoSelectedError() {
5269
+ const selection = this.getRepositorySelectionState();
5270
+ const detected = this.formatDetectedRepositoryNames();
5271
+ const parts = [
5272
+ "No repository specified and no current repository set.",
5273
+ `launchCwd=${this.launchCwd}`,
5274
+ `configPath=${this.configPath ?? "none"}`,
5275
+ `loadedRepos=${this.repos.size} (config: ${selection.configured.length}, detected: ${selection.detected.length})`
5276
+ ];
5277
+ if (detected.length > 0) {
5278
+ parts.push(`Detected repos: ${this.formatKnownRepositoryNames(detected)}.`);
5279
+ }
5280
+ if (selection.configured.length > 0) {
5281
+ parts.push(`Configured repos: ${this.formatKnownRepositoryNames(selection.configured)}.`);
5282
+ }
5283
+ if (selection.configured.length > 0 || detected.length > 0) {
5284
+ parts.push("Recovery: call set_current_repository with one of the repo names above or pass repoName explicitly.");
5285
+ } else {
5286
+ parts.push(
5287
+ "Recovery: call detect_context {path: <workspace>}, load_config {configPath: <file>}, set SYNC_WORKTREES_CONFIG env var, or pass repoName explicitly."
5288
+ );
5289
+ }
5290
+ return parts.join(" ");
5291
+ }
5292
+ buildRepoNotFoundError(name) {
5293
+ const known = Array.from(this.repos.keys());
5294
+ const knownStr = this.formatKnownRepositoryNames(known);
5295
+ return `Repository '${name}' not found. Known repos: ${knownStr}. Run load_config or detect_context to register it.`;
5296
+ }
3769
5297
  getEntry(repoName) {
3770
5298
  const name = repoName ?? this.currentRepo;
3771
5299
  if (!name) return null;
@@ -3792,40 +5320,229 @@ var RepositoryContext = class {
3792
5320
  source: e.source
3793
5321
  }));
3794
5322
  }
5323
+ getConfiguredRepositoryNames() {
5324
+ return Array.from(this.repos.values()).filter((entry) => entry.source === "config").map((entry) => entry.name);
5325
+ }
5326
+ async getConfiguredRepositorySummaries(options = {}) {
5327
+ const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
5328
+ const currentRepo = this.currentRepo;
5329
+ const buildLean = (entry) => {
5330
+ const mode = resolveMode(entry.config);
5331
+ const isCurrent = entry.name === currentRepo;
5332
+ if (mode === REPOSITORY_MODES.CLONE) {
5333
+ return { name: entry.name, mode: "clone", checkoutPath: path14.resolve(entry.config.worktreeDir), isCurrent };
5334
+ }
5335
+ return { name: entry.name, mode: "worktree", worktreeDir: path14.resolve(entry.config.worktreeDir), isCurrent };
5336
+ };
5337
+ if (!options.detailed) {
5338
+ return entries.map(buildLean);
5339
+ }
5340
+ const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5341
+ return Promise.all(
5342
+ entries.map(
5343
+ (entry) => limit(async () => {
5344
+ const summary = buildLean(entry);
5345
+ summary.repoUrl = entry.config.repoUrl;
5346
+ if (entry.config.branch) summary.branch = entry.config.branch;
5347
+ if (entry.config.sparseCheckout) {
5348
+ const sc = entry.config.sparseCheckout;
5349
+ summary.sparseCheckout = {
5350
+ ...sc,
5351
+ include: [...sc.include],
5352
+ ...sc.exclude ? { exclude: [...sc.exclude] } : {}
5353
+ };
5354
+ }
5355
+ if (summary.mode === "clone") {
5356
+ summary.localReady = await isGitCheckout(summary.checkoutPath);
5357
+ return summary;
5358
+ }
5359
+ if (entry.config.bareRepoDir) {
5360
+ summary.bareRepoDir = path14.resolve(entry.config.bareRepoDir);
5361
+ summary.localReady = await isDirectory(summary.bareRepoDir);
5362
+ } else {
5363
+ summary.localReady = false;
5364
+ }
5365
+ return summary;
5366
+ })
5367
+ )
5368
+ );
5369
+ }
5370
+ autoSelectCurrentRepoIfSingleConfig() {
5371
+ const decision = this.selectDefaultRepository();
5372
+ if (decision.kind !== "selected") return null;
5373
+ if (decision.source === "single-config") {
5374
+ this.currentRepo = decision.repoName;
5375
+ }
5376
+ return this.currentRepo;
5377
+ }
5378
+ async getAllConfiguredWorktreeDetails(currentWorktreePath = null) {
5379
+ const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
5380
+ const results = await Promise.all(
5381
+ entries.map(async (entry) => ({
5382
+ name: entry.name,
5383
+ result: await this.readConfiguredWorktrees(entry, currentWorktreePath)
5384
+ }))
5385
+ );
5386
+ const worktreesByRepo = {};
5387
+ const errorsByRepo = {};
5388
+ for (const entry of results) {
5389
+ worktreesByRepo[entry.name] = entry.result.worktrees;
5390
+ if (entry.result.error) {
5391
+ errorsByRepo[entry.name] = entry.result.error;
5392
+ }
5393
+ }
5394
+ return { worktreesByRepo, errorsByRepo };
5395
+ }
3795
5396
  getConfigPath() {
3796
5397
  return this.configPath;
3797
5398
  }
5399
+ async readConfiguredWorktrees(entry, currentWorktreePath) {
5400
+ if (entry.source === "config" && resolveMode(entry.config) === REPOSITORY_MODES.CLONE) {
5401
+ return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
5402
+ }
5403
+ if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
5404
+ const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
5405
+ if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
5406
+ try {
5407
+ const output = await simpleGit6(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
5408
+ return { worktrees: parseWorktreeList(output, currentWorktreePath) };
5409
+ } catch (err) {
5410
+ return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
5411
+ }
5412
+ }
5413
+ findConfiguredCloneEntry(worktreeRoot) {
5414
+ const foldedRoot = normalizePathForCompare(path14.resolve(worktreeRoot));
5415
+ for (const entry of this.repos.values()) {
5416
+ if (entry.source !== "config" || resolveMode(entry.config) !== REPOSITORY_MODES.CLONE) continue;
5417
+ if (normalizePathForCompare(path14.resolve(entry.config.worktreeDir)) === foldedRoot) {
5418
+ return entry;
5419
+ }
5420
+ }
5421
+ return null;
5422
+ }
5423
+ async buildCloneModeContext(entry, worktreeRoot, notes) {
5424
+ const resolvedRoot = path14.resolve(worktreeRoot);
5425
+ let currentBranch = null;
5426
+ try {
5427
+ currentBranch = await readCurrentBranch(resolvedRoot);
5428
+ } catch (err) {
5429
+ notes.push(`Could not read clone-mode branch: ${err instanceof Error ? err.message : String(err)}`);
5430
+ }
5431
+ const branch = currentBranch ?? "unknown";
5432
+ const cloneModeReason = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
5433
+ const capabilities = {
5434
+ listWorktrees: { available: true },
5435
+ getStatus: { available: true },
5436
+ createWorktree: { available: false, reason: cloneModeReason },
5437
+ removeWorktree: { available: false, reason: cloneModeReason },
5438
+ updateWorktree: { available: false, reason: cloneModeReason },
5439
+ sync: { available: true },
5440
+ initialize: { available: true }
5441
+ };
5442
+ const discovered = {
5443
+ isWorktree: true,
5444
+ kind: "managed",
5445
+ currentBranch,
5446
+ currentWorktreePath: resolvedRoot,
5447
+ bareRepoPath: null,
5448
+ repoUrl: entry.config.repoUrl,
5449
+ worktreeDir: resolvedRoot,
5450
+ allWorktrees: [{ path: resolvedRoot, branch, isCurrent: true }],
5451
+ siblingRepositories: [],
5452
+ configPath: this.configPath,
5453
+ repoName: entry.name,
5454
+ capabilities,
5455
+ notes
5456
+ };
5457
+ entry.discovered = discovered;
5458
+ this.bootstrapCurrentRepo(entry.name, true);
5459
+ return discovered;
5460
+ }
5461
+ async readConfiguredCloneWorktree(entry, currentWorktreePath) {
5462
+ const worktreePath = path14.resolve(entry.config.worktreeDir);
5463
+ if (!await isDirectory(worktreePath) || !await hasGitMetadata(worktreePath)) {
5464
+ return { worktrees: [] };
5465
+ }
5466
+ try {
5467
+ const branch = await readCurrentBranch(worktreePath);
5468
+ return {
5469
+ worktrees: [
5470
+ {
5471
+ path: worktreePath,
5472
+ branch,
5473
+ isCurrent: currentWorktreePath !== null && normalizePathForCompare(worktreePath) === normalizePathForCompare(currentWorktreePath)
5474
+ }
5475
+ ]
5476
+ };
5477
+ } catch (err) {
5478
+ return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
5479
+ }
5480
+ }
3798
5481
  };
3799
5482
  function parseWorktreeList(output, currentPath) {
3800
- const foldedCurrent = normalizePathForCompare(currentPath);
5483
+ const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
3801
5484
  const results = [];
3802
5485
  for (const wt of parseWorktreeListPorcelain(output)) {
3803
- const resolved = path9.resolve(wt.path);
5486
+ const resolved = path14.resolve(wt.path);
3804
5487
  const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
3805
5488
  if (!branch) continue;
3806
5489
  results.push({
3807
5490
  path: resolved,
3808
5491
  branch,
3809
- isCurrent: normalizePathForCompare(resolved) === foldedCurrent
5492
+ isCurrent: foldedCurrent !== null && normalizePathForCompare(resolved) === foldedCurrent
3810
5493
  });
3811
5494
  }
3812
5495
  return results;
3813
5496
  }
3814
5497
  async function safeMtimeMs(filePath) {
3815
5498
  try {
3816
- const stat4 = await fs7.stat(filePath);
5499
+ const stat4 = await fs10.stat(filePath);
3817
5500
  return stat4.mtimeMs;
3818
5501
  } catch {
3819
5502
  return null;
3820
5503
  }
3821
5504
  }
5505
+ async function isDirectory(filePath) {
5506
+ try {
5507
+ const stat4 = await fs10.stat(filePath);
5508
+ return stat4.isDirectory();
5509
+ } catch {
5510
+ return false;
5511
+ }
5512
+ }
5513
+ async function hasGitMetadata(worktreePath) {
5514
+ try {
5515
+ await fs10.stat(path14.join(worktreePath, ".git"));
5516
+ return true;
5517
+ } catch {
5518
+ return false;
5519
+ }
5520
+ }
5521
+ async function isGitCheckout(checkoutPath) {
5522
+ if (!await isDirectory(checkoutPath)) return false;
5523
+ try {
5524
+ const inside = (await simpleGit6(checkoutPath).raw(["rev-parse", "--is-inside-work-tree"])).trim();
5525
+ return inside === "true";
5526
+ } catch {
5527
+ return false;
5528
+ }
5529
+ }
5530
+ async function readCurrentBranch(worktreePath) {
5531
+ const git = simpleGit6(worktreePath);
5532
+ const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
5533
+ if (branch && branch !== "HEAD") {
5534
+ return branch;
5535
+ }
5536
+ const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
5537
+ return head ? `(detached ${head})` : "(detached)";
5538
+ }
3822
5539
  async function findWorktreeRoot(startPath) {
3823
- let current = path9.resolve(startPath);
3824
- const root = path9.parse(current).root;
5540
+ let current = path14.resolve(startPath);
5541
+ const root = path14.parse(current).root;
3825
5542
  while (true) {
3826
- const gitPath = path9.join(current, ".git");
5543
+ const gitPath = path14.join(current, ".git");
3827
5544
  try {
3828
- const content = await fs7.readFile(gitPath, "utf-8");
5545
+ const content = await fs10.readFile(gitPath, "utf-8");
3829
5546
  return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
3830
5547
  } catch (err) {
3831
5548
  const code = err.code;
@@ -3837,7 +5554,7 @@ async function findWorktreeRoot(startPath) {
3837
5554
  }
3838
5555
  }
3839
5556
  if (current === root) return null;
3840
- const parent = path9.dirname(current);
5557
+ const parent = path14.dirname(current);
3841
5558
  if (parent === current) return null;
3842
5559
  current = parent;
3843
5560
  }
@@ -3848,13 +5565,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3848
5565
  import { z } from "zod";
3849
5566
 
3850
5567
  // src/mcp/handlers.ts
3851
- import * as path10 from "path";
3852
- import pLimit2 from "p-limit";
5568
+ import * as path15 from "path";
5569
+ import pLimit3 from "p-limit";
3853
5570
 
3854
5571
  // src/utils/disk-space.ts
3855
5572
  import fastFolderSize from "fast-folder-size";
3856
5573
  async function calculateDirectorySize(dirPath) {
3857
- return new Promise((resolve9, reject) => {
5574
+ return new Promise((resolve11, reject) => {
3858
5575
  fastFolderSize(dirPath, (err, bytes) => {
3859
5576
  if (err) {
3860
5577
  reject(err);
@@ -3864,7 +5581,7 @@ async function calculateDirectorySize(dirPath) {
3864
5581
  reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
3865
5582
  return;
3866
5583
  }
3867
- resolve9(bytes);
5584
+ resolve11(bytes);
3868
5585
  });
3869
5586
  });
3870
5587
  }
@@ -3924,7 +5641,7 @@ function formatToolResponse(data) {
3924
5641
  content: [
3925
5642
  {
3926
5643
  type: "text",
3927
- text: JSON.stringify(data, null, 2)
5644
+ text: JSON.stringify(data)
3928
5645
  }
3929
5646
  ]
3930
5647
  };
@@ -3951,7 +5668,7 @@ function formatErrorResponse(error) {
3951
5668
  content: [
3952
5669
  {
3953
5670
  type: "text",
3954
- text: JSON.stringify(body, null, 2)
5671
+ text: JSON.stringify(body)
3955
5672
  }
3956
5673
  ],
3957
5674
  isError: true
@@ -3978,7 +5695,7 @@ function wrapHandler(fn) {
3978
5695
  }
3979
5696
 
3980
5697
  // src/mcp/worktree-summary.ts
3981
- import simpleGit6 from "simple-git";
5698
+ import simpleGit7 from "simple-git";
3982
5699
  function deriveLabel(status, isCurrent) {
3983
5700
  if (isCurrent) return "current";
3984
5701
  if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
@@ -3999,7 +5716,7 @@ function deriveSafeToRemove(status) {
3999
5716
  }
4000
5717
  async function getDivergence(worktreePath) {
4001
5718
  try {
4002
- const git = simpleGit6(worktreePath);
5719
+ const git = simpleGit7(worktreePath);
4003
5720
  const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
4004
5721
  const [aheadStr, behindStr] = output.trim().split(/\s+/);
4005
5722
  return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
@@ -4010,6 +5727,7 @@ async function getDivergence(worktreePath) {
4010
5727
 
4011
5728
  // src/mcp/handlers.ts
4012
5729
  var pathResolution = new PathResolutionService();
5730
+ var CLONE_MODE_WORKTREE_MUTATION_REASON = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
4013
5731
  function ensureCapability(discovered, key, toolName) {
4014
5732
  if (!discovered) return;
4015
5733
  const cap = discovered.capabilities[key];
@@ -4019,6 +5737,9 @@ function ensureCapability(discovered, key, toolName) {
4019
5737
  }
4020
5738
  }
4021
5739
  async function getReadyService(ctx, repoName, options = {}) {
5740
+ if (!repoName) {
5741
+ ctx.autoSelectCurrentRepoIfSingleConfig();
5742
+ }
4022
5743
  const discovered = ctx.getDiscoveredContext(repoName);
4023
5744
  if (options.capability && options.toolName) {
4024
5745
  ensureCapability(discovered, options.capability, options.toolName);
@@ -4041,33 +5762,75 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
4041
5762
  }
4042
5763
  return result.value;
4043
5764
  }
4044
- async function ensureRepoWorktreePath(ctx, params, git) {
4045
- await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
4046
- return path10.resolve(params.path);
5765
+ async function ensureRepoWorktreePath(ctx, params, service, git) {
5766
+ await ensurePathBelongsToRepo(ctx, params.path, params.repoName, service, git);
5767
+ return path15.resolve(params.path);
4047
5768
  }
4048
- async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
5769
+ async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
4049
5770
  const discovered = ctx.getDiscoveredContext(repoName);
4050
5771
  if (discovered?.allWorktrees.length) {
4051
5772
  const match = discovered.allWorktrees.some((w) => pathsEqual(w.path, targetPath));
4052
5773
  if (match) return;
4053
5774
  }
4054
5775
  try {
4055
- const worktrees = await git.getWorktrees();
5776
+ const worktrees = await getWorktreesFromService(service, git);
4056
5777
  if (worktrees.some((w) => pathsEqual(w.path, targetPath))) return;
4057
5778
  } catch {
4058
5779
  }
4059
5780
  throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
4060
5781
  }
5782
+ function isCloneModeService(service) {
5783
+ const candidate = service;
5784
+ return typeof candidate.isCloneMode === "function" && candidate.isCloneMode();
5785
+ }
5786
+ function ensureWorktreeModeService(service, toolName) {
5787
+ if (isCloneModeService(service)) {
5788
+ throw new CapabilityUnavailableError(toolName, [CLONE_MODE_WORKTREE_MUTATION_REASON]);
5789
+ }
5790
+ }
5791
+ async function getWorktreesFromService(service, git) {
5792
+ const candidate = service;
5793
+ if (typeof candidate.getWorktrees === "function") {
5794
+ return candidate.getWorktrees();
5795
+ }
5796
+ return git.getWorktrees();
5797
+ }
4061
5798
  async function handleDetectContext(ctx, params, _extra) {
4062
5799
  const target = params.path ?? process.cwd();
4063
5800
  const discovered = await ctx.detectFromPath(target);
4064
- if (!params.includeStatus || discovered.allWorktrees.length === 0) {
4065
- return formatToolResponse(discovered);
5801
+ const configuredRepositories = await ctx.getConfiguredRepositorySummaries({ detailed: params.detailed ?? false });
5802
+ let response = { ...discovered, configuredRepositories };
5803
+ if (params.includeAllWorktrees) {
5804
+ const details = await ctx.getAllConfiguredWorktreeDetails(discovered.currentWorktreePath);
5805
+ const errorsByRepo = Object.keys(details.errorsByRepo).length > 0 ? details.errorsByRepo : void 0;
5806
+ response = {
5807
+ ...response,
5808
+ allWorktreesByRepo: details.worktreesByRepo,
5809
+ allWorktreeErrorsByRepo: errorsByRepo
5810
+ };
5811
+ }
5812
+ if (!params.includeStatus) {
5813
+ return formatToolResponse(response);
4066
5814
  }
4067
5815
  const statusService = new WorktreeStatusService();
4068
- const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4069
- const enriched = await Promise.all(
4070
- discovered.allWorktrees.map(
5816
+ const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5817
+ const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
5818
+ let allWorktreesByRepo = response.allWorktreesByRepo;
5819
+ if (allWorktreesByRepo) {
5820
+ const entries = await Promise.all(
5821
+ Object.entries(allWorktreesByRepo).map(async ([repoName, worktrees]) => [
5822
+ repoName,
5823
+ await enrichDetectedWorktrees(worktrees, statusService, statusLimit)
5824
+ ])
5825
+ );
5826
+ allWorktreesByRepo = Object.fromEntries(entries);
5827
+ }
5828
+ return formatToolResponse({ ...response, allWorktrees: enriched, allWorktreesByRepo });
5829
+ }
5830
+ async function enrichDetectedWorktrees(worktrees, statusService, limit) {
5831
+ if (worktrees.length === 0) return worktrees;
5832
+ return Promise.all(
5833
+ worktrees.map(
4071
5834
  (wt) => limit(async () => {
4072
5835
  const [status, divergence] = await Promise.all([
4073
5836
  statusService.getFullWorktreeStatus(wt.path, false).catch(() => null),
@@ -4082,16 +5845,47 @@ async function handleDetectContext(ctx, params, _extra) {
4082
5845
  })
4083
5846
  )
4084
5847
  );
4085
- return formatToolResponse({ ...discovered, allWorktrees: enriched });
4086
5848
  }
4087
5849
  async function handleListWorktrees(ctx, params, _extra) {
4088
- const { discovered, git } = await getReadyService(ctx, params.repoName, {
5850
+ const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
5851
+ if (configuredRepoNames.length > 0) {
5852
+ const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
5853
+ const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5854
+ const repositories = await Promise.all(
5855
+ configuredRepoNames.map(
5856
+ (repoName) => limit(async () => {
5857
+ try {
5858
+ return [
5859
+ repoName,
5860
+ {
5861
+ worktrees: await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit)
5862
+ }
5863
+ ];
5864
+ } catch (err) {
5865
+ return [
5866
+ repoName,
5867
+ {
5868
+ worktrees: [],
5869
+ error: err instanceof Error ? err.message : String(err)
5870
+ }
5871
+ ];
5872
+ }
5873
+ })
5874
+ )
5875
+ );
5876
+ return formatToolResponse({ repositories: Object.fromEntries(repositories) });
5877
+ }
5878
+ const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
5879
+ return formatToolResponse({ worktrees: results });
5880
+ }
5881
+ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
5882
+ const { discovered, service, git } = await getReadyService(ctx, repoName, {
4089
5883
  capability: "listWorktrees",
4090
5884
  toolName: "list_worktrees"
4091
5885
  });
4092
5886
  let worktrees;
4093
5887
  try {
4094
- worktrees = await git.getWorktrees();
5888
+ worktrees = await getWorktreesFromService(service, git);
4095
5889
  } catch {
4096
5890
  if (discovered) {
4097
5891
  worktrees = discovered.allWorktrees.map((w) => ({ path: w.path, branch: w.branch }));
@@ -4100,17 +5894,16 @@ async function handleListWorktrees(ctx, params, _extra) {
4100
5894
  }
4101
5895
  }
4102
5896
  const currentPath = discovered?.currentWorktreePath ?? null;
4103
- const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4104
5897
  const results = await Promise.all(
4105
5898
  worktrees.map(
4106
5899
  (wt) => limit(async () => {
4107
- const resolvedPath = path10.resolve(wt.path);
5900
+ const resolvedPath = path15.resolve(wt.path);
4108
5901
  const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
4109
5902
  const [status, divergence, metadata, sizeBytes] = await Promise.all([
4110
5903
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
4111
5904
  getDivergence(wt.path),
4112
5905
  git.getWorktreeMetadata(wt.path).catch(() => null),
4113
- params.includeSize ? calculateDirectorySize(wt.path).catch(() => null) : Promise.resolve(null)
5906
+ includeSize ? calculateDirectorySize(wt.path).catch(() => null) : Promise.resolve(null)
4114
5907
  ]);
4115
5908
  return {
4116
5909
  path: resolvedPath,
@@ -4126,14 +5919,14 @@ async function handleListWorktrees(ctx, params, _extra) {
4126
5919
  })
4127
5920
  )
4128
5921
  );
4129
- return formatToolResponse({ worktrees: results });
5922
+ return results;
4130
5923
  }
4131
5924
  async function handleGetWorktreeStatus(ctx, params, _extra) {
4132
- const { git } = await getReadyService(ctx, params.repoName, {
5925
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4133
5926
  capability: "getStatus",
4134
5927
  toolName: "get_worktree_status"
4135
5928
  });
4136
- const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
5929
+ const resolvedPath = await ensureRepoWorktreePath(ctx, params, service, git);
4137
5930
  const [status, divergence] = await Promise.all([
4138
5931
  git.getFullWorktreeStatus(params.path, params.includeDetails ?? false),
4139
5932
  getDivergence(params.path)
@@ -4145,7 +5938,8 @@ async function handleGetWorktreeStatus(ctx, params, _extra) {
4145
5938
  });
4146
5939
  }
4147
5940
  async function handleCreateWorktree(ctx, params, _extra) {
4148
- const { branchName, baseBranch, push } = params;
5941
+ const { branchName, baseBranch } = params;
5942
+ const push = params.push ?? true;
4149
5943
  const validation = isValidGitBranchName(branchName);
4150
5944
  if (!validation.valid) {
4151
5945
  throw new Error(`Invalid branch name '${branchName}': ${validation.error}`);
@@ -4154,9 +5948,10 @@ async function handleCreateWorktree(ctx, params, _extra) {
4154
5948
  capability: "createWorktree",
4155
5949
  toolName: "create_worktree"
4156
5950
  });
5951
+ ensureWorktreeModeService(service, "create_worktree");
4157
5952
  return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4158
5953
  if (!service.isInitialized()) {
4159
- await service.initialize();
5954
+ await service.initializeUnlocked();
4160
5955
  }
4161
5956
  const existence = await git.branchExists(branchName);
4162
5957
  let created = false;
@@ -4186,7 +5981,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
4186
5981
  return formatToolResponse({
4187
5982
  success: true,
4188
5983
  branchName,
4189
- worktreePath: path10.resolve(worktreePath),
5984
+ worktreePath: path15.resolve(worktreePath),
4190
5985
  created,
4191
5986
  pushed
4192
5987
  });
@@ -4197,11 +5992,12 @@ async function handleRemoveWorktree(ctx, params, _extra) {
4197
5992
  capability: "removeWorktree",
4198
5993
  toolName: "remove_worktree"
4199
5994
  });
5995
+ ensureWorktreeModeService(service, "remove_worktree");
4200
5996
  return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4201
5997
  if (!service.isInitialized()) {
4202
- await service.initialize();
5998
+ await service.initializeUnlocked();
4203
5999
  }
4204
- const removedPath = await ensureRepoWorktreePath(ctx, params, git);
6000
+ const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
4205
6001
  if (!params.force) {
4206
6002
  const status = await git.getFullWorktreeStatus(params.path, false);
4207
6003
  if (!status.canRemove) {
@@ -4224,13 +6020,31 @@ async function handleSync(ctx, params, extra) {
4224
6020
  const dispose = attachProgressReporter(service, extra);
4225
6021
  try {
4226
6022
  const start = Date.now();
6023
+ service.clearRecordedSkips();
4227
6024
  const result = await service.sync();
4228
6025
  if (!result.started) {
4229
6026
  throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
4230
6027
  }
4231
6028
  const duration = Date.now() - start;
4232
6029
  ctx.invalidateDiscovered();
4233
- return formatToolResponse({ success: true, duration });
6030
+ const outcome = result.outcome ?? createEmptySyncOutcome(
6031
+ isCloneModeService(service) ? "clone" : "worktree",
6032
+ ctx.getEntry(params.repoName)?.name ?? params.repoName,
6033
+ duration
6034
+ );
6035
+ const skips = service.getRecordedSkips().map((reason) => ({
6036
+ ...reason,
6037
+ message: formatCloneSkipReason(reason)
6038
+ }));
6039
+ return formatToolResponse({
6040
+ success: true,
6041
+ duration,
6042
+ outcome: {
6043
+ ...outcome,
6044
+ durationMs: outcome.durationMs ?? duration
6045
+ },
6046
+ skips
6047
+ });
4234
6048
  } finally {
4235
6049
  dispose();
4236
6050
  }
@@ -4240,11 +6054,12 @@ async function handleUpdateWorktree(ctx, params, _extra) {
4240
6054
  capability: "updateWorktree",
4241
6055
  toolName: "update_worktree"
4242
6056
  });
6057
+ ensureWorktreeModeService(service, "update_worktree");
4243
6058
  return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4244
6059
  if (!service.isInitialized()) {
4245
- await service.initialize();
6060
+ await service.initializeUnlocked();
4246
6061
  }
4247
- const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
6062
+ const worktreePath = await ensureRepoWorktreePath(ctx, params, service, git);
4248
6063
  await git.updateWorktree(params.path);
4249
6064
  ctx.invalidateDiscovered();
4250
6065
  return formatToolResponse({
@@ -4261,7 +6076,8 @@ async function handleInitialize(ctx, params, extra) {
4261
6076
  const dispose = attachProgressReporter(service, extra);
4262
6077
  try {
4263
6078
  return await runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4264
- await service.initialize();
6079
+ await service.initializeUnlocked();
6080
+ service.clearPendingInitSkip();
4265
6081
  const git = service.getGitService();
4266
6082
  ctx.invalidateDiscovered();
4267
6083
  return formatToolResponse({
@@ -4281,7 +6097,7 @@ async function handleLoadConfig(ctx, params, _extra) {
4281
6097
  }
4282
6098
  await ctx.loadConfig(configPath);
4283
6099
  return formatToolResponse({
4284
- configPath: path10.resolve(configPath),
6100
+ configPath: path15.resolve(configPath),
4285
6101
  currentRepository: ctx.getCurrentRepo(),
4286
6102
  repositories: ctx.getRepositoryList()
4287
6103
  });
@@ -4316,20 +6132,23 @@ function attachProgressReporter(service, extra) {
4316
6132
  }
4317
6133
 
4318
6134
  // src/mcp/server.ts
4319
- var REPO_NAME_DESCRIBE = "Repository name from loaded config. If omitted, uses the current repository set via set_current_repository or the only loaded repo.";
4320
- var PATH_DESCRIBE_SUFFIX = "Absolute path preferred; relative paths resolve from the server's CWD.";
4321
- var SERVER_INSTRUCTIONS = "Before running git worktree operations, call `detect_context` to learn the current repo, current branch, sibling repositories under the workspace root, and which capabilities are available. It walks up to auto-discover sync-worktrees.config.{js,mjs,cjs,ts}, lists sibling worktrees, and reports per-capability {available, reason} so you can tell which tool is gated and why.";
6135
+ var REPO_NAME_DESCRIBE = "Repo name from loaded config. Omit to use current (set via set_current_repository) or the only loaded repo.";
6136
+ var PATH_DESCRIBE_SUFFIX = "Absolute preferred; relative resolves from server CWD.";
6137
+ var SERVER_INSTRUCTIONS = "Call `detect_context` for the project map and live worktree state; `configuredRepositories` in its response is the server-wide loaded-config inventory. Use `set_current_repository` to switch repos. Auto-loads sync-worktrees.config.{js,mjs,cjs,ts} via walk-up.";
4322
6138
  function buildInstructions(snapshot) {
4323
6139
  const d = snapshot?.discovered;
4324
- if (!d || !d.isWorktree || d.kind !== "managed") return SERVER_INSTRUCTIONS;
4325
- const lines = ["Connect-time context (call `detect_context` for live state):"];
4326
- if (d.kind) lines.push(`- kind: ${d.kind}`);
4327
- if (d.currentWorktreePath) lines.push(`- currentWorktreePath: ${d.currentWorktreePath}`);
4328
- if (d.currentBranch) lines.push(`- currentBranch: ${d.currentBranch}`);
4329
- if (d.configPath) lines.push(`- configPath: ${d.configPath}`);
4330
- return `${SERVER_INSTRUCTIONS}
4331
-
4332
- ${lines.join("\n")}`;
6140
+ if (!d || !d.isWorktree || d.kind !== "managed") {
6141
+ return SERVER_INSTRUCTIONS;
6142
+ }
6143
+ const fields = [];
6144
+ if (d.repoName) fields.push(`workspace=${d.repoName}`);
6145
+ if (d.currentWorktreePath) fields.push(`path=${d.currentWorktreePath}`);
6146
+ if (d.configPath) fields.push(`config=${d.configPath}`);
6147
+ if (typeof snapshot?.configuredRepoCount === "number") {
6148
+ fields.push(`configuredRepos=${snapshot.configuredRepoCount}`);
6149
+ }
6150
+ fields.push(`worktrees=${d.allWorktrees.length}`);
6151
+ return `${SERVER_INSTRUCTIONS} Connect-time: ${fields.join(" ")}.`;
4333
6152
  }
4334
6153
  function createServer(context, snapshot) {
4335
6154
  const server = new McpServer(
@@ -4346,22 +6165,24 @@ function createServer(context, snapshot) {
4346
6165
  "sync-worktrees://workspace",
4347
6166
  {
4348
6167
  title: "Workspace context",
4349
- description: "Current sync-worktrees workspace context: whether CWD is inside a managed worktree, the current branch, sibling worktrees, sibling repositories, auto-discovered configPath, and per-capability {available, reason}. Returns { isWorktree: false } when CWD is outside any workspace.",
6168
+ description: "Workspace context: isWorktree, kind, currentWorktreePath, currentBranch, allWorktrees, siblingRepositories, configPath, capabilities {available,reason}, configuredRepositories (server-wide loaded-config inventory). {isWorktree:false} when outside any workspace.",
4350
6169
  mimeType: "application/json"
4351
6170
  },
4352
6171
  async (uri) => {
4353
- let discovered;
6172
+ let payload;
4354
6173
  try {
4355
- discovered = await context.detectFromPath(process.cwd());
6174
+ const discovered = await context.detectFromPath(process.cwd());
6175
+ const configuredRepositories = await context.getConfiguredRepositorySummaries();
6176
+ payload = { ...discovered, configuredRepositories };
4356
6177
  } catch (err) {
4357
- discovered = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
6178
+ payload = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
4358
6179
  }
4359
6180
  return {
4360
6181
  contents: [
4361
6182
  {
4362
6183
  uri: uri.href,
4363
6184
  mimeType: "application/json",
4364
- text: JSON.stringify(discovered, null, 2)
6185
+ text: JSON.stringify(payload)
4365
6186
  }
4366
6187
  ]
4367
6188
  };
@@ -4370,11 +6191,13 @@ function createServer(context, snapshot) {
4370
6191
  server.registerTool(
4371
6192
  "detect_context",
4372
6193
  {
4373
- description: "Detect sync-worktrees structure from a filesystem path. Reads .git file, resolves bare repo, discovers sibling worktrees, walks up for a sync-worktrees.config.{js,mjs,cjs,ts}, and lists sibling bare repos under the workspace root. Defaults to CWD. Use when: bootstrapping from an unknown checkout. Returns: discovered repo root, bare repo path, all sibling worktrees, sibling repositories, current worktree path, configPath (auto-found), per-capability {available, reason}, notes[].",
6194
+ description: "Detect sync-worktrees structure from path (default: CWD). Reads .git, resolves bare repo, walks up to auto-load sync-worktrees.config.{js,mjs,cjs,ts}. Returns: configuredRepositories (server-wide loaded-config inventory; independent of params.path), bareRepoPath, allWorktrees, siblingRepositories, currentWorktreePath, configPath, capabilities {available,reason}, notes. Lean configuredRepositories entries are mode-discriminated: clone \u2192 {name, mode:'clone', checkoutPath, isCurrent}; worktree \u2192 {name, mode:'worktree', worktreeDir, isCurrent}. detailed=true adds repoUrl, branch?, sparseCheckout?, localReady, plus bareRepoDir for worktree mode. Use at session start or to bootstrap from unknown checkout.",
4374
6195
  inputSchema: {
4375
- path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD."),
6196
+ path: z.string().optional().describe("Directory to inspect. Default: server CWD."),
6197
+ detailed: z.boolean().optional().default(false).describe("Expand configuredRepositories with repoUrl, branch, sparseCheckout, localReady, bareRepoDir."),
6198
+ includeAllWorktrees: z.boolean().optional().describe("Include allWorktreesByRepo + allWorktreeErrorsByRepo for each configured repo. Default: false."),
4376
6199
  includeStatus: z.boolean().optional().describe(
4377
- "If true, enriches each entry in allWorktrees with label, divergence, and staleHint. Adds one git status + rev-list per worktree. Default: false (cheap path)."
6200
+ "Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Default: false."
4378
6201
  )
4379
6202
  },
4380
6203
  annotations: {
@@ -4389,11 +6212,11 @@ function createServer(context, snapshot) {
4389
6212
  server.registerTool(
4390
6213
  "list_worktrees",
4391
6214
  {
4392
- description: "List all worktrees of a repository with enriched status. Returns: array of { path, branch, isCurrent, label (clean|dirty|stale|current|unknown), status, divergence (ahead/behind), safeToRemove: { safe, reason }, lastSyncAt, sizeBytes }.",
6215
+ description: "List worktrees with status. No repoName + config loaded = all configured repos grouped by repoName. With repoName = single repo. Entries: {path, branch, isCurrent, label (clean|dirty|stale|current|unknown), status, divergence, safeToRemove, lastSyncAt, sizeBytes}.",
4393
6216
  inputSchema: {
4394
- repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
6217
+ repoName: z.string().optional().describe("Repo name. Omit + config loaded = list all configured repos."),
4395
6218
  includeSize: z.boolean().optional().describe(
4396
- "If true, computes the on-disk size of each worktree (in bytes). Slow on large worktrees. Default: false (sizeBytes returned as null)."
6219
+ "Compute on-disk size per worktree (bytes). Slow on large worktrees. Default: false (sizeBytes=null)."
4397
6220
  )
4398
6221
  },
4399
6222
  annotations: {
@@ -4408,11 +6231,11 @@ function createServer(context, snapshot) {
4408
6231
  server.registerTool(
4409
6232
  "get_worktree_status",
4410
6233
  {
4411
- description: "Get detailed status for one worktree (dirty files, unpushed commits, stashes, upstream gone, operations in progress). Returns: full status object plus divergence { ahead, behind } and resolved absolute path.",
6234
+ description: "Detailed status for one worktree: dirty files, unpushed commits, stashes, upstream gone, ops in progress. Returns: status + divergence {ahead,behind} + resolved path.",
4412
6235
  inputSchema: {
4413
6236
  path: z.string().describe(`Worktree path. ${PATH_DESCRIBE_SUFFIX}`),
4414
6237
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
4415
- includeDetails: z.boolean().optional().describe("If true, includes file-level lists (modified, untracked, staged). Default: false (counts only).")
6238
+ includeDetails: z.boolean().optional().describe("Include file-level lists (modified, untracked, staged). Default: false (counts only).")
4416
6239
  },
4417
6240
  annotations: {
4418
6241
  title: "Get worktree status",
@@ -4426,13 +6249,13 @@ function createServer(context, snapshot) {
4426
6249
  server.registerTool(
4427
6250
  "create_worktree",
4428
6251
  {
4429
- description: "Create a worktree for a branch. If the branch exists (local or remote), checks it out; otherwise creates it from baseBranch. Optionally pushes the new branch to origin. Key params: baseBranch is required only when the branch does not yet exist \u2014 pass it defensively if unsure. push=true only affects newly created branches. Preconditions: repository must be initialized (auto-runs on first call). Returns: { success, branchName, worktreePath, created, pushed }.",
6252
+ description: "Create worktree for a branch. Existing branch (local/remote) = checkout. New branch = create from baseBranch + push to origin (default). baseBranch required only for new branches \u2014 pass defensively if unsure. push=false opts out. Preconditions: repo initialized (auto-runs). Returns: {success, branchName, worktreePath, created, pushed}.",
4430
6253
  inputSchema: {
4431
- branchName: z.string().describe("Branch name. Slashes and special chars are sanitized for the worktree directory name."),
6254
+ branchName: z.string().describe("Branch name. Slashes/special chars sanitized for dir name."),
4432
6255
  baseBranch: z.string().optional().describe(
4433
- "Base branch for creating a new branch. Required if branchName does not exist locally or remotely; ignored otherwise."
6256
+ "Base for new branch. Required if branchName doesn't exist locally or remotely; ignored otherwise."
4434
6257
  ),
4435
- push: z.boolean().optional().describe("Push the newly created branch to origin. Ignored if the branch already existed."),
6258
+ push: z.boolean().optional().describe("Push new branch to origin. Default: true. Ignored if branch existed."),
4436
6259
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4437
6260
  },
4438
6261
  annotations: {
@@ -4448,12 +6271,10 @@ function createServer(context, snapshot) {
4448
6271
  server.registerTool(
4449
6272
  "remove_worktree",
4450
6273
  {
4451
- description: "Remove a worktree. Runs safety checks first: rejects if worktree is dirty, has unpushed commits, has stashes, or has an in-progress git operation (merge/rebase/cherry-pick/revert/bisect). force=true: runs `git worktree remove --force`, which DELETES uncommitted and untracked files in the worktree directory. Branch ref, stashes, and remote state are preserved. Returns: { success, removedPath }.",
6274
+ 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}.",
4452
6275
  inputSchema: {
4453
6276
  path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
4454
- force: z.boolean().optional().describe(
4455
- "Skip safety checks and delete uncommitted/untracked files in the worktree directory. Branch ref is preserved. Default: false."
4456
- ),
6277
+ force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
4457
6278
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4458
6279
  },
4459
6280
  annotations: {
@@ -4469,7 +6290,7 @@ function createServer(context, snapshot) {
4469
6290
  server.registerTool(
4470
6291
  "sync",
4471
6292
  {
4472
- description: "Full repo-wide synchronization: fetch all, create worktrees for new remote branches, remove worktrees for pruned remote branches (clean only), fast-forward existing worktrees. Emits progress notifications. Do not use when: you only need to update one worktree \u2014 use update_worktree. Only need to create one \u2014 use create_worktree. Preconditions: config must be loaded (load_config) and the repository initialized (auto-runs on first call). Returns: { success, duration } after sync completes.",
6293
+ description: "Repo-wide sync: fetch, create worktrees for new remote branches, remove pruned (clean only), fast-forward existing. Emits progress. Single worktree? Use update_worktree. Single create? Use create_worktree. Preconditions: config loaded + repo initialized (auto-runs). Returns: {success, duration, skips}.",
4473
6294
  inputSchema: {
4474
6295
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4475
6296
  },
@@ -4486,7 +6307,7 @@ function createServer(context, snapshot) {
4486
6307
  server.registerTool(
4487
6308
  "update_worktree",
4488
6309
  {
4489
- description: "Fast-forward one worktree to match its upstream. No merge commits, no rebasing, aborts if not fast-forwardable. Do not use when: you want to update every worktree in the repo \u2014 use sync.",
6310
+ description: "Fast-forward one worktree to upstream. No merge, no rebase, aborts if not fast-forwardable. Whole repo? Use sync.",
4490
6311
  inputSchema: {
4491
6312
  path: z.string().describe(`Worktree path to fast-forward. ${PATH_DESCRIBE_SUFFIX}`),
4492
6313
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
@@ -4504,7 +6325,7 @@ function createServer(context, snapshot) {
4504
6325
  server.registerTool(
4505
6326
  "initialize",
4506
6327
  {
4507
- description: "Initialize a repository: clone as bare repo if missing, create main worktree. Safe to call on already-initialized repos (no-op-ish). Emits progress notifications. Preconditions: config must be loaded (load_config) so the repo's URL and paths are known. Returns: { success, defaultBranch, worktreeDir }.",
6328
+ description: "Initialize repo: clone as bare if missing, create main worktree. Idempotent. Emits progress. Preconditions: config loaded. Returns: {success, defaultBranch, worktreeDir}.",
4508
6329
  inputSchema: {
4509
6330
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4510
6331
  },
@@ -4521,11 +6342,9 @@ function createServer(context, snapshot) {
4521
6342
  server.registerTool(
4522
6343
  "load_config",
4523
6344
  {
4524
- description: "Load or reload a sync-worktrees JavaScript config file into the server's session. Replaces any previously loaded repositories. Call this before sync/initialize/create_worktree when using a config-driven workflow. Returns: { configPath, currentRepository, repositories: [{ name, repoUrl, worktreeDir, source }] }.",
6345
+ 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}]}.",
4525
6346
  inputSchema: {
4526
- configPath: z.string().optional().describe(
4527
- "Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."
4528
- )
6347
+ configPath: z.string().optional().describe("Config file path. Falls back to SYNC_WORKTREES_CONFIG env var. Errors if neither set.")
4529
6348
  },
4530
6349
  annotations: {
4531
6350
  title: "Load sync-worktrees config",
@@ -4540,9 +6359,9 @@ function createServer(context, snapshot) {
4540
6359
  server.registerTool(
4541
6360
  "set_current_repository",
4542
6361
  {
4543
- description: "Set the current repository for subsequent tool calls that omit repoName. Session-scoped; not persisted across server restarts. Preconditions: load_config must have been called so the name is known.",
6362
+ description: "Set current repo for tool calls that omit repoName. Session-scoped. Preconditions: load_config called.",
4544
6363
  inputSchema: {
4545
- repoName: z.string().describe("Repository name as listed in the loaded config's `repositories[].name`.")
6364
+ repoName: z.string().describe("Repo name from loaded config repositories[].name.")
4546
6365
  },
4547
6366
  annotations: {
4548
6367
  title: "Set current repository",
@@ -4584,7 +6403,10 @@ async function main() {
4584
6403
  process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
4585
6404
  `);
4586
6405
  }
4587
- const server = createServer(context, { discovered });
6406
+ const server = createServer(context, {
6407
+ discovered,
6408
+ configuredRepoCount: context.getConfiguredRepositoryNames().length
6409
+ });
4588
6410
  const transport = new StdioServerTransport();
4589
6411
  await server.connect(transport);
4590
6412
  }