sync-worktrees 3.6.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +388 -263
  2. package/dist/components/App.d.ts +52 -0
  3. package/dist/components/App.d.ts.map +1 -0
  4. package/dist/components/BranchCreationWizard.d.ts +26 -0
  5. package/dist/components/BranchCreationWizard.d.ts.map +1 -0
  6. package/dist/components/HelpModal.d.ts +7 -0
  7. package/dist/components/HelpModal.d.ts.map +1 -0
  8. package/dist/components/LogPanel.d.ts +10 -0
  9. package/dist/components/LogPanel.d.ts.map +1 -0
  10. package/dist/components/LogViewer.d.ts +9 -0
  11. package/dist/components/LogViewer.d.ts.map +1 -0
  12. package/dist/components/OpenEditorWizard.d.ts +25 -0
  13. package/dist/components/OpenEditorWizard.d.ts.map +1 -0
  14. package/dist/components/StatusBar.d.ts +11 -0
  15. package/dist/components/StatusBar.d.ts.map +1 -0
  16. package/dist/components/WorktreeStatusView.d.ts +17 -0
  17. package/dist/components/WorktreeStatusView.d.ts.map +1 -0
  18. package/dist/constants.d.ts +112 -0
  19. package/dist/constants.d.ts.map +1 -0
  20. package/dist/errors/index.d.ts +59 -0
  21. package/dist/errors/index.d.ts.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +2327 -1082
  25. package/dist/index.js.map +4 -4
  26. package/dist/mcp/context.d.ts +143 -0
  27. package/dist/mcp/context.d.ts.map +1 -0
  28. package/dist/mcp/handlers.d.ts +46 -0
  29. package/dist/mcp/handlers.d.ts.map +1 -0
  30. package/dist/mcp/index.d.ts +2 -0
  31. package/dist/mcp/index.d.ts.map +1 -0
  32. package/dist/mcp/server.d.ts +9 -0
  33. package/dist/mcp/server.d.ts.map +1 -0
  34. package/dist/mcp/utils.d.ts +14 -0
  35. package/dist/mcp/utils.d.ts.map +1 -0
  36. package/dist/mcp/worktree-summary.d.ts +14 -0
  37. package/dist/mcp/worktree-summary.d.ts.map +1 -0
  38. package/dist/mcp-server.js +2513 -691
  39. package/dist/mcp-server.js.map +4 -4
  40. package/dist/services/InteractiveUIService.d.ts +85 -0
  41. package/dist/services/InteractiveUIService.d.ts.map +1 -0
  42. package/dist/services/branch-created-actions.service.d.ts +27 -0
  43. package/dist/services/branch-created-actions.service.d.ts.map +1 -0
  44. package/dist/services/clone-sync.service.d.ts +93 -0
  45. package/dist/services/clone-sync.service.d.ts.map +1 -0
  46. package/dist/services/config-loader.service.d.ts +28 -0
  47. package/dist/services/config-loader.service.d.ts.map +1 -0
  48. package/dist/services/file-copy.service.d.ts +19 -0
  49. package/dist/services/file-copy.service.d.ts.map +1 -0
  50. package/dist/services/git.service.d.ts +92 -0
  51. package/dist/services/git.service.d.ts.map +1 -0
  52. package/dist/services/hook-execution.service.d.ts +20 -0
  53. package/dist/services/hook-execution.service.d.ts.map +1 -0
  54. package/dist/services/logger.service.d.ts +24 -0
  55. package/dist/services/logger.service.d.ts.map +1 -0
  56. package/dist/services/path-resolution.service.d.ts +10 -0
  57. package/dist/services/path-resolution.service.d.ts.map +1 -0
  58. package/dist/services/progress-emitter.d.ts +14 -0
  59. package/dist/services/progress-emitter.d.ts.map +1 -0
  60. package/dist/services/repo-operation-lock.d.ts +16 -0
  61. package/dist/services/repo-operation-lock.d.ts.map +1 -0
  62. package/dist/services/sparse-checkout.service.d.ts +45 -0
  63. package/dist/services/sparse-checkout.service.d.ts.map +1 -0
  64. package/dist/services/sync-outcome.d.ts +47 -0
  65. package/dist/services/sync-outcome.d.ts.map +1 -0
  66. package/dist/services/sync-retry-policy.d.ts +18 -0
  67. package/dist/services/sync-retry-policy.d.ts.map +1 -0
  68. package/dist/services/worktree-metadata.service.d.ts +25 -0
  69. package/dist/services/worktree-metadata.service.d.ts.map +1 -0
  70. package/dist/services/worktree-mode-sync-runner.d.ts +36 -0
  71. package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -0
  72. package/dist/services/worktree-status.service.d.ts +60 -0
  73. package/dist/services/worktree-status.service.d.ts.map +1 -0
  74. package/dist/services/worktree-sync-planner.d.ts +62 -0
  75. package/dist/services/worktree-sync-planner.d.ts.map +1 -0
  76. package/dist/services/worktree-sync.service.d.ts +49 -0
  77. package/dist/services/worktree-sync.service.d.ts.map +1 -0
  78. package/dist/types/index.d.ts +289 -0
  79. package/dist/types/index.d.ts.map +1 -0
  80. package/dist/types/sync-metadata.d.ts +16 -0
  81. package/dist/types/sync-metadata.d.ts.map +1 -0
  82. package/dist/utils/app-events.d.ts +21 -0
  83. package/dist/utils/app-events.d.ts.map +1 -0
  84. package/dist/utils/branch-filter.d.ts +3 -0
  85. package/dist/utils/branch-filter.d.ts.map +1 -0
  86. package/dist/utils/cli.d.ts +21 -0
  87. package/dist/utils/cli.d.ts.map +1 -0
  88. package/dist/utils/clone-skip-format.d.ts +3 -0
  89. package/dist/utils/clone-skip-format.d.ts.map +1 -0
  90. package/dist/utils/config-generator.d.ts +10 -0
  91. package/dist/utils/config-generator.d.ts.map +1 -0
  92. package/dist/utils/date-filter.d.ts +10 -0
  93. package/dist/utils/date-filter.d.ts.map +1 -0
  94. package/dist/utils/disk-space.d.ts +23 -0
  95. package/dist/utils/disk-space.d.ts.map +1 -0
  96. package/dist/utils/file-exists.d.ts +2 -0
  97. package/dist/utils/file-exists.d.ts.map +1 -0
  98. package/dist/utils/git-progress.d.ts +25 -0
  99. package/dist/utils/git-progress.d.ts.map +1 -0
  100. package/dist/utils/git-url.d.ts +23 -0
  101. package/dist/utils/git-url.d.ts.map +1 -0
  102. package/dist/utils/git-validation.d.ts +5 -0
  103. package/dist/utils/git-validation.d.ts.map +1 -0
  104. package/dist/utils/interactive.d.ts +3 -0
  105. package/dist/utils/interactive.d.ts.map +1 -0
  106. package/dist/utils/lfs-error.d.ts +35 -0
  107. package/dist/utils/lfs-error.d.ts.map +1 -0
  108. package/dist/utils/lock-path.d.ts +9 -0
  109. package/dist/utils/lock-path.d.ts.map +1 -0
  110. package/dist/utils/path-compare.d.ts +16 -0
  111. package/dist/utils/path-compare.d.ts.map +1 -0
  112. package/dist/utils/repo-mode.d.ts +8 -0
  113. package/dist/utils/repo-mode.d.ts.map +1 -0
  114. package/dist/utils/retry.d.ts +24 -0
  115. package/dist/utils/retry.d.ts.map +1 -0
  116. package/dist/utils/sanitize-name.d.ts +2 -0
  117. package/dist/utils/sanitize-name.d.ts.map +1 -0
  118. package/dist/utils/shell-escape.d.ts +5 -0
  119. package/dist/utils/shell-escape.d.ts.map +1 -0
  120. package/dist/utils/signal-handlers.d.ts +14 -0
  121. package/dist/utils/signal-handlers.d.ts.map +1 -0
  122. package/dist/utils/timing.d.ts +24 -0
  123. package/dist/utils/timing.d.ts.map +1 -0
  124. package/dist/utils/worktree-list-parser.d.ts +10 -0
  125. package/dist/utils/worktree-list-parser.d.ts.map +1 -0
  126. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as path13 from "path";
5
- import { confirm as confirm2 } from "@inquirer/prompts";
6
- import * as cron3 from "node-cron";
4
+ import { realpathSync as realpathSync2 } from "fs";
5
+ import * as path17 from "path";
6
+ import { fileURLToPath } from "url";
7
7
  import pLimit3 from "p-limit";
8
8
 
9
9
  // src/constants.ts
@@ -87,7 +87,8 @@ var ENV_CONSTANTS = {
87
87
  };
88
88
  var PATH_CONSTANTS = {
89
89
  GIT_DIR: ".git",
90
- README: "README"
90
+ README: "README",
91
+ CLONE_INIT_MARKER: ".sync-worktrees-clone-init"
91
92
  };
92
93
  var CONFIG_FILE_NAMES = [
93
94
  "sync-worktrees.config.js",
@@ -127,8 +128,68 @@ var HOOK_CONSTANTS = {
127
128
  }
128
129
  };
129
130
 
