sync-worktrees 3.6.3 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +383 -261
  2. package/dist/components/App.d.ts +50 -0
  3. package/dist/components/App.d.ts.map +1 -0
  4. package/dist/components/BranchCreationWizard.d.ts +26 -0
  5. package/dist/components/BranchCreationWizard.d.ts.map +1 -0
  6. package/dist/components/HelpModal.d.ts +7 -0
  7. package/dist/components/HelpModal.d.ts.map +1 -0
  8. package/dist/components/LogPanel.d.ts +10 -0
  9. package/dist/components/LogPanel.d.ts.map +1 -0
  10. package/dist/components/LogViewer.d.ts +9 -0
  11. package/dist/components/LogViewer.d.ts.map +1 -0
  12. package/dist/components/OpenEditorWizard.d.ts +25 -0
  13. package/dist/components/OpenEditorWizard.d.ts.map +1 -0
  14. package/dist/components/StatusBar.d.ts +14 -0
  15. package/dist/components/StatusBar.d.ts.map +1 -0
  16. package/dist/components/WorktreeStatusView.d.ts +14 -0
  17. package/dist/components/WorktreeStatusView.d.ts.map +1 -0
  18. package/dist/constants.d.ts +112 -0
  19. package/dist/constants.d.ts.map +1 -0
  20. package/dist/errors/index.d.ts +59 -0
  21. package/dist/errors/index.d.ts.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +2523 -1106
  25. package/dist/index.js.map +4 -4
  26. package/dist/mcp/context.d.ts +143 -0
  27. package/dist/mcp/context.d.ts.map +1 -0
  28. package/dist/mcp/handlers.d.ts +46 -0
  29. package/dist/mcp/handlers.d.ts.map +1 -0
  30. package/dist/mcp/index.d.ts +2 -0
  31. package/dist/mcp/index.d.ts.map +1 -0
  32. package/dist/mcp/server.d.ts +9 -0
  33. package/dist/mcp/server.d.ts.map +1 -0
  34. package/dist/mcp/utils.d.ts +14 -0
  35. package/dist/mcp/utils.d.ts.map +1 -0
  36. package/dist/mcp/worktree-summary.d.ts +14 -0
  37. package/dist/mcp/worktree-summary.d.ts.map +1 -0
  38. package/dist/mcp-server.js +2347 -640
  39. package/dist/mcp-server.js.map +4 -4
  40. package/dist/services/InteractiveUIService.d.ts +85 -0
  41. package/dist/services/InteractiveUIService.d.ts.map +1 -0
  42. package/dist/services/branch-created-actions.service.d.ts +27 -0
  43. package/dist/services/branch-created-actions.service.d.ts.map +1 -0
  44. package/dist/services/clone-sync.service.d.ts +93 -0
  45. package/dist/services/clone-sync.service.d.ts.map +1 -0
  46. package/dist/services/config-loader.service.d.ts +28 -0
  47. package/dist/services/config-loader.service.d.ts.map +1 -0
  48. package/dist/services/file-copy.service.d.ts +19 -0
  49. package/dist/services/file-copy.service.d.ts.map +1 -0
  50. package/dist/services/git.service.d.ts +94 -0
  51. package/dist/services/git.service.d.ts.map +1 -0
  52. package/dist/services/hook-execution.service.d.ts +20 -0
  53. package/dist/services/hook-execution.service.d.ts.map +1 -0
  54. package/dist/services/logger.service.d.ts +24 -0
  55. package/dist/services/logger.service.d.ts.map +1 -0
  56. package/dist/services/path-resolution.service.d.ts +10 -0
  57. package/dist/services/path-resolution.service.d.ts.map +1 -0
  58. package/dist/services/progress-emitter.d.ts +14 -0
  59. package/dist/services/progress-emitter.d.ts.map +1 -0
  60. package/dist/services/repo-operation-lock.d.ts +16 -0
  61. package/dist/services/repo-operation-lock.d.ts.map +1 -0
  62. package/dist/services/sparse-checkout.service.d.ts +45 -0
  63. package/dist/services/sparse-checkout.service.d.ts.map +1 -0
  64. package/dist/services/sync-outcome.d.ts +47 -0
  65. package/dist/services/sync-outcome.d.ts.map +1 -0
  66. package/dist/services/sync-retry-policy.d.ts +18 -0
  67. package/dist/services/sync-retry-policy.d.ts.map +1 -0
  68. package/dist/services/worktree-metadata.service.d.ts +25 -0
  69. package/dist/services/worktree-metadata.service.d.ts.map +1 -0
  70. package/dist/services/worktree-mode-sync-runner.d.ts +36 -0
  71. package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -0
  72. package/dist/services/worktree-status.service.d.ts +60 -0
  73. package/dist/services/worktree-status.service.d.ts.map +1 -0
  74. package/dist/services/worktree-sync-planner.d.ts +62 -0
  75. package/dist/services/worktree-sync-planner.d.ts.map +1 -0
  76. package/dist/services/worktree-sync.service.d.ts +49 -0
  77. package/dist/services/worktree-sync.service.d.ts.map +1 -0
  78. package/dist/types/index.d.ts +303 -0
  79. package/dist/types/index.d.ts.map +1 -0
  80. package/dist/types/sync-metadata.d.ts +16 -0
  81. package/dist/types/sync-metadata.d.ts.map +1 -0
  82. package/dist/utils/app-events.d.ts +31 -0
  83. package/dist/utils/app-events.d.ts.map +1 -0
  84. package/dist/utils/branch-filter.d.ts +3 -0
  85. package/dist/utils/branch-filter.d.ts.map +1 -0
  86. package/dist/utils/cli.d.ts +21 -0
  87. package/dist/utils/cli.d.ts.map +1 -0
  88. package/dist/utils/clone-skip-format.d.ts +3 -0
  89. package/dist/utils/clone-skip-format.d.ts.map +1 -0
  90. package/dist/utils/config-generator.d.ts +10 -0
  91. package/dist/utils/config-generator.d.ts.map +1 -0
  92. package/dist/utils/date-filter.d.ts +10 -0
  93. package/dist/utils/date-filter.d.ts.map +1 -0
  94. package/dist/utils/disk-space.d.ts +23 -0
  95. package/dist/utils/disk-space.d.ts.map +1 -0
  96. package/dist/utils/file-exists.d.ts +2 -0
  97. package/dist/utils/file-exists.d.ts.map +1 -0
  98. package/dist/utils/git-progress.d.ts +25 -0
  99. package/dist/utils/git-progress.d.ts.map +1 -0
  100. package/dist/utils/git-url.d.ts +23 -0
  101. package/dist/utils/git-url.d.ts.map +1 -0
  102. package/dist/utils/git-validation.d.ts +5 -0
  103. package/dist/utils/git-validation.d.ts.map +1 -0
  104. package/dist/utils/interactive.d.ts +3 -0
  105. package/dist/utils/interactive.d.ts.map +1 -0
  106. package/dist/utils/lfs-error.d.ts +35 -0
  107. package/dist/utils/lfs-error.d.ts.map +1 -0
  108. package/dist/utils/lock-path.d.ts +9 -0
  109. package/dist/utils/lock-path.d.ts.map +1 -0
  110. package/dist/utils/path-compare.d.ts +16 -0
  111. package/dist/utils/path-compare.d.ts.map +1 -0
  112. package/dist/utils/repo-mode.d.ts +8 -0
  113. package/dist/utils/repo-mode.d.ts.map +1 -0
  114. package/dist/utils/retry.d.ts +24 -0
  115. package/dist/utils/retry.d.ts.map +1 -0
  116. package/dist/utils/sanitize-name.d.ts +2 -0
  117. package/dist/utils/sanitize-name.d.ts.map +1 -0
  118. package/dist/utils/shell-escape.d.ts +5 -0
  119. package/dist/utils/shell-escape.d.ts.map +1 -0
  120. package/dist/utils/signal-handlers.d.ts +14 -0
  121. package/dist/utils/signal-handlers.d.ts.map +1 -0
  122. package/dist/utils/timing.d.ts +24 -0
  123. package/dist/utils/timing.d.ts.map +1 -0
  124. package/dist/utils/worktree-list-parser.d.ts +10 -0
  125. package/dist/utils/worktree-list-parser.d.ts.map +1 -0
  126. package/package.json +5 -2
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";
@@ -689,7 +826,15 @@ import { Box as Box7, useInput as useInput6, useStdout } from "ink";
689
826
  import React, { useState, useEffect } from "react";
690
827
  import { Box, Text } from "ink";
691
828
  import { CronExpressionParser } from "cron-parser";
692
- var StatusBar = ({ status, repositoryCount, lastSyncTime, cronSchedule, diskSpaceUsed }) => {
829
+ var StatusBar = ({
830
+ status,
831
+ syncProgressEntries = [],
832
+ maxProgressLines = 2,
833
+ repositoryCount,
834
+ lastSyncTime,
835
+ cronSchedule,
836
+ diskSpaceUsed
837
+ }) => {
693
838
  const [nextSyncTime, setNextSyncTime] = useState(null);
694
839
  useEffect(() => {
695
840
  if (!cronSchedule) {
@@ -719,7 +864,17 @@ var StatusBar = ({ status, repositoryCount, lastSyncTime, cronSchedule, diskSpac
719
864
  const getStatusIcon = () => {
720
865
  return status === "syncing" ? "\u27F3" : "\u2713";
721
866
  };
722
- return /* @__PURE__ */ React.createElement(Box, { borderStyle: "single", paddingX: 1 }, /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: "100%" }, /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, getStatusIcon(), " Status:", " ", /* @__PURE__ */ React.createElement(Text, { color: getStatusColor() }, status === "syncing" ? "Syncing..." : "Running")), /* @__PURE__ */ React.createElement(Text, null, "Repositories: ", /* @__PURE__ */ React.createElement(Text, { bold: true, color: "cyan" }, repositoryCount))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Last Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(lastSyncTime))), cronSchedule && /* @__PURE__ */ React.createElement(Text, null, "Next Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(nextSyncTime)))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "w"), "tree", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
867
+ const formatProgress = (syncProgress) => {
868
+ const percent = syncProgress.progress === void 0 || syncProgress.message.includes(`${syncProgress.progress}%`) ? "" : ` ${syncProgress.progress}%`;
869
+ return `[${syncProgress.repo}] ${syncProgress.message}${percent}`;
870
+ };
871
+ const progressLineCount = Math.max(1, maxProgressLines);
872
+ const visibleProgress = syncProgressEntries.slice(-progressLineCount);
873
+ return /* @__PURE__ */ React.createElement(Box, { borderStyle: "single", paddingX: 1 }, /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: "100%" }, /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, getStatusIcon(), " Status:", " ", /* @__PURE__ */ React.createElement(Text, { color: getStatusColor() }, status === "syncing" ? "Syncing..." : "Running")), /* @__PURE__ */ React.createElement(Text, null, "Repositories: ", /* @__PURE__ */ React.createElement(Text, { bold: true, color: "cyan" }, repositoryCount))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Last Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(lastSyncTime))), cronSchedule && /* @__PURE__ */ React.createElement(Text, null, "Next Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(nextSyncTime)))), status === "syncing" && Array.from({ length: progressLineCount }).map((_, index) => {
874
+ const entry = visibleProgress[index];
875
+ const message = entry ? formatProgress(entry) : index === 0 ? "waiting for progress events" : "";
876
+ return /* @__PURE__ */ React.createElement(Box, { key: index }, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, message ? "Progress: " : " ", message && /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, message)));
877
+ }), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "w"), "tree", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
723
878
  };
724
879
  var StatusBar_default = StatusBar;
725
880
 
@@ -1309,6 +1464,14 @@ function isLfsError(errorMessage) {
1309
1464
  function isLfsErrorFromError(error) {
1310
1465
  return isLfsError(getErrorMessage(error));
1311
1466
  }
1467
+ var MISSING_REMOTE_REF_PATTERNS = Object.freeze([
1468
+ "couldn't find remote ref",
1469
+ "Couldn't find remote ref",
1470
+ "not our ref"
1471
+ ]);
1472
+ function isMissingRemoteRefError(errorMessage) {
1473
+ return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
1474
+ }
1312
1475
 
1313
1476
  // src/components/WorktreeStatusView.tsx
1314
1477
  var getStatusFlags = (status) => {
@@ -1381,6 +1544,7 @@ var formatDivergedDate = (dateStr) => {
1381
1544
  var WorktreeStatusView = ({
1382
1545
  repositories,
1383
1546
  getWorktreeStatusForRepo,
1547
+ getRepositoryDiskUsage,
1384
1548
  getDivergedDirectoriesForRepo,
1385
1549
  deleteDivergedDirectory,
1386
1550
  onClose
@@ -1395,6 +1559,8 @@ var WorktreeStatusView = ({
1395
1559
  const [entryFilter, setEntryFilter] = useState4("");
1396
1560
  const [expandedEntry, setExpandedEntry] = useState4(null);
1397
1561
  const [loading, setLoading] = useState4(false);
1562
+ const [repoDiskUsage, setRepoDiskUsage] = useState4({});
1563
+ const requestedDiskUsageRef = useRef3(/* @__PURE__ */ new Set());
1398
1564
  const [confirmDelete, setConfirmDelete] = useState4(null);
1399
1565
  const [deleting, setDeleting] = useState4(false);
1400
1566
  const [error, setError] = useState4(null);
@@ -1450,6 +1616,29 @@ var WorktreeStatusView = ({
1450
1616
  },
1451
1617
  [getWorktreeStatusForRepo, getDivergedDirectoriesForRepo]
1452
1618
  );
1619
+ useEffect4(() => {
1620
+ if (!getRepositoryDiskUsage) return void 0;
1621
+ let cancelled = false;
1622
+ const indexesToLoad = repositories.map((repo) => repo.index).filter((repoIndex) => !requestedDiskUsageRef.current.has(repoIndex));
1623
+ if (indexesToLoad.length === 0) return void 0;
1624
+ for (const repoIndex of indexesToLoad) {
1625
+ requestedDiskUsageRef.current.add(repoIndex);
1626
+ setRepoDiskUsage((prev) => ({ ...prev, [repoIndex]: { status: "loading" } }));
1627
+ void getRepositoryDiskUsage(repoIndex).then((usage) => {
1628
+ if (cancelled) return;
1629
+ setRepoDiskUsage((prev) => ({ ...prev, [repoIndex]: { status: "ready", usage } }));
1630
+ }).catch(() => {
1631
+ if (cancelled) return;
1632
+ setRepoDiskUsage((prev) => ({
1633
+ ...prev,
1634
+ [repoIndex]: { status: "error" }
1635
+ }));
1636
+ });
1637
+ }
1638
+ return () => {
1639
+ cancelled = true;
1640
+ };
1641
+ }, [repositories, getRepositoryDiskUsage]);
1453
1642
  useEffect4(() => {
1454
1643
  if (step === "VIEW_STATUS" && entries.length === 0 && !loading && selectedRepoIndexRef.current >= 0) {
1455
1644
  loadStatus(selectedRepoIndexRef.current);
@@ -1523,11 +1712,11 @@ var WorktreeStatusView = ({
1523
1712
  } else if (key.downArrow) {
1524
1713
  setSelectedProjectIndex((prev) => Math.min(filteredProjects.length - 1, prev + 1));
1525
1714
  } else if (key.return && filteredProjects.length > 0) {
1526
- const selectedRepo = filteredProjects[selectedProjectIndex];
1527
- if (selectedRepo) {
1528
- selectedRepoIndexRef.current = selectedRepo.index;
1715
+ const selectedRepo2 = filteredProjects[selectedProjectIndex];
1716
+ if (selectedRepo2) {
1717
+ selectedRepoIndexRef.current = selectedRepo2.index;
1529
1718
  setStep("VIEW_STATUS");
1530
- loadStatus(selectedRepo.index);
1719
+ loadStatus(selectedRepo2.index);
1531
1720
  }
1532
1721
  } else if (key.backspace || key.delete) {
1533
1722
  setProjectFilter((prev) => prev.slice(0, -1));
@@ -1575,7 +1764,7 @@ var WorktreeStatusView = ({
1575
1764
  return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Select repository:"), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, null, "Filter: "), /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, projectFilter || "_"), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " ", "(", filteredProjects.length, "/", repositories.length, " matches)")), /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column" }, filteredProjects.length === 0 ? /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, "No matches") : /* @__PURE__ */ React5.createElement(React5.Fragment, null, startIdx > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " ..."), visibleProjects.map((repo, idx) => {
1576
1765
  const actualIdx = startIdx + idx;
1577
1766
  const isSelected = actualIdx === selectedProjectIndex;
1578
- return /* @__PURE__ */ React5.createElement(Box5, { key: repo.index }, /* @__PURE__ */ React5.createElement(Text5, { color: isSelected ? "cyan" : void 0 }, isSelected ? "> " : " ", repo.name));
1767
+ return /* @__PURE__ */ React5.createElement(Box5, { key: repo.index }, /* @__PURE__ */ React5.createElement(Text5, { color: isSelected ? "cyan" : void 0 }, isSelected ? "> " : " "), /* @__PURE__ */ React5.createElement(Box5, { width: 38 }, /* @__PURE__ */ React5.createElement(Text5, { color: isSelected ? "cyan" : void 0 }, repo.name)), getRepositoryDiskUsage && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " "), renderRepositoryDiskUsage(repo.index));
1579
1768
  }), endIdx < filteredProjects.length && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " ..."))));
1580
1769
  };
1581
1770
  const renderDetailPanel = (entry) => {
@@ -1586,6 +1775,17 @@ var WorktreeStatusView = ({
1586
1775
  const renderDivergedDetailPanel = (entry) => {
1587
1776
  return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Original branch: ", entry.originalBranch), entry.divergedAt && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Diverged: ", entry.divergedAt), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Size: ", entry.sizeFormatted));
1588
1777
  };
1778
+ const renderRepositoryDiskUsage = (repoIndex) => {
1779
+ if (!getRepositoryDiskUsage) return null;
1780
+ const state = repoDiskUsage[repoIndex];
1781
+ if (!state || state.status === "loading") {
1782
+ return /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Size: calculating...");
1783
+ }
1784
+ if (state.status === "error") {
1785
+ return /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, "Size: N/A");
1786
+ }
1787
+ return /* @__PURE__ */ React5.createElement(Text5, null, "Size: ", /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, state.usage.sizeFormatted));
1788
+ };
1589
1789
  const renderStatusList = () => {
1590
1790
  if (loading) {
1591
1791
  return /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, "Loading worktree status...");
@@ -1639,7 +1839,8 @@ var WorktreeStatusView = ({
1639
1839
  }
1640
1840
  return /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, step === "VIEW_STATUS" ? isDivergedSelected ? "\u2191/\u2193 navigate \u2022 Type to filter \u2022 Enter to expand \u2022 d to delete \u2022 ESC to close" : "\u2191/\u2193 navigate \u2022 Type to filter \u2022 Enter to expand \u2022 ESC to close" : "\u2191/\u2193 navigate \u2022 Type to filter \u2022 Enter to select \u2022 ESC to cancel");
1641
1841
  };
1642
- return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginTop: 1, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Box5, { borderStyle: "round", borderColor: "green", paddingX: 2, paddingY: 1, flexDirection: "column", width: 70 }, /* @__PURE__ */ React5.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { bold: true, color: "green" }, "\u{1F4CA} Worktree Status", " ", step !== "ERROR" && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "(Step ", getStepNumber(), "/", getTotalSteps(), ")"))), repositories.length > 1 && step === "VIEW_STATUS" && !loading && selectedRepoIndexRef.current >= 0 && /* @__PURE__ */ React5.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Repository: ", /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, repositories.find((r) => r.index === selectedRepoIndexRef.current)?.name))), renderContent(), /* @__PURE__ */ React5.createElement(Box5, { marginTop: 1 }, renderFooter())));
1842
+ const selectedRepo = selectedRepoIndexRef.current >= 0 ? repositories.find((repo) => repo.index === selectedRepoIndexRef.current) : void 0;
1843
+ return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginTop: 1, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Box5, { borderStyle: "round", borderColor: "green", paddingX: 2, paddingY: 1, flexDirection: "column", width: 70 }, /* @__PURE__ */ React5.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { bold: true, color: "green" }, "\u{1F4CA} Worktree Status", " ", step !== "ERROR" && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "(Step ", getStepNumber(), "/", getTotalSteps(), ")"))), step === "VIEW_STATUS" && selectedRepo && /* @__PURE__ */ React5.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Repository: ", /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, selectedRepo.name)), getRepositoryDiskUsage && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " "), renderRepositoryDiskUsage(selectedRepo.index)), renderContent(), /* @__PURE__ */ React5.createElement(Box5, { marginTop: 1 }, renderFooter())));
1643
1844
  };
1644
1845
  var WorktreeStatusView_default = WorktreeStatusView;
1645
1846
 
