sync-worktrees 3.6.3 → 4.1.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 +383 -261
  2. package/dist/components/App.d.ts +50 -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 +14 -0
  15. package/dist/components/StatusBar.d.ts.map +1 -0
  16. package/dist/components/WorktreeStatusView.d.ts +14 -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 +2523 -1106
  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 +2347 -640
  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 +94 -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 +303 -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 +31 -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.debug(` \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;
@@ -1794,11 +2756,12 @@ function sanitizeGitEnv(env) {
1794
2756
  return sanitized;
1795
2757
  }
1796
2758
  var GitService = class {
1797
- constructor(config, logger) {
2759
+ constructor(config, logger, progressEmitter) {
1798
2760
  this.config = config;
2761
+ this.progressEmitter = progressEmitter;
1799
2762
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
1800
2763
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
1801
- this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
2764
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1802
2765
  this.metadataService = new WorktreeMetadataService(this.logger);
1803
2766
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
1804
2767
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -1826,36 +2789,22 @@ var GitService = class {
1826
2789
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
1827
2790
  }
1828
2791
  getCachedGit(dirPath, useLfsSkip = false) {
1829
- const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
2792
+ const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1830
2793
  let git = this.gitInstances.get(key);
1831
2794
  if (!git) {
1832
- const base = simpleGit4(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2795
+ const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
1833
2796
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
1834
2797
  this.gitInstances.set(key, git);
1835
2798
  }
1836
2799
  return git;
1837
2800
  }
1838
2801
  buildSimpleGitOptions(blockMs) {
1839
- const options = { progress: this.makeProgressHandler() };
2802
+ const options = {
2803
+ progress: makeGitProgressHandler(this.logger, (event) => this.progressEmitter?.(event))
2804
+ };
1840
2805
  if (blockMs > 0) options.timeout = { block: blockMs };
1841
2806
  return options;
1842
2807
  }
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
2808
  updateLogger(logger) {
1860
2809
  this.logger = logger;
1861
2810
  this.sparseCheckoutService.updateLogger(logger);
@@ -1863,11 +2812,11 @@ var GitService = class {
1863
2812
  async initialize() {
1864
2813
  const { repoUrl } = this.config;
1865
2814
  try {
1866
- await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
2815
+ await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
1867
2816
  } catch {
1868
2817
  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()));
2818
+ await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
2819
+ const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
1871
2820
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
1872
2821
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
1873
2822
  this.logger.info("\u2705 Clone successful.");
@@ -1885,17 +2834,17 @@ var GitService = class {
1885
2834
  this.logger.info("Fetching remote branches...");
1886
2835
  await bareGit.fetch(["--all", "--progress"]);
1887
2836
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
1888
- this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
2837
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
1889
2838
  let needsMainWorktree = true;
1890
2839
  try {
1891
2840
  const worktrees = await this.getWorktreesFromBare(bareGit);
1892
- needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
2841
+ needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
1893
2842
  } catch {
1894
2843
  }
1895
2844
  if (needsMainWorktree) {
1896
2845
  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);
2846
+ await fs6.mkdir(this.config.worktreeDir, { recursive: true });
2847
+ const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
1899
2848
  const branches = await bareGit.branch();
1900
2849
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
1901
2850
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -1931,7 +2880,7 @@ var GitService = class {
1931
2880
  }
1932
2881
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
1933
2882
  const mainWorktreeRegistered = updatedWorktrees.some(
1934
- (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
2883
+ (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
1935
2884
  );
1936
2885
  if (!mainWorktreeRegistered) {
1937
2886
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -1957,6 +2906,45 @@ var GitService = class {
1957
2906
  getBareRepoPath() {
1958
2907
  return this.bareRepoPath;
1959
2908
  }
2909
+ async getRemoteDefaultBranch(repoUrl) {
2910
+ const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
2911
+ try {
2912
+ const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
2913
+ const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
2914
+ if (match && match[1]) {
2915
+ return match[1];
2916
+ }
2917
+ } catch {
2918
+ }
2919
+ const existing = [];
2920
+ for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
2921
+ try {
2922
+ const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
2923
+ if (out.trim().length > 0) {
2924
+ existing.push(candidate);
2925
+ }
2926
+ } catch {
2927
+ }
2928
+ }
2929
+ if (existing.length === 1) {
2930
+ this.logger.warn(
2931
+ `Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
2932
+ );
2933
+ return existing[0];
2934
+ }
2935
+ if (existing.length > 1) {
2936
+ throw new Error(
2937
+ `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.`
2938
+ );
2939
+ }
2940
+ throw new Error(
2941
+ `Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
2942
+ );
2943
+ }
2944
+ async verifyLfs(worktreePath, label) {
2945
+ if (this.isLfsSkipEnabled()) return;
2946
+ await this.verifyLfsFilesDownloaded(worktreePath, label);
2947
+ }
1960
2948
  async fetchAll() {
1961
2949
  this.assertInitialized();
1962
2950
  this.logger.info("Fetching latest data from remote...");
@@ -2003,7 +2991,7 @@ var GitService = class {
2003
2991
  return branches;
2004
2992
  }
2005
2993
  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);
2994
+ const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
2007
2995
  try {
2008
2996
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
2009
2997
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -2014,7 +3002,7 @@ var GitService = class {
2014
3002
  const existence = await Promise.all(
2015
3003
  lfsFileList.map(async (f) => {
2016
3004
  try {
2017
- await fs4.access(path6.join(worktreePath, f));
3005
+ await fs6.access(path8.join(worktreePath, f));
2018
3006
  return f;
2019
3007
  } catch {
2020
3008
  return null;
@@ -2042,9 +3030,9 @@ var GitService = class {
2042
3030
  let allDownloaded = true;
2043
3031
  const notDownloaded = [];
2044
3032
  for (const file of samplesToCheck) {
2045
- const filePath = path6.join(worktreePath, file);
3033
+ const filePath = path8.join(worktreePath, file);
2046
3034
  try {
2047
- const handle = await fs4.open(filePath, "r");
3035
+ const handle = await fs6.open(filePath, "r");
2048
3036
  try {
2049
3037
  const buffer = Buffer.alloc(200);
2050
3038
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -2069,7 +3057,7 @@ var GitService = class {
2069
3057
  }
2070
3058
  retries++;
2071
3059
  if (retries < maxRetries) {
2072
- await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
3060
+ await new Promise((resolve11) => setTimeout(resolve11, retryDelay));
2073
3061
  }
2074
3062
  }
2075
3063
  this.logger.warn(
@@ -2131,18 +3119,18 @@ var GitService = class {
2131
3119
  }
2132
3120
  async addWorktree(branchName, worktreePath) {
2133
3121
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
2134
- const absoluteWorktreePath = path6.resolve(worktreePath);
2135
- await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
3122
+ const absoluteWorktreePath = path8.resolve(worktreePath);
3123
+ await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
2136
3124
  try {
2137
- await fs4.access(absoluteWorktreePath);
3125
+ await fs6.access(absoluteWorktreePath);
2138
3126
  const worktrees = await this.getWorktreesFromBare(bareGit);
2139
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3127
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
2140
3128
  if (isValidWorktree) {
2141
3129
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2142
3130
  return;
2143
3131
  } else {
2144
3132
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
2145
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
3133
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
2146
3134
  }
2147
3135
  } catch {
2148
3136
  }
@@ -2181,7 +3169,7 @@ var GitService = class {
2181
3169
  }
2182
3170
  if (errorMessage.includes("already registered worktree")) {
2183
3171
  const worktrees = await this.getWorktreesFromBare(bareGit);
2184
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3172
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
2185
3173
  if (existingWorktree && !existingWorktree.isPrunable) {
2186
3174
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
2187
3175
  return;
@@ -2189,7 +3177,7 @@ var GitService = class {
2189
3177
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
2190
3178
  await bareGit.raw(["worktree", "prune"]);
2191
3179
  try {
2192
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
3180
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
2193
3181
  } catch {
2194
3182
  }
2195
3183
  let retryCreatedNewBranch = false;
@@ -2225,15 +3213,15 @@ var GitService = class {
2225
3213
  }
2226
3214
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
2227
3215
  try {
2228
- await fs4.access(absoluteWorktreePath);
3216
+ await fs6.access(absoluteWorktreePath);
2229
3217
  const worktrees = await this.getWorktreesFromBare(bareGit);
2230
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3218
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
2231
3219
  if (isValidWorktree) {
2232
3220
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2233
3221
  return;
2234
3222
  } else {
2235
3223
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
2236
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
3224
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
2237
3225
  }
2238
3226
  } catch {
2239
3227
  }
@@ -2257,7 +3245,7 @@ var GitService = class {
2257
3245
  const fallbackErrorMessage = getErrorMessage(fallbackError);
2258
3246
  if (fallbackErrorMessage.includes("already registered worktree")) {
2259
3247
  const worktrees = await this.getWorktreesFromBare(bareGit);
2260
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3248
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
2261
3249
  if (existingWorktree && !existingWorktree.isPrunable) {
2262
3250
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
2263
3251
  return;
@@ -2468,14 +3456,48 @@ var GitService = class {
2468
3456
  return false;
2469
3457
  }
2470
3458
  }
2471
- async isLocalAheadOfRemote(worktreePath, branch) {
2472
- const worktreeGit = this.getCachedGit(worktreePath);
3459
+ async isLocalAheadOfRemote(worktreePath, branch) {
3460
+ const worktreeGit = this.getCachedGit(worktreePath);
3461
+ try {
3462
+ const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
3463
+ const mergeBaseSha = mergeBase.trim();
3464
+ const remoteSha = await worktreeGit.revparse([`origin/${branch}`]);
3465
+ const remoteShaTrimmed = remoteSha.trim();
3466
+ return mergeBaseSha === remoteShaTrimmed;
3467
+ } catch {
3468
+ return false;
3469
+ }
3470
+ }
3471
+ async classifyRemoteRelationship(worktreePath, branch) {
3472
+ const worktreeGit = this.getCachedGit(worktreePath);
3473
+ let headSha;
3474
+ let remoteSha;
3475
+ try {
3476
+ headSha = (await worktreeGit.revparse(["HEAD"])).trim();
3477
+ remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
3478
+ } catch {
3479
+ return "diverged";
3480
+ }
3481
+ if (headSha === remoteSha) return "up_to_date";
3482
+ let mergeBase = "";
3483
+ let mergeBaseFailed = false;
3484
+ try {
3485
+ mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
3486
+ } catch {
3487
+ mergeBaseFailed = true;
3488
+ }
3489
+ if (mergeBaseFailed || !mergeBase) {
3490
+ if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
3491
+ return "diverged";
3492
+ }
3493
+ if (mergeBase === headSha) return "fast_forward";
3494
+ if (mergeBase === remoteSha) return "local_ahead";
3495
+ return "diverged";
3496
+ }
3497
+ async isShallowRepository(git) {
2473
3498
  try {
2474
- const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
2475
- const mergeBaseSha = mergeBase.trim();
2476
- const remoteSha = await worktreeGit.revparse([`origin/${branch}`]);
2477
- const remoteShaTrimmed = remoteSha.trim();
2478
- return mergeBaseSha === remoteShaTrimmed;
3499
+ const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
3500
+ return output.trim() === "true";
2479
3501
  } catch {
2480
3502
  return false;
2481
3503
  }
@@ -2590,193 +3612,129 @@ var GitService = class {
2590
3612
  }
2591
3613
  };
2592
3614
 
2593
- // src/services/path-resolution.service.ts
2594
- import { createHash } from "crypto";
2595
- import * as fs5 from "fs";
2596
- import * as path7 from "path";
2597
- var BRANCH_STEM_MAX = 80;
2598
- var BRANCH_HASH_LEN = 8;
2599
- var PathResolutionService = class {
2600
- sanitizeBranchName(branchName) {
2601
- 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);
2603
- return `${stem}-${hash}`;
2604
- }
2605
- getBranchWorktreePath(worktreeDir, branchName) {
2606
- return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
3615
+ // src/services/progress-emitter.ts
3616
+ var ProgressEmitter = class {
3617
+ listeners = /* @__PURE__ */ new Set();
3618
+ onProgress(listener) {
3619
+ this.listeners.add(listener);
3620
+ return () => this.listeners.delete(listener);
2607
3621
  }
2608
- resolveRealPath(inputPath) {
2609
- const absolute = path7.resolve(inputPath);
2610
- const missing = [];
2611
- let current = absolute;
2612
- while (!fs5.existsSync(current)) {
2613
- const parent = path7.dirname(current);
2614
- if (parent === current) {
2615
- return absolute;
3622
+ emit(event) {
3623
+ for (const listener of [...this.listeners]) {
3624
+ try {
3625
+ listener(event);
3626
+ } catch {
2616
3627
  }
2617
- missing.unshift(path7.basename(current));
2618
- current = parent;
2619
- }
2620
- try {
2621
- return path7.join(fs5.realpathSync(current), ...missing);
2622
- } catch {
2623
- return absolute;
2624
- }
2625
- }
2626
- isResolvedPathInsideBase(resolved, resolvedBase) {
2627
- const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
2628
- const a = fold(resolved);
2629
- const b = fold(resolvedBase);
2630
- if (a === b) return true;
2631
- return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
2632
- }
2633
- normalizeWorktreePath(worktreePath, worktreeBaseDir) {
2634
- const resolved = this.resolveRealPath(worktreePath);
2635
- const resolvedBase = this.resolveRealPath(worktreeBaseDir);
2636
- if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
2637
- throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
2638
3628
  }
2639
- return path7.relative(resolvedBase, resolved);
2640
- }
2641
- isPathInsideBaseDir(targetPath, baseDir) {
2642
- const resolved = this.resolveRealPath(targetPath);
2643
- const resolvedBase = this.resolveRealPath(baseDir);
2644
- return this.isResolvedPathInsideBase(resolved, resolvedBase);
2645
- }
2646
- extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
2647
- return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
2648
3629
  }
2649
3630
  };
2650
3631
 
2651
- // src/services/worktree-sync.service.ts
2652
- var WorktreeSyncService = class {
2653
- constructor(config) {
2654
- this.config = config;
2655
- this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
2656
- this.gitService = new GitService(config, this.logger);
2657
- }
2658
- gitService;
2659
- logger;
2660
- syncInProgress = false;
2661
- 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;
3632
+ // src/services/repo-operation-lock.ts
3633
+ import * as fs7 from "fs/promises";
3634
+ import * as path10 from "path";
3635
+ import * as lockfile from "proper-lockfile";
3636
+
3637
+ // src/utils/lock-path.ts
3638
+ import { createHash } from "crypto";
3639
+ import * as os from "os";
3640
+ import * as path9 from "path";
3641
+ function getCloneModeLockTarget(config) {
3642
+ const name = config.name;
3643
+ const configDir = config.__configFileDir;
3644
+ const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
3645
+ if (configDir) {
3646
+ return {
3647
+ dir: path9.join(configDir, ".sync-worktrees-state"),
3648
+ file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
3649
+ };
2673
3650
  }
2674
- getGitService() {
2675
- return this.gitService;
3651
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
3652
+ const dir = path9.join(stateBase, "sync-worktrees", "locks");
3653
+ return { dir, file: `${hash}.lock` };
3654
+ }
3655
+
3656
+ // src/services/repo-operation-lock.ts
3657
+ var RepoOperationLock = class {
3658
+ constructor(config, gitService, logger = Logger.createDefault()) {
3659
+ this.config = config;
3660
+ this.gitService = gitService;
3661
+ this.logger = logger;
2676
3662
  }
2677
3663
  updateLogger(logger) {
2678
3664
  this.logger = logger;
2679
- this.gitService.updateLogger(logger);
2680
3665
  }
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" };
3666
+ async acquire() {
3667
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
3668
+ return async () => {
3669
+ };
2689
3670
  }
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" };
3671
+ if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
3672
+ return this.acquireCloneModeLock();
2694
3673
  }
2695
- this.syncInProgress = true;
3674
+ return this.acquireWorktreeModeLock();
3675
+ }
3676
+ async acquireCloneModeLock() {
3677
+ const target = getCloneModeLockTarget(this.config);
3678
+ const lockTarget = path10.join(target.dir, target.file);
2696
3679
  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
- }
3680
+ await fs7.mkdir(target.dir, { recursive: true });
3681
+ await fs7.writeFile(lockTarget, "", { flag: "a" });
3682
+ } catch {
3683
+ return null;
2705
3684
  }
3685
+ return this.lockPath(lockTarget);
2706
3686
  }
2707
- emitProgress(event) {
2708
- for (const listener of this.progressListeners) {
2709
- try {
2710
- listener(event);
2711
- } catch {
2712
- }
3687
+ async acquireWorktreeModeLock() {
3688
+ const barePath = this.gitService.getBareRepoPath();
3689
+ try {
3690
+ await fs7.mkdir(barePath, { recursive: true });
3691
+ } catch {
3692
+ return null;
2713
3693
  }
3694
+ return this.lockPath(barePath);
2714
3695
  }
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 });
3696
+ async lockPath(lockTarget) {
2757
3697
  try {
2758
- const release = await lockfile.lock(barePath, {
3698
+ return await lockfile.lock(lockTarget, {
2759
3699
  stale: DEFAULT_CONFIG.LOCK_STALE_MS,
2760
3700
  update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
2761
3701
  retries: 0,
2762
3702
  realpath: false
2763
3703
  });
2764
- return release;
2765
3704
  } catch (error) {
2766
3705
  const code = error.code;
2767
3706
  if (code === "ELOCKED") {
2768
3707
  return null;
2769
3708
  }
2770
- throw error;
3709
+ this.logger.warn(
3710
+ `Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
3711
+ );
3712
+ return null;
2771
3713
  }
2772
3714
  }
2773
- createRetryOptions(syncContext) {
3715
+ };
3716
+
3717
+ // src/services/sync-retry-policy.ts
3718
+ var SyncRetryPolicy = class {
3719
+ constructor(config, gitService, logger) {
3720
+ this.config = config;
3721
+ this.gitService = gitService;
3722
+ this.logger = logger;
3723
+ }
3724
+ updateLogger(logger) {
3725
+ this.logger = logger;
3726
+ }
3727
+ createContext() {
3728
+ return { lfsSkipEnabled: false };
3729
+ }
3730
+ createOptions(syncContext) {
2774
3731
  return {
2775
3732
  maxAttempts: this.config.retry?.maxAttempts ?? 3,
2776
3733
  maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
2777
3734
  initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
2778
3735
  maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
2779
3736
  backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
3737
+ jitterMs: this.config.retry?.jitterMs ?? 0,
2780
3738
  onRetry: (error, attempt, context) => {
2781
3739
  const errorMessage = getErrorMessage(error);
2782
3740
  this.logger.info(`
@@ -2797,25 +3755,228 @@ var WorktreeSyncService = class {
2797
3755
  }
2798
3756
  };
2799
3757
  }
2800
- async runSyncAttempt(phaseTimer, syncContext) {
3758
+ resetLfsSkipIfNeeded(syncContext) {
3759
+ if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
3760
+ this.gitService.setLfsSkipEnabled(false);
3761
+ }
3762
+ }
3763
+ };
3764
+
3765
+ // src/services/worktree-mode-sync-runner.ts
3766
+ import * as fs9 from "fs/promises";
3767
+ import * as path13 from "path";
3768
+ import pLimit from "p-limit";
3769
+
3770
+ // src/utils/date-filter.ts
3771
+ function parseDuration(durationStr) {
3772
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
3773
+ if (!match) {
3774
+ return null;
3775
+ }
3776
+ const value = parseInt(match[1], 10);
3777
+ const unit = match[2];
3778
+ const multipliers = {
3779
+ h: 60 * 60 * 1e3,
3780
+ // hours
3781
+ d: 24 * 60 * 60 * 1e3,
3782
+ // days
3783
+ w: 7 * 24 * 60 * 60 * 1e3,
3784
+ // weeks
3785
+ m: 30 * 24 * 60 * 60 * 1e3,
3786
+ // months (approximate)
3787
+ y: 365 * 24 * 60 * 60 * 1e3
3788
+ // years (approximate)
3789
+ };
3790
+ return value * multipliers[unit];
3791
+ }
3792
+ function filterBranchesByAge(branches, maxAge) {
3793
+ const maxAgeMs = parseDuration(maxAge);
3794
+ if (maxAgeMs === null) {
3795
+ console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
3796
+ return branches;
3797
+ }
3798
+ const cutoffDate = new Date(Date.now() - maxAgeMs);
3799
+ return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
3800
+ }
3801
+ function formatDuration2(durationStr) {
3802
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
3803
+ if (!match) {
3804
+ return durationStr;
3805
+ }
3806
+ const value = parseInt(match[1], 10);
3807
+ const unit = match[2];
3808
+ const unitNames = {
3809
+ h: value === 1 ? "hour" : "hours",
3810
+ d: value === 1 ? "day" : "days",
3811
+ w: value === 1 ? "week" : "weeks",
3812
+ m: value === 1 ? "month" : "months",
3813
+ y: value === 1 ? "year" : "years"
3814
+ };
3815
+ return `${value} ${unitNames[unit]}`;
3816
+ }
3817
+
3818
+ // src/services/path-resolution.service.ts
3819
+ import { createHash as createHash2 } from "crypto";
3820
+ import * as fs8 from "fs";
3821
+ import * as path11 from "path";
3822
+ var BRANCH_STEM_MAX = 80;
3823
+ var BRANCH_HASH_LEN = 8;
3824
+ var PathResolutionService = class {
3825
+ sanitizeBranchName(branchName) {
3826
+ const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
3827
+ const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
3828
+ return `${stem}-${hash}`;
3829
+ }
3830
+ getBranchWorktreePath(worktreeDir, branchName) {
3831
+ return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
3832
+ }
3833
+ resolveRealPath(inputPath) {
3834
+ const absolute = path11.resolve(inputPath);
3835
+ const missing = [];
3836
+ let current = absolute;
3837
+ while (!fs8.existsSync(current)) {
3838
+ const parent = path11.dirname(current);
3839
+ if (parent === current) {
3840
+ return absolute;
3841
+ }
3842
+ missing.unshift(path11.basename(current));
3843
+ current = parent;
3844
+ }
3845
+ try {
3846
+ return path11.join(fs8.realpathSync(current), ...missing);
3847
+ } catch {
3848
+ return absolute;
3849
+ }
3850
+ }
3851
+ isResolvedPathInsideBase(resolved, resolvedBase) {
3852
+ const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
3853
+ const a = fold(resolved);
3854
+ const b = fold(resolvedBase);
3855
+ if (a === b) return true;
3856
+ return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
3857
+ }
3858
+ normalizeWorktreePath(worktreePath, worktreeBaseDir) {
3859
+ const resolved = this.resolveRealPath(worktreePath);
3860
+ const resolvedBase = this.resolveRealPath(worktreeBaseDir);
3861
+ if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
3862
+ throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
3863
+ }
3864
+ return path11.relative(resolvedBase, resolved);
3865
+ }
3866
+ isPathInsideBaseDir(targetPath, baseDir) {
3867
+ const resolved = this.resolveRealPath(targetPath);
3868
+ const resolvedBase = this.resolveRealPath(baseDir);
3869
+ return this.isResolvedPathInsideBase(resolved, resolvedBase);
3870
+ }
3871
+ extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
3872
+ return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
3873
+ }
3874
+ };
3875
+
3876
+ // src/services/worktree-sync-planner.ts
3877
+ import * as path12 from "path";
3878
+ function createWorktreeSyncPlan(inventory, options = {}) {
3879
+ return {
3880
+ create: planCreateActions(inventory, options),
3881
+ prune: planPruneActions(inventory),
3882
+ update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
3883
+ sparse: planSparseActions(inventory, options.sparseCheckout),
3884
+ warnings: []
3885
+ };
3886
+ }
3887
+ function planCreateActions(inventory, options = {}) {
3888
+ const pathResolution2 = options.pathResolution ?? new PathResolutionService();
3889
+ const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
3890
+ const newBranches = inventory.remoteBranches.filter(
3891
+ (branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
3892
+ );
3893
+ const reservedPaths = /* @__PURE__ */ new Map();
3894
+ for (const worktree of inventory.existingWorktrees) {
3895
+ reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
3896
+ }
3897
+ const actions = [];
3898
+ for (const branch of newBranches) {
3899
+ const worktreePath = pathResolution2.getBranchWorktreePath(inventory.worktreeDir, branch);
3900
+ const resolved = path12.resolve(worktreePath);
3901
+ const conflictingBranch = reservedPaths.get(resolved);
3902
+ if (conflictingBranch && conflictingBranch !== branch) {
3903
+ actions.push({
3904
+ kind: "skip-create",
3905
+ branch,
3906
+ path: worktreePath,
3907
+ reason: "path-collision",
3908
+ conflictingBranch
3909
+ });
3910
+ continue;
3911
+ }
3912
+ reservedPaths.set(resolved, branch);
3913
+ actions.push({ kind: "create", branch, path: worktreePath });
3914
+ }
3915
+ return actions;
3916
+ }
3917
+ function planPruneActions(inventory) {
3918
+ const remoteBranches = new Set(inventory.remoteBranches);
3919
+ return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
3920
+ }
3921
+ function planUpdateActions(inventory) {
3922
+ const remoteBranches = new Set(inventory.remoteBranches);
3923
+ return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
3924
+ }
3925
+ function planSparseActions(inventory, sparseCheckout) {
3926
+ if (!sparseCheckout) {
3927
+ return [];
3928
+ }
3929
+ return inventory.existingWorktrees.map((worktree) => ({
3930
+ kind: "check-sparse",
3931
+ branch: worktree.branch,
3932
+ path: worktree.path
3933
+ }));
3934
+ }
3935
+
3936
+ // src/services/worktree-mode-sync-runner.ts
3937
+ var WorktreeModeSyncRunner = class {
3938
+ constructor(config, gitService, logger, progressEmitter) {
3939
+ this.config = config;
3940
+ this.gitService = gitService;
3941
+ this.logger = logger;
3942
+ this.progressEmitter = progressEmitter;
3943
+ }
3944
+ pathResolution = new PathResolutionService();
3945
+ updateLogger(logger) {
3946
+ this.logger = logger;
3947
+ }
3948
+ async runSyncAttempt(phaseTimer, syncContext, outcome) {
2801
3949
  await this.gitService.pruneWorktrees();
2802
3950
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
2803
3951
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
2804
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
3952
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
2805
3953
  const worktrees = await this.gitService.getWorktrees();
2806
3954
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
2807
3955
  await this.cleanupOrphanedDirectories(worktrees);
2808
- await this.createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer);
2809
- await this.pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer);
3956
+ const syncPlan = createWorktreeSyncPlan(
3957
+ {
3958
+ remoteBranches,
3959
+ defaultBranch,
3960
+ existingWorktrees: worktrees,
3961
+ worktreeDir: this.config.worktreeDir
3962
+ },
3963
+ {
3964
+ pathResolution: this.pathResolution,
3965
+ updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
3966
+ sparseCheckout: this.config.sparseCheckout
3967
+ }
3968
+ );
3969
+ await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
3970
+ await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
2810
3971
  if (this.config.updateExistingWorktrees !== false) {
2811
- await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
3972
+ await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
2812
3973
  }
2813
3974
  if (this.config.sparseCheckout) {
2814
- await this.reapplySparseCheckout(worktrees);
3975
+ await this.reapplySparseCheckout(syncPlan.sparse, outcome);
2815
3976
  }
2816
3977
  await this.finalizeSyncAttempt(phaseTimer);
2817
3978
  }
2818
- async reapplySparseCheckout(worktrees) {
3979
+ async reapplySparseCheckout(actions, outcome) {
2819
3980
  const sparseConfig = this.config.sparseCheckout;
2820
3981
  if (!sparseConfig) return;
2821
3982
  this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
@@ -2823,32 +3984,44 @@ var WorktreeSyncService = class {
2823
3984
  const desired = sparseService.buildPatterns(sparseConfig);
2824
3985
  const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
2825
3986
  await Promise.all(
2826
- worktrees.map(
2827
- (worktree) => limit(async () => {
3987
+ actions.map(
3988
+ (action) => limit(async () => {
3989
+ if (action.kind !== "check-sparse") return;
2828
3990
  try {
2829
3991
  try {
2830
- await fs6.access(worktree.path);
3992
+ await fs9.access(action.path);
2831
3993
  } catch {
2832
3994
  return;
2833
3995
  }
2834
- const current = await sparseService.readCurrent(worktree.path);
3996
+ const current = await sparseService.readCurrent(action.path);
2835
3997
  if (current !== null && sparseService.patternsEqual(current, desired)) return;
2836
3998
  if (sparseService.isNarrowing(current, desired)) {
2837
- const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
3999
+ const status = await this.gitService.getFullWorktreeStatus(action.path, false);
2838
4000
  if (!status.canRemove) {
2839
4001
  this.logger.warn(
2840
- ` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
4002
+ ` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
2841
4003
  );
4004
+ outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
4005
+ branch: action.branch,
4006
+ path: action.path,
4007
+ message: status.reasons.join(", ")
4008
+ });
2842
4009
  return;
2843
4010
  }
2844
4011
  }
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}'`);
4012
+ await sparseService.applyToWorktree(action.path, sparseConfig);
4013
+ await this.gitService.checkoutHead(action.path);
4014
+ this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
4015
+ outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
2848
4016
  } catch (error) {
2849
4017
  this.logger.warn(
2850
- ` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
4018
+ ` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
2851
4019
  );
4020
+ outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
4021
+ reason: "sparse_checkout_failed",
4022
+ branch: action.branch,
4023
+ path: action.path
4024
+ });
2852
4025
  }
2853
4026
  })
2854
4027
  )
@@ -2857,7 +4030,7 @@ var WorktreeSyncService = class {
2857
4030
  async fetchLatestRemoteData(phaseTimer, syncContext) {
2858
4031
  this.logger.info("Step 1: Fetching latest data from remote...");
2859
4032
  phaseTimer.startPhase("Phase 1: Fetch");
2860
- this.emitProgress({ phase: "fetch", message: "Fetching latest data from remote" });
4033
+ this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
2861
4034
  try {
2862
4035
  await this.gitService.fetchAll();
2863
4036
  } catch (fetchError) {
@@ -2902,7 +4075,7 @@ var WorktreeSyncService = class {
2902
4075
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
2903
4076
  const remoteBranches = filteredBranches.map((b) => b.branch);
2904
4077
  this.logger.info(
2905
- `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
4078
+ `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
2906
4079
  );
2907
4080
  if (filteredByName.length > remoteBranches.length) {
2908
4081
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -2921,45 +4094,38 @@ var WorktreeSyncService = class {
2921
4094
  }
2922
4095
  async finalizeSyncAttempt(phaseTimer) {
2923
4096
  phaseTimer.startPhase("Phase 5: Cleanup");
2924
- this.emitProgress({ phase: "cleanup", message: "Pruning worktree metadata" });
4097
+ this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
2925
4098
  await this.gitService.pruneWorktrees();
2926
4099
  this.logger.info("Step 5: Pruned worktree metadata.");
2927
4100
  phaseTimer.endPhase();
2928
4101
  }
2929
- async createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer) {
4102
+ async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
2930
4103
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
2931
4104
  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);
4105
+ this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
4106
+ await this.createNewWorktrees(syncPlan.create, outcome);
4107
+ phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
2937
4108
  phaseTimer.endPhase();
2938
4109
  }
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) {
4110
+ async createNewWorktrees(actions, outcome) {
4111
+ if (actions.length === 0) {
2943
4112
  this.logger.info("Step 2: No new branches to create worktrees for.");
2944
4113
  return;
2945
4114
  }
2946
- const reservedPaths = /* @__PURE__ */ new Map();
2947
- for (const w of worktrees) {
2948
- reservedPaths.set(path8.resolve(w.path), w.branch);
2949
- }
2950
4115
  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) {
4116
+ for (const action of actions) {
4117
+ if (action.kind === "skip-create") {
2956
4118
  this.logger.error(
2957
- ` \u274C Skipping '${branchName}': sanitized worktree path '${worktreePath}' collides with existing branch '${conflict}'.`
4119
+ ` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
2958
4120
  );
4121
+ outcome.recordSkipped("branch", "path_collision", {
4122
+ branch: action.branch,
4123
+ path: action.path,
4124
+ message: `Path collides with existing branch '${action.conflictingBranch}'`
4125
+ });
2959
4126
  continue;
2960
4127
  }
2961
- reservedPaths.set(resolved, branchName);
2962
- plan.push({ branchName, worktreePath });
4128
+ plan.push({ branchName: action.branch, worktreePath: action.path });
2963
4129
  }
2964
4130
  this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
2965
4131
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
@@ -2970,8 +4136,14 @@ var WorktreeSyncService = class {
2970
4136
  try {
2971
4137
  await this.gitService.addWorktree(branchName, worktreePath);
2972
4138
  this.logger.info(` \u2705 Created worktree for '${branchName}'`);
4139
+ outcome.recordCreated(branchName, worktreePath);
2973
4140
  } catch (error) {
2974
4141
  this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
4142
+ outcome.recordFailed("worktree", getErrorMessage(error), {
4143
+ reason: "create_failed",
4144
+ branch: branchName,
4145
+ path: worktreePath
4146
+ });
2975
4147
  throw error;
2976
4148
  }
2977
4149
  })
@@ -2980,23 +4152,21 @@ var WorktreeSyncService = class {
2980
4152
  const successCount = results.filter((r) => r.status === "fulfilled").length;
2981
4153
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
2982
4154
  }
2983
- async pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer) {
4155
+ async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
2984
4156
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
2985
4157
  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);
4158
+ this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
4159
+ await this.pruneOldWorktrees(actions, outcome);
4160
+ phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
2990
4161
  phaseTimer.endPhase();
2991
4162
  }
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...`);
4163
+ async pruneOldWorktrees(actions, outcome) {
4164
+ if (actions.length > 0) {
4165
+ this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
2996
4166
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
2997
4167
  const limit = pLimit(maxConcurrent);
2998
4168
  const statusResults = await Promise.allSettled(
2999
- deletedWorktrees.map(
4169
+ actions.map(
3000
4170
  ({ branch: branchName, path: worktreePath }) => limit(async () => {
3001
4171
  const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
3002
4172
  return { branchName, worktreePath, status };
@@ -3019,6 +4189,10 @@ var WorktreeSyncService = class {
3019
4189
  const branchName = result.reason?.branchName ?? "unknown";
3020
4190
  this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
3021
4191
  this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
4192
+ outcome.recordSkipped("worktree", "prune_status_check_failed", {
4193
+ branch: branchName,
4194
+ message: getErrorMessage(result.reason)
4195
+ });
3022
4196
  }
3023
4197
  }
3024
4198
  if (toRemove.length > 0) {
@@ -3034,12 +4208,23 @@ var WorktreeSyncService = class {
3034
4208
  this.logger.warn(
3035
4209
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
3036
4210
  );
4211
+ outcome.recordSkipped("worktree", "prune_status_changed", {
4212
+ branch: branchName,
4213
+ path: worktreePath,
4214
+ message: recheck.reasons.join(", ")
4215
+ });
3037
4216
  return;
3038
4217
  }
3039
4218
  await this.gitService.removeWorktree(worktreePath);
3040
4219
  this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
4220
+ outcome.recordRemoved(branchName, worktreePath);
3041
4221
  } catch (error) {
3042
4222
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
4223
+ outcome.recordFailed("worktree", getErrorMessage(error), {
4224
+ reason: "remove_failed",
4225
+ branch: branchName,
4226
+ path: worktreePath
4227
+ });
3043
4228
  throw error;
3044
4229
  }
3045
4230
  })
@@ -3052,6 +4237,11 @@ var WorktreeSyncService = class {
3052
4237
  this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
3053
4238
  }
3054
4239
  for (const { branchName, worktreePath, status } of toSkip) {
4240
+ outcome.recordSkipped("worktree", "unsafe_to_remove", {
4241
+ branch: branchName,
4242
+ path: worktreePath,
4243
+ message: status.reasons.join(", ")
4244
+ });
3055
4245
  if (status.upstreamGone && status.hasUnpushedCommits) {
3056
4246
  this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
3057
4247
  this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
@@ -3144,53 +4334,52 @@ var WorktreeSyncService = class {
3144
4334
  this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
3145
4335
  }
3146
4336
  }
3147
- async updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer) {
4337
+ async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
3148
4338
  const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
3149
4339
  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);
4340
+ this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
4341
+ await this.updateExistingWorktrees(actions, outcome);
4342
+ phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
3154
4343
  phaseTimer.endPhase();
3155
4344
  }
3156
- async updateExistingWorktrees(worktrees, remoteBranches) {
4345
+ async updateExistingWorktrees(actions, outcome) {
3157
4346
  this.logger.info("Step 4: Checking for worktrees that need updates...");
3158
- const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4347
+ const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3159
4348
  try {
3160
- const diverged = await fs6.readdir(divergedDir);
4349
+ const diverged = await fs9.readdir(divergedDir);
3161
4350
  if (diverged.length > 0) {
3162
4351
  this.logger.info(
3163
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
4352
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
3164
4353
  );
3165
4354
  }
3166
4355
  } catch {
3167
4356
  }
3168
- const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
3169
4357
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
3170
4358
  const limit = pLimit(maxConcurrent);
3171
4359
  const checkResults = await Promise.allSettled(
3172
- activeWorktrees.map(
3173
- (worktree) => limit(async () => {
4360
+ actions.map(
4361
+ (action) => limit(async () => {
4362
+ const worktree = { path: action.path, branch: action.branch };
3174
4363
  try {
3175
- await fs6.access(worktree.path);
4364
+ await fs9.access(worktree.path);
3176
4365
  } catch {
3177
- return null;
4366
+ return { action: "skip", worktree, reason: "missing_worktree_path" };
3178
4367
  }
3179
4368
  const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
3180
- if (hasOp) return null;
4369
+ if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
3181
4370
  const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
3182
- if (!isClean) return null;
4371
+ if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
3183
4372
  const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
3184
4373
  if (!canFastForward) {
3185
4374
  const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
3186
4375
  if (isAhead) {
3187
4376
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
3188
- return null;
4377
+ return { action: "skip", worktree, reason: "local_ahead" };
3189
4378
  }
3190
4379
  return { action: "diverged", worktree };
3191
4380
  }
3192
4381
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
3193
- if (!isBehind) return null;
4382
+ if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
3194
4383
  const sparseCfg = this.config.sparseCheckout;
3195
4384
  if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
3196
4385
  const sparseService = this.gitService.getSparseCheckoutService();
@@ -3202,7 +4391,7 @@ var WorktreeSyncService = class {
3202
4391
  );
3203
4392
  if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
3204
4393
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
3205
- return null;
4394
+ return { action: "skip", worktree, reason: "outside_sparse_checkout" };
3206
4395
  }
3207
4396
  }
3208
4397
  }
@@ -3214,13 +4403,25 @@ var WorktreeSyncService = class {
3214
4403
  const divergedWorktrees = [];
3215
4404
  for (const result of checkResults) {
3216
4405
  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);
4406
+ switch (result.value.action) {
4407
+ case "update":
4408
+ worktreesToUpdate.push(result.value.worktree);
4409
+ break;
4410
+ case "diverged":
4411
+ divergedWorktrees.push(result.value.worktree);
4412
+ break;
4413
+ case "noop":
4414
+ outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
4415
+ break;
4416
+ case "skip":
4417
+ outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
4418
+ break;
3221
4419
  }
3222
4420
  } else if (result.status === "rejected") {
3223
4421
  this.logger.error(` - Error checking worktree:`, result.reason);
4422
+ outcome.recordSkipped("worktree", "update_check_failed", {
4423
+ message: getErrorMessage(result.reason)
4424
+ });
3224
4425
  }
3225
4426
  }
3226
4427
  const updateLimit = pLimit(
@@ -3234,6 +4435,7 @@ var WorktreeSyncService = class {
3234
4435
  this.logger.info(` - Updating worktree '${worktree.branch}'...`);
3235
4436
  await this.gitService.updateWorktree(worktree.path);
3236
4437
  this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
4438
+ outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
3237
4439
  } catch (error) {
3238
4440
  const errorMessage = getErrorMessage(error);
3239
4441
  if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
@@ -3241,13 +4443,23 @@ var WorktreeSyncService = class {
3241
4443
  ` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
3242
4444
  );
3243
4445
  try {
3244
- await this.handleDivergedBranch(worktree);
4446
+ await this.handleDivergedBranch(worktree, outcome);
3245
4447
  } catch (divergedError) {
3246
4448
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
4449
+ outcome.recordFailed("worktree", getErrorMessage(divergedError), {
4450
+ reason: "diverged_recovery_failed",
4451
+ branch: worktree.branch,
4452
+ path: worktree.path
4453
+ });
3247
4454
  throw divergedError;
3248
4455
  }
3249
4456
  } else {
3250
4457
  this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
4458
+ outcome.recordFailed("worktree", errorMessage, {
4459
+ reason: "update_failed",
4460
+ branch: worktree.branch,
4461
+ path: worktree.path
4462
+ });
3251
4463
  throw error;
3252
4464
  }
3253
4465
  }
@@ -3259,9 +4471,14 @@ var WorktreeSyncService = class {
3259
4471
  mutationTasks.push(
3260
4472
  updateLimit(async () => {
3261
4473
  try {
3262
- await this.handleDivergedBranch(worktree);
4474
+ await this.handleDivergedBranch(worktree, outcome);
3263
4475
  } catch (error) {
3264
4476
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
4477
+ outcome.recordFailed("worktree", getErrorMessage(error), {
4478
+ reason: "diverged_recovery_failed",
4479
+ branch: worktree.branch,
4480
+ path: worktree.path
4481
+ });
3265
4482
  throw error;
3266
4483
  }
3267
4484
  return { type: "diverged", branch: worktree.branch };
@@ -3284,13 +4501,13 @@ var WorktreeSyncService = class {
3284
4501
  }
3285
4502
  async cleanupOrphanedDirectories(worktrees) {
3286
4503
  try {
3287
- const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
3288
- const allDirs = await fs6.readdir(this.config.worktreeDir);
4504
+ const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
4505
+ const allDirs = await fs9.readdir(this.config.worktreeDir);
3289
4506
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
3290
4507
  const orphanedDirs = [];
3291
4508
  for (const dir of regularDirs) {
3292
4509
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
3293
- return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
4510
+ return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
3294
4511
  });
3295
4512
  if (!isPartOfWorktree) {
3296
4513
  orphanedDirs.push(dir);
@@ -3299,11 +4516,11 @@ var WorktreeSyncService = class {
3299
4516
  if (orphanedDirs.length > 0) {
3300
4517
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
3301
4518
  for (const dir of orphanedDirs) {
3302
- const dirPath = path8.join(this.config.worktreeDir, dir);
4519
+ const dirPath = path13.join(this.config.worktreeDir, dir);
3303
4520
  try {
3304
- const stat4 = await fs6.stat(dirPath);
4521
+ const stat4 = await fs9.stat(dirPath);
3305
4522
  if (stat4.isDirectory()) {
3306
- await fs6.rm(dirPath, { recursive: true, force: true });
4523
+ await fs9.rm(dirPath, { recursive: true, force: true });
3307
4524
  this.logger.info(` - Removed orphaned directory: ${dir}`);
3308
4525
  }
3309
4526
  } catch (error) {
@@ -3315,13 +4532,14 @@ var WorktreeSyncService = class {
3315
4532
  this.logger.error("Error during orphaned directory cleanup:", error);
3316
4533
  }
3317
4534
  }
3318
- async handleDivergedBranch(worktree) {
4535
+ async handleDivergedBranch(worktree, outcome) {
3319
4536
  this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
3320
4537
  const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
3321
4538
  if (treesIdentical) {
3322
4539
  this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
3323
4540
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
3324
4541
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
4542
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
3325
4543
  } else {
3326
4544
  const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
3327
4545
  if (!hasLocalChanges) {
@@ -3330,10 +4548,12 @@ var WorktreeSyncService = class {
3330
4548
  );
3331
4549
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
3332
4550
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
4551
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
3333
4552
  } else {
3334
4553
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
3335
4554
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
3336
- const relativePath = path8.relative(process.cwd(), divergedPath);
4555
+ const relativePath = path13.relative(process.cwd(), divergedPath);
4556
+ outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
3337
4557
  this.logger.info(` Moved to: ${relativePath}`);
3338
4558
  this.logger.info(` Your local changes are preserved. To review:`);
3339
4559
  this.logger.info(` cd ${relativePath}`);
@@ -3357,19 +4577,19 @@ var WorktreeSyncService = class {
3357
4577
  }
3358
4578
  }
3359
4579
  async divergeWorktree(worktreePath, branchName) {
3360
- const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4580
+ const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3361
4581
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3362
4582
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
3363
4583
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
3364
4584
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
3365
- const divergedPath = path8.join(divergedBaseDir, divergedName);
3366
- await fs6.mkdir(divergedBaseDir, { recursive: true });
4585
+ const divergedPath = path13.join(divergedBaseDir, divergedName);
4586
+ await fs9.mkdir(divergedBaseDir, { recursive: true });
3367
4587
  try {
3368
- await fs6.rename(worktreePath, divergedPath);
4588
+ await fs9.rename(worktreePath, divergedPath);
3369
4589
  } catch (err) {
3370
4590
  if (err.code === ERROR_MESSAGES.EXDEV) {
3371
- await fs6.cp(worktreePath, divergedPath, { recursive: true });
3372
- await fs6.rm(worktreePath, { recursive: true, force: true });
4591
+ await fs9.cp(worktreePath, divergedPath, { recursive: true });
4592
+ await fs9.rm(worktreePath, { recursive: true, force: true });
3373
4593
  } else {
3374
4594
  throw err;
3375
4595
  }
@@ -3388,14 +4608,197 @@ var WorktreeSyncService = class {
3388
4608
 
3389
4609
  Original worktree location: ${worktreePath}`
3390
4610
  };
3391
- await fs6.writeFile(
3392
- path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4611
+ await fs9.writeFile(
4612
+ path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
3393
4613
  JSON.stringify(metadata, null, 2)
3394
4614
  );
3395
4615
  return divergedPath;
3396
4616
  }
3397
4617
  };
3398
4618
 
4619
+ // src/services/worktree-sync.service.ts
4620
+ var WorktreeSyncService = class {
4621
+ constructor(config) {
4622
+ this.config = config;
4623
+ this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
4624
+ this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
4625
+ this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
4626
+ this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
4627
+ this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
4628
+ config,
4629
+ this.gitService,
4630
+ this.logger,
4631
+ this.progressEmitter
4632
+ );
4633
+ if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
4634
+ this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
4635
+ progressEmitter: (event) => this.emitProgress(event),
4636
+ onSkip: (reason) => {
4637
+ this.skipsAccumulator.push(reason);
4638
+ }
4639
+ });
4640
+ }
4641
+ }
4642
+ gitService;
4643
+ cloneSyncService = null;
4644
+ logger;
4645
+ syncInProgress = false;
4646
+ progressEmitter = new ProgressEmitter();
4647
+ repoOperationLock;
4648
+ retryPolicy;
4649
+ worktreeModeSyncRunner;
4650
+ skipsAccumulator = [];
4651
+ lastOutcome = null;
4652
+ getRecordedSkips() {
4653
+ return [...this.skipsAccumulator];
4654
+ }
4655
+ clearRecordedSkips() {
4656
+ this.skipsAccumulator = [];
4657
+ }
4658
+ clearPendingInitSkip() {
4659
+ this.cloneSyncService?.clearPendingInitSkip();
4660
+ }
4661
+ getLastOutcome() {
4662
+ return this.lastOutcome;
4663
+ }
4664
+ isCloneMode() {
4665
+ return this.cloneSyncService !== null;
4666
+ }
4667
+ async getWorktrees() {
4668
+ if (this.cloneSyncService) {
4669
+ return this.cloneSyncService.getWorktrees();
4670
+ }
4671
+ return this.gitService.getWorktrees();
4672
+ }
4673
+ async initialize() {
4674
+ if (this.isInitialized()) return;
4675
+ const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
4676
+ if (!result.started) {
4677
+ const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
4678
+ this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
4679
+ }
4680
+ }
4681
+ async initializeUnlocked(outcome) {
4682
+ this.emitProgress({ phase: "initialize", message: "Initializing repository" });
4683
+ if (this.cloneSyncService) {
4684
+ await this.cloneSyncService.initialize(outcome);
4685
+ } else {
4686
+ await this.gitService.initialize();
4687
+ }
4688
+ this.emitProgress({ phase: "initialize", message: "Repository initialized" });
4689
+ }
4690
+ isInitialized() {
4691
+ if (this.cloneSyncService) {
4692
+ return this.cloneSyncService.isInitialized();
4693
+ }
4694
+ return this.gitService.isInitialized();
4695
+ }
4696
+ isSyncInProgress() {
4697
+ return this.syncInProgress;
4698
+ }
4699
+ getGitService() {
4700
+ return this.gitService;
4701
+ }
4702
+ updateLogger(logger) {
4703
+ this.logger = logger;
4704
+ this.gitService.updateLogger(logger);
4705
+ this.cloneSyncService?.updateLogger(logger);
4706
+ this.retryPolicy.updateLogger(logger);
4707
+ this.worktreeModeSyncRunner.updateLogger(logger);
4708
+ this.repoOperationLock.updateLogger(logger);
4709
+ }
4710
+ onProgress(listener) {
4711
+ return this.progressEmitter.onProgress(listener);
4712
+ }
4713
+ async runExclusiveRepoOperation(operation) {
4714
+ if (this.syncInProgress) {
4715
+ this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
4716
+ return { started: false, reason: "in_progress" };
4717
+ }
4718
+ this.syncInProgress = true;
4719
+ let release;
4720
+ try {
4721
+ release = await this.repoOperationLock.acquire();
4722
+ } catch (error) {
4723
+ this.syncInProgress = false;
4724
+ throw error;
4725
+ }
4726
+ if (release === null) {
4727
+ this.syncInProgress = false;
4728
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
4729
+ return { started: false, reason: "locked" };
4730
+ }
4731
+ try {
4732
+ return { started: true, value: await operation() };
4733
+ } finally {
4734
+ try {
4735
+ await release();
4736
+ } catch (releaseError) {
4737
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
4738
+ }
4739
+ this.syncInProgress = false;
4740
+ }
4741
+ }
4742
+ emitProgress(event) {
4743
+ this.progressEmitter.emit(event);
4744
+ }
4745
+ async sync() {
4746
+ const result = await this.runExclusiveRepoOperation(async () => {
4747
+ const totalTimer = new Timer();
4748
+ const phaseTimer = new PhaseTimer();
4749
+ const outcome = new SyncOutcomeAccumulator({
4750
+ mode: this.cloneSyncService ? "clone" : "worktree",
4751
+ repoName: this.config.name
4752
+ });
4753
+ const syncContext = this.retryPolicy.createContext();
4754
+ const retryOptions = this.retryPolicy.createOptions(syncContext);
4755
+ let durationMs;
4756
+ try {
4757
+ if (!this.isInitialized()) {
4758
+ await this.initializeUnlocked(outcome);
4759
+ }
4760
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
4761
+ const retryOutcomeBaseline = outcome.snapshot();
4762
+ const retryOptionsWithOutcomeReset = {
4763
+ ...retryOptions,
4764
+ onRetry: (error, attempt, context) => {
4765
+ outcome.restore(retryOutcomeBaseline);
4766
+ retryOptions.onRetry?.(error, attempt, context);
4767
+ }
4768
+ };
4769
+ const cloneSync = this.cloneSyncService;
4770
+ if (cloneSync) {
4771
+ await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
4772
+ } else {
4773
+ await retry(
4774
+ () => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
4775
+ retryOptionsWithOutcomeReset
4776
+ );
4777
+ }
4778
+ } catch (error) {
4779
+ if (outcome.getCounts().failed === 0) {
4780
+ outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
4781
+ }
4782
+ this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
4783
+ throw error;
4784
+ } finally {
4785
+ this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
4786
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
4787
+ `);
4788
+ durationMs = totalTimer.stop();
4789
+ this.lastOutcome = outcome.toOutcome(durationMs);
4790
+ if (this.config.debug) {
4791
+ const phaseResults = phaseTimer.getResults();
4792
+ const repoName = this.config.name;
4793
+ this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
4794
+ }
4795
+ }
4796
+ return this.lastOutcome ?? outcome.toOutcome(durationMs);
4797
+ });
4798
+ return result.started ? { started: true, outcome: result.value } : result;
4799
+ }
4800
+ };
4801
+
3399
4802
  // src/mcp/context.ts
3400
4803
  var AUTO_DETECT_PREFIX = "__auto_detected__:";
3401
4804
  var DISCOVERY_CACHE_TTL_MS = 5e3;
@@ -3442,11 +4845,18 @@ var RepositoryContext = class {
3442
4845
  configPath = null;
3443
4846
  configLoader = new ConfigLoaderService();
3444
4847
  discoveryCache = /* @__PURE__ */ new Map();
4848
+ launchCwd;
4849
+ constructor(options = {}) {
4850
+ this.launchCwd = path14.resolve(options.launchCwd ?? process.cwd());
4851
+ }
4852
+ getLaunchCwd() {
4853
+ return this.launchCwd;
4854
+ }
3445
4855
  async loadConfig(configPath, options = {}) {
3446
4856
  const setDefaultCurrent = options.setDefaultCurrent ?? true;
3447
- const absolutePath = path9.resolve(configPath);
4857
+ const absolutePath = path14.resolve(configPath);
3448
4858
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
3449
- const configDir = path9.dirname(absolutePath);
4859
+ const configDir = path14.dirname(absolutePath);
3450
4860
  const globalDefaults = configFile.defaults;
3451
4861
  const resolvedAll = [];
3452
4862
  for (const repo of configFile.repositories) {
@@ -3483,7 +4893,7 @@ var RepositoryContext = class {
3483
4893
  return configFile.repositories;
3484
4894
  }
3485
4895
  async detectFromPath(dirPath) {
3486
- const absolutePath = path9.resolve(dirPath);
4896
+ const absolutePath = path14.resolve(dirPath);
3487
4897
  const cached = this.discoveryCache.get(absolutePath);
3488
4898
  if (cached && await this.isCacheFresh(cached)) {
3489
4899
  return cached.result;
@@ -3502,8 +4912,8 @@ var RepositoryContext = class {
3502
4912
  const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
3503
4913
  if (result.isWorktree && result.bareRepoPath && adminDir) {
3504
4914
  const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
3505
- safeMtimeMs(path9.join(adminDir, "HEAD")),
3506
- safeMtimeMs(path9.join(result.bareRepoPath, "worktrees"))
4915
+ safeMtimeMs(path14.join(adminDir, "HEAD")),
4916
+ safeMtimeMs(path14.join(result.bareRepoPath, "worktrees"))
3507
4917
  ]);
3508
4918
  this.discoveryCache.set(absolutePath, {
3509
4919
  result,
@@ -3534,12 +4944,16 @@ var RepositoryContext = class {
3534
4944
  __discoveryCacheSizeForTest() {
3535
4945
  return this.discoveryCache.size;
3536
4946
  }
4947
+ /** @internal Test-only helper — exposes the internal selection state. */
4948
+ __getRepositorySelectionStateForTest() {
4949
+ return this.getRepositorySelectionState();
4950
+ }
3537
4951
  async discoverSiblingRepositories(currentBareRepoPath) {
3538
4952
  const currentBare = normalizePathForCompare(currentBareRepoPath);
3539
4953
  const results = /* @__PURE__ */ new Map();
3540
4954
  const byName = (a, b) => a.name.localeCompare(b.name);
3541
4955
  const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
3542
- const bareRepoPath = path9.resolve(entry.config.bareRepoDir);
4956
+ const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
3543
4957
  return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
3544
4958
  }).filter((c) => c.foldedBare !== currentBare);
3545
4959
  const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
@@ -3547,7 +4961,7 @@ var RepositoryContext = class {
3547
4961
  const sibling = {
3548
4962
  name: entry.name,
3549
4963
  bareRepoPath,
3550
- worktreeDir: path9.resolve(entry.config.worktreeDir),
4964
+ worktreeDir: path14.resolve(entry.config.worktreeDir),
3551
4965
  repoUrl: entry.config.repoUrl,
3552
4966
  present: configPresence[i],
3553
4967
  configMatched: true
@@ -3557,24 +4971,24 @@ var RepositoryContext = class {
3557
4971
  }
3558
4972
  results.set(foldedBare, sibling);
3559
4973
  });
3560
- const repoDir = path9.dirname(currentBareRepoPath);
3561
- const workspaceRoot = path9.dirname(repoDir);
4974
+ const repoDir = path14.dirname(currentBareRepoPath);
4975
+ const workspaceRoot = path14.dirname(repoDir);
3562
4976
  if (workspaceRoot === repoDir) {
3563
4977
  return Array.from(results.values()).sort(byName);
3564
4978
  }
3565
4979
  let entries;
3566
4980
  try {
3567
- entries = await fs7.readdir(workspaceRoot);
4981
+ entries = await fs10.readdir(workspaceRoot);
3568
4982
  } catch {
3569
4983
  return Array.from(results.values()).sort(byName);
3570
4984
  }
3571
4985
  const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
3572
4986
  await Promise.all(
3573
4987
  entries.map(async (entry) => {
3574
- const candidate = path9.join(workspaceRoot, entry);
3575
- const bareCandidate = path9.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
4988
+ const candidate = path14.join(workspaceRoot, entry);
4989
+ const bareCandidate = path14.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3576
4990
  if (!await isDirectory(bareCandidate)) return;
3577
- const resolvedBare = path9.resolve(bareCandidate);
4991
+ const resolvedBare = path14.resolve(bareCandidate);
3578
4992
  const foldedBare = normalizePathForCompare(resolvedBare);
3579
4993
  if (foldedBare === currentBare || results.has(foldedBare)) return;
3580
4994
  const matchedName = configBares.get(foldedBare);
@@ -3600,8 +5014,8 @@ var RepositoryContext = class {
3600
5014
  if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
3601
5015
  if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
3602
5016
  const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
3603
- safeMtimeMs(path9.join(cached.worktreeAdminDir, "HEAD")),
3604
- safeMtimeMs(path9.join(cached.result.bareRepoPath, "worktrees"))
5017
+ safeMtimeMs(path14.join(cached.worktreeAdminDir, "HEAD")),
5018
+ safeMtimeMs(path14.join(cached.result.bareRepoPath, "worktrees"))
3605
5019
  ]);
3606
5020
  return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
3607
5021
  }
@@ -3634,6 +5048,13 @@ var RepositoryContext = class {
3634
5048
  return unsupported("No .git file found in path or any parent directory");
3635
5049
  }
3636
5050
  if (located.kind === "regular-git-dir") {
5051
+ const cloneEntry = this.findConfiguredCloneEntry(worktreeRoot);
5052
+ if (cloneEntry) {
5053
+ return {
5054
+ result: await this.buildCloneModeContext(cloneEntry, worktreeRoot, notes),
5055
+ adminDir: null
5056
+ };
5057
+ }
3637
5058
  return unsupported("Directory has .git folder (regular repo, not a sync-worktrees worktree)");
3638
5059
  }
3639
5060
  const gitFileContent = located.gitFileContent;
@@ -3642,18 +5063,18 @@ var RepositoryContext = class {
3642
5063
  return unsupported("Invalid .git file format (missing gitdir line)");
3643
5064
  }
3644
5065
  const gitdir = gitdirMatch[1].trim();
3645
- const resolvedGitdir = path9.isAbsolute(gitdir) ? gitdir : path9.resolve(worktreeRoot, gitdir);
5066
+ const resolvedGitdir = path14.isAbsolute(gitdir) ? gitdir : path14.resolve(worktreeRoot, gitdir);
3646
5067
  const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
3647
5068
  if (!worktreesMatch) {
3648
5069
  return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
3649
5070
  }
3650
- const bareRepoPath = path9.resolve(worktreesMatch[1]);
3651
- const adminDir = path9.resolve(resolvedGitdir);
5071
+ const bareRepoPath = path14.resolve(worktreesMatch[1]);
5072
+ const adminDir = path14.resolve(resolvedGitdir);
3652
5073
  let repoUrl = null;
3653
5074
  let worktrees = [];
3654
5075
  let currentBranch = null;
3655
5076
  try {
3656
- const bareGit = simpleGit5(bareRepoPath);
5077
+ const bareGit = simpleGit6(bareRepoPath);
3657
5078
  try {
3658
5079
  const remoteResult = await bareGit.remote(["get-url", "origin"]);
3659
5080
  const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
@@ -3689,7 +5110,7 @@ var RepositoryContext = class {
3689
5110
  adminDir
3690
5111
  };
3691
5112
  }
3692
- const worktreeDir = path9.dirname(worktreeRoot);
5113
+ const worktreeDir = path14.dirname(worktreeRoot);
3693
5114
  const noUrlReason = "no remote origin URL detected";
3694
5115
  const capabilities = {
3695
5116
  listWorktrees: { available: true },
@@ -3725,7 +5146,7 @@ var RepositoryContext = class {
3725
5146
  cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
3726
5147
  runOnce: true
3727
5148
  };
3728
- const detectedKey = `${AUTO_DETECT_PREFIX}${path9.basename(bareRepoPath)}@${bareRepoPath}`;
5149
+ const detectedKey = `${AUTO_DETECT_PREFIX}${path14.basename(bareRepoPath)}@${bareRepoPath}`;
3729
5150
  if (!this.repos.has(detectedKey)) {
3730
5151
  this.repos.set(detectedKey, {
3731
5152
  name: detectedKey,
@@ -3766,13 +5187,19 @@ var RepositoryContext = class {
3766
5187
  return { result: discovered, adminDir };
3767
5188
  }
3768
5189
  async getService(repoName) {
5190
+ if (repoName) {
5191
+ const explicit = this.selectExplicitRepository(repoName);
5192
+ if (explicit.kind !== "selected") {
5193
+ throw new Error(this.buildRepoNotFoundError(repoName));
5194
+ }
5195
+ }
3769
5196
  const name = repoName ?? this.currentRepo;
3770
5197
  if (!name) {
3771
- throw new Error("No repository specified and no current repository set");
5198
+ throw new Error(this.buildNoRepoSelectedError());
3772
5199
  }
3773
5200
  const entry = this.repos.get(name);
3774
5201
  if (!entry) {
3775
- throw new Error(`Repository '${name}' not found. Load a config or run detect_context first.`);
5202
+ throw new Error(this.buildRepoNotFoundError(name));
3776
5203
  }
3777
5204
  if (!entry.service) {
3778
5205
  const logger = createStderrLogger(entry.name);
@@ -3783,6 +5210,93 @@ var RepositoryContext = class {
3783
5210
  }
3784
5211
  return entry.service;
3785
5212
  }
5213
+ getRepositorySelectionState() {
5214
+ const configured = this.getConfiguredRepositoryNames();
5215
+ const detected = this.getDetectedRepositoryNames();
5216
+ return {
5217
+ currentRepo: this.currentRepo,
5218
+ configured,
5219
+ detected,
5220
+ defaultDecision: this.selectDefaultRepository(configured, detected)
5221
+ };
5222
+ }
5223
+ selectExplicitRepository(repoName) {
5224
+ if (this.repos.has(repoName)) {
5225
+ return { kind: "selected", repoName, source: "explicit" };
5226
+ }
5227
+ return {
5228
+ kind: "missing",
5229
+ configured: this.getConfiguredRepositoryNames(),
5230
+ detected: this.getDetectedRepositoryNames(),
5231
+ reason: `Repository '${repoName}' not found`
5232
+ };
5233
+ }
5234
+ selectDefaultRepository(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
5235
+ if (this.currentRepo !== null) {
5236
+ return { kind: "selected", repoName: this.currentRepo, source: "current" };
5237
+ }
5238
+ if (this.canAutoSelectSingleConfig(configured, detected)) {
5239
+ return { kind: "selected", repoName: configured[0], source: "single-config" };
5240
+ }
5241
+ if (configured.length === 0 && detected.length === 0) {
5242
+ return {
5243
+ kind: "missing",
5244
+ configured,
5245
+ detected,
5246
+ reason: "no configured or detected repositories are registered"
5247
+ };
5248
+ }
5249
+ return {
5250
+ kind: "ambiguous",
5251
+ configured,
5252
+ detected,
5253
+ reason: "repository selection is ambiguous without currentRepo or explicit repoName"
5254
+ };
5255
+ }
5256
+ canAutoSelectSingleConfig(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
5257
+ return this.currentRepo === null && configured.length === 1 && detected.length === 0;
5258
+ }
5259
+ getDetectedRepositoryNames() {
5260
+ return Array.from(this.repos.values()).filter((entry) => entry.source === "detected").map((entry) => entry.name);
5261
+ }
5262
+ formatDetectedRepositoryNames() {
5263
+ return Array.from(this.repos.values()).filter((e) => e.source === "detected").map((e) => {
5264
+ const location = e.discovered?.currentWorktreePath ?? e.config.bareRepoDir ?? e.config.worktreeDir;
5265
+ return location ? `${e.name} (${location})` : e.name;
5266
+ });
5267
+ }
5268
+ formatKnownRepositoryNames(names) {
5269
+ return names.length === 0 ? "[]" : `[${names.join(", ")}]`;
5270
+ }
5271
+ buildNoRepoSelectedError() {
5272
+ const selection = this.getRepositorySelectionState();
5273
+ const detected = this.formatDetectedRepositoryNames();
5274
+ const parts = [
5275
+ "No repository specified and no current repository set.",
5276
+ `launchCwd=${this.launchCwd}`,
5277
+ `configPath=${this.configPath ?? "none"}`,
5278
+ `loadedRepos=${this.repos.size} (config: ${selection.configured.length}, detected: ${selection.detected.length})`
5279
+ ];
5280
+ if (detected.length > 0) {
5281
+ parts.push(`Detected repos: ${this.formatKnownRepositoryNames(detected)}.`);
5282
+ }
5283
+ if (selection.configured.length > 0) {
5284
+ parts.push(`Configured repos: ${this.formatKnownRepositoryNames(selection.configured)}.`);
5285
+ }
5286
+ if (selection.configured.length > 0 || detected.length > 0) {
5287
+ parts.push("Recovery: call set_current_repository with one of the repo names above or pass repoName explicitly.");
5288
+ } else {
5289
+ parts.push(
5290
+ "Recovery: call detect_context {path: <workspace>}, load_config {configPath: <file>}, set SYNC_WORKTREES_CONFIG env var, or pass repoName explicitly."
5291
+ );
5292
+ }
5293
+ return parts.join(" ");
5294
+ }
5295
+ buildRepoNotFoundError(name) {
5296
+ const known = Array.from(this.repos.keys());
5297
+ const knownStr = this.formatKnownRepositoryNames(known);
5298
+ return `Repository '${name}' not found. Known repos: ${knownStr}. Run load_config or detect_context to register it.`;
5299
+ }
3786
5300
  getEntry(repoName) {
3787
5301
  const name = repoName ?? this.currentRepo;
3788
5302
  if (!name) return null;
@@ -3812,6 +5326,58 @@ var RepositoryContext = class {
3812
5326
  getConfiguredRepositoryNames() {
3813
5327
  return Array.from(this.repos.values()).filter((entry) => entry.source === "config").map((entry) => entry.name);
3814
5328
  }
5329
+ async getConfiguredRepositorySummaries(options = {}) {
5330
+ const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
5331
+ const currentRepo = this.currentRepo;
5332
+ const buildLean = (entry) => {
5333
+ const mode = resolveMode(entry.config);
5334
+ const isCurrent = entry.name === currentRepo;
5335
+ if (mode === REPOSITORY_MODES.CLONE) {
5336
+ return { name: entry.name, mode: "clone", checkoutPath: path14.resolve(entry.config.worktreeDir), isCurrent };
5337
+ }
5338
+ return { name: entry.name, mode: "worktree", worktreeDir: path14.resolve(entry.config.worktreeDir), isCurrent };
5339
+ };
5340
+ if (!options.detailed) {
5341
+ return entries.map(buildLean);
5342
+ }
5343
+ const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5344
+ return Promise.all(
5345
+ entries.map(
5346
+ (entry) => limit(async () => {
5347
+ const summary = buildLean(entry);
5348
+ summary.repoUrl = entry.config.repoUrl;
5349
+ if (entry.config.branch) summary.branch = entry.config.branch;
5350
+ if (entry.config.sparseCheckout) {
5351
+ const sc = entry.config.sparseCheckout;
5352
+ summary.sparseCheckout = {
5353
+ ...sc,
5354
+ include: [...sc.include],
5355
+ ...sc.exclude ? { exclude: [...sc.exclude] } : {}
5356
+ };
5357
+ }
5358
+ if (summary.mode === "clone") {
5359
+ summary.localReady = await isGitCheckout(summary.checkoutPath);
5360
+ return summary;
5361
+ }
5362
+ if (entry.config.bareRepoDir) {
5363
+ summary.bareRepoDir = path14.resolve(entry.config.bareRepoDir);
5364
+ summary.localReady = await isDirectory(summary.bareRepoDir);
5365
+ } else {
5366
+ summary.localReady = false;
5367
+ }
5368
+ return summary;
5369
+ })
5370
+ )
5371
+ );
5372
+ }
5373
+ autoSelectCurrentRepoIfSingleConfig() {
5374
+ const decision = this.selectDefaultRepository();
5375
+ if (decision.kind !== "selected") return null;
5376
+ if (decision.source === "single-config") {
5377
+ this.currentRepo = decision.repoName;
5378
+ }
5379
+ return this.currentRepo;
5380
+ }
3815
5381
  async getAllConfiguredWorktreeDetails(currentWorktreePath = null) {
3816
5382
  const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
3817
5383
  const results = await Promise.all(
@@ -3834,22 +5400,93 @@ var RepositoryContext = class {
3834
5400
  return this.configPath;
3835
5401
  }
3836
5402
  async readConfiguredWorktrees(entry, currentWorktreePath) {
5403
+ if (entry.source === "config" && resolveMode(entry.config) === REPOSITORY_MODES.CLONE) {
5404
+ return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
5405
+ }
3837
5406
  if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
3838
- const bareRepoPath = path9.resolve(entry.config.bareRepoDir);
5407
+ const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
3839
5408
  if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
3840
5409
  try {
3841
- const output = await simpleGit5(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
5410
+ const output = await simpleGit6(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
3842
5411
  return { worktrees: parseWorktreeList(output, currentWorktreePath) };
3843
5412
  } catch (err) {
3844
5413
  return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
3845
5414
  }
3846
5415
  }
5416
+ findConfiguredCloneEntry(worktreeRoot) {
5417
+ const foldedRoot = normalizePathForCompare(path14.resolve(worktreeRoot));
5418
+ for (const entry of this.repos.values()) {
5419
+ if (entry.source !== "config" || resolveMode(entry.config) !== REPOSITORY_MODES.CLONE) continue;
5420
+ if (normalizePathForCompare(path14.resolve(entry.config.worktreeDir)) === foldedRoot) {
5421
+ return entry;
5422
+ }
5423
+ }
5424
+ return null;
5425
+ }
5426
+ async buildCloneModeContext(entry, worktreeRoot, notes) {
5427
+ const resolvedRoot = path14.resolve(worktreeRoot);
5428
+ let currentBranch = null;
5429
+ try {
5430
+ currentBranch = await readCurrentBranch(resolvedRoot);
5431
+ } catch (err) {
5432
+ notes.push(`Could not read clone-mode branch: ${err instanceof Error ? err.message : String(err)}`);
5433
+ }
5434
+ const branch = currentBranch ?? "unknown";
5435
+ const cloneModeReason = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
5436
+ const capabilities = {
5437
+ listWorktrees: { available: true },
5438
+ getStatus: { available: true },
5439
+ createWorktree: { available: false, reason: cloneModeReason },
5440
+ removeWorktree: { available: false, reason: cloneModeReason },
5441
+ updateWorktree: { available: false, reason: cloneModeReason },
5442
+ sync: { available: true },
5443
+ initialize: { available: true }
5444
+ };
5445
+ const discovered = {
5446
+ isWorktree: true,
5447
+ kind: "managed",
5448
+ currentBranch,
5449
+ currentWorktreePath: resolvedRoot,
5450
+ bareRepoPath: null,
5451
+ repoUrl: entry.config.repoUrl,
5452
+ worktreeDir: resolvedRoot,
5453
+ allWorktrees: [{ path: resolvedRoot, branch, isCurrent: true }],
5454
+ siblingRepositories: [],
5455
+ configPath: this.configPath,
5456
+ repoName: entry.name,
5457
+ capabilities,
5458
+ notes
5459
+ };
5460
+ entry.discovered = discovered;
5461
+ this.bootstrapCurrentRepo(entry.name, true);
5462
+ return discovered;
5463
+ }
5464
+ async readConfiguredCloneWorktree(entry, currentWorktreePath) {
5465
+ const worktreePath = path14.resolve(entry.config.worktreeDir);
5466
+ if (!await isDirectory(worktreePath) || !await hasGitMetadata(worktreePath)) {
5467
+ return { worktrees: [] };
5468
+ }
5469
+ try {
5470
+ const branch = await readCurrentBranch(worktreePath);
5471
+ return {
5472
+ worktrees: [
5473
+ {
5474
+ path: worktreePath,
5475
+ branch,
5476
+ isCurrent: currentWorktreePath !== null && normalizePathForCompare(worktreePath) === normalizePathForCompare(currentWorktreePath)
5477
+ }
5478
+ ]
5479
+ };
5480
+ } catch (err) {
5481
+ return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
5482
+ }
5483
+ }
3847
5484
  };
3848
5485
  function parseWorktreeList(output, currentPath) {
3849
5486
  const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
3850
5487
  const results = [];
3851
5488
  for (const wt of parseWorktreeListPorcelain(output)) {
3852
- const resolved = path9.resolve(wt.path);
5489
+ const resolved = path14.resolve(wt.path);
3853
5490
  const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
3854
5491
  if (!branch) continue;
3855
5492
  results.push({
@@ -3862,7 +5499,7 @@ function parseWorktreeList(output, currentPath) {
3862
5499
  }
3863
5500
  async function safeMtimeMs(filePath) {
3864
5501
  try {
3865
- const stat4 = await fs7.stat(filePath);
5502
+ const stat4 = await fs10.stat(filePath);
3866
5503
  return stat4.mtimeMs;
3867
5504
  } catch {
3868
5505
  return null;
@@ -3870,19 +5507,45 @@ async function safeMtimeMs(filePath) {
3870
5507
  }
3871
5508
  async function isDirectory(filePath) {
3872
5509
  try {
3873
- const stat4 = await fs7.stat(filePath);
5510
+ const stat4 = await fs10.stat(filePath);
3874
5511
  return stat4.isDirectory();
3875
5512
  } catch {
3876
5513
  return false;
3877
5514
  }
3878
5515
  }
5516
+ async function hasGitMetadata(worktreePath) {
5517
+ try {
5518
+ await fs10.stat(path14.join(worktreePath, ".git"));
5519
+ return true;
5520
+ } catch {
5521
+ return false;
5522
+ }
5523
+ }
5524
+ async function isGitCheckout(checkoutPath) {
5525
+ if (!await isDirectory(checkoutPath)) return false;
5526
+ try {
5527
+ const inside = (await simpleGit6(checkoutPath).raw(["rev-parse", "--is-inside-work-tree"])).trim();
5528
+ return inside === "true";
5529
+ } catch {
5530
+ return false;
5531
+ }
5532
+ }
5533
+ async function readCurrentBranch(worktreePath) {
5534
+ const git = simpleGit6(worktreePath);
5535
+ const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
5536
+ if (branch && branch !== "HEAD") {
5537
+ return branch;
5538
+ }
5539
+ const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
5540
+ return head ? `(detached ${head})` : "(detached)";
5541
+ }
3879
5542
  async function findWorktreeRoot(startPath) {
3880
- let current = path9.resolve(startPath);
3881
- const root = path9.parse(current).root;
5543
+ let current = path14.resolve(startPath);
5544
+ const root = path14.parse(current).root;
3882
5545
  while (true) {
3883
- const gitPath = path9.join(current, ".git");
5546
+ const gitPath = path14.join(current, ".git");
3884
5547
  try {
3885
- const content = await fs7.readFile(gitPath, "utf-8");
5548
+ const content = await fs10.readFile(gitPath, "utf-8");
3886
5549
  return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
3887
5550
  } catch (err) {
3888
5551
  const code = err.code;
@@ -3894,7 +5557,7 @@ async function findWorktreeRoot(startPath) {
3894
5557
  }
3895
5558
  }
3896
5559
  if (current === root) return null;
3897
- const parent = path9.dirname(current);
5560
+ const parent = path14.dirname(current);
3898
5561
  if (parent === current) return null;
3899
5562
  current = parent;
3900
5563
  }
@@ -3905,13 +5568,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3905
5568
  import { z } from "zod";
3906
5569
 
3907
5570
  // src/mcp/handlers.ts
3908
- import * as path10 from "path";
3909
- import pLimit2 from "p-limit";
5571
+ import * as path15 from "path";
5572
+ import pLimit3 from "p-limit";
3910
5573
 
3911
5574
  // src/utils/disk-space.ts
3912
5575
  import fastFolderSize from "fast-folder-size";
3913
5576
  async function calculateDirectorySize(dirPath) {
3914
- return new Promise((resolve9, reject) => {
5577
+ return new Promise((resolve11, reject) => {
3915
5578
  fastFolderSize(dirPath, (err, bytes) => {
3916
5579
  if (err) {
3917
5580
  reject(err);
@@ -3921,7 +5584,7 @@ async function calculateDirectorySize(dirPath) {
3921
5584
  reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
3922
5585
  return;
3923
5586
  }
3924
- resolve9(bytes);
5587
+ resolve11(bytes);
3925
5588
  });
3926
5589
  });
3927
5590
  }
@@ -3981,7 +5644,7 @@ function formatToolResponse(data) {
3981
5644
  content: [
3982
5645
  {
3983
5646
  type: "text",
3984
- text: JSON.stringify(data, null, 2)
5647
+ text: JSON.stringify(data)
3985
5648
  }
3986
5649
  ]
3987
5650
  };
@@ -4008,7 +5671,7 @@ function formatErrorResponse(error) {
4008
5671
  content: [
4009
5672
  {
4010
5673
  type: "text",
4011
- text: JSON.stringify(body, null, 2)
5674
+ text: JSON.stringify(body)
4012
5675
  }
4013
5676
  ],
4014
5677
  isError: true
@@ -4035,7 +5698,7 @@ function wrapHandler(fn) {
4035
5698
  }
4036
5699
 
4037
5700
  // src/mcp/worktree-summary.ts
4038
- import simpleGit6 from "simple-git";
5701
+ import simpleGit7 from "simple-git";
4039
5702
  function deriveLabel(status, isCurrent) {
4040
5703
  if (isCurrent) return "current";
4041
5704
  if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
@@ -4056,7 +5719,7 @@ function deriveSafeToRemove(status) {
4056
5719
  }
4057
5720
  async function getDivergence(worktreePath) {
4058
5721
  try {
4059
- const git = simpleGit6(worktreePath);
5722
+ const git = simpleGit7(worktreePath);
4060
5723
  const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
4061
5724
  const [aheadStr, behindStr] = output.trim().split(/\s+/);
4062
5725
  return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
@@ -4067,6 +5730,7 @@ async function getDivergence(worktreePath) {
4067
5730
 
4068
5731
  // src/mcp/handlers.ts
4069
5732
  var pathResolution = new PathResolutionService();
5733
+ var CLONE_MODE_WORKTREE_MUTATION_REASON = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
4070
5734
  function ensureCapability(discovered, key, toolName) {
4071
5735
  if (!discovered) return;
4072
5736
  const cap = discovered.capabilities[key];
@@ -4076,6 +5740,9 @@ function ensureCapability(discovered, key, toolName) {
4076
5740
  }
4077
5741
  }
4078
5742
  async function getReadyService(ctx, repoName, options = {}) {
5743
+ if (!repoName) {
5744
+ ctx.autoSelectCurrentRepoIfSingleConfig();
5745
+ }
4079
5746
  const discovered = ctx.getDiscoveredContext(repoName);
4080
5747
  if (options.capability && options.toolName) {
4081
5748
  ensureCapability(discovered, options.capability, options.toolName);
@@ -4098,27 +5765,44 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
4098
5765
  }
4099
5766
  return result.value;
4100
5767
  }
4101
- async function ensureRepoWorktreePath(ctx, params, git) {
4102
- await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
4103
- return path10.resolve(params.path);
5768
+ async function ensureRepoWorktreePath(ctx, params, service, git) {
5769
+ await ensurePathBelongsToRepo(ctx, params.path, params.repoName, service, git);
5770
+ return path15.resolve(params.path);
4104
5771
  }
4105
- async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
5772
+ async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
4106
5773
  const discovered = ctx.getDiscoveredContext(repoName);
4107
5774
  if (discovered?.allWorktrees.length) {
4108
5775
  const match = discovered.allWorktrees.some((w) => pathsEqual(w.path, targetPath));
4109
5776
  if (match) return;
4110
5777
  }
4111
5778
  try {
4112
- const worktrees = await git.getWorktrees();
5779
+ const worktrees = await getWorktreesFromService(service, git);
4113
5780
  if (worktrees.some((w) => pathsEqual(w.path, targetPath))) return;
4114
5781
  } catch {
4115
5782
  }
4116
5783
  throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
4117
5784
  }
5785
+ function isCloneModeService(service) {
5786
+ const candidate = service;
5787
+ return typeof candidate.isCloneMode === "function" && candidate.isCloneMode();
5788
+ }
5789
+ function ensureWorktreeModeService(service, toolName) {
5790
+ if (isCloneModeService(service)) {
5791
+ throw new CapabilityUnavailableError(toolName, [CLONE_MODE_WORKTREE_MUTATION_REASON]);
5792
+ }
5793
+ }
5794
+ async function getWorktreesFromService(service, git) {
5795
+ const candidate = service;
5796
+ if (typeof candidate.getWorktrees === "function") {
5797
+ return candidate.getWorktrees();
5798
+ }
5799
+ return git.getWorktrees();
5800
+ }
4118
5801
  async function handleDetectContext(ctx, params, _extra) {
4119
5802
  const target = params.path ?? process.cwd();
4120
5803
  const discovered = await ctx.detectFromPath(target);
4121
- let response = discovered;
5804
+ const configuredRepositories = await ctx.getConfiguredRepositorySummaries({ detailed: params.detailed ?? false });
5805
+ let response = { ...discovered, configuredRepositories };
4122
5806
  if (params.includeAllWorktrees) {
4123
5807
  const details = await ctx.getAllConfiguredWorktreeDetails(discovered.currentWorktreePath);
4124
5808
  const errorsByRepo = Object.keys(details.errorsByRepo).length > 0 ? details.errorsByRepo : void 0;
@@ -4132,7 +5816,7 @@ async function handleDetectContext(ctx, params, _extra) {
4132
5816
  return formatToolResponse(response);
4133
5817
  }
4134
5818
  const statusService = new WorktreeStatusService();
4135
- const statusLimit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5819
+ const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4136
5820
  const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
4137
5821
  let allWorktreesByRepo = response.allWorktreesByRepo;
4138
5822
  if (allWorktreesByRepo) {
@@ -4168,8 +5852,8 @@ async function enrichDetectedWorktrees(worktrees, statusService, limit) {
4168
5852
  async function handleListWorktrees(ctx, params, _extra) {
4169
5853
  const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
4170
5854
  if (configuredRepoNames.length > 0) {
4171
- const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
4172
- const statusLimit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
5855
+ const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
5856
+ const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4173
5857
  const repositories = await Promise.all(
4174
5858
  configuredRepoNames.map(
4175
5859
  (repoName) => limit(async () => {
@@ -4197,14 +5881,14 @@ async function handleListWorktrees(ctx, params, _extra) {
4197
5881
  const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
4198
5882
  return formatToolResponse({ worktrees: results });
4199
5883
  }
4200
- async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
4201
- const { discovered, git } = await getReadyService(ctx, repoName, {
5884
+ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
5885
+ const { discovered, service, git } = await getReadyService(ctx, repoName, {
4202
5886
  capability: "listWorktrees",
4203
5887
  toolName: "list_worktrees"
4204
5888
  });
4205
5889
  let worktrees;
4206
5890
  try {
4207
- worktrees = await git.getWorktrees();
5891
+ worktrees = await getWorktreesFromService(service, git);
4208
5892
  } catch {
4209
5893
  if (discovered) {
4210
5894
  worktrees = discovered.allWorktrees.map((w) => ({ path: w.path, branch: w.branch }));
@@ -4216,7 +5900,7 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(
4216
5900
  const results = await Promise.all(
4217
5901
  worktrees.map(
4218
5902
  (wt) => limit(async () => {
4219
- const resolvedPath = path10.resolve(wt.path);
5903
+ const resolvedPath = path15.resolve(wt.path);
4220
5904
  const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
4221
5905
  const [status, divergence, metadata, sizeBytes] = await Promise.all([
4222
5906
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
@@ -4241,11 +5925,11 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(
4241
5925
  return results;
4242
5926
  }
4243
5927
  async function handleGetWorktreeStatus(ctx, params, _extra) {
4244
- const { git } = await getReadyService(ctx, params.repoName, {
5928
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4245
5929
  capability: "getStatus",
4246
5930
  toolName: "get_worktree_status"
4247
5931
  });
4248
- const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
5932
+ const resolvedPath = await ensureRepoWorktreePath(ctx, params, service, git);
4249
5933
  const [status, divergence] = await Promise.all([
4250
5934
  git.getFullWorktreeStatus(params.path, params.includeDetails ?? false),
4251
5935
  getDivergence(params.path)
@@ -4267,9 +5951,10 @@ async function handleCreateWorktree(ctx, params, _extra) {
4267
5951
  capability: "createWorktree",
4268
5952
  toolName: "create_worktree"
4269
5953
  });
5954
+ ensureWorktreeModeService(service, "create_worktree");
4270
5955
  return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4271
5956
  if (!service.isInitialized()) {
4272
- await service.initialize();
5957
+ await service.initializeUnlocked();
4273
5958
  }
4274
5959
  const existence = await git.branchExists(branchName);
4275
5960
  let created = false;
@@ -4299,7 +5984,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
4299
5984
  return formatToolResponse({
4300
5985
  success: true,
4301
5986
  branchName,
4302
- worktreePath: path10.resolve(worktreePath),
5987
+ worktreePath: path15.resolve(worktreePath),
4303
5988
  created,
4304
5989
  pushed
4305
5990
  });
@@ -4310,11 +5995,12 @@ async function handleRemoveWorktree(ctx, params, _extra) {
4310
5995
  capability: "removeWorktree",
4311
5996
  toolName: "remove_worktree"
4312
5997
  });
5998
+ ensureWorktreeModeService(service, "remove_worktree");
4313
5999
  return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4314
6000
  if (!service.isInitialized()) {
4315
- await service.initialize();
6001
+ await service.initializeUnlocked();
4316
6002
  }
4317
- const removedPath = await ensureRepoWorktreePath(ctx, params, git);
6003
+ const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
4318
6004
  if (!params.force) {
4319
6005
  const status = await git.getFullWorktreeStatus(params.path, false);
4320
6006
  if (!status.canRemove) {
@@ -4337,13 +6023,31 @@ async function handleSync(ctx, params, extra) {
4337
6023
  const dispose = attachProgressReporter(service, extra);
4338
6024
  try {
4339
6025
  const start = Date.now();
6026
+ service.clearRecordedSkips();
4340
6027
  const result = await service.sync();
4341
6028
  if (!result.started) {
4342
6029
  throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
4343
6030
  }
4344
6031
  const duration = Date.now() - start;
4345
6032
  ctx.invalidateDiscovered();
4346
- return formatToolResponse({ success: true, duration });
6033
+ const outcome = result.outcome ?? createEmptySyncOutcome(
6034
+ isCloneModeService(service) ? "clone" : "worktree",
6035
+ ctx.getEntry(params.repoName)?.name ?? params.repoName,
6036
+ duration
6037
+ );
6038
+ const skips = service.getRecordedSkips().map((reason) => ({
6039
+ ...reason,
6040
+ message: formatCloneSkipReason(reason)
6041
+ }));
6042
+ return formatToolResponse({
6043
+ success: true,
6044
+ duration,
6045
+ outcome: {
6046
+ ...outcome,
6047
+ durationMs: outcome.durationMs ?? duration
6048
+ },
6049
+ skips
6050
+ });
4347
6051
  } finally {
4348
6052
  dispose();
4349
6053
  }
@@ -4353,11 +6057,12 @@ async function handleUpdateWorktree(ctx, params, _extra) {
4353
6057
  capability: "updateWorktree",
4354
6058
  toolName: "update_worktree"
4355
6059
  });
6060
+ ensureWorktreeModeService(service, "update_worktree");
4356
6061
  return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4357
6062
  if (!service.isInitialized()) {
4358
- await service.initialize();
6063
+ await service.initializeUnlocked();
4359
6064
  }
4360
- const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
6065
+ const worktreePath = await ensureRepoWorktreePath(ctx, params, service, git);
4361
6066
  await git.updateWorktree(params.path);
4362
6067
  ctx.invalidateDiscovered();
4363
6068
  return formatToolResponse({
@@ -4374,7 +6079,8 @@ async function handleInitialize(ctx, params, extra) {
4374
6079
  const dispose = attachProgressReporter(service, extra);
4375
6080
  try {
4376
6081
  return await runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4377
- await service.initialize();
6082
+ await service.initializeUnlocked();
6083
+ service.clearPendingInitSkip();
4378
6084
  const git = service.getGitService();
4379
6085
  ctx.invalidateDiscovered();
4380
6086
  return formatToolResponse({
@@ -4394,7 +6100,7 @@ async function handleLoadConfig(ctx, params, _extra) {
4394
6100
  }
4395
6101
  await ctx.loadConfig(configPath);
4396
6102
  return formatToolResponse({
4397
- configPath: path10.resolve(configPath),
6103
+ configPath: path15.resolve(configPath),
4398
6104
  currentRepository: ctx.getCurrentRepo(),
4399
6105
  repositories: ctx.getRepositoryList()
4400
6106
  });
@@ -4429,20 +6135,23 @@ function attachProgressReporter(service, extra) {
4429
6135
  }
4430
6136
 
4431
6137
  // src/mcp/server.ts
4432
- 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.";
4433
- var PATH_DESCRIBE_SUFFIX = "Absolute path preferred; relative paths resolve from the server's CWD.";
4434
- var SERVER_INSTRUCTIONS = "Before running git worktree operations, call `detect_context` with `includeAllWorktrees: true` at session start to learn every configured repository and worktree, plus the current repo, current branch, sibling repositories, and available capabilities. It walks up to auto-discover sync-worktrees.config.{js,mjs,cjs,ts}, reports config-driven sibling repositories, and reports per-capability {available, reason} so you can tell which tool is gated and why.";
6138
+ var REPO_NAME_DESCRIBE = "Repo name from loaded config. Omit to use current (set via set_current_repository) or the only loaded repo.";
6139
+ var PATH_DESCRIBE_SUFFIX = "Absolute preferred; relative resolves from server CWD.";
6140
+ 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.";
4435
6141
  function buildInstructions(snapshot) {
4436
6142
  const d = snapshot?.discovered;
4437
- if (!d || !d.isWorktree || d.kind !== "managed") return SERVER_INSTRUCTIONS;
4438
- const lines = ["Connect-time context (call `detect_context` for live state):"];
4439
- if (d.kind) lines.push(`- kind: ${d.kind}`);
4440
- if (d.currentWorktreePath) lines.push(`- currentWorktreePath: ${d.currentWorktreePath}`);
4441
- if (d.currentBranch) lines.push(`- currentBranch: ${d.currentBranch}`);
4442
- if (d.configPath) lines.push(`- configPath: ${d.configPath}`);
4443
- return `${SERVER_INSTRUCTIONS}
4444
-
4445
- ${lines.join("\n")}`;
6143
+ if (!d || !d.isWorktree || d.kind !== "managed") {
6144
+ return SERVER_INSTRUCTIONS;
6145
+ }
6146
+ const fields = [];
6147
+ if (d.repoName) fields.push(`workspace=${d.repoName}`);
6148
+ if (d.currentWorktreePath) fields.push(`path=${d.currentWorktreePath}`);
6149
+ if (d.configPath) fields.push(`config=${d.configPath}`);
6150
+ if (typeof snapshot?.configuredRepoCount === "number") {
6151
+ fields.push(`configuredRepos=${snapshot.configuredRepoCount}`);
6152
+ }
6153
+ fields.push(`worktrees=${d.allWorktrees.length}`);
6154
+ return `${SERVER_INSTRUCTIONS} Connect-time: ${fields.join(" ")}.`;
4446
6155
  }
4447
6156
  function createServer(context, snapshot) {
4448
6157
  const server = new McpServer(
@@ -4459,22 +6168,24 @@ function createServer(context, snapshot) {
4459
6168
  "sync-worktrees://workspace",
4460
6169
  {
4461
6170
  title: "Workspace context",
4462
- 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.",
6171
+ 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.",
4463
6172
  mimeType: "application/json"
4464
6173
  },
4465
6174
  async (uri) => {
4466
- let discovered;
6175
+ let payload;
4467
6176
  try {
4468
- discovered = await context.detectFromPath(process.cwd());
6177
+ const discovered = await context.detectFromPath(process.cwd());
6178
+ const configuredRepositories = await context.getConfiguredRepositorySummaries();
6179
+ payload = { ...discovered, configuredRepositories };
4469
6180
  } catch (err) {
4470
- discovered = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
6181
+ payload = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
4471
6182
  }
4472
6183
  return {
4473
6184
  contents: [
4474
6185
  {
4475
6186
  uri: uri.href,
4476
6187
  mimeType: "application/json",
4477
- text: JSON.stringify(discovered, null, 2)
6188
+ text: JSON.stringify(payload)
4478
6189
  }
4479
6190
  ]
4480
6191
  };
@@ -4483,14 +6194,13 @@ function createServer(context, snapshot) {
4483
6194
  server.registerTool(
4484
6195
  "detect_context",
4485
6196
  {
4486
- 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 configured sibling repositories. 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[].",
6197
+ 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.",
4487
6198
  inputSchema: {
4488
- path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD."),
4489
- includeAllWorktrees: z.boolean().optional().describe(
4490
- "If true, includes allWorktreesByRepo with worktrees for every configured repository, keyed by repoName, and allWorktreeErrorsByRepo for repos that could not be enumerated. Default: false."
4491
- ),
6199
+ path: z.string().optional().describe("Directory to inspect. Default: server CWD."),
6200
+ detailed: z.boolean().optional().default(false).describe("Expand configuredRepositories with repoUrl, branch, sparseCheckout, localReady, bareRepoDir."),
6201
+ includeAllWorktrees: z.boolean().optional().describe("Include allWorktreesByRepo + allWorktreeErrorsByRepo for each configured repo. Default: false."),
4492
6202
  includeStatus: z.boolean().optional().describe(
4493
- "If true, enriches worktree entries with label, divergence, and staleHint. Adds one git status + rev-list per worktree. Default: false (cheap path)."
6203
+ "Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Default: false."
4494
6204
  )
4495
6205
  },
4496
6206
  annotations: {
@@ -4505,13 +6215,11 @@ function createServer(context, snapshot) {
4505
6215
  server.registerTool(
4506
6216
  "list_worktrees",
4507
6217
  {
4508
- description: "List worktrees with enriched status. Without repoName and with a loaded config, returns all configured repositories grouped by repoName. With repoName, returns that single repository. Returns worktree entries as { path, branch, isCurrent, label (clean|dirty|stale|current|unknown), status, divergence (ahead/behind), safeToRemove: { safe, reason }, lastSyncAt, sizeBytes }.",
6218
+ 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}.",
4509
6219
  inputSchema: {
4510
- repoName: z.string().optional().describe(
4511
- "Repository name from loaded config. If omitted and a config is loaded, lists all configured repos."
4512
- ),
6220
+ repoName: z.string().optional().describe("Repo name. Omit + config loaded = list all configured repos."),
4513
6221
  includeSize: z.boolean().optional().describe(
4514
- "If true, computes the on-disk size of each worktree (in bytes). Slow on large worktrees. Default: false (sizeBytes returned as null)."
6222
+ "Compute on-disk size per worktree (bytes). Slow on large worktrees. Default: false (sizeBytes=null)."
4515
6223
  )
4516
6224
  },
4517
6225
  annotations: {
@@ -4526,11 +6234,11 @@ function createServer(context, snapshot) {
4526
6234
  server.registerTool(
4527
6235
  "get_worktree_status",
4528
6236
  {
4529
- 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.",
6237
+ description: "Detailed status for one worktree: dirty files, unpushed commits, stashes, upstream gone, ops in progress. Returns: status + divergence {ahead,behind} + resolved path.",
4530
6238
  inputSchema: {
4531
6239
  path: z.string().describe(`Worktree path. ${PATH_DESCRIBE_SUFFIX}`),
4532
6240
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
4533
- includeDetails: z.boolean().optional().describe("If true, includes file-level lists (modified, untracked, staged). Default: false (counts only).")
6241
+ includeDetails: z.boolean().optional().describe("Include file-level lists (modified, untracked, staged). Default: false (counts only).")
4534
6242
  },
4535
6243
  annotations: {
4536
6244
  title: "Get worktree status",
@@ -4544,13 +6252,13 @@ function createServer(context, snapshot) {
4544
6252
  server.registerTool(
4545
6253
  "create_worktree",
4546
6254
  {
4547
- description: "Create a worktree for a branch. If the branch exists (local or remote), checks it out; otherwise creates it from baseBranch and pushes the new branch to origin by default. Key params: baseBranch is required only when the branch does not yet exist \u2014 pass it defensively if unsure. push=false opts out for newly created branches. Preconditions: repository must be initialized (auto-runs on first call). Returns: { success, branchName, worktreePath, created, pushed }.",
6255
+ 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}.",
4548
6256
  inputSchema: {
4549
- branchName: z.string().describe("Branch name. Slashes and special chars are sanitized for the worktree directory name."),
6257
+ branchName: z.string().describe("Branch name. Slashes/special chars sanitized for dir name."),
4550
6258
  baseBranch: z.string().optional().describe(
4551
- "Base branch for creating a new branch. Required if branchName does not exist locally or remotely; ignored otherwise."
6259
+ "Base for new branch. Required if branchName doesn't exist locally or remotely; ignored otherwise."
4552
6260
  ),
4553
- push: z.boolean().optional().describe("Push the newly created branch to origin. Default: true. Ignored if the branch already existed."),
6261
+ push: z.boolean().optional().describe("Push new branch to origin. Default: true. Ignored if branch existed."),
4554
6262
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4555
6263
  },
4556
6264
  annotations: {
@@ -4566,12 +6274,10 @@ function createServer(context, snapshot) {
4566
6274
  server.registerTool(
4567
6275
  "remove_worktree",
4568
6276
  {
4569
- 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 }.",
6277
+ description: "Remove worktree. Safety checks reject if dirty, unpushed commits, stashes, or op in progress (merge/rebase/cherry-pick/revert/bisect). force=true: `git worktree remove --force` DELETES uncommitted/untracked files in dir; branch ref + stashes + remote preserved. Returns: {success, removedPath}.",
4570
6278
  inputSchema: {
4571
6279
  path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
4572
- force: z.boolean().optional().describe(
4573
- "Skip safety checks and delete uncommitted/untracked files in the worktree directory. Branch ref is preserved. Default: false."
4574
- ),
6280
+ force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
4575
6281
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4576
6282
  },
4577
6283
  annotations: {
@@ -4587,7 +6293,7 @@ function createServer(context, snapshot) {
4587
6293
  server.registerTool(
4588
6294
  "sync",
4589
6295
  {
4590
- 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.",
6296
+ 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}.",
4591
6297
  inputSchema: {
4592
6298
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4593
6299
  },
@@ -4604,7 +6310,7 @@ function createServer(context, snapshot) {
4604
6310
  server.registerTool(
4605
6311
  "update_worktree",
4606
6312
  {
4607
- 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.",
6313
+ description: "Fast-forward one worktree to upstream. No merge, no rebase, aborts if not fast-forwardable. Whole repo? Use sync.",
4608
6314
  inputSchema: {
4609
6315
  path: z.string().describe(`Worktree path to fast-forward. ${PATH_DESCRIBE_SUFFIX}`),
4610
6316
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
@@ -4622,7 +6328,7 @@ function createServer(context, snapshot) {
4622
6328
  server.registerTool(
4623
6329
  "initialize",
4624
6330
  {
4625
- 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 }.",
6331
+ description: "Initialize repo: clone as bare if missing, create main worktree. Idempotent. Emits progress. Preconditions: config loaded. Returns: {success, defaultBranch, worktreeDir}.",
4626
6332
  inputSchema: {
4627
6333
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4628
6334
  },
@@ -4639,11 +6345,9 @@ function createServer(context, snapshot) {
4639
6345
  server.registerTool(
4640
6346
  "load_config",
4641
6347
  {
4642
- 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 }] }.",
6348
+ description: "Load/reload sync-worktrees JS config into session. Replaces previously loaded repos. Call before sync/initialize/create_worktree in config-driven workflow. Returns: {configPath, currentRepository, repositories: [{name, repoUrl, worktreeDir, source}]}.",
4643
6349
  inputSchema: {
4644
- configPath: z.string().optional().describe(
4645
- "Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."
4646
- )
6350
+ configPath: z.string().optional().describe("Config file path. Falls back to SYNC_WORKTREES_CONFIG env var. Errors if neither set.")
4647
6351
  },
4648
6352
  annotations: {
4649
6353
  title: "Load sync-worktrees config",
@@ -4658,9 +6362,9 @@ function createServer(context, snapshot) {
4658
6362
  server.registerTool(
4659
6363
  "set_current_repository",
4660
6364
  {
4661
- 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.",
6365
+ description: "Set current repo for tool calls that omit repoName. Session-scoped. Preconditions: load_config called.",
4662
6366
  inputSchema: {
4663
- repoName: z.string().describe("Repository name as listed in the loaded config's `repositories[].name`.")
6367
+ repoName: z.string().describe("Repo name from loaded config repositories[].name.")
4664
6368
  },
4665
6369
  annotations: {
4666
6370
  title: "Set current repository",
@@ -4702,7 +6406,10 @@ async function main() {
4702
6406
  process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
4703
6407
  `);
4704
6408
  }
4705
- const server = createServer(context, { discovered });
6409
+ const server = createServer(context, {
6410
+ discovered,
6411
+ configuredRepoCount: context.getConfiguredRepositoryNames().length
6412
+ });
4706
6413
  const transport = new StdioServerTransport();
4707
6414
  await server.connect(transport);
4708
6415
  }