131
+ // src/errors/index.ts
132
+ var SyncWorktreesError = class extends Error {
133
+ constructor(message, code, cause) {
134
+ super(message);
135
+ this.code = code;
136
+ this.cause = cause;
137
+ this.name = this.constructor.name;
138
+ Object.setPrototypeOf(this, new.target.prototype);
139
+ if (cause && cause.stack) {
140
+ this.stack = `${this.stack}
141
+ Caused by: ${cause.stack}`;
142
+ }
143
+ }
144
+ };
145
+ var GitError = class extends SyncWorktreesError {
146
+ constructor(message, code, cause) {
147
+ super(message, `GIT_${code}`, cause);
148
+ }
149
+ };
150
+ var GitOperationError = class extends GitError {
151
+ constructor(operation, details, cause) {
152
+ super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
153
+ }
154
+ };
155
+ var WorktreeError = class extends SyncWorktreesError {
156
+ constructor(message, code, cause) {
157
+ super(message, `WORKTREE_${code}`, cause);
158
+ }
159
+ };
160
+ var WorktreeNotCleanError = class extends WorktreeError {
161
+ constructor(path18, reasons) {
162
+ super(`Worktree at '${path18}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
163
+ this.path = path18;
164
+ this.reasons = reasons;
165
+ }
166
+ };
167
+ var ConfigError = class extends SyncWorktreesError {
168
+ constructor(message, code, cause) {
169
+ super(message, `CONFIG_${code}`, cause);
170
+ }
171
+ };
172
+ var ConfigValidationError = class extends ConfigError {
173
+ constructor(field, reason) {
174
+ super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
175
+ this.field = field;
176
+ this.reason = reason;
177
+ }
178
+ };
179
+ var ConfigFileNotFoundError = class extends ConfigError {
180
+ constructor(configPath) {
181
+ super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
182
+ this.configPath = configPath;
183
+ }
184
+ };
185
+ var ConfigFileExistsError = class extends ConfigError {
186
+ constructor(configPath) {
187
+ super(`Config file already exists: ${configPath}`, "FILE_EXISTS");
188
+ this.configPath = configPath;
189
+ }
190
+ };
191
+
130
192
  // src/services/config-loader.service.ts
131
- import * as fs from "fs/promises";
132
193
  import * as path2 from "path";
133
194
  import { pathToFileURL } from "url";
134
195
  import * as cron from "node-cron";
@@ -153,6 +214,17 @@ function filterBranchesByName(branches, include, exclude) {
153
214
  return result;
154
215
  }
155
216
 
217
+ // src/utils/file-exists.ts
218
+ import * as fs from "fs/promises";
219
+ async function fileExists(path18) {
220
+ try {
221
+ await fs.access(path18);
222
+ return true;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
156
228
  // src/utils/git-url.ts
157
229
  function extractRepoNameFromUrl(gitUrl) {
158
230
  const url = gitUrl.trim();
@@ -178,6 +250,16 @@ function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
178
250
  const repoName = extractRepoNameFromUrl(repoUrl);
179
251
  return `${baseDir}/${repoName}`;
180
252
  }
253
+ function normalizeRepoUrlForComparison(url) {
254
+ let normalized = url.trim();
255
+ const isForgeUrl = /^(https?|ssh|git):\/\//i.test(normalized) || /^[\w.-]+@[^/]+:/.test(normalized);
256
+ normalized = normalized.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+/, (prefix) => prefix.toLowerCase());
257
+ normalized = normalized.replace(/\/+$/, "");
258
+ if (isForgeUrl) {
259
+ normalized = normalized.replace(/\.git$/, "");
260
+ }
261
+ return normalized;
262
+ }
181
263
 
182
264
  // src/utils/path-compare.ts
183
265
  import * as path from "path";
@@ -190,54 +272,17 @@ function normalizePathForCompare(p, platform = process.platform) {
190
272
  return isCaseInsensitiveFs(platform) ? resolved.toLowerCase() : resolved;
191
273
  }
192
274
 
193
- // src/errors/index.ts
194
- var SyncWorktreesError = class extends Error {
195
- constructor(message, code, cause) {
196
- super(message);
197
- this.code = code;
198
- this.cause = cause;
199
- this.name = this.constructor.name;
200
- Object.setPrototypeOf(this, new.target.prototype);
201
- if (cause && cause.stack) {
202
- this.stack = `${this.stack}
203
- Caused by: ${cause.stack}`;
204
- }
205
- }
206
- };
207
- var GitError = class extends SyncWorktreesError {
208
- constructor(message, code, cause) {
209
- super(message, `GIT_${code}`, cause);
210
- }
211
- };
212
- var GitOperationError = class extends GitError {
213
- constructor(operation, details, cause) {
214
- super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
215
- }
216
- };
217
- var WorktreeError = class extends SyncWorktreesError {
218
- constructor(message, code, cause) {
219
- super(message, `WORKTREE_${code}`, cause);
220
- }
221
- };
222
- var WorktreeNotCleanError = class extends WorktreeError {
223
- constructor(path14, reasons) {
224
- super(`Worktree at '${path14}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
225
- this.path = path14;
226
- this.reasons = reasons;
227
- }
228
- };
229
- var ConfigError = class extends SyncWorktreesError {
230
- constructor(message, code, cause) {
231
- super(message, `CONFIG_${code}`, cause);
232
- }
233
- };
234
- var ConfigValidationError = class extends ConfigError {
235
- constructor(field, reason) {
236
- super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
237
- this.field = field;
238
- this.reason = reason;
239
- }
275
+ // src/utils/repo-mode.ts
276
+ var REPOSITORY_MODES = {
277
+ CLONE: "clone",
278
+ WORKTREE: "worktree"
240
279
  };
280
+ function isRepositoryMode(value) {
281
+ return value === REPOSITORY_MODES.CLONE || value === REPOSITORY_MODES.WORKTREE;
282
+ }
283
+ function resolveMode(cfg) {
284
+ return cfg.mode ?? REPOSITORY_MODES.WORKTREE;
285
+ }
241
286
 
242
287
  // src/utils/sanitize-name.ts
243
288
  var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
@@ -261,6 +306,13 @@ function sanitizeNameForPath(name, fieldContext = "name") {
261
306
  }
262
307
 
263
308
  // src/services/config-loader.service.ts
309
+ var CLONE_MODE_CONFLICTING_FIELDS = [
310
+ "branchInclude",
311
+ "branchExclude",
312
+ "branchMaxAge",
313
+ "updateExistingWorktrees",
314
+ "bareRepoDir"
315
+ ];
264
316
  var ConfigLoaderService = class {
265
317
  async findConfigUpward(startDir) {
266
318
  let current = path2.resolve(startDir);
@@ -268,10 +320,8 @@ var ConfigLoaderService = class {
268
320
  while (true) {
269
321
  for (const name of CONFIG_FILE_NAMES) {
270
322
  const candidate = path2.join(current, name);
271
- try {
272
- await fs.access(candidate);
323
+ if (await fileExists(candidate)) {
273
324
  return candidate;
274
- } catch {
275
325
  }
276
326
  }
277
327
  if (current === root) return null;
@@ -282,10 +332,8 @@ var ConfigLoaderService = class {
282
332
  }
283
333
  async loadConfigFile(configPath) {
284
334
  const absolutePath = path2.resolve(configPath);
285
- try {
286
- await fs.access(absolutePath);
287
- } catch {
288
- throw new Error(`Config file not found: ${absolutePath}`);
335
+ if (!await fileExists(absolutePath)) {
336
+ throw new ConfigFileNotFoundError(absolutePath);
289
337
  }
290
338
  try {
291
339
  const fileUrl = pathToFileURL(absolutePath);
@@ -298,7 +346,7 @@ var ConfigLoaderService = class {
298
346
  this.validateConfigFile(config);
299
347
  return config;
300
348
  } catch (error) {
301
- if (error instanceof Error && error.message.includes("Config file not found")) {
349
+ if (error instanceof SyncWorktreesError) {
302
350
  throw error;
303
351
  }
304
352
  throw new Error(`Failed to load config file: ${error.message}`);
@@ -351,6 +399,12 @@ var ConfigLoaderService = class {
351
399
  if (repoObj.runOnce !== void 0 && typeof repoObj.runOnce !== "boolean") {
352
400
  throw new Error(`Repository '${repoObj.name}' has invalid 'runOnce' property`);
353
401
  }
402
+ if (repoObj.debug !== void 0 && typeof repoObj.debug !== "boolean") {
403
+ throw new Error(`Repository '${repoObj.name}' has invalid 'debug' property`);
404
+ }
405
+ if (repoObj.retry !== void 0) {
406
+ this.validateRetryConfig(repoObj.retry, `Repository '${repoObj.name}' retry config`);
407
+ }
354
408
  if (repoObj.filesToCopyOnBranchCreate !== void 0) {
355
409
  this.validateFilesToCopyConfig(repoObj.filesToCopyOnBranchCreate, `Repository '${repoObj.name}'`);
356
410
  }
@@ -360,6 +414,8 @@ var ConfigLoaderService = class {
360
414
  if (repoObj.sparseCheckout !== void 0) {
361
415
  this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
362
416
  }
417
+ this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
418
+ this.validateRepositoryMode(repoObj, configObj.defaults);
363
419
  });
364
420
  this.warnOnDuplicateRepoUrls(configObj.repositories);
365
421
  if (configObj.defaults) {
@@ -376,9 +432,15 @@ var ConfigLoaderService = class {
376
432
  if (defaults.runOnce !== void 0 && typeof defaults.runOnce !== "boolean") {
377
433
  throw new Error("Invalid 'runOnce' in defaults");
378
434
  }
435
+ if (defaults.debug !== void 0 && typeof defaults.debug !== "boolean") {
436
+ throw new Error("Invalid 'debug' in defaults");
437
+ }
379
438
  if (defaults.retry !== void 0 && typeof defaults.retry !== "object") {
380
439
  throw new Error("Invalid 'retry' in defaults");
381
440
  }
441
+ if (defaults.retry !== void 0) {
442
+ this.validateRetryConfig(defaults.retry, "defaults retry config");
443
+ }
382
444
  if (defaults.filesToCopyOnBranchCreate !== void 0) {
383
445
  this.validateFilesToCopyConfig(defaults.filesToCopyOnBranchCreate, "defaults");
384
446
  }
@@ -388,39 +450,17 @@ var ConfigLoaderService = class {
388
450
  if (defaults.sparseCheckout !== void 0) {
389
451
  this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
390
452
  }
391
- }
392
- if (configObj.retry !== void 0) {
393
- if (typeof configObj.retry !== "object") {
394
- throw new Error("'retry' must be an object");
395
- }
396
- const retry2 = configObj.retry;
397
- if (retry2.maxAttempts !== void 0) {
398
- if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
399
- throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
400
- }
401
- }
402
- if (retry2.maxLfsRetries !== void 0) {
403
- if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
404
- throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
405
- }
406
- }
407
- if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
408
- throw new Error("Invalid 'initialDelayMs' in retry config");
409
- }
410
- if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
411
- throw new Error("Invalid 'maxDelayMs' in retry config");
453
+ this.validateDepth(defaults.depth, "defaults.depth");
454
+ if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
455
+ throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
412
456
  }
413
- if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
414
- throw new Error("Invalid 'backoffMultiplier' in retry config");
415
- }
416
- const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
417
- const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
418
- if (initialDelay > maxDelay) {
419
- throw new Error(
420
- `Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
421
- );
457
+ if (defaults.branch !== void 0 && (typeof defaults.branch !== "string" || defaults.branch.trim() === "")) {
458
+ throw new ConfigValidationError("defaults.branch", "must be a non-empty string");
422
459
  }
423
460
  }
461
+ if (configObj.retry !== void 0) {
462
+ this.validateRetryConfig(configObj.retry, "retry config");
463
+ }
424
464
  if (configObj.parallelism !== void 0) {
425
465
  this.validateParallelismConfig(configObj.parallelism, "global");
426
466
  }
@@ -431,6 +471,47 @@ var ConfigLoaderService = class {
431
471
  }
432
472
  }
433
473
  }
474
+ validateDepth(value, field) {
475
+ if (value === void 0) return;
476
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
477
+ throw new ConfigValidationError(field, "must be a positive safe integer");
478
+ }
479
+ }
480
+ validateRetryConfig(value, context) {
481
+ if (typeof value !== "object" || value === null) {
482
+ throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
483
+ }
484
+ const retry2 = value;
485
+ if (retry2.maxAttempts !== void 0) {
486
+ if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
487
+ throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
488
+ }
489
+ }
490
+ if (retry2.maxLfsRetries !== void 0) {
491
+ if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
492
+ throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
493
+ }
494
+ }
495
+ if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
496
+ throw new Error("Invalid 'initialDelayMs' in retry config");
497
+ }
498
+ if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
499
+ throw new Error("Invalid 'maxDelayMs' in retry config");
500
+ }
501
+ if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
502
+ throw new Error("Invalid 'backoffMultiplier' in retry config");
503
+ }
504
+ if (retry2.jitterMs !== void 0 && (typeof retry2.jitterMs !== "number" || retry2.jitterMs < 0)) {
505
+ throw new Error("Invalid 'jitterMs' in retry config");
506
+ }
507
+ const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
508
+ const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
509
+ if (initialDelay > maxDelay) {
510
+ throw new Error(
511
+ `Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
512
+ );
513
+ }
514
+ }
434
515
  validateParallelismConfig(parallelism, context) {
435
516
  if (typeof parallelism !== "object" || parallelism === null) {
436
517
  throw new Error(`'parallelism' in ${context} must be an object`);
@@ -531,6 +612,50 @@ var ConfigLoaderService = class {
531
612
  }
532
613
  }
533
614
  }
615
+ validateRepositoryMode(repoObj, defaults) {
616
+ const repoName = repoObj.name;
617
+ const repoMode = repoObj.mode;
618
+ if (repoMode !== void 0 && !isRepositoryMode(repoMode)) {
619
+ throw new ConfigValidationError(`Repository '${repoName}' mode`, "must be 'clone' or 'worktree'");
620
+ }
621
+ if (repoObj.branch !== void 0 && (typeof repoObj.branch !== "string" || repoObj.branch.trim() === "")) {
622
+ throw new ConfigValidationError(`Repository '${repoName}' branch`, "must be a non-empty string");
623
+ }
624
+ const effectiveMode = repoMode ?? defaults?.mode;
625
+ if (effectiveMode !== REPOSITORY_MODES.CLONE) {
626
+ const depthFromRepo = repoObj.depth;
627
+ const depthFromDefaults = defaults?.depth;
628
+ if (depthFromRepo !== void 0 || depthFromDefaults !== void 0) {
629
+ const source = depthFromRepo !== void 0 ? "repository" : "defaults";
630
+ throw new ConfigValidationError(
631
+ `Repository '${repoName}' depth`,
632
+ `only supported when mode is 'clone' (set on ${source})`
633
+ );
634
+ }
635
+ const branchFromRepo = repoObj.branch;
636
+ const branchFromDefaults = defaults?.branch;
637
+ if (branchFromRepo !== void 0 || branchFromDefaults !== void 0) {
638
+ const source = branchFromRepo !== void 0 ? "repository" : "defaults";
639
+ throw new ConfigValidationError(
640
+ `Repository '${repoName}' branch`,
641
+ `only supported when mode is 'clone' (set on ${source})`
642
+ );
643
+ }
644
+ return;
645
+ }
646
+ for (const field of CLONE_MODE_CONFLICTING_FIELDS) {
647
+ const fromRepo = repoObj[field];
648
+ const fromDefaults = defaults?.[field];
649
+ const present = fromRepo !== void 0 || fromDefaults !== void 0;
650
+ if (present) {
651
+ const source = fromRepo !== void 0 ? "repository" : "defaults";
652
+ throw new ConfigValidationError(
653
+ `Repository '${repoName}' ${field}`,
654
+ `not supported when mode is 'clone' (set on ${source})`
655
+ );
656
+ }
657
+ }
658
+ }
534
659
  validateHooksConfig(hooks, context) {
535
660
  if (typeof hooks !== "object" || hooks === null) {
536
661
  throw new Error(`'hooks' in ${context} must be an object`);
@@ -551,29 +676,47 @@ var ConfigLoaderService = class {
551
676
  }
552
677
  }
553
678
  resolveRepositoryConfig(repo, defaults, configDir, globalRetry, allRepositories) {
679
+ const mode = repo.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
554
680
  const resolved = {
555
681
  name: repo.name,
556
682
  repoUrl: repo.repoUrl,
557
683
  worktreeDir: this.resolvePath(repo.worktreeDir, configDir),
558
684
  cronSchedule: repo.cronSchedule ?? defaults?.cronSchedule ?? DEFAULT_CONFIG.CRON_SCHEDULE,
559
- runOnce: repo.runOnce ?? defaults?.runOnce ?? false
685
+ runOnce: repo.runOnce ?? defaults?.runOnce ?? false,
686
+ debug: repo.debug ?? defaults?.debug,
687
+ mode
560
688
  };
561
- if (repo.bareRepoDir) {
562
- resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
563
- } else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories)) {
564
- const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
565
- resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
566
- } else {
567
- resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
689
+ if (configDir) {
690
+ resolved.__configFileDir = configDir;
568
691
  }
569
- if (repo.branchMaxAge || defaults?.branchMaxAge) {
570
- resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
571
- }
572
- if (repo.branchInclude || defaults?.branchInclude) {
573
- resolved.branchInclude = repo.branchInclude ?? defaults?.branchInclude;
574
- }
575
- if (repo.branchExclude || defaults?.branchExclude) {
576
- resolved.branchExclude = repo.branchExclude ?? defaults?.branchExclude;
692
+ if (mode === REPOSITORY_MODES.CLONE) {
693
+ if (repo.branch ?? defaults?.branch) {
694
+ resolved.branch = repo.branch ?? defaults?.branch;
695
+ }
696
+ if (repo.depth !== void 0 || defaults?.depth !== void 0) {
697
+ resolved.depth = repo.depth ?? defaults?.depth;
698
+ }
699
+ } else {
700
+ if (repo.bareRepoDir) {
701
+ resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
702
+ } else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories, defaults)) {
703
+ const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
704
+ resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
705
+ } else {
706
+ resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
707
+ }
708
+ if (repo.branchMaxAge || defaults?.branchMaxAge) {
709
+ resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
710
+ }
711
+ if (repo.branchInclude || defaults?.branchInclude) {
712
+ resolved.branchInclude = repo.branchInclude ?? defaults?.branchInclude;
713
+ }
714
+ if (repo.branchExclude || defaults?.branchExclude) {
715
+ resolved.branchExclude = repo.branchExclude ?? defaults?.branchExclude;
716
+ }
717
+ if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
718
+ resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
719
+ }
577
720
  }
578
721
  if (repo.skipLfs !== void 0 || defaults?.skipLfs !== void 0) {
579
722
  resolved.skipLfs = repo.skipLfs ?? defaults?.skipLfs ?? false;
@@ -591,9 +734,6 @@ var ConfigLoaderService = class {
591
734
  ...repo.parallelism || {}
592
735
  };
593
736
  }
594
- if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
595
- resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
596
- }
597
737
  if (repo.filesToCopyOnBranchCreate || defaults?.filesToCopyOnBranchCreate) {
598
738
  const files = repo.filesToCopyOnBranchCreate ?? defaults?.filesToCopyOnBranchCreate;
599
739
  resolved.filesToCopyOnBranchCreate = files?.map((f) => this.resolvePath(f, configDir));
@@ -610,8 +750,11 @@ var ConfigLoaderService = class {
610
750
  }
611
751
  return resolved;
612
752
  }
613
- isDuplicateRepoUrl(repo, all) {
614
- const firstIndex = all.findIndex((r) => r.repoUrl === repo.repoUrl);
753
+ isDuplicateRepoUrl(repo, all, defaults) {
754
+ const firstIndex = all.findIndex((r) => {
755
+ const mode = r.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
756
+ return r.repoUrl === repo.repoUrl && mode === REPOSITORY_MODES.WORKTREE;
757
+ });
615
758
  const myIndex = all.indexOf(repo);
616
759
  return firstIndex !== -1 && myIndex !== -1 && myIndex !== firstIndex;
617
760
  }
@@ -662,19 +805,13 @@ var ConfigLoaderService = class {
662
805
  if (overrides?.filter) {
663
806
  repositories = this.filterRepositories(repositories, overrides.filter);
664
807
  }
665
- if (overrides?.noUpdateExisting) {
666
- repositories = repositories.map((repo) => ({ ...repo, updateExistingWorktrees: false }));
667
- }
668
- if (overrides?.debug) {
669
- repositories = repositories.map((repo) => ({ ...repo, debug: true }));
670
- }
671
808
  return { repositories, configFile, configDir };
672
809
  }
673
810
  };
674
811
 
675
812
  // src/services/InteractiveUIService.tsx
676
813
  import React8 from "react";
677
- import * as path10 from "path";
814
+ import * as path14 from "path";
678
815
  import { render } from "ink";
679
816
  import * as cron2 from "node-cron";
680
817
  import pLimit2 from "p-limit";
@@ -1309,6 +1446,14 @@ function isLfsError(errorMessage) {
1309
1446
  function isLfsErrorFromError(error) {
1310
1447
  return isLfsError(getErrorMessage(error));
1311
1448
  }
1449
+ var MISSING_REMOTE_REF_PATTERNS = Object.freeze([
1450
+ "couldn't find remote ref",
1451
+ "Couldn't find remote ref",
1452
+ "not our ref"
1453
+ ]);
1454
+ function isMissingRemoteRefError(errorMessage) {
1455
+ return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
1456
+ }
1312
1457
 
1313
1458
  // src/components/WorktreeStatusView.tsx
1314
1459
  var getStatusFlags = (status) => {
@@ -1772,7 +1917,7 @@ var App = ({
1772
1917
  const [diskSpaceUsed, setDiskSpaceUsed] = useState6(null);
1773
1918
  const [logs, setLogs] = useState6([]);
1774
1919
  const [repoCount, setRepoCount] = useState6(repositoryCount);
1775
- const [schedule3, setSchedule] = useState6(cronSchedule);
1920
+ const [schedule2, setSchedule] = useState6(cronSchedule);
1776
1921
  const { stdout } = useStdout();
1777
1922
  const addLog = useCallback4((message, level = "info") => {
1778
1923
  setLogs((prev) => {
@@ -1941,67 +2086,13 @@ var App = ({
1941
2086
  status,
1942
2087
  repositoryCount: repoCount,
1943
2088
  lastSyncTime,
1944
- cronSchedule: schedule3,
2089
+ cronSchedule: schedule2,
1945
2090
  diskSpaceUsed: diskSpaceUsed ?? void 0
1946
2091
  }
1947
2092
  ));
1948
2093
  };
1949
2094
  var App_default = App;
1950
2095
 
1951
- // src/services/worktree-sync.service.ts
1952
- import * as fs6 from "fs/promises";
1953
- import * as path8 from "path";
1954
- import pLimit from "p-limit";
1955
- import * as lockfile from "proper-lockfile";
1956
-
1957
- // src/utils/date-filter.ts
1958
- function parseDuration(durationStr) {
1959
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
1960
- if (!match) {
1961
- return null;
1962
- }
1963
- const value = parseInt(match[1], 10);
1964
- const unit = match[2];
1965
- const multipliers = {
1966
- h: 60 * 60 * 1e3,
1967
- // hours
1968
- d: 24 * 60 * 60 * 1e3,
1969
- // days
1970
- w: 7 * 24 * 60 * 60 * 1e3,
1971
- // weeks
1972
- m: 30 * 24 * 60 * 60 * 1e3,
1973
- // months (approximate)
1974
- y: 365 * 24 * 60 * 60 * 1e3
1975
- // years (approximate)
1976
- };
1977
- return value * multipliers[unit];
1978
- }
1979
- function filterBranchesByAge(branches, maxAge) {
1980
- const maxAgeMs = parseDuration(maxAge);
1981
- if (maxAgeMs === null) {
1982
- console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
1983
- return branches;
1984
- }
1985
- const cutoffDate = new Date(Date.now() - maxAgeMs);
1986
- return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
1987
- }
1988
- function formatDuration(durationStr) {
1989
- const match = durationStr.match(/^(\d+)([hdwmy])$/);
1990
- if (!match) {
1991
- return durationStr;
1992
- }
1993
- const value = parseInt(match[1], 10);
1994
- const unit = match[2];
1995
- const unitNames = {
1996
- h: value === 1 ? "hour" : "hours",
1997
- d: value === 1 ? "day" : "days",
1998
- w: value === 1 ? "week" : "weeks",
1999
- m: value === 1 ? "month" : "months",
2000
- y: value === 1 ? "year" : "years"
2001
- };
2002
- return `${value} ${unitNames[unit]}`;
2003
- }
2004
-
2005
2096
  // src/utils/retry.ts
2006
2097
  var DEFAULT_OPTIONS = {
2007
2098
  maxAttempts: "unlimited",
@@ -2072,7 +2163,7 @@ async function retry(fn, options = {}) {
2072
2163
  const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
2073
2164
  const delay = baseDelay + jitter;
2074
2165
  opts.onRetry(error, attempt, lfsContext);
2075
- await new Promise((resolve9) => setTimeout(resolve9, delay));
2166
+ await new Promise((resolve12) => setTimeout(resolve12, delay));
2076
2167
  attempt++;
2077
2168
  }
2078
2169
  }
@@ -2143,7 +2234,7 @@ var PhaseTimer = class {
2143
2234
  return results;
2144
2235
  }
2145
2236
  };
2146
- function formatDuration2(ms) {
2237
+ function formatDuration(ms) {
2147
2238
  if (ms < 1e3) {
2148
2239
  return `${ms}ms`;
2149
2240
  }
@@ -2165,7 +2256,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2165
2256
  }
2166
2257
  });
2167
2258
  table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
2168
- table.push(["Total Sync", formatDuration2(totalDuration), ""]);
2259
+ table.push(["Total Sync", formatDuration(totalDuration), ""]);
2169
2260
  for (let i = 0; i < phaseResults.length; i++) {
2170
2261
  const result = phaseResults[i];
2171
2262
  const isLast = i === phaseResults.length - 1;
@@ -2173,34 +2264,888 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2173
2264
  const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
2174
2265
  const name = ` ${prefix} ${result.name}${countStr}`;
2175
2266
  const efficiency = result.efficiency ? `${result.efficiency}%` : "";
2176
- table.push([name, formatDuration2(result.duration), efficiency]);
2267
+ table.push([name, formatDuration(result.duration), efficiency]);
2177
2268
  }
2178
2269
  return table.toString();
2179
2270
  }
2180
2271
 
2181
- // src/services/git.service.ts
2182
- import * as fs4 from "fs/promises";
2183
- import * as path6 from "path";
2184
- import simpleGit4 from "simple-git";
2272
+ // src/services/clone-sync.service.ts
2273
+ import * as fs3 from "fs/promises";
2274
+ import * as path4 from "path";
2275
+ import simpleGit from "simple-git";
2185
2276
 
2186
- // src/utils/worktree-list-parser.ts
2187
- function parseWorktreeListPorcelain(output) {
2188
- const worktrees = [];
2189
- let current = {};
2190
- const flush = () => {
2191
- if (!current.path) {
2192
- current = {};
2193
- return;
2194
- }
2195
- worktrees.push({
2196
- path: current.path,
2197
- branch: current.branch ?? null,
2198
- head: current.head ?? null,
2199
- detached: current.detached ?? false,
2200
- prunable: current.prunable ?? false,
2201
- locked: current.locked ?? false
2277
+ // src/utils/git-progress.ts
2278
+ function makeGitProgressHandler(logger, emitProgress) {
2279
+ const lastBucket = /* @__PURE__ */ new Map();
2280
+ return (event) => {
2281
+ if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
2282
+ const key = `${event.method}:${event.stage}`;
2283
+ const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
2284
+ let last = lastBucket.get(key) ?? -1;
2285
+ if (bucket < last) last = -1;
2286
+ if (bucket <= last && event.progress < 100) return;
2287
+ lastBucket.set(key, bucket);
2288
+ const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
2289
+ const message = `${event.method} ${event.stage}: ${event.progress}% (${total})`;
2290
+ logger.info(` \u21B3 ${message}`);
2291
+ emitProgress?.({
2292
+ phase: event.method,
2293
+ message,
2294
+ progress: event.progress,
2295
+ processed: event.processed,
2296
+ total: event.total
2202
2297
  });
2203
- current = {};
2298
+ };
2299
+ }
2300
+
2301
+ // src/services/file-copy.service.ts
2302
+ import * as fs2 from "fs/promises";
2303
+ import * as path3 from "path";
2304
+ import { glob } from "glob";
2305
+ var DEFAULT_IGNORE_PATTERNS = [
2306
+ "**/node_modules/**",
2307
+ "**/.git/**",
2308
+ "**/dist/**",
2309
+ "**/build/**",
2310
+ "**/.next/**",
2311
+ "**/coverage/**"
2312
+ ];
2313
+ var FileCopyService = class {
2314
+ /**
2315
+ * Copy files matching patterns from source to destination directory.
2316
+ * Skips files that already exist at destination.
2317
+ * Preserves directory structure relative to source.
2318
+ */
2319
+ async copyFiles(sourceDir, destDir, patterns) {
2320
+ const result = {
2321
+ copied: [],
2322
+ skipped: [],
2323
+ errors: []
2324
+ };
2325
+ if (!patterns || patterns.length === 0) {
2326
+ return result;
2327
+ }
2328
+ const filesToCopy = await this.expandPatterns(sourceDir, patterns);
2329
+ for (const relativePath of filesToCopy) {
2330
+ const sourcePath = path3.join(sourceDir, relativePath);
2331
+ const destPath = path3.join(destDir, relativePath);
2332
+ try {
2333
+ const copied = await this.copyFile(sourcePath, destPath);
2334
+ if (copied) {
2335
+ result.copied.push(relativePath);
2336
+ } else {
2337
+ result.skipped.push(relativePath);
2338
+ }
2339
+ } catch (error) {
2340
+ result.errors.push({
2341
+ file: relativePath,
2342
+ error: error instanceof Error ? error.message : String(error)
2343
+ });
2344
+ }
2345
+ }
2346
+ return result;
2347
+ }
2348
+ async expandPatterns(sourceDir, patterns) {
2349
+ const allFiles = /* @__PURE__ */ new Set();
2350
+ for (const pattern of patterns) {
2351
+ try {
2352
+ const matches = await glob(pattern, {
2353
+ cwd: sourceDir,
2354
+ nodir: true,
2355
+ dot: true,
2356
+ ignore: DEFAULT_IGNORE_PATTERNS
2357
+ });
2358
+ for (const match of matches) {
2359
+ allFiles.add(match);
2360
+ }
2361
+ } catch {
2362
+ }
2363
+ }
2364
+ return Array.from(allFiles);
2365
+ }
2366
+ async copyFile(sourcePath, destPath) {
2367
+ if (await fileExists(destPath)) {
2368
+ return false;
2369
+ }
2370
+ const destDir = path3.dirname(destPath);
2371
+ await fs2.mkdir(destDir, { recursive: true });
2372
+ await fs2.copyFile(sourcePath, destPath);
2373
+ return true;
2374
+ }
2375
+ };
2376
+
2377
+ // src/services/branch-created-actions.service.ts
2378
+ var BranchCreatedActionsService = class {
2379
+ fileCopyService;
2380
+ constructor(fileCopyService) {
2381
+ this.fileCopyService = fileCopyService ?? new FileCopyService();
2382
+ }
2383
+ async copyFiles(params) {
2384
+ const { config, sourceDir, worktreePath, branchName, logger } = params;
2385
+ const patterns = config.filesToCopyOnBranchCreate;
2386
+ if (!patterns?.length) return;
2387
+ try {
2388
+ const result = await this.fileCopyService.copyFiles(sourceDir, worktreePath, patterns);
2389
+ if (result.copied.length > 0) {
2390
+ logger.info(`\u{1F4CB} Copied ${result.copied.length} file(s) to '${branchName}': ${result.copied.join(", ")}`);
2391
+ }
2392
+ if (result.errors.length > 0) {
2393
+ logger.warn(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s) to '${branchName}':`);
2394
+ for (const err of result.errors) {
2395
+ logger.warn(` - ${err.file}: ${err.error}`);
2396
+ }
2397
+ }
2398
+ } catch (error) {
2399
+ logger.error(`Failed to copy files to '${branchName}': ${error}`);
2400
+ }
2401
+ }
2402
+ runHooks(params) {
2403
+ const { config, branchName, worktreePath, repoName, baseBranch, logger, hookExecutionService } = params;
2404
+ if (!config.hooks?.onBranchCreated?.length) return;
2405
+ const context = {
2406
+ branchName,
2407
+ worktreePath,
2408
+ repoName,
2409
+ baseBranch,
2410
+ repoUrl: config.repoUrl
2411
+ };
2412
+ logger.info(`Running ${config.hooks.onBranchCreated.length} hook(s) for branch '${branchName}'...`);
2413
+ hookExecutionService.executeOnBranchCreated(config.hooks, context, {
2414
+ onStdout: (data) => logger.info(`[hook] ${data}`),
2415
+ onStderr: (data) => logger.warn(`[hook] ${data}`),
2416
+ onError: (command, error) => logger.error(`[hook] Failed to execute '${command}': ${error.message}`),
2417
+ onComplete: (command, exitCode) => {
2418
+ if (exitCode === 0) {
2419
+ logger.info(`[hook] Command completed successfully`);
2420
+ } else if (exitCode !== null) {
2421
+ logger.warn(`[hook] Command exited with code ${exitCode}`);
2422
+ }
2423
+ }
2424
+ });
2425
+ }
2426
+ };
2427
+
2428
+ // src/utils/clone-skip-format.ts
2429
+ function formatCloneSkipReason(reason) {
2430
+ switch (reason.kind) {
2431
+ case "branch_mismatch":
2432
+ return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
2433
+ case "head_unreadable":
2434
+ return `could not read HEAD: ${reason.error}`;
2435
+ case "dirty_tree":
2436
+ return `working tree has local changes`;
2437
+ case "diverged":
2438
+ return `diverged from origin/${reason.branch}`;
2439
+ case "ahead_unpushed":
2440
+ return `unpushed commits ahead of origin/${reason.branch}`;
2441
+ case "missing_remote_ref":
2442
+ return reason.source === "fetch_error" ? `origin/${reason.branch} missing on remote (fetch error)` : `origin/${reason.branch} pruned after fetch`;
2443
+ case "indeterminate_shallow":
2444
+ 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`;
2445
+ case "origin_mismatch":
2446
+ return `clone origin is '${reason.actual}', expected '${reason.expected}'`;
2447
+ default: {
2448
+ const _exhaustive = reason;
2449
+ return _exhaustive;
2450
+ }
2451
+ }
2452
+ }
2453
+
2454
+ // src/services/sync-outcome.ts
2455
+ var EMPTY_COUNTS = {
2456
+ created: 0,
2457
+ removed: 0,
2458
+ updated: 0,
2459
+ skipped: 0,
2460
+ preserved: 0,
2461
+ failed: 0,
2462
+ noop: 0
2463
+ };
2464
+ function cloneCounts(counts) {
2465
+ return { ...counts };
2466
+ }
2467
+ function cloneAction(action) {
2468
+ return { ...action };
2469
+ }
2470
+ function countKeyFor(action) {
2471
+ switch (action.kind) {
2472
+ case "created":
2473
+ return "created";
2474
+ case "removed":
2475
+ return "removed";
2476
+ case "updated":
2477
+ return "updated";
2478
+ case "skipped":
2479
+ return "skipped";
2480
+ case "preserved-diverged":
2481
+ return "preserved";
2482
+ case "failed":
2483
+ return "failed";
2484
+ case "noop":
2485
+ return "noop";
2486
+ default: {
2487
+ const _exhaustive = action;
2488
+ return _exhaustive;
2489
+ }
2490
+ }
2491
+ }
2492
+ var SyncOutcomeAccumulator = class {
2493
+ constructor(options) {
2494
+ this.options = options;
2495
+ }
2496
+ counts = cloneCounts(EMPTY_COUNTS);
2497
+ actions = [];
2498
+ add(action) {
2499
+ this.actions.push(action);
2500
+ this.counts[countKeyFor(action)]++;
2501
+ }
2502
+ recordCreated(branch, path18) {
2503
+ this.add({ kind: "created", branch, path: path18 });
2504
+ }
2505
+ recordRemoved(branch, path18) {
2506
+ this.add({ kind: "removed", branch, path: path18 });
2507
+ }
2508
+ recordUpdated(branch, path18, reason) {
2509
+ this.add({ kind: "updated", branch, path: path18, reason });
2510
+ }
2511
+ recordNoop(scope, reason, details) {
2512
+ this.add({ kind: "noop", scope, reason, ...details });
2513
+ }
2514
+ recordSkipped(scope, reason, details) {
2515
+ this.add({ kind: "skipped", scope, reason, ...details });
2516
+ }
2517
+ recordPreservedDiverged(branch, path18, preservedPath) {
2518
+ this.add({ kind: "preserved-diverged", branch, path: path18, preservedPath });
2519
+ }
2520
+ recordFailed(scope, error, details = {}) {
2521
+ this.add({ kind: "failed", scope, error, ...details });
2522
+ }
2523
+ getCounts() {
2524
+ return cloneCounts(this.counts);
2525
+ }
2526
+ snapshot() {
2527
+ return {
2528
+ counts: cloneCounts(this.counts),
2529
+ actions: this.actions.map(cloneAction)
2530
+ };
2531
+ }
2532
+ restore(snapshot) {
2533
+ this.counts = cloneCounts(snapshot.counts);
2534
+ this.actions = snapshot.actions.map(cloneAction);
2535
+ }
2536
+ toOutcome(durationMs) {
2537
+ return {
2538
+ repoName: this.options.repoName,
2539
+ mode: this.options.mode,
2540
+ started: true,
2541
+ counts: cloneCounts(this.counts),
2542
+ actions: this.actions.map(cloneAction),
2543
+ durationMs
2544
+ };
2545
+ }
2546
+ };
2547
+ function cloneSkipToOutcomeAction(reason, details = {}) {
2548
+ const message = formatCloneSkipReason(reason);
2549
+ const branch = "branch" in reason ? reason.branch : reason.kind === "branch_mismatch" ? reason.expectedBranch : details.branch;
2550
+ return {
2551
+ kind: "skipped",
2552
+ scope: "repo",
2553
+ reason: `clone_${reason.kind}`,
2554
+ branch,
2555
+ path: details.path,
2556
+ message
2557
+ };
2558
+ }
2559
+
2560
+ // src/services/clone-sync.service.ts
2561
+ var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
2562
+ var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
2563
+ var CloneSyncService = class {
2564
+ constructor(config, gitService, logger, options = {}) {
2565
+ this.config = config;
2566
+ this.gitService = gitService;
2567
+ this.logger = logger;
2568
+ this.branchCreatedActions = options.branchCreatedActions ?? new BranchCreatedActionsService();
2569
+ this.progressEmitter = options.progressEmitter;
2570
+ this.onSkip = options.onSkip;
2571
+ }
2572
+ initialized = false;
2573
+ resolvedBranch = null;
2574
+ branchCreatedActions;
2575
+ progressEmitter;
2576
+ onSkip;
2577
+ outcomeAccumulator;
2578
+ // One-shot suppression token. When init records a wrong-branch / unreadable-HEAD
2579
+ // skip for an existing clone, it sets this so the immediately following
2580
+ // runSyncAttempt (same sync operation) does not record the identical skip again.
2581
+ pendingInitSkip = null;
2582
+ updateLogger(logger) {
2583
+ this.logger = logger;
2584
+ }
2585
+ isInitialized() {
2586
+ return this.initialized;
2587
+ }
2588
+ clearPendingInitSkip() {
2589
+ this.pendingInitSkip = null;
2590
+ }
2591
+ async getWorktrees() {
2592
+ const worktreeDir = path4.resolve(this.config.worktreeDir);
2593
+ if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
2594
+ return [];
2595
+ }
2596
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2597
+ let branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
2598
+ if (!branch || branch === "HEAD") {
2599
+ const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
2600
+ branch = head ? `(detached ${head})` : "(detached)";
2601
+ }
2602
+ return [{ path: worktreeDir, branch }];
2603
+ }
2604
+ get repoName() {
2605
+ return this.config.name ?? this.config.repoUrl;
2606
+ }
2607
+ getCloneTimeoutMs() {
2608
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
2609
+ return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
2610
+ }
2611
+ getFetchTimeoutMs() {
2612
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
2613
+ return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
2614
+ }
2615
+ isLfsSkipEnabled() {
2616
+ return this.config.skipLfs === true;
2617
+ }
2618
+ buildGitOptions(blockMs) {
2619
+ const options = {
2620
+ progress: makeGitProgressHandler(this.logger, (event) => this.emitProgress(event))
2621
+ };
2622
+ if (blockMs > 0) options.timeout = { block: blockMs };
2623
+ return options;
2624
+ }
2625
+ emitProgress(event) {
2626
+ try {
2627
+ this.progressEmitter?.(event);
2628
+ } catch {
2629
+ }
2630
+ }
2631
+ async withOutcome(outcome, operation) {
2632
+ const previousOutcome = this.outcomeAccumulator;
2633
+ if (outcome) {
2634
+ this.outcomeAccumulator = outcome;
2635
+ }
2636
+ try {
2637
+ return await operation();
2638
+ } finally {
2639
+ if (outcome) {
2640
+ this.outcomeAccumulator = previousOutcome;
2641
+ }
2642
+ }
2643
+ }
2644
+ recordSkip(reason, logMessage, progressMessage, logLevel = "warn") {
2645
+ if (logLevel === "warn") {
2646
+ this.logger.warn(logMessage);
2647
+ } else {
2648
+ this.logger.info(logMessage);
2649
+ }
2650
+ this.emitProgress({ phase: "skip", message: progressMessage ?? logMessage });
2651
+ try {
2652
+ this.onSkip?.(reason);
2653
+ } catch {
2654
+ }
2655
+ this.outcomeAccumulator?.add(
2656
+ cloneSkipToOutcomeAction(reason, {
2657
+ branch: this.resolvedBranch ?? this.config.branch,
2658
+ path: this.config.worktreeDir
2659
+ })
2660
+ );
2661
+ }
2662
+ clientFor(dir, blockMs) {
2663
+ return simpleGit(dir, this.buildGitOptions(blockMs)).env(this.buildGitEnv());
2664
+ }
2665
+ // Force a stable C locale so git's stderr is deterministic English. The
2666
+ // missing-remote-ref and LFS error classification matches on those strings
2667
+ // and would otherwise misfire under a non-English LANG/LC_ALL. simple-git's
2668
+ // .env() merges this object with process.env (PATH etc. preserved).
2669
+ buildGitEnv(opts = {}) {
2670
+ const env = { LC_ALL: "C", LANG: "C" };
2671
+ if (opts.forceLfsSkip || this.isLfsSkipEnabled()) {
2672
+ env[ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE] = "1";
2673
+ }
2674
+ return env;
2675
+ }
2676
+ buildCloneArgs(branch) {
2677
+ const args = ["--branch", branch, "--progress"];
2678
+ if (this.config.depth !== void 0) {
2679
+ args.push("--depth", String(this.config.depth), "--no-single-branch");
2680
+ }
2681
+ return args;
2682
+ }
2683
+ async buildFetchArgs(git) {
2684
+ const args = ["origin", "--prune", "--progress"];
2685
+ if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
2686
+ args.push("--depth", String(this.config.depth));
2687
+ }
2688
+ return args;
2689
+ }
2690
+ async ensureAllRemoteBranchesRefspec(git) {
2691
+ let fetchRefspecs = [];
2692
+ try {
2693
+ const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
2694
+ fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2695
+ } catch {
2696
+ fetchRefspecs = [];
2697
+ }
2698
+ if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
2699
+ const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
2700
+ this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
2701
+ await git.raw(["remote", "set-branches", "origin", "*"]);
2702
+ for (const refspec of customRefspecs) {
2703
+ await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
2704
+ }
2705
+ }
2706
+ isOriginRemoteBranchTrackingRefspec(refspec) {
2707
+ const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
2708
+ if (withoutForce.startsWith("^")) return false;
2709
+ const [source, destination] = withoutForce.split(":");
2710
+ return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
2711
+ }
2712
+ recordMissingRemoteRefSkip(branch) {
2713
+ this.recordSkip(
2714
+ { kind: "missing_remote_ref", branch, source: "fetch_error" },
2715
+ `Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
2716
+ `Skipping '${this.repoName}': origin/${branch} is missing`
2717
+ );
2718
+ }
2719
+ async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
2720
+ try {
2721
+ await git.fetch(fetchArgs);
2722
+ return { skipped: false };
2723
+ } catch (fetchError) {
2724
+ const message = getErrorMessage(fetchError);
2725
+ if (isLfsError(message)) {
2726
+ this.logger.info(`\u26A0\uFE0F LFS error during fetch for '${this.repoName}'; retrying with LFS disabled.`);
2727
+ this.emitProgress({ phase: "fetch", message: `Retrying fetch for '${this.repoName}' with LFS disabled` });
2728
+ const lfsSkipGit = simpleGit(worktreeDir, this.buildGitOptions(this.getFetchTimeoutMs())).env(
2729
+ this.buildGitEnv({ forceLfsSkip: true })
2730
+ );
2731
+ try {
2732
+ await lfsSkipGit.fetch(fetchArgs);
2733
+ return { skipped: false };
2734
+ } catch (retryError) {
2735
+ if (isMissingRemoteRefError(getErrorMessage(retryError))) {
2736
+ this.recordMissingRemoteRefSkip(branch);
2737
+ return { skipped: true };
2738
+ }
2739
+ throw retryError;
2740
+ }
2741
+ }
2742
+ if (isMissingRemoteRefError(message)) {
2743
+ this.recordMissingRemoteRefSkip(branch);
2744
+ return { skipped: true };
2745
+ }
2746
+ throw fetchError;
2747
+ }
2748
+ }
2749
+ async hasRemoteBranch(git, branch) {
2750
+ try {
2751
+ await git.raw(["show-ref", "--verify", `refs/remotes/origin/${branch}`]);
2752
+ return true;
2753
+ } catch {
2754
+ return false;
2755
+ }
2756
+ }
2757
+ async isShallowRepository(git) {
2758
+ try {
2759
+ const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
2760
+ return output.trim() === "true";
2761
+ } catch {
2762
+ return false;
2763
+ }
2764
+ }
2765
+ async unshallowIfDepthRemoved(git) {
2766
+ if (this.config.depth !== void 0) return;
2767
+ if (!await this.isShallowRepository(git)) return;
2768
+ this.logger.info(
2769
+ `[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
2770
+ );
2771
+ await git.fetch(["--unshallow"]);
2772
+ }
2773
+ getDeepenTargets() {
2774
+ const configuredDepth = this.config.depth;
2775
+ if (configuredDepth === void 0) return [];
2776
+ return SHALLOW_RELATION_DEEPEN_TARGETS.filter((target) => target > configuredDepth);
2777
+ }
2778
+ async deepenShallowHistoryToDepth(git, branch, targetDepth) {
2779
+ this.logger.info(
2780
+ `[deepen] Shallow clone for '${this.repoName}' lacks enough history to classify origin/${branch}; refetching to depth ${targetDepth} before deciding.`
2781
+ );
2782
+ this.emitProgress({
2783
+ phase: "fetch",
2784
+ message: `Deepening '${this.repoName}' to depth ${targetDepth} before classifying origin/${branch}`
2785
+ });
2786
+ await git.fetch([
2787
+ "origin",
2788
+ "--depth",
2789
+ String(targetDepth),
2790
+ "--prune",
2791
+ "--progress",
2792
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`
2793
+ ]);
2794
+ }
2795
+ async resolveBranch() {
2796
+ if (this.resolvedBranch) return this.resolvedBranch;
2797
+ if (this.config.branch) {
2798
+ this.resolvedBranch = this.config.branch;
2799
+ this.emitProgress({ phase: "branch", message: `Using configured branch '${this.resolvedBranch}'` });
2800
+ return this.resolvedBranch;
2801
+ }
2802
+ this.logger.info(`No branch configured for '${this.repoName}', detecting remote default branch...`);
2803
+ this.emitProgress({ phase: "branch", message: `Resolving remote default branch for '${this.repoName}'` });
2804
+ this.resolvedBranch = await this.gitService.getRemoteDefaultBranch(this.config.repoUrl);
2805
+ this.logger.info(` \u21B3 resolved default branch: ${this.resolvedBranch}`);
2806
+ this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
2807
+ return this.resolvedBranch;
2808
+ }
2809
+ async initialize(outcome) {
2810
+ return this.withOutcome(outcome, () => this.initializeInternal());
2811
+ }
2812
+ async initializeInternal() {
2813
+ this.pendingInitSkip = null;
2814
+ const branch = await this.resolveBranch();
2815
+ const worktreeDir = this.config.worktreeDir;
2816
+ let entries = null;
2817
+ try {
2818
+ entries = await fs3.readdir(worktreeDir);
2819
+ } catch {
2820
+ entries = null;
2821
+ }
2822
+ if (entries?.includes(PATH_CONSTANTS.GIT_DIR)) {
2823
+ this.emitProgress({ phase: "clone", message: `Validating existing clone for '${this.repoName}'` });
2824
+ const result = await this.validateExistingClone(branch);
2825
+ if (!result.valid) {
2826
+ this.recordSkip(result.skip, result.warnMessage, `Skipping '${this.repoName}': ${result.progressDetail}`);
2827
+ this.pendingInitSkip = result.skip;
2828
+ this.initialized = true;
2829
+ return;
2830
+ }
2831
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2832
+ await this.ensureAllRemoteBranchesRefspec(git);
2833
+ this.initialized = true;
2834
+ this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
2835
+ return;
2836
+ }
2837
+ if (entries && entries.length > 0) {
2838
+ throw new ConfigError(
2839
+ `Cannot clone into '${worktreeDir}': directory exists and is not empty. Remove existing contents or point worktreeDir at an empty path.`,
2840
+ "CLONE_DESTINATION_NOT_EMPTY"
2841
+ );
2842
+ }
2843
+ const cloneCreatedDir = entries === null;
2844
+ await fs3.mkdir(worktreeDir, { recursive: true });
2845
+ this.logger.info(`Cloning '${this.config.repoUrl}' (${branch}) into '${worktreeDir}'...`);
2846
+ this.emitProgress({ phase: "clone", message: `Cloning '${this.repoName}' (${branch})` });
2847
+ const cloneClient = simpleGit(this.buildGitOptions(this.getCloneTimeoutMs())).env(this.buildGitEnv());
2848
+ try {
2849
+ await cloneClient.clone(this.config.repoUrl, worktreeDir, this.buildCloneArgs(branch));
2850
+ } catch (error) {
2851
+ await this.maybeCleanupPartialClone(worktreeDir, cloneCreatedDir);
2852
+ this.outcomeAccumulator?.recordFailed("repo", getErrorMessage(error), {
2853
+ reason: "clone_failed",
2854
+ branch,
2855
+ path: worktreeDir
2856
+ });
2857
+ throw error;
2858
+ }
2859
+ const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2860
+ await this.ensureAllRemoteBranchesRefspec(worktreeGit);
2861
+ this.logger.info(`\u2705 Clone successful.`);
2862
+ this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
2863
+ if (this.config.sparseCheckout) {
2864
+ this.logger.info(`Applying sparse-checkout patterns to '${worktreeDir}'...`);
2865
+ this.emitProgress({ phase: "sparse_checkout", message: `Applying sparse-checkout for '${this.repoName}'` });
2866
+ const sparseService = this.gitService.getSparseCheckoutService();
2867
+ await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
2868
+ await worktreeGit.raw(["checkout", "HEAD"]);
2869
+ this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout applied for '${this.repoName}'` });
2870
+ }
2871
+ this.emitProgress({ phase: "lfs", message: `Verifying LFS for '${this.repoName}'` });
2872
+ await this.gitService.verifyLfs(worktreeDir, branch);
2873
+ this.emitProgress({ phase: "lfs", message: `LFS verified for '${this.repoName}'` });
2874
+ await this.runInitialFileCopy(worktreeDir, branch);
2875
+ this.outcomeAccumulator?.recordCreated(branch, worktreeDir);
2876
+ this.initialized = true;
2877
+ }
2878
+ // Detects an on-disk clone whose `origin` no longer matches the configured
2879
+ // repoUrl (e.g. repoUrl was repointed in config). Returns a skip descriptor so
2880
+ // we never fetch/ff-merge from the wrong remote; null when origin matches or
2881
+ // can't be read. Comparison is normalized so https/.git/trailing-slash
2882
+ // variants don't false-positive; the raw URLs are kept in the message.
2883
+ async evaluateOriginMatch(git, worktreeDir) {
2884
+ let originUrl;
2885
+ try {
2886
+ originUrl = (await git.raw(["remote", "get-url", "origin"])).trim();
2887
+ } catch {
2888
+ this.logger.warn(`Could not read 'origin' remote URL from existing clone at '${worktreeDir}'.`);
2889
+ return null;
2890
+ }
2891
+ if (!originUrl || normalizeRepoUrlForComparison(originUrl) === normalizeRepoUrlForComparison(this.config.repoUrl)) {
2892
+ return null;
2893
+ }
2894
+ return {
2895
+ skip: { kind: "origin_mismatch", actual: originUrl, expected: this.config.repoUrl },
2896
+ 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.`,
2897
+ progressDetail: `origin '${originUrl}' is not '${this.config.repoUrl}'`
2898
+ };
2899
+ }
2900
+ async validateExistingClone(expectedBranch) {
2901
+ const worktreeDir = this.config.worktreeDir;
2902
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2903
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
2904
+ if (originMismatch) {
2905
+ return { valid: false, ...originMismatch };
2906
+ }
2907
+ let currentBranch;
2908
+ try {
2909
+ currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
2910
+ } catch (error) {
2911
+ const errorMessage = getErrorMessage(error);
2912
+ return {
2913
+ valid: false,
2914
+ skip: { kind: "head_unreadable", phase: "init", error: errorMessage },
2915
+ warnMessage: `Existing clone at '${worktreeDir}' has a .git folder but reading HEAD failed: ${errorMessage}`,
2916
+ progressDetail: `could not read HEAD (${errorMessage})`
2917
+ };
2918
+ }
2919
+ if (currentBranch !== expectedBranch) {
2920
+ return {
2921
+ valid: false,
2922
+ skip: {
2923
+ kind: "branch_mismatch",
2924
+ phase: "init",
2925
+ currentBranch,
2926
+ expectedBranch
2927
+ },
2928
+ warnMessage: `Existing clone at '${worktreeDir}' is on branch '${currentBranch}', expected '${expectedBranch}'. Switch the working tree to '${expectedBranch}' or update the config.`,
2929
+ progressDetail: `current branch '${currentBranch}' is not '${expectedBranch}'`
2930
+ };
2931
+ }
2932
+ return { valid: true };
2933
+ }
2934
+ async maybeCleanupPartialClone(worktreeDir, cloneCreatedDir) {
2935
+ if (!cloneCreatedDir) {
2936
+ this.logger.warn(
2937
+ `Clone failed; leaving '${worktreeDir}' for manual inspection (directory existed before clone attempt).`
2938
+ );
2939
+ return;
2940
+ }
2941
+ let entries;
2942
+ try {
2943
+ entries = await fs3.readdir(worktreeDir);
2944
+ } catch {
2945
+ return;
2946
+ }
2947
+ const looksIncomplete = entries.every((e) => e.startsWith("."));
2948
+ const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
2949
+ if (looksIncomplete && !hasUsableGit) {
2950
+ try {
2951
+ await fs3.rm(worktreeDir, { recursive: true, force: true });
2952
+ this.logger.info(`Cleaned up incomplete clone at '${worktreeDir}'.`);
2953
+ } catch (rmError) {
2954
+ this.logger.warn(`Failed to clean up incomplete clone at '${worktreeDir}': ${getErrorMessage(rmError)}`);
2955
+ }
2956
+ } else {
2957
+ this.logger.warn(
2958
+ `Clone failed; leaving '${worktreeDir}' for manual inspection (post-failure contents do not look like an empty incomplete clone).`
2959
+ );
2960
+ }
2961
+ }
2962
+ getInitMarkerPath(worktreeDir) {
2963
+ return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
2964
+ }
2965
+ async runInitialFileCopy(worktreeDir, branch) {
2966
+ const marker = this.getInitMarkerPath(worktreeDir);
2967
+ if (await fileExists(marker)) {
2968
+ return;
2969
+ }
2970
+ const sourceDir = this.config.__configFileDir ?? worktreeDir;
2971
+ await this.branchCreatedActions.copyFiles({
2972
+ config: this.config,
2973
+ branchName: branch,
2974
+ worktreePath: worktreeDir,
2975
+ sourceDir,
2976
+ logger: this.logger
2977
+ });
2978
+ try {
2979
+ await fs3.writeFile(marker, (/* @__PURE__ */ new Date()).toISOString());
2980
+ } catch (error) {
2981
+ this.logger.warn(`Could not write clone-init marker: ${getErrorMessage(error)}`);
2982
+ }
2983
+ }
2984
+ async runSyncAttempt(outcome) {
2985
+ return this.withOutcome(outcome, () => this.runSyncAttemptInternal());
2986
+ }
2987
+ async runSyncAttemptInternal() {
2988
+ if (!this.initialized) {
2989
+ await this.initialize();
2990
+ this.pendingInitSkip = null;
2991
+ return;
2992
+ }
2993
+ if (this.pendingInitSkip) {
2994
+ this.pendingInitSkip = null;
2995
+ return;
2996
+ }
2997
+ const branch = await this.resolveBranch();
2998
+ const worktreeDir = this.config.worktreeDir;
2999
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
3000
+ let currentBranch;
3001
+ try {
3002
+ currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
3003
+ } catch (error) {
3004
+ const errorMessage = getErrorMessage(error);
3005
+ this.recordSkip(
3006
+ { kind: "head_unreadable", phase: "sync", error: errorMessage },
3007
+ `Could not read current branch from '${worktreeDir}': ${errorMessage}`,
3008
+ `Skipping '${this.repoName}': could not read current branch`
3009
+ );
3010
+ return;
3011
+ }
3012
+ if (currentBranch !== branch) {
3013
+ this.recordSkip(
3014
+ { kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
3015
+ `Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
3016
+ `Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
3017
+ );
3018
+ return;
3019
+ }
3020
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
3021
+ if (originMismatch) {
3022
+ this.recordSkip(
3023
+ originMismatch.skip,
3024
+ originMismatch.warnMessage,
3025
+ `Skipping '${this.repoName}': ${originMismatch.progressDetail}`
3026
+ );
3027
+ return;
3028
+ }
3029
+ await this.unshallowIfDepthRemoved(git);
3030
+ await this.ensureAllRemoteBranchesRefspec(git);
3031
+ const fetchArgs = await this.buildFetchArgs(git);
3032
+ this.emitProgress({ phase: "fetch", message: `Fetching origin branches for '${this.repoName}'` });
3033
+ if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
3034
+ return;
3035
+ }
3036
+ this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
3037
+ if (!await this.hasRemoteBranch(git, branch)) {
3038
+ this.recordSkip(
3039
+ { kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
3040
+ `Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
3041
+ `Skipping '${this.repoName}': origin/${branch} is missing`
3042
+ );
3043
+ return;
3044
+ }
3045
+ if (this.config.sparseCheckout) {
3046
+ const sparseService = this.gitService.getSparseCheckoutService();
3047
+ try {
3048
+ if (await sparseService.needsUpdate(worktreeDir, this.config.sparseCheckout)) {
3049
+ this.emitProgress({ phase: "sparse_checkout", message: `Updating sparse-checkout for '${this.repoName}'` });
3050
+ await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
3051
+ this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout updated for '${this.repoName}'` });
3052
+ }
3053
+ } catch (error) {
3054
+ this.logger.warn(`Failed to reapply sparse-checkout for '${this.repoName}': ${getErrorMessage(error)}`);
3055
+ }
3056
+ }
3057
+ const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
3058
+ if (!isClean) {
3059
+ this.recordSkip(
3060
+ { kind: "dirty_tree" },
3061
+ `\u23ED\uFE0F Skipping ff-merge for '${this.repoName}' \u2014 working tree has local changes.`,
3062
+ `Skipping merge for '${this.repoName}': working tree has local changes`,
3063
+ "info"
3064
+ );
3065
+ return;
3066
+ }
3067
+ let relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
3068
+ let lastDeepenedTo = null;
3069
+ if (relationship === "indeterminate_shallow") {
3070
+ for (const target of this.getDeepenTargets()) {
3071
+ await this.deepenShallowHistoryToDepth(git, branch, target);
3072
+ lastDeepenedTo = target;
3073
+ relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
3074
+ if (relationship !== "indeterminate_shallow") break;
3075
+ }
3076
+ }
3077
+ if (relationship === "up_to_date") {
3078
+ this.logger.info(`'${this.repoName}' already up to date with origin/${branch}.`);
3079
+ this.emitProgress({
3080
+ phase: "skip",
3081
+ message: `'${this.repoName}' already up to date with origin/${branch}`
3082
+ });
3083
+ this.outcomeAccumulator?.recordNoop("repo", "already_up_to_date", {
3084
+ branch,
3085
+ path: worktreeDir,
3086
+ message: `Already up to date with origin/${branch}`
3087
+ });
3088
+ return;
3089
+ }
3090
+ if (relationship !== "fast_forward") {
3091
+ if (relationship === "local_ahead") {
3092
+ this.recordSkip(
3093
+ { kind: "ahead_unpushed", branch },
3094
+ `\u23ED\uFE0F '${this.repoName}' has unpushed commits ahead of origin/${branch}. Skipping merge.`,
3095
+ `Skipping merge for '${this.repoName}': unpushed commits ahead of origin/${branch}`,
3096
+ "info"
3097
+ );
3098
+ } else if (relationship === "indeterminate_shallow") {
3099
+ const detail = lastDeepenedTo === null ? `no deepening attempted (configured depth already at or above all deepen targets)` : `deepening to ${lastDeepenedTo} commits`;
3100
+ const progressDetail = lastDeepenedTo === null ? `no deepening attempted (configured depth at/above limits)` : `shallow depth budget exhausted at ${lastDeepenedTo}`;
3101
+ this.recordSkip(
3102
+ { kind: "indeterminate_shallow", branch, deepenedTo: lastDeepenedTo },
3103
+ `\u23ED\uFE0F '${this.repoName}' could not classify origin/${branch} after ${detail}. Skipping merge \u2014 consider removing or raising 'depth' to unshallow.`,
3104
+ `Skipping merge for '${this.repoName}': ${progressDetail}`,
3105
+ "info"
3106
+ );
3107
+ } else {
3108
+ this.recordSkip(
3109
+ { kind: "diverged", branch },
3110
+ `\u23ED\uFE0F '${this.repoName}' has diverged from origin/${branch}. Skipping merge (no auto-reset).`,
3111
+ `Skipping merge for '${this.repoName}': diverged from origin/${branch}`,
3112
+ "info"
3113
+ );
3114
+ }
3115
+ return;
3116
+ }
3117
+ this.logger.info(`Fast-forwarding '${this.repoName}' to origin/${branch}...`);
3118
+ this.emitProgress({ phase: "merge", message: `Fast-forwarding '${this.repoName}' to origin/${branch}` });
3119
+ await git.merge([`origin/${branch}`, "--ff-only"]);
3120
+ this.logger.info(`\u2705 Updated '${this.repoName}' to origin/${branch}.`);
3121
+ this.emitProgress({ phase: "merge", message: `Updated '${this.repoName}' to origin/${branch}` });
3122
+ this.outcomeAccumulator?.recordUpdated(branch, worktreeDir, "fast_forward");
3123
+ }
3124
+ };
3125
+
3126
+ // src/services/git.service.ts
3127
+ import * as fs6 from "fs/promises";
3128
+ import * as path8 from "path";
3129
+ import simpleGit5 from "simple-git";
3130
+
3131
+ // src/utils/worktree-list-parser.ts
3132
+ function parseWorktreeListPorcelain(output) {
3133
+ const worktrees = [];
3134
+ let current = {};
3135
+ const flush = () => {
3136
+ if (!current.path) {
3137
+ current = {};
3138
+ return;
3139
+ }
3140
+ worktrees.push({
3141
+ path: current.path,
3142
+ branch: current.branch ?? null,
3143
+ head: current.head ?? null,
3144
+ detached: current.detached ?? false,
3145
+ prunable: current.prunable ?? false,
3146
+ locked: current.locked ?? false
3147
+ });
3148
+ current = {};
2204
3149
  };
2205
3150
  for (const line of output.split("\n")) {
2206
3151
  if (line.startsWith("worktree ")) {
@@ -2324,8 +3269,8 @@ function defaultConsoleOutput(msg, level) {
2324
3269
  }
2325
3270
 
2326
3271
  // src/services/sparse-checkout.service.ts
2327
- import * as path3 from "path";
2328
- import simpleGit from "simple-git";
3272
+ import * as path5 from "path";
3273
+ import simpleGit2 from "simple-git";
2329
3274
  var SparseCheckoutService = class {
2330
3275
  logger;
2331
3276
  gitFactory;
@@ -2333,7 +3278,7 @@ var SparseCheckoutService = class {
2333
3278
  matcherCache = /* @__PURE__ */ new WeakMap();
2334
3279
  constructor(logger, gitFactory) {
2335
3280
  this.logger = logger ?? Logger.createDefault();
2336
- this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
3281
+ this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
2337
3282
  }
2338
3283
  updateLogger(logger) {
2339
3284
  this.logger = logger;
@@ -2384,11 +3329,25 @@ var SparseCheckoutService = class {
2384
3329
  return null;
2385
3330
  }
2386
3331
  }
3332
+ async readCurrentMode(worktreePath) {
3333
+ const git = this.gitFactory(worktreePath);
3334
+ try {
3335
+ const out = await git.raw(["config", "--bool", "--get", "core.sparseCheckoutCone"]);
3336
+ const value = out.trim().toLowerCase();
3337
+ if (value === "true") return "cone";
3338
+ if (value === "false") return "no-cone";
3339
+ return null;
3340
+ } catch {
3341
+ return null;
3342
+ }
3343
+ }
2387
3344
  async needsUpdate(worktreePath, cfg) {
3345
+ const desiredMode = this.resolveMode(cfg);
3346
+ const currentMode = await this.readCurrentMode(worktreePath);
3347
+ if (currentMode !== desiredMode) return true;
2388
3348
  const current = await this.readCurrent(worktreePath);
2389
- const desired = this.buildPatterns(cfg);
2390
3349
  if (current === null) return true;
2391
- return !this.patternsEqual(current, desired);
3350
+ return !this.patternsEqual(current, this.buildPatternsForMode(cfg, desiredMode));
2392
3351
  }
2393
3352
  isNarrowing(currentPatterns, nextPatterns) {
2394
3353
  if (!currentPatterns || currentPatterns.length === 0) return false;
@@ -2445,7 +3404,7 @@ var SparseCheckoutService = class {
2445
3404
  for (const pat of matcher.patterns) {
2446
3405
  if (p === pat || p.startsWith(pat + "/")) return true;
2447
3406
  }
2448
- return matcher.ancestorDirs.has(path3.posix.dirname(p));
3407
+ return matcher.ancestorDirs.has(path5.posix.dirname(p));
2449
3408
  });
2450
3409
  }
2451
3410
  getMatcher(cfg) {
@@ -2472,9 +3431,9 @@ var SparseCheckoutService = class {
2472
3431
  };
2473
3432
 
2474
3433
  // src/services/worktree-metadata.service.ts
2475
- import * as fs2 from "fs/promises";
2476
- import * as path4 from "path";
2477
- import simpleGit2 from "simple-git";
3434
+ import * as fs4 from "fs/promises";
3435
+ import * as path6 from "path";
3436
+ import simpleGit3 from "simple-git";
2478
3437
  var WorktreeMetadataService = class {
2479
3438
  logger;
2480
3439
  constructor(logger) {
@@ -2486,7 +3445,7 @@ var WorktreeMetadataService = class {
2486
3445
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
2487
3446
  */
2488
3447
  getWorktreeDirectoryName(worktreePath) {
2489
- return path4.basename(worktreePath);
3448
+ return path6.basename(worktreePath);
2490
3449
  }
2491
3450
  async getMetadataPath(bareRepoPath, worktreeName) {
2492
3451
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -2494,7 +3453,7 @@ var WorktreeMetadataService = class {
2494
3453
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
2495
3454
  );
2496
3455
  }
2497
- return path4.join(
3456
+ return path6.join(
2498
3457
  bareRepoPath,
2499
3458
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
2500
3459
  worktreeName,
@@ -2507,31 +3466,31 @@ var WorktreeMetadataService = class {
2507
3466
  }
2508
3467
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
2509
3468
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2510
- await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
3469
+ await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
2511
3470
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
2512
3471
  let renamed = false;
2513
3472
  try {
2514
- await fs2.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
3473
+ await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
2515
3474
  try {
2516
- await fs2.rename(tmpPath, metadataPath);
3475
+ await fs4.rename(tmpPath, metadataPath);
2517
3476
  renamed = true;
2518
3477
  } catch (err) {
2519
3478
  if (err.code === ERROR_MESSAGES.EXDEV) {
2520
- await fs2.copyFile(tmpPath, metadataPath);
3479
+ await fs4.copyFile(tmpPath, metadataPath);
2521
3480
  } else {
2522
3481
  throw err;
2523
3482
  }
2524
3483
  }
2525
3484
  } finally {
2526
3485
  if (!renamed) {
2527
- await fs2.unlink(tmpPath).catch(() => void 0);
3486
+ await fs4.unlink(tmpPath).catch(() => void 0);
2528
3487
  }
2529
3488
  }
2530
3489
  }
2531
3490
  async loadMetadata(bareRepoPath, worktreeName) {
2532
3491
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2533
3492
  try {
2534
- const content = await fs2.readFile(metadataPath, "utf-8");
3493
+ const content = await fs4.readFile(metadataPath, "utf-8");
2535
3494
  return JSON.parse(content);
2536
3495
  } catch {
2537
3496
  return null;
@@ -2540,7 +3499,7 @@ var WorktreeMetadataService = class {
2540
3499
  async loadMetadataFromPath(bareRepoPath, worktreePath) {
2541
3500
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2542
3501
  try {
2543
- const content = await fs2.readFile(metadataPath, "utf-8");
3502
+ const content = await fs4.readFile(metadataPath, "utf-8");
2544
3503
  const metadata = JSON.parse(content);
2545
3504
  if (!await this.validateMetadata(metadata)) {
2546
3505
  this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
@@ -2554,7 +3513,7 @@ var WorktreeMetadataService = class {
2554
3513
  async deleteMetadata(bareRepoPath, worktreeName) {
2555
3514
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2556
3515
  try {
2557
- await fs2.unlink(metadataPath);
3516
+ await fs4.unlink(metadataPath);
2558
3517
  } catch (error) {
2559
3518
  if (error.code !== "ENOENT") {
2560
3519
  throw error;
@@ -2564,7 +3523,7 @@ var WorktreeMetadataService = class {
2564
3523
  async deleteMetadataFromPath(bareRepoPath, worktreePath) {
2565
3524
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2566
3525
  try {
2567
- await fs2.unlink(metadataPath);
3526
+ await fs4.unlink(metadataPath);
2568
3527
  } catch (error) {
2569
3528
  if (error.code !== "ENOENT") {
2570
3529
  throw error;
@@ -2598,7 +3557,7 @@ var WorktreeMetadataService = class {
2598
3557
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
2599
3558
  this.logger.info(` Attempting to create initial metadata...`);
2600
3559
  try {
2601
- const worktreeGit = simpleGit2(worktreePath);
3560
+ const worktreeGit = simpleGit3(worktreePath);
2602
3561
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
2603
3562
  const branchSummary = await worktreeGit.branch();
2604
3563
  const actualBranchName = branchSummary.current;
@@ -2699,9 +3658,9 @@ var WorktreeMetadataService = class {
2699
3658
  };
2700
3659
 
2701
3660
  // src/services/worktree-status.service.ts
2702
- import * as fs3 from "fs/promises";
2703
- import * as path5 from "path";
2704
- import simpleGit3 from "simple-git";
3661
+ import * as fs5 from "fs/promises";
3662
+ import * as path7 from "path";
3663
+ import simpleGit4 from "simple-git";
2705
3664
  var OPERATION_FILES = [
2706
3665
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
2707
3666
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -2732,9 +3691,7 @@ var WorktreeStatusService = class {
2732
3691
  return true;
2733
3692
  }
2734
3693
  async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
2735
- try {
2736
- await fs3.access(worktreePath);
2737
- } catch {
3694
+ if (!await fileExists(worktreePath)) {
2738
3695
  return {
2739
3696
  isClean: true,
2740
3697
  hasUnpushedCommits: false,
@@ -2904,7 +3861,7 @@ var WorktreeStatusService = class {
2904
3861
  async detectOperationFile(gitDir) {
2905
3862
  const results = await Promise.all(
2906
3863
  OPERATION_FILES.map(
2907
- ({ file }) => fs3.access(path5.join(gitDir, file)).then(
3864
+ ({ file }) => fs5.access(path7.join(gitDir, file)).then(
2908
3865
  () => true,
2909
3866
  () => false
2910
3867
  )
@@ -3025,14 +3982,14 @@ var WorktreeStatusService = class {
3025
3982
  }
3026
3983
  }
3027
3984
  async resolveGitDir(worktreePath) {
3028
- const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
3985
+ const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
3029
3986
  try {
3030
- const stat3 = await fs3.stat(gitPath);
3987
+ const stat3 = await fs5.stat(gitPath);
3031
3988
  if (stat3.isFile()) {
3032
- const content = await fs3.readFile(gitPath, "utf-8");
3989
+ const content = await fs5.readFile(gitPath, "utf-8");
3033
3990
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
3034
3991
  if (gitdirMatch) {
3035
- return path5.resolve(worktreePath, gitdirMatch[1].trim());
3992
+ return path7.resolve(worktreePath, gitdirMatch[1].trim());
3036
3993
  }
3037
3994
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
3038
3995
  }
@@ -3046,10 +4003,10 @@ var WorktreeStatusService = class {
3046
4003
  }
3047
4004
  }
3048
4005
  createGitInstance(worktreePath) {
3049
- const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
4006
+ const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
3050
4007
  let git = this.gitInstances.get(key);
3051
4008
  if (!git) {
3052
- git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
4009
+ git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
3053
4010
  this.gitInstances.set(key, git);
3054
4011
  }
3055
4012
  return git;
@@ -3069,7 +4026,7 @@ var GitService = class {
3069
4026
  this.config = config;
3070
4027
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
3071
4028
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
3072
- this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
4029
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3073
4030
  this.metadataService = new WorktreeMetadataService(this.logger);
3074
4031
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
3075
4032
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -3097,36 +4054,20 @@ var GitService = class {
3097
4054
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
3098
4055
  }
3099
4056
  getCachedGit(dirPath, useLfsSkip = false) {
3100
- const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
4057
+ const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3101
4058
  let git = this.gitInstances.get(key);
3102
4059
  if (!git) {
3103
- const base = simpleGit4(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4060
+ const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
3104
4061
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
3105
4062
  this.gitInstances.set(key, git);
3106
4063
  }
3107
4064
  return git;
3108
4065
  }
3109
4066
  buildSimpleGitOptions(blockMs) {
3110
- const options = { progress: this.makeProgressHandler() };
4067
+ const options = { progress: makeGitProgressHandler(this.logger) };
3111
4068
  if (blockMs > 0) options.timeout = { block: blockMs };
3112
4069
  return options;
3113
4070
  }
3114
- makeProgressHandler() {
3115
- const lastBucket = /* @__PURE__ */ new Map();
3116
- return (event) => {
3117
- if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
3118
- const key = `${event.method}:${event.stage}`;
3119
- const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
3120
- let last = lastBucket.get(key) ?? -1;
3121
- if (bucket < last) {
3122
- last = -1;
3123
- }
3124
- if (bucket <= last && event.progress < 100) return;
3125
- lastBucket.set(key, bucket);
3126
- const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
3127
- this.logger.info(` \u21B3 ${event.method} ${event.stage}: ${event.progress}% (${total})`);
3128
- };
3129
- }
3130
4071
  updateLogger(logger) {
3131
4072
  this.logger = logger;
3132
4073
  this.sparseCheckoutService.updateLogger(logger);
@@ -3134,11 +4075,11 @@ var GitService = class {
3134
4075
  async initialize() {
3135
4076
  const { repoUrl } = this.config;
3136
4077
  try {
3137
- await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
4078
+ await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
3138
4079
  } catch {
3139
4080
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
3140
- await fs4.mkdir(path6.dirname(this.bareRepoPath), { recursive: true });
3141
- const cloneBase = simpleGit4(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
4081
+ await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
4082
+ const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
3142
4083
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
3143
4084
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
3144
4085
  this.logger.info("\u2705 Clone successful.");
@@ -3156,17 +4097,17 @@ var GitService = class {
3156
4097
  this.logger.info("Fetching remote branches...");
3157
4098
  await bareGit.fetch(["--all", "--progress"]);
3158
4099
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
3159
- this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
4100
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
3160
4101
  let needsMainWorktree = true;
3161
4102
  try {
3162
4103
  const worktrees = await this.getWorktreesFromBare(bareGit);
3163
- needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
4104
+ needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
3164
4105
  } catch {
3165
4106
  }
3166
4107
  if (needsMainWorktree) {
3167
4108
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
3168
- await fs4.mkdir(this.config.worktreeDir, { recursive: true });
3169
- const absoluteWorktreePath = path6.resolve(this.mainWorktreePath);
4109
+ await fs6.mkdir(this.config.worktreeDir, { recursive: true });
4110
+ const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
3170
4111
  const branches = await bareGit.branch();
3171
4112
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
3172
4113
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -3202,7 +4143,7 @@ var GitService = class {
3202
4143
  }
3203
4144
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
3204
4145
  const mainWorktreeRegistered = updatedWorktrees.some(
3205
- (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
4146
+ (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
3206
4147
  );
3207
4148
  if (!mainWorktreeRegistered) {
3208
4149
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -3228,6 +4169,45 @@ var GitService = class {
3228
4169
  getBareRepoPath() {
3229
4170
  return this.bareRepoPath;
3230
4171
  }
4172
+ async getRemoteDefaultBranch(repoUrl) {
4173
+ const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4174
+ try {
4175
+ const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
4176
+ const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
4177
+ if (match && match[1]) {
4178
+ return match[1];
4179
+ }
4180
+ } catch {
4181
+ }
4182
+ const existing = [];
4183
+ for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
4184
+ try {
4185
+ const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
4186
+ if (out.trim().length > 0) {
4187
+ existing.push(candidate);
4188
+ }
4189
+ } catch {
4190
+ }
4191
+ }
4192
+ if (existing.length === 1) {
4193
+ this.logger.warn(
4194
+ `Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
4195
+ );
4196
+ return existing[0];
4197
+ }
4198
+ if (existing.length > 1) {
4199
+ throw new Error(
4200
+ `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.`
4201
+ );
4202
+ }
4203
+ throw new Error(
4204
+ `Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
4205
+ );
4206
+ }
4207
+ async verifyLfs(worktreePath, label) {
4208
+ if (this.isLfsSkipEnabled()) return;
4209
+ await this.verifyLfsFilesDownloaded(worktreePath, label);
4210
+ }
3231
4211
  async fetchAll() {
3232
4212
  this.assertInitialized();
3233
4213
  this.logger.info("Fetching latest data from remote...");
@@ -3274,7 +4254,7 @@ var GitService = class {
3274
4254
  return branches;
3275
4255
  }
3276
4256
  async verifyLfsFilesDownloaded(worktreePath, branchName) {
3277
- const worktreeGit = this.config.sparseCheckout ? simpleGit4(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
4257
+ const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
3278
4258
  try {
3279
4259
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
3280
4260
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -3285,7 +4265,7 @@ var GitService = class {
3285
4265
  const existence = await Promise.all(
3286
4266
  lfsFileList.map(async (f) => {
3287
4267
  try {
3288
- await fs4.access(path6.join(worktreePath, f));
4268
+ await fs6.access(path8.join(worktreePath, f));
3289
4269
  return f;
3290
4270
  } catch {
3291
4271
  return null;
@@ -3313,9 +4293,9 @@ var GitService = class {
3313
4293
  let allDownloaded = true;
3314
4294
  const notDownloaded = [];
3315
4295
  for (const file of samplesToCheck) {
3316
- const filePath = path6.join(worktreePath, file);
4296
+ const filePath = path8.join(worktreePath, file);
3317
4297
  try {
3318
- const handle = await fs4.open(filePath, "r");
4298
+ const handle = await fs6.open(filePath, "r");
3319
4299
  try {
3320
4300
  const buffer = Buffer.alloc(200);
3321
4301
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -3340,7 +4320,7 @@ var GitService = class {
3340
4320
  }
3341
4321
  retries++;
3342
4322
  if (retries < maxRetries) {
3343
- await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
4323
+ await new Promise((resolve12) => setTimeout(resolve12, retryDelay));
3344
4324
  }
3345
4325
  }
3346
4326
  this.logger.warn(
@@ -3402,18 +4382,18 @@ var GitService = class {
3402
4382
  }
3403
4383
  async addWorktree(branchName, worktreePath) {
3404
4384
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
3405
- const absoluteWorktreePath = path6.resolve(worktreePath);
3406
- await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
4385
+ const absoluteWorktreePath = path8.resolve(worktreePath);
4386
+ await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
3407
4387
  try {
3408
- await fs4.access(absoluteWorktreePath);
4388
+ await fs6.access(absoluteWorktreePath);
3409
4389
  const worktrees = await this.getWorktreesFromBare(bareGit);
3410
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
4390
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3411
4391
  if (isValidWorktree) {
3412
4392
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3413
4393
  return;
3414
4394
  } else {
3415
4395
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
3416
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
4396
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3417
4397
  }
3418
4398
  } catch {
3419
4399
  }
@@ -3452,7 +4432,7 @@ var GitService = class {
3452
4432
  }
3453
4433
  if (errorMessage.includes("already registered worktree")) {
3454
4434
  const worktrees = await this.getWorktreesFromBare(bareGit);
3455
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
4435
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3456
4436
  if (existingWorktree && !existingWorktree.isPrunable) {
3457
4437
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
3458
4438
  return;
@@ -3460,7 +4440,7 @@ var GitService = class {
3460
4440
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
3461
4441
  await bareGit.raw(["worktree", "prune"]);
3462
4442
  try {
3463
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
4443
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3464
4444
  } catch {
3465
4445
  }
3466
4446
  let retryCreatedNewBranch = false;
@@ -3496,15 +4476,15 @@ var GitService = class {
3496
4476
  }
3497
4477
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
3498
4478
  try {
3499
- await fs4.access(absoluteWorktreePath);
4479
+ await fs6.access(absoluteWorktreePath);
3500
4480
  const worktrees = await this.getWorktreesFromBare(bareGit);
3501
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
4481
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3502
4482
  if (isValidWorktree) {
3503
4483
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3504
4484
  return;
3505
4485
  } else {
3506
4486
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
3507
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
4487
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3508
4488
  }
3509
4489
  } catch {
3510
4490
  }
@@ -3528,7 +4508,7 @@ var GitService = class {
3528
4508
  const fallbackErrorMessage = getErrorMessage(fallbackError);
3529
4509
  if (fallbackErrorMessage.includes("already registered worktree")) {
3530
4510
  const worktrees = await this.getWorktreesFromBare(bareGit);
3531
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
4511
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3532
4512
  if (existingWorktree && !existingWorktree.isPrunable) {
3533
4513
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
3534
4514
  return;
@@ -3751,6 +4731,40 @@ var GitService = class {
3751
4731
  return false;
3752
4732
  }
3753
4733
  }
4734
+ async classifyRemoteRelationship(worktreePath, branch) {
4735
+ const worktreeGit = this.getCachedGit(worktreePath);
4736
+ let headSha;
4737
+ let remoteSha;
4738
+ try {
4739
+ headSha = (await worktreeGit.revparse(["HEAD"])).trim();
4740
+ remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
4741
+ } catch {
4742
+ return "diverged";
4743
+ }
4744
+ if (headSha === remoteSha) return "up_to_date";
4745
+ let mergeBase = "";
4746
+ let mergeBaseFailed = false;
4747
+ try {
4748
+ mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
4749
+ } catch {
4750
+ mergeBaseFailed = true;
4751
+ }
4752
+ if (mergeBaseFailed || !mergeBase) {
4753
+ if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
4754
+ return "diverged";
4755
+ }
4756
+ if (mergeBase === headSha) return "fast_forward";
4757
+ if (mergeBase === remoteSha) return "local_ahead";
4758
+ return "diverged";
4759
+ }
4760
+ async isShallowRepository(git) {
4761
+ try {
4762
+ const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
4763
+ return output.trim() === "true";
4764
+ } catch {
4765
+ return false;
4766
+ }
4767
+ }
3754
4768
  async getChangedPathsInRange(worktreePath, fromRef, toRef) {
3755
4769
  const worktreeGit = this.getCachedGit(worktreePath);
3756
4770
  try {
@@ -3840,7 +4854,7 @@ var GitService = class {
3840
4854
  async createBranch(branchName, baseBranch) {
3841
4855
  const bareGit = this.getCachedGit(this.bareRepoPath);
3842
4856
  const baseRef = await this.resolveCreateBranchBaseRef(bareGit, baseBranch);
3843
- await bareGit.raw(["branch", branchName, baseRef]);
4857
+ await bareGit.raw(["branch", "--no-track", branchName, baseRef]);
3844
4858
  this.logger.info(`Created branch '${branchName}' from '${baseRef}'`);
3845
4859
  }
3846
4860
  async pushBranch(branchName) {
@@ -3861,35 +4875,238 @@ var GitService = class {
3861
4875
  }
3862
4876
  };
3863
4877
 
4878
+ // src/services/progress-emitter.ts
4879
+ var ProgressEmitter = class {
4880
+ listeners = /* @__PURE__ */ new Set();
4881
+ onProgress(listener) {
4882
+ this.listeners.add(listener);
4883
+ return () => this.listeners.delete(listener);
4884
+ }
4885
+ emit(event) {
4886
+ for (const listener of [...this.listeners]) {
4887
+ try {
4888
+ listener(event);
4889
+ } catch {
4890
+ }
4891
+ }
4892
+ }
4893
+ };
4894
+
4895
+ // src/services/repo-operation-lock.ts
4896
+ import * as fs7 from "fs/promises";
4897
+ import * as path10 from "path";
4898
+ import * as lockfile from "proper-lockfile";
4899
+
4900
+ // src/utils/lock-path.ts
4901
+ import { createHash } from "crypto";
4902
+ import * as os from "os";
4903
+ import * as path9 from "path";
4904
+ function getCloneModeLockTarget(config) {
4905
+ const name = config.name;
4906
+ const configDir = config.__configFileDir;
4907
+ const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
4908
+ if (configDir) {
4909
+ return {
4910
+ dir: path9.join(configDir, ".sync-worktrees-state"),
4911
+ file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
4912
+ };
4913
+ }
4914
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
4915
+ const dir = path9.join(stateBase, "sync-worktrees", "locks");
4916
+ return { dir, file: `${hash}.lock` };
4917
+ }
4918
+
4919
+ // src/services/repo-operation-lock.ts
4920
+ var RepoOperationLock = class {
4921
+ constructor(config, gitService, logger = Logger.createDefault()) {
4922
+ this.config = config;
4923
+ this.gitService = gitService;
4924
+ this.logger = logger;
4925
+ }
4926
+ updateLogger(logger) {
4927
+ this.logger = logger;
4928
+ }
4929
+ async acquire() {
4930
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
4931
+ return async () => {
4932
+ };
4933
+ }
4934
+ if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
4935
+ return this.acquireCloneModeLock();
4936
+ }
4937
+ return this.acquireWorktreeModeLock();
4938
+ }
4939
+ async acquireCloneModeLock() {
4940
+ const target = getCloneModeLockTarget(this.config);
4941
+ const lockTarget = path10.join(target.dir, target.file);
4942
+ try {
4943
+ await fs7.mkdir(target.dir, { recursive: true });
4944
+ await fs7.writeFile(lockTarget, "", { flag: "a" });
4945
+ } catch {
4946
+ return null;
4947
+ }
4948
+ return this.lockPath(lockTarget);
4949
+ }
4950
+ async acquireWorktreeModeLock() {
4951
+ const barePath = this.gitService.getBareRepoPath();
4952
+ try {
4953
+ await fs7.mkdir(barePath, { recursive: true });
4954
+ } catch {
4955
+ return null;
4956
+ }
4957
+ return this.lockPath(barePath);
4958
+ }
4959
+ async lockPath(lockTarget) {
4960
+ try {
4961
+ return await lockfile.lock(lockTarget, {
4962
+ stale: DEFAULT_CONFIG.LOCK_STALE_MS,
4963
+ update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
4964
+ retries: 0,
4965
+ realpath: false
4966
+ });
4967
+ } catch (error) {
4968
+ const code = error.code;
4969
+ if (code === "ELOCKED") {
4970
+ return null;
4971
+ }
4972
+ this.logger.warn(
4973
+ `Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
4974
+ );
4975
+ return null;
4976
+ }
4977
+ }
4978
+ };
4979
+
4980
+ // src/services/sync-retry-policy.ts
4981
+ var SyncRetryPolicy = class {
4982
+ constructor(config, gitService, logger) {
4983
+ this.config = config;
4984
+ this.gitService = gitService;
4985
+ this.logger = logger;
4986
+ }
4987
+ updateLogger(logger) {
4988
+ this.logger = logger;
4989
+ }
4990
+ createContext() {
4991
+ return { lfsSkipEnabled: false };
4992
+ }
4993
+ createOptions(syncContext) {
4994
+ return {
4995
+ maxAttempts: this.config.retry?.maxAttempts ?? 3,
4996
+ maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
4997
+ initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
4998
+ maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
4999
+ backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
5000
+ jitterMs: this.config.retry?.jitterMs ?? 0,
5001
+ onRetry: (error, attempt, context) => {
5002
+ const errorMessage = getErrorMessage(error);
5003
+ this.logger.info(`
5004
+ \u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
5005
+ if (context?.isLfsError && !this.config.skipLfs) {
5006
+ this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
5007
+ } else {
5008
+ this.logger.info(`\u{1F504} Retrying synchronization...
5009
+ `);
5010
+ }
5011
+ },
5012
+ lfsRetryHandler: () => {
5013
+ if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
5014
+ this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
5015
+ this.gitService.setLfsSkipEnabled(true);
5016
+ syncContext.lfsSkipEnabled = true;
5017
+ }
5018
+ }
5019
+ };
5020
+ }
5021
+ resetLfsSkipIfNeeded(syncContext) {
5022
+ if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
5023
+ this.gitService.setLfsSkipEnabled(false);
5024
+ }
5025
+ }
5026
+ };
5027
+
5028
+ // src/services/worktree-mode-sync-runner.ts
5029
+ import * as fs9 from "fs/promises";
5030
+ import * as path13 from "path";
5031
+ import pLimit from "p-limit";
5032
+
5033
+ // src/utils/date-filter.ts
5034
+ function parseDuration(durationStr) {
5035
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
5036
+ if (!match) {
5037
+ return null;
5038
+ }
5039
+ const value = parseInt(match[1], 10);
5040
+ const unit = match[2];
5041
+ const multipliers = {
5042
+ h: 60 * 60 * 1e3,
5043
+ // hours
5044
+ d: 24 * 60 * 60 * 1e3,
5045
+ // days
5046
+ w: 7 * 24 * 60 * 60 * 1e3,
5047
+ // weeks
5048
+ m: 30 * 24 * 60 * 60 * 1e3,
5049
+ // months (approximate)
5050
+ y: 365 * 24 * 60 * 60 * 1e3
5051
+ // years (approximate)
5052
+ };
5053
+ return value * multipliers[unit];
5054
+ }
5055
+ function filterBranchesByAge(branches, maxAge) {
5056
+ const maxAgeMs = parseDuration(maxAge);
5057
+ if (maxAgeMs === null) {
5058
+ console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
5059
+ return branches;
5060
+ }
5061
+ const cutoffDate = new Date(Date.now() - maxAgeMs);
5062
+ return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
5063
+ }
5064
+ function formatDuration2(durationStr) {
5065
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
5066
+ if (!match) {
5067
+ return durationStr;
5068
+ }
5069
+ const value = parseInt(match[1], 10);
5070
+ const unit = match[2];
5071
+ const unitNames = {
5072
+ h: value === 1 ? "hour" : "hours",
5073
+ d: value === 1 ? "day" : "days",
5074
+ w: value === 1 ? "week" : "weeks",
5075
+ m: value === 1 ? "month" : "months",
5076
+ y: value === 1 ? "year" : "years"
5077
+ };
5078
+ return `${value} ${unitNames[unit]}`;
5079
+ }
5080
+
3864
5081
  // src/services/path-resolution.service.ts
3865
- import { createHash } from "crypto";
3866
- import * as fs5 from "fs";
3867
- import * as path7 from "path";
5082
+ import { createHash as createHash2 } from "crypto";
5083
+ import * as fs8 from "fs";
5084
+ import * as path11 from "path";
3868
5085
  var BRANCH_STEM_MAX = 80;
3869
5086
  var BRANCH_HASH_LEN = 8;
3870
5087
  var PathResolutionService = class {
3871
5088
  sanitizeBranchName(branchName) {
3872
5089
  const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
3873
- const hash = createHash("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
5090
+ const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
3874
5091
  return `${stem}-${hash}`;
3875
5092
  }
3876
5093
  getBranchWorktreePath(worktreeDir, branchName) {
3877
- return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
5094
+ return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
3878
5095
  }
3879
5096
  resolveRealPath(inputPath) {
3880
- const absolute = path7.resolve(inputPath);
5097
+ const absolute = path11.resolve(inputPath);
3881
5098
  const missing = [];
3882
5099
  let current = absolute;
3883
- while (!fs5.existsSync(current)) {
3884
- const parent = path7.dirname(current);
5100
+ while (!fs8.existsSync(current)) {
5101
+ const parent = path11.dirname(current);
3885
5102
  if (parent === current) {
3886
5103
  return absolute;
3887
5104
  }
3888
- missing.unshift(path7.basename(current));
5105
+ missing.unshift(path11.basename(current));
3889
5106
  current = parent;
3890
5107
  }
3891
5108
  try {
3892
- return path7.join(fs5.realpathSync(current), ...missing);
5109
+ return path11.join(fs8.realpathSync(current), ...missing);
3893
5110
  } catch {
3894
5111
  return absolute;
3895
5112
  }
@@ -3899,7 +5116,7 @@ var PathResolutionService = class {
3899
5116
  const a = fold(resolved);
3900
5117
  const b = fold(resolvedBase);
3901
5118
  if (a === b) return true;
3902
- return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
5119
+ return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
3903
5120
  }
3904
5121
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
3905
5122
  const resolved = this.resolveRealPath(worktreePath);
@@ -3907,7 +5124,7 @@ var PathResolutionService = class {
3907
5124
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
3908
5125
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
3909
5126
  }
3910
- return path7.relative(resolvedBase, resolved);
5127
+ return path11.relative(resolvedBase, resolved);
3911
5128
  }
3912
5129
  isPathInsideBaseDir(targetPath, baseDir) {
3913
5130
  const resolved = this.resolveRealPath(targetPath);
@@ -3919,174 +5136,110 @@ var PathResolutionService = class {
3919
5136
  }
3920
5137
  };
3921
5138
 
3922
- // src/services/worktree-sync.service.ts
3923
- var WorktreeSyncService = class {
3924
- constructor(config) {
5139
+ // src/services/worktree-sync-planner.ts
5140
+ import * as path12 from "path";
5141
+ function createWorktreeSyncPlan(inventory, options = {}) {
5142
+ return {
5143
+ create: planCreateActions(inventory, options),
5144
+ prune: planPruneActions(inventory),
5145
+ update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
5146
+ sparse: planSparseActions(inventory, options.sparseCheckout),
5147
+ warnings: []
5148
+ };
5149
+ }
5150
+ function planCreateActions(inventory, options = {}) {
5151
+ const pathResolution = options.pathResolution ?? new PathResolutionService();
5152
+ const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
5153
+ const newBranches = inventory.remoteBranches.filter(
5154
+ (branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
5155
+ );
5156
+ const reservedPaths = /* @__PURE__ */ new Map();
5157
+ for (const worktree of inventory.existingWorktrees) {
5158
+ reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
5159
+ }
5160
+ const actions = [];
5161
+ for (const branch of newBranches) {
5162
+ const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
5163
+ const resolved = path12.resolve(worktreePath);
5164
+ const conflictingBranch = reservedPaths.get(resolved);
5165
+ if (conflictingBranch && conflictingBranch !== branch) {
5166
+ actions.push({
5167
+ kind: "skip-create",
5168
+ branch,
5169
+ path: worktreePath,
5170
+ reason: "path-collision",
5171
+ conflictingBranch
5172
+ });
5173
+ continue;
5174
+ }
5175
+ reservedPaths.set(resolved, branch);
5176
+ actions.push({ kind: "create", branch, path: worktreePath });
5177
+ }
5178
+ return actions;
5179
+ }
5180
+ function planPruneActions(inventory) {
5181
+ const remoteBranches = new Set(inventory.remoteBranches);
5182
+ return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
5183
+ }
5184
+ function planUpdateActions(inventory) {
5185
+ const remoteBranches = new Set(inventory.remoteBranches);
5186
+ return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
5187
+ }
5188
+ function planSparseActions(inventory, sparseCheckout) {
5189
+ if (!sparseCheckout) {
5190
+ return [];
5191
+ }
5192
+ return inventory.existingWorktrees.map((worktree) => ({
5193
+ kind: "check-sparse",
5194
+ branch: worktree.branch,
5195
+ path: worktree.path
5196
+ }));
5197
+ }
5198
+
5199
+ // src/services/worktree-mode-sync-runner.ts
5200
+ var WorktreeModeSyncRunner = class {
5201
+ constructor(config, gitService, logger, progressEmitter) {
3925
5202
  this.config = config;
3926
- this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
3927
- this.gitService = new GitService(config, this.logger);
5203
+ this.gitService = gitService;
5204
+ this.logger = logger;
5205
+ this.progressEmitter = progressEmitter;
3928
5206
  }
3929
- gitService;
3930
- logger;
3931
- syncInProgress = false;
3932
5207
  pathResolution = new PathResolutionService();
3933
- progressListeners = /* @__PURE__ */ new Set();
3934
- async initialize() {
3935
- this.emitProgress({ phase: "initialize", message: "Initializing repository" });
3936
- await this.gitService.initialize();
3937
- this.emitProgress({ phase: "initialize", message: "Repository initialized" });
3938
- }
3939
- isInitialized() {
3940
- return this.gitService.isInitialized();
3941
- }
3942
- isSyncInProgress() {
3943
- return this.syncInProgress;
3944
- }
3945
- getGitService() {
3946
- return this.gitService;
3947
- }
3948
5208
  updateLogger(logger) {
3949
5209
  this.logger = logger;
3950
- this.gitService.updateLogger(logger);
3951
- }
3952
- onProgress(listener) {
3953
- this.progressListeners.add(listener);
3954
- return () => this.progressListeners.delete(listener);
3955
- }
3956
- async runExclusiveRepoOperation(operation) {
3957
- if (this.syncInProgress) {
3958
- this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
3959
- return { started: false, reason: "in_progress" };
3960
- }
3961
- const release = await this.acquireBareLock();
3962
- if (release === null) {
3963
- this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
3964
- return { started: false, reason: "locked" };
3965
- }
3966
- this.syncInProgress = true;
3967
- try {
3968
- return { started: true, value: await operation() };
3969
- } finally {
3970
- this.syncInProgress = false;
3971
- try {
3972
- await release();
3973
- } catch (releaseError) {
3974
- this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
3975
- }
3976
- }
3977
- }
3978
- emitProgress(event) {
3979
- for (const listener of this.progressListeners) {
3980
- try {
3981
- listener(event);
3982
- } catch {
3983
- }
3984
- }
3985
- }
3986
- async sync() {
3987
- const result = await this.runExclusiveRepoOperation(async () => {
3988
- if (!this.isInitialized()) {
3989
- await this.initialize();
3990
- }
3991
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
3992
- const totalTimer = new Timer();
3993
- const phaseTimer = new PhaseTimer();
3994
- const syncContext = { lfsSkipEnabled: false };
3995
- const retryOptions = this.createRetryOptions(syncContext);
3996
- try {
3997
- await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
3998
- } catch (error) {
3999
- this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
4000
- throw error;
4001
- } finally {
4002
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
4003
- this.gitService.setLfsSkipEnabled(false);
4004
- }
4005
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
4006
- `);
4007
- if (this.config.debug) {
4008
- const totalDuration = totalTimer.stop();
4009
- const phaseResults = phaseTimer.getResults();
4010
- const repoName = this.config.name;
4011
- this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
4012
- }
4013
- }
4014
- });
4015
- return result.started ? { started: true } : result;
4016
- }
4017
- async acquireBareLock() {
4018
- if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
4019
- return async () => {
4020
- };
4021
- }
4022
- if (typeof this.gitService.getBareRepoPath !== "function") {
4023
- return async () => {
4024
- };
4025
- }
4026
- const barePath = this.gitService.getBareRepoPath();
4027
- await fs6.mkdir(barePath, { recursive: true });
4028
- try {
4029
- const release = await lockfile.lock(barePath, {
4030
- stale: DEFAULT_CONFIG.LOCK_STALE_MS,
4031
- update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
4032
- retries: 0,
4033
- realpath: false
4034
- });
4035
- return release;
4036
- } catch (error) {
4037
- const code = error.code;
4038
- if (code === "ELOCKED") {
4039
- return null;
4040
- }
4041
- throw error;
4042
- }
4043
- }
4044
- createRetryOptions(syncContext) {
4045
- return {
4046
- maxAttempts: this.config.retry?.maxAttempts ?? 3,
4047
- maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
4048
- initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
4049
- maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
4050
- backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
4051
- onRetry: (error, attempt, context) => {
4052
- const errorMessage = getErrorMessage(error);
4053
- this.logger.info(`
4054
- \u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
4055
- if (context?.isLfsError && !this.config.skipLfs) {
4056
- this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
4057
- } else {
4058
- this.logger.info(`\u{1F504} Retrying synchronization...
4059
- `);
4060
- }
4061
- },
4062
- lfsRetryHandler: () => {
4063
- if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
4064
- this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
4065
- this.gitService.setLfsSkipEnabled(true);
4066
- syncContext.lfsSkipEnabled = true;
4067
- }
4068
- }
4069
- };
4070
5210
  }
4071
- async runSyncAttempt(phaseTimer, syncContext) {
5211
+ async runSyncAttempt(phaseTimer, syncContext, outcome) {
4072
5212
  await this.gitService.pruneWorktrees();
4073
5213
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
4074
5214
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
4075
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
5215
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
4076
5216
  const worktrees = await this.gitService.getWorktrees();
4077
5217
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
4078
5218
  await this.cleanupOrphanedDirectories(worktrees);
4079
- await this.createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer);
4080
- await this.pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer);
5219
+ const syncPlan = createWorktreeSyncPlan(
5220
+ {
5221
+ remoteBranches,
5222
+ defaultBranch,
5223
+ existingWorktrees: worktrees,
5224
+ worktreeDir: this.config.worktreeDir
5225
+ },
5226
+ {
5227
+ pathResolution: this.pathResolution,
5228
+ updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
5229
+ sparseCheckout: this.config.sparseCheckout
5230
+ }
5231
+ );
5232
+ await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
5233
+ await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
4081
5234
  if (this.config.updateExistingWorktrees !== false) {
4082
- await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
5235
+ await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
4083
5236
  }
4084
5237
  if (this.config.sparseCheckout) {
4085
- await this.reapplySparseCheckout(worktrees);
5238
+ await this.reapplySparseCheckout(syncPlan.sparse, outcome);
4086
5239
  }
4087
5240
  await this.finalizeSyncAttempt(phaseTimer);
4088
5241
  }
4089
- async reapplySparseCheckout(worktrees) {
5242
+ async reapplySparseCheckout(actions, outcome) {
4090
5243
  const sparseConfig = this.config.sparseCheckout;
4091
5244
  if (!sparseConfig) return;
4092
5245
  this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
@@ -4094,32 +5247,44 @@ var WorktreeSyncService = class {
4094
5247
  const desired = sparseService.buildPatterns(sparseConfig);
4095
5248
  const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4096
5249
  await Promise.all(
4097
- worktrees.map(
4098
- (worktree) => limit(async () => {
5250
+ actions.map(
5251
+ (action) => limit(async () => {
5252
+ if (action.kind !== "check-sparse") return;
4099
5253
  try {
4100
5254
  try {
4101
- await fs6.access(worktree.path);
5255
+ await fs9.access(action.path);
4102
5256
  } catch {
4103
5257
  return;
4104
5258
  }
4105
- const current = await sparseService.readCurrent(worktree.path);
5259
+ const current = await sparseService.readCurrent(action.path);
4106
5260
  if (current !== null && sparseService.patternsEqual(current, desired)) return;
4107
5261
  if (sparseService.isNarrowing(current, desired)) {
4108
- const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
5262
+ const status = await this.gitService.getFullWorktreeStatus(action.path, false);
4109
5263
  if (!status.canRemove) {
4110
5264
  this.logger.warn(
4111
- ` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
5265
+ ` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
4112
5266
  );
5267
+ outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
5268
+ branch: action.branch,
5269
+ path: action.path,
5270
+ message: status.reasons.join(", ")
5271
+ });
4113
5272
  return;
4114
5273
  }
4115
5274
  }
4116
- await sparseService.applyToWorktree(worktree.path, sparseConfig);
4117
- await this.gitService.checkoutHead(worktree.path);
4118
- this.logger.info(` - \u2705 Sparse-checkout updated for '${worktree.branch}'`);
5275
+ await sparseService.applyToWorktree(action.path, sparseConfig);
5276
+ await this.gitService.checkoutHead(action.path);
5277
+ this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
5278
+ outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
4119
5279
  } catch (error) {
4120
5280
  this.logger.warn(
4121
- ` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
5281
+ ` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
4122
5282
  );
5283
+ outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
5284
+ reason: "sparse_checkout_failed",
5285
+ branch: action.branch,
5286
+ path: action.path
5287
+ });
4123
5288
  }
4124
5289
  })
4125
5290
  )
@@ -4128,7 +5293,7 @@ var WorktreeSyncService = class {
4128
5293
  async fetchLatestRemoteData(phaseTimer, syncContext) {
4129
5294
  this.logger.info("Step 1: Fetching latest data from remote...");
4130
5295
  phaseTimer.startPhase("Phase 1: Fetch");
4131
- this.emitProgress({ phase: "fetch", message: "Fetching latest data from remote" });
5296
+ this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
4132
5297
  try {
4133
5298
  await this.gitService.fetchAll();
4134
5299
  } catch (fetchError) {
@@ -4173,7 +5338,7 @@ var WorktreeSyncService = class {
4173
5338
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
4174
5339
  const remoteBranches = filteredBranches.map((b) => b.branch);
4175
5340
  this.logger.info(
4176
- `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
5341
+ `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
4177
5342
  );
4178
5343
  if (filteredByName.length > remoteBranches.length) {
4179
5344
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -4192,45 +5357,38 @@ var WorktreeSyncService = class {
4192
5357
  }
4193
5358
  async finalizeSyncAttempt(phaseTimer) {
4194
5359
  phaseTimer.startPhase("Phase 5: Cleanup");
4195
- this.emitProgress({ phase: "cleanup", message: "Pruning worktree metadata" });
5360
+ this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
4196
5361
  await this.gitService.pruneWorktrees();
4197
5362
  this.logger.info("Step 5: Pruned worktree metadata.");
4198
5363
  phaseTimer.endPhase();
4199
5364
  }
4200
- async createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer) {
5365
+ async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
4201
5366
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
4202
5367
  phaseTimer.startPhase("Phase 2: Create", maxConcurrent);
4203
- this.emitProgress({ phase: "create", message: "Creating worktrees for new branches" });
4204
- await this.createNewWorktrees(remoteBranches, worktrees, defaultBranch);
4205
- const existingBranches = new Set(worktrees.map((w) => w.branch));
4206
- const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
4207
- phaseTimer.setPhaseCount("Phase 2: Create", newBranches.length);
5368
+ this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
5369
+ await this.createNewWorktrees(syncPlan.create, outcome);
5370
+ phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
4208
5371
  phaseTimer.endPhase();
4209
5372
  }
4210
- async createNewWorktrees(remoteBranches, worktrees, defaultBranch) {
4211
- const existingBranches = new Set(worktrees.map((w) => w.branch));
4212
- const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
4213
- if (newBranches.length === 0) {
5373
+ async createNewWorktrees(actions, outcome) {
5374
+ if (actions.length === 0) {
4214
5375
  this.logger.info("Step 2: No new branches to create worktrees for.");
4215
5376
  return;
4216
5377
  }
4217
- const reservedPaths = /* @__PURE__ */ new Map();
4218
- for (const w of worktrees) {
4219
- reservedPaths.set(path8.resolve(w.path), w.branch);
4220
- }
4221
5378
  const plan = [];
4222
- for (const branchName of newBranches) {
4223
- const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
4224
- const resolved = path8.resolve(worktreePath);
4225
- const conflict = reservedPaths.get(resolved);
4226
- if (conflict && conflict !== branchName) {
5379
+ for (const action of actions) {
5380
+ if (action.kind === "skip-create") {
4227
5381
  this.logger.error(
4228
- ` \u274C Skipping '${branchName}': sanitized worktree path '${worktreePath}' collides with existing branch '${conflict}'.`
5382
+ ` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
4229
5383
  );
5384
+ outcome.recordSkipped("branch", "path_collision", {
5385
+ branch: action.branch,
5386
+ path: action.path,
5387
+ message: `Path collides with existing branch '${action.conflictingBranch}'`
5388
+ });
4230
5389
  continue;
4231
5390
  }
4232
- reservedPaths.set(resolved, branchName);
4233
- plan.push({ branchName, worktreePath });
5391
+ plan.push({ branchName: action.branch, worktreePath: action.path });
4234
5392
  }
4235
5393
  this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
4236
5394
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
@@ -4241,8 +5399,14 @@ var WorktreeSyncService = class {
4241
5399
  try {
4242
5400
  await this.gitService.addWorktree(branchName, worktreePath);
4243
5401
  this.logger.info(` \u2705 Created worktree for '${branchName}'`);
5402
+ outcome.recordCreated(branchName, worktreePath);
4244
5403
  } catch (error) {
4245
5404
  this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
5405
+ outcome.recordFailed("worktree", getErrorMessage(error), {
5406
+ reason: "create_failed",
5407
+ branch: branchName,
5408
+ path: worktreePath
5409
+ });
4246
5410
  throw error;
4247
5411
  }
4248
5412
  })
@@ -4251,23 +5415,21 @@ var WorktreeSyncService = class {
4251
5415
  const successCount = results.filter((r) => r.status === "fulfilled").length;
4252
5416
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
4253
5417
  }
4254
- async pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer) {
5418
+ async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
4255
5419
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4256
5420
  phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
4257
- this.emitProgress({ phase: "prune", message: "Pruning stale worktrees" });
4258
- await this.pruneOldWorktrees(remoteBranches, worktrees);
4259
- const deletedWorktrees = worktrees.filter((w) => !remoteBranches.includes(w.branch));
4260
- phaseTimer.setPhaseCount("Phase 3: Prune", deletedWorktrees.length);
5421
+ this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
5422
+ await this.pruneOldWorktrees(actions, outcome);
5423
+ phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
4261
5424
  phaseTimer.endPhase();
4262
5425
  }
4263
- async pruneOldWorktrees(remoteBranches, worktrees) {
4264
- const deletedWorktrees = worktrees.filter((w) => !remoteBranches.includes(w.branch));
4265
- if (deletedWorktrees.length > 0) {
4266
- this.logger.info(`Step 3: Checking ${deletedWorktrees.length} stale worktrees to prune...`);
5426
+ async pruneOldWorktrees(actions, outcome) {
5427
+ if (actions.length > 0) {
5428
+ this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
4267
5429
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4268
5430
  const limit = pLimit(maxConcurrent);
4269
5431
  const statusResults = await Promise.allSettled(
4270
- deletedWorktrees.map(
5432
+ actions.map(
4271
5433
  ({ branch: branchName, path: worktreePath }) => limit(async () => {
4272
5434
  const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
4273
5435
  return { branchName, worktreePath, status };
@@ -4290,6 +5452,10 @@ var WorktreeSyncService = class {
4290
5452
  const branchName = result.reason?.branchName ?? "unknown";
4291
5453
  this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
4292
5454
  this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
5455
+ outcome.recordSkipped("worktree", "prune_status_check_failed", {
5456
+ branch: branchName,
5457
+ message: getErrorMessage(result.reason)
5458
+ });
4293
5459
  }
4294
5460
  }
4295
5461
  if (toRemove.length > 0) {
@@ -4305,12 +5471,23 @@ var WorktreeSyncService = class {
4305
5471
  this.logger.warn(
4306
5472
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
4307
5473
  );
5474
+ outcome.recordSkipped("worktree", "prune_status_changed", {
5475
+ branch: branchName,
5476
+ path: worktreePath,
5477
+ message: recheck.reasons.join(", ")
5478
+ });
4308
5479
  return;
4309
5480
  }
4310
5481
  await this.gitService.removeWorktree(worktreePath);
4311
5482
  this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
5483
+ outcome.recordRemoved(branchName, worktreePath);
4312
5484
  } catch (error) {
4313
5485
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
5486
+ outcome.recordFailed("worktree", getErrorMessage(error), {
5487
+ reason: "remove_failed",
5488
+ branch: branchName,
5489
+ path: worktreePath
5490
+ });
4314
5491
  throw error;
4315
5492
  }
4316
5493
  })
@@ -4323,6 +5500,11 @@ var WorktreeSyncService = class {
4323
5500
  this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
4324
5501
  }
4325
5502
  for (const { branchName, worktreePath, status } of toSkip) {
5503
+ outcome.recordSkipped("worktree", "unsafe_to_remove", {
5504
+ branch: branchName,
5505
+ path: worktreePath,
5506
+ message: status.reasons.join(", ")
5507
+ });
4326
5508
  if (status.upstreamGone && status.hasUnpushedCommits) {
4327
5509
  this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
4328
5510
  this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
@@ -4415,53 +5597,52 @@ var WorktreeSyncService = class {
4415
5597
  this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
4416
5598
  }
4417
5599
  }
4418
- async updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer) {
5600
+ async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
4419
5601
  const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
4420
5602
  phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
4421
- this.emitProgress({ phase: "update", message: "Updating existing worktrees" });
4422
- await this.updateExistingWorktrees(worktrees, remoteBranches);
4423
- const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
4424
- phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
5603
+ this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
5604
+ await this.updateExistingWorktrees(actions, outcome);
5605
+ phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
4425
5606
  phaseTimer.endPhase();
4426
5607
  }
4427
- async updateExistingWorktrees(worktrees, remoteBranches) {
5608
+ async updateExistingWorktrees(actions, outcome) {
4428
5609
  this.logger.info("Step 4: Checking for worktrees that need updates...");
4429
- const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5610
+ const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4430
5611
  try {
4431
- const diverged = await fs6.readdir(divergedDir);
5612
+ const diverged = await fs9.readdir(divergedDir);
4432
5613
  if (diverged.length > 0) {
4433
5614
  this.logger.info(
4434
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
5615
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
4435
5616
  );
4436
5617
  }
4437
5618
  } catch {
4438
5619
  }
4439
- const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
4440
5620
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4441
5621
  const limit = pLimit(maxConcurrent);
4442
5622
  const checkResults = await Promise.allSettled(
4443
- activeWorktrees.map(
4444
- (worktree) => limit(async () => {
5623
+ actions.map(
5624
+ (action) => limit(async () => {
5625
+ const worktree = { path: action.path, branch: action.branch };
4445
5626
  try {
4446
- await fs6.access(worktree.path);
5627
+ await fs9.access(worktree.path);
4447
5628
  } catch {
4448
- return null;
5629
+ return { action: "skip", worktree, reason: "missing_worktree_path" };
4449
5630
  }
4450
5631
  const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
4451
- if (hasOp) return null;
5632
+ if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
4452
5633
  const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
4453
- if (!isClean) return null;
5634
+ if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
4454
5635
  const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
4455
5636
  if (!canFastForward) {
4456
5637
  const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
4457
5638
  if (isAhead) {
4458
5639
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
4459
- return null;
5640
+ return { action: "skip", worktree, reason: "local_ahead" };
4460
5641
  }
4461
5642
  return { action: "diverged", worktree };
4462
5643
  }
4463
5644
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
4464
- if (!isBehind) return null;
5645
+ if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
4465
5646
  const sparseCfg = this.config.sparseCheckout;
4466
5647
  if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
4467
5648
  const sparseService = this.gitService.getSparseCheckoutService();
@@ -4473,7 +5654,7 @@ var WorktreeSyncService = class {
4473
5654
  );
4474
5655
  if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
4475
5656
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
4476
- return null;
5657
+ return { action: "skip", worktree, reason: "outside_sparse_checkout" };
4477
5658
  }
4478
5659
  }
4479
5660
  }
@@ -4485,13 +5666,25 @@ var WorktreeSyncService = class {
4485
5666
  const divergedWorktrees = [];
4486
5667
  for (const result of checkResults) {
4487
5668
  if (result.status === "fulfilled" && result.value) {
4488
- if (result.value.action === "update") {
4489
- worktreesToUpdate.push(result.value.worktree);
4490
- } else {
4491
- divergedWorktrees.push(result.value.worktree);
5669
+ switch (result.value.action) {
5670
+ case "update":
5671
+ worktreesToUpdate.push(result.value.worktree);
5672
+ break;
5673
+ case "diverged":
5674
+ divergedWorktrees.push(result.value.worktree);
5675
+ break;
5676
+ case "noop":
5677
+ outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
5678
+ break;
5679
+ case "skip":
5680
+ outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
5681
+ break;
4492
5682
  }
4493
5683
  } else if (result.status === "rejected") {
4494
5684
  this.logger.error(` - Error checking worktree:`, result.reason);
5685
+ outcome.recordSkipped("worktree", "update_check_failed", {
5686
+ message: getErrorMessage(result.reason)
5687
+ });
4495
5688
  }
4496
5689
  }
4497
5690
  const updateLimit = pLimit(
@@ -4505,6 +5698,7 @@ var WorktreeSyncService = class {
4505
5698
  this.logger.info(` - Updating worktree '${worktree.branch}'...`);
4506
5699
  await this.gitService.updateWorktree(worktree.path);
4507
5700
  this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
5701
+ outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
4508
5702
  } catch (error) {
4509
5703
  const errorMessage = getErrorMessage(error);
4510
5704
  if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
@@ -4512,13 +5706,23 @@ var WorktreeSyncService = class {
4512
5706
  ` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
4513
5707
  );
4514
5708
  try {
4515
- await this.handleDivergedBranch(worktree);
5709
+ await this.handleDivergedBranch(worktree, outcome);
4516
5710
  } catch (divergedError) {
4517
5711
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
5712
+ outcome.recordFailed("worktree", getErrorMessage(divergedError), {
5713
+ reason: "diverged_recovery_failed",
5714
+ branch: worktree.branch,
5715
+ path: worktree.path
5716
+ });
4518
5717
  throw divergedError;
4519
5718
  }
4520
5719
  } else {
4521
5720
  this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
5721
+ outcome.recordFailed("worktree", errorMessage, {
5722
+ reason: "update_failed",
5723
+ branch: worktree.branch,
5724
+ path: worktree.path
5725
+ });
4522
5726
  throw error;
4523
5727
  }
4524
5728
  }
@@ -4530,9 +5734,14 @@ var WorktreeSyncService = class {
4530
5734
  mutationTasks.push(
4531
5735
  updateLimit(async () => {
4532
5736
  try {
4533
- await this.handleDivergedBranch(worktree);
5737
+ await this.handleDivergedBranch(worktree, outcome);
4534
5738
  } catch (error) {
4535
5739
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
5740
+ outcome.recordFailed("worktree", getErrorMessage(error), {
5741
+ reason: "diverged_recovery_failed",
5742
+ branch: worktree.branch,
5743
+ path: worktree.path
5744
+ });
4536
5745
  throw error;
4537
5746
  }
4538
5747
  return { type: "diverged", branch: worktree.branch };
@@ -4555,13 +5764,13 @@ var WorktreeSyncService = class {
4555
5764
  }
4556
5765
  async cleanupOrphanedDirectories(worktrees) {
4557
5766
  try {
4558
- const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
4559
- const allDirs = await fs6.readdir(this.config.worktreeDir);
5767
+ const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
5768
+ const allDirs = await fs9.readdir(this.config.worktreeDir);
4560
5769
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
4561
5770
  const orphanedDirs = [];
4562
5771
  for (const dir of regularDirs) {
4563
5772
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
4564
- return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
5773
+ return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
4565
5774
  });
4566
5775
  if (!isPartOfWorktree) {
4567
5776
  orphanedDirs.push(dir);
@@ -4570,11 +5779,11 @@ var WorktreeSyncService = class {
4570
5779
  if (orphanedDirs.length > 0) {
4571
5780
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
4572
5781
  for (const dir of orphanedDirs) {
4573
- const dirPath = path8.join(this.config.worktreeDir, dir);
5782
+ const dirPath = path13.join(this.config.worktreeDir, dir);
4574
5783
  try {
4575
- const stat3 = await fs6.stat(dirPath);
5784
+ const stat3 = await fs9.stat(dirPath);
4576
5785
  if (stat3.isDirectory()) {
4577
- await fs6.rm(dirPath, { recursive: true, force: true });
5786
+ await fs9.rm(dirPath, { recursive: true, force: true });
4578
5787
  this.logger.info(` - Removed orphaned directory: ${dir}`);
4579
5788
  }
4580
5789
  } catch (error) {
@@ -4586,13 +5795,14 @@ var WorktreeSyncService = class {
4586
5795
  this.logger.error("Error during orphaned directory cleanup:", error);
4587
5796
  }
4588
5797
  }
4589
- async handleDivergedBranch(worktree) {
5798
+ async handleDivergedBranch(worktree, outcome) {
4590
5799
  this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
4591
5800
  const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
4592
5801
  if (treesIdentical) {
4593
5802
  this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
4594
5803
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
4595
5804
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
5805
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
4596
5806
  } else {
4597
5807
  const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
4598
5808
  if (!hasLocalChanges) {
@@ -4601,10 +5811,12 @@ var WorktreeSyncService = class {
4601
5811
  );
4602
5812
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
4603
5813
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
5814
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
4604
5815
  } else {
4605
5816
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
4606
5817
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
4607
- const relativePath = path8.relative(process.cwd(), divergedPath);
5818
+ const relativePath = path13.relative(process.cwd(), divergedPath);
5819
+ outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
4608
5820
  this.logger.info(` Moved to: ${relativePath}`);
4609
5821
  this.logger.info(` Your local changes are preserved. To review:`);
4610
5822
  this.logger.info(` cd ${relativePath}`);
@@ -4628,19 +5840,19 @@ var WorktreeSyncService = class {
4628
5840
  }
4629
5841
  }
4630
5842
  async divergeWorktree(worktreePath, branchName) {
4631
- const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5843
+ const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4632
5844
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4633
5845
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
4634
5846
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
4635
5847
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
4636
- const divergedPath = path8.join(divergedBaseDir, divergedName);
4637
- await fs6.mkdir(divergedBaseDir, { recursive: true });
5848
+ const divergedPath = path13.join(divergedBaseDir, divergedName);
5849
+ await fs9.mkdir(divergedBaseDir, { recursive: true });
4638
5850
  try {
4639
- await fs6.rename(worktreePath, divergedPath);
5851
+ await fs9.rename(worktreePath, divergedPath);
4640
5852
  } catch (err) {
4641
5853
  if (err.code === ERROR_MESSAGES.EXDEV) {
4642
- await fs6.cp(worktreePath, divergedPath, { recursive: true });
4643
- await fs6.rm(worktreePath, { recursive: true, force: true });
5854
+ await fs9.cp(worktreePath, divergedPath, { recursive: true });
5855
+ await fs9.rm(worktreePath, { recursive: true, force: true });
4644
5856
  } else {
4645
5857
  throw err;
4646
5858
  }
@@ -4659,89 +5871,194 @@ var WorktreeSyncService = class {
4659
5871
 
4660
5872
  Original worktree location: ${worktreePath}`
4661
5873
  };
4662
- await fs6.writeFile(
4663
- path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
5874
+ await fs9.writeFile(
5875
+ path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4664
5876
  JSON.stringify(metadata, null, 2)
4665
5877
  );
4666
5878
  return divergedPath;
4667
5879
  }
4668
5880
  };
4669
5881
 
4670
- // src/services/file-copy.service.ts
4671
- import * as fs7 from "fs/promises";
4672
- import * as path9 from "path";
4673
- import { glob } from "glob";
4674
- var DEFAULT_IGNORE_PATTERNS = [
4675
- "**/node_modules/**",
4676
- "**/.git/**",
4677
- "**/dist/**",
4678
- "**/build/**",
4679
- "**/.next/**",
4680
- "**/coverage/**"
4681
- ];
4682
- var FileCopyService = class {
4683
- /**
4684
- * Copy files matching patterns from source to destination directory.
4685
- * Skips files that already exist at destination.
4686
- * Preserves directory structure relative to source.
4687
- */
4688
- async copyFiles(sourceDir, destDir, patterns) {
4689
- const result = {
4690
- copied: [],
4691
- skipped: [],
4692
- errors: []
4693
- };
4694
- if (!patterns || patterns.length === 0) {
4695
- return result;
5882
+ // src/services/worktree-sync.service.ts
5883
+ var WorktreeSyncService = class {
5884
+ constructor(config) {
5885
+ this.config = config;
5886
+ this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
5887
+ this.gitService = new GitService(config, this.logger);
5888
+ this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
5889
+ this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
5890
+ this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
5891
+ config,
5892
+ this.gitService,
5893
+ this.logger,
5894
+ this.progressEmitter
5895
+ );
5896
+ if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
5897
+ this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
5898
+ progressEmitter: (event) => this.emitProgress(event),
5899
+ onSkip: (reason) => {
5900
+ this.skipsAccumulator.push(reason);
5901
+ }
5902
+ });
5903
+ }
5904
+ }
5905
+ gitService;
5906
+ cloneSyncService = null;
5907
+ logger;
5908
+ syncInProgress = false;
5909
+ progressEmitter = new ProgressEmitter();
5910
+ repoOperationLock;
5911
+ retryPolicy;
5912
+ worktreeModeSyncRunner;
5913
+ skipsAccumulator = [];
5914
+ lastOutcome = null;
5915
+ getRecordedSkips() {
5916
+ return [...this.skipsAccumulator];
5917
+ }
5918
+ clearRecordedSkips() {
5919
+ this.skipsAccumulator = [];
5920
+ }
5921
+ clearPendingInitSkip() {
5922
+ this.cloneSyncService?.clearPendingInitSkip();
5923
+ }
5924
+ getLastOutcome() {
5925
+ return this.lastOutcome;
5926
+ }
5927
+ isCloneMode() {
5928
+ return this.cloneSyncService !== null;
5929
+ }
5930
+ async getWorktrees() {
5931
+ if (this.cloneSyncService) {
5932
+ return this.cloneSyncService.getWorktrees();
5933
+ }
5934
+ return this.gitService.getWorktrees();
5935
+ }
5936
+ async initialize() {
5937
+ if (this.isInitialized()) return;
5938
+ const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
5939
+ if (!result.started) {
5940
+ const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
5941
+ this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
5942
+ }
5943
+ }
5944
+ async initializeUnlocked(outcome) {
5945
+ this.emitProgress({ phase: "initialize", message: "Initializing repository" });
5946
+ if (this.cloneSyncService) {
5947
+ await this.cloneSyncService.initialize(outcome);
5948
+ } else {
5949
+ await this.gitService.initialize();
5950
+ }
5951
+ this.emitProgress({ phase: "initialize", message: "Repository initialized" });
5952
+ }
5953
+ isInitialized() {
5954
+ if (this.cloneSyncService) {
5955
+ return this.cloneSyncService.isInitialized();
5956
+ }
5957
+ return this.gitService.isInitialized();
5958
+ }
5959
+ isSyncInProgress() {
5960
+ return this.syncInProgress;
5961
+ }
5962
+ getGitService() {
5963
+ return this.gitService;
5964
+ }
5965
+ updateLogger(logger) {
5966
+ this.logger = logger;
5967
+ this.gitService.updateLogger(logger);
5968
+ this.cloneSyncService?.updateLogger(logger);
5969
+ this.retryPolicy.updateLogger(logger);
5970
+ this.worktreeModeSyncRunner.updateLogger(logger);
5971
+ this.repoOperationLock.updateLogger(logger);
5972
+ }
5973
+ onProgress(listener) {
5974
+ return this.progressEmitter.onProgress(listener);
5975
+ }
5976
+ async runExclusiveRepoOperation(operation) {
5977
+ if (this.syncInProgress) {
5978
+ this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
5979
+ return { started: false, reason: "in_progress" };
5980
+ }
5981
+ this.syncInProgress = true;
5982
+ let release;
5983
+ try {
5984
+ release = await this.repoOperationLock.acquire();
5985
+ } catch (error) {
5986
+ this.syncInProgress = false;
5987
+ throw error;
5988
+ }
5989
+ if (release === null) {
5990
+ this.syncInProgress = false;
5991
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
5992
+ return { started: false, reason: "locked" };
5993
+ }
5994
+ try {
5995
+ return { started: true, value: await operation() };
5996
+ } finally {
5997
+ try {
5998
+ await release();
5999
+ } catch (releaseError) {
6000
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
6001
+ }
6002
+ this.syncInProgress = false;
4696
6003
  }
4697
- const filesToCopy = await this.expandPatterns(sourceDir, patterns);
4698
- for (const relativePath of filesToCopy) {
4699
- const sourcePath = path9.join(sourceDir, relativePath);
4700
- const destPath = path9.join(destDir, relativePath);
6004
+ }
6005
+ emitProgress(event) {
6006
+ this.progressEmitter.emit(event);
6007
+ }
6008
+ async sync() {
6009
+ const result = await this.runExclusiveRepoOperation(async () => {
6010
+ const totalTimer = new Timer();
6011
+ const phaseTimer = new PhaseTimer();
6012
+ const outcome = new SyncOutcomeAccumulator({
6013
+ mode: this.cloneSyncService ? "clone" : "worktree",
6014
+ repoName: this.config.name
6015
+ });
6016
+ const syncContext = this.retryPolicy.createContext();
6017
+ const retryOptions = this.retryPolicy.createOptions(syncContext);
6018
+ let durationMs;
4701
6019
  try {
4702
- const copied = await this.copyFile(sourcePath, destPath);
4703
- if (copied) {
4704
- result.copied.push(relativePath);
6020
+ if (!this.isInitialized()) {
6021
+ await this.initializeUnlocked(outcome);
6022
+ }
6023
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
6024
+ const retryOutcomeBaseline = outcome.snapshot();
6025
+ const retryOptionsWithOutcomeReset = {
6026
+ ...retryOptions,
6027
+ onRetry: (error, attempt, context) => {
6028
+ outcome.restore(retryOutcomeBaseline);
6029
+ retryOptions.onRetry?.(error, attempt, context);
6030
+ }
6031
+ };
6032
+ const cloneSync = this.cloneSyncService;
6033
+ if (cloneSync) {
6034
+ await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
4705
6035
  } else {
4706
- result.skipped.push(relativePath);
6036
+ await retry(
6037
+ () => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
6038
+ retryOptionsWithOutcomeReset
6039
+ );
4707
6040
  }
4708
6041
  } catch (error) {
4709
- result.errors.push({
4710
- file: relativePath,
4711
- error: error instanceof Error ? error.message : String(error)
4712
- });
4713
- }
4714
- }
4715
- return result;
4716
- }
4717
- async expandPatterns(sourceDir, patterns) {
4718
- const allFiles = /* @__PURE__ */ new Set();
4719
- for (const pattern of patterns) {
4720
- try {
4721
- const matches = await glob(pattern, {
4722
- cwd: sourceDir,
4723
- nodir: true,
4724
- dot: true,
4725
- ignore: DEFAULT_IGNORE_PATTERNS
4726
- });
4727
- for (const match of matches) {
4728
- allFiles.add(match);
6042
+ if (outcome.getCounts().failed === 0) {
6043
+ outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
6044
+ }
6045
+ this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
6046
+ throw error;
6047
+ } finally {
6048
+ this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
6049
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
6050
+ `);
6051
+ durationMs = totalTimer.stop();
6052
+ this.lastOutcome = outcome.toOutcome(durationMs);
6053
+ if (this.config.debug) {
6054
+ const phaseResults = phaseTimer.getResults();
6055
+ const repoName = this.config.name;
6056
+ this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
4729
6057
  }
4730
- } catch {
4731
6058
  }
4732
- }
4733
- return Array.from(allFiles);
4734
- }
4735
- async copyFile(sourcePath, destPath) {
4736
- try {
4737
- await fs7.access(destPath);
4738
- return false;
4739
- } catch {
4740
- }
4741
- const destDir = path9.dirname(destPath);
4742
- await fs7.mkdir(destDir, { recursive: true });
4743
- await fs7.copyFile(sourcePath, destPath);
4744
- return true;
6059
+ return this.lastOutcome ?? outcome.toOutcome(durationMs);
6060
+ });
6061
+ return result.started ? { started: true, outcome: result.value } : result;
4745
6062
  }
4746
6063
  };
4747
6064
 
@@ -4874,7 +6191,7 @@ var HookExecutionService = class {
4874
6191
  // src/utils/disk-space.ts
4875
6192
  import fastFolderSize from "fast-folder-size";
4876
6193
  async function calculateDirectorySize(dirPath) {
4877
- return new Promise((resolve9, reject) => {
6194
+ return new Promise((resolve12, reject) => {
4878
6195
  fastFolderSize(dirPath, (err, bytes) => {
4879
6196
  if (err) {
4880
6197
  reject(err);
@@ -4884,7 +6201,7 @@ async function calculateDirectorySize(dirPath) {
4884
6201
  reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
4885
6202
  return;
4886
6203
  }
4887
- resolve9(bytes);
6204
+ resolve12(bytes);
4888
6205
  });
4889
6206
  });
4890
6207
  }
@@ -4955,7 +6272,7 @@ var AppEventEmitter = class {
4955
6272
  };
4956
6273
 
4957
6274
  // src/services/InteractiveUIService.tsx
4958
- import * as fs8 from "fs/promises";
6275
+ import * as fs10 from "fs/promises";
4959
6276
  var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
4960
6277
  var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
4961
6278
  var InteractiveUIService = class {
@@ -4968,15 +6285,15 @@ var InteractiveUIService = class {
4968
6285
  logBuffer = [];
4969
6286
  uiReady = false;
4970
6287
  hookExecutionService = new HookExecutionService();
6288
+ branchCreatedActions = new BranchCreatedActionsService();
4971
6289
  pathResolution = new PathResolutionService();
4972
6290
  limit;
4973
6291
  reloadInProgress = false;
4974
6292
  isDestroyed = false;
4975
- reloadOptions;
4976
6293
  events;
4977
6294
  ownsEvents;
4978
6295
  unsubscribeCallbacks = [];
4979
- constructor(syncServices, configPath, cronSchedule, maxParallel, reloadOptions, events) {
6296
+ constructor(syncServices, configPath, cronSchedule, maxParallel, events) {
4980
6297
  this.ownsEvents = events === void 0;
4981
6298
  this.events = events ?? new AppEventEmitter();
4982
6299
  if (syncServices.length === 0) {
@@ -4987,7 +6304,6 @@ var InteractiveUIService = class {
4987
6304
  this.cronSchedule = cronSchedule;
4988
6305
  this.repositoryCount = syncServices.length;
4989
6306
  this.limit = pLimit2(maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
4990
- this.reloadOptions = reloadOptions ?? {};
4991
6307
  this.startBufferFlushCheck();
4992
6308
  this.renderUI();
4993
6309
  this.injectLoggersIntoServices();
@@ -5045,15 +6361,15 @@ var InteractiveUIService = class {
5045
6361
  const scheduleGroups = /* @__PURE__ */ new Map();
5046
6362
  for (const service of this.syncServices) {
5047
6363
  if (service.config.runOnce) continue;
5048
- const schedule3 = service.config.cronSchedule || this.cronSchedule;
5049
- if (!schedule3) continue;
5050
- if (!scheduleGroups.has(schedule3)) {
5051
- scheduleGroups.set(schedule3, []);
6364
+ const schedule2 = service.config.cronSchedule || this.cronSchedule;
6365
+ if (!schedule2) continue;
6366
+ if (!scheduleGroups.has(schedule2)) {
6367
+ scheduleGroups.set(schedule2, []);
5052
6368
  }
5053
- scheduleGroups.get(schedule3).push(service);
6369
+ scheduleGroups.get(schedule2).push(service);
5054
6370
  }
5055
- for (const [schedule3, services] of scheduleGroups) {
5056
- const task = cron2.schedule(schedule3, async () => {
6371
+ for (const [schedule2, services] of scheduleGroups) {
6372
+ const task = cron2.schedule(schedule2, async () => {
5057
6373
  await this.runSyncCycle(services, { logErrors: false });
5058
6374
  });
5059
6375
  this.cronJobs.push(task);
@@ -5091,8 +6407,8 @@ var InteractiveUIService = class {
5091
6407
  getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
5092
6408
  getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
5093
6409
  deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
5094
- openEditorInWorktree: (path14) => this.openEditorInWorktree(path14),
5095
- openTerminalInWorktree: (repoIndex, path14, branchName) => this.openTerminalInWorktree(repoIndex, path14, branchName),
6410
+ openEditorInWorktree: (path18) => this.openEditorInWorktree(path18),
6411
+ openTerminalInWorktree: (repoIndex, path18, branchName) => this.openTerminalInWorktree(repoIndex, path18, branchName),
5096
6412
  copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
5097
6413
  createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
5098
6414
  executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
@@ -5121,24 +6437,28 @@ var InteractiveUIService = class {
5121
6437
  this.addLog("Reloading configuration...");
5122
6438
  this.setStatus("syncing");
5123
6439
  const configLoader = new ConfigLoaderService();
5124
- const { repositories } = await configLoader.buildRepositories(this.configPath, {
5125
- filter: this.reloadOptions.filter,
5126
- noUpdateExisting: this.reloadOptions.noUpdateExisting,
5127
- debug: this.reloadOptions.debug
5128
- });
6440
+ const { repositories } = await configLoader.buildRepositories(this.configPath);
5129
6441
  const initResults = await Promise.allSettled(
5130
6442
  repositories.map(
5131
6443
  (repoConfig) => this.limit(async () => {
5132
6444
  const service = new WorktreeSyncService(repoConfig);
5133
6445
  await service.initialize();
5134
- return service;
6446
+ return {
6447
+ service,
6448
+ clonePhaseSkips: service.getRecordedSkips().map((reason) => ({
6449
+ repo: repoConfig.name || repoConfig.repoUrl,
6450
+ reason: formatCloneSkipReason(reason)
6451
+ }))
6452
+ };
5135
6453
  })
5136
6454
  )
5137
6455
  );
5138
6456
  const newServices = [];
6457
+ const initClonePhaseSkips = [];
5139
6458
  for (const result of initResults) {
5140
6459
  if (result.status === "fulfilled") {
5141
- newServices.push(result.value);
6460
+ newServices.push(result.value.service);
6461
+ initClonePhaseSkips.push(...result.value.clonePhaseSkips);
5142
6462
  } else {
5143
6463
  this.addLog(`Failed to initialize repository: ${result.reason}`, "error");
5144
6464
  }
@@ -5156,12 +6476,24 @@ var InteractiveUIService = class {
5156
6476
  this.setupCronJobs();
5157
6477
  this.events.emit("updateRepositoryCount", this.repositoryCount);
5158
6478
  this.events.emit("updateCronSchedule", this.cronSchedule);
5159
- const { failures, skipped, attempted } = await this.runSyncServices(this.syncServices);
6479
+ const {
6480
+ failures,
6481
+ skipped,
6482
+ clonePhaseSkips: syncClonePhaseSkips,
6483
+ attempted
6484
+ } = await this.runSyncServices(this.syncServices);
6485
+ const clonePhaseSkips = [...initClonePhaseSkips, ...syncClonePhaseSkips];
5160
6486
  await this.recordSyncOutcome({ failures, skipped, attempted });
5161
6487
  this.setStatus("idle");
5162
6488
  for (const skip of skipped) {
5163
6489
  this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
5164
6490
  }
6491
+ for (const skip of clonePhaseSkips) {
6492
+ this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
6493
+ }
6494
+ if (clonePhaseSkips.length > 0) {
6495
+ this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) during reload`, "warn");
6496
+ }
5165
6497
  if (failures.length > 0) {
5166
6498
  for (const failure of failures) {
5167
6499
  this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
@@ -5195,14 +6527,14 @@ var InteractiveUIService = class {
5195
6527
  if (Date.now() - startTime > timeoutMs) {
5196
6528
  throw new Error("Timeout waiting for sync operations to complete");
5197
6529
  }
5198
- await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
6530
+ await new Promise((resolve12) => setTimeout(resolve12, checkInterval));
5199
6531
  }
5200
6532
  });
5201
6533
  try {
5202
6534
  await Promise.all(syncChecks);
5203
6535
  } catch {
5204
6536
  this.addLog(
5205
- `Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
6537
+ `Warning: Timeout waiting for sync operations to complete after ${formatDuration(timeoutMs)}. Proceeding with potential data loss risk.`,
5206
6538
  "warn"
5207
6539
  );
5208
6540
  }
@@ -5304,8 +6636,7 @@ var InteractiveUIService = class {
5304
6636
  throw new Error(`Invalid repository index: ${repoIndex}`);
5305
6637
  }
5306
6638
  const service = this.syncServices[repoIndex];
5307
- const gitService = service.getGitService();
5308
- return gitService.getWorktrees();
6639
+ return this.getWorktreesFromService(service);
5309
6640
  }
5310
6641
  async getWorktreeStatusForRepo(repoIndex) {
5311
6642
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -5313,7 +6644,7 @@ var InteractiveUIService = class {
5313
6644
  }
5314
6645
  const service = this.syncServices[repoIndex];
5315
6646
  const gitService = service.getGitService();
5316
- const worktrees = await gitService.getWorktrees();
6647
+ const worktrees = await this.getWorktreesFromService(service);
5317
6648
  const results = await Promise.allSettled(
5318
6649
  worktrees.map(async (wt) => {
5319
6650
  const status = await gitService.getFullWorktreeStatus(wt.path, true);
@@ -5322,28 +6653,35 @@ var InteractiveUIService = class {
5322
6653
  );
5323
6654
  return results.filter((r) => r.status === "fulfilled").map((r) => r.value);
5324
6655
  }
6656
+ async getWorktreesFromService(service) {
6657
+ const worktreeProvider = service;
6658
+ if (typeof worktreeProvider.getWorktrees === "function") {
6659
+ return worktreeProvider.getWorktrees();
6660
+ }
6661
+ return service.getGitService().getWorktrees();
6662
+ }
5325
6663
  async getDivergedDirectoriesForRepo(repoIndex) {
5326
6664
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
5327
6665
  return [];
5328
6666
  }
5329
6667
  const service = this.syncServices[repoIndex];
5330
6668
  const worktreeDir = service.config.worktreeDir;
5331
- const divergedDir = path10.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6669
+ const divergedDir = path14.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5332
6670
  let dirEntries;
5333
6671
  try {
5334
- dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
6672
+ dirEntries = await fs10.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
5335
6673
  } catch {
5336
6674
  return [];
5337
6675
  }
5338
6676
  const subdirs = dirEntries.filter((e) => e.isDirectory());
5339
6677
  const results = await Promise.allSettled(
5340
6678
  subdirs.map(async (entry) => {
5341
- const fullPath = path10.join(divergedDir, entry.name);
5342
- const infoFilePath = path10.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
6679
+ const fullPath = path14.join(divergedDir, entry.name);
6680
+ const infoFilePath = path14.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
5343
6681
  let originalBranch = entry.name;
5344
6682
  let divergedAt = "";
5345
6683
  try {
5346
- const infoContent = await fs8.readFile(infoFilePath, "utf-8");
6684
+ const infoContent = await fs10.readFile(infoFilePath, "utf-8");
5347
6685
  const info = JSON.parse(infoContent);
5348
6686
  if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
5349
6687
  if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
@@ -5374,15 +6712,15 @@ var InteractiveUIService = class {
5374
6712
  }
5375
6713
  const service = this.syncServices[repoIndex];
5376
6714
  const worktreeDir = service.config.worktreeDir;
5377
- const divergedBase = path10.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6715
+ const divergedBase = path14.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5378
6716
  if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
5379
6717
  throw new Error(`Invalid diverged directory name: "${name}"`);
5380
6718
  }
5381
- const targetPath = path10.join(divergedBase, name);
6719
+ const targetPath = path14.join(divergedBase, name);
5382
6720
  if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
5383
6721
  throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
5384
6722
  }
5385
- await fs8.rm(targetPath, { recursive: true, force: true });
6723
+ await fs10.rm(targetPath, { recursive: true, force: true });
5386
6724
  this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
5387
6725
  }
5388
6726
  async createWorktreeForBranch(repoIndex, branchName) {
@@ -5500,7 +6838,7 @@ var InteractiveUIService = class {
5500
6838
  async runSyncCycle(services, options) {
5501
6839
  this.setStatus("syncing");
5502
6840
  try {
5503
- const { failures, skipped, attempted } = await this.runSyncServices(services);
6841
+ const { failures, skipped, partialSkips, clonePhaseSkips, attempted } = await this.runSyncServices(services);
5504
6842
  if (options.logErrors) {
5505
6843
  for (const failure of failures) {
5506
6844
  this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
@@ -5509,6 +6847,15 @@ var InteractiveUIService = class {
5509
6847
  for (const skip of skipped) {
5510
6848
  this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
5511
6849
  }
6850
+ for (const skip of clonePhaseSkips) {
6851
+ this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
6852
+ }
6853
+ if (clonePhaseSkips.length > 0) {
6854
+ this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) this cycle`, "warn");
6855
+ }
6856
+ for (const partial of partialSkips) {
6857
+ this.addLog(`${partial.repo}: ${partial.reason}`, "info");
6858
+ }
5512
6859
  await this.recordSyncOutcome({ failures, skipped, attempted });
5513
6860
  return failures;
5514
6861
  } finally {
@@ -5525,6 +6872,7 @@ var InteractiveUIService = class {
5525
6872
  const syncResults = await Promise.allSettled(
5526
6873
  services.map(
5527
6874
  (service) => this.limit(async () => {
6875
+ service.clearRecordedSkips();
5528
6876
  if (!service.isInitialized()) {
5529
6877
  await service.initialize();
5530
6878
  }
@@ -5538,18 +6886,39 @@ var InteractiveUIService = class {
5538
6886
  );
5539
6887
  const failures = [];
5540
6888
  const skipped = [];
6889
+ const partialSkips = [];
6890
+ const clonePhaseSkips = [];
5541
6891
  for (let i = 0; i < syncResults.length; i++) {
5542
6892
  const result = syncResults[i];
6893
+ const repoName = services[i].config.name || services[i].config.repoUrl;
5543
6894
  if (result.status === "rejected") {
5544
- const repoName = result.reason?.repoName ?? "unknown";
6895
+ const fallbackName = result.reason?.repoName ?? repoName;
5545
6896
  const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
5546
- failures.push({ repo: repoName, error: errorMessage });
6897
+ failures.push({ repo: fallbackName, error: errorMessage });
5547
6898
  } else if (result.value.result && result.value.result.started === false) {
5548
- const repoName = services[i].config.name || services[i].config.repoUrl;
5549
6899
  skipped.push({ repo: repoName, reason: `sync skipped: ${result.value.result.reason}` });
6900
+ } else if (result.status === "fulfilled" && result.value.result?.started === true) {
6901
+ const outcome = result.value.result.outcome;
6902
+ if (outcome?.counts.failed) {
6903
+ failures.push({ repo: repoName, error: `${outcome.counts.failed} sync action(s) failed` });
6904
+ }
6905
+ if (outcome?.mode === "worktree" && outcome.counts.skipped > 0) {
6906
+ partialSkips.push({ repo: repoName, reason: `${outcome.counts.skipped} sync action(s) skipped` });
6907
+ }
6908
+ }
6909
+ for (const reason of services[i].getRecordedSkips()) {
6910
+ clonePhaseSkips.push({ repo: repoName, reason: formatCloneSkipReason(reason) });
5550
6911
  }
5551
6912
  }
5552
- return { failures, skipped, attempted: services.length };
6913
+ return { failures, skipped, partialSkips, clonePhaseSkips, attempted: services.length };
6914
+ }
6915
+ buildUiLogger() {
6916
+ return new Logger({
6917
+ outputFn: (msg, level) => {
6918
+ const uiLevel = level === "warn" ? "warn" : level === "error" ? "error" : "info";
6919
+ this.addLog(msg, uiLevel);
6920
+ }
6921
+ });
5553
6922
  }
5554
6923
  executeOnBranchCreatedHooks(repoIndex, context) {
5555
6924
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -5557,27 +6926,15 @@ var InteractiveUIService = class {
5557
6926
  }
5558
6927
  const service = this.syncServices[repoIndex];
5559
6928
  const config = service.config;
5560
- if (!config.hooks?.onBranchCreated?.length) {
5561
- return;
5562
- }
5563
- this.addLog(`Running ${config.hooks.onBranchCreated.length} hook(s) for branch '${context.branchName}'...`, "info");
5564
- this.hookExecutionService.executeOnBranchCreated(config.hooks, context, {
5565
- onStdout: (data) => {
5566
- this.addLog(`[hook] ${data}`, "info");
5567
- },
5568
- onStderr: (data) => {
5569
- this.addLog(`[hook] ${data}`, "warn");
5570
- },
5571
- onError: (command, error) => {
5572
- this.addLog(`[hook] Failed to execute '${command}': ${error.message}`, "error");
5573
- },
5574
- onComplete: (command, exitCode) => {
5575
- if (exitCode === 0) {
5576
- this.addLog(`[hook] Command completed successfully`, "info");
5577
- } else if (exitCode !== null) {
5578
- this.addLog(`[hook] Command exited with code ${exitCode}`, "warn");
5579
- }
5580
- }
6929
+ const repoName = config.name || config.repoUrl;
6930
+ this.branchCreatedActions.runHooks({
6931
+ config,
6932
+ repoName,
6933
+ branchName: context.branchName,
6934
+ worktreePath: context.worktreePath,
6935
+ baseBranch: context.baseBranch,
6936
+ logger: this.buildUiLogger(),
6937
+ hookExecutionService: this.hookExecutionService
5581
6938
  });
5582
6939
  }
5583
6940
  async copyBranchFiles(repoIndex, baseBranch, targetBranch) {
@@ -5589,33 +6946,20 @@ var InteractiveUIService = class {
5589
6946
  if (!config.filesToCopyOnBranchCreate?.length) {
5590
6947
  return;
5591
6948
  }
5592
- const gitService = service.getGitService();
5593
- const worktrees = await gitService.getWorktrees();
6949
+ const worktrees = await this.getWorktreesFromService(service);
5594
6950
  const sourceWorktree = worktrees.find((w) => w.branch === baseBranch);
5595
6951
  const targetWorktree = worktrees.find((w) => w.branch === targetBranch);
5596
6952
  if (!sourceWorktree || !targetWorktree) {
5597
6953
  this.addLog(`Could not find worktrees for file copy: source=${baseBranch}, target=${targetBranch}`, "warn");
5598
6954
  return;
5599
6955
  }
5600
- const fileCopyService = new FileCopyService();
5601
- try {
5602
- const result = await fileCopyService.copyFiles(
5603
- sourceWorktree.path,
5604
- targetWorktree.path,
5605
- config.filesToCopyOnBranchCreate
5606
- );
5607
- if (result.copied.length > 0) {
5608
- this.addLog(`\u{1F4CB} Copied ${result.copied.length} file(s) to new branch: ${result.copied.join(", ")}`, "info");
5609
- }
5610
- if (result.errors.length > 0) {
5611
- this.addLog(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s):`, "warn");
5612
- for (const err of result.errors) {
5613
- this.addLog(` - ${err.file}: ${err.error}`, "warn");
5614
- }
5615
- }
5616
- } catch (error) {
5617
- this.addLog(`Failed to copy files to new branch: ${error}`, "error");
5618
- }
6956
+ await this.branchCreatedActions.copyFiles({
6957
+ config,
6958
+ branchName: targetBranch,
6959
+ worktreePath: targetWorktree.path,
6960
+ sourceDir: sourceWorktree.path,
6961
+ logger: this.buildUiLogger()
6962
+ });
5619
6963
  }
5620
6964
  async destroy(fast = false) {
5621
6965
  this.isDestroyed = true;
@@ -5644,135 +6988,85 @@ var InteractiveUIService = class {
5644
6988
  // src/utils/cli.ts
5645
6989
  import yargs from "yargs";
5646
6990
  import { hideBin } from "yargs/helpers";
5647
- function parseArguments() {
5648
- const argv = yargs(hideBin(process.argv)).option("config", {
5649
- alias: "c",
5650
- type: "string",
5651
- description: "Path to JavaScript config file"
5652
- }).option("filter", {
5653
- alias: "f",
5654
- type: "string",
5655
- description: "Filter repositories by name (supports wildcards and comma-separated values)"
5656
- }).option("list", {
5657
- alias: "l",
5658
- type: "boolean",
5659
- description: "List configured repositories and exit",
5660
- default: false
5661
- }).option("bareRepoDir", {
5662
- alias: "b",
5663
- type: "string",
5664
- description: "Directory for storing bare repositories (default: .bare/<repo-name>)."
5665
- }).option("repoUrl", {
5666
- alias: "u",
5667
- type: "string",
5668
- description: "Git repository URL (e.g., SSH or HTTPS)."
5669
- }).option("worktreeDir", {
5670
- alias: "w",
5671
- type: "string",
5672
- description: "Absolute path to the directory for storing worktrees."
5673
- }).option("cronSchedule", {
5674
- alias: "s",
5675
- type: "string",
5676
- description: "Cron schedule for how often to run the sync.",
5677
- default: "0 * * * *"
5678
- }).option("runOnce", {
5679
- type: "boolean",
5680
- description: "Run the sync process once and then exit, without scheduling.",
5681
- default: false
5682
- }).option("branchMaxAge", {
5683
- alias: "a",
5684
- type: "string",
5685
- description: "Maximum age of branches to sync (e.g., '30d', '6m', '1y')."
5686
- }).option("branchInclude", {
5687
- type: "string",
5688
- description: "Only sync branches matching these patterns (comma-separated, supports wildcards)."
5689
- }).option("branchExclude", {
5690
- type: "string",
5691
- description: "Exclude branches matching these patterns (comma-separated, supports wildcards)."
5692
- }).option("skipLfs", {
5693
- type: "boolean",
5694
- description: "Skip Git LFS downloads when fetching and creating worktrees.",
5695
- default: false
5696
- }).option("no-update-existing", {
5697
- type: "boolean",
5698
- description: "Disable automatic updates of existing worktrees.",
5699
- default: false
5700
- }).option("debug", {
5701
- alias: "d",
5702
- type: "boolean",
5703
- description: "Enable debug mode to show detailed reasons why worktrees are not cleaned up.",
5704
- default: false
5705
- }).option("sync-on-start", {
5706
- type: "boolean",
5707
- description: "Run sync immediately when starting the interactive UI (config mode only).",
5708
- default: false
5709
- }).help().alias("help", "h").parseSync();
5710
- return {
5711
- config: argv.config,
5712
- filter: argv.filter,
5713
- list: argv.list,
5714
- repoUrl: argv.repoUrl,
5715
- worktreeDir: argv.worktreeDir,
5716
- cronSchedule: argv.cronSchedule,
5717
- runOnce: argv.runOnce,
5718
- bareRepoDir: argv.bareRepoDir,
5719
- branchMaxAge: argv.branchMaxAge,
5720
- branchInclude: argv.branchInclude ? argv.branchInclude.split(",").map((p) => p.trim()) : void 0,
5721
- branchExclude: argv.branchExclude ? argv.branchExclude.split(",").map((p) => p.trim()) : void 0,
5722
- skipLfs: argv.skipLfs,
5723
- noUpdateExisting: argv["no-update-existing"],
5724
- debug: argv.debug,
5725
- syncOnStart: argv["sync-on-start"]
5726
- };
5727
- }
5728
- function isInteractiveMode(config) {
5729
- return !config.repoUrl || !config.worktreeDir;
5730
- }
5731
- function reconstructCliCommand(config) {
5732
- const executable = process.argv[1].includes("ts-node") ? "ts-node src/index.ts" : "sync-worktrees";
5733
- const args = [];
5734
- args.push(`--repoUrl "${config.repoUrl}"`);
5735
- if (config.worktreeDir) {
5736
- args.push(`--worktreeDir "${config.worktreeDir}"`);
5737
- }
5738
- if (config.bareRepoDir) {
5739
- args.push(`--bareRepoDir "${config.bareRepoDir}"`);
5740
- }
5741
- if (config.cronSchedule && config.cronSchedule !== "0 * * * *") {
5742
- args.push(`--cronSchedule "${config.cronSchedule}"`);
5743
- }
5744
- if (config.runOnce) {
5745
- args.push("--runOnce");
5746
- }
5747
- if (config.branchMaxAge) {
5748
- args.push(`--branchMaxAge "${config.branchMaxAge}"`);
5749
- }
5750
- if (config.branchInclude?.length) {
5751
- args.push(`--branchInclude "${config.branchInclude.join(",")}"`);
5752
- }
5753
- if (config.branchExclude?.length) {
5754
- args.push(`--branchExclude "${config.branchExclude.join(",")}"`);
5755
- }
5756
- if (config.skipLfs) {
5757
- args.push("--skip-lfs");
5758
- }
5759
- if (config.updateExistingWorktrees === false) {
5760
- args.push("--no-update-existing");
5761
- }
5762
- if (config.debug) {
5763
- args.push("--debug");
6991
+ var CLI_COMMANDS = {
6992
+ RUN: "run",
6993
+ INIT: "init",
6994
+ LIST: "list"
6995
+ };
6996
+ function parseArguments(argv = hideBin(process.argv)) {
6997
+ let parsed;
6998
+ yargs(argv).scriptName("sync-worktrees").parserConfiguration({ "camel-case-expansion": false }).strict().command(
6999
+ "$0",
7000
+ "Sync git worktrees against a config file",
7001
+ (y) => y.option("config", {
7002
+ alias: "c",
7003
+ type: "string",
7004
+ description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
7005
+ }).option("runOnce", {
7006
+ type: "boolean",
7007
+ description: "Run a sync once and exit, overriding config runOnce settings for this invocation.",
7008
+ default: false
7009
+ }),
7010
+ (args) => {
7011
+ parsed = {
7012
+ command: CLI_COMMANDS.RUN,
7013
+ config: args.config,
7014
+ runOnce: args.runOnce
7015
+ };
7016
+ }
7017
+ ).command(
7018
+ CLI_COMMANDS.INIT,
7019
+ "Create a new config file interactively",
7020
+ (y) => y.option("config", {
7021
+ alias: "c",
7022
+ type: "string",
7023
+ description: "Target path for the generated config file (default: ./sync-worktrees.config.js)."
7024
+ }).option("force", {
7025
+ type: "boolean",
7026
+ description: "Overwrite the target file if it already exists.",
7027
+ default: false
7028
+ }),
7029
+ (args) => {
7030
+ parsed = {
7031
+ command: CLI_COMMANDS.INIT,
7032
+ config: args.config,
7033
+ force: args.force
7034
+ };
7035
+ }
7036
+ ).command(
7037
+ CLI_COMMANDS.LIST,
7038
+ "List repositories configured in a config file and exit",
7039
+ (y) => y.option("config", {
7040
+ alias: "c",
7041
+ type: "string",
7042
+ description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
7043
+ }).option("filter", {
7044
+ alias: "f",
7045
+ type: "string",
7046
+ description: "Filter repositories by name (wildcards, comma-separated)."
7047
+ }),
7048
+ (args) => {
7049
+ parsed = {
7050
+ command: CLI_COMMANDS.LIST,
7051
+ config: args.config,
7052
+ filter: args.filter
7053
+ };
7054
+ }
7055
+ ).demandCommand(0, 0).help().alias("help", "h").version().parseSync();
7056
+ if (!parsed) {
7057
+ throw new Error("Failed to parse CLI arguments");
5764
7058
  }
5765
- return `${executable} ${args.join(" ")}`;
7059
+ return parsed;
5766
7060
  }
5767
7061
 
5768
7062
  // src/utils/config-generator.ts
5769
- import * as fs9 from "fs/promises";
5770
- import * as path11 from "path";
7063
+ import * as fs11 from "fs/promises";
7064
+ import * as path15 from "path";
5771
7065
  function serializeToESM(obj, indent = 0) {
5772
7066
  const spaces = " ".repeat(indent);
5773
7067
  const innerSpaces = " ".repeat(indent + 2);
5774
7068
  if (typeof obj === "string") {
5775
- return `"${obj}"`;
7069
+ return JSON.stringify(obj);
5776
7070
  }
5777
7071
  if (typeof obj === "number" || typeof obj === "boolean") {
5778
7072
  return String(obj);
@@ -5796,99 +7090,102 @@ ${spaces}}`;
5796
7090
  }
5797
7091
  return String(obj);
5798
7092
  }
5799
- async function generateConfigFile(config, configPath) {
5800
- const configDir = path11.dirname(configPath);
5801
- await fs9.mkdir(configDir, { recursive: true });
5802
- const worktreeDirRelative = path11.relative(configDir, config.worktreeDir);
7093
+ async function generateConfigFile(input2, configPath, options = {}) {
7094
+ const configDir = path15.dirname(configPath);
7095
+ await fs11.mkdir(configDir, { recursive: true });
7096
+ const worktreeDirRelative = path15.relative(configDir, input2.worktreeDir);
5803
7097
  const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
5804
- const repoName = extractRepoNameFromUrl(config.repoUrl);
7098
+ const repoName = extractRepoNameFromUrl(input2.repoUrl);
5805
7099
  const repository = {
5806
7100
  name: repoName,
5807
- repoUrl: config.repoUrl,
5808
- worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
7101
+ repoUrl: input2.repoUrl,
7102
+ worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
5809
7103
  };
5810
- if (config.bareRepoDir) {
5811
- const bareRepoDirRelative = path11.relative(configDir, config.bareRepoDir);
7104
+ if (input2.bareRepoDir) {
7105
+ const bareRepoDirRelative = path15.relative(configDir, input2.bareRepoDir);
5812
7106
  const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
5813
- repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
7107
+ repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
5814
7108
  }
5815
7109
  const configObject = {
5816
7110
  defaults: {
5817
- cronSchedule: config.cronSchedule,
5818
- runOnce: config.runOnce
7111
+ cronSchedule: input2.cronSchedule,
7112
+ runOnce: input2.runOnce
5819
7113
  },
5820
7114
  repositories: [repository]
5821
7115
  };
5822
- const configContent = `/**
7116
+ const configContent = `// @ts-check
7117
+
7118
+ /**
5823
7119
  * Sync-worktrees configuration file
5824
7120
  * Generated on ${(/* @__PURE__ */ new Date()).toISOString()}
5825
7121
  */
5826
7122
 
5827
- export default ${serializeToESM(configObject)};
7123
+ /** @satisfies {import("sync-worktrees").SyncWorktreesConfig} */
7124
+ const config = ${serializeToESM(configObject)};
7125
+
7126
+ export default config;
5828
7127
  `;
5829
- await fs9.writeFile(configPath, configContent, "utf-8");
7128
+ try {
7129
+ await fs11.writeFile(configPath, configContent, {
7130
+ encoding: "utf-8",
7131
+ flag: options.overwrite ? "w" : "wx"
7132
+ });
7133
+ } catch (error) {
7134
+ if (error.code === "EEXIST") {
7135
+ throw new ConfigFileExistsError(configPath);
7136
+ }
7137
+ throw error;
7138
+ }
5830
7139
  }
5831
7140
  function getDefaultConfigPath() {
5832
- return path11.join(process.cwd(), "sync-worktrees.config.js");
7141
+ return path15.join(process.cwd(), "sync-worktrees.config.js");
5833
7142
  }
5834
7143
  async function findConfigInCwd(cwd = process.cwd()) {
5835
7144
  for (const name of CONFIG_FILE_NAMES) {
5836
- const full = path11.join(cwd, name);
5837
- try {
5838
- await fs9.access(full);
7145
+ const full = path15.join(cwd, name);
7146
+ if (await fileExists(full)) {
5839
7147
  return full;
5840
- } catch {
5841
7148
  }
5842
7149
  }
5843
7150
  return null;
5844
7151
  }
5845
7152
 
5846
7153
  // src/utils/interactive.ts
5847
- import * as path12 from "path";
7154
+ import * as path16 from "path";
5848
7155
  import { confirm, input, select } from "@inquirer/prompts";
5849
- async function promptForConfig(partialConfig) {
7156
+ async function promptForInitConfig() {
5850
7157
  console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
5851
- let repoUrl = partialConfig.repoUrl;
5852
- if (!repoUrl) {
5853
- repoUrl = await input({
5854
- message: "Enter the Git repository URL (e.g., https://github.com/user/repo.git):",
5855
- validate: (value) => {
5856
- if (!value.trim()) {
5857
- return "Repository URL is required";
5858
- }
5859
- try {
5860
- if (!value.match(/^(https?:\/\/|ssh:\/\/|git@|file:\/\/).*$/)) {
5861
- return "Please enter a valid Git URL (https://, ssh://, git@, or file://)";
5862
- }
5863
- return true;
5864
- } catch {
5865
- return "Please enter a valid URL";
5866
- }
7158
+ const repoUrl = await input({
7159
+ message: "Enter the Git repository URL (e.g., https://github.com/user/repo.git):",
7160
+ validate: (value) => {
7161
+ if (!value.trim()) {
7162
+ return "Repository URL is required";
5867
7163
  }
5868
- });
5869
- }
5870
- let worktreeDir = partialConfig.worktreeDir;
5871
- if (!worktreeDir) {
5872
- const repoName = repoUrl ? extractRepoNameFromUrl(repoUrl) : "";
5873
- const defaultWorktreeDir = repoName ? `./${repoName}` : "";
5874
- worktreeDir = await input({
5875
- message: "Enter the directory for storing worktrees:",
5876
- default: defaultWorktreeDir,
5877
- validate: (value) => {
5878
- if (!value.trim() && !defaultWorktreeDir) {
5879
- return "Worktree directory is required";
5880
- }
5881
- return true;
7164
+ if (!value.match(/^(https?:\/\/|ssh:\/\/|git@|file:\/\/).*$/)) {
7165
+ return "Please enter a valid Git URL (https://, ssh://, git@, or file://)";
5882
7166
  }
5883
- });
5884
- if (!worktreeDir.trim() && defaultWorktreeDir) {
5885
- worktreeDir = defaultWorktreeDir;
7167
+ return true;
5886
7168
  }
5887
- if (!path12.isAbsolute(worktreeDir)) {
5888
- worktreeDir = path12.resolve(worktreeDir);
7169
+ });
7170
+ const repoName = extractRepoNameFromUrl(repoUrl);
7171
+ const defaultWorktreeDir = repoName ? `./${repoName}` : "";
7172
+ let worktreeDir = await input({
7173
+ message: "Enter the directory for storing worktrees:",
7174
+ default: defaultWorktreeDir,
7175
+ validate: (value) => {
7176
+ if (!value.trim() && !defaultWorktreeDir) {
7177
+ return "Worktree directory is required";
7178
+ }
7179
+ return true;
5889
7180
  }
7181
+ });
7182
+ if (!worktreeDir.trim() && defaultWorktreeDir) {
7183
+ worktreeDir = defaultWorktreeDir;
5890
7184
  }
5891
- let bareRepoDir = partialConfig.bareRepoDir;
7185
+ if (!path16.isAbsolute(worktreeDir)) {
7186
+ worktreeDir = path16.resolve(worktreeDir);
7187
+ }
7188
+ let bareRepoDir;
5892
7189
  const askForBareDir = await confirm({
5893
7190
  message: "Would you like to specify a custom location for the bare repository?",
5894
7191
  default: false
@@ -5904,96 +7201,42 @@ async function promptForConfig(partialConfig) {
5904
7201
  return true;
5905
7202
  }
5906
7203
  });
5907
- if (!path12.isAbsolute(bareRepoDir)) {
5908
- bareRepoDir = path12.resolve(bareRepoDir);
5909
- }
5910
- }
5911
- let runOnce = partialConfig.runOnce;
5912
- let cronSchedule = partialConfig.cronSchedule || "0 * * * *";
5913
- if (runOnce === void 0) {
5914
- const runMode = await select({
5915
- message: "How would you like to run the sync?",
5916
- choices: [
5917
- { name: "Run once", value: "once" },
5918
- { name: "Schedule with cron", value: "scheduled" }
5919
- ]
5920
- });
5921
- runOnce = runMode === "once";
5922
- if (!runOnce && !partialConfig.cronSchedule) {
5923
- cronSchedule = await input({
5924
- message: "Enter the cron schedule (or press enter for default):",
5925
- default: "0 * * * *",
5926
- validate: (value) => {
5927
- if (!value.trim()) {
5928
- return "Cron schedule is required";
5929
- }
5930
- const parts = value.trim().split(" ");
5931
- if (parts.length < 5) {
5932
- return "Invalid cron pattern. Expected format: '* * * * *'";
5933
- }
5934
- return true;
5935
- }
5936
- });
7204
+ if (!path16.isAbsolute(bareRepoDir)) {
7205
+ bareRepoDir = path16.resolve(bareRepoDir);
5937
7206
  }
5938
7207
  }
5939
- const finalConfig = {
5940
- repoUrl,
5941
- worktreeDir,
5942
- cronSchedule,
5943
- runOnce: runOnce || false,
5944
- bareRepoDir
5945
- };
5946
- console.log("\n\u{1F4CB} Configuration summary:");
5947
- console.log(` Repository URL: ${finalConfig.repoUrl}`);
5948
- console.log(` Worktrees: ${finalConfig.worktreeDir}`);
5949
- if (finalConfig.bareRepoDir) {
5950
- console.log(` Bare repo: ${finalConfig.bareRepoDir}`);
5951
- } else {
5952
- console.log(` Bare repo: .bare/<repo-name> (default)`);
5953
- }
5954
- if (finalConfig.runOnce) {
5955
- console.log(` Mode: Run once`);
5956
- } else {
5957
- console.log(` Mode: Scheduled (${finalConfig.cronSchedule})`);
5958
- }
5959
- console.log("");
5960
- const saveConfig = await confirm({
5961
- message: "Would you like to save this configuration to a file for future use?",
5962
- default: true
7208
+ const runMode = await select({
7209
+ message: "How would you like to run the sync?",
7210
+ choices: [
7211
+ { name: "Run once", value: "once" },
7212
+ { name: "Schedule with cron", value: "scheduled" }
7213
+ ]
5963
7214
  });
5964
- let savedConfigPath;
5965
- if (saveConfig) {
5966
- const defaultConfigPath = getDefaultConfigPath();
5967
- let configPath = await input({
5968
- message: "Enter the path for the config file:",
5969
- default: defaultConfigPath,
7215
+ const runOnce = runMode === "once";
7216
+ let cronSchedule = "0 * * * *";
7217
+ if (!runOnce) {
7218
+ cronSchedule = await input({
7219
+ message: "Enter the cron schedule (or press enter for default):",
7220
+ default: "0 * * * *",
5970
7221
  validate: (value) => {
5971
7222
  if (!value.trim()) {
5972
- return "Config file path is required";
7223
+ return "Cron schedule is required";
5973
7224
  }
5974
- if (!value.endsWith(".js")) {
5975
- return "Config file must have a .js extension";
7225
+ const parts = value.trim().split(" ");
7226
+ if (parts.length < 5) {
7227
+ return "Invalid cron pattern. Expected format: '* * * * *'";
5976
7228
  }
5977
7229
  return true;
5978
7230
  }
5979
7231
  });
5980
- if (!path12.isAbsolute(configPath)) {
5981
- configPath = path12.resolve(configPath);
5982
- }
5983
- try {
5984
- await generateConfigFile(finalConfig, configPath);
5985
- savedConfigPath = configPath;
5986
- console.log(`
5987
- \u2705 Configuration saved to: ${configPath}`);
5988
- console.log(`
5989
- \u{1F4A1} Next time run \`sync-worktrees\` from this directory \u2014 the config will be auto-loaded.`);
5990
- console.log("");
5991
- } catch (error) {
5992
- console.error(`
5993
- \u274C Failed to save config file: ${error.message}`);
5994
- }
5995
7232
  }
5996
- return { config: finalConfig, savedConfigPath };
7233
+ return {
7234
+ repoUrl,
7235
+ worktreeDir,
7236
+ bareRepoDir,
7237
+ cronSchedule,
7238
+ runOnce
7239
+ };
5997
7240
  }
5998
7241
 
5999
7242
  // src/utils/signal-handlers.ts
@@ -6045,48 +7288,12 @@ Shutdown took longer than ${forceExitMs}ms, forcing exit.`);
6045
7288
 
6046
7289
  // src/index.ts
6047
7290
  var signalHandle = setupSignalHandlers();
6048
- async function runSingleRepository(config) {
6049
- const logger = Logger.createDefault(void 0, config.debug);
6050
- logger.info("\n\u{1F4CB} CLI Command (for future reference):");
6051
- logger.info(` ${reconstructCliCommand(config)}`);
6052
- logger.info("");
6053
- if (!config.logger) {
6054
- config.logger = logger;
6055
- }
6056
- const syncService = new WorktreeSyncService(config);
6057
- try {
6058
- await syncService.initialize();
6059
- if (config.runOnce) {
6060
- logger.info("Running the sync process once as requested by --runOnce flag.");
6061
- await syncService.sync();
6062
- } else {
6063
- const uiService = new InteractiveUIService([syncService], void 0, config.cronSchedule);
6064
- signalHandle.register((fast) => uiService.destroy(fast));
6065
- await syncService.sync();
6066
- uiService.updateLastSyncTime();
6067
- void uiService.calculateAndUpdateDiskSpace();
6068
- const job = cron3.schedule(config.cronSchedule, async () => {
6069
- try {
6070
- uiService.setStatus("syncing");
6071
- await syncService.sync();
6072
- uiService.updateLastSyncTime();
6073
- void uiService.calculateAndUpdateDiskSpace();
6074
- } catch (error) {
6075
- logger.error(`Error during scheduled sync: ${error.message}`, error);
6076
- uiService.setStatus("idle");
6077
- }
6078
- });
6079
- uiService.registerCronJob(job);
6080
- }
6081
- } catch (error) {
6082
- logger.error("\u274C Fatal Error during initialization:", error);
6083
- process.exit(1);
6084
- }
6085
- }
6086
- async function runMultipleRepositories(repositories, runOnce, configPath, maxParallel, syncOnStart, reloadOptions) {
7291
+ async function runMultipleRepositories(configFile, repositories, configPath) {
6087
7292
  const services = /* @__PURE__ */ new Map();
6088
7293
  const globalLogger = Logger.createDefault();
6089
- const limit = pLimit3(maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
7294
+ const runOnce = configFile.defaults?.runOnce ?? false;
7295
+ const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
7296
+ const limit = pLimit3(maxParallel);
6090
7297
  if (runOnce) {
6091
7298
  globalLogger.info(`
6092
7299
  \u{1F504} Syncing ${repositories.length} repositories...`);
@@ -6123,7 +7330,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6123
7330
  servicesToSync.map(
6124
7331
  ({ name, service }) => limit(async () => {
6125
7332
  try {
6126
- await service.sync();
7333
+ return await service.sync();
6127
7334
  } catch (error) {
6128
7335
  globalLogger.error(`\u274C Error syncing repository '${name}':`, error);
6129
7336
  throw error;
@@ -6131,9 +7338,66 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6131
7338
  })
6132
7339
  )
6133
7340
  );
6134
- const successCount = syncResults.filter((r) => r.status === "fulfilled").length;
6135
- globalLogger.info(`
6136
- \u2705 Successfully synced ${successCount}/${servicesToSync.length} repositories`);
7341
+ const skipsByRepo = [];
7342
+ const skippedNames = /* @__PURE__ */ new Set();
7343
+ const outcomeFailedNames = /* @__PURE__ */ new Set();
7344
+ const partialSkipNames = /* @__PURE__ */ new Set();
7345
+ for (let i = 0; i < servicesToSync.length; i++) {
7346
+ const { name, service } = servicesToSync[i];
7347
+ const reasons = service.getRecordedSkips();
7348
+ if (reasons.length > 0) {
7349
+ skipsByRepo.push({ repo: name, reasons });
7350
+ skippedNames.add(name);
7351
+ }
7352
+ const result = syncResults[i];
7353
+ if (result.status === "fulfilled") {
7354
+ if (!result.value.started) {
7355
+ skippedNames.add(name);
7356
+ continue;
7357
+ }
7358
+ const counts = result.value.outcome?.counts;
7359
+ if (counts) {
7360
+ if (counts.failed > 0) {
7361
+ outcomeFailedNames.add(name);
7362
+ }
7363
+ if (counts.skipped > 0 && !skippedNames.has(name) && !outcomeFailedNames.has(name)) {
7364
+ partialSkipNames.add(name);
7365
+ }
7366
+ }
7367
+ }
7368
+ }
7369
+ if (skipsByRepo.length > 0) {
7370
+ const skipsRepoWord = skipsByRepo.length === 1 ? "repo" : "repos";
7371
+ globalLogger.warn(`
7372
+ \u26A0\uFE0F Clone-mode skips (${skipsByRepo.length} ${skipsRepoWord}):`);
7373
+ for (const { repo, reasons } of skipsByRepo) {
7374
+ for (const reason of reasons) {
7375
+ globalLogger.warn(` \u2022 ${repo} \u2014 ${formatCloneSkipReason(reason)}`);
7376
+ }
7377
+ }
7378
+ }
7379
+ const initFailures = initResults.filter(
7380
+ (result, index) => result.status === "rejected" && !skippedNames.has(repositories[index].name)
7381
+ ).length;
7382
+ const syncFailures = syncResults.filter(
7383
+ (result, index) => result.status === "rejected" && !skippedNames.has(servicesToSync[index].name)
7384
+ ).length;
7385
+ const failedCount = initFailures + syncFailures + outcomeFailedNames.size;
7386
+ const skippedCount = skippedNames.size;
7387
+ const successCount = syncResults.filter((result, index) => {
7388
+ const repoName = servicesToSync[index].name;
7389
+ return result.status === "fulfilled" && result.value.started && !skippedNames.has(repoName) && !outcomeFailedNames.has(repoName);
7390
+ }).length;
7391
+ const processedRepoWord = repositories.length === 1 ? "repo" : "repos";
7392
+ const skipSummaryLabel = skippedNames.size === skipsByRepo.length ? "with clone-mode skips" : "skipped";
7393
+ const partialSuffix = partialSkipNames.size > 0 ? ` (${partialSkipNames.size} with partial skips)` : "";
7394
+ globalLogger.info(
7395
+ `
7396
+ \u{1F4CA} Processed ${repositories.length} ${processedRepoWord}: ${successCount} synced${partialSuffix}, ${skippedCount} ${skipSummaryLabel}, ${failedCount} failed`
7397
+ );
7398
+ if (failedCount > 0) {
7399
+ process.exitCode = 1;
7400
+ }
6137
7401
  } else {
6138
7402
  for (const repoConfig of repositories) {
6139
7403
  const syncService = new WorktreeSyncService(repoConfig);
@@ -6142,7 +7406,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6142
7406
  const uniqueSchedules = [...new Set(repositories.map((r) => r.cronSchedule))];
6143
7407
  const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
6144
7408
  const allServices = Array.from(services.values());
6145
- const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel, reloadOptions);
7409
+ const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel);
6146
7410
  signalHandle.register((fast) => uiService.destroy(fast));
6147
7411
  void uiService.calculateAndUpdateDiskSpace();
6148
7412
  uiService.setupCronJobs();
@@ -6151,15 +7415,12 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6151
7415
  for (const repo of repositories) {
6152
7416
  cronSchedules.set(repo.cronSchedule, (cronSchedules.get(repo.cronSchedule) || 0) + 1);
6153
7417
  }
6154
- for (const [schedule3, count] of cronSchedules) {
6155
- uiService.addLog(`\u23F0 ${schedule3}: ${count} repository(ies)`);
6156
- }
6157
- if (syncOnStart) {
6158
- await uiService.triggerInitialSync();
7418
+ for (const [schedule2, count] of cronSchedules) {
7419
+ uiService.addLog(`\u23F0 ${schedule2}: ${count} repository(ies)`);
6159
7420
  }
6160
7421
  }
6161
7422
  }
6162
- async function listRepositories(configPath, filter) {
7423
+ async function runList(configPath, filter) {
6163
7424
  const configLoader = new ConfigLoaderService();
6164
7425
  try {
6165
7426
  const { repositories } = await configLoader.buildRepositories(configPath, { filter });
@@ -6187,114 +7448,98 @@ async function listRepositories(configPath, filter) {
6187
7448
  process.exit(1);
6188
7449
  }
6189
7450
  }
6190
- async function runFromConfigFile(configPath, options) {
7451
+ async function runFromConfigFile(configPath, runOnceOverride = false) {
6191
7452
  const configLoader = new ConfigLoaderService();
6192
- const { repositories, configFile } = await configLoader.buildRepositories(configPath, {
6193
- filter: options.filter,
6194
- noUpdateExisting: options.noUpdateExisting,
6195
- debug: options.debug
6196
- });
6197
- if (options.filter && repositories.length === 0) {
6198
- console.error(`\u274C No repositories match filter: ${options.filter}`);
7453
+ const { repositories, configFile } = await configLoader.buildRepositories(configPath);
7454
+ const effectiveConfigFile = runOnceOverride ? { ...configFile, defaults: { ...configFile.defaults ?? {}, runOnce: true } } : configFile;
7455
+ await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
7456
+ }
7457
+ async function resolveConfigOrExit(cliPath) {
7458
+ const resolved = cliPath ? path17.resolve(cliPath) : await findConfigInCwd();
7459
+ if (!resolved) {
7460
+ console.error(
7461
+ "\u274C No config file found. Pass --config <path>, run `sync-worktrees init` to create one, or place a sync-worktrees.config.{js,mjs,cjs} in this directory."
7462
+ );
6199
7463
  process.exit(1);
6200
7464
  }
6201
- const globalRunOnce = options.runOnce ?? configFile.defaults?.runOnce ?? false;
6202
- const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
6203
- const reloadOptions = {
6204
- filter: options.filter,
6205
- noUpdateExisting: options.noUpdateExisting,
6206
- debug: options.debug
6207
- };
6208
- await runMultipleRepositories(
6209
- repositories,
6210
- globalRunOnce,
6211
- configPath,
6212
- maxParallel,
6213
- options.syncOnStart,
6214
- reloadOptions
6215
- );
7465
+ return resolved;
6216
7466
  }
6217
- async function runInteractive(partial, options) {
6218
- const result = await promptForConfig(partial);
6219
- if (result.savedConfigPath) {
6220
- await runFromConfigFile(result.savedConfigPath, {
6221
- filter: options.filter,
6222
- noUpdateExisting: options.noUpdateExisting,
6223
- debug: options.debug,
6224
- runOnce: options.runOnce,
6225
- syncOnStart: options.syncOnStart
6226
- });
6227
- return;
7467
+ function exitConfigExists(targetPath) {
7468
+ console.error(`
7469
+ \u274C Config file already exists: ${targetPath}`);
7470
+ console.error(`\u{1F4A1} Re-run with --force to overwrite.`);
7471
+ process.exit(1);
7472
+ }
7473
+ async function runInit(configPath, force) {
7474
+ const targetPath = configPath ? path17.resolve(configPath) : getDefaultConfigPath();
7475
+ if (!force && await fileExists(targetPath)) {
7476
+ exitConfigExists(targetPath);
6228
7477
  }
6229
- const config = result.config;
6230
- if (options.noUpdateExisting) {
6231
- config.updateExistingWorktrees = false;
6232
- } else if (config.updateExistingWorktrees === void 0) {
6233
- config.updateExistingWorktrees = true;
7478
+ const input2 = await promptForInitConfig();
7479
+ try {
7480
+ await generateConfigFile(input2, targetPath, { overwrite: force });
7481
+ } catch (error) {
7482
+ if (error instanceof ConfigFileExistsError) {
7483
+ exitConfigExists(error.configPath);
7484
+ }
7485
+ throw error;
6234
7486
  }
6235
- if (options.debug !== void 0) {
6236
- config.debug = options.debug;
7487
+ const displayPath = path17.relative(process.cwd(), targetPath) || targetPath;
7488
+ console.log(`
7489
+ \u2705 Configuration saved to: ${targetPath}`);
7490
+ console.log(`
7491
+ \u{1F4A1} Next: sync-worktrees --config ${displayPath}`);
7492
+ }
7493
+ async function runSync(options) {
7494
+ const configPath = await resolveConfigOrExit(options.config);
7495
+ const displayPath = path17.relative(process.cwd(), configPath) || configPath;
7496
+ console.log(`\u{1F4C4} Using config: ${displayPath}`);
7497
+ try {
7498
+ await runFromConfigFile(configPath, options.runOnce);
7499
+ } catch (error) {
7500
+ if (error instanceof ConfigFileNotFoundError) {
7501
+ console.error(`
7502
+ \u274C Config file not found: ${error.configPath}`);
7503
+ console.error(`\u{1F4A1} Run 'sync-worktrees init --config ${displayPath}' to create one.`);
7504
+ process.exit(1);
7505
+ }
7506
+ console.error("\u274C Error loading config file:", error.message);
7507
+ process.exit(1);
6237
7508
  }
6238
- await runSingleRepository(config);
6239
7509
  }
6240
7510
  async function main() {
6241
7511
  const options = parseArguments();
6242
- if (!options.config && !options.repoUrl && !options.worktreeDir) {
6243
- const discovered = await findConfigInCwd();
6244
- if (discovered) {
6245
- options.config = discovered;
6246
- console.log(`\u{1F4C4} Using config: ${path13.relative(process.cwd(), discovered)}`);
6247
- }
6248
- }
6249
- if (options.config) {
6250
- if (options.list) {
6251
- await listRepositories(options.config, options.filter);
6252
- return;
6253
- }
6254
- try {
6255
- await runFromConfigFile(options.config, {
6256
- filter: options.filter,
6257
- noUpdateExisting: options.noUpdateExisting,
6258
- debug: options.debug,
6259
- runOnce: options.runOnce,
6260
- syncOnStart: options.syncOnStart
6261
- });
6262
- } catch (error) {
6263
- if (error instanceof Error && error.message.includes("Config file not found")) {
6264
- console.error(`
6265
- \u274C Config file not found: ${options.config}`);
6266
- const createConfig = await confirm2({
6267
- message: "Would you like to run interactive setup to create a config file?",
6268
- default: true
6269
- });
6270
- if (createConfig) {
6271
- await runInteractive({}, options);
6272
- } else {
6273
- console.log("\n\u{1F4A1} You can create a config file manually or run without --config for interactive setup.");
6274
- process.exit(1);
6275
- }
6276
- } else {
6277
- console.error("\u274C Error loading config file:", error.message);
6278
- process.exit(1);
6279
- }
7512
+ switch (options.command) {
7513
+ case CLI_COMMANDS.INIT:
7514
+ return runInit(options.config, options.force);
7515
+ case CLI_COMMANDS.LIST: {
7516
+ const configPath = await resolveConfigOrExit(options.config);
7517
+ return runList(configPath, options.filter);
6280
7518
  }
6281
- } else if (isInteractiveMode(options)) {
6282
- await runInteractive(options, options);
6283
- } else {
6284
- const config = options;
6285
- if (options.noUpdateExisting) {
6286
- config.updateExistingWorktrees = false;
6287
- } else if (config.updateExistingWorktrees === void 0) {
6288
- config.updateExistingWorktrees = true;
6289
- }
6290
- if (options.debug !== void 0) {
6291
- config.debug = options.debug;
7519
+ case CLI_COMMANDS.RUN:
7520
+ return runSync(options);
7521
+ default: {
7522
+ const _exhaustive = options;
7523
+ throw new Error(`Unhandled command: ${JSON.stringify(_exhaustive)}`);
6292
7524
  }
6293
- await runSingleRepository(config);
6294
7525
  }
6295
7526
  }
6296
- main().catch((error) => {
6297
- console.error("\u274C Unhandled error:", error);
6298
- process.exit(1);
6299
- });
7527
+ function isMainEntrypoint() {
7528
+ const entry = process.argv[1];
7529
+ if (!entry) return false;
7530
+ try {
7531
+ return realpathSync2(entry) === fileURLToPath(import.meta.url);
7532
+ } catch {
7533
+ return false;
7534
+ }
7535
+ }
7536
+ if (isMainEntrypoint()) {
7537
+ main().catch((error) => {
7538
+ console.error("\u274C Unhandled error:", error);
7539
+ process.exit(1);
7540
+ });
7541
+ }
7542
+ export {
7543
+ runMultipleRepositories
7544
+ };
6300
7545
  //# sourceMappingURL=index.js.map