@@ -1748,7 +1949,9 @@ var App = ({
1748
1949
  onManualSync,
1749
1950
  onReload,
1750
1951
  onQuit,
1952
+ maxProgressLines = 2,
1751
1953
  getRepositoryList,
1954
+ getRepositoryDiskUsage,
1752
1955
  getBranchesForRepo,
1753
1956
  getDefaultBranchForRepo,
1754
1957
  fetchForRepo,
@@ -1768,11 +1971,12 @@ var App = ({
1768
1971
  const [showOpenEditorWizard, setShowOpenEditorWizard] = useState6(false);
1769
1972
  const [showWorktreeStatus, setShowWorktreeStatus] = useState6(false);
1770
1973
  const [status, setStatus] = useState6("idle");
1974
+ const [syncProgressEntries, setSyncProgressEntries] = useState6([]);
1771
1975
  const [lastSyncTime, setLastSyncTime] = useState6(null);
1772
1976
  const [diskSpaceUsed, setDiskSpaceUsed] = useState6(null);
1773
1977
  const [logs, setLogs] = useState6([]);
1774
1978
  const [repoCount, setRepoCount] = useState6(repositoryCount);
1775
- const [schedule3, setSchedule] = useState6(cronSchedule);
1979
+ const [schedule2, setSchedule] = useState6(cronSchedule);
1776
1980
  const { stdout } = useStdout();
1777
1981
  const addLog = useCallback4((message, level = "info") => {
1778
1982
  setLogs((prev) => {
@@ -1838,15 +2042,36 @@ var App = ({
1838
2042
  const updateLastSyncTime = useCallback4(() => {
1839
2043
  setLastSyncTime(/* @__PURE__ */ new Date());
1840
2044
  setStatus("idle");
2045
+ setSyncProgressEntries([]);
1841
2046
  }, []);
1842
2047
  useEffect6(() => {
1843
2048
  const unsubscribers = [
1844
2049
  events.on("updateLastSyncTime", () => {
1845
2050
  setLastSyncTime(/* @__PURE__ */ new Date());
1846
2051
  setStatus("idle");
2052
+ setSyncProgressEntries([]);
1847
2053
  }),
1848
2054
  events.on("setStatus", (newStatus) => {
1849
2055
  setStatus(newStatus);
2056
+ if (newStatus === "idle") {
2057
+ setSyncProgressEntries([]);
2058
+ }
2059
+ }),
2060
+ events.on("setSyncProgress", (progress) => {
2061
+ if (progress === null) {
2062
+ setSyncProgressEntries([]);
2063
+ return;
2064
+ }
2065
+ setSyncProgressEntries((prev) => {
2066
+ if (progress.completed) {
2067
+ return prev.filter((entry) => entry.repo !== progress.repo);
2068
+ }
2069
+ const existingIndex = prev.findIndex((entry) => entry.repo === progress.repo);
2070
+ if (existingIndex === -1) {
2071
+ return [...prev, progress];
2072
+ }
2073
+ return prev.map((entry, index) => index === existingIndex ? progress : entry);
2074
+ });
1850
2075
  }),
1851
2076
  events.on("setDiskSpace", (diskSpace) => {
1852
2077
  setDiskSpaceUsed(diskSpace);
@@ -1866,7 +2091,8 @@ var App = ({
1866
2091
  unsubscribers.forEach((unsub) => unsub());
1867
2092
  };
1868
2093
  }, []);
1869
- const statusBarHeight = 5;
2094
+ const progressLineCount = status === "syncing" ? Math.max(1, maxProgressLines) : 0;
2095
+ const statusBarHeight = 5 + progressLineCount;
1870
2096
  const terminalRows = stdout.rows ?? 24;
1871
2097
  const logPanelHeight = Math.max(5, terminalRows - statusBarHeight);
1872
2098
  const showModal = showHelp || showBranchWizard || showOpenEditorWizard || showWorktreeStatus;
@@ -1931,6 +2157,7 @@ var App = ({
1931
2157
  {
1932
2158
  repositories: getRepositoryList(),
1933
2159
  getWorktreeStatusForRepo,
2160
+ getRepositoryDiskUsage,
1934
2161
  getDivergedDirectoriesForRepo,
1935
2162
  deleteDivergedDirectory,
1936
2163
  onClose: () => setShowWorktreeStatus(false)
@@ -1939,69 +2166,17 @@ var App = ({
1939
2166
  StatusBar_default,
1940
2167
  {
1941
2168
  status,
2169
+ syncProgressEntries,
2170
+ maxProgressLines,
1942
2171
  repositoryCount: repoCount,
1943
2172
  lastSyncTime,
1944
- cronSchedule: schedule3,
2173
+ cronSchedule: schedule2,
1945
2174
  diskSpaceUsed: diskSpaceUsed ?? void 0
1946
2175
  }
1947
2176
  ));
1948
2177
  };
1949
2178
  var App_default = App;
1950
2179
 
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
2180
  // src/utils/retry.ts
2006
2181
  var DEFAULT_OPTIONS = {
2007
2182
  maxAttempts: "unlimited",
@@ -2072,7 +2247,7 @@ async function retry(fn, options = {}) {
2072
2247
  const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
2073
2248
  const delay = baseDelay + jitter;
2074
2249
  opts.onRetry(error, attempt, lfsContext);
2075
- await new Promise((resolve9) => setTimeout(resolve9, delay));
2250
+ await new Promise((resolve12) => setTimeout(resolve12, delay));
2076
2251
  attempt++;
2077
2252
  }
2078
2253
  }
@@ -2143,7 +2318,7 @@ var PhaseTimer = class {
2143
2318
  return results;
2144
2319
  }
2145
2320
  };
2146
- function formatDuration2(ms) {
2321
+ function formatDuration(ms) {
2147
2322
  if (ms < 1e3) {
2148
2323
  return `${ms}ms`;
2149
2324
  }
@@ -2165,7 +2340,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2165
2340
  }
2166
2341
  });
2167
2342
  table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
2168
- table.push(["Total Sync", formatDuration2(totalDuration), ""]);
2343
+ table.push(["Total Sync", formatDuration(totalDuration), ""]);
2169
2344
  for (let i = 0; i < phaseResults.length; i++) {
2170
2345
  const result = phaseResults[i];
2171
2346
  const isLast = i === phaseResults.length - 1;
@@ -2173,15 +2348,869 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2173
2348
  const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
2174
2349
  const name = ` ${prefix} ${result.name}${countStr}`;
2175
2350
  const efficiency = result.efficiency ? `${result.efficiency}%` : "";
2176
- table.push([name, formatDuration2(result.duration), efficiency]);
2351
+ table.push([name, formatDuration(result.duration), efficiency]);
2177
2352
  }
2178
2353
  return table.toString();
2179
2354
  }
2180
2355
 
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";
2356
+ // src/services/clone-sync.service.ts
2357
+ import * as fs3 from "fs/promises";
2358
+ import * as path4 from "path";
2359
+ import simpleGit from "simple-git";
2360
+
2361
+ // src/utils/git-progress.ts
2362
+ function makeGitProgressHandler(logger, emitProgress) {
2363
+ const lastBucket = /* @__PURE__ */ new Map();
2364
+ return (event) => {
2365
+ if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
2366
+ const key = `${event.method}:${event.stage}`;
2367
+ const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
2368
+ let last = lastBucket.get(key) ?? -1;
2369
+ if (bucket < last) last = -1;
2370
+ if (bucket <= last && event.progress < 100) return;
2371
+ lastBucket.set(key, bucket);
2372
+ const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
2373
+ const message = `${event.method} ${event.stage}: ${event.progress}% (${total})`;
2374
+ logger.debug(` \u21B3 ${message}`);
2375
+ emitProgress?.({
2376
+ phase: event.method,
2377
+ message,
2378
+ progress: event.progress,
2379
+ processed: event.processed,
2380
+ total: event.total
2381
+ });
2382
+ };
2383
+ }
2384
+
2385
+ // src/services/file-copy.service.ts
2386
+ import * as fs2 from "fs/promises";
2387
+ import * as path3 from "path";
2388
+ import { glob } from "glob";
2389
+ var DEFAULT_IGNORE_PATTERNS = [
2390
+ "**/node_modules/**",
2391
+ "**/.git/**",
2392
+ "**/dist/**",
2393
+ "**/build/**",
2394
+ "**/.next/**",
2395
+ "**/coverage/**"
2396
+ ];
2397
+ var FileCopyService = class {
2398
+ /**
2399
+ * Copy files matching patterns from source to destination directory.
2400
+ * Skips files that already exist at destination.
2401
+ * Preserves directory structure relative to source.
2402
+ */
2403
+ async copyFiles(sourceDir, destDir, patterns) {
2404
+ const result = {
2405
+ copied: [],
2406
+ skipped: [],
2407
+ errors: []
2408
+ };
2409
+ if (!patterns || patterns.length === 0) {
2410
+ return result;
2411
+ }
2412
+ const filesToCopy = await this.expandPatterns(sourceDir, patterns);
2413
+ for (const relativePath of filesToCopy) {
2414
+ const sourcePath = path3.join(sourceDir, relativePath);
2415
+ const destPath = path3.join(destDir, relativePath);
2416
+ try {
2417
+ const copied = await this.copyFile(sourcePath, destPath);
2418
+ if (copied) {
2419
+ result.copied.push(relativePath);
2420
+ } else {
2421
+ result.skipped.push(relativePath);
2422
+ }
2423
+ } catch (error) {
2424
+ result.errors.push({
2425
+ file: relativePath,
2426
+ error: error instanceof Error ? error.message : String(error)
2427
+ });
2428
+ }
2429
+ }
2430
+ return result;
2431
+ }
2432
+ async expandPatterns(sourceDir, patterns) {
2433
+ const allFiles = /* @__PURE__ */ new Set();
2434
+ for (const pattern of patterns) {
2435
+ try {
2436
+ const matches = await glob(pattern, {
2437
+ cwd: sourceDir,
2438
+ nodir: true,
2439
+ dot: true,
2440
+ ignore: DEFAULT_IGNORE_PATTERNS
2441
+ });
2442
+ for (const match of matches) {
2443
+ allFiles.add(match);
2444
+ }
2445
+ } catch {
2446
+ }
2447
+ }
2448
+ return Array.from(allFiles);
2449
+ }
2450
+ async copyFile(sourcePath, destPath) {
2451
+ if (await fileExists(destPath)) {
2452
+ return false;
2453
+ }
2454
+ const destDir = path3.dirname(destPath);
2455
+ await fs2.mkdir(destDir, { recursive: true });
2456
+ await fs2.copyFile(sourcePath, destPath);
2457
+ return true;
2458
+ }
2459
+ };
2460
+
2461
+ // src/services/branch-created-actions.service.ts
2462
+ var BranchCreatedActionsService = class {
2463
+ fileCopyService;
2464
+ constructor(fileCopyService) {
2465
+ this.fileCopyService = fileCopyService ?? new FileCopyService();
2466
+ }
2467
+ async copyFiles(params) {
2468
+ const { config, sourceDir, worktreePath, branchName, logger } = params;
2469
+ const patterns = config.filesToCopyOnBranchCreate;
2470
+ if (!patterns?.length) return;
2471
+ try {
2472
+ const result = await this.fileCopyService.copyFiles(sourceDir, worktreePath, patterns);
2473
+ if (result.copied.length > 0) {
2474
+ logger.info(`\u{1F4CB} Copied ${result.copied.length} file(s) to '${branchName}': ${result.copied.join(", ")}`);
2475
+ }
2476
+ if (result.errors.length > 0) {
2477
+ logger.warn(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s) to '${branchName}':`);
2478
+ for (const err of result.errors) {
2479
+ logger.warn(` - ${err.file}: ${err.error}`);
2480
+ }
2481
+ }
2482
+ } catch (error) {
2483
+ logger.error(`Failed to copy files to '${branchName}': ${error}`);
2484
+ }
2485
+ }
2486
+ runHooks(params) {
2487
+ const { config, branchName, worktreePath, repoName, baseBranch, logger, hookExecutionService } = params;
2488
+ if (!config.hooks?.onBranchCreated?.length) return;
2489
+ const context = {
2490
+ branchName,
2491
+ worktreePath,
2492
+ repoName,
2493
+ baseBranch,
2494
+ repoUrl: config.repoUrl
2495
+ };
2496
+ logger.info(`Running ${config.hooks.onBranchCreated.length} hook(s) for branch '${branchName}'...`);
2497
+ hookExecutionService.executeOnBranchCreated(config.hooks, context, {
2498
+ onStdout: (data) => logger.info(`[hook] ${data}`),
2499
+ onStderr: (data) => logger.warn(`[hook] ${data}`),
2500
+ onError: (command, error) => logger.error(`[hook] Failed to execute '${command}': ${error.message}`),
2501
+ onComplete: (command, exitCode) => {
2502
+ if (exitCode === 0) {
2503
+ logger.info(`[hook] Command completed successfully`);
2504
+ } else if (exitCode !== null) {
2505
+ logger.warn(`[hook] Command exited with code ${exitCode}`);
2506
+ }
2507
+ }
2508
+ });
2509
+ }
2510
+ };
2511
+
2512
+ // src/utils/clone-skip-format.ts
2513
+ function formatCloneSkipReason(reason) {
2514
+ switch (reason.kind) {
2515
+ case "branch_mismatch":
2516
+ return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
2517
+ case "head_unreadable":
2518
+ return `could not read HEAD: ${reason.error}`;
2519
+ case "dirty_tree":
2520
+ return `working tree has local changes`;
2521
+ case "diverged":
2522
+ return `diverged from origin/${reason.branch}`;
2523
+ case "ahead_unpushed":
2524
+ return `unpushed commits ahead of origin/${reason.branch}`;
2525
+ case "missing_remote_ref":
2526
+ return reason.source === "fetch_error" ? `origin/${reason.branch} missing on remote (fetch error)` : `origin/${reason.branch} pruned after fetch`;
2527
+ case "indeterminate_shallow":
2528
+ 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`;
2529
+ case "origin_mismatch":
2530
+ return `clone origin is '${reason.actual}', expected '${reason.expected}'`;
2531
+ default: {
2532
+ const _exhaustive = reason;
2533
+ return _exhaustive;
2534
+ }
2535
+ }
2536
+ }
2537
+
2538
+ // src/services/sync-outcome.ts
2539
+ var EMPTY_COUNTS = {
2540
+ created: 0,
2541
+ removed: 0,
2542
+ updated: 0,
2543
+ skipped: 0,
2544
+ preserved: 0,
2545
+ failed: 0,
2546
+ noop: 0
2547
+ };
2548
+ function cloneCounts(counts) {
2549
+ return { ...counts };
2550
+ }
2551
+ function cloneAction(action) {
2552
+ return { ...action };
2553
+ }
2554
+ function countKeyFor(action) {
2555
+ switch (action.kind) {
2556
+ case "created":
2557
+ return "created";
2558
+ case "removed":
2559
+ return "removed";
2560
+ case "updated":
2561
+ return "updated";
2562
+ case "skipped":
2563
+ return "skipped";
2564
+ case "preserved-diverged":
2565
+ return "preserved";
2566
+ case "failed":
2567
+ return "failed";
2568
+ case "noop":
2569
+ return "noop";
2570
+ default: {
2571
+ const _exhaustive = action;
2572
+ return _exhaustive;
2573
+ }
2574
+ }
2575
+ }
2576
+ var SyncOutcomeAccumulator = class {
2577
+ constructor(options) {
2578
+ this.options = options;
2579
+ }
2580
+ counts = cloneCounts(EMPTY_COUNTS);
2581
+ actions = [];
2582
+ add(action) {
2583
+ this.actions.push(action);
2584
+ this.counts[countKeyFor(action)]++;
2585
+ }
2586
+ recordCreated(branch, path18) {
2587
+ this.add({ kind: "created", branch, path: path18 });
2588
+ }
2589
+ recordRemoved(branch, path18) {
2590
+ this.add({ kind: "removed", branch, path: path18 });
2591
+ }
2592
+ recordUpdated(branch, path18, reason) {
2593
+ this.add({ kind: "updated", branch, path: path18, reason });
2594
+ }
2595
+ recordNoop(scope, reason, details) {
2596
+ this.add({ kind: "noop", scope, reason, ...details });
2597
+ }
2598
+ recordSkipped(scope, reason, details) {
2599
+ this.add({ kind: "skipped", scope, reason, ...details });
2600
+ }
2601
+ recordPreservedDiverged(branch, path18, preservedPath) {
2602
+ this.add({ kind: "preserved-diverged", branch, path: path18, preservedPath });
2603
+ }
2604
+ recordFailed(scope, error, details = {}) {
2605
+ this.add({ kind: "failed", scope, error, ...details });
2606
+ }
2607
+ getCounts() {
2608
+ return cloneCounts(this.counts);
2609
+ }
2610
+ snapshot() {
2611
+ return {
2612
+ counts: cloneCounts(this.counts),
2613
+ actions: this.actions.map(cloneAction)
2614
+ };
2615
+ }
2616
+ restore(snapshot) {
2617
+ this.counts = cloneCounts(snapshot.counts);
2618
+ this.actions = snapshot.actions.map(cloneAction);
2619
+ }
2620
+ toOutcome(durationMs) {
2621
+ return {
2622
+ repoName: this.options.repoName,
2623
+ mode: this.options.mode,
2624
+ started: true,
2625
+ counts: cloneCounts(this.counts),
2626
+ actions: this.actions.map(cloneAction),
2627
+ durationMs
2628
+ };
2629
+ }
2630
+ };
2631
+ function cloneSkipToOutcomeAction(reason, details = {}) {
2632
+ const message = formatCloneSkipReason(reason);
2633
+ const branch = "branch" in reason ? reason.branch : reason.kind === "branch_mismatch" ? reason.expectedBranch : details.branch;
2634
+ return {
2635
+ kind: "skipped",
2636
+ scope: "repo",
2637
+ reason: `clone_${reason.kind}`,
2638
+ branch,
2639
+ path: details.path,
2640
+ message
2641
+ };
2642
+ }
2643
+
2644
+ // src/services/clone-sync.service.ts
2645
+ var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
2646
+ var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
2647
+ var CloneSyncService = class {
2648
+ constructor(config, gitService, logger, options = {}) {
2649
+ this.config = config;
2650
+ this.gitService = gitService;
2651
+ this.logger = logger;
2652
+ this.branchCreatedActions = options.branchCreatedActions ?? new BranchCreatedActionsService();
2653
+ this.progressEmitter = options.progressEmitter;
2654
+ this.onSkip = options.onSkip;
2655
+ }
2656
+ initialized = false;
2657
+ resolvedBranch = null;
2658
+ branchCreatedActions;
2659
+ progressEmitter;
2660
+ onSkip;
2661
+ outcomeAccumulator;
2662
+ // One-shot suppression token. When init records a wrong-branch / unreadable-HEAD
2663
+ // skip for an existing clone, it sets this so the immediately following
2664
+ // runSyncAttempt (same sync operation) does not record the identical skip again.
2665
+ pendingInitSkip = null;
2666
+ updateLogger(logger) {
2667
+ this.logger = logger;
2668
+ }
2669
+ isInitialized() {
2670
+ return this.initialized;
2671
+ }
2672
+ clearPendingInitSkip() {
2673
+ this.pendingInitSkip = null;
2674
+ }
2675
+ async getWorktrees() {
2676
+ const worktreeDir = path4.resolve(this.config.worktreeDir);
2677
+ if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
2678
+ return [];
2679
+ }
2680
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2681
+ let branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
2682
+ if (!branch || branch === "HEAD") {
2683
+ const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
2684
+ branch = head ? `(detached ${head})` : "(detached)";
2685
+ }
2686
+ return [{ path: worktreeDir, branch }];
2687
+ }
2688
+ get repoName() {
2689
+ return this.config.name ?? this.config.repoUrl;
2690
+ }
2691
+ getCloneTimeoutMs() {
2692
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
2693
+ return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
2694
+ }
2695
+ getFetchTimeoutMs() {
2696
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
2697
+ return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
2698
+ }
2699
+ isLfsSkipEnabled() {
2700
+ return this.config.skipLfs === true;
2701
+ }
2702
+ buildGitOptions(blockMs) {
2703
+ const options = {
2704
+ progress: makeGitProgressHandler(this.logger, (event) => this.emitProgress(event))
2705
+ };
2706
+ if (blockMs > 0) options.timeout = { block: blockMs };
2707
+ return options;
2708
+ }
2709
+ emitProgress(event) {
2710
+ try {
2711
+ this.progressEmitter?.(event);
2712
+ } catch {
2713
+ }
2714
+ }
2715
+ async withOutcome(outcome, operation) {
2716
+ const previousOutcome = this.outcomeAccumulator;
2717
+ if (outcome) {
2718
+ this.outcomeAccumulator = outcome;
2719
+ }
2720
+ try {
2721
+ return await operation();
2722
+ } finally {
2723
+ if (outcome) {
2724
+ this.outcomeAccumulator = previousOutcome;
2725
+ }
2726
+ }
2727
+ }
2728
+ recordSkip(reason, logMessage, progressMessage, logLevel = "warn") {
2729
+ if (logLevel === "warn") {
2730
+ this.logger.warn(logMessage);
2731
+ } else {
2732
+ this.logger.info(logMessage);
2733
+ }
2734
+ this.emitProgress({ phase: "skip", message: progressMessage ?? logMessage });
2735
+ try {
2736
+ this.onSkip?.(reason);
2737
+ } catch {
2738
+ }
2739
+ this.outcomeAccumulator?.add(
2740
+ cloneSkipToOutcomeAction(reason, {
2741
+ branch: this.resolvedBranch ?? this.config.branch,
2742
+ path: this.config.worktreeDir
2743
+ })
2744
+ );
2745
+ }
2746
+ clientFor(dir, blockMs) {
2747
+ return simpleGit(dir, this.buildGitOptions(blockMs)).env(this.buildGitEnv());
2748
+ }
2749
+ // Force a stable C locale so git's stderr is deterministic English. The
2750
+ // missing-remote-ref and LFS error classification matches on those strings
2751
+ // and would otherwise misfire under a non-English LANG/LC_ALL. simple-git's
2752
+ // .env() merges this object with process.env (PATH etc. preserved).
2753
+ buildGitEnv(opts = {}) {
2754
+ const env = { LC_ALL: "C", LANG: "C" };
2755
+ if (opts.forceLfsSkip || this.isLfsSkipEnabled()) {
2756
+ env[ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE] = "1";
2757
+ }
2758
+ return env;
2759
+ }
2760
+ buildCloneArgs(branch) {
2761
+ const args = ["--branch", branch, "--progress"];
2762
+ if (this.config.depth !== void 0) {
2763
+ args.push("--depth", String(this.config.depth), "--no-single-branch");
2764
+ }
2765
+ return args;
2766
+ }
2767
+ async buildFetchArgs(git) {
2768
+ const args = ["origin", "--prune", "--progress"];
2769
+ if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
2770
+ args.push("--depth", String(this.config.depth));
2771
+ }
2772
+ return args;
2773
+ }
2774
+ async ensureAllRemoteBranchesRefspec(git) {
2775
+ let fetchRefspecs = [];
2776
+ try {
2777
+ const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
2778
+ fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2779
+ } catch {
2780
+ fetchRefspecs = [];
2781
+ }
2782
+ if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
2783
+ const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
2784
+ this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
2785
+ await git.raw(["remote", "set-branches", "origin", "*"]);
2786
+ for (const refspec of customRefspecs) {
2787
+ await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
2788
+ }
2789
+ }
2790
+ isOriginRemoteBranchTrackingRefspec(refspec) {
2791
+ const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
2792
+ if (withoutForce.startsWith("^")) return false;
2793
+ const [source, destination] = withoutForce.split(":");
2794
+ return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
2795
+ }
2796
+ recordMissingRemoteRefSkip(branch) {
2797
+ this.recordSkip(
2798
+ { kind: "missing_remote_ref", branch, source: "fetch_error" },
2799
+ `Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
2800
+ `Skipping '${this.repoName}': origin/${branch} is missing`
2801
+ );
2802
+ }
2803
+ async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
2804
+ try {
2805
+ await git.fetch(fetchArgs);
2806
+ return { skipped: false };
2807
+ } catch (fetchError) {
2808
+ const message = getErrorMessage(fetchError);
2809
+ if (isLfsError(message)) {
2810
+ this.logger.info(`\u26A0\uFE0F LFS error during fetch for '${this.repoName}'; retrying with LFS disabled.`);
2811
+ this.emitProgress({ phase: "fetch", message: `Retrying fetch for '${this.repoName}' with LFS disabled` });
2812
+ const lfsSkipGit = simpleGit(worktreeDir, this.buildGitOptions(this.getFetchTimeoutMs())).env(
2813
+ this.buildGitEnv({ forceLfsSkip: true })
2814
+ );
2815
+ try {
2816
+ await lfsSkipGit.fetch(fetchArgs);
2817
+ return { skipped: false };
2818
+ } catch (retryError) {
2819
+ if (isMissingRemoteRefError(getErrorMessage(retryError))) {
2820
+ this.recordMissingRemoteRefSkip(branch);
2821
+ return { skipped: true };
2822
+ }
2823
+ throw retryError;
2824
+ }
2825
+ }
2826
+ if (isMissingRemoteRefError(message)) {
2827
+ this.recordMissingRemoteRefSkip(branch);
2828
+ return { skipped: true };
2829
+ }
2830
+ throw fetchError;
2831
+ }
2832
+ }
2833
+ async hasRemoteBranch(git, branch) {
2834
+ try {
2835
+ await git.raw(["show-ref", "--verify", `refs/remotes/origin/${branch}`]);
2836
+ return true;
2837
+ } catch {
2838
+ return false;
2839
+ }
2840
+ }
2841
+ async isShallowRepository(git) {
2842
+ try {
2843
+ const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
2844
+ return output.trim() === "true";
2845
+ } catch {
2846
+ return false;
2847
+ }
2848
+ }
2849
+ async unshallowIfDepthRemoved(git) {
2850
+ if (this.config.depth !== void 0) return;
2851
+ if (!await this.isShallowRepository(git)) return;
2852
+ this.logger.info(
2853
+ `[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
2854
+ );
2855
+ await git.fetch(["--unshallow"]);
2856
+ }
2857
+ getDeepenTargets() {
2858
+ const configuredDepth = this.config.depth;
2859
+ if (configuredDepth === void 0) return [];
2860
+ return SHALLOW_RELATION_DEEPEN_TARGETS.filter((target) => target > configuredDepth);
2861
+ }
2862
+ async deepenShallowHistoryToDepth(git, branch, targetDepth) {
2863
+ this.logger.info(
2864
+ `[deepen] Shallow clone for '${this.repoName}' lacks enough history to classify origin/${branch}; refetching to depth ${targetDepth} before deciding.`
2865
+ );
2866
+ this.emitProgress({
2867
+ phase: "fetch",
2868
+ message: `Deepening '${this.repoName}' to depth ${targetDepth} before classifying origin/${branch}`
2869
+ });
2870
+ await git.fetch([
2871
+ "origin",
2872
+ "--depth",
2873
+ String(targetDepth),
2874
+ "--prune",
2875
+ "--progress",
2876
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`
2877
+ ]);
2878
+ }
2879
+ async resolveBranch() {
2880
+ if (this.resolvedBranch) return this.resolvedBranch;
2881
+ if (this.config.branch) {
2882
+ this.resolvedBranch = this.config.branch;
2883
+ this.emitProgress({ phase: "branch", message: `Using configured branch '${this.resolvedBranch}'` });
2884
+ return this.resolvedBranch;
2885
+ }
2886
+ this.logger.info(`No branch configured for '${this.repoName}', detecting remote default branch...`);
2887
+ this.emitProgress({ phase: "branch", message: `Resolving remote default branch for '${this.repoName}'` });
2888
+ this.resolvedBranch = await this.gitService.getRemoteDefaultBranch(this.config.repoUrl);
2889
+ this.logger.info(` \u21B3 resolved default branch: ${this.resolvedBranch}`);
2890
+ this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
2891
+ return this.resolvedBranch;
2892
+ }
2893
+ async initialize(outcome) {
2894
+ return this.withOutcome(outcome, () => this.initializeInternal());
2895
+ }
2896
+ async initializeInternal() {
2897
+ this.pendingInitSkip = null;
2898
+ const branch = await this.resolveBranch();
2899
+ const worktreeDir = this.config.worktreeDir;
2900
+ let entries = null;
2901
+ try {
2902
+ entries = await fs3.readdir(worktreeDir);
2903
+ } catch {
2904
+ entries = null;
2905
+ }
2906
+ if (entries?.includes(PATH_CONSTANTS.GIT_DIR)) {
2907
+ this.emitProgress({ phase: "clone", message: `Validating existing clone for '${this.repoName}'` });
2908
+ const result = await this.validateExistingClone(branch);
2909
+ if (!result.valid) {
2910
+ this.recordSkip(result.skip, result.warnMessage, `Skipping '${this.repoName}': ${result.progressDetail}`);
2911
+ this.pendingInitSkip = result.skip;
2912
+ this.initialized = true;
2913
+ return;
2914
+ }
2915
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2916
+ await this.ensureAllRemoteBranchesRefspec(git);
2917
+ this.initialized = true;
2918
+ this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
2919
+ return;
2920
+ }
2921
+ if (entries && entries.length > 0) {
2922
+ throw new ConfigError(
2923
+ `Cannot clone into '${worktreeDir}': directory exists and is not empty. Remove existing contents or point worktreeDir at an empty path.`,
2924
+ "CLONE_DESTINATION_NOT_EMPTY"
2925
+ );
2926
+ }
2927
+ const cloneCreatedDir = entries === null;
2928
+ await fs3.mkdir(worktreeDir, { recursive: true });
2929
+ this.logger.info(`Cloning '${this.config.repoUrl}' (${branch}) into '${worktreeDir}'...`);
2930
+ this.emitProgress({ phase: "clone", message: `Cloning '${this.repoName}' (${branch})` });
2931
+ const cloneClient = simpleGit(this.buildGitOptions(this.getCloneTimeoutMs())).env(this.buildGitEnv());
2932
+ try {
2933
+ await cloneClient.clone(this.config.repoUrl, worktreeDir, this.buildCloneArgs(branch));
2934
+ } catch (error) {
2935
+ await this.maybeCleanupPartialClone(worktreeDir, cloneCreatedDir);
2936
+ this.outcomeAccumulator?.recordFailed("repo", getErrorMessage(error), {
2937
+ reason: "clone_failed",
2938
+ branch,
2939
+ path: worktreeDir
2940
+ });
2941
+ throw error;
2942
+ }
2943
+ const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2944
+ await this.ensureAllRemoteBranchesRefspec(worktreeGit);
2945
+ this.logger.info(`\u2705 Clone successful.`);
2946
+ this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
2947
+ if (this.config.sparseCheckout) {
2948
+ this.logger.info(`Applying sparse-checkout patterns to '${worktreeDir}'...`);
2949
+ this.emitProgress({ phase: "sparse_checkout", message: `Applying sparse-checkout for '${this.repoName}'` });
2950
+ const sparseService = this.gitService.getSparseCheckoutService();
2951
+ await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
2952
+ await worktreeGit.raw(["checkout", "HEAD"]);
2953
+ this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout applied for '${this.repoName}'` });
2954
+ }
2955
+ this.emitProgress({ phase: "lfs", message: `Verifying LFS for '${this.repoName}'` });
2956
+ await this.gitService.verifyLfs(worktreeDir, branch);
2957
+ this.emitProgress({ phase: "lfs", message: `LFS verified for '${this.repoName}'` });
2958
+ await this.runInitialFileCopy(worktreeDir, branch);
2959
+ this.outcomeAccumulator?.recordCreated(branch, worktreeDir);
2960
+ this.initialized = true;
2961
+ }
2962
+ // Detects an on-disk clone whose `origin` no longer matches the configured
2963
+ // repoUrl (e.g. repoUrl was repointed in config). Returns a skip descriptor so
2964
+ // we never fetch/ff-merge from the wrong remote; null when origin matches or
2965
+ // can't be read. Comparison is normalized so https/.git/trailing-slash
2966
+ // variants don't false-positive; the raw URLs are kept in the message.
2967
+ async evaluateOriginMatch(git, worktreeDir) {
2968
+ let originUrl;
2969
+ try {
2970
+ originUrl = (await git.raw(["remote", "get-url", "origin"])).trim();
2971
+ } catch {
2972
+ this.logger.warn(`Could not read 'origin' remote URL from existing clone at '${worktreeDir}'.`);
2973
+ return null;
2974
+ }
2975
+ if (!originUrl || normalizeRepoUrlForComparison(originUrl) === normalizeRepoUrlForComparison(this.config.repoUrl)) {
2976
+ return null;
2977
+ }
2978
+ return {
2979
+ skip: { kind: "origin_mismatch", actual: originUrl, expected: this.config.repoUrl },
2980
+ 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.`,
2981
+ progressDetail: `origin '${originUrl}' is not '${this.config.repoUrl}'`
2982
+ };
2983
+ }
2984
+ async validateExistingClone(expectedBranch) {
2985
+ const worktreeDir = this.config.worktreeDir;
2986
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
2987
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
2988
+ if (originMismatch) {
2989
+ return { valid: false, ...originMismatch };
2990
+ }
2991
+ let currentBranch;
2992
+ try {
2993
+ currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
2994
+ } catch (error) {
2995
+ const errorMessage = getErrorMessage(error);
2996
+ return {
2997
+ valid: false,
2998
+ skip: { kind: "head_unreadable", phase: "init", error: errorMessage },
2999
+ warnMessage: `Existing clone at '${worktreeDir}' has a .git folder but reading HEAD failed: ${errorMessage}`,
3000
+ progressDetail: `could not read HEAD (${errorMessage})`
3001
+ };
3002
+ }
3003
+ if (currentBranch !== expectedBranch) {
3004
+ return {
3005
+ valid: false,
3006
+ skip: {
3007
+ kind: "branch_mismatch",
3008
+ phase: "init",
3009
+ currentBranch,
3010
+ expectedBranch
3011
+ },
3012
+ warnMessage: `Existing clone at '${worktreeDir}' is on branch '${currentBranch}', expected '${expectedBranch}'. Switch the working tree to '${expectedBranch}' or update the config.`,
3013
+ progressDetail: `current branch '${currentBranch}' is not '${expectedBranch}'`
3014
+ };
3015
+ }
3016
+ return { valid: true };
3017
+ }
3018
+ async maybeCleanupPartialClone(worktreeDir, cloneCreatedDir) {
3019
+ if (!cloneCreatedDir) {
3020
+ this.logger.warn(
3021
+ `Clone failed; leaving '${worktreeDir}' for manual inspection (directory existed before clone attempt).`
3022
+ );
3023
+ return;
3024
+ }
3025
+ let entries;
3026
+ try {
3027
+ entries = await fs3.readdir(worktreeDir);
3028
+ } catch {
3029
+ return;
3030
+ }
3031
+ const looksIncomplete = entries.every((e) => e.startsWith("."));
3032
+ const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
3033
+ if (looksIncomplete && !hasUsableGit) {
3034
+ try {
3035
+ await fs3.rm(worktreeDir, { recursive: true, force: true });
3036
+ this.logger.info(`Cleaned up incomplete clone at '${worktreeDir}'.`);
3037
+ } catch (rmError) {
3038
+ this.logger.warn(`Failed to clean up incomplete clone at '${worktreeDir}': ${getErrorMessage(rmError)}`);
3039
+ }
3040
+ } else {
3041
+ this.logger.warn(
3042
+ `Clone failed; leaving '${worktreeDir}' for manual inspection (post-failure contents do not look like an empty incomplete clone).`
3043
+ );
3044
+ }
3045
+ }
3046
+ getInitMarkerPath(worktreeDir) {
3047
+ return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
3048
+ }
3049
+ async runInitialFileCopy(worktreeDir, branch) {
3050
+ const marker = this.getInitMarkerPath(worktreeDir);
3051
+ if (await fileExists(marker)) {
3052
+ return;
3053
+ }
3054
+ const sourceDir = this.config.__configFileDir ?? worktreeDir;
3055
+ await this.branchCreatedActions.copyFiles({
3056
+ config: this.config,
3057
+ branchName: branch,
3058
+ worktreePath: worktreeDir,
3059
+ sourceDir,
3060
+ logger: this.logger
3061
+ });
3062
+ try {
3063
+ await fs3.writeFile(marker, (/* @__PURE__ */ new Date()).toISOString());
3064
+ } catch (error) {
3065
+ this.logger.warn(`Could not write clone-init marker: ${getErrorMessage(error)}`);
3066
+ }
3067
+ }
3068
+ async runSyncAttempt(outcome) {
3069
+ return this.withOutcome(outcome, () => this.runSyncAttemptInternal());
3070
+ }
3071
+ async runSyncAttemptInternal() {
3072
+ if (!this.initialized) {
3073
+ await this.initialize();
3074
+ this.pendingInitSkip = null;
3075
+ return;
3076
+ }
3077
+ if (this.pendingInitSkip) {
3078
+ this.pendingInitSkip = null;
3079
+ return;
3080
+ }
3081
+ const branch = await this.resolveBranch();
3082
+ const worktreeDir = this.config.worktreeDir;
3083
+ const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
3084
+ let currentBranch;
3085
+ try {
3086
+ currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
3087
+ } catch (error) {
3088
+ const errorMessage = getErrorMessage(error);
3089
+ this.recordSkip(
3090
+ { kind: "head_unreadable", phase: "sync", error: errorMessage },
3091
+ `Could not read current branch from '${worktreeDir}': ${errorMessage}`,
3092
+ `Skipping '${this.repoName}': could not read current branch`
3093
+ );
3094
+ return;
3095
+ }
3096
+ if (currentBranch !== branch) {
3097
+ this.recordSkip(
3098
+ { kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
3099
+ `Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
3100
+ `Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
3101
+ );
3102
+ return;
3103
+ }
3104
+ const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
3105
+ if (originMismatch) {
3106
+ this.recordSkip(
3107
+ originMismatch.skip,
3108
+ originMismatch.warnMessage,
3109
+ `Skipping '${this.repoName}': ${originMismatch.progressDetail}`
3110
+ );
3111
+ return;
3112
+ }
3113
+ await this.unshallowIfDepthRemoved(git);
3114
+ await this.ensureAllRemoteBranchesRefspec(git);
3115
+ const fetchArgs = await this.buildFetchArgs(git);
3116
+ this.emitProgress({ phase: "fetch", message: `Fetching origin branches for '${this.repoName}'` });
3117
+ if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
3118
+ return;
3119
+ }
3120
+ this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
3121
+ if (!await this.hasRemoteBranch(git, branch)) {
3122
+ this.recordSkip(
3123
+ { kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
3124
+ `Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
3125
+ `Skipping '${this.repoName}': origin/${branch} is missing`
3126
+ );
3127
+ return;
3128
+ }
3129
+ if (this.config.sparseCheckout) {
3130
+ const sparseService = this.gitService.getSparseCheckoutService();
3131
+ try {
3132
+ if (await sparseService.needsUpdate(worktreeDir, this.config.sparseCheckout)) {
3133
+ this.emitProgress({ phase: "sparse_checkout", message: `Updating sparse-checkout for '${this.repoName}'` });
3134
+ await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
3135
+ this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout updated for '${this.repoName}'` });
3136
+ }
3137
+ } catch (error) {
3138
+ this.logger.warn(`Failed to reapply sparse-checkout for '${this.repoName}': ${getErrorMessage(error)}`);
3139
+ }
3140
+ }
3141
+ const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
3142
+ if (!isClean) {
3143
+ this.recordSkip(
3144
+ { kind: "dirty_tree" },
3145
+ `\u23ED\uFE0F Skipping ff-merge for '${this.repoName}' \u2014 working tree has local changes.`,
3146
+ `Skipping merge for '${this.repoName}': working tree has local changes`,
3147
+ "info"
3148
+ );
3149
+ return;
3150
+ }
3151
+ let relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
3152
+ let lastDeepenedTo = null;
3153
+ if (relationship === "indeterminate_shallow") {
3154
+ for (const target of this.getDeepenTargets()) {
3155
+ await this.deepenShallowHistoryToDepth(git, branch, target);
3156
+ lastDeepenedTo = target;
3157
+ relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
3158
+ if (relationship !== "indeterminate_shallow") break;
3159
+ }
3160
+ }
3161
+ if (relationship === "up_to_date") {
3162
+ this.logger.info(`'${this.repoName}' already up to date with origin/${branch}.`);
3163
+ this.emitProgress({
3164
+ phase: "skip",
3165
+ message: `'${this.repoName}' already up to date with origin/${branch}`
3166
+ });
3167
+ this.outcomeAccumulator?.recordNoop("repo", "already_up_to_date", {
3168
+ branch,
3169
+ path: worktreeDir,
3170
+ message: `Already up to date with origin/${branch}`
3171
+ });
3172
+ return;
3173
+ }
3174
+ if (relationship !== "fast_forward") {
3175
+ if (relationship === "local_ahead") {
3176
+ this.recordSkip(
3177
+ { kind: "ahead_unpushed", branch },
3178
+ `\u23ED\uFE0F '${this.repoName}' has unpushed commits ahead of origin/${branch}. Skipping merge.`,
3179
+ `Skipping merge for '${this.repoName}': unpushed commits ahead of origin/${branch}`,
3180
+ "info"
3181
+ );
3182
+ } else if (relationship === "indeterminate_shallow") {
3183
+ const detail = lastDeepenedTo === null ? `no deepening attempted (configured depth already at or above all deepen targets)` : `deepening to ${lastDeepenedTo} commits`;
3184
+ const progressDetail = lastDeepenedTo === null ? `no deepening attempted (configured depth at/above limits)` : `shallow depth budget exhausted at ${lastDeepenedTo}`;
3185
+ this.recordSkip(
3186
+ { kind: "indeterminate_shallow", branch, deepenedTo: lastDeepenedTo },
3187
+ `\u23ED\uFE0F '${this.repoName}' could not classify origin/${branch} after ${detail}. Skipping merge \u2014 consider removing or raising 'depth' to unshallow.`,
3188
+ `Skipping merge for '${this.repoName}': ${progressDetail}`,
3189
+ "info"
3190
+ );
3191
+ } else {
3192
+ this.recordSkip(
3193
+ { kind: "diverged", branch },
3194
+ `\u23ED\uFE0F '${this.repoName}' has diverged from origin/${branch}. Skipping merge (no auto-reset).`,
3195
+ `Skipping merge for '${this.repoName}': diverged from origin/${branch}`,
3196
+ "info"
3197
+ );
3198
+ }
3199
+ return;
3200
+ }
3201
+ this.logger.info(`Fast-forwarding '${this.repoName}' to origin/${branch}...`);
3202
+ this.emitProgress({ phase: "merge", message: `Fast-forwarding '${this.repoName}' to origin/${branch}` });
3203
+ await git.merge([`origin/${branch}`, "--ff-only"]);
3204
+ this.logger.info(`\u2705 Updated '${this.repoName}' to origin/${branch}.`);
3205
+ this.emitProgress({ phase: "merge", message: `Updated '${this.repoName}' to origin/${branch}` });
3206
+ this.outcomeAccumulator?.recordUpdated(branch, worktreeDir, "fast_forward");
3207
+ }
3208
+ };
3209
+
3210
+ // src/services/git.service.ts
3211
+ import * as fs6 from "fs/promises";
3212
+ import * as path8 from "path";
3213
+ import simpleGit5 from "simple-git";
2185
3214
 
2186
3215
  // src/utils/worktree-list-parser.ts
2187
3216
  function parseWorktreeListPorcelain(output) {
@@ -2324,8 +3353,8 @@ function defaultConsoleOutput(msg, level) {
2324
3353
  }
2325
3354
 
2326
3355
  // src/services/sparse-checkout.service.ts
2327
- import * as path3 from "path";
2328
- import simpleGit from "simple-git";
3356
+ import * as path5 from "path";
3357
+ import simpleGit2 from "simple-git";
2329
3358
  var SparseCheckoutService = class {
2330
3359
  logger;
2331
3360
  gitFactory;
@@ -2333,7 +3362,7 @@ var SparseCheckoutService = class {
2333
3362
  matcherCache = /* @__PURE__ */ new WeakMap();
2334
3363
  constructor(logger, gitFactory) {
2335
3364
  this.logger = logger ?? Logger.createDefault();
2336
- this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
3365
+ this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
2337
3366
  }
2338
3367
  updateLogger(logger) {
2339
3368
  this.logger = logger;
@@ -2384,11 +3413,25 @@ var SparseCheckoutService = class {
2384
3413
  return null;
2385
3414
  }
2386
3415
  }
3416
+ async readCurrentMode(worktreePath) {
3417
+ const git = this.gitFactory(worktreePath);
3418
+ try {
3419
+ const out = await git.raw(["config", "--bool", "--get", "core.sparseCheckoutCone"]);
3420
+ const value = out.trim().toLowerCase();
3421
+ if (value === "true") return "cone";
3422
+ if (value === "false") return "no-cone";
3423
+ return null;
3424
+ } catch {
3425
+ return null;
3426
+ }
3427
+ }
2387
3428
  async needsUpdate(worktreePath, cfg) {
3429
+ const desiredMode = this.resolveMode(cfg);
3430
+ const currentMode = await this.readCurrentMode(worktreePath);
3431
+ if (currentMode !== desiredMode) return true;
2388
3432
  const current = await this.readCurrent(worktreePath);
2389
- const desired = this.buildPatterns(cfg);
2390
3433
  if (current === null) return true;
2391
- return !this.patternsEqual(current, desired);
3434
+ return !this.patternsEqual(current, this.buildPatternsForMode(cfg, desiredMode));
2392
3435
  }
2393
3436
  isNarrowing(currentPatterns, nextPatterns) {
2394
3437
  if (!currentPatterns || currentPatterns.length === 0) return false;
@@ -2445,7 +3488,7 @@ var SparseCheckoutService = class {
2445
3488
  for (const pat of matcher.patterns) {
2446
3489
  if (p === pat || p.startsWith(pat + "/")) return true;
2447
3490
  }
2448
- return matcher.ancestorDirs.has(path3.posix.dirname(p));
3491
+ return matcher.ancestorDirs.has(path5.posix.dirname(p));
2449
3492
  });
2450
3493
  }
2451
3494
  getMatcher(cfg) {
@@ -2472,9 +3515,9 @@ var SparseCheckoutService = class {
2472
3515
  };
2473
3516
 
2474
3517
  // 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";
3518
+ import * as fs4 from "fs/promises";
3519
+ import * as path6 from "path";
3520
+ import simpleGit3 from "simple-git";
2478
3521
  var WorktreeMetadataService = class {
2479
3522
  logger;
2480
3523
  constructor(logger) {
@@ -2486,7 +3529,7 @@ var WorktreeMetadataService = class {
2486
3529
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
2487
3530
  */
2488
3531
  getWorktreeDirectoryName(worktreePath) {
2489
- return path4.basename(worktreePath);
3532
+ return path6.basename(worktreePath);
2490
3533
  }
2491
3534
  async getMetadataPath(bareRepoPath, worktreeName) {
2492
3535
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -2494,7 +3537,7 @@ var WorktreeMetadataService = class {
2494
3537
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
2495
3538
  );
2496
3539
  }
2497
- return path4.join(
3540
+ return path6.join(
2498
3541
  bareRepoPath,
2499
3542
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
2500
3543
  worktreeName,
@@ -2507,31 +3550,31 @@ var WorktreeMetadataService = class {
2507
3550
  }
2508
3551
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
2509
3552
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2510
- await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
3553
+ await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
2511
3554
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
2512
3555
  let renamed = false;
2513
3556
  try {
2514
- await fs2.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
3557
+ await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
2515
3558
  try {
2516
- await fs2.rename(tmpPath, metadataPath);
3559
+ await fs4.rename(tmpPath, metadataPath);
2517
3560
  renamed = true;
2518
3561
  } catch (err) {
2519
3562
  if (err.code === ERROR_MESSAGES.EXDEV) {
2520
- await fs2.copyFile(tmpPath, metadataPath);
3563
+ await fs4.copyFile(tmpPath, metadataPath);
2521
3564
  } else {
2522
3565
  throw err;
2523
3566
  }
2524
3567
  }
2525
3568
  } finally {
2526
3569
  if (!renamed) {
2527
- await fs2.unlink(tmpPath).catch(() => void 0);
3570
+ await fs4.unlink(tmpPath).catch(() => void 0);
2528
3571
  }
2529
3572
  }
2530
3573
  }
2531
3574
  async loadMetadata(bareRepoPath, worktreeName) {
2532
3575
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2533
3576
  try {
2534
- const content = await fs2.readFile(metadataPath, "utf-8");
3577
+ const content = await fs4.readFile(metadataPath, "utf-8");
2535
3578
  return JSON.parse(content);
2536
3579
  } catch {
2537
3580
  return null;
@@ -2540,7 +3583,7 @@ var WorktreeMetadataService = class {
2540
3583
  async loadMetadataFromPath(bareRepoPath, worktreePath) {
2541
3584
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2542
3585
  try {
2543
- const content = await fs2.readFile(metadataPath, "utf-8");
3586
+ const content = await fs4.readFile(metadataPath, "utf-8");
2544
3587
  const metadata = JSON.parse(content);
2545
3588
  if (!await this.validateMetadata(metadata)) {
2546
3589
  this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
@@ -2554,7 +3597,7 @@ var WorktreeMetadataService = class {
2554
3597
  async deleteMetadata(bareRepoPath, worktreeName) {
2555
3598
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2556
3599
  try {
2557
- await fs2.unlink(metadataPath);
3600
+ await fs4.unlink(metadataPath);
2558
3601
  } catch (error) {
2559
3602
  if (error.code !== "ENOENT") {
2560
3603
  throw error;
@@ -2564,7 +3607,7 @@ var WorktreeMetadataService = class {
2564
3607
  async deleteMetadataFromPath(bareRepoPath, worktreePath) {
2565
3608
  const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
2566
3609
  try {
2567
- await fs2.unlink(metadataPath);
3610
+ await fs4.unlink(metadataPath);
2568
3611
  } catch (error) {
2569
3612
  if (error.code !== "ENOENT") {
2570
3613
  throw error;
@@ -2598,7 +3641,7 @@ var WorktreeMetadataService = class {
2598
3641
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
2599
3642
  this.logger.info(` Attempting to create initial metadata...`);
2600
3643
  try {
2601
- const worktreeGit = simpleGit2(worktreePath);
3644
+ const worktreeGit = simpleGit3(worktreePath);
2602
3645
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
2603
3646
  const branchSummary = await worktreeGit.branch();
2604
3647
  const actualBranchName = branchSummary.current;
@@ -2699,9 +3742,9 @@ var WorktreeMetadataService = class {
2699
3742
  };
2700
3743
 
2701
3744
  // 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";
3745
+ import * as fs5 from "fs/promises";
3746
+ import * as path7 from "path";
3747
+ import simpleGit4 from "simple-git";
2705
3748
  var OPERATION_FILES = [
2706
3749
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
2707
3750
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -2732,9 +3775,7 @@ var WorktreeStatusService = class {
2732
3775
  return true;
2733
3776
  }
2734
3777
  async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
2735
- try {
2736
- await fs3.access(worktreePath);
2737
- } catch {
3778
+ if (!await fileExists(worktreePath)) {
2738
3779
  return {
2739
3780
  isClean: true,
2740
3781
  hasUnpushedCommits: false,
@@ -2904,7 +3945,7 @@ var WorktreeStatusService = class {
2904
3945
  async detectOperationFile(gitDir) {
2905
3946
  const results = await Promise.all(
2906
3947
  OPERATION_FILES.map(
2907
- ({ file }) => fs3.access(path5.join(gitDir, file)).then(
3948
+ ({ file }) => fs5.access(path7.join(gitDir, file)).then(
2908
3949
  () => true,
2909
3950
  () => false
2910
3951
  )
@@ -3025,14 +4066,14 @@ var WorktreeStatusService = class {
3025
4066
  }
3026
4067
  }
3027
4068
  async resolveGitDir(worktreePath) {
3028
- const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
4069
+ const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
3029
4070
  try {
3030
- const stat3 = await fs3.stat(gitPath);
4071
+ const stat3 = await fs5.stat(gitPath);
3031
4072
  if (stat3.isFile()) {
3032
- const content = await fs3.readFile(gitPath, "utf-8");
4073
+ const content = await fs5.readFile(gitPath, "utf-8");
3033
4074
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
3034
4075
  if (gitdirMatch) {
3035
- return path5.resolve(worktreePath, gitdirMatch[1].trim());
4076
+ return path7.resolve(worktreePath, gitdirMatch[1].trim());
3036
4077
  }
3037
4078
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
3038
4079
  }
@@ -3046,10 +4087,10 @@ var WorktreeStatusService = class {
3046
4087
  }
3047
4088
  }
3048
4089
  createGitInstance(worktreePath) {
3049
- const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
4090
+ const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
3050
4091
  let git = this.gitInstances.get(key);
3051
4092
  if (!git) {
3052
- git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
4093
+ git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
3053
4094
  this.gitInstances.set(key, git);
3054
4095
  }
3055
4096
  return git;
@@ -3065,11 +4106,12 @@ function sanitizeGitEnv(env) {
3065
4106
  return sanitized;
3066
4107
  }
3067
4108
  var GitService = class {
3068
- constructor(config, logger) {
4109
+ constructor(config, logger, progressEmitter) {
3069
4110
  this.config = config;
4111
+ this.progressEmitter = progressEmitter;
3070
4112
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
3071
4113
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
3072
- this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
4114
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3073
4115
  this.metadataService = new WorktreeMetadataService(this.logger);
3074
4116
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
3075
4117
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -3097,36 +4139,22 @@ var GitService = class {
3097
4139
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
3098
4140
  }
3099
4141
  getCachedGit(dirPath, useLfsSkip = false) {
3100
- const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
4142
+ const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3101
4143
  let git = this.gitInstances.get(key);
3102
4144
  if (!git) {
3103
- const base = simpleGit4(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4145
+ const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
3104
4146
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
3105
4147
  this.gitInstances.set(key, git);
3106
4148
  }
3107
4149
  return git;
3108
4150
  }
3109
4151
  buildSimpleGitOptions(blockMs) {
3110
- const options = { progress: this.makeProgressHandler() };
4152
+ const options = {
4153
+ progress: makeGitProgressHandler(this.logger, (event) => this.progressEmitter?.(event))
4154
+ };
3111
4155
  if (blockMs > 0) options.timeout = { block: blockMs };
3112
4156
  return options;
3113
4157
  }
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
4158
  updateLogger(logger) {
3131
4159
  this.logger = logger;
3132
4160
  this.sparseCheckoutService.updateLogger(logger);
@@ -3134,11 +4162,11 @@ var GitService = class {
3134
4162
  async initialize() {
3135
4163
  const { repoUrl } = this.config;
3136
4164
  try {
3137
- await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
4165
+ await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
3138
4166
  } catch {
3139
4167
  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()));
4168
+ await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
4169
+ const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
3142
4170
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
3143
4171
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
3144
4172
  this.logger.info("\u2705 Clone successful.");
@@ -3156,17 +4184,17 @@ var GitService = class {
3156
4184
  this.logger.info("Fetching remote branches...");
3157
4185
  await bareGit.fetch(["--all", "--progress"]);
3158
4186
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
3159
- this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
4187
+ this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
3160
4188
  let needsMainWorktree = true;
3161
4189
  try {
3162
4190
  const worktrees = await this.getWorktreesFromBare(bareGit);
3163
- needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
4191
+ needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
3164
4192
  } catch {
3165
4193
  }
3166
4194
  if (needsMainWorktree) {
3167
4195
  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);
4196
+ await fs6.mkdir(this.config.worktreeDir, { recursive: true });
4197
+ const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
3170
4198
  const branches = await bareGit.branch();
3171
4199
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
3172
4200
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -3202,7 +4230,7 @@ var GitService = class {
3202
4230
  }
3203
4231
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
3204
4232
  const mainWorktreeRegistered = updatedWorktrees.some(
3205
- (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
4233
+ (w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
3206
4234
  );
3207
4235
  if (!mainWorktreeRegistered) {
3208
4236
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -3228,6 +4256,45 @@ var GitService = class {
3228
4256
  getBareRepoPath() {
3229
4257
  return this.bareRepoPath;
3230
4258
  }
4259
+ async getRemoteDefaultBranch(repoUrl) {
4260
+ const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
4261
+ try {
4262
+ const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
4263
+ const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
4264
+ if (match && match[1]) {
4265
+ return match[1];
4266
+ }
4267
+ } catch {
4268
+ }
4269
+ const existing = [];
4270
+ for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
4271
+ try {
4272
+ const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
4273
+ if (out.trim().length > 0) {
4274
+ existing.push(candidate);
4275
+ }
4276
+ } catch {
4277
+ }
4278
+ }
4279
+ if (existing.length === 1) {
4280
+ this.logger.warn(
4281
+ `Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
4282
+ );
4283
+ return existing[0];
4284
+ }
4285
+ if (existing.length > 1) {
4286
+ throw new Error(
4287
+ `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.`
4288
+ );
4289
+ }
4290
+ throw new Error(
4291
+ `Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
4292
+ );
4293
+ }
4294
+ async verifyLfs(worktreePath, label) {
4295
+ if (this.isLfsSkipEnabled()) return;
4296
+ await this.verifyLfsFilesDownloaded(worktreePath, label);
4297
+ }
3231
4298
  async fetchAll() {
3232
4299
  this.assertInitialized();
3233
4300
  this.logger.info("Fetching latest data from remote...");
@@ -3274,7 +4341,7 @@ var GitService = class {
3274
4341
  return branches;
3275
4342
  }
3276
4343
  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);
4344
+ const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
3278
4345
  try {
3279
4346
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
3280
4347
  let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
@@ -3285,7 +4352,7 @@ var GitService = class {
3285
4352
  const existence = await Promise.all(
3286
4353
  lfsFileList.map(async (f) => {
3287
4354
  try {
3288
- await fs4.access(path6.join(worktreePath, f));
4355
+ await fs6.access(path8.join(worktreePath, f));
3289
4356
  return f;
3290
4357
  } catch {
3291
4358
  return null;
@@ -3313,9 +4380,9 @@ var GitService = class {
3313
4380
  let allDownloaded = true;
3314
4381
  const notDownloaded = [];
3315
4382
  for (const file of samplesToCheck) {
3316
- const filePath = path6.join(worktreePath, file);
4383
+ const filePath = path8.join(worktreePath, file);
3317
4384
  try {
3318
- const handle = await fs4.open(filePath, "r");
4385
+ const handle = await fs6.open(filePath, "r");
3319
4386
  try {
3320
4387
  const buffer = Buffer.alloc(200);
3321
4388
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
@@ -3340,7 +4407,7 @@ var GitService = class {
3340
4407
  }
3341
4408
  retries++;
3342
4409
  if (retries < maxRetries) {
3343
- await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
4410
+ await new Promise((resolve12) => setTimeout(resolve12, retryDelay));
3344
4411
  }
3345
4412
  }
3346
4413
  this.logger.warn(
@@ -3402,18 +4469,18 @@ var GitService = class {
3402
4469
  }
3403
4470
  async addWorktree(branchName, worktreePath) {
3404
4471
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
3405
- const absoluteWorktreePath = path6.resolve(worktreePath);
3406
- await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
4472
+ const absoluteWorktreePath = path8.resolve(worktreePath);
4473
+ await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
3407
4474
  try {
3408
- await fs4.access(absoluteWorktreePath);
4475
+ await fs6.access(absoluteWorktreePath);
3409
4476
  const worktrees = await this.getWorktreesFromBare(bareGit);
3410
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
4477
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3411
4478
  if (isValidWorktree) {
3412
4479
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3413
4480
  return;
3414
4481
  } else {
3415
4482
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
3416
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
4483
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3417
4484
  }
3418
4485
  } catch {
3419
4486
  }
@@ -3452,7 +4519,7 @@ var GitService = class {
3452
4519
  }
3453
4520
  if (errorMessage.includes("already registered worktree")) {
3454
4521
  const worktrees = await this.getWorktreesFromBare(bareGit);
3455
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
4522
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3456
4523
  if (existingWorktree && !existingWorktree.isPrunable) {
3457
4524
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
3458
4525
  return;
@@ -3460,7 +4527,7 @@ var GitService = class {
3460
4527
  this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
3461
4528
  await bareGit.raw(["worktree", "prune"]);
3462
4529
  try {
3463
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
4530
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3464
4531
  } catch {
3465
4532
  }
3466
4533
  let retryCreatedNewBranch = false;
@@ -3496,15 +4563,15 @@ var GitService = class {
3496
4563
  }
3497
4564
  this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
3498
4565
  try {
3499
- await fs4.access(absoluteWorktreePath);
4566
+ await fs6.access(absoluteWorktreePath);
3500
4567
  const worktrees = await this.getWorktreesFromBare(bareGit);
3501
- const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
4568
+ const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
3502
4569
  if (isValidWorktree) {
3503
4570
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3504
4571
  return;
3505
4572
  } else {
3506
4573
  this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
3507
- await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
4574
+ await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
3508
4575
  }
3509
4576
  } catch {
3510
4577
  }
@@ -3528,7 +4595,7 @@ var GitService = class {
3528
4595
  const fallbackErrorMessage = getErrorMessage(fallbackError);
3529
4596
  if (fallbackErrorMessage.includes("already registered worktree")) {
3530
4597
  const worktrees = await this.getWorktreesFromBare(bareGit);
3531
- const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
4598
+ const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
3532
4599
  if (existingWorktree && !existingWorktree.isPrunable) {
3533
4600
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
3534
4601
  return;
@@ -3751,6 +4818,40 @@ var GitService = class {
3751
4818
  return false;
3752
4819
  }
3753
4820
  }
4821
+ async classifyRemoteRelationship(worktreePath, branch) {
4822
+ const worktreeGit = this.getCachedGit(worktreePath);
4823
+ let headSha;
4824
+ let remoteSha;
4825
+ try {
4826
+ headSha = (await worktreeGit.revparse(["HEAD"])).trim();
4827
+ remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
4828
+ } catch {
4829
+ return "diverged";
4830
+ }
4831
+ if (headSha === remoteSha) return "up_to_date";
4832
+ let mergeBase = "";
4833
+ let mergeBaseFailed = false;
4834
+ try {
4835
+ mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
4836
+ } catch {
4837
+ mergeBaseFailed = true;
4838
+ }
4839
+ if (mergeBaseFailed || !mergeBase) {
4840
+ if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
4841
+ return "diverged";
4842
+ }
4843
+ if (mergeBase === headSha) return "fast_forward";
4844
+ if (mergeBase === remoteSha) return "local_ahead";
4845
+ return "diverged";
4846
+ }
4847
+ async isShallowRepository(git) {
4848
+ try {
4849
+ const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
4850
+ return output.trim() === "true";
4851
+ } catch {
4852
+ return false;
4853
+ }
4854
+ }
3754
4855
  async getChangedPathsInRange(worktreePath, fromRef, toRef) {
3755
4856
  const worktreeGit = this.getCachedGit(worktreePath);
3756
4857
  try {
@@ -3861,232 +4962,371 @@ var GitService = class {
3861
4962
  }
3862
4963
  };
3863
4964
 
3864
- // src/services/path-resolution.service.ts
4965
+ // src/services/progress-emitter.ts
4966
+ var ProgressEmitter = class {
4967
+ listeners = /* @__PURE__ */ new Set();
4968
+ onProgress(listener) {
4969
+ this.listeners.add(listener);
4970
+ return () => this.listeners.delete(listener);
4971
+ }
4972
+ emit(event) {
4973
+ for (const listener of [...this.listeners]) {
4974
+ try {
4975
+ listener(event);
4976
+ } catch {
4977
+ }
4978
+ }
4979
+ }
4980
+ };
4981
+
4982
+ // src/services/repo-operation-lock.ts
4983
+ import * as fs7 from "fs/promises";
4984
+ import * as path10 from "path";
4985
+ import * as lockfile from "proper-lockfile";
4986
+
4987
+ // src/utils/lock-path.ts
3865
4988
  import { createHash } from "crypto";
3866
- import * as fs5 from "fs";
3867
- import * as path7 from "path";
3868
- var BRANCH_STEM_MAX = 80;
3869
- var BRANCH_HASH_LEN = 8;
3870
- var PathResolutionService = class {
3871
- sanitizeBranchName(branchName) {
3872
- 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);
3874
- return `${stem}-${hash}`;
4989
+ import * as os from "os";
4990
+ import * as path9 from "path";
4991
+ function getCloneModeLockTarget(config) {
4992
+ const name = config.name;
4993
+ const configDir = config.__configFileDir;
4994
+ const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
4995
+ if (configDir) {
4996
+ return {
4997
+ dir: path9.join(configDir, ".sync-worktrees-state"),
4998
+ file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
4999
+ };
3875
5000
  }
3876
- getBranchWorktreePath(worktreeDir, branchName) {
3877
- return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
5001
+ const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
5002
+ const dir = path9.join(stateBase, "sync-worktrees", "locks");
5003
+ return { dir, file: `${hash}.lock` };
5004
+ }
5005
+
5006
+ // src/services/repo-operation-lock.ts
5007
+ var RepoOperationLock = class {
5008
+ constructor(config, gitService, logger = Logger.createDefault()) {
5009
+ this.config = config;
5010
+ this.gitService = gitService;
5011
+ this.logger = logger;
3878
5012
  }
3879
- resolveRealPath(inputPath) {
3880
- const absolute = path7.resolve(inputPath);
3881
- const missing = [];
3882
- let current = absolute;
3883
- while (!fs5.existsSync(current)) {
3884
- const parent = path7.dirname(current);
3885
- if (parent === current) {
3886
- return absolute;
3887
- }
3888
- missing.unshift(path7.basename(current));
3889
- current = parent;
5013
+ updateLogger(logger) {
5014
+ this.logger = logger;
5015
+ }
5016
+ async acquire() {
5017
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
5018
+ return async () => {
5019
+ };
3890
5020
  }
5021
+ if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
5022
+ return this.acquireCloneModeLock();
5023
+ }
5024
+ return this.acquireWorktreeModeLock();
5025
+ }
5026
+ async acquireCloneModeLock() {
5027
+ const target = getCloneModeLockTarget(this.config);
5028
+ const lockTarget = path10.join(target.dir, target.file);
3891
5029
  try {
3892
- return path7.join(fs5.realpathSync(current), ...missing);
5030
+ await fs7.mkdir(target.dir, { recursive: true });
5031
+ await fs7.writeFile(lockTarget, "", { flag: "a" });
3893
5032
  } catch {
3894
- return absolute;
5033
+ return null;
3895
5034
  }
5035
+ return this.lockPath(lockTarget);
3896
5036
  }
3897
- isResolvedPathInsideBase(resolved, resolvedBase) {
3898
- const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
3899
- const a = fold(resolved);
3900
- const b = fold(resolvedBase);
3901
- if (a === b) return true;
3902
- return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
3903
- }
3904
- normalizeWorktreePath(worktreePath, worktreeBaseDir) {
3905
- const resolved = this.resolveRealPath(worktreePath);
3906
- const resolvedBase = this.resolveRealPath(worktreeBaseDir);
3907
- if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
3908
- throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
5037
+ async acquireWorktreeModeLock() {
5038
+ const barePath = this.gitService.getBareRepoPath();
5039
+ try {
5040
+ await fs7.mkdir(barePath, { recursive: true });
5041
+ } catch {
5042
+ return null;
3909
5043
  }
3910
- return path7.relative(resolvedBase, resolved);
3911
- }
3912
- isPathInsideBaseDir(targetPath, baseDir) {
3913
- const resolved = this.resolveRealPath(targetPath);
3914
- const resolvedBase = this.resolveRealPath(baseDir);
3915
- return this.isResolvedPathInsideBase(resolved, resolvedBase);
5044
+ return this.lockPath(barePath);
3916
5045
  }
3917
- extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
3918
- return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
5046
+ async lockPath(lockTarget) {
5047
+ try {
5048
+ return await lockfile.lock(lockTarget, {
5049
+ stale: DEFAULT_CONFIG.LOCK_STALE_MS,
5050
+ update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
5051
+ retries: 0,
5052
+ realpath: false
5053
+ });
5054
+ } catch (error) {
5055
+ const code = error.code;
5056
+ if (code === "ELOCKED") {
5057
+ return null;
5058
+ }
5059
+ this.logger.warn(
5060
+ `Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
5061
+ );
5062
+ return null;
5063
+ }
3919
5064
  }
3920
5065
  };
3921
5066
 
3922
- // src/services/worktree-sync.service.ts
3923
- var WorktreeSyncService = class {
3924
- constructor(config) {
5067
+ // src/services/sync-retry-policy.ts
5068
+ var SyncRetryPolicy = class {
5069
+ constructor(config, gitService, logger) {
3925
5070
  this.config = config;
3926
- this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
3927
- this.gitService = new GitService(config, this.logger);
5071
+ this.gitService = gitService;
5072
+ this.logger = logger;
3928
5073
  }
3929
- gitService;
3930
- logger;
3931
- syncInProgress = false;
3932
- 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" });
5074
+ updateLogger(logger) {
5075
+ this.logger = logger;
3938
5076
  }
3939
- isInitialized() {
3940
- return this.gitService.isInitialized();
5077
+ createContext() {
5078
+ return { lfsSkipEnabled: false };
3941
5079
  }
3942
- isSyncInProgress() {
3943
- return this.syncInProgress;
5080
+ createOptions(syncContext) {
5081
+ return {
5082
+ maxAttempts: this.config.retry?.maxAttempts ?? 3,
5083
+ maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
5084
+ initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
5085
+ maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
5086
+ backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
5087
+ jitterMs: this.config.retry?.jitterMs ?? 0,
5088
+ onRetry: (error, attempt, context) => {
5089
+ const errorMessage = getErrorMessage(error);
5090
+ this.logger.info(`
5091
+ \u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
5092
+ if (context?.isLfsError && !this.config.skipLfs) {
5093
+ this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
5094
+ } else {
5095
+ this.logger.info(`\u{1F504} Retrying synchronization...
5096
+ `);
5097
+ }
5098
+ },
5099
+ lfsRetryHandler: () => {
5100
+ if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
5101
+ this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
5102
+ this.gitService.setLfsSkipEnabled(true);
5103
+ syncContext.lfsSkipEnabled = true;
5104
+ }
5105
+ }
5106
+ };
3944
5107
  }
3945
- getGitService() {
3946
- return this.gitService;
5108
+ resetLfsSkipIfNeeded(syncContext) {
5109
+ if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
5110
+ this.gitService.setLfsSkipEnabled(false);
5111
+ }
3947
5112
  }
3948
- updateLogger(logger) {
3949
- this.logger = logger;
3950
- this.gitService.updateLogger(logger);
5113
+ };
5114
+
5115
+ // src/services/worktree-mode-sync-runner.ts
5116
+ import * as fs9 from "fs/promises";
5117
+ import * as path13 from "path";
5118
+ import pLimit from "p-limit";
5119
+
5120
+ // src/utils/date-filter.ts
5121
+ function parseDuration(durationStr) {
5122
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
5123
+ if (!match) {
5124
+ return null;
3951
5125
  }
3952
- onProgress(listener) {
3953
- this.progressListeners.add(listener);
3954
- return () => this.progressListeners.delete(listener);
5126
+ const value = parseInt(match[1], 10);
5127
+ const unit = match[2];
5128
+ const multipliers = {
5129
+ h: 60 * 60 * 1e3,
5130
+ // hours
5131
+ d: 24 * 60 * 60 * 1e3,
5132
+ // days
5133
+ w: 7 * 24 * 60 * 60 * 1e3,
5134
+ // weeks
5135
+ m: 30 * 24 * 60 * 60 * 1e3,
5136
+ // months (approximate)
5137
+ y: 365 * 24 * 60 * 60 * 1e3
5138
+ // years (approximate)
5139
+ };
5140
+ return value * multipliers[unit];
5141
+ }
5142
+ function filterBranchesByAge(branches, maxAge) {
5143
+ const maxAgeMs = parseDuration(maxAge);
5144
+ if (maxAgeMs === null) {
5145
+ console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
5146
+ return branches;
3955
5147
  }
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)}`);
5148
+ const cutoffDate = new Date(Date.now() - maxAgeMs);
5149
+ return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
5150
+ }
5151
+ function formatDuration2(durationStr) {
5152
+ const match = durationStr.match(/^(\d+)([hdwmy])$/);
5153
+ if (!match) {
5154
+ return durationStr;
5155
+ }
5156
+ const value = parseInt(match[1], 10);
5157
+ const unit = match[2];
5158
+ const unitNames = {
5159
+ h: value === 1 ? "hour" : "hours",
5160
+ d: value === 1 ? "day" : "days",
5161
+ w: value === 1 ? "week" : "weeks",
5162
+ m: value === 1 ? "month" : "months",
5163
+ y: value === 1 ? "year" : "years"
5164
+ };
5165
+ return `${value} ${unitNames[unit]}`;
5166
+ }
5167
+
5168
+ // src/services/path-resolution.service.ts
5169
+ import { createHash as createHash2 } from "crypto";
5170
+ import * as fs8 from "fs";
5171
+ import * as path11 from "path";
5172
+ var BRANCH_STEM_MAX = 80;
5173
+ var BRANCH_HASH_LEN = 8;
5174
+ var PathResolutionService = class {
5175
+ sanitizeBranchName(branchName) {
5176
+ const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
5177
+ const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
5178
+ return `${stem}-${hash}`;
5179
+ }
5180
+ getBranchWorktreePath(worktreeDir, branchName) {
5181
+ return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
5182
+ }
5183
+ resolveRealPath(inputPath) {
5184
+ const absolute = path11.resolve(inputPath);
5185
+ const missing = [];
5186
+ let current = absolute;
5187
+ while (!fs8.existsSync(current)) {
5188
+ const parent = path11.dirname(current);
5189
+ if (parent === current) {
5190
+ return absolute;
3975
5191
  }
5192
+ missing.unshift(path11.basename(current));
5193
+ current = parent;
3976
5194
  }
3977
- }
3978
- emitProgress(event) {
3979
- for (const listener of this.progressListeners) {
3980
- try {
3981
- listener(event);
3982
- } catch {
3983
- }
5195
+ try {
5196
+ return path11.join(fs8.realpathSync(current), ...missing);
5197
+ } catch {
5198
+ return absolute;
3984
5199
  }
3985
5200
  }
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;
5201
+ isResolvedPathInsideBase(resolved, resolvedBase) {
5202
+ const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
5203
+ const a = fold(resolved);
5204
+ const b = fold(resolvedBase);
5205
+ if (a === b) return true;
5206
+ return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
4016
5207
  }
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
- };
5208
+ normalizeWorktreePath(worktreePath, worktreeBaseDir) {
5209
+ const resolved = this.resolveRealPath(worktreePath);
5210
+ const resolvedBase = this.resolveRealPath(worktreeBaseDir);
5211
+ if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
5212
+ throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
4025
5213
  }
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
5214
+ return path11.relative(resolvedBase, resolved);
5215
+ }
5216
+ isPathInsideBaseDir(targetPath, baseDir) {
5217
+ const resolved = this.resolveRealPath(targetPath);
5218
+ const resolvedBase = this.resolveRealPath(baseDir);
5219
+ return this.isResolvedPathInsideBase(resolved, resolvedBase);
5220
+ }
5221
+ extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
5222
+ return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
5223
+ }
5224
+ };
5225
+
5226
+ // src/services/worktree-sync-planner.ts
5227
+ import * as path12 from "path";
5228
+ function createWorktreeSyncPlan(inventory, options = {}) {
5229
+ return {
5230
+ create: planCreateActions(inventory, options),
5231
+ prune: planPruneActions(inventory),
5232
+ update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
5233
+ sparse: planSparseActions(inventory, options.sparseCheckout),
5234
+ warnings: []
5235
+ };
5236
+ }
5237
+ function planCreateActions(inventory, options = {}) {
5238
+ const pathResolution = options.pathResolution ?? new PathResolutionService();
5239
+ const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
5240
+ const newBranches = inventory.remoteBranches.filter(
5241
+ (branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
5242
+ );
5243
+ const reservedPaths = /* @__PURE__ */ new Map();
5244
+ for (const worktree of inventory.existingWorktrees) {
5245
+ reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
5246
+ }
5247
+ const actions = [];
5248
+ for (const branch of newBranches) {
5249
+ const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
5250
+ const resolved = path12.resolve(worktreePath);
5251
+ const conflictingBranch = reservedPaths.get(resolved);
5252
+ if (conflictingBranch && conflictingBranch !== branch) {
5253
+ actions.push({
5254
+ kind: "skip-create",
5255
+ branch,
5256
+ path: worktreePath,
5257
+ reason: "path-collision",
5258
+ conflictingBranch
4034
5259
  });
4035
- return release;
4036
- } catch (error) {
4037
- const code = error.code;
4038
- if (code === "ELOCKED") {
4039
- return null;
4040
- }
4041
- throw error;
5260
+ continue;
4042
5261
  }
5262
+ reservedPaths.set(resolved, branch);
5263
+ actions.push({ kind: "create", branch, path: worktreePath });
4043
5264
  }
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
- };
5265
+ return actions;
5266
+ }
5267
+ function planPruneActions(inventory) {
5268
+ const remoteBranches = new Set(inventory.remoteBranches);
5269
+ return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
5270
+ }
5271
+ function planUpdateActions(inventory) {
5272
+ const remoteBranches = new Set(inventory.remoteBranches);
5273
+ return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
5274
+ }
5275
+ function planSparseActions(inventory, sparseCheckout) {
5276
+ if (!sparseCheckout) {
5277
+ return [];
5278
+ }
5279
+ return inventory.existingWorktrees.map((worktree) => ({
5280
+ kind: "check-sparse",
5281
+ branch: worktree.branch,
5282
+ path: worktree.path
5283
+ }));
5284
+ }
5285
+
5286
+ // src/services/worktree-mode-sync-runner.ts
5287
+ var WorktreeModeSyncRunner = class {
5288
+ constructor(config, gitService, logger, progressEmitter) {
5289
+ this.config = config;
5290
+ this.gitService = gitService;
5291
+ this.logger = logger;
5292
+ this.progressEmitter = progressEmitter;
5293
+ }
5294
+ pathResolution = new PathResolutionService();
5295
+ updateLogger(logger) {
5296
+ this.logger = logger;
4070
5297
  }
4071
- async runSyncAttempt(phaseTimer, syncContext) {
5298
+ async runSyncAttempt(phaseTimer, syncContext, outcome) {
4072
5299
  await this.gitService.pruneWorktrees();
4073
5300
  await this.fetchLatestRemoteData(phaseTimer, syncContext);
4074
5301
  const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
4075
- await fs6.mkdir(this.config.worktreeDir, { recursive: true });
5302
+ await fs9.mkdir(this.config.worktreeDir, { recursive: true });
4076
5303
  const worktrees = await this.gitService.getWorktrees();
4077
5304
  this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
4078
5305
  await this.cleanupOrphanedDirectories(worktrees);
4079
- await this.createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer);
4080
- await this.pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer);
5306
+ const syncPlan = createWorktreeSyncPlan(
5307
+ {
5308
+ remoteBranches,
5309
+ defaultBranch,
5310
+ existingWorktrees: worktrees,
5311
+ worktreeDir: this.config.worktreeDir
5312
+ },
5313
+ {
5314
+ pathResolution: this.pathResolution,
5315
+ updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
5316
+ sparseCheckout: this.config.sparseCheckout
5317
+ }
5318
+ );
5319
+ await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
5320
+ await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
4081
5321
  if (this.config.updateExistingWorktrees !== false) {
4082
- await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
5322
+ await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
4083
5323
  }
4084
5324
  if (this.config.sparseCheckout) {
4085
- await this.reapplySparseCheckout(worktrees);
5325
+ await this.reapplySparseCheckout(syncPlan.sparse, outcome);
4086
5326
  }
4087
5327
  await this.finalizeSyncAttempt(phaseTimer);
4088
5328
  }
4089
- async reapplySparseCheckout(worktrees) {
5329
+ async reapplySparseCheckout(actions, outcome) {
4090
5330
  const sparseConfig = this.config.sparseCheckout;
4091
5331
  if (!sparseConfig) return;
4092
5332
  this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
@@ -4094,32 +5334,44 @@ var WorktreeSyncService = class {
4094
5334
  const desired = sparseService.buildPatterns(sparseConfig);
4095
5335
  const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4096
5336
  await Promise.all(
4097
- worktrees.map(
4098
- (worktree) => limit(async () => {
5337
+ actions.map(
5338
+ (action) => limit(async () => {
5339
+ if (action.kind !== "check-sparse") return;
4099
5340
  try {
4100
5341
  try {
4101
- await fs6.access(worktree.path);
5342
+ await fs9.access(action.path);
4102
5343
  } catch {
4103
5344
  return;
4104
5345
  }
4105
- const current = await sparseService.readCurrent(worktree.path);
5346
+ const current = await sparseService.readCurrent(action.path);
4106
5347
  if (current !== null && sparseService.patternsEqual(current, desired)) return;
4107
5348
  if (sparseService.isNarrowing(current, desired)) {
4108
- const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
5349
+ const status = await this.gitService.getFullWorktreeStatus(action.path, false);
4109
5350
  if (!status.canRemove) {
4110
5351
  this.logger.warn(
4111
- ` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
5352
+ ` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
4112
5353
  );
5354
+ outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
5355
+ branch: action.branch,
5356
+ path: action.path,
5357
+ message: status.reasons.join(", ")
5358
+ });
4113
5359
  return;
4114
5360
  }
4115
5361
  }
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}'`);
5362
+ await sparseService.applyToWorktree(action.path, sparseConfig);
5363
+ await this.gitService.checkoutHead(action.path);
5364
+ this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
5365
+ outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
4119
5366
  } catch (error) {
4120
5367
  this.logger.warn(
4121
- ` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
5368
+ ` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
4122
5369
  );
5370
+ outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
5371
+ reason: "sparse_checkout_failed",
5372
+ branch: action.branch,
5373
+ path: action.path
5374
+ });
4123
5375
  }
4124
5376
  })
4125
5377
  )
@@ -4128,7 +5380,7 @@ var WorktreeSyncService = class {
4128
5380
  async fetchLatestRemoteData(phaseTimer, syncContext) {
4129
5381
  this.logger.info("Step 1: Fetching latest data from remote...");
4130
5382
  phaseTimer.startPhase("Phase 1: Fetch");
4131
- this.emitProgress({ phase: "fetch", message: "Fetching latest data from remote" });
5383
+ this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
4132
5384
  try {
4133
5385
  await this.gitService.fetchAll();
4134
5386
  } catch (fetchError) {
@@ -4173,7 +5425,7 @@ var WorktreeSyncService = class {
4173
5425
  const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
4174
5426
  const remoteBranches = filteredBranches.map((b) => b.branch);
4175
5427
  this.logger.info(
4176
- `After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
5428
+ `After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
4177
5429
  );
4178
5430
  if (filteredByName.length > remoteBranches.length) {
4179
5431
  const excludedCount = filteredByName.length - remoteBranches.length;
@@ -4192,45 +5444,38 @@ var WorktreeSyncService = class {
4192
5444
  }
4193
5445
  async finalizeSyncAttempt(phaseTimer) {
4194
5446
  phaseTimer.startPhase("Phase 5: Cleanup");
4195
- this.emitProgress({ phase: "cleanup", message: "Pruning worktree metadata" });
5447
+ this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
4196
5448
  await this.gitService.pruneWorktrees();
4197
5449
  this.logger.info("Step 5: Pruned worktree metadata.");
4198
5450
  phaseTimer.endPhase();
4199
5451
  }
4200
- async createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer) {
5452
+ async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
4201
5453
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
4202
5454
  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);
5455
+ this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
5456
+ await this.createNewWorktrees(syncPlan.create, outcome);
5457
+ phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
4208
5458
  phaseTimer.endPhase();
4209
5459
  }
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) {
5460
+ async createNewWorktrees(actions, outcome) {
5461
+ if (actions.length === 0) {
4214
5462
  this.logger.info("Step 2: No new branches to create worktrees for.");
4215
5463
  return;
4216
5464
  }
4217
- const reservedPaths = /* @__PURE__ */ new Map();
4218
- for (const w of worktrees) {
4219
- reservedPaths.set(path8.resolve(w.path), w.branch);
4220
- }
4221
5465
  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) {
5466
+ for (const action of actions) {
5467
+ if (action.kind === "skip-create") {
4227
5468
  this.logger.error(
4228
- ` \u274C Skipping '${branchName}': sanitized worktree path '${worktreePath}' collides with existing branch '${conflict}'.`
5469
+ ` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
4229
5470
  );
5471
+ outcome.recordSkipped("branch", "path_collision", {
5472
+ branch: action.branch,
5473
+ path: action.path,
5474
+ message: `Path collides with existing branch '${action.conflictingBranch}'`
5475
+ });
4230
5476
  continue;
4231
5477
  }
4232
- reservedPaths.set(resolved, branchName);
4233
- plan.push({ branchName, worktreePath });
5478
+ plan.push({ branchName: action.branch, worktreePath: action.path });
4234
5479
  }
4235
5480
  this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
4236
5481
  const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
@@ -4241,8 +5486,14 @@ var WorktreeSyncService = class {
4241
5486
  try {
4242
5487
  await this.gitService.addWorktree(branchName, worktreePath);
4243
5488
  this.logger.info(` \u2705 Created worktree for '${branchName}'`);
5489
+ outcome.recordCreated(branchName, worktreePath);
4244
5490
  } catch (error) {
4245
5491
  this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
5492
+ outcome.recordFailed("worktree", getErrorMessage(error), {
5493
+ reason: "create_failed",
5494
+ branch: branchName,
5495
+ path: worktreePath
5496
+ });
4246
5497
  throw error;
4247
5498
  }
4248
5499
  })
@@ -4251,23 +5502,21 @@ var WorktreeSyncService = class {
4251
5502
  const successCount = results.filter((r) => r.status === "fulfilled").length;
4252
5503
  this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
4253
5504
  }
4254
- async pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer) {
5505
+ async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
4255
5506
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4256
5507
  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);
5508
+ this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
5509
+ await this.pruneOldWorktrees(actions, outcome);
5510
+ phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
4261
5511
  phaseTimer.endPhase();
4262
5512
  }
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...`);
5513
+ async pruneOldWorktrees(actions, outcome) {
5514
+ if (actions.length > 0) {
5515
+ this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
4267
5516
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4268
5517
  const limit = pLimit(maxConcurrent);
4269
5518
  const statusResults = await Promise.allSettled(
4270
- deletedWorktrees.map(
5519
+ actions.map(
4271
5520
  ({ branch: branchName, path: worktreePath }) => limit(async () => {
4272
5521
  const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
4273
5522
  return { branchName, worktreePath, status };
@@ -4290,6 +5539,10 @@ var WorktreeSyncService = class {
4290
5539
  const branchName = result.reason?.branchName ?? "unknown";
4291
5540
  this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
4292
5541
  this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
5542
+ outcome.recordSkipped("worktree", "prune_status_check_failed", {
5543
+ branch: branchName,
5544
+ message: getErrorMessage(result.reason)
5545
+ });
4293
5546
  }
4294
5547
  }
4295
5548
  if (toRemove.length > 0) {
@@ -4305,12 +5558,23 @@ var WorktreeSyncService = class {
4305
5558
  this.logger.warn(
4306
5559
  ` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
4307
5560
  );
5561
+ outcome.recordSkipped("worktree", "prune_status_changed", {
5562
+ branch: branchName,
5563
+ path: worktreePath,
5564
+ message: recheck.reasons.join(", ")
5565
+ });
4308
5566
  return;
4309
5567
  }
4310
5568
  await this.gitService.removeWorktree(worktreePath);
4311
5569
  this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
5570
+ outcome.recordRemoved(branchName, worktreePath);
4312
5571
  } catch (error) {
4313
5572
  this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
5573
+ outcome.recordFailed("worktree", getErrorMessage(error), {
5574
+ reason: "remove_failed",
5575
+ branch: branchName,
5576
+ path: worktreePath
5577
+ });
4314
5578
  throw error;
4315
5579
  }
4316
5580
  })
@@ -4323,6 +5587,11 @@ var WorktreeSyncService = class {
4323
5587
  this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
4324
5588
  }
4325
5589
  for (const { branchName, worktreePath, status } of toSkip) {
5590
+ outcome.recordSkipped("worktree", "unsafe_to_remove", {
5591
+ branch: branchName,
5592
+ path: worktreePath,
5593
+ message: status.reasons.join(", ")
5594
+ });
4326
5595
  if (status.upstreamGone && status.hasUnpushedCommits) {
4327
5596
  this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
4328
5597
  this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
@@ -4415,53 +5684,52 @@ var WorktreeSyncService = class {
4415
5684
  this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
4416
5685
  }
4417
5686
  }
4418
- async updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer) {
5687
+ async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
4419
5688
  const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
4420
5689
  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);
5690
+ this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
5691
+ await this.updateExistingWorktrees(actions, outcome);
5692
+ phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
4425
5693
  phaseTimer.endPhase();
4426
5694
  }
4427
- async updateExistingWorktrees(worktrees, remoteBranches) {
5695
+ async updateExistingWorktrees(actions, outcome) {
4428
5696
  this.logger.info("Step 4: Checking for worktrees that need updates...");
4429
- const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5697
+ const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4430
5698
  try {
4431
- const diverged = await fs6.readdir(divergedDir);
5699
+ const diverged = await fs9.readdir(divergedDir);
4432
5700
  if (diverged.length > 0) {
4433
5701
  this.logger.info(
4434
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
5702
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
4435
5703
  );
4436
5704
  }
4437
5705
  } catch {
4438
5706
  }
4439
- const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
4440
5707
  const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
4441
5708
  const limit = pLimit(maxConcurrent);
4442
5709
  const checkResults = await Promise.allSettled(
4443
- activeWorktrees.map(
4444
- (worktree) => limit(async () => {
5710
+ actions.map(
5711
+ (action) => limit(async () => {
5712
+ const worktree = { path: action.path, branch: action.branch };
4445
5713
  try {
4446
- await fs6.access(worktree.path);
5714
+ await fs9.access(worktree.path);
4447
5715
  } catch {
4448
- return null;
5716
+ return { action: "skip", worktree, reason: "missing_worktree_path" };
4449
5717
  }
4450
5718
  const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
4451
- if (hasOp) return null;
5719
+ if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
4452
5720
  const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
4453
- if (!isClean) return null;
5721
+ if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
4454
5722
  const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
4455
5723
  if (!canFastForward) {
4456
5724
  const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
4457
5725
  if (isAhead) {
4458
5726
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
4459
- return null;
5727
+ return { action: "skip", worktree, reason: "local_ahead" };
4460
5728
  }
4461
5729
  return { action: "diverged", worktree };
4462
5730
  }
4463
5731
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
4464
- if (!isBehind) return null;
5732
+ if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
4465
5733
  const sparseCfg = this.config.sparseCheckout;
4466
5734
  if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
4467
5735
  const sparseService = this.gitService.getSparseCheckoutService();
@@ -4473,7 +5741,7 @@ var WorktreeSyncService = class {
4473
5741
  );
4474
5742
  if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
4475
5743
  this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
4476
- return null;
5744
+ return { action: "skip", worktree, reason: "outside_sparse_checkout" };
4477
5745
  }
4478
5746
  }
4479
5747
  }
@@ -4485,13 +5753,25 @@ var WorktreeSyncService = class {
4485
5753
  const divergedWorktrees = [];
4486
5754
  for (const result of checkResults) {
4487
5755
  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);
5756
+ switch (result.value.action) {
5757
+ case "update":
5758
+ worktreesToUpdate.push(result.value.worktree);
5759
+ break;
5760
+ case "diverged":
5761
+ divergedWorktrees.push(result.value.worktree);
5762
+ break;
5763
+ case "noop":
5764
+ outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
5765
+ break;
5766
+ case "skip":
5767
+ outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
5768
+ break;
4492
5769
  }
4493
5770
  } else if (result.status === "rejected") {
4494
5771
  this.logger.error(` - Error checking worktree:`, result.reason);
5772
+ outcome.recordSkipped("worktree", "update_check_failed", {
5773
+ message: getErrorMessage(result.reason)
5774
+ });
4495
5775
  }
4496
5776
  }
4497
5777
  const updateLimit = pLimit(
@@ -4505,6 +5785,7 @@ var WorktreeSyncService = class {
4505
5785
  this.logger.info(` - Updating worktree '${worktree.branch}'...`);
4506
5786
  await this.gitService.updateWorktree(worktree.path);
4507
5787
  this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
5788
+ outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
4508
5789
  } catch (error) {
4509
5790
  const errorMessage = getErrorMessage(error);
4510
5791
  if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
@@ -4512,13 +5793,23 @@ var WorktreeSyncService = class {
4512
5793
  ` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
4513
5794
  );
4514
5795
  try {
4515
- await this.handleDivergedBranch(worktree);
5796
+ await this.handleDivergedBranch(worktree, outcome);
4516
5797
  } catch (divergedError) {
4517
5798
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
5799
+ outcome.recordFailed("worktree", getErrorMessage(divergedError), {
5800
+ reason: "diverged_recovery_failed",
5801
+ branch: worktree.branch,
5802
+ path: worktree.path
5803
+ });
4518
5804
  throw divergedError;
4519
5805
  }
4520
5806
  } else {
4521
5807
  this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
5808
+ outcome.recordFailed("worktree", errorMessage, {
5809
+ reason: "update_failed",
5810
+ branch: worktree.branch,
5811
+ path: worktree.path
5812
+ });
4522
5813
  throw error;
4523
5814
  }
4524
5815
  }
@@ -4530,9 +5821,14 @@ var WorktreeSyncService = class {
4530
5821
  mutationTasks.push(
4531
5822
  updateLimit(async () => {
4532
5823
  try {
4533
- await this.handleDivergedBranch(worktree);
5824
+ await this.handleDivergedBranch(worktree, outcome);
4534
5825
  } catch (error) {
4535
5826
  this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
5827
+ outcome.recordFailed("worktree", getErrorMessage(error), {
5828
+ reason: "diverged_recovery_failed",
5829
+ branch: worktree.branch,
5830
+ path: worktree.path
5831
+ });
4536
5832
  throw error;
4537
5833
  }
4538
5834
  return { type: "diverged", branch: worktree.branch };
@@ -4555,13 +5851,13 @@ var WorktreeSyncService = class {
4555
5851
  }
4556
5852
  async cleanupOrphanedDirectories(worktrees) {
4557
5853
  try {
4558
- const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
4559
- const allDirs = await fs6.readdir(this.config.worktreeDir);
5854
+ const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
5855
+ const allDirs = await fs9.readdir(this.config.worktreeDir);
4560
5856
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
4561
5857
  const orphanedDirs = [];
4562
5858
  for (const dir of regularDirs) {
4563
5859
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
4564
- return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
5860
+ return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
4565
5861
  });
4566
5862
  if (!isPartOfWorktree) {
4567
5863
  orphanedDirs.push(dir);
@@ -4570,11 +5866,11 @@ var WorktreeSyncService = class {
4570
5866
  if (orphanedDirs.length > 0) {
4571
5867
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
4572
5868
  for (const dir of orphanedDirs) {
4573
- const dirPath = path8.join(this.config.worktreeDir, dir);
5869
+ const dirPath = path13.join(this.config.worktreeDir, dir);
4574
5870
  try {
4575
- const stat3 = await fs6.stat(dirPath);
5871
+ const stat3 = await fs9.stat(dirPath);
4576
5872
  if (stat3.isDirectory()) {
4577
- await fs6.rm(dirPath, { recursive: true, force: true });
5873
+ await fs9.rm(dirPath, { recursive: true, force: true });
4578
5874
  this.logger.info(` - Removed orphaned directory: ${dir}`);
4579
5875
  }
4580
5876
  } catch (error) {
@@ -4586,13 +5882,14 @@ var WorktreeSyncService = class {
4586
5882
  this.logger.error("Error during orphaned directory cleanup:", error);
4587
5883
  }
4588
5884
  }
4589
- async handleDivergedBranch(worktree) {
5885
+ async handleDivergedBranch(worktree, outcome) {
4590
5886
  this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
4591
5887
  const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
4592
5888
  if (treesIdentical) {
4593
5889
  this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
4594
5890
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
4595
5891
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
5892
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
4596
5893
  } else {
4597
5894
  const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
4598
5895
  if (!hasLocalChanges) {
@@ -4601,10 +5898,12 @@ var WorktreeSyncService = class {
4601
5898
  );
4602
5899
  await this.gitService.resetToUpstream(worktree.path, worktree.branch);
4603
5900
  this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
5901
+ outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
4604
5902
  } else {
4605
5903
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
4606
5904
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
4607
- const relativePath = path8.relative(process.cwd(), divergedPath);
5905
+ const relativePath = path13.relative(process.cwd(), divergedPath);
5906
+ outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
4608
5907
  this.logger.info(` Moved to: ${relativePath}`);
4609
5908
  this.logger.info(` Your local changes are preserved. To review:`);
4610
5909
  this.logger.info(` cd ${relativePath}`);
@@ -4628,19 +5927,19 @@ var WorktreeSyncService = class {
4628
5927
  }
4629
5928
  }
4630
5929
  async divergeWorktree(worktreePath, branchName) {
4631
- const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5930
+ const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4632
5931
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4633
5932
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
4634
5933
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
4635
5934
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
4636
- const divergedPath = path8.join(divergedBaseDir, divergedName);
4637
- await fs6.mkdir(divergedBaseDir, { recursive: true });
5935
+ const divergedPath = path13.join(divergedBaseDir, divergedName);
5936
+ await fs9.mkdir(divergedBaseDir, { recursive: true });
4638
5937
  try {
4639
- await fs6.rename(worktreePath, divergedPath);
5938
+ await fs9.rename(worktreePath, divergedPath);
4640
5939
  } catch (err) {
4641
5940
  if (err.code === ERROR_MESSAGES.EXDEV) {
4642
- await fs6.cp(worktreePath, divergedPath, { recursive: true });
4643
- await fs6.rm(worktreePath, { recursive: true, force: true });
5941
+ await fs9.cp(worktreePath, divergedPath, { recursive: true });
5942
+ await fs9.rm(worktreePath, { recursive: true, force: true });
4644
5943
  } else {
4645
5944
  throw err;
4646
5945
  }
@@ -4659,89 +5958,194 @@ var WorktreeSyncService = class {
4659
5958
 
4660
5959
  Original worktree location: ${worktreePath}`
4661
5960
  };
4662
- await fs6.writeFile(
4663
- path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
5961
+ await fs9.writeFile(
5962
+ path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4664
5963
  JSON.stringify(metadata, null, 2)
4665
5964
  );
4666
5965
  return divergedPath;
4667
5966
  }
4668
5967
  };
4669
5968
 
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;
5969
+ // src/services/worktree-sync.service.ts
5970
+ var WorktreeSyncService = class {
5971
+ constructor(config) {
5972
+ this.config = config;
5973
+ this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
5974
+ this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
5975
+ this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
5976
+ this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
5977
+ this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
5978
+ config,
5979
+ this.gitService,
5980
+ this.logger,
5981
+ this.progressEmitter
5982
+ );
5983
+ if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
5984
+ this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
5985
+ progressEmitter: (event) => this.emitProgress(event),
5986
+ onSkip: (reason) => {
5987
+ this.skipsAccumulator.push(reason);
5988
+ }
5989
+ });
4696
5990
  }
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);
5991
+ }
5992
+ gitService;
5993
+ cloneSyncService = null;
5994
+ logger;
5995
+ syncInProgress = false;
5996
+ progressEmitter = new ProgressEmitter();
5997
+ repoOperationLock;
5998
+ retryPolicy;
5999
+ worktreeModeSyncRunner;
6000
+ skipsAccumulator = [];
6001
+ lastOutcome = null;
6002
+ getRecordedSkips() {
6003
+ return [...this.skipsAccumulator];
6004
+ }
6005
+ clearRecordedSkips() {
6006
+ this.skipsAccumulator = [];
6007
+ }
6008
+ clearPendingInitSkip() {
6009
+ this.cloneSyncService?.clearPendingInitSkip();
6010
+ }
6011
+ getLastOutcome() {
6012
+ return this.lastOutcome;
6013
+ }
6014
+ isCloneMode() {
6015
+ return this.cloneSyncService !== null;
6016
+ }
6017
+ async getWorktrees() {
6018
+ if (this.cloneSyncService) {
6019
+ return this.cloneSyncService.getWorktrees();
6020
+ }
6021
+ return this.gitService.getWorktrees();
6022
+ }
6023
+ async initialize() {
6024
+ if (this.isInitialized()) return;
6025
+ const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
6026
+ if (!result.started) {
6027
+ const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
6028
+ this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
6029
+ }
6030
+ }
6031
+ async initializeUnlocked(outcome) {
6032
+ this.emitProgress({ phase: "initialize", message: "Initializing repository" });
6033
+ if (this.cloneSyncService) {
6034
+ await this.cloneSyncService.initialize(outcome);
6035
+ } else {
6036
+ await this.gitService.initialize();
6037
+ }
6038
+ this.emitProgress({ phase: "initialize", message: "Repository initialized" });
6039
+ }
6040
+ isInitialized() {
6041
+ if (this.cloneSyncService) {
6042
+ return this.cloneSyncService.isInitialized();
6043
+ }
6044
+ return this.gitService.isInitialized();
6045
+ }
6046
+ isSyncInProgress() {
6047
+ return this.syncInProgress;
6048
+ }
6049
+ getGitService() {
6050
+ return this.gitService;
6051
+ }
6052
+ updateLogger(logger) {
6053
+ this.logger = logger;
6054
+ this.gitService.updateLogger(logger);
6055
+ this.cloneSyncService?.updateLogger(logger);
6056
+ this.retryPolicy.updateLogger(logger);
6057
+ this.worktreeModeSyncRunner.updateLogger(logger);
6058
+ this.repoOperationLock.updateLogger(logger);
6059
+ }
6060
+ onProgress(listener) {
6061
+ return this.progressEmitter.onProgress(listener);
6062
+ }
6063
+ async runExclusiveRepoOperation(operation) {
6064
+ if (this.syncInProgress) {
6065
+ this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
6066
+ return { started: false, reason: "in_progress" };
6067
+ }
6068
+ this.syncInProgress = true;
6069
+ let release;
6070
+ try {
6071
+ release = await this.repoOperationLock.acquire();
6072
+ } catch (error) {
6073
+ this.syncInProgress = false;
6074
+ throw error;
6075
+ }
6076
+ if (release === null) {
6077
+ this.syncInProgress = false;
6078
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
6079
+ return { started: false, reason: "locked" };
6080
+ }
6081
+ try {
6082
+ return { started: true, value: await operation() };
6083
+ } finally {
4701
6084
  try {
4702
- const copied = await this.copyFile(sourcePath, destPath);
4703
- if (copied) {
4704
- result.copied.push(relativePath);
4705
- } else {
4706
- result.skipped.push(relativePath);
4707
- }
4708
- } catch (error) {
4709
- result.errors.push({
4710
- file: relativePath,
4711
- error: error instanceof Error ? error.message : String(error)
4712
- });
6085
+ await release();
6086
+ } catch (releaseError) {
6087
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
4713
6088
  }
6089
+ this.syncInProgress = false;
4714
6090
  }
4715
- return result;
4716
6091
  }
4717
- async expandPatterns(sourceDir, patterns) {
4718
- const allFiles = /* @__PURE__ */ new Set();
4719
- for (const pattern of patterns) {
6092
+ emitProgress(event) {
6093
+ this.progressEmitter.emit(event);
6094
+ }
6095
+ async sync() {
6096
+ const result = await this.runExclusiveRepoOperation(async () => {
6097
+ const totalTimer = new Timer();
6098
+ const phaseTimer = new PhaseTimer();
6099
+ const outcome = new SyncOutcomeAccumulator({
6100
+ mode: this.cloneSyncService ? "clone" : "worktree",
6101
+ repoName: this.config.name
6102
+ });
6103
+ const syncContext = this.retryPolicy.createContext();
6104
+ const retryOptions = this.retryPolicy.createOptions(syncContext);
6105
+ let durationMs;
4720
6106
  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);
6107
+ if (!this.isInitialized()) {
6108
+ await this.initializeUnlocked(outcome);
6109
+ }
6110
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
6111
+ const retryOutcomeBaseline = outcome.snapshot();
6112
+ const retryOptionsWithOutcomeReset = {
6113
+ ...retryOptions,
6114
+ onRetry: (error, attempt, context) => {
6115
+ outcome.restore(retryOutcomeBaseline);
6116
+ retryOptions.onRetry?.(error, attempt, context);
6117
+ }
6118
+ };
6119
+ const cloneSync = this.cloneSyncService;
6120
+ if (cloneSync) {
6121
+ await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
6122
+ } else {
6123
+ await retry(
6124
+ () => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
6125
+ retryOptionsWithOutcomeReset
6126
+ );
6127
+ }
6128
+ } catch (error) {
6129
+ if (outcome.getCounts().failed === 0) {
6130
+ outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
6131
+ }
6132
+ this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
6133
+ throw error;
6134
+ } finally {
6135
+ this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
6136
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
6137
+ `);
6138
+ durationMs = totalTimer.stop();
6139
+ this.lastOutcome = outcome.toOutcome(durationMs);
6140
+ if (this.config.debug) {
6141
+ const phaseResults = phaseTimer.getResults();
6142
+ const repoName = this.config.name;
6143
+ this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
4729
6144
  }
4730
- } catch {
4731
6145
  }
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;
6146
+ return this.lastOutcome ?? outcome.toOutcome(durationMs);
6147
+ });
6148
+ return result.started ? { started: true, outcome: result.value } : result;
4745
6149
  }
4746
6150
  };
4747
6151
 
@@ -4874,7 +6278,7 @@ var HookExecutionService = class {
4874
6278
  // src/utils/disk-space.ts
4875
6279
  import fastFolderSize from "fast-folder-size";
4876
6280
  async function calculateDirectorySize(dirPath) {
4877
- return new Promise((resolve9, reject) => {
6281
+ return new Promise((resolve12, reject) => {
4878
6282
  fastFolderSize(dirPath, (err, bytes) => {
4879
6283
  if (err) {
4880
6284
  reject(err);
@@ -4884,7 +6288,7 @@ async function calculateDirectorySize(dirPath) {
4884
6288
  reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
4885
6289
  return;
4886
6290
  }
4887
- resolve9(bytes);
6291
+ resolve12(bytes);
4888
6292
  });
4889
6293
  });
4890
6294
  }
@@ -4955,7 +6359,7 @@ var AppEventEmitter = class {
4955
6359
  };
4956
6360
 
4957
6361
  // src/services/InteractiveUIService.tsx
4958
- import * as fs8 from "fs/promises";
6362
+ import * as fs10 from "fs/promises";
4959
6363
  var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
4960
6364
  var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
4961
6365
  var InteractiveUIService = class {
@@ -4968,15 +6372,17 @@ var InteractiveUIService = class {
4968
6372
  logBuffer = [];
4969
6373
  uiReady = false;
4970
6374
  hookExecutionService = new HookExecutionService();
6375
+ branchCreatedActions = new BranchCreatedActionsService();
4971
6376
  pathResolution = new PathResolutionService();
4972
6377
  limit;
6378
+ maxProgressLines;
4973
6379
  reloadInProgress = false;
4974
6380
  isDestroyed = false;
4975
- reloadOptions;
4976
6381
  events;
4977
6382
  ownsEvents;
4978
6383
  unsubscribeCallbacks = [];
4979
- constructor(syncServices, configPath, cronSchedule, maxParallel, reloadOptions, events) {
6384
+ progressUnsubscribers = [];
6385
+ constructor(syncServices, configPath, cronSchedule, maxParallel, events) {
4980
6386
  this.ownsEvents = events === void 0;
4981
6387
  this.events = events ?? new AppEventEmitter();
4982
6388
  if (syncServices.length === 0) {
@@ -4986,10 +6392,11 @@ var InteractiveUIService = class {
4986
6392
  this.configPath = configPath;
4987
6393
  this.cronSchedule = cronSchedule;
4988
6394
  this.repositoryCount = syncServices.length;
4989
- this.limit = pLimit2(maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
4990
- this.reloadOptions = reloadOptions ?? {};
6395
+ this.maxProgressLines = Math.max(1, maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
6396
+ this.limit = pLimit2(this.maxProgressLines);
4991
6397
  this.startBufferFlushCheck();
4992
6398
  this.renderUI();
6399
+ this.subscribeToServiceProgress();
4993
6400
  this.injectLoggersIntoServices();
4994
6401
  setTimeout(() => {
4995
6402
  this.addLog("\u{1F680} sync-worktrees UI initialized", "info");
@@ -5027,6 +6434,26 @@ var InteractiveUIService = class {
5027
6434
  );
5028
6435
  }
5029
6436
  }
6437
+ subscribeToServiceProgress() {
6438
+ for (const unsubscribe of this.progressUnsubscribers) {
6439
+ unsubscribe();
6440
+ }
6441
+ this.progressUnsubscribers = this.syncServices.map((service, index) => {
6442
+ const repoName = this.getRepoName(index);
6443
+ if (!service.onProgress) return () => void 0;
6444
+ return service.onProgress((event) => {
6445
+ if (this.isDestroyed) return;
6446
+ this.events.emit("setSyncProgress", {
6447
+ repo: repoName,
6448
+ phase: event.phase,
6449
+ message: event.message,
6450
+ progress: event.progress,
6451
+ processed: event.processed,
6452
+ total: event.total
6453
+ });
6454
+ });
6455
+ });
6456
+ }
5030
6457
  addLog(message, level = "info") {
5031
6458
  if (this.isDestroyed) return;
5032
6459
  if (this.uiReady) {
@@ -5045,15 +6472,15 @@ var InteractiveUIService = class {
5045
6472
  const scheduleGroups = /* @__PURE__ */ new Map();
5046
6473
  for (const service of this.syncServices) {
5047
6474
  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, []);
6475
+ const schedule2 = service.config.cronSchedule || this.cronSchedule;
6476
+ if (!schedule2) continue;
6477
+ if (!scheduleGroups.has(schedule2)) {
6478
+ scheduleGroups.set(schedule2, []);
5052
6479
  }
5053
- scheduleGroups.get(schedule3).push(service);
6480
+ scheduleGroups.get(schedule2).push(service);
5054
6481
  }
5055
- for (const [schedule3, services] of scheduleGroups) {
5056
- const task = cron2.schedule(schedule3, async () => {
6482
+ for (const [schedule2, services] of scheduleGroups) {
6483
+ const task = cron2.schedule(schedule2, async () => {
5057
6484
  await this.runSyncCycle(services, { logErrors: false });
5058
6485
  });
5059
6486
  this.cronJobs.push(task);
@@ -5079,6 +6506,7 @@ var InteractiveUIService = class {
5079
6506
  events: this.events,
5080
6507
  repositoryCount: this.repositoryCount,
5081
6508
  cronSchedule: this.cronSchedule,
6509
+ maxProgressLines: this.maxProgressLines,
5082
6510
  onManualSync: () => this.handleManualSync(),
5083
6511
  onReload: () => this.handleReload(),
5084
6512
  onQuit: () => this.handleQuit(),
@@ -5089,10 +6517,11 @@ var InteractiveUIService = class {
5089
6517
  createAndPushBranch: (repoIndex, baseBranch, branchName) => this.createAndPushBranch(repoIndex, baseBranch, branchName),
5090
6518
  getWorktreesForRepo: (index) => this.getWorktreesForRepo(index),
5091
6519
  getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
6520
+ getRepositoryDiskUsage: (index) => this.getRepositoryDiskUsage(index),
5092
6521
  getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
5093
6522
  deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
5094
- openEditorInWorktree: (path14) => this.openEditorInWorktree(path14),
5095
- openTerminalInWorktree: (repoIndex, path14, branchName) => this.openTerminalInWorktree(repoIndex, path14, branchName),
6523
+ openEditorInWorktree: (path18) => this.openEditorInWorktree(path18),
6524
+ openTerminalInWorktree: (repoIndex, path18, branchName) => this.openTerminalInWorktree(repoIndex, path18, branchName),
5096
6525
  copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
5097
6526
  createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
5098
6527
  executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
@@ -5121,24 +6550,28 @@ var InteractiveUIService = class {
5121
6550
  this.addLog("Reloading configuration...");
5122
6551
  this.setStatus("syncing");
5123
6552
  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
- });
6553
+ const { repositories } = await configLoader.buildRepositories(this.configPath);
5129
6554
  const initResults = await Promise.allSettled(
5130
6555
  repositories.map(
5131
6556
  (repoConfig) => this.limit(async () => {
5132
6557
  const service = new WorktreeSyncService(repoConfig);
5133
6558
  await service.initialize();
5134
- return service;
6559
+ return {
6560
+ service,
6561
+ clonePhaseSkips: service.getRecordedSkips().map((reason) => ({
6562
+ repo: repoConfig.name || repoConfig.repoUrl,
6563
+ reason: formatCloneSkipReason(reason)
6564
+ }))
6565
+ };
5135
6566
  })
5136
6567
  )
5137
6568
  );
5138
6569
  const newServices = [];
6570
+ const initClonePhaseSkips = [];
5139
6571
  for (const result of initResults) {
5140
6572
  if (result.status === "fulfilled") {
5141
- newServices.push(result.value);
6573
+ newServices.push(result.value.service);
6574
+ initClonePhaseSkips.push(...result.value.clonePhaseSkips);
5142
6575
  } else {
5143
6576
  this.addLog(`Failed to initialize repository: ${result.reason}`, "error");
5144
6577
  }
@@ -5150,18 +6583,31 @@ var InteractiveUIService = class {
5150
6583
  cronJobsCancelled = true;
5151
6584
  this.syncServices = newServices;
5152
6585
  this.repositoryCount = this.syncServices.length;
6586
+ this.subscribeToServiceProgress();
5153
6587
  this.injectLoggersIntoServices();
5154
6588
  const uniqueSchedules = [...new Set(this.syncServices.map((s) => s.config.cronSchedule))];
5155
6589
  this.cronSchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
5156
6590
  this.setupCronJobs();
5157
6591
  this.events.emit("updateRepositoryCount", this.repositoryCount);
5158
6592
  this.events.emit("updateCronSchedule", this.cronSchedule);
5159
- const { failures, skipped, attempted } = await this.runSyncServices(this.syncServices);
6593
+ const {
6594
+ failures,
6595
+ skipped,
6596
+ clonePhaseSkips: syncClonePhaseSkips,
6597
+ attempted
6598
+ } = await this.runSyncServices(this.syncServices);
6599
+ const clonePhaseSkips = [...initClonePhaseSkips, ...syncClonePhaseSkips];
5160
6600
  await this.recordSyncOutcome({ failures, skipped, attempted });
5161
6601
  this.setStatus("idle");
5162
6602
  for (const skip of skipped) {
5163
6603
  this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
5164
6604
  }
6605
+ for (const skip of clonePhaseSkips) {
6606
+ this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
6607
+ }
6608
+ if (clonePhaseSkips.length > 0) {
6609
+ this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) during reload`, "warn");
6610
+ }
5165
6611
  if (failures.length > 0) {
5166
6612
  for (const failure of failures) {
5167
6613
  this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
@@ -5195,14 +6641,14 @@ var InteractiveUIService = class {
5195
6641
  if (Date.now() - startTime > timeoutMs) {
5196
6642
  throw new Error("Timeout waiting for sync operations to complete");
5197
6643
  }
5198
- await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
6644
+ await new Promise((resolve12) => setTimeout(resolve12, checkInterval));
5199
6645
  }
5200
6646
  });
5201
6647
  try {
5202
6648
  await Promise.all(syncChecks);
5203
6649
  } catch {
5204
6650
  this.addLog(
5205
- `Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
6651
+ `Warning: Timeout waiting for sync operations to complete after ${formatDuration(timeoutMs)}. Proceeding with potential data loss risk.`,
5206
6652
  "warn"
5207
6653
  );
5208
6654
  }
@@ -5214,6 +6660,9 @@ var InteractiveUIService = class {
5214
6660
  setStatus(status) {
5215
6661
  if (this.isDestroyed) return;
5216
6662
  this.events.emit("setStatus", status);
6663
+ if (status === "idle") {
6664
+ this.events.emit("setSyncProgress", null);
6665
+ }
5217
6666
  }
5218
6667
  setDiskSpace(diskSpace) {
5219
6668
  if (this.isDestroyed) return;
@@ -5243,6 +6692,45 @@ var InteractiveUIService = class {
5243
6692
  const service = this.syncServices[index];
5244
6693
  return service.config.name || `repo-${index}`;
5245
6694
  }
6695
+ async getRepositoryDiskUsage(repoIndex) {
6696
+ if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
6697
+ throw new Error(`Invalid repository index: ${repoIndex}`);
6698
+ }
6699
+ const service = this.syncServices[repoIndex];
6700
+ const config = service.config;
6701
+ const repoName = this.getRepoName(repoIndex);
6702
+ const mode = resolveMode(config);
6703
+ const sizeTargets = [
6704
+ ...mode === "worktree" ? [{ kind: "bare", path: config.bareRepoDir || getDefaultBareRepoDir(config.repoUrl) }] : [],
6705
+ { kind: "worktree", path: config.worktreeDir }
6706
+ ];
6707
+ let bareSizeBytes = 0;
6708
+ let worktreeSizeBytes = 0;
6709
+ const errors = [];
6710
+ for (const target of sizeTargets) {
6711
+ try {
6712
+ const size = await calculateDirectorySize(target.path);
6713
+ if (target.kind === "bare") {
6714
+ bareSizeBytes = size;
6715
+ } else {
6716
+ worktreeSizeBytes = size;
6717
+ }
6718
+ } catch (error) {
6719
+ errors.push(`${target.path}: ${error instanceof Error ? error.message : String(error)}`);
6720
+ }
6721
+ }
6722
+ const sizeBytes = bareSizeBytes + worktreeSizeBytes;
6723
+ const failedAllPaths = errors.length === sizeTargets.length;
6724
+ return {
6725
+ repoIndex,
6726
+ repoName,
6727
+ sizeBytes: failedAllPaths ? null : sizeBytes,
6728
+ sizeFormatted: failedAllPaths ? "N/A" : formatBytes(sizeBytes),
6729
+ bareSizeBytes,
6730
+ worktreeSizeBytes,
6731
+ error: errors.length > 0 ? errors.join("; ") : void 0
6732
+ };
6733
+ }
5246
6734
  async getBranchesForRepo(repoIndex) {
5247
6735
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
5248
6736
  throw new Error(`Invalid repository index: ${repoIndex}`);
@@ -5304,8 +6792,7 @@ var InteractiveUIService = class {
5304
6792
  throw new Error(`Invalid repository index: ${repoIndex}`);
5305
6793
  }
5306
6794
  const service = this.syncServices[repoIndex];
5307
- const gitService = service.getGitService();
5308
- return gitService.getWorktrees();
6795
+ return this.getWorktreesFromService(service);
5309
6796
  }
5310
6797
  async getWorktreeStatusForRepo(repoIndex) {
5311
6798
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -5313,7 +6800,7 @@ var InteractiveUIService = class {
5313
6800
  }
5314
6801
  const service = this.syncServices[repoIndex];
5315
6802
  const gitService = service.getGitService();
5316
- const worktrees = await gitService.getWorktrees();
6803
+ const worktrees = await this.getWorktreesFromService(service);
5317
6804
  const results = await Promise.allSettled(
5318
6805
  worktrees.map(async (wt) => {
5319
6806
  const status = await gitService.getFullWorktreeStatus(wt.path, true);
@@ -5322,28 +6809,35 @@ var InteractiveUIService = class {
5322
6809
  );
5323
6810
  return results.filter((r) => r.status === "fulfilled").map((r) => r.value);
5324
6811
  }
6812
+ async getWorktreesFromService(service) {
6813
+ const worktreeProvider = service;
6814
+ if (typeof worktreeProvider.getWorktrees === "function") {
6815
+ return worktreeProvider.getWorktrees();
6816
+ }
6817
+ return service.getGitService().getWorktrees();
6818
+ }
5325
6819
  async getDivergedDirectoriesForRepo(repoIndex) {
5326
6820
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
5327
6821
  return [];
5328
6822
  }
5329
6823
  const service = this.syncServices[repoIndex];
5330
6824
  const worktreeDir = service.config.worktreeDir;
5331
- const divergedDir = path10.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6825
+ const divergedDir = path14.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5332
6826
  let dirEntries;
5333
6827
  try {
5334
- dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
6828
+ dirEntries = await fs10.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
5335
6829
  } catch {
5336
6830
  return [];
5337
6831
  }
5338
6832
  const subdirs = dirEntries.filter((e) => e.isDirectory());
5339
6833
  const results = await Promise.allSettled(
5340
6834
  subdirs.map(async (entry) => {
5341
- const fullPath = path10.join(divergedDir, entry.name);
5342
- const infoFilePath = path10.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
6835
+ const fullPath = path14.join(divergedDir, entry.name);
6836
+ const infoFilePath = path14.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
5343
6837
  let originalBranch = entry.name;
5344
6838
  let divergedAt = "";
5345
6839
  try {
5346
- const infoContent = await fs8.readFile(infoFilePath, "utf-8");
6840
+ const infoContent = await fs10.readFile(infoFilePath, "utf-8");
5347
6841
  const info = JSON.parse(infoContent);
5348
6842
  if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
5349
6843
  if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
@@ -5374,15 +6868,15 @@ var InteractiveUIService = class {
5374
6868
  }
5375
6869
  const service = this.syncServices[repoIndex];
5376
6870
  const worktreeDir = service.config.worktreeDir;
5377
- const divergedBase = path10.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
6871
+ const divergedBase = path14.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5378
6872
  if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
5379
6873
  throw new Error(`Invalid diverged directory name: "${name}"`);
5380
6874
  }
5381
- const targetPath = path10.join(divergedBase, name);
6875
+ const targetPath = path14.join(divergedBase, name);
5382
6876
  if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
5383
6877
  throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
5384
6878
  }
5385
- await fs8.rm(targetPath, { recursive: true, force: true });
6879
+ await fs10.rm(targetPath, { recursive: true, force: true });
5386
6880
  this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
5387
6881
  }
5388
6882
  async createWorktreeForBranch(repoIndex, branchName) {
@@ -5500,7 +6994,7 @@ var InteractiveUIService = class {
5500
6994
  async runSyncCycle(services, options) {
5501
6995
  this.setStatus("syncing");
5502
6996
  try {
5503
- const { failures, skipped, attempted } = await this.runSyncServices(services);
6997
+ const { failures, skipped, partialSkips, clonePhaseSkips, attempted } = await this.runSyncServices(services);
5504
6998
  if (options.logErrors) {
5505
6999
  for (const failure of failures) {
5506
7000
  this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
@@ -5509,6 +7003,15 @@ var InteractiveUIService = class {
5509
7003
  for (const skip of skipped) {
5510
7004
  this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
5511
7005
  }
7006
+ for (const skip of clonePhaseSkips) {
7007
+ this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
7008
+ }
7009
+ if (clonePhaseSkips.length > 0) {
7010
+ this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) this cycle`, "warn");
7011
+ }
7012
+ for (const partial of partialSkips) {
7013
+ this.addLog(`${partial.repo}: ${partial.reason}`, "info");
7014
+ }
5512
7015
  await this.recordSyncOutcome({ failures, skipped, attempted });
5513
7016
  return failures;
5514
7017
  } finally {
@@ -5523,33 +7026,64 @@ var InteractiveUIService = class {
5523
7026
  }
5524
7027
  async runSyncServices(services) {
5525
7028
  const syncResults = await Promise.allSettled(
5526
- services.map(
5527
- (service) => this.limit(async () => {
5528
- if (!service.isInitialized()) {
5529
- await service.initialize();
7029
+ services.map((service) => {
7030
+ const repoName = service.config.name || service.config.repoUrl;
7031
+ return this.limit(async () => {
7032
+ service.clearRecordedSkips();
7033
+ try {
7034
+ if (!service.isInitialized()) {
7035
+ await service.initialize();
7036
+ }
7037
+ const result = await service.sync();
7038
+ return { service, result };
7039
+ } finally {
7040
+ this.events.emit("setSyncProgress", {
7041
+ repo: repoName,
7042
+ phase: "complete",
7043
+ message: "Finished",
7044
+ completed: true
7045
+ });
5530
7046
  }
5531
- const result = await service.sync();
5532
- return { service, result };
5533
7047
  }).catch((error) => {
5534
- const repoName = service.config.name || service.config.repoUrl;
5535
7048
  throw Object.assign(error instanceof Error ? error : new Error(String(error)), { repoName });
5536
- })
5537
- )
7049
+ });
7050
+ })
5538
7051
  );
5539
7052
  const failures = [];
5540
7053
  const skipped = [];
7054
+ const partialSkips = [];
7055
+ const clonePhaseSkips = [];
5541
7056
  for (let i = 0; i < syncResults.length; i++) {
5542
7057
  const result = syncResults[i];
7058
+ const repoName = services[i].config.name || services[i].config.repoUrl;
5543
7059
  if (result.status === "rejected") {
5544
- const repoName = result.reason?.repoName ?? "unknown";
7060
+ const fallbackName = result.reason?.repoName ?? repoName;
5545
7061
  const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
5546
- failures.push({ repo: repoName, error: errorMessage });
7062
+ failures.push({ repo: fallbackName, error: errorMessage });
5547
7063
  } else if (result.value.result && result.value.result.started === false) {
5548
- const repoName = services[i].config.name || services[i].config.repoUrl;
5549
7064
  skipped.push({ repo: repoName, reason: `sync skipped: ${result.value.result.reason}` });
7065
+ } else if (result.status === "fulfilled" && result.value.result?.started === true) {
7066
+ const outcome = result.value.result.outcome;
7067
+ if (outcome?.counts.failed) {
7068
+ failures.push({ repo: repoName, error: `${outcome.counts.failed} sync action(s) failed` });
7069
+ }
7070
+ if (outcome?.mode === "worktree" && outcome.counts.skipped > 0) {
7071
+ partialSkips.push({ repo: repoName, reason: `${outcome.counts.skipped} sync action(s) skipped` });
7072
+ }
7073
+ }
7074
+ for (const reason of services[i].getRecordedSkips()) {
7075
+ clonePhaseSkips.push({ repo: repoName, reason: formatCloneSkipReason(reason) });
5550
7076
  }
5551
7077
  }
5552
- return { failures, skipped, attempted: services.length };
7078
+ return { failures, skipped, partialSkips, clonePhaseSkips, attempted: services.length };
7079
+ }
7080
+ buildUiLogger() {
7081
+ return new Logger({
7082
+ outputFn: (msg, level) => {
7083
+ const uiLevel = level === "warn" ? "warn" : level === "error" ? "error" : "info";
7084
+ this.addLog(msg, uiLevel);
7085
+ }
7086
+ });
5553
7087
  }
5554
7088
  executeOnBranchCreatedHooks(repoIndex, context) {
5555
7089
  if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
@@ -5557,27 +7091,15 @@ var InteractiveUIService = class {
5557
7091
  }
5558
7092
  const service = this.syncServices[repoIndex];
5559
7093
  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
- }
7094
+ const repoName = config.name || config.repoUrl;
7095
+ this.branchCreatedActions.runHooks({
7096
+ config,
7097
+ repoName,
7098
+ branchName: context.branchName,
7099
+ worktreePath: context.worktreePath,
7100
+ baseBranch: context.baseBranch,
7101
+ logger: this.buildUiLogger(),
7102
+ hookExecutionService: this.hookExecutionService
5581
7103
  });
5582
7104
  }
5583
7105
  async copyBranchFiles(repoIndex, baseBranch, targetBranch) {
@@ -5589,33 +7111,20 @@ var InteractiveUIService = class {
5589
7111
  if (!config.filesToCopyOnBranchCreate?.length) {
5590
7112
  return;
5591
7113
  }
5592
- const gitService = service.getGitService();
5593
- const worktrees = await gitService.getWorktrees();
7114
+ const worktrees = await this.getWorktreesFromService(service);
5594
7115
  const sourceWorktree = worktrees.find((w) => w.branch === baseBranch);
5595
7116
  const targetWorktree = worktrees.find((w) => w.branch === targetBranch);
5596
7117
  if (!sourceWorktree || !targetWorktree) {
5597
7118
  this.addLog(`Could not find worktrees for file copy: source=${baseBranch}, target=${targetBranch}`, "warn");
5598
7119
  return;
5599
7120
  }
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
- }
7121
+ await this.branchCreatedActions.copyFiles({
7122
+ config,
7123
+ branchName: targetBranch,
7124
+ worktreePath: targetWorktree.path,
7125
+ sourceDir: sourceWorktree.path,
7126
+ logger: this.buildUiLogger()
7127
+ });
5619
7128
  }
5620
7129
  async destroy(fast = false) {
5621
7130
  this.isDestroyed = true;
@@ -5633,6 +7142,10 @@ var InteractiveUIService = class {
5633
7142
  unsubscribe();
5634
7143
  }
5635
7144
  this.unsubscribeCallbacks = [];
7145
+ for (const unsubscribe of this.progressUnsubscribers) {
7146
+ unsubscribe();
7147
+ }
7148
+ this.progressUnsubscribers = [];
5636
7149
  if (this.ownsEvents) {
5637
7150
  this.events.removeAllListeners();
5638
7151
  }
@@ -5644,135 +7157,85 @@ var InteractiveUIService = class {
5644
7157
  // src/utils/cli.ts
5645
7158
  import yargs from "yargs";
5646
7159
  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");
7160
+ var CLI_COMMANDS = {
7161
+ RUN: "run",
7162
+ INIT: "init",
7163
+ LIST: "list"
7164
+ };
7165
+ function parseArguments(argv = hideBin(process.argv)) {
7166
+ let parsed;
7167
+ yargs(argv).scriptName("sync-worktrees").parserConfiguration({ "camel-case-expansion": false }).strict().command(
7168
+ "$0",
7169
+ "Sync git worktrees against a config file",
7170
+ (y) => y.option("config", {
7171
+ alias: "c",
7172
+ type: "string",
7173
+ description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
7174
+ }).option("runOnce", {
7175
+ type: "boolean",
7176
+ description: "Run a sync once and exit, overriding config runOnce settings for this invocation.",
7177
+ default: false
7178
+ }),
7179
+ (args) => {
7180
+ parsed = {
7181
+ command: CLI_COMMANDS.RUN,
7182
+ config: args.config,
7183
+ runOnce: args.runOnce
7184
+ };
7185
+ }
7186
+ ).command(
7187
+ CLI_COMMANDS.INIT,
7188
+ "Create a new config file interactively",
7189
+ (y) => y.option("config", {
7190
+ alias: "c",
7191
+ type: "string",
7192
+ description: "Target path for the generated config file (default: ./sync-worktrees.config.js)."
7193
+ }).option("force", {
7194
+ type: "boolean",
7195
+ description: "Overwrite the target file if it already exists.",
7196
+ default: false
7197
+ }),
7198
+ (args) => {
7199
+ parsed = {
7200
+ command: CLI_COMMANDS.INIT,
7201
+ config: args.config,
7202
+ force: args.force
7203
+ };
7204
+ }
7205
+ ).command(
7206
+ CLI_COMMANDS.LIST,
7207
+ "List repositories configured in a config file and exit",
7208
+ (y) => y.option("config", {
7209
+ alias: "c",
7210
+ type: "string",
7211
+ description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
7212
+ }).option("filter", {
7213
+ alias: "f",
7214
+ type: "string",
7215
+ description: "Filter repositories by name (wildcards, comma-separated)."
7216
+ }),
7217
+ (args) => {
7218
+ parsed = {
7219
+ command: CLI_COMMANDS.LIST,
7220
+ config: args.config,
7221
+ filter: args.filter
7222
+ };
7223
+ }
7224
+ ).demandCommand(0, 0).help().alias("help", "h").version().parseSync();
7225
+ if (!parsed) {
7226
+ throw new Error("Failed to parse CLI arguments");
5764
7227
  }
5765
- return `${executable} ${args.join(" ")}`;
7228
+ return parsed;
5766
7229
  }
5767
7230
 
5768
7231
  // src/utils/config-generator.ts
5769
- import * as fs9 from "fs/promises";
5770
- import * as path11 from "path";
7232
+ import * as fs11 from "fs/promises";
7233
+ import * as path15 from "path";
5771
7234
  function serializeToESM(obj, indent = 0) {
5772
7235
  const spaces = " ".repeat(indent);
5773
7236
  const innerSpaces = " ".repeat(indent + 2);
5774
7237
  if (typeof obj === "string") {
5775
- return `"${obj}"`;
7238
+ return JSON.stringify(obj);
5776
7239
  }
5777
7240
  if (typeof obj === "number" || typeof obj === "boolean") {
5778
7241
  return String(obj);
@@ -5796,99 +7259,105 @@ ${spaces}}`;
5796
7259
  }
5797
7260
  return String(obj);
5798
7261
  }
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);
7262
+ async function generateConfigFile(input2, configPath, options = {}) {
7263
+ const configDir = path15.dirname(configPath);
7264
+ await fs11.mkdir(configDir, { recursive: true });
7265
+ const worktreeDirRelative = path15.relative(configDir, input2.worktreeDir);
5803
7266
  const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
5804
- const repoName = extractRepoNameFromUrl(config.repoUrl);
7267
+ const repoName = extractRepoNameFromUrl(input2.repoUrl);
5805
7268
  const repository = {
5806
7269
  name: repoName,
5807
- repoUrl: config.repoUrl,
5808
- worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
7270
+ repoUrl: input2.repoUrl,
7271
+ worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
5809
7272
  };
5810
- if (config.bareRepoDir) {
5811
- const bareRepoDirRelative = path11.relative(configDir, config.bareRepoDir);
7273
+ if (input2.bareRepoDir) {
7274
+ const bareRepoDirRelative = path15.relative(configDir, input2.bareRepoDir);
5812
7275
  const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
5813
- repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
7276
+ repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
7277
+ }
7278
+ const defaults = {
7279
+ cronSchedule: input2.cronSchedule
7280
+ };
7281
+ if (input2.runOnce) {
7282
+ defaults.runOnce = input2.runOnce;
5814
7283
  }
5815
7284
  const configObject = {
5816
- defaults: {
5817
- cronSchedule: config.cronSchedule,
5818
- runOnce: config.runOnce
5819
- },
7285
+ defaults,
5820
7286
  repositories: [repository]
5821
7287
  };
5822
- const configContent = `/**
7288
+ const configContent = `// @ts-check
7289
+
7290
+ /**
5823
7291
  * Sync-worktrees configuration file
5824
7292
  * Generated on ${(/* @__PURE__ */ new Date()).toISOString()}
5825
7293
  */
5826
7294
 
5827
- export default ${serializeToESM(configObject)};
7295
+ /** @satisfies {import("sync-worktrees").SyncWorktreesConfig} */
7296
+ const config = ${serializeToESM(configObject)};
7297
+
7298
+ export default config;
5828
7299
  `;
5829
- await fs9.writeFile(configPath, configContent, "utf-8");
7300
+ try {
7301
+ await fs11.writeFile(configPath, configContent, {
7302
+ encoding: "utf-8",
7303
+ flag: options.overwrite ? "w" : "wx"
7304
+ });
7305
+ } catch (error) {
7306
+ if (error.code === "EEXIST") {
7307
+ throw new ConfigFileExistsError(configPath);
7308
+ }
7309
+ throw error;
7310
+ }
5830
7311
  }
5831
7312
  function getDefaultConfigPath() {
5832
- return path11.join(process.cwd(), "sync-worktrees.config.js");
7313
+ return path15.join(process.cwd(), "sync-worktrees.config.js");
5833
7314
  }
5834
7315
  async function findConfigInCwd(cwd = process.cwd()) {
5835
7316
  for (const name of CONFIG_FILE_NAMES) {
5836
- const full = path11.join(cwd, name);
5837
- try {
5838
- await fs9.access(full);
7317
+ const full = path15.join(cwd, name);
7318
+ if (await fileExists(full)) {
5839
7319
  return full;
5840
- } catch {
5841
7320
  }
5842
7321
  }
5843
7322
  return null;
5844
7323
  }
5845
7324
 
5846
7325
  // src/utils/interactive.ts
5847
- import * as path12 from "path";
7326
+ import * as path16 from "path";
5848
7327
  import { confirm, input, select } from "@inquirer/prompts";
5849
- async function promptForConfig(partialConfig) {
7328
+ async function promptForInitConfig() {
5850
7329
  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
- }
7330
+ const repoUrl = await input({
7331
+ message: "Enter the Git repository URL (e.g., https://github.com/user/repo.git):",
7332
+ validate: (value) => {
7333
+ if (!value.trim()) {
7334
+ return "Repository URL is required";
5867
7335
  }
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;
7336
+ if (!value.match(/^(https?:\/\/|ssh:\/\/|git@|file:\/\/).*$/)) {
7337
+ return "Please enter a valid Git URL (https://, ssh://, git@, or file://)";
5882
7338
  }
5883
- });
5884
- if (!worktreeDir.trim() && defaultWorktreeDir) {
5885
- worktreeDir = defaultWorktreeDir;
7339
+ return true;
5886
7340
  }
5887
- if (!path12.isAbsolute(worktreeDir)) {
5888
- worktreeDir = path12.resolve(worktreeDir);
7341
+ });
7342
+ const repoName = extractRepoNameFromUrl(repoUrl);
7343
+ const defaultWorktreeDir = repoName ? `./${repoName}` : "";
7344
+ let worktreeDir = await input({
7345
+ message: "Enter the directory for storing worktrees:",
7346
+ default: defaultWorktreeDir,
7347
+ validate: (value) => {
7348
+ if (!value.trim() && !defaultWorktreeDir) {
7349
+ return "Worktree directory is required";
7350
+ }
7351
+ return true;
5889
7352
  }
7353
+ });
7354
+ if (!worktreeDir.trim() && defaultWorktreeDir) {
7355
+ worktreeDir = defaultWorktreeDir;
5890
7356
  }
5891
- let bareRepoDir = partialConfig.bareRepoDir;
7357
+ if (!path16.isAbsolute(worktreeDir)) {
7358
+ worktreeDir = path16.resolve(worktreeDir);
7359
+ }
7360
+ let bareRepoDir;
5892
7361
  const askForBareDir = await confirm({
5893
7362
  message: "Would you like to specify a custom location for the bare repository?",
5894
7363
  default: false
@@ -5904,96 +7373,42 @@ async function promptForConfig(partialConfig) {
5904
7373
  return true;
5905
7374
  }
5906
7375
  });
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
- });
7376
+ if (!path16.isAbsolute(bareRepoDir)) {
7377
+ bareRepoDir = path16.resolve(bareRepoDir);
5937
7378
  }
5938
7379
  }
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
7380
+ const runMode = await select({
7381
+ message: "How would you like to run the sync?",
7382
+ choices: [
7383
+ { name: "Run once", value: "once" },
7384
+ { name: "Schedule with cron", value: "scheduled" }
7385
+ ]
5963
7386
  });
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,
7387
+ const runOnce = runMode === "once";
7388
+ let cronSchedule = "0 * * * *";
7389
+ if (!runOnce) {
7390
+ cronSchedule = await input({
7391
+ message: "Enter the cron schedule (or press enter for default):",
7392
+ default: "0 * * * *",
5970
7393
  validate: (value) => {
5971
7394
  if (!value.trim()) {
5972
- return "Config file path is required";
7395
+ return "Cron schedule is required";
5973
7396
  }
5974
- if (!value.endsWith(".js")) {
5975
- return "Config file must have a .js extension";
7397
+ const parts = value.trim().split(" ");
7398
+ if (parts.length < 5) {
7399
+ return "Invalid cron pattern. Expected format: '* * * * *'";
5976
7400
  }
5977
7401
  return true;
5978
7402
  }
5979
7403
  });
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
7404
  }
5996
- return { config: finalConfig, savedConfigPath };
7405
+ return {
7406
+ repoUrl,
7407
+ worktreeDir,
7408
+ bareRepoDir,
7409
+ cronSchedule,
7410
+ runOnce
7411
+ };
5997
7412
  }
5998
7413
 
5999
7414
  // src/utils/signal-handlers.ts
@@ -6045,48 +7460,12 @@ Shutdown took longer than ${forceExitMs}ms, forcing exit.`);
6045
7460
 
6046
7461
  // src/index.ts
6047
7462
  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) {
7463
+ async function runMultipleRepositories(configFile, repositories, configPath) {
6087
7464
  const services = /* @__PURE__ */ new Map();
6088
7465
  const globalLogger = Logger.createDefault();
6089
- const limit = pLimit3(maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
7466
+ const runOnce = configFile.defaults?.runOnce ?? false;
7467
+ const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
7468
+ const limit = pLimit3(maxParallel);
6090
7469
  if (runOnce) {
6091
7470
  globalLogger.info(`
6092
7471
  \u{1F504} Syncing ${repositories.length} repositories...`);
@@ -6123,7 +7502,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6123
7502
  servicesToSync.map(
6124
7503
  ({ name, service }) => limit(async () => {
6125
7504
  try {
6126
- await service.sync();
7505
+ return await service.sync();
6127
7506
  } catch (error) {
6128
7507
  globalLogger.error(`\u274C Error syncing repository '${name}':`, error);
6129
7508
  throw error;
@@ -6131,9 +7510,66 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6131
7510
  })
6132
7511
  )
6133
7512
  );
6134
- const successCount = syncResults.filter((r) => r.status === "fulfilled").length;
6135
- globalLogger.info(`
6136
- \u2705 Successfully synced ${successCount}/${servicesToSync.length} repositories`);
7513
+ const skipsByRepo = [];
7514
+ const skippedNames = /* @__PURE__ */ new Set();
7515
+ const outcomeFailedNames = /* @__PURE__ */ new Set();
7516
+ const partialSkipNames = /* @__PURE__ */ new Set();
7517
+ for (let i = 0; i < servicesToSync.length; i++) {
7518
+ const { name, service } = servicesToSync[i];
7519
+ const reasons = service.getRecordedSkips();
7520
+ if (reasons.length > 0) {
7521
+ skipsByRepo.push({ repo: name, reasons });
7522
+ skippedNames.add(name);
7523
+ }
7524
+ const result = syncResults[i];
7525
+ if (result.status === "fulfilled") {
7526
+ if (!result.value.started) {
7527
+ skippedNames.add(name);
7528
+ continue;
7529
+ }
7530
+ const counts = result.value.outcome?.counts;
7531
+ if (counts) {
7532
+ if (counts.failed > 0) {
7533
+ outcomeFailedNames.add(name);
7534
+ }
7535
+ if (counts.skipped > 0 && !skippedNames.has(name) && !outcomeFailedNames.has(name)) {
7536
+ partialSkipNames.add(name);
7537
+ }
7538
+ }
7539
+ }
7540
+ }
7541
+ if (skipsByRepo.length > 0) {
7542
+ const skipsRepoWord = skipsByRepo.length === 1 ? "repo" : "repos";
7543
+ globalLogger.warn(`
7544
+ \u26A0\uFE0F Clone-mode skips (${skipsByRepo.length} ${skipsRepoWord}):`);
7545
+ for (const { repo, reasons } of skipsByRepo) {
7546
+ for (const reason of reasons) {
7547
+ globalLogger.warn(` \u2022 ${repo} \u2014 ${formatCloneSkipReason(reason)}`);
7548
+ }
7549
+ }
7550
+ }
7551
+ const initFailures = initResults.filter(
7552
+ (result, index) => result.status === "rejected" && !skippedNames.has(repositories[index].name)
7553
+ ).length;
7554
+ const syncFailures = syncResults.filter(
7555
+ (result, index) => result.status === "rejected" && !skippedNames.has(servicesToSync[index].name)
7556
+ ).length;
7557
+ const failedCount = initFailures + syncFailures + outcomeFailedNames.size;
7558
+ const skippedCount = skippedNames.size;
7559
+ const successCount = syncResults.filter((result, index) => {
7560
+ const repoName = servicesToSync[index].name;
7561
+ return result.status === "fulfilled" && result.value.started && !skippedNames.has(repoName) && !outcomeFailedNames.has(repoName);
7562
+ }).length;
7563
+ const processedRepoWord = repositories.length === 1 ? "repo" : "repos";
7564
+ const skipSummaryLabel = skippedNames.size === skipsByRepo.length ? "with clone-mode skips" : "skipped";
7565
+ const partialSuffix = partialSkipNames.size > 0 ? ` (${partialSkipNames.size} with partial skips)` : "";
7566
+ globalLogger.info(
7567
+ `
7568
+ \u{1F4CA} Processed ${repositories.length} ${processedRepoWord}: ${successCount} synced${partialSuffix}, ${skippedCount} ${skipSummaryLabel}, ${failedCount} failed`
7569
+ );
7570
+ if (failedCount > 0) {
7571
+ process.exitCode = 1;
7572
+ }
6137
7573
  } else {
6138
7574
  for (const repoConfig of repositories) {
6139
7575
  const syncService = new WorktreeSyncService(repoConfig);
@@ -6142,7 +7578,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6142
7578
  const uniqueSchedules = [...new Set(repositories.map((r) => r.cronSchedule))];
6143
7579
  const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
6144
7580
  const allServices = Array.from(services.values());
6145
- const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel, reloadOptions);
7581
+ const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel);
6146
7582
  signalHandle.register((fast) => uiService.destroy(fast));
6147
7583
  void uiService.calculateAndUpdateDiskSpace();
6148
7584
  uiService.setupCronJobs();
@@ -6151,15 +7587,12 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
6151
7587
  for (const repo of repositories) {
6152
7588
  cronSchedules.set(repo.cronSchedule, (cronSchedules.get(repo.cronSchedule) || 0) + 1);
6153
7589
  }
6154
- for (const [schedule3, count] of cronSchedules) {
6155
- uiService.addLog(`\u23F0 ${schedule3}: ${count} repository(ies)`);
6156
- }
6157
- if (syncOnStart) {
6158
- await uiService.triggerInitialSync();
7590
+ for (const [schedule2, count] of cronSchedules) {
7591
+ uiService.addLog(`\u23F0 ${schedule2}: ${count} repository(ies)`);
6159
7592
  }
6160
7593
  }
6161
7594
  }
6162
- async function listRepositories(configPath, filter) {
7595
+ async function runList(configPath, filter) {
6163
7596
  const configLoader = new ConfigLoaderService();
6164
7597
  try {
6165
7598
  const { repositories } = await configLoader.buildRepositories(configPath, { filter });
@@ -6187,114 +7620,98 @@ async function listRepositories(configPath, filter) {
6187
7620
  process.exit(1);
6188
7621
  }
6189
7622
  }
6190
- async function runFromConfigFile(configPath, options) {
7623
+ async function runFromConfigFile(configPath, runOnceOverride = false) {
6191
7624
  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}`);
7625
+ const { repositories, configFile } = await configLoader.buildRepositories(configPath);
7626
+ const effectiveConfigFile = runOnceOverride ? { ...configFile, defaults: { ...configFile.defaults ?? {}, runOnce: true } } : configFile;
7627
+ await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
7628
+ }
7629
+ async function resolveConfigOrExit(cliPath) {
7630
+ const resolved = cliPath ? path17.resolve(cliPath) : await findConfigInCwd();
7631
+ if (!resolved) {
7632
+ console.error(
7633
+ "\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."
7634
+ );
6199
7635
  process.exit(1);
6200
7636
  }
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
- );
7637
+ return resolved;
6216
7638
  }
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;
7639
+ function exitConfigExists(targetPath) {
7640
+ console.error(`
7641
+ \u274C Config file already exists: ${targetPath}`);
7642
+ console.error(`\u{1F4A1} Re-run with --force to overwrite.`);
7643
+ process.exit(1);
7644
+ }
7645
+ async function runInit(configPath, force) {
7646
+ const targetPath = configPath ? path17.resolve(configPath) : getDefaultConfigPath();
7647
+ if (!force && await fileExists(targetPath)) {
7648
+ exitConfigExists(targetPath);
6228
7649
  }
6229
- const config = result.config;
6230
- if (options.noUpdateExisting) {
6231
- config.updateExistingWorktrees = false;
6232
- } else if (config.updateExistingWorktrees === void 0) {
6233
- config.updateExistingWorktrees = true;
7650
+ const input2 = await promptForInitConfig();
7651
+ try {
7652
+ await generateConfigFile(input2, targetPath, { overwrite: force });
7653
+ } catch (error) {
7654
+ if (error instanceof ConfigFileExistsError) {
7655
+ exitConfigExists(error.configPath);
7656
+ }
7657
+ throw error;
6234
7658
  }
6235
- if (options.debug !== void 0) {
6236
- config.debug = options.debug;
7659
+ const displayPath = path17.relative(process.cwd(), targetPath) || targetPath;
7660
+ console.log(`
7661
+ \u2705 Configuration saved to: ${targetPath}`);
7662
+ console.log(`
7663
+ \u{1F4A1} Next: sync-worktrees --config ${displayPath}`);
7664
+ }
7665
+ async function runSync(options) {
7666
+ const configPath = await resolveConfigOrExit(options.config);
7667
+ const displayPath = path17.relative(process.cwd(), configPath) || configPath;
7668
+ console.log(`\u{1F4C4} Using config: ${displayPath}`);
7669
+ try {
7670
+ await runFromConfigFile(configPath, options.runOnce);
7671
+ } catch (error) {
7672
+ if (error instanceof ConfigFileNotFoundError) {
7673
+ console.error(`
7674
+ \u274C Config file not found: ${error.configPath}`);
7675
+ console.error(`\u{1F4A1} Run 'sync-worktrees init --config ${displayPath}' to create one.`);
7676
+ process.exit(1);
7677
+ }
7678
+ console.error("\u274C Error loading config file:", error.message);
7679
+ process.exit(1);
6237
7680
  }
6238
- await runSingleRepository(config);
6239
7681
  }
6240
7682
  async function main() {
6241
7683
  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
- }
7684
+ switch (options.command) {
7685
+ case CLI_COMMANDS.INIT:
7686
+ return runInit(options.config, options.force);
7687
+ case CLI_COMMANDS.LIST: {
7688
+ const configPath = await resolveConfigOrExit(options.config);
7689
+ return runList(configPath, options.filter);
6280
7690
  }
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;
7691
+ case CLI_COMMANDS.RUN:
7692
+ return runSync(options);
7693
+ default: {
7694
+ const _exhaustive = options;
7695
+ throw new Error(`Unhandled command: ${JSON.stringify(_exhaustive)}`);
6292
7696
  }
6293
- await runSingleRepository(config);
6294
7697
  }
6295
7698
  }
6296
- main().catch((error) => {
6297
- console.error("\u274C Unhandled error:", error);
6298
- process.exit(1);
6299
- });
7699
+ function isMainEntrypoint() {
7700
+ const entry = process.argv[1];
7701
+ if (!entry) return false;
7702
+ try {
7703
+ return realpathSync2(entry) === fileURLToPath(import.meta.url);
7704
+ } catch {
7705
+ return false;
7706
+ }
7707
+ }
7708
+ if (isMainEntrypoint()) {
7709
+ main().catch((error) => {
7710
+ console.error("\u274C Unhandled error:", error);
7711
+ process.exit(1);
7712
+ });
7713
+ }
7714
+ export {
7715
+ runMultipleRepositories
7716
+ };
6300
7717
  //# sourceMappingURL=index.js.map