sync-worktrees 3.3.1 → 3.5.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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as path12 from "path";
4
+ import * as path13 from "path";
5
5
  import { confirm as confirm2 } from "@inquirer/prompts";
6
6
  import * as cron3 from "node-cron";
7
7
  import pLimit3 from "p-limit";
@@ -55,7 +55,11 @@ var DEFAULT_CONFIG = {
55
55
  MAX_SAFE_TOTAL_CONCURRENT_OPS: 100
56
56
  },
57
57
  UPDATE_EXISTING_WORKTREES: true,
58
- HOOK_TIMEOUT_MS: 6e4
58
+ HOOK_TIMEOUT_MS: 6e4,
59
+ FETCH_TIMEOUT_MS: 3e5,
60
+ CLONE_TIMEOUT_MS: 9e5,
61
+ LOCK_STALE_MS: 6e5,
62
+ LOCK_UPDATE_MS: 3e4
59
63
  };
60
64
  var ERROR_MESSAGES = {
61
65
  GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
@@ -215,9 +219,9 @@ var WorktreeError = class extends SyncWorktreesError {
215
219
  }
216
220
  };
217
221
  var WorktreeNotCleanError = class extends WorktreeError {
218
- constructor(path13, reasons) {
219
- super(`Worktree at '${path13}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
220
- this.path = path13;
222
+ constructor(path14, reasons) {
223
+ super(`Worktree at '${path14}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
224
+ this.path = path14;
221
225
  this.reasons = reasons;
222
226
  }
223
227
  };
@@ -669,7 +673,7 @@ var ConfigLoaderService = class {
669
673
 
670
674
  // src/services/InteractiveUIService.tsx
671
675
  import React8 from "react";
672
- import * as path9 from "path";
676
+ import * as path10 from "path";
673
677
  import { render } from "ink";
674
678
  import * as cron2 from "node-cron";
675
679
  import pLimit2 from "p-limit";
@@ -1945,8 +1949,9 @@ var App_default = App;
1945
1949
 
1946
1950
  // src/services/worktree-sync.service.ts
1947
1951
  import * as fs6 from "fs/promises";
1948
- import * as path7 from "path";
1952
+ import * as path8 from "path";
1949
1953
  import pLimit from "p-limit";
1954
+ import * as lockfile from "proper-lockfile";
1950
1955
 
1951
1956
  // src/utils/date-filter.ts
1952
1957
  function parseDuration(durationStr) {
@@ -2174,7 +2179,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2174
2179
 
2175
2180
  // src/services/git.service.ts
2176
2181
  import * as fs4 from "fs/promises";
2177
- import * as path5 from "path";
2182
+ import * as path6 from "path";
2178
2183
  import simpleGit4 from "simple-git";
2179
2184
 
2180
2185
  // src/utils/worktree-list-parser.ts
@@ -2318,11 +2323,13 @@ function defaultConsoleOutput(msg, level) {
2318
2323
  }
2319
2324
 
2320
2325
  // src/services/sparse-checkout.service.ts
2326
+ import * as path3 from "path";
2321
2327
  import simpleGit from "simple-git";
2322
2328
  var SparseCheckoutService = class {
2323
2329
  logger;
2324
2330
  gitFactory;
2325
2331
  warnedConfigs = /* @__PURE__ */ new WeakSet();
2332
+ matcherCache = /* @__PURE__ */ new WeakMap();
2326
2333
  constructor(logger, gitFactory) {
2327
2334
  this.logger = logger ?? Logger.createDefault();
2328
2335
  this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
@@ -2406,11 +2413,66 @@ var SparseCheckoutService = class {
2406
2413
  const bt = b.map((x) => x.trim());
2407
2414
  return at.every((v, i) => v === bt[i]);
2408
2415
  }
2416
+ /**
2417
+ * Decide whether a list of changed file paths intersects the sparse-checkout
2418
+ * set defined by `cfg`. Used to skip fast-forward updates when upstream
2419
+ * commits only touch files outside the materialized worktree.
2420
+ *
2421
+ * Cone mode materializes:
2422
+ * - all files at the repository root,
2423
+ * - all files directly inside every ancestor of an included directory
2424
+ * (e.g. include `tools/build` keeps `tools/foo.txt` checked out too),
2425
+ * - everything inside an included directory.
2426
+ * We mirror those rules here. Missing the ancestor-files case would let
2427
+ * stale files linger when only those parent files change upstream.
2428
+ *
2429
+ * No-cone mode: gitignore-style matching with negation is non-trivial and
2430
+ * not implemented here yet. We return `true` so the caller falls back to
2431
+ * the safe behavior of always running the update.
2432
+ *
2433
+ * The matcher derived from `cfg` is cached on the cfg object identity
2434
+ * (WeakMap), so callers should reuse the same `cfg` reference across
2435
+ * invocations to benefit from the cache.
2436
+ */
2437
+ pathsTouchSparse(changedPaths, cfg) {
2438
+ if (changedPaths.length === 0) return false;
2439
+ const matcher = this.getMatcher(cfg);
2440
+ if (matcher.mode === "no-cone") return true;
2441
+ if (matcher.patterns.length === 0) return true;
2442
+ return changedPaths.some((p) => {
2443
+ if (!p.includes("/")) return true;
2444
+ for (const pat of matcher.patterns) {
2445
+ if (p === pat || p.startsWith(pat + "/")) return true;
2446
+ }
2447
+ return matcher.ancestorDirs.has(path3.posix.dirname(p));
2448
+ });
2449
+ }
2450
+ getMatcher(cfg) {
2451
+ const cached = this.matcherCache.get(cfg);
2452
+ if (cached) return cached;
2453
+ const mode = this.resolveMode(cfg);
2454
+ if (mode === "no-cone") {
2455
+ const matcher2 = { mode, patterns: [], ancestorDirs: /* @__PURE__ */ new Set() };
2456
+ this.matcherCache.set(cfg, matcher2);
2457
+ return matcher2;
2458
+ }
2459
+ const patterns = cfg.include.map((p) => p.trim()).filter((p) => p.length > 0).map((p) => p.endsWith("/") ? p.slice(0, -1) : p);
2460
+ const ancestorDirs = /* @__PURE__ */ new Set();
2461
+ for (const pat of patterns) {
2462
+ const parts = pat.split("/");
2463
+ for (let i = 1; i < parts.length; i++) {
2464
+ ancestorDirs.add(parts.slice(0, i).join("/"));
2465
+ }
2466
+ }
2467
+ const matcher = { mode, patterns, ancestorDirs };
2468
+ this.matcherCache.set(cfg, matcher);
2469
+ return matcher;
2470
+ }
2409
2471
  };
2410
2472
 
2411
2473
  // src/services/worktree-metadata.service.ts
2412
2474
  import * as fs2 from "fs/promises";
2413
- import * as path3 from "path";
2475
+ import * as path4 from "path";
2414
2476
  import simpleGit2 from "simple-git";
2415
2477
  var WorktreeMetadataService = class {
2416
2478
  logger;
@@ -2423,7 +2485,7 @@ var WorktreeMetadataService = class {
2423
2485
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
2424
2486
  */
2425
2487
  getWorktreeDirectoryName(worktreePath) {
2426
- return path3.basename(worktreePath);
2488
+ return path4.basename(worktreePath);
2427
2489
  }
2428
2490
  async getMetadataPath(bareRepoPath, worktreeName) {
2429
2491
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -2431,7 +2493,7 @@ var WorktreeMetadataService = class {
2431
2493
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
2432
2494
  );
2433
2495
  }
2434
- return path3.join(
2496
+ return path4.join(
2435
2497
  bareRepoPath,
2436
2498
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
2437
2499
  worktreeName,
@@ -2444,7 +2506,7 @@ var WorktreeMetadataService = class {
2444
2506
  }
2445
2507
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
2446
2508
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2447
- await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
2509
+ await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
2448
2510
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
2449
2511
  let renamed = false;
2450
2512
  try {
@@ -2637,7 +2699,7 @@ var WorktreeMetadataService = class {
2637
2699
 
2638
2700
  // src/services/worktree-status.service.ts
2639
2701
  import * as fs3 from "fs/promises";
2640
- import * as path4 from "path";
2702
+ import * as path5 from "path";
2641
2703
  import simpleGit3 from "simple-git";
2642
2704
  var OPERATION_FILES = [
2643
2705
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
@@ -2840,7 +2902,7 @@ var WorktreeStatusService = class {
2840
2902
  async detectOperationFile(gitDir) {
2841
2903
  const results = await Promise.all(
2842
2904
  OPERATION_FILES.map(
2843
- ({ file }) => fs3.access(path4.join(gitDir, file)).then(
2905
+ ({ file }) => fs3.access(path5.join(gitDir, file)).then(
2844
2906
  () => true,
2845
2907
  () => false
2846
2908
  )
@@ -2961,14 +3023,14 @@ var WorktreeStatusService = class {
2961
3023
  }
2962
3024
  }
2963
3025
  async resolveGitDir(worktreePath) {
2964
- const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
3026
+ const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
2965
3027
  try {
2966
3028
  const stat3 = await fs3.stat(gitPath);
2967
3029
  if (stat3.isFile()) {
2968
3030
  const content = await fs3.readFile(gitPath, "utf-8");
2969
3031
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
2970
3032
  if (gitdirMatch) {
2971
- return path4.resolve(worktreePath, gitdirMatch[1].trim());
3033
+ return path5.resolve(worktreePath, gitdirMatch[1].trim());
2972
3034
  }
2973
3035
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
2974
3036
  }
@@ -2982,7 +3044,7 @@ var WorktreeStatusService = class {
2982
3044
  }
2983
3045
  }
2984
3046
  createGitInstance(worktreePath) {
2985
- const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
3047
+ const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
2986
3048
  let git = this.gitInstances.get(key);
2987
3049
  if (!git) {
2988
3050
  git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
@@ -3005,7 +3067,7 @@ var GitService = class {
3005
3067
  this.config = config;
3006
3068
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
3007
3069
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
3008
- this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3070
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3009
3071
  this.metadataService = new WorktreeMetadataService(this.logger);
3010
3072
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
3011
3073
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -3024,11 +3086,21 @@ var GitService = class {
3024
3086
  getSparseCheckoutService() {
3025
3087
  return this.sparseCheckoutService;
3026
3088
  }
3089
+ getFetchTimeoutMs() {
3090
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
3091
+ return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
3092
+ }
3093
+ getCloneTimeoutMs() {
3094
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
3095
+ return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
3096
+ }
3027
3097
  getCachedGit(dirPath, useLfsSkip = false) {
3028
- const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3098
+ const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3029
3099
  let git = this.gitInstances.get(key);
3030
3100
  if (!git) {
3031
- git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
3101
+ const block = this.getFetchTimeoutMs();
3102
+ const base = block > 0 ? simpleGit4(dirPath, { timeout: { block } }) : simpleGit4(dirPath);
3103
+ git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
3032
3104
  this.gitInstances.set(key, git);
3033
3105
  }
3034
3106
  return git;
@@ -3040,11 +3112,13 @@ var GitService = class {
3040
3112
  async initialize() {
3041
3113
  const { repoUrl } = this.config;
3042
3114
  try {
3043
- await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
3115
+ await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
3044
3116
  } catch {
3045
3117
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
3046
- await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
3047
- const cloneGit = this.isLfsSkipEnabled() ? simpleGit4().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4();
3118
+ await fs4.mkdir(path6.dirname(this.bareRepoPath), { recursive: true });
3119
+ const cloneBlock = this.getCloneTimeoutMs();
3120
+ const cloneBase = cloneBlock > 0 ? simpleGit4({ timeout: { block: cloneBlock } }) : simpleGit4();
3121
+ const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
3048
3122
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
3049
3123
  this.logger.info("\u2705 Clone successful.");
3050
3124
  }
@@ -3061,17 +3135,17 @@ var GitService = class {
3061
3135
  this.logger.info("Fetching remote branches...");
3062
3136
  await bareGit.fetch(["--all"]);
3063
3137
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
3064
- this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
3138
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
3065
3139
  let needsMainWorktree = true;
3066
3140
  try {
3067
3141
  const worktrees = await this.getWorktreesFromBare(bareGit);
3068
- needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
3142
+ needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
3069
3143
  } catch {
3070
3144
  }
3071
3145
  if (needsMainWorktree) {
3072
3146
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
3073
3147
  await fs4.mkdir(this.config.worktreeDir, { recursive: true });
3074
- const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
3148
+ const absoluteWorktreePath = path6.resolve(this.mainWorktreePath);
3075
3149
  const branches = await bareGit.branch();
3076
3150
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
3077
3151
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -3107,7 +3181,7 @@ var GitService = class {
3107
3181
  }
3108
3182
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
3109
3183
  const mainWorktreeRegistered = updatedWorktrees.some(
3110
- (w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
3184
+ (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
3111
3185
  );
3112
3186
  if (!mainWorktreeRegistered) {
3113
3187
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -3130,6 +3204,9 @@ var GitService = class {
3130
3204
  getDefaultBranch() {
3131
3205
  return this.defaultBranch;
3132
3206
  }
3207
+ getBareRepoPath() {
3208
+ return this.bareRepoPath;
3209
+ }
3133
3210
  async fetchAll() {
3134
3211
  this.assertInitialized();
3135
3212
  this.logger.info("Fetching latest data from remote...");
@@ -3187,7 +3264,7 @@ var GitService = class {
3187
3264
  const existence = await Promise.all(
3188
3265
  lfsFileList.map(async (f) => {
3189
3266
  try {
3190
- await fs4.access(path5.join(worktreePath, f));
3267
+ await fs4.access(path6.join(worktreePath, f));
3191
3268
  return f;
3192
3269
  } catch {
3193
3270
  return null;
@@ -3215,7 +3292,7 @@ var GitService = class {
3215
3292
  let allDownloaded = true;
3216
3293
  const notDownloaded = [];
3217
3294
  for (const file of samplesToCheck) {
3218
- const filePath = path5.join(worktreePath, file);
3295
+ const filePath = path6.join(worktreePath, file);
3219
3296
  try {
3220
3297
  const handle = await fs4.open(filePath, "r");
3221
3298
  try {
@@ -3304,12 +3381,12 @@ var GitService = class {
3304
3381
  }
3305
3382
  async addWorktree(branchName, worktreePath) {
3306
3383
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
3307
- const absoluteWorktreePath = path5.resolve(worktreePath);
3308
- await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
3384
+ const absoluteWorktreePath = path6.resolve(worktreePath);
3385
+ await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
3309
3386
  try {
3310
3387
  await fs4.access(absoluteWorktreePath);
3311
3388
  const worktrees = await this.getWorktreesFromBare(bareGit);
3312
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
3389
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3313
3390
  if (isValidWorktree) {
3314
3391
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3315
3392
  return;
@@ -3354,7 +3431,7 @@ var GitService = class {
3354
3431
  }
3355
3432
  if (errorMessage.includes("already registered worktree")) {
3356
3433
  const worktrees = await this.getWorktreesFromBare(bareGit);
3357
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
3434
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3358
3435
  if (existingWorktree && !existingWorktree.isPrunable) {
3359
3436
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
3360
3437
  return;
@@ -3400,7 +3477,7 @@ var GitService = class {
3400
3477
  try {
3401
3478
  await fs4.access(absoluteWorktreePath);
3402
3479
  const worktrees = await this.getWorktreesFromBare(bareGit);
3403
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
3480
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3404
3481
  if (isValidWorktree) {
3405
3482
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3406
3483
  return;
@@ -3430,7 +3507,7 @@ var GitService = class {
3430
3507
  const fallbackErrorMessage = getErrorMessage(fallbackError);
3431
3508
  if (fallbackErrorMessage.includes("already registered worktree")) {
3432
3509
  const worktrees = await this.getWorktreesFromBare(bareGit);
3433
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
3510
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3434
3511
  if (existingWorktree && !existingWorktree.isPrunable) {
3435
3512
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
3436
3513
  return;
@@ -3653,6 +3730,23 @@ var GitService = class {
3653
3730
  return false;
3654
3731
  }
3655
3732
  }
3733
+ async getChangedPathsInRange(worktreePath, fromRef, toRef) {
3734
+ const worktreeGit = this.getCachedGit(worktreePath);
3735
+ try {
3736
+ const out = await worktreeGit.raw([
3737
+ "-c",
3738
+ "core.quotePath=false",
3739
+ "diff",
3740
+ "--name-only",
3741
+ "--no-renames",
3742
+ `${fromRef}..${toRef}`
3743
+ ]);
3744
+ return out.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.length > 0);
3745
+ } catch (error) {
3746
+ this.logger.warn(`Failed to compute diff ${fromRef}..${toRef} in ${worktreePath}: ${getErrorMessage(error)}`);
3747
+ return null;
3748
+ }
3749
+ }
3656
3750
  async compareTreeContent(worktreePath, branch) {
3657
3751
  const worktreeGit = this.getCachedGit(worktreePath);
3658
3752
  try {
@@ -3737,7 +3831,7 @@ var GitService = class {
3737
3831
  // src/services/path-resolution.service.ts
3738
3832
  import { createHash } from "crypto";
3739
3833
  import * as fs5 from "fs";
3740
- import * as path6 from "path";
3834
+ import * as path7 from "path";
3741
3835
  var BRANCH_STEM_MAX = 80;
3742
3836
  var BRANCH_HASH_LEN = 8;
3743
3837
  var PathResolutionService = class {
@@ -3747,22 +3841,22 @@ var PathResolutionService = class {
3747
3841
  return `${stem}-${hash}`;
3748
3842
  }
3749
3843
  getBranchWorktreePath(worktreeDir, branchName) {
3750
- return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
3844
+ return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
3751
3845
  }
3752
3846
  resolveRealPath(inputPath) {
3753
- const absolute = path6.resolve(inputPath);
3847
+ const absolute = path7.resolve(inputPath);
3754
3848
  const missing = [];
3755
3849
  let current = absolute;
3756
3850
  while (!fs5.existsSync(current)) {
3757
- const parent = path6.dirname(current);
3851
+ const parent = path7.dirname(current);
3758
3852
  if (parent === current) {
3759
3853
  return absolute;
3760
3854
  }
3761
- missing.unshift(path6.basename(current));
3855
+ missing.unshift(path7.basename(current));
3762
3856
  current = parent;
3763
3857
  }
3764
3858
  try {
3765
- return path6.join(fs5.realpathSync(current), ...missing);
3859
+ return path7.join(fs5.realpathSync(current), ...missing);
3766
3860
  } catch {
3767
3861
  return absolute;
3768
3862
  }
@@ -3772,7 +3866,7 @@ var PathResolutionService = class {
3772
3866
  const a = fold(resolved);
3773
3867
  const b = fold(resolvedBase);
3774
3868
  if (a === b) return true;
3775
- return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
3869
+ return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
3776
3870
  }
3777
3871
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
3778
3872
  const resolved = this.resolveRealPath(worktreePath);
@@ -3780,7 +3874,7 @@ var PathResolutionService = class {
3780
3874
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
3781
3875
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
3782
3876
  }
3783
- return path6.relative(resolvedBase, resolved);
3877
+ return path7.relative(resolvedBase, resolved);
3784
3878
  }
3785
3879
  isPathInsideBaseDir(targetPath, baseDir) {
3786
3880
  const resolved = this.resolveRealPath(targetPath);
@@ -3839,6 +3933,11 @@ var WorktreeSyncService = class {
3839
3933
  this.logger.warn("\u26A0\uFE0F Sync already in progress, skipping...");
3840
3934
  return { started: false, reason: "in_progress" };
3841
3935
  }
3936
+ const release = await this.acquireBareLock();
3937
+ if (release === null) {
3938
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
3939
+ return { started: false, reason: "locked" };
3940
+ }
3842
3941
  this.syncInProgress = true;
3843
3942
  this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
3844
3943
  const totalTimer = new Timer();
@@ -3855,6 +3954,11 @@ var WorktreeSyncService = class {
3855
3954
  this.gitService.setLfsSkipEnabled(false);
3856
3955
  }
3857
3956
  this.syncInProgress = false;
3957
+ try {
3958
+ await release();
3959
+ } catch (releaseError) {
3960
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
3961
+ }
3858
3962
  this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
3859
3963
  `);
3860
3964
  if (this.config.debug) {
@@ -3866,6 +3970,39 @@ var WorktreeSyncService = class {
3866
3970
  }
3867
3971
  return { started: true };
3868
3972
  }
3973
+ async acquireBareLock() {
3974
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
3975
+ return async () => {
3976
+ };
3977
+ }
3978
+ if (typeof this.gitService.getBareRepoPath !== "function") {
3979
+ return async () => {
3980
+ };
3981
+ }
3982
+ const barePath = this.gitService.getBareRepoPath();
3983
+ const lockTarget = path8.join(barePath, "HEAD");
3984
+ try {
3985
+ await fs6.access(lockTarget);
3986
+ } catch {
3987
+ return async () => {
3988
+ };
3989
+ }
3990
+ try {
3991
+ const release = await lockfile.lock(lockTarget, {
3992
+ stale: DEFAULT_CONFIG.LOCK_STALE_MS,
3993
+ update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
3994
+ retries: 0,
3995
+ realpath: false
3996
+ });
3997
+ return release;
3998
+ } catch (error) {
3999
+ const code = error.code;
4000
+ if (code === "ELOCKED") {
4001
+ return null;
4002
+ }
4003
+ throw error;
4004
+ }
4005
+ }
3869
4006
  createRetryOptions(syncContext) {
3870
4007
  return {
3871
4008
  maxAttempts: this.config.retry?.maxAttempts ?? 3,
@@ -4041,12 +4178,12 @@ var WorktreeSyncService = class {
4041
4178
  }
4042
4179
  const reservedPaths = /* @__PURE__ */ new Map();
4043
4180
  for (const w of worktrees) {
4044
- reservedPaths.set(path7.resolve(w.path), w.branch);
4181
+ reservedPaths.set(path8.resolve(w.path), w.branch);
4045
4182
  }
4046
4183
  const plan = [];
4047
4184
  for (const branchName of newBranches) {
4048
4185
  const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
4049
- const resolved = path7.resolve(worktreePath);
4186
+ const resolved = path8.resolve(worktreePath);
4050
4187
  const conflict = reservedPaths.get(resolved);
4051
4188
  if (conflict && conflict !== branchName) {
4052
4189
  this.logger.error(
@@ -4251,12 +4388,12 @@ var WorktreeSyncService = class {
4251
4388
  }
4252
4389
  async updateExistingWorktrees(worktrees, remoteBranches) {
4253
4390
  this.logger.info("Step 4: Checking for worktrees that need updates...");
4254
- const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4391
+ const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4255
4392
  try {
4256
4393
  const diverged = await fs6.readdir(divergedDir);
4257
4394
  if (diverged.length > 0) {
4258
4395
  this.logger.info(
4259
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
4396
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
4260
4397
  );
4261
4398
  }
4262
4399
  } catch {
@@ -4286,7 +4423,23 @@ var WorktreeSyncService = class {
4286
4423
  return { action: "diverged", worktree };
4287
4424
  }
4288
4425
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
4289
- return isBehind ? { action: "update", worktree } : null;
4426
+ if (!isBehind) return null;
4427
+ const sparseCfg = this.config.sparseCheckout;
4428
+ if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
4429
+ const sparseService = this.gitService.getSparseCheckoutService();
4430
+ if (sparseService.resolveMode(sparseCfg) === "cone") {
4431
+ const diff = await this.gitService.getChangedPathsInRange(
4432
+ worktree.path,
4433
+ "HEAD",
4434
+ `origin/${worktree.branch}`
4435
+ );
4436
+ if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
4437
+ this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
4438
+ return null;
4439
+ }
4440
+ }
4441
+ }
4442
+ return { action: "update", worktree };
4290
4443
  })
4291
4444
  )
4292
4445
  );
@@ -4364,13 +4517,13 @@ var WorktreeSyncService = class {
4364
4517
  }
4365
4518
  async cleanupOrphanedDirectories(worktrees) {
4366
4519
  try {
4367
- const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
4520
+ const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
4368
4521
  const allDirs = await fs6.readdir(this.config.worktreeDir);
4369
4522
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
4370
4523
  const orphanedDirs = [];
4371
4524
  for (const dir of regularDirs) {
4372
4525
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
4373
- return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
4526
+ return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
4374
4527
  });
4375
4528
  if (!isPartOfWorktree) {
4376
4529
  orphanedDirs.push(dir);
@@ -4379,7 +4532,7 @@ var WorktreeSyncService = class {
4379
4532
  if (orphanedDirs.length > 0) {
4380
4533
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
4381
4534
  for (const dir of orphanedDirs) {
4382
- const dirPath = path7.join(this.config.worktreeDir, dir);
4535
+ const dirPath = path8.join(this.config.worktreeDir, dir);
4383
4536
  try {
4384
4537
  const stat3 = await fs6.stat(dirPath);
4385
4538
  if (stat3.isDirectory()) {
@@ -4413,7 +4566,7 @@ var WorktreeSyncService = class {
4413
4566
  } else {
4414
4567
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
4415
4568
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
4416
- const relativePath = path7.relative(process.cwd(), divergedPath);
4569
+ const relativePath = path8.relative(process.cwd(), divergedPath);
4417
4570
  this.logger.info(` Moved to: ${relativePath}`);
4418
4571
  this.logger.info(` Your local changes are preserved. To review:`);
4419
4572
  this.logger.info(` cd ${relativePath}`);
@@ -4437,12 +4590,12 @@ var WorktreeSyncService = class {
4437
4590
  }
4438
4591
  }
4439
4592
  async divergeWorktree(worktreePath, branchName) {
4440
- const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4593
+ const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4441
4594
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4442
4595
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
4443
4596
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
4444
4597
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
4445
- const divergedPath = path7.join(divergedBaseDir, divergedName);
4598
+ const divergedPath = path8.join(divergedBaseDir, divergedName);
4446
4599
  await fs6.mkdir(divergedBaseDir, { recursive: true });
4447
4600
  try {
4448
4601
  await fs6.rename(worktreePath, divergedPath);
@@ -4469,7 +4622,7 @@ var WorktreeSyncService = class {
4469
4622
  Original worktree location: ${worktreePath}`
4470
4623
  };
4471
4624
  await fs6.writeFile(
4472
- path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4625
+ path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4473
4626
  JSON.stringify(metadata, null, 2)
4474
4627
  );
4475
4628
  return divergedPath;
@@ -4478,7 +4631,7 @@ var WorktreeSyncService = class {
4478
4631
 
4479
4632
  // src/services/file-copy.service.ts
4480
4633
  import * as fs7 from "fs/promises";
4481
- import * as path8 from "path";
4634
+ import * as path9 from "path";
4482
4635
  import { glob } from "glob";
4483
4636
  var DEFAULT_IGNORE_PATTERNS = [
4484
4637
  "**/node_modules/**",
@@ -4505,8 +4658,8 @@ var FileCopyService = class {
4505
4658
  }
4506
4659
  const filesToCopy = await this.expandPatterns(sourceDir, patterns);
4507
4660
  for (const relativePath of filesToCopy) {
4508
- const sourcePath = path8.join(sourceDir, relativePath);
4509
- const destPath = path8.join(destDir, relativePath);
4661
+ const sourcePath = path9.join(sourceDir, relativePath);
4662
+ const destPath = path9.join(destDir, relativePath);
4510
4663
  try {
4511
4664
  const copied = await this.copyFile(sourcePath, destPath);
4512
4665
  if (copied) {
@@ -4547,7 +4700,7 @@ var FileCopyService = class {
4547
4700
  return false;
4548
4701
  } catch {
4549
4702
  }
4550
- const destDir = path8.dirname(destPath);
4703
+ const destDir = path9.dirname(destPath);
4551
4704
  await fs7.mkdir(destDir, { recursive: true });
4552
4705
  await fs7.copyFile(sourcePath, destPath);
4553
4706
  return true;
@@ -4765,6 +4918,8 @@ var AppEventEmitter = class {
4765
4918
 
4766
4919
  // src/services/InteractiveUIService.tsx
4767
4920
  import * as fs8 from "fs/promises";
4921
+ var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
4922
+ var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
4768
4923
  var InteractiveUIService = class {
4769
4924
  app = null;
4770
4925
  syncServices;
@@ -4872,6 +5027,9 @@ var InteractiveUIService = class {
4872
5027
  }
4873
5028
  this.cronJobs = [];
4874
5029
  }
5030
+ registerCronJob(job) {
5031
+ this.cronJobs.push(job);
5032
+ }
4875
5033
  renderUI() {
4876
5034
  if (this.app) {
4877
5035
  this.app.unmount();
@@ -4895,8 +5053,8 @@ var InteractiveUIService = class {
4895
5053
  getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
4896
5054
  getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
4897
5055
  deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
4898
- openEditorInWorktree: (path13) => this.openEditorInWorktree(path13),
4899
- openTerminalInWorktree: (repoIndex, path13, branchName) => this.openTerminalInWorktree(repoIndex, path13, branchName),
5056
+ openEditorInWorktree: (path14) => this.openEditorInWorktree(path14),
5057
+ openTerminalInWorktree: (repoIndex, path14, branchName) => this.openTerminalInWorktree(repoIndex, path14, branchName),
4900
5058
  copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
4901
5059
  createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
4902
5060
  executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
@@ -4986,18 +5144,17 @@ var InteractiveUIService = class {
4986
5144
  await this.destroy();
4987
5145
  process.exit(0);
4988
5146
  }
4989
- async waitForInProgressSyncs() {
5147
+ async waitForInProgressSyncs(timeoutMs = WAIT_SYNC_DEFAULT_TIMEOUT_MS) {
4990
5148
  const inProgressServices = this.syncServices.filter((s) => s.isSyncInProgress());
4991
5149
  if (inProgressServices.length === 0) {
4992
5150
  return;
4993
5151
  }
4994
5152
  this.addLog(`Waiting for ${inProgressServices.length} in-progress sync(s) to finish...`, "info");
4995
5153
  const syncChecks = inProgressServices.map(async (service) => {
4996
- const timeout = 3e4;
4997
5154
  const checkInterval = 500;
4998
5155
  const startTime = Date.now();
4999
5156
  while (service.isSyncInProgress()) {
5000
- if (Date.now() - startTime > timeout) {
5157
+ if (Date.now() - startTime > timeoutMs) {
5001
5158
  throw new Error("Timeout waiting for sync operations to complete");
5002
5159
  }
5003
5160
  await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
@@ -5007,7 +5164,7 @@ var InteractiveUIService = class {
5007
5164
  await Promise.all(syncChecks);
5008
5165
  } catch {
5009
5166
  this.addLog(
5010
- "Warning: Timeout waiting for sync operations to complete after 30s. Proceeding with potential data loss risk.",
5167
+ `Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
5011
5168
  "warn"
5012
5169
  );
5013
5170
  }
@@ -5133,7 +5290,7 @@ var InteractiveUIService = class {
5133
5290
  }
5134
5291
  const service = this.syncServices[repoIndex];
5135
5292
  const worktreeDir = service.config.worktreeDir;
5136
- const divergedDir = path9.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5293
+ const divergedDir = path10.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5137
5294
  let dirEntries;
5138
5295
  try {
5139
5296
  dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
@@ -5143,8 +5300,8 @@ var InteractiveUIService = class {
5143
5300
  const subdirs = dirEntries.filter((e) => e.isDirectory());
5144
5301
  const results = await Promise.allSettled(
5145
5302
  subdirs.map(async (entry) => {
5146
- const fullPath = path9.join(divergedDir, entry.name);
5147
- const infoFilePath = path9.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
5303
+ const fullPath = path10.join(divergedDir, entry.name);
5304
+ const infoFilePath = path10.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
5148
5305
  let originalBranch = entry.name;
5149
5306
  let divergedAt = "";
5150
5307
  try {
@@ -5179,11 +5336,11 @@ var InteractiveUIService = class {
5179
5336
  }
5180
5337
  const service = this.syncServices[repoIndex];
5181
5338
  const worktreeDir = service.config.worktreeDir;
5182
- const divergedBase = path9.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5339
+ const divergedBase = path10.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5183
5340
  if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
5184
5341
  throw new Error(`Invalid diverged directory name: "${name}"`);
5185
5342
  }
5186
- const targetPath = path9.join(divergedBase, name);
5343
+ const targetPath = path10.join(divergedBase, name);
5187
5344
  if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
5188
5345
  throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
5189
5346
  }
@@ -5422,11 +5579,11 @@ var InteractiveUIService = class {
5422
5579
  this.addLog(`Failed to copy files to new branch: ${error}`, "error");
5423
5580
  }
5424
5581
  }
5425
- async destroy() {
5582
+ async destroy(fast = false) {
5426
5583
  this.isDestroyed = true;
5427
5584
  this.cancelCronJobs();
5428
5585
  try {
5429
- await this.waitForInProgressSyncs();
5586
+ await this.waitForInProgressSyncs(fast ? WAIT_SYNC_FAST_TIMEOUT_MS : WAIT_SYNC_DEFAULT_TIMEOUT_MS);
5430
5587
  } catch {
5431
5588
  }
5432
5589
  this.hookExecutionService.cleanup();
@@ -5572,7 +5729,7 @@ function reconstructCliCommand(config) {
5572
5729
 
5573
5730
  // src/utils/config-generator.ts
5574
5731
  import * as fs9 from "fs/promises";
5575
- import * as path10 from "path";
5732
+ import * as path11 from "path";
5576
5733
  function serializeToESM(obj, indent = 0) {
5577
5734
  const spaces = " ".repeat(indent);
5578
5735
  const innerSpaces = " ".repeat(indent + 2);
@@ -5602,9 +5759,9 @@ ${spaces}}`;
5602
5759
  return String(obj);
5603
5760
  }
5604
5761
  async function generateConfigFile(config, configPath) {
5605
- const configDir = path10.dirname(configPath);
5762
+ const configDir = path11.dirname(configPath);
5606
5763
  await fs9.mkdir(configDir, { recursive: true });
5607
- const worktreeDirRelative = path10.relative(configDir, config.worktreeDir);
5764
+ const worktreeDirRelative = path11.relative(configDir, config.worktreeDir);
5608
5765
  const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
5609
5766
  const repoName = extractRepoNameFromUrl(config.repoUrl);
5610
5767
  const repository = {
@@ -5613,7 +5770,7 @@ async function generateConfigFile(config, configPath) {
5613
5770
  worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
5614
5771
  };
5615
5772
  if (config.bareRepoDir) {
5616
- const bareRepoDirRelative = path10.relative(configDir, config.bareRepoDir);
5773
+ const bareRepoDirRelative = path11.relative(configDir, config.bareRepoDir);
5617
5774
  const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
5618
5775
  repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
5619
5776
  }
@@ -5634,11 +5791,11 @@ export default ${serializeToESM(configObject)};
5634
5791
  await fs9.writeFile(configPath, configContent, "utf-8");
5635
5792
  }
5636
5793
  function getDefaultConfigPath() {
5637
- return path10.join(process.cwd(), "sync-worktrees.config.js");
5794
+ return path11.join(process.cwd(), "sync-worktrees.config.js");
5638
5795
  }
5639
5796
  async function findConfigInCwd(cwd = process.cwd()) {
5640
5797
  for (const name of CONFIG_FILE_NAMES) {
5641
- const full = path10.join(cwd, name);
5798
+ const full = path11.join(cwd, name);
5642
5799
  try {
5643
5800
  await fs9.access(full);
5644
5801
  return full;
@@ -5649,7 +5806,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
5649
5806
  }
5650
5807
 
5651
5808
  // src/utils/interactive.ts
5652
- import * as path11 from "path";
5809
+ import * as path12 from "path";
5653
5810
  import { confirm, input, select } from "@inquirer/prompts";
5654
5811
  async function promptForConfig(partialConfig) {
5655
5812
  console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
@@ -5689,8 +5846,8 @@ async function promptForConfig(partialConfig) {
5689
5846
  if (!worktreeDir.trim() && defaultWorktreeDir) {
5690
5847
  worktreeDir = defaultWorktreeDir;
5691
5848
  }
5692
- if (!path11.isAbsolute(worktreeDir)) {
5693
- worktreeDir = path11.resolve(worktreeDir);
5849
+ if (!path12.isAbsolute(worktreeDir)) {
5850
+ worktreeDir = path12.resolve(worktreeDir);
5694
5851
  }
5695
5852
  }
5696
5853
  let bareRepoDir = partialConfig.bareRepoDir;
@@ -5709,8 +5866,8 @@ async function promptForConfig(partialConfig) {
5709
5866
  return true;
5710
5867
  }
5711
5868
  });
5712
- if (!path11.isAbsolute(bareRepoDir)) {
5713
- bareRepoDir = path11.resolve(bareRepoDir);
5869
+ if (!path12.isAbsolute(bareRepoDir)) {
5870
+ bareRepoDir = path12.resolve(bareRepoDir);
5714
5871
  }
5715
5872
  }
5716
5873
  let runOnce = partialConfig.runOnce;
@@ -5782,8 +5939,8 @@ async function promptForConfig(partialConfig) {
5782
5939
  return true;
5783
5940
  }
5784
5941
  });
5785
- if (!path11.isAbsolute(configPath)) {
5786
- configPath = path11.resolve(configPath);
5942
+ if (!path12.isAbsolute(configPath)) {
5943
+ configPath = path12.resolve(configPath);
5787
5944
  }
5788
5945
  try {
5789
5946
  await generateConfigFile(finalConfig, configPath);
@@ -5801,26 +5958,55 @@ async function promptForConfig(partialConfig) {
5801
5958
  return { config: finalConfig, savedConfigPath };
5802
5959
  }
5803
5960
 
5804
- // src/index.ts
5805
- var cleanupFns = [];
5806
- function setupSignalHandlers() {
5807
- let shuttingDown = false;
5808
- const handler = async (signal) => {
5809
- if (shuttingDown) return;
5810
- shuttingDown = true;
5811
- console.log(`
5812
- Received ${signal}, shutting down gracefully...`);
5813
- for (const fn of cleanupFns) {
5814
- try {
5815
- await fn();
5816
- } catch {
5817
- }
5961
+ // src/utils/signal-handlers.ts
5962
+ var DEFAULT_FORCE_EXIT_MS = 3e3;
5963
+ function setupSignalHandlers(options = {}) {
5964
+ const forceExitMs = options.forceExitMs ?? DEFAULT_FORCE_EXIT_MS;
5965
+ const log = options.log ?? ((msg) => console.log(msg));
5966
+ const exit = options.exit ?? ((code) => process.exit(code));
5967
+ const target = options.process ?? process;
5968
+ const cleanupFns = [];
5969
+ let signalCount = 0;
5970
+ const handler = (signal) => {
5971
+ signalCount += 1;
5972
+ if (signalCount >= 2) {
5973
+ log(`
5974
+ Received second ${signal}, forcing exit.`);
5975
+ exit(130);
5976
+ return;
5977
+ }
5978
+ log(`
5979
+ Received ${signal}, shutting down (Ctrl+C again to force exit)...`);
5980
+ const watchdog = setTimeout(() => {
5981
+ log(`
5982
+ Shutdown took longer than ${forceExitMs}ms, forcing exit.`);
5983
+ exit(130);
5984
+ }, forceExitMs);
5985
+ if (typeof watchdog.unref === "function") {
5986
+ watchdog.unref();
5987
+ }
5988
+ void Promise.allSettled(cleanupFns.map((fn) => Promise.resolve().then(() => fn(true)))).then(() => {
5989
+ clearTimeout(watchdog);
5990
+ exit(0);
5991
+ });
5992
+ };
5993
+ const sigintListener = () => handler("SIGINT");
5994
+ const sigtermListener = () => handler("SIGTERM");
5995
+ target.on("SIGINT", sigintListener);
5996
+ target.on("SIGTERM", sigtermListener);
5997
+ return {
5998
+ register: (fn) => {
5999
+ cleanupFns.push(fn);
6000
+ },
6001
+ dispose: () => {
6002
+ target.removeListener("SIGINT", sigintListener);
6003
+ target.removeListener("SIGTERM", sigtermListener);
5818
6004
  }
5819
- process.exit(0);
5820
6005
  };
5821
- process.on("SIGINT", () => void handler("SIGINT"));
5822
- process.on("SIGTERM", () => void handler("SIGTERM"));
5823
6006
  }
6007
+
6008
+ // src/index.ts
6009
+ var signalHandle = setupSignalHandlers();
5824
6010
  async function runSingleRepository(config) {
5825
6011
  const logger = Logger.createDefault(void 0, config.debug);
5826
6012
  logger.info("\n\u{1F4CB} CLI Command (for future reference):");
@@ -5837,11 +6023,11 @@ async function runSingleRepository(config) {
5837
6023
  await syncService.sync();
5838
6024
  } else {
5839
6025
  const uiService = new InteractiveUIService([syncService], void 0, config.cronSchedule);
5840
- cleanupFns.push(() => uiService.destroy());
6026
+ signalHandle.register((fast) => uiService.destroy(fast));
5841
6027
  await syncService.sync();
5842
6028
  uiService.updateLastSyncTime();
5843
6029
  void uiService.calculateAndUpdateDiskSpace();
5844
- cron3.schedule(config.cronSchedule, async () => {
6030
+ const job = cron3.schedule(config.cronSchedule, async () => {
5845
6031
  try {
5846
6032
  uiService.setStatus("syncing");
5847
6033
  await syncService.sync();
@@ -5852,6 +6038,7 @@ async function runSingleRepository(config) {
5852
6038
  uiService.setStatus("idle");
5853
6039
  }
5854
6040
  });
6041
+ uiService.registerCronJob(job);
5855
6042
  }
5856
6043
  } catch (error) {
5857
6044
  logger.error("\u274C Fatal Error during initialization:", error);
@@ -5918,7 +6105,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
5918
6105
  const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
5919
6106
  const allServices = Array.from(services.values());
5920
6107
  const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel, reloadOptions);
5921
- cleanupFns.push(() => uiService.destroy());
6108
+ signalHandle.register((fast) => uiService.destroy(fast));
5922
6109
  void uiService.calculateAndUpdateDiskSpace();
5923
6110
  uiService.setupCronJobs();
5924
6111
  uiService.addLog(`\u{1F4CB} ${repositories.length} repositories configured`);
@@ -6013,13 +6200,12 @@ async function runInteractive(partial, options) {
6013
6200
  await runSingleRepository(config);
6014
6201
  }
6015
6202
  async function main() {
6016
- setupSignalHandlers();
6017
6203
  const options = parseArguments();
6018
6204
  if (!options.config && !options.repoUrl && !options.worktreeDir) {
6019
6205
  const discovered = await findConfigInCwd();
6020
6206
  if (discovered) {
6021
6207
  options.config = discovered;
6022
- console.log(`\u{1F4C4} Using config: ${path12.relative(process.cwd(), discovered)}`);
6208
+ console.log(`\u{1F4C4} Using config: ${path13.relative(process.cwd(), discovered)}`);
6023
6209
  }
6024
6210
  }
6025
6211
  if (options.config) {