sync-worktrees 3.4.0 → 3.6.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";
@@ -25,7 +25,8 @@ var GIT_CONSTANTS = {
25
25
  REMOTES: "refs/remotes/origin",
26
26
  REMOTES_ORIGIN: "refs/remotes/origin/*"
27
27
  },
28
- FETCH_CONFIG: "+refs/heads/*:refs/remotes/origin/*"
28
+ FETCH_CONFIG: "+refs/heads/*:refs/remotes/origin/*",
29
+ PROGRESS_BUCKET_PERCENT: 25
29
30
  };
30
31
  var GIT_OPERATIONS = {
31
32
  MERGE_HEAD: "MERGE_HEAD",
@@ -219,9 +220,9 @@ var WorktreeError = class extends SyncWorktreesError {
219
220
  }
220
221
  };
221
222
  var WorktreeNotCleanError = class extends WorktreeError {
222
- constructor(path13, reasons) {
223
- super(`Worktree at '${path13}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
224
- this.path = path13;
223
+ constructor(path14, reasons) {
224
+ super(`Worktree at '${path14}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
225
+ this.path = path14;
225
226
  this.reasons = reasons;
226
227
  }
227
228
  };
@@ -673,7 +674,7 @@ var ConfigLoaderService = class {
673
674
 
674
675
  // src/services/InteractiveUIService.tsx
675
676
  import React8 from "react";
676
- import * as path9 from "path";
677
+ import * as path10 from "path";
677
678
  import { render } from "ink";
678
679
  import * as cron2 from "node-cron";
679
680
  import pLimit2 from "p-limit";
@@ -1949,7 +1950,7 @@ var App_default = App;
1949
1950
 
1950
1951
  // src/services/worktree-sync.service.ts
1951
1952
  import * as fs6 from "fs/promises";
1952
- import * as path7 from "path";
1953
+ import * as path8 from "path";
1953
1954
  import pLimit from "p-limit";
1954
1955
  import * as lockfile from "proper-lockfile";
1955
1956
 
@@ -2179,7 +2180,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
2179
2180
 
2180
2181
  // src/services/git.service.ts
2181
2182
  import * as fs4 from "fs/promises";
2182
- import * as path5 from "path";
2183
+ import * as path6 from "path";
2183
2184
  import simpleGit4 from "simple-git";
2184
2185
 
2185
2186
  // src/utils/worktree-list-parser.ts
@@ -2323,11 +2324,13 @@ function defaultConsoleOutput(msg, level) {
2323
2324
  }
2324
2325
 
2325
2326
  // src/services/sparse-checkout.service.ts
2327
+ import * as path3 from "path";
2326
2328
  import simpleGit from "simple-git";
2327
2329
  var SparseCheckoutService = class {
2328
2330
  logger;
2329
2331
  gitFactory;
2330
2332
  warnedConfigs = /* @__PURE__ */ new WeakSet();
2333
+ matcherCache = /* @__PURE__ */ new WeakMap();
2331
2334
  constructor(logger, gitFactory) {
2332
2335
  this.logger = logger ?? Logger.createDefault();
2333
2336
  this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
@@ -2411,11 +2414,66 @@ var SparseCheckoutService = class {
2411
2414
  const bt = b.map((x) => x.trim());
2412
2415
  return at.every((v, i) => v === bt[i]);
2413
2416
  }
2417
+ /**
2418
+ * Decide whether a list of changed file paths intersects the sparse-checkout
2419
+ * set defined by `cfg`. Used to skip fast-forward updates when upstream
2420
+ * commits only touch files outside the materialized worktree.
2421
+ *
2422
+ * Cone mode materializes:
2423
+ * - all files at the repository root,
2424
+ * - all files directly inside every ancestor of an included directory
2425
+ * (e.g. include `tools/build` keeps `tools/foo.txt` checked out too),
2426
+ * - everything inside an included directory.
2427
+ * We mirror those rules here. Missing the ancestor-files case would let
2428
+ * stale files linger when only those parent files change upstream.
2429
+ *
2430
+ * No-cone mode: gitignore-style matching with negation is non-trivial and
2431
+ * not implemented here yet. We return `true` so the caller falls back to
2432
+ * the safe behavior of always running the update.
2433
+ *
2434
+ * The matcher derived from `cfg` is cached on the cfg object identity
2435
+ * (WeakMap), so callers should reuse the same `cfg` reference across
2436
+ * invocations to benefit from the cache.
2437
+ */
2438
+ pathsTouchSparse(changedPaths, cfg) {
2439
+ if (changedPaths.length === 0) return false;
2440
+ const matcher = this.getMatcher(cfg);
2441
+ if (matcher.mode === "no-cone") return true;
2442
+ if (matcher.patterns.length === 0) return true;
2443
+ return changedPaths.some((p) => {
2444
+ if (!p.includes("/")) return true;
2445
+ for (const pat of matcher.patterns) {
2446
+ if (p === pat || p.startsWith(pat + "/")) return true;
2447
+ }
2448
+ return matcher.ancestorDirs.has(path3.posix.dirname(p));
2449
+ });
2450
+ }
2451
+ getMatcher(cfg) {
2452
+ const cached = this.matcherCache.get(cfg);
2453
+ if (cached) return cached;
2454
+ const mode = this.resolveMode(cfg);
2455
+ if (mode === "no-cone") {
2456
+ const matcher2 = { mode, patterns: [], ancestorDirs: /* @__PURE__ */ new Set() };
2457
+ this.matcherCache.set(cfg, matcher2);
2458
+ return matcher2;
2459
+ }
2460
+ const patterns = cfg.include.map((p) => p.trim()).filter((p) => p.length > 0).map((p) => p.endsWith("/") ? p.slice(0, -1) : p);
2461
+ const ancestorDirs = /* @__PURE__ */ new Set();
2462
+ for (const pat of patterns) {
2463
+ const parts = pat.split("/");
2464
+ for (let i = 1; i < parts.length; i++) {
2465
+ ancestorDirs.add(parts.slice(0, i).join("/"));
2466
+ }
2467
+ }
2468
+ const matcher = { mode, patterns, ancestorDirs };
2469
+ this.matcherCache.set(cfg, matcher);
2470
+ return matcher;
2471
+ }
2414
2472
  };
2415
2473
 
2416
2474
  // src/services/worktree-metadata.service.ts
2417
2475
  import * as fs2 from "fs/promises";
2418
- import * as path3 from "path";
2476
+ import * as path4 from "path";
2419
2477
  import simpleGit2 from "simple-git";
2420
2478
  var WorktreeMetadataService = class {
2421
2479
  logger;
@@ -2428,7 +2486,7 @@ var WorktreeMetadataService = class {
2428
2486
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
2429
2487
  */
2430
2488
  getWorktreeDirectoryName(worktreePath) {
2431
- return path3.basename(worktreePath);
2489
+ return path4.basename(worktreePath);
2432
2490
  }
2433
2491
  async getMetadataPath(bareRepoPath, worktreeName) {
2434
2492
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -2436,7 +2494,7 @@ var WorktreeMetadataService = class {
2436
2494
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
2437
2495
  );
2438
2496
  }
2439
- return path3.join(
2497
+ return path4.join(
2440
2498
  bareRepoPath,
2441
2499
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
2442
2500
  worktreeName,
@@ -2449,7 +2507,7 @@ var WorktreeMetadataService = class {
2449
2507
  }
2450
2508
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
2451
2509
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
2452
- await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
2510
+ await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
2453
2511
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
2454
2512
  let renamed = false;
2455
2513
  try {
@@ -2642,7 +2700,7 @@ var WorktreeMetadataService = class {
2642
2700
 
2643
2701
  // src/services/worktree-status.service.ts
2644
2702
  import * as fs3 from "fs/promises";
2645
- import * as path4 from "path";
2703
+ import * as path5 from "path";
2646
2704
  import simpleGit3 from "simple-git";
2647
2705
  var OPERATION_FILES = [
2648
2706
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
@@ -2845,7 +2903,7 @@ var WorktreeStatusService = class {
2845
2903
  async detectOperationFile(gitDir) {
2846
2904
  const results = await Promise.all(
2847
2905
  OPERATION_FILES.map(
2848
- ({ file }) => fs3.access(path4.join(gitDir, file)).then(
2906
+ ({ file }) => fs3.access(path5.join(gitDir, file)).then(
2849
2907
  () => true,
2850
2908
  () => false
2851
2909
  )
@@ -2966,14 +3024,14 @@ var WorktreeStatusService = class {
2966
3024
  }
2967
3025
  }
2968
3026
  async resolveGitDir(worktreePath) {
2969
- const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
3027
+ const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
2970
3028
  try {
2971
3029
  const stat3 = await fs3.stat(gitPath);
2972
3030
  if (stat3.isFile()) {
2973
3031
  const content = await fs3.readFile(gitPath, "utf-8");
2974
3032
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
2975
3033
  if (gitdirMatch) {
2976
- return path4.resolve(worktreePath, gitdirMatch[1].trim());
3034
+ return path5.resolve(worktreePath, gitdirMatch[1].trim());
2977
3035
  }
2978
3036
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
2979
3037
  }
@@ -2987,7 +3045,7 @@ var WorktreeStatusService = class {
2987
3045
  }
2988
3046
  }
2989
3047
  createGitInstance(worktreePath) {
2990
- const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
3048
+ const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
2991
3049
  let git = this.gitInstances.get(key);
2992
3050
  if (!git) {
2993
3051
  git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
@@ -3010,7 +3068,7 @@ var GitService = class {
3010
3068
  this.config = config;
3011
3069
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
3012
3070
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
3013
- this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3071
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
3014
3072
  this.metadataService = new WorktreeMetadataService(this.logger);
3015
3073
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
3016
3074
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -3038,16 +3096,36 @@ var GitService = class {
3038
3096
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
3039
3097
  }
3040
3098
  getCachedGit(dirPath, useLfsSkip = false) {
3041
- const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3099
+ const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
3042
3100
  let git = this.gitInstances.get(key);
3043
3101
  if (!git) {
3044
- const block = this.getFetchTimeoutMs();
3045
- const base = block > 0 ? simpleGit4(dirPath, { timeout: { block } }) : simpleGit4(dirPath);
3102
+ const base = simpleGit4(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
3046
3103
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
3047
3104
  this.gitInstances.set(key, git);
3048
3105
  }
3049
3106
  return git;
3050
3107
  }
3108
+ buildSimpleGitOptions(blockMs) {
3109
+ const options = { progress: this.makeProgressHandler() };
3110
+ if (blockMs > 0) options.timeout = { block: blockMs };
3111
+ return options;
3112
+ }
3113
+ makeProgressHandler() {
3114
+ const lastBucket = /* @__PURE__ */ new Map();
3115
+ return (event) => {
3116
+ if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
3117
+ const key = `${event.method}:${event.stage}`;
3118
+ const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
3119
+ let last = lastBucket.get(key) ?? -1;
3120
+ if (bucket < last) {
3121
+ last = -1;
3122
+ }
3123
+ if (bucket <= last && event.progress < 100) return;
3124
+ lastBucket.set(key, bucket);
3125
+ const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
3126
+ this.logger.info(` \u21B3 ${event.method} ${event.stage}: ${event.progress}% (${total})`);
3127
+ };
3128
+ }
3051
3129
  updateLogger(logger) {
3052
3130
  this.logger = logger;
3053
3131
  this.sparseCheckoutService.updateLogger(logger);
@@ -3055,14 +3133,13 @@ var GitService = class {
3055
3133
  async initialize() {
3056
3134
  const { repoUrl } = this.config;
3057
3135
  try {
3058
- await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
3136
+ await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
3059
3137
  } catch {
3060
3138
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
3061
- await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
3062
- const cloneBlock = this.getCloneTimeoutMs();
3063
- const cloneBase = cloneBlock > 0 ? simpleGit4({ timeout: { block: cloneBlock } }) : simpleGit4();
3139
+ await fs4.mkdir(path6.dirname(this.bareRepoPath), { recursive: true });
3140
+ const cloneBase = simpleGit4(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
3064
3141
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
3065
- await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
3142
+ await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
3066
3143
  this.logger.info("\u2705 Clone successful.");
3067
3144
  }
3068
3145
  const bareGit = this.getCachedGit(this.bareRepoPath);
@@ -3076,19 +3153,19 @@ var GitService = class {
3076
3153
  await bareGit.addConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*");
3077
3154
  }
3078
3155
  this.logger.info("Fetching remote branches...");
3079
- await bareGit.fetch(["--all"]);
3156
+ await bareGit.fetch(["--all", "--progress"]);
3080
3157
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
3081
- this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
3158
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
3082
3159
  let needsMainWorktree = true;
3083
3160
  try {
3084
3161
  const worktrees = await this.getWorktreesFromBare(bareGit);
3085
- needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
3162
+ needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
3086
3163
  } catch {
3087
3164
  }
3088
3165
  if (needsMainWorktree) {
3089
3166
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
3090
3167
  await fs4.mkdir(this.config.worktreeDir, { recursive: true });
3091
- const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
3168
+ const absoluteWorktreePath = path6.resolve(this.mainWorktreePath);
3092
3169
  const branches = await bareGit.branch();
3093
3170
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
3094
3171
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -3124,7 +3201,7 @@ var GitService = class {
3124
3201
  }
3125
3202
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
3126
3203
  const mainWorktreeRegistered = updatedWorktrees.some(
3127
- (w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
3204
+ (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
3128
3205
  );
3129
3206
  if (!mainWorktreeRegistered) {
3130
3207
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -3154,12 +3231,12 @@ var GitService = class {
3154
3231
  this.assertInitialized();
3155
3232
  this.logger.info("Fetching latest data from remote...");
3156
3233
  const git = this.getCachedGit(this.mainWorktreePath, this.isLfsSkipEnabled());
3157
- await git.fetch(["--all", "--prune"]);
3234
+ await git.fetch(["--all", "--prune", "--progress"]);
3158
3235
  }
3159
3236
  async fetchBranch(branchName) {
3160
3237
  this.assertInitialized();
3161
3238
  const git = this.getCachedGit(this.mainWorktreePath, this.isLfsSkipEnabled());
3162
- await git.fetch(["origin", branchName, "--prune"]);
3239
+ await git.fetch(["origin", branchName, "--prune", "--progress"]);
3163
3240
  }
3164
3241
  assertInitialized() {
3165
3242
  if (!this.git) {
@@ -3207,7 +3284,7 @@ var GitService = class {
3207
3284
  const existence = await Promise.all(
3208
3285
  lfsFileList.map(async (f) => {
3209
3286
  try {
3210
- await fs4.access(path5.join(worktreePath, f));
3287
+ await fs4.access(path6.join(worktreePath, f));
3211
3288
  return f;
3212
3289
  } catch {
3213
3290
  return null;
@@ -3235,7 +3312,7 @@ var GitService = class {
3235
3312
  let allDownloaded = true;
3236
3313
  const notDownloaded = [];
3237
3314
  for (const file of samplesToCheck) {
3238
- const filePath = path5.join(worktreePath, file);
3315
+ const filePath = path6.join(worktreePath, file);
3239
3316
  try {
3240
3317
  const handle = await fs4.open(filePath, "r");
3241
3318
  try {
@@ -3324,12 +3401,12 @@ var GitService = class {
3324
3401
  }
3325
3402
  async addWorktree(branchName, worktreePath) {
3326
3403
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
3327
- const absoluteWorktreePath = path5.resolve(worktreePath);
3328
- await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
3404
+ const absoluteWorktreePath = path6.resolve(worktreePath);
3405
+ await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
3329
3406
  try {
3330
3407
  await fs4.access(absoluteWorktreePath);
3331
3408
  const worktrees = await this.getWorktreesFromBare(bareGit);
3332
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
3409
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3333
3410
  if (isValidWorktree) {
3334
3411
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3335
3412
  return;
@@ -3374,7 +3451,7 @@ var GitService = class {
3374
3451
  }
3375
3452
  if (errorMessage.includes("already registered worktree")) {
3376
3453
  const worktrees = await this.getWorktreesFromBare(bareGit);
3377
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
3454
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3378
3455
  if (existingWorktree && !existingWorktree.isPrunable) {
3379
3456
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
3380
3457
  return;
@@ -3420,7 +3497,7 @@ var GitService = class {
3420
3497
  try {
3421
3498
  await fs4.access(absoluteWorktreePath);
3422
3499
  const worktrees = await this.getWorktreesFromBare(bareGit);
3423
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
3500
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
3424
3501
  if (isValidWorktree) {
3425
3502
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
3426
3503
  return;
@@ -3450,7 +3527,7 @@ var GitService = class {
3450
3527
  const fallbackErrorMessage = getErrorMessage(fallbackError);
3451
3528
  if (fallbackErrorMessage.includes("already registered worktree")) {
3452
3529
  const worktrees = await this.getWorktreesFromBare(bareGit);
3453
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
3530
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
3454
3531
  if (existingWorktree && !existingWorktree.isPrunable) {
3455
3532
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
3456
3533
  return;
@@ -3673,6 +3750,23 @@ var GitService = class {
3673
3750
  return false;
3674
3751
  }
3675
3752
  }
3753
+ async getChangedPathsInRange(worktreePath, fromRef, toRef) {
3754
+ const worktreeGit = this.getCachedGit(worktreePath);
3755
+ try {
3756
+ const out = await worktreeGit.raw([
3757
+ "-c",
3758
+ "core.quotePath=false",
3759
+ "diff",
3760
+ "--name-only",
3761
+ "--no-renames",
3762
+ `${fromRef}..${toRef}`
3763
+ ]);
3764
+ return out.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.length > 0);
3765
+ } catch (error) {
3766
+ this.logger.warn(`Failed to compute diff ${fromRef}..${toRef} in ${worktreePath}: ${getErrorMessage(error)}`);
3767
+ return null;
3768
+ }
3769
+ }
3676
3770
  async compareTreeContent(worktreePath, branch) {
3677
3771
  const worktreeGit = this.getCachedGit(worktreePath);
3678
3772
  try {
@@ -3757,7 +3851,7 @@ var GitService = class {
3757
3851
  // src/services/path-resolution.service.ts
3758
3852
  import { createHash } from "crypto";
3759
3853
  import * as fs5 from "fs";
3760
- import * as path6 from "path";
3854
+ import * as path7 from "path";
3761
3855
  var BRANCH_STEM_MAX = 80;
3762
3856
  var BRANCH_HASH_LEN = 8;
3763
3857
  var PathResolutionService = class {
@@ -3767,22 +3861,22 @@ var PathResolutionService = class {
3767
3861
  return `${stem}-${hash}`;
3768
3862
  }
3769
3863
  getBranchWorktreePath(worktreeDir, branchName) {
3770
- return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
3864
+ return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
3771
3865
  }
3772
3866
  resolveRealPath(inputPath) {
3773
- const absolute = path6.resolve(inputPath);
3867
+ const absolute = path7.resolve(inputPath);
3774
3868
  const missing = [];
3775
3869
  let current = absolute;
3776
3870
  while (!fs5.existsSync(current)) {
3777
- const parent = path6.dirname(current);
3871
+ const parent = path7.dirname(current);
3778
3872
  if (parent === current) {
3779
3873
  return absolute;
3780
3874
  }
3781
- missing.unshift(path6.basename(current));
3875
+ missing.unshift(path7.basename(current));
3782
3876
  current = parent;
3783
3877
  }
3784
3878
  try {
3785
- return path6.join(fs5.realpathSync(current), ...missing);
3879
+ return path7.join(fs5.realpathSync(current), ...missing);
3786
3880
  } catch {
3787
3881
  return absolute;
3788
3882
  }
@@ -3792,7 +3886,7 @@ var PathResolutionService = class {
3792
3886
  const a = fold(resolved);
3793
3887
  const b = fold(resolvedBase);
3794
3888
  if (a === b) return true;
3795
- return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
3889
+ return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
3796
3890
  }
3797
3891
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
3798
3892
  const resolved = this.resolveRealPath(worktreePath);
@@ -3800,7 +3894,7 @@ var PathResolutionService = class {
3800
3894
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
3801
3895
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
3802
3896
  }
3803
- return path6.relative(resolvedBase, resolved);
3897
+ return path7.relative(resolvedBase, resolved);
3804
3898
  }
3805
3899
  isPathInsideBaseDir(targetPath, baseDir) {
3806
3900
  const resolved = this.resolveRealPath(targetPath);
@@ -3906,7 +4000,7 @@ var WorktreeSyncService = class {
3906
4000
  };
3907
4001
  }
3908
4002
  const barePath = this.gitService.getBareRepoPath();
3909
- const lockTarget = path7.join(barePath, "HEAD");
4003
+ const lockTarget = path8.join(barePath, "HEAD");
3910
4004
  try {
3911
4005
  await fs6.access(lockTarget);
3912
4006
  } catch {
@@ -4104,12 +4198,12 @@ var WorktreeSyncService = class {
4104
4198
  }
4105
4199
  const reservedPaths = /* @__PURE__ */ new Map();
4106
4200
  for (const w of worktrees) {
4107
- reservedPaths.set(path7.resolve(w.path), w.branch);
4201
+ reservedPaths.set(path8.resolve(w.path), w.branch);
4108
4202
  }
4109
4203
  const plan = [];
4110
4204
  for (const branchName of newBranches) {
4111
4205
  const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
4112
- const resolved = path7.resolve(worktreePath);
4206
+ const resolved = path8.resolve(worktreePath);
4113
4207
  const conflict = reservedPaths.get(resolved);
4114
4208
  if (conflict && conflict !== branchName) {
4115
4209
  this.logger.error(
@@ -4314,12 +4408,12 @@ var WorktreeSyncService = class {
4314
4408
  }
4315
4409
  async updateExistingWorktrees(worktrees, remoteBranches) {
4316
4410
  this.logger.info("Step 4: Checking for worktrees that need updates...");
4317
- const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4411
+ const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4318
4412
  try {
4319
4413
  const diverged = await fs6.readdir(divergedDir);
4320
4414
  if (diverged.length > 0) {
4321
4415
  this.logger.info(
4322
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
4416
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
4323
4417
  );
4324
4418
  }
4325
4419
  } catch {
@@ -4349,7 +4443,23 @@ var WorktreeSyncService = class {
4349
4443
  return { action: "diverged", worktree };
4350
4444
  }
4351
4445
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
4352
- return isBehind ? { action: "update", worktree } : null;
4446
+ if (!isBehind) return null;
4447
+ const sparseCfg = this.config.sparseCheckout;
4448
+ if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
4449
+ const sparseService = this.gitService.getSparseCheckoutService();
4450
+ if (sparseService.resolveMode(sparseCfg) === "cone") {
4451
+ const diff = await this.gitService.getChangedPathsInRange(
4452
+ worktree.path,
4453
+ "HEAD",
4454
+ `origin/${worktree.branch}`
4455
+ );
4456
+ if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
4457
+ this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
4458
+ return null;
4459
+ }
4460
+ }
4461
+ }
4462
+ return { action: "update", worktree };
4353
4463
  })
4354
4464
  )
4355
4465
  );
@@ -4427,13 +4537,13 @@ var WorktreeSyncService = class {
4427
4537
  }
4428
4538
  async cleanupOrphanedDirectories(worktrees) {
4429
4539
  try {
4430
- const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
4540
+ const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
4431
4541
  const allDirs = await fs6.readdir(this.config.worktreeDir);
4432
4542
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
4433
4543
  const orphanedDirs = [];
4434
4544
  for (const dir of regularDirs) {
4435
4545
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
4436
- return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
4546
+ return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
4437
4547
  });
4438
4548
  if (!isPartOfWorktree) {
4439
4549
  orphanedDirs.push(dir);
@@ -4442,7 +4552,7 @@ var WorktreeSyncService = class {
4442
4552
  if (orphanedDirs.length > 0) {
4443
4553
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
4444
4554
  for (const dir of orphanedDirs) {
4445
- const dirPath = path7.join(this.config.worktreeDir, dir);
4555
+ const dirPath = path8.join(this.config.worktreeDir, dir);
4446
4556
  try {
4447
4557
  const stat3 = await fs6.stat(dirPath);
4448
4558
  if (stat3.isDirectory()) {
@@ -4476,7 +4586,7 @@ var WorktreeSyncService = class {
4476
4586
  } else {
4477
4587
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
4478
4588
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
4479
- const relativePath = path7.relative(process.cwd(), divergedPath);
4589
+ const relativePath = path8.relative(process.cwd(), divergedPath);
4480
4590
  this.logger.info(` Moved to: ${relativePath}`);
4481
4591
  this.logger.info(` Your local changes are preserved. To review:`);
4482
4592
  this.logger.info(` cd ${relativePath}`);
@@ -4500,12 +4610,12 @@ var WorktreeSyncService = class {
4500
4610
  }
4501
4611
  }
4502
4612
  async divergeWorktree(worktreePath, branchName) {
4503
- const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4613
+ const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
4504
4614
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
4505
4615
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
4506
4616
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
4507
4617
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
4508
- const divergedPath = path7.join(divergedBaseDir, divergedName);
4618
+ const divergedPath = path8.join(divergedBaseDir, divergedName);
4509
4619
  await fs6.mkdir(divergedBaseDir, { recursive: true });
4510
4620
  try {
4511
4621
  await fs6.rename(worktreePath, divergedPath);
@@ -4532,7 +4642,7 @@ var WorktreeSyncService = class {
4532
4642
  Original worktree location: ${worktreePath}`
4533
4643
  };
4534
4644
  await fs6.writeFile(
4535
- path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4645
+ path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
4536
4646
  JSON.stringify(metadata, null, 2)
4537
4647
  );
4538
4648
  return divergedPath;
@@ -4541,7 +4651,7 @@ var WorktreeSyncService = class {
4541
4651
 
4542
4652
  // src/services/file-copy.service.ts
4543
4653
  import * as fs7 from "fs/promises";
4544
- import * as path8 from "path";
4654
+ import * as path9 from "path";
4545
4655
  import { glob } from "glob";
4546
4656
  var DEFAULT_IGNORE_PATTERNS = [
4547
4657
  "**/node_modules/**",
@@ -4568,8 +4678,8 @@ var FileCopyService = class {
4568
4678
  }
4569
4679
  const filesToCopy = await this.expandPatterns(sourceDir, patterns);
4570
4680
  for (const relativePath of filesToCopy) {
4571
- const sourcePath = path8.join(sourceDir, relativePath);
4572
- const destPath = path8.join(destDir, relativePath);
4681
+ const sourcePath = path9.join(sourceDir, relativePath);
4682
+ const destPath = path9.join(destDir, relativePath);
4573
4683
  try {
4574
4684
  const copied = await this.copyFile(sourcePath, destPath);
4575
4685
  if (copied) {
@@ -4610,7 +4720,7 @@ var FileCopyService = class {
4610
4720
  return false;
4611
4721
  } catch {
4612
4722
  }
4613
- const destDir = path8.dirname(destPath);
4723
+ const destDir = path9.dirname(destPath);
4614
4724
  await fs7.mkdir(destDir, { recursive: true });
4615
4725
  await fs7.copyFile(sourcePath, destPath);
4616
4726
  return true;
@@ -4828,6 +4938,8 @@ var AppEventEmitter = class {
4828
4938
 
4829
4939
  // src/services/InteractiveUIService.tsx
4830
4940
  import * as fs8 from "fs/promises";
4941
+ var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
4942
+ var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
4831
4943
  var InteractiveUIService = class {
4832
4944
  app = null;
4833
4945
  syncServices;
@@ -4935,6 +5047,9 @@ var InteractiveUIService = class {
4935
5047
  }
4936
5048
  this.cronJobs = [];
4937
5049
  }
5050
+ registerCronJob(job) {
5051
+ this.cronJobs.push(job);
5052
+ }
4938
5053
  renderUI() {
4939
5054
  if (this.app) {
4940
5055
  this.app.unmount();
@@ -4958,8 +5073,8 @@ var InteractiveUIService = class {
4958
5073
  getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
4959
5074
  getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
4960
5075
  deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
4961
- openEditorInWorktree: (path13) => this.openEditorInWorktree(path13),
4962
- openTerminalInWorktree: (repoIndex, path13, branchName) => this.openTerminalInWorktree(repoIndex, path13, branchName),
5076
+ openEditorInWorktree: (path14) => this.openEditorInWorktree(path14),
5077
+ openTerminalInWorktree: (repoIndex, path14, branchName) => this.openTerminalInWorktree(repoIndex, path14, branchName),
4963
5078
  copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
4964
5079
  createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
4965
5080
  executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
@@ -5049,18 +5164,17 @@ var InteractiveUIService = class {
5049
5164
  await this.destroy();
5050
5165
  process.exit(0);
5051
5166
  }
5052
- async waitForInProgressSyncs() {
5167
+ async waitForInProgressSyncs(timeoutMs = WAIT_SYNC_DEFAULT_TIMEOUT_MS) {
5053
5168
  const inProgressServices = this.syncServices.filter((s) => s.isSyncInProgress());
5054
5169
  if (inProgressServices.length === 0) {
5055
5170
  return;
5056
5171
  }
5057
5172
  this.addLog(`Waiting for ${inProgressServices.length} in-progress sync(s) to finish...`, "info");
5058
5173
  const syncChecks = inProgressServices.map(async (service) => {
5059
- const timeout = 3e4;
5060
5174
  const checkInterval = 500;
5061
5175
  const startTime = Date.now();
5062
5176
  while (service.isSyncInProgress()) {
5063
- if (Date.now() - startTime > timeout) {
5177
+ if (Date.now() - startTime > timeoutMs) {
5064
5178
  throw new Error("Timeout waiting for sync operations to complete");
5065
5179
  }
5066
5180
  await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
@@ -5070,7 +5184,7 @@ var InteractiveUIService = class {
5070
5184
  await Promise.all(syncChecks);
5071
5185
  } catch {
5072
5186
  this.addLog(
5073
- "Warning: Timeout waiting for sync operations to complete after 30s. Proceeding with potential data loss risk.",
5187
+ `Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
5074
5188
  "warn"
5075
5189
  );
5076
5190
  }
@@ -5196,7 +5310,7 @@ var InteractiveUIService = class {
5196
5310
  }
5197
5311
  const service = this.syncServices[repoIndex];
5198
5312
  const worktreeDir = service.config.worktreeDir;
5199
- const divergedDir = path9.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5313
+ const divergedDir = path10.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5200
5314
  let dirEntries;
5201
5315
  try {
5202
5316
  dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
@@ -5206,8 +5320,8 @@ var InteractiveUIService = class {
5206
5320
  const subdirs = dirEntries.filter((e) => e.isDirectory());
5207
5321
  const results = await Promise.allSettled(
5208
5322
  subdirs.map(async (entry) => {
5209
- const fullPath = path9.join(divergedDir, entry.name);
5210
- const infoFilePath = path9.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
5323
+ const fullPath = path10.join(divergedDir, entry.name);
5324
+ const infoFilePath = path10.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
5211
5325
  let originalBranch = entry.name;
5212
5326
  let divergedAt = "";
5213
5327
  try {
@@ -5242,11 +5356,11 @@ var InteractiveUIService = class {
5242
5356
  }
5243
5357
  const service = this.syncServices[repoIndex];
5244
5358
  const worktreeDir = service.config.worktreeDir;
5245
- const divergedBase = path9.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5359
+ const divergedBase = path10.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
5246
5360
  if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
5247
5361
  throw new Error(`Invalid diverged directory name: "${name}"`);
5248
5362
  }
5249
- const targetPath = path9.join(divergedBase, name);
5363
+ const targetPath = path10.join(divergedBase, name);
5250
5364
  if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
5251
5365
  throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
5252
5366
  }
@@ -5485,11 +5599,11 @@ var InteractiveUIService = class {
5485
5599
  this.addLog(`Failed to copy files to new branch: ${error}`, "error");
5486
5600
  }
5487
5601
  }
5488
- async destroy() {
5602
+ async destroy(fast = false) {
5489
5603
  this.isDestroyed = true;
5490
5604
  this.cancelCronJobs();
5491
5605
  try {
5492
- await this.waitForInProgressSyncs();
5606
+ await this.waitForInProgressSyncs(fast ? WAIT_SYNC_FAST_TIMEOUT_MS : WAIT_SYNC_DEFAULT_TIMEOUT_MS);
5493
5607
  } catch {
5494
5608
  }
5495
5609
  this.hookExecutionService.cleanup();
@@ -5635,7 +5749,7 @@ function reconstructCliCommand(config) {
5635
5749
 
5636
5750
  // src/utils/config-generator.ts
5637
5751
  import * as fs9 from "fs/promises";
5638
- import * as path10 from "path";
5752
+ import * as path11 from "path";
5639
5753
  function serializeToESM(obj, indent = 0) {
5640
5754
  const spaces = " ".repeat(indent);
5641
5755
  const innerSpaces = " ".repeat(indent + 2);
@@ -5665,9 +5779,9 @@ ${spaces}}`;
5665
5779
  return String(obj);
5666
5780
  }
5667
5781
  async function generateConfigFile(config, configPath) {
5668
- const configDir = path10.dirname(configPath);
5782
+ const configDir = path11.dirname(configPath);
5669
5783
  await fs9.mkdir(configDir, { recursive: true });
5670
- const worktreeDirRelative = path10.relative(configDir, config.worktreeDir);
5784
+ const worktreeDirRelative = path11.relative(configDir, config.worktreeDir);
5671
5785
  const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
5672
5786
  const repoName = extractRepoNameFromUrl(config.repoUrl);
5673
5787
  const repository = {
@@ -5676,7 +5790,7 @@ async function generateConfigFile(config, configPath) {
5676
5790
  worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
5677
5791
  };
5678
5792
  if (config.bareRepoDir) {
5679
- const bareRepoDirRelative = path10.relative(configDir, config.bareRepoDir);
5793
+ const bareRepoDirRelative = path11.relative(configDir, config.bareRepoDir);
5680
5794
  const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
5681
5795
  repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
5682
5796
  }
@@ -5697,11 +5811,11 @@ export default ${serializeToESM(configObject)};
5697
5811
  await fs9.writeFile(configPath, configContent, "utf-8");
5698
5812
  }
5699
5813
  function getDefaultConfigPath() {
5700
- return path10.join(process.cwd(), "sync-worktrees.config.js");
5814
+ return path11.join(process.cwd(), "sync-worktrees.config.js");
5701
5815
  }
5702
5816
  async function findConfigInCwd(cwd = process.cwd()) {
5703
5817
  for (const name of CONFIG_FILE_NAMES) {
5704
- const full = path10.join(cwd, name);
5818
+ const full = path11.join(cwd, name);
5705
5819
  try {
5706
5820
  await fs9.access(full);
5707
5821
  return full;
@@ -5712,7 +5826,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
5712
5826
  }
5713
5827
 
5714
5828
  // src/utils/interactive.ts
5715
- import * as path11 from "path";
5829
+ import * as path12 from "path";
5716
5830
  import { confirm, input, select } from "@inquirer/prompts";
5717
5831
  async function promptForConfig(partialConfig) {
5718
5832
  console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
@@ -5752,8 +5866,8 @@ async function promptForConfig(partialConfig) {
5752
5866
  if (!worktreeDir.trim() && defaultWorktreeDir) {
5753
5867
  worktreeDir = defaultWorktreeDir;
5754
5868
  }
5755
- if (!path11.isAbsolute(worktreeDir)) {
5756
- worktreeDir = path11.resolve(worktreeDir);
5869
+ if (!path12.isAbsolute(worktreeDir)) {
5870
+ worktreeDir = path12.resolve(worktreeDir);
5757
5871
  }
5758
5872
  }
5759
5873
  let bareRepoDir = partialConfig.bareRepoDir;
@@ -5772,8 +5886,8 @@ async function promptForConfig(partialConfig) {
5772
5886
  return true;
5773
5887
  }
5774
5888
  });
5775
- if (!path11.isAbsolute(bareRepoDir)) {
5776
- bareRepoDir = path11.resolve(bareRepoDir);
5889
+ if (!path12.isAbsolute(bareRepoDir)) {
5890
+ bareRepoDir = path12.resolve(bareRepoDir);
5777
5891
  }
5778
5892
  }
5779
5893
  let runOnce = partialConfig.runOnce;
@@ -5845,8 +5959,8 @@ async function promptForConfig(partialConfig) {
5845
5959
  return true;
5846
5960
  }
5847
5961
  });
5848
- if (!path11.isAbsolute(configPath)) {
5849
- configPath = path11.resolve(configPath);
5962
+ if (!path12.isAbsolute(configPath)) {
5963
+ configPath = path12.resolve(configPath);
5850
5964
  }
5851
5965
  try {
5852
5966
  await generateConfigFile(finalConfig, configPath);
@@ -5864,26 +5978,55 @@ async function promptForConfig(partialConfig) {
5864
5978
  return { config: finalConfig, savedConfigPath };
5865
5979
  }
5866
5980
 
5867
- // src/index.ts
5868
- var cleanupFns = [];
5869
- function setupSignalHandlers() {
5870
- let shuttingDown = false;
5871
- const handler = async (signal) => {
5872
- if (shuttingDown) return;
5873
- shuttingDown = true;
5874
- console.log(`
5875
- Received ${signal}, shutting down gracefully...`);
5876
- for (const fn of cleanupFns) {
5877
- try {
5878
- await fn();
5879
- } catch {
5880
- }
5981
+ // src/utils/signal-handlers.ts
5982
+ var DEFAULT_FORCE_EXIT_MS = 3e3;
5983
+ function setupSignalHandlers(options = {}) {
5984
+ const forceExitMs = options.forceExitMs ?? DEFAULT_FORCE_EXIT_MS;
5985
+ const log = options.log ?? ((msg) => console.log(msg));
5986
+ const exit = options.exit ?? ((code) => process.exit(code));
5987
+ const target = options.process ?? process;
5988
+ const cleanupFns = [];
5989
+ let signalCount = 0;
5990
+ const handler = (signal) => {
5991
+ signalCount += 1;
5992
+ if (signalCount >= 2) {
5993
+ log(`
5994
+ Received second ${signal}, forcing exit.`);
5995
+ exit(130);
5996
+ return;
5997
+ }
5998
+ log(`
5999
+ Received ${signal}, shutting down (Ctrl+C again to force exit)...`);
6000
+ const watchdog = setTimeout(() => {
6001
+ log(`
6002
+ Shutdown took longer than ${forceExitMs}ms, forcing exit.`);
6003
+ exit(130);
6004
+ }, forceExitMs);
6005
+ if (typeof watchdog.unref === "function") {
6006
+ watchdog.unref();
6007
+ }
6008
+ void Promise.allSettled(cleanupFns.map((fn) => Promise.resolve().then(() => fn(true)))).then(() => {
6009
+ clearTimeout(watchdog);
6010
+ exit(0);
6011
+ });
6012
+ };
6013
+ const sigintListener = () => handler("SIGINT");
6014
+ const sigtermListener = () => handler("SIGTERM");
6015
+ target.on("SIGINT", sigintListener);
6016
+ target.on("SIGTERM", sigtermListener);
6017
+ return {
6018
+ register: (fn) => {
6019
+ cleanupFns.push(fn);
6020
+ },
6021
+ dispose: () => {
6022
+ target.removeListener("SIGINT", sigintListener);
6023
+ target.removeListener("SIGTERM", sigtermListener);
5881
6024
  }
5882
- process.exit(0);
5883
6025
  };
5884
- process.on("SIGINT", () => void handler("SIGINT"));
5885
- process.on("SIGTERM", () => void handler("SIGTERM"));
5886
6026
  }
6027
+
6028
+ // src/index.ts
6029
+ var signalHandle = setupSignalHandlers();
5887
6030
  async function runSingleRepository(config) {
5888
6031
  const logger = Logger.createDefault(void 0, config.debug);
5889
6032
  logger.info("\n\u{1F4CB} CLI Command (for future reference):");
@@ -5900,11 +6043,11 @@ async function runSingleRepository(config) {
5900
6043
  await syncService.sync();
5901
6044
  } else {
5902
6045
  const uiService = new InteractiveUIService([syncService], void 0, config.cronSchedule);
5903
- cleanupFns.push(() => uiService.destroy());
6046
+ signalHandle.register((fast) => uiService.destroy(fast));
5904
6047
  await syncService.sync();
5905
6048
  uiService.updateLastSyncTime();
5906
6049
  void uiService.calculateAndUpdateDiskSpace();
5907
- cron3.schedule(config.cronSchedule, async () => {
6050
+ const job = cron3.schedule(config.cronSchedule, async () => {
5908
6051
  try {
5909
6052
  uiService.setStatus("syncing");
5910
6053
  await syncService.sync();
@@ -5915,6 +6058,7 @@ async function runSingleRepository(config) {
5915
6058
  uiService.setStatus("idle");
5916
6059
  }
5917
6060
  });
6061
+ uiService.registerCronJob(job);
5918
6062
  }
5919
6063
  } catch (error) {
5920
6064
  logger.error("\u274C Fatal Error during initialization:", error);
@@ -5981,7 +6125,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
5981
6125
  const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
5982
6126
  const allServices = Array.from(services.values());
5983
6127
  const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel, reloadOptions);
5984
- cleanupFns.push(() => uiService.destroy());
6128
+ signalHandle.register((fast) => uiService.destroy(fast));
5985
6129
  void uiService.calculateAndUpdateDiskSpace();
5986
6130
  uiService.setupCronJobs();
5987
6131
  uiService.addLog(`\u{1F4CB} ${repositories.length} repositories configured`);
@@ -6076,13 +6220,12 @@ async function runInteractive(partial, options) {
6076
6220
  await runSingleRepository(config);
6077
6221
  }
6078
6222
  async function main() {
6079
- setupSignalHandlers();
6080
6223
  const options = parseArguments();
6081
6224
  if (!options.config && !options.repoUrl && !options.worktreeDir) {
6082
6225
  const discovered = await findConfigInCwd();
6083
6226
  if (discovered) {
6084
6227
  options.config = discovered;
6085
- console.log(`\u{1F4C4} Using config: ${path12.relative(process.cwd(), discovered)}`);
6228
+ console.log(`\u{1F4C4} Using config: ${path13.relative(process.cwd(), discovered)}`);
6086
6229
  }
6087
6230
  }
6088
6231
  if (options.config) {