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.
@@ -5,7 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
 
6
6
  // src/mcp/context.ts
7
7
  import * as fs7 from "fs/promises";
8
- import * as path8 from "path";
8
+ import * as path9 from "path";
9
9
  import simpleGit5 from "simple-git";
10
10
 
11
11
  // src/constants.ts
@@ -57,7 +57,11 @@ var DEFAULT_CONFIG = {
57
57
  MAX_SAFE_TOTAL_CONCURRENT_OPS: 100
58
58
  },
59
59
  UPDATE_EXISTING_WORKTREES: true,
60
- HOOK_TIMEOUT_MS: 6e4
60
+ HOOK_TIMEOUT_MS: 6e4,
61
+ FETCH_TIMEOUT_MS: 3e5,
62
+ CLONE_TIMEOUT_MS: 9e5,
63
+ LOCK_STALE_MS: 6e5,
64
+ LOCK_UPDATE_MS: 3e4
61
65
  };
62
66
  var ERROR_MESSAGES = {
63
67
  GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
@@ -198,9 +202,9 @@ var WorktreeError = class extends SyncWorktreesError {
198
202
  }
199
203
  };
200
204
  var WorktreeNotCleanError = class extends WorktreeError {
201
- constructor(path10, reasons) {
202
- super(`Worktree at '${path10}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
203
- this.path = path10;
205
+ constructor(path11, reasons) {
206
+ super(`Worktree at '${path11}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
207
+ this.path = path11;
204
208
  this.reasons = reasons;
205
209
  }
206
210
  };
@@ -751,8 +755,9 @@ function defaultConsoleOutput(msg, level) {
751
755
 
752
756
  // src/services/worktree-sync.service.ts
753
757
  import * as fs6 from "fs/promises";
754
- import * as path7 from "path";
758
+ import * as path8 from "path";
755
759
  import pLimit from "p-limit";
760
+ import * as lockfile from "proper-lockfile";
756
761
 
757
762
  // src/utils/date-filter.ts
758
763
  function parseDuration(durationStr) {
@@ -1002,7 +1007,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
1002
1007
 
1003
1008
  // src/services/git.service.ts
1004
1009
  import * as fs4 from "fs/promises";
1005
- import * as path5 from "path";
1010
+ import * as path6 from "path";
1006
1011
  import simpleGit4 from "simple-git";
1007
1012
 
1008
1013
  // src/utils/worktree-list-parser.ts
@@ -1047,11 +1052,13 @@ function parseWorktreeListPorcelain(output) {
1047
1052
  }
1048
1053
 
1049
1054
  // src/services/sparse-checkout.service.ts
1055
+ import * as path3 from "path";
1050
1056
  import simpleGit from "simple-git";
1051
1057
  var SparseCheckoutService = class {
1052
1058
  logger;
1053
1059
  gitFactory;
1054
1060
  warnedConfigs = /* @__PURE__ */ new WeakSet();
1061
+ matcherCache = /* @__PURE__ */ new WeakMap();
1055
1062
  constructor(logger, gitFactory) {
1056
1063
  this.logger = logger ?? Logger.createDefault();
1057
1064
  this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
@@ -1135,11 +1142,66 @@ var SparseCheckoutService = class {
1135
1142
  const bt = b.map((x) => x.trim());
1136
1143
  return at.every((v, i) => v === bt[i]);
1137
1144
  }
1145
+ /**
1146
+ * Decide whether a list of changed file paths intersects the sparse-checkout
1147
+ * set defined by `cfg`. Used to skip fast-forward updates when upstream
1148
+ * commits only touch files outside the materialized worktree.
1149
+ *
1150
+ * Cone mode materializes:
1151
+ * - all files at the repository root,
1152
+ * - all files directly inside every ancestor of an included directory
1153
+ * (e.g. include `tools/build` keeps `tools/foo.txt` checked out too),
1154
+ * - everything inside an included directory.
1155
+ * We mirror those rules here. Missing the ancestor-files case would let
1156
+ * stale files linger when only those parent files change upstream.
1157
+ *
1158
+ * No-cone mode: gitignore-style matching with negation is non-trivial and
1159
+ * not implemented here yet. We return `true` so the caller falls back to
1160
+ * the safe behavior of always running the update.
1161
+ *
1162
+ * The matcher derived from `cfg` is cached on the cfg object identity
1163
+ * (WeakMap), so callers should reuse the same `cfg` reference across
1164
+ * invocations to benefit from the cache.
1165
+ */
1166
+ pathsTouchSparse(changedPaths, cfg) {
1167
+ if (changedPaths.length === 0) return false;
1168
+ const matcher = this.getMatcher(cfg);
1169
+ if (matcher.mode === "no-cone") return true;
1170
+ if (matcher.patterns.length === 0) return true;
1171
+ return changedPaths.some((p) => {
1172
+ if (!p.includes("/")) return true;
1173
+ for (const pat of matcher.patterns) {
1174
+ if (p === pat || p.startsWith(pat + "/")) return true;
1175
+ }
1176
+ return matcher.ancestorDirs.has(path3.posix.dirname(p));
1177
+ });
1178
+ }
1179
+ getMatcher(cfg) {
1180
+ const cached = this.matcherCache.get(cfg);
1181
+ if (cached) return cached;
1182
+ const mode = this.resolveMode(cfg);
1183
+ if (mode === "no-cone") {
1184
+ const matcher2 = { mode, patterns: [], ancestorDirs: /* @__PURE__ */ new Set() };
1185
+ this.matcherCache.set(cfg, matcher2);
1186
+ return matcher2;
1187
+ }
1188
+ const patterns = cfg.include.map((p) => p.trim()).filter((p) => p.length > 0).map((p) => p.endsWith("/") ? p.slice(0, -1) : p);
1189
+ const ancestorDirs = /* @__PURE__ */ new Set();
1190
+ for (const pat of patterns) {
1191
+ const parts = pat.split("/");
1192
+ for (let i = 1; i < parts.length; i++) {
1193
+ ancestorDirs.add(parts.slice(0, i).join("/"));
1194
+ }
1195
+ }
1196
+ const matcher = { mode, patterns, ancestorDirs };
1197
+ this.matcherCache.set(cfg, matcher);
1198
+ return matcher;
1199
+ }
1138
1200
  };
1139
1201
 
1140
1202
  // src/services/worktree-metadata.service.ts
1141
1203
  import * as fs2 from "fs/promises";
1142
- import * as path3 from "path";
1204
+ import * as path4 from "path";
1143
1205
  import simpleGit2 from "simple-git";
1144
1206
  var WorktreeMetadataService = class {
1145
1207
  logger;
@@ -1152,7 +1214,7 @@ var WorktreeMetadataService = class {
1152
1214
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
1153
1215
  */
1154
1216
  getWorktreeDirectoryName(worktreePath) {
1155
- return path3.basename(worktreePath);
1217
+ return path4.basename(worktreePath);
1156
1218
  }
1157
1219
  async getMetadataPath(bareRepoPath, worktreeName) {
1158
1220
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -1160,7 +1222,7 @@ var WorktreeMetadataService = class {
1160
1222
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
1161
1223
  );
1162
1224
  }
1163
- return path3.join(
1225
+ return path4.join(
1164
1226
  bareRepoPath,
1165
1227
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
1166
1228
  worktreeName,
@@ -1173,7 +1235,7 @@ var WorktreeMetadataService = class {
1173
1235
  }
1174
1236
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
1175
1237
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
1176
- await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
1238
+ await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
1177
1239
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
1178
1240
  let renamed = false;
1179
1241
  try {
@@ -1366,7 +1428,7 @@ var WorktreeMetadataService = class {
1366
1428
 
1367
1429
  // src/services/worktree-status.service.ts
1368
1430
  import * as fs3 from "fs/promises";
1369
- import * as path4 from "path";
1431
+ import * as path5 from "path";
1370
1432
  import simpleGit3 from "simple-git";
1371
1433
  var OPERATION_FILES = [
1372
1434
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
@@ -1569,7 +1631,7 @@ var WorktreeStatusService = class {
1569
1631
  async detectOperationFile(gitDir) {
1570
1632
  const results = await Promise.all(
1571
1633
  OPERATION_FILES.map(
1572
- ({ file }) => fs3.access(path4.join(gitDir, file)).then(
1634
+ ({ file }) => fs3.access(path5.join(gitDir, file)).then(
1573
1635
  () => true,
1574
1636
  () => false
1575
1637
  )
@@ -1690,14 +1752,14 @@ var WorktreeStatusService = class {
1690
1752
  }
1691
1753
  }
1692
1754
  async resolveGitDir(worktreePath) {
1693
- const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
1755
+ const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
1694
1756
  try {
1695
1757
  const stat4 = await fs3.stat(gitPath);
1696
1758
  if (stat4.isFile()) {
1697
1759
  const content = await fs3.readFile(gitPath, "utf-8");
1698
1760
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
1699
1761
  if (gitdirMatch) {
1700
- return path4.resolve(worktreePath, gitdirMatch[1].trim());
1762
+ return path5.resolve(worktreePath, gitdirMatch[1].trim());
1701
1763
  }
1702
1764
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
1703
1765
  }
@@ -1711,7 +1773,7 @@ var WorktreeStatusService = class {
1711
1773
  }
1712
1774
  }
1713
1775
  createGitInstance(worktreePath) {
1714
- const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
1776
+ const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
1715
1777
  let git = this.gitInstances.get(key);
1716
1778
  if (!git) {
1717
1779
  git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
@@ -1734,7 +1796,7 @@ var GitService = class {
1734
1796
  this.config = config;
1735
1797
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
1736
1798
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
1737
- this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1799
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1738
1800
  this.metadataService = new WorktreeMetadataService(this.logger);
1739
1801
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
1740
1802
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -1753,11 +1815,21 @@ var GitService = class {
1753
1815
  getSparseCheckoutService() {
1754
1816
  return this.sparseCheckoutService;
1755
1817
  }
1818
+ getFetchTimeoutMs() {
1819
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
1820
+ return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
1821
+ }
1822
+ getCloneTimeoutMs() {
1823
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
1824
+ return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
1825
+ }
1756
1826
  getCachedGit(dirPath, useLfsSkip = false) {
1757
- const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1827
+ const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1758
1828
  let git = this.gitInstances.get(key);
1759
1829
  if (!git) {
1760
- git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
1830
+ const block = this.getFetchTimeoutMs();
1831
+ const base = block > 0 ? simpleGit4(dirPath, { timeout: { block } }) : simpleGit4(dirPath);
1832
+ git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
1761
1833
  this.gitInstances.set(key, git);
1762
1834
  }
1763
1835
  return git;
@@ -1769,11 +1841,13 @@ var GitService = class {
1769
1841
  async initialize() {
1770
1842
  const { repoUrl } = this.config;
1771
1843
  try {
1772
- await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
1844
+ await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
1773
1845
  } catch {
1774
1846
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
1775
- await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
1776
- const cloneGit = this.isLfsSkipEnabled() ? simpleGit4().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4();
1847
+ await fs4.mkdir(path6.dirname(this.bareRepoPath), { recursive: true });
1848
+ const cloneBlock = this.getCloneTimeoutMs();
1849
+ const cloneBase = cloneBlock > 0 ? simpleGit4({ timeout: { block: cloneBlock } }) : simpleGit4();
1850
+ const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
1777
1851
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
1778
1852
  this.logger.info("\u2705 Clone successful.");
1779
1853
  }
@@ -1790,17 +1864,17 @@ var GitService = class {
1790
1864
  this.logger.info("Fetching remote branches...");
1791
1865
  await bareGit.fetch(["--all"]);
1792
1866
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
1793
- this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
1867
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
1794
1868
  let needsMainWorktree = true;
1795
1869
  try {
1796
1870
  const worktrees = await this.getWorktreesFromBare(bareGit);
1797
- needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
1871
+ needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
1798
1872
  } catch {
1799
1873
  }
1800
1874
  if (needsMainWorktree) {
1801
1875
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
1802
1876
  await fs4.mkdir(this.config.worktreeDir, { recursive: true });
1803
- const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
1877
+ const absoluteWorktreePath = path6.resolve(this.mainWorktreePath);
1804
1878
  const branches = await bareGit.branch();
1805
1879
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
1806
1880
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -1836,7 +1910,7 @@ var GitService = class {
1836
1910
  }
1837
1911
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
1838
1912
  const mainWorktreeRegistered = updatedWorktrees.some(
1839
- (w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
1913
+ (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
1840
1914
  );
1841
1915
  if (!mainWorktreeRegistered) {
1842
1916
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -1859,6 +1933,9 @@ var GitService = class {
1859
1933
  getDefaultBranch() {
1860
1934
  return this.defaultBranch;
1861
1935
  }
1936
+ getBareRepoPath() {
1937
+ return this.bareRepoPath;
1938
+ }
1862
1939
  async fetchAll() {
1863
1940
  this.assertInitialized();
1864
1941
  this.logger.info("Fetching latest data from remote...");
@@ -1916,7 +1993,7 @@ var GitService = class {
1916
1993
  const existence = await Promise.all(
1917
1994
  lfsFileList.map(async (f) => {
1918
1995
  try {
1919
- await fs4.access(path5.join(worktreePath, f));
1996
+ await fs4.access(path6.join(worktreePath, f));
1920
1997
  return f;
1921
1998
  } catch {
1922
1999
  return null;
@@ -1944,7 +2021,7 @@ var GitService = class {
1944
2021
  let allDownloaded = true;
1945
2022
  const notDownloaded = [];
1946
2023
  for (const file of samplesToCheck) {
1947
- const filePath = path5.join(worktreePath, file);
2024
+ const filePath = path6.join(worktreePath, file);
1948
2025
  try {
1949
2026
  const handle = await fs4.open(filePath, "r");
1950
2027
  try {
@@ -2033,12 +2110,12 @@ var GitService = class {
2033
2110
  }
2034
2111
  async addWorktree(branchName, worktreePath) {
2035
2112
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
2036
- const absoluteWorktreePath = path5.resolve(worktreePath);
2037
- await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
2113
+ const absoluteWorktreePath = path6.resolve(worktreePath);
2114
+ await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
2038
2115
  try {
2039
2116
  await fs4.access(absoluteWorktreePath);
2040
2117
  const worktrees = await this.getWorktreesFromBare(bareGit);
2041
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
2118
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
2042
2119
  if (isValidWorktree) {
2043
2120
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2044
2121
  return;
@@ -2083,7 +2160,7 @@ var GitService = class {
2083
2160
  }
2084
2161
  if (errorMessage.includes("already registered worktree")) {
2085
2162
  const worktrees = await this.getWorktreesFromBare(bareGit);
2086
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
2163
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
2087
2164
  if (existingWorktree && !existingWorktree.isPrunable) {
2088
2165
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
2089
2166
  return;
@@ -2129,7 +2206,7 @@ var GitService = class {
2129
2206
  try {
2130
2207
  await fs4.access(absoluteWorktreePath);
2131
2208
  const worktrees = await this.getWorktreesFromBare(bareGit);
2132
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
2209
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
2133
2210
  if (isValidWorktree) {
2134
2211
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2135
2212
  return;
@@ -2159,7 +2236,7 @@ var GitService = class {
2159
2236
  const fallbackErrorMessage = getErrorMessage(fallbackError);
2160
2237
  if (fallbackErrorMessage.includes("already registered worktree")) {
2161
2238
  const worktrees = await this.getWorktreesFromBare(bareGit);
2162
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
2239
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
2163
2240
  if (existingWorktree && !existingWorktree.isPrunable) {
2164
2241
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
2165
2242
  return;
@@ -2382,6 +2459,23 @@ var GitService = class {
2382
2459
  return false;
2383
2460
  }
2384
2461
  }
2462
+ async getChangedPathsInRange(worktreePath, fromRef, toRef) {
2463
+ const worktreeGit = this.getCachedGit(worktreePath);
2464
+ try {
2465
+ const out = await worktreeGit.raw([
2466
+ "-c",
2467
+ "core.quotePath=false",
2468
+ "diff",
2469
+ "--name-only",
2470
+ "--no-renames",
2471
+ `${fromRef}..${toRef}`
2472
+ ]);
2473
+ return out.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.length > 0);
2474
+ } catch (error) {
2475
+ this.logger.warn(`Failed to compute diff ${fromRef}..${toRef} in ${worktreePath}: ${getErrorMessage(error)}`);
2476
+ return null;
2477
+ }
2478
+ }
2385
2479
  async compareTreeContent(worktreePath, branch) {
2386
2480
  const worktreeGit = this.getCachedGit(worktreePath);
2387
2481
  try {
@@ -2466,7 +2560,7 @@ var GitService = class {
2466
2560
  // src/services/path-resolution.service.ts
2467
2561
  import { createHash } from "crypto";
2468
2562
  import * as fs5 from "fs";
2469
- import * as path6 from "path";
2563
+ import * as path7 from "path";
2470
2564
  var BRANCH_STEM_MAX = 80;
2471
2565
  var BRANCH_HASH_LEN = 8;
2472
2566
  var PathResolutionService = class {
@@ -2476,22 +2570,22 @@ var PathResolutionService = class {
2476
2570
  return `${stem}-${hash}`;
2477
2571
  }
2478
2572
  getBranchWorktreePath(worktreeDir, branchName) {
2479
- return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
2573
+ return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
2480
2574
  }
2481
2575
  resolveRealPath(inputPath) {
2482
- const absolute = path6.resolve(inputPath);
2576
+ const absolute = path7.resolve(inputPath);
2483
2577
  const missing = [];
2484
2578
  let current = absolute;
2485
2579
  while (!fs5.existsSync(current)) {
2486
- const parent = path6.dirname(current);
2580
+ const parent = path7.dirname(current);
2487
2581
  if (parent === current) {
2488
2582
  return absolute;
2489
2583
  }
2490
- missing.unshift(path6.basename(current));
2584
+ missing.unshift(path7.basename(current));
2491
2585
  current = parent;
2492
2586
  }
2493
2587
  try {
2494
- return path6.join(fs5.realpathSync(current), ...missing);
2588
+ return path7.join(fs5.realpathSync(current), ...missing);
2495
2589
  } catch {
2496
2590
  return absolute;
2497
2591
  }
@@ -2501,7 +2595,7 @@ var PathResolutionService = class {
2501
2595
  const a = fold(resolved);
2502
2596
  const b = fold(resolvedBase);
2503
2597
  if (a === b) return true;
2504
- return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
2598
+ return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
2505
2599
  }
2506
2600
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
2507
2601
  const resolved = this.resolveRealPath(worktreePath);
@@ -2509,7 +2603,7 @@ var PathResolutionService = class {
2509
2603
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
2510
2604
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
2511
2605
  }
2512
- return path6.relative(resolvedBase, resolved);
2606
+ return path7.relative(resolvedBase, resolved);
2513
2607
  }
2514
2608
  isPathInsideBaseDir(targetPath, baseDir) {
2515
2609
  const resolved = this.resolveRealPath(targetPath);
@@ -2568,6 +2662,11 @@ var WorktreeSyncService = class {
2568
2662
  this.logger.warn("\u26A0\uFE0F Sync already in progress, skipping...");
2569
2663
  return { started: false, reason: "in_progress" };
2570
2664
  }
2665
+ const release = await this.acquireBareLock();
2666
+ if (release === null) {
2667
+ this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
2668
+ return { started: false, reason: "locked" };
2669
+ }
2571
2670
  this.syncInProgress = true;
2572
2671
  this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
2573
2672
  const totalTimer = new Timer();
@@ -2584,6 +2683,11 @@ var WorktreeSyncService = class {
2584
2683
  this.gitService.setLfsSkipEnabled(false);
2585
2684
  }
2586
2685
  this.syncInProgress = false;
2686
+ try {
2687
+ await release();
2688
+ } catch (releaseError) {
2689
+ this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
2690
+ }
2587
2691
  this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
2588
2692
  `);
2589
2693
  if (this.config.debug) {
@@ -2595,6 +2699,39 @@ var WorktreeSyncService = class {
2595
2699
  }
2596
2700
  return { started: true };
2597
2701
  }
2702
+ async acquireBareLock() {
2703
+ if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
2704
+ return async () => {
2705
+ };
2706
+ }
2707
+ if (typeof this.gitService.getBareRepoPath !== "function") {
2708
+ return async () => {
2709
+ };
2710
+ }
2711
+ const barePath = this.gitService.getBareRepoPath();
2712
+ const lockTarget = path8.join(barePath, "HEAD");
2713
+ try {
2714
+ await fs6.access(lockTarget);
2715
+ } catch {
2716
+ return async () => {
2717
+ };
2718
+ }
2719
+ try {
2720
+ const release = await lockfile.lock(lockTarget, {
2721
+ stale: DEFAULT_CONFIG.LOCK_STALE_MS,
2722
+ update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
2723
+ retries: 0,
2724
+ realpath: false
2725
+ });
2726
+ return release;
2727
+ } catch (error) {
2728
+ const code = error.code;
2729
+ if (code === "ELOCKED") {
2730
+ return null;
2731
+ }
2732
+ throw error;
2733
+ }
2734
+ }
2598
2735
  createRetryOptions(syncContext) {
2599
2736
  return {
2600
2737
  maxAttempts: this.config.retry?.maxAttempts ?? 3,
@@ -2770,12 +2907,12 @@ var WorktreeSyncService = class {
2770
2907
  }
2771
2908
  const reservedPaths = /* @__PURE__ */ new Map();
2772
2909
  for (const w of worktrees) {
2773
- reservedPaths.set(path7.resolve(w.path), w.branch);
2910
+ reservedPaths.set(path8.resolve(w.path), w.branch);
2774
2911
  }
2775
2912
  const plan = [];
2776
2913
  for (const branchName of newBranches) {
2777
2914
  const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
2778
- const resolved = path7.resolve(worktreePath);
2915
+ const resolved = path8.resolve(worktreePath);
2779
2916
  const conflict = reservedPaths.get(resolved);
2780
2917
  if (conflict && conflict !== branchName) {
2781
2918
  this.logger.error(
@@ -2980,12 +3117,12 @@ var WorktreeSyncService = class {
2980
3117
  }
2981
3118
  async updateExistingWorktrees(worktrees, remoteBranches) {
2982
3119
  this.logger.info("Step 4: Checking for worktrees that need updates...");
2983
- const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3120
+ const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
2984
3121
  try {
2985
3122
  const diverged = await fs6.readdir(divergedDir);
2986
3123
  if (diverged.length > 0) {
2987
3124
  this.logger.info(
2988
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
3125
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
2989
3126
  );
2990
3127
  }
2991
3128
  } catch {
@@ -3015,7 +3152,23 @@ var WorktreeSyncService = class {
3015
3152
  return { action: "diverged", worktree };
3016
3153
  }
3017
3154
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
3018
- return isBehind ? { action: "update", worktree } : null;
3155
+ if (!isBehind) return null;
3156
+ const sparseCfg = this.config.sparseCheckout;
3157
+ if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
3158
+ const sparseService = this.gitService.getSparseCheckoutService();
3159
+ if (sparseService.resolveMode(sparseCfg) === "cone") {
3160
+ const diff = await this.gitService.getChangedPathsInRange(
3161
+ worktree.path,
3162
+ "HEAD",
3163
+ `origin/${worktree.branch}`
3164
+ );
3165
+ if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
3166
+ this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
3167
+ return null;
3168
+ }
3169
+ }
3170
+ }
3171
+ return { action: "update", worktree };
3019
3172
  })
3020
3173
  )
3021
3174
  );
@@ -3093,13 +3246,13 @@ var WorktreeSyncService = class {
3093
3246
  }
3094
3247
  async cleanupOrphanedDirectories(worktrees) {
3095
3248
  try {
3096
- const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
3249
+ const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
3097
3250
  const allDirs = await fs6.readdir(this.config.worktreeDir);
3098
3251
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
3099
3252
  const orphanedDirs = [];
3100
3253
  for (const dir of regularDirs) {
3101
3254
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
3102
- return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
3255
+ return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
3103
3256
  });
3104
3257
  if (!isPartOfWorktree) {
3105
3258
  orphanedDirs.push(dir);
@@ -3108,7 +3261,7 @@ var WorktreeSyncService = class {
3108
3261
  if (orphanedDirs.length > 0) {
3109
3262
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
3110
3263
  for (const dir of orphanedDirs) {
3111
- const dirPath = path7.join(this.config.worktreeDir, dir);
3264
+ const dirPath = path8.join(this.config.worktreeDir, dir);
3112
3265
  try {
3113
3266
  const stat4 = await fs6.stat(dirPath);
3114
3267
  if (stat4.isDirectory()) {
@@ -3142,7 +3295,7 @@ var WorktreeSyncService = class {
3142
3295
  } else {
3143
3296
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
3144
3297
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
3145
- const relativePath = path7.relative(process.cwd(), divergedPath);
3298
+ const relativePath = path8.relative(process.cwd(), divergedPath);
3146
3299
  this.logger.info(` Moved to: ${relativePath}`);
3147
3300
  this.logger.info(` Your local changes are preserved. To review:`);
3148
3301
  this.logger.info(` cd ${relativePath}`);
@@ -3166,12 +3319,12 @@ var WorktreeSyncService = class {
3166
3319
  }
3167
3320
  }
3168
3321
  async divergeWorktree(worktreePath, branchName) {
3169
- const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3322
+ const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3170
3323
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3171
3324
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
3172
3325
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
3173
3326
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
3174
- const divergedPath = path7.join(divergedBaseDir, divergedName);
3327
+ const divergedPath = path8.join(divergedBaseDir, divergedName);
3175
3328
  await fs6.mkdir(divergedBaseDir, { recursive: true });
3176
3329
  try {
3177
3330
  await fs6.rename(worktreePath, divergedPath);
@@ -3198,7 +3351,7 @@ var WorktreeSyncService = class {
3198
3351
  Original worktree location: ${worktreePath}`
3199
3352
  };
3200
3353
  await fs6.writeFile(
3201
- path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
3354
+ path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
3202
3355
  JSON.stringify(metadata, null, 2)
3203
3356
  );
3204
3357
  return divergedPath;
@@ -3253,9 +3406,9 @@ var RepositoryContext = class {
3253
3406
  discoveryCache = /* @__PURE__ */ new Map();
3254
3407
  async loadConfig(configPath, options = {}) {
3255
3408
  const setDefaultCurrent = options.setDefaultCurrent ?? true;
3256
- const absolutePath = path8.resolve(configPath);
3409
+ const absolutePath = path9.resolve(configPath);
3257
3410
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
3258
- const configDir = path8.dirname(absolutePath);
3411
+ const configDir = path9.dirname(absolutePath);
3259
3412
  const globalDefaults = configFile.defaults;
3260
3413
  const resolvedAll = [];
3261
3414
  for (const repo of configFile.repositories) {
@@ -3292,7 +3445,7 @@ var RepositoryContext = class {
3292
3445
  return configFile.repositories;
3293
3446
  }
3294
3447
  async detectFromPath(dirPath) {
3295
- const absolutePath = path8.resolve(dirPath);
3448
+ const absolutePath = path9.resolve(dirPath);
3296
3449
  const cached = this.discoveryCache.get(absolutePath);
3297
3450
  if (cached && await this.isCacheFresh(cached)) {
3298
3451
  return cached.result;
@@ -3311,8 +3464,8 @@ var RepositoryContext = class {
3311
3464
  const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
3312
3465
  if (result.isWorktree && result.bareRepoPath && adminDir) {
3313
3466
  const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
3314
- safeMtimeMs(path8.join(adminDir, "HEAD")),
3315
- safeMtimeMs(path8.join(result.bareRepoPath, "worktrees"))
3467
+ safeMtimeMs(path9.join(adminDir, "HEAD")),
3468
+ safeMtimeMs(path9.join(result.bareRepoPath, "worktrees"))
3316
3469
  ]);
3317
3470
  this.discoveryCache.set(absolutePath, {
3318
3471
  result,
@@ -3344,8 +3497,8 @@ var RepositoryContext = class {
3344
3497
  return this.discoveryCache.size;
3345
3498
  }
3346
3499
  async discoverSiblingRepositories(currentBareRepoPath) {
3347
- const repoDir = path8.dirname(currentBareRepoPath);
3348
- const workspaceRoot = path8.dirname(repoDir);
3500
+ const repoDir = path9.dirname(currentBareRepoPath);
3501
+ const workspaceRoot = path9.dirname(repoDir);
3349
3502
  if (workspaceRoot === repoDir) return [];
3350
3503
  let entries;
3351
3504
  try {
@@ -3362,15 +3515,15 @@ var RepositoryContext = class {
3362
3515
  const results = [];
3363
3516
  await Promise.all(
3364
3517
  entries.map(async (entry) => {
3365
- const candidate = path8.join(workspaceRoot, entry);
3366
- const bareCandidate = path8.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3518
+ const candidate = path9.join(workspaceRoot, entry);
3519
+ const bareCandidate = path9.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3367
3520
  try {
3368
3521
  const stat4 = await fs7.stat(bareCandidate);
3369
3522
  if (!stat4.isDirectory()) return;
3370
3523
  } catch {
3371
3524
  return;
3372
3525
  }
3373
- const resolvedBare = path8.resolve(bareCandidate);
3526
+ const resolvedBare = path9.resolve(bareCandidate);
3374
3527
  const matchedName = configBares.get(normalizePathForCompare(resolvedBare));
3375
3528
  results.push({
3376
3529
  name: matchedName ?? entry,
@@ -3392,8 +3545,8 @@ var RepositoryContext = class {
3392
3545
  if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
3393
3546
  if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
3394
3547
  const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
3395
- safeMtimeMs(path8.join(cached.worktreeAdminDir, "HEAD")),
3396
- safeMtimeMs(path8.join(cached.result.bareRepoPath, "worktrees"))
3548
+ safeMtimeMs(path9.join(cached.worktreeAdminDir, "HEAD")),
3549
+ safeMtimeMs(path9.join(cached.result.bareRepoPath, "worktrees"))
3397
3550
  ]);
3398
3551
  return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
3399
3552
  }
@@ -3434,13 +3587,13 @@ var RepositoryContext = class {
3434
3587
  return unsupported("Invalid .git file format (missing gitdir line)");
3435
3588
  }
3436
3589
  const gitdir = gitdirMatch[1].trim();
3437
- const resolvedGitdir = path8.isAbsolute(gitdir) ? gitdir : path8.resolve(worktreeRoot, gitdir);
3590
+ const resolvedGitdir = path9.isAbsolute(gitdir) ? gitdir : path9.resolve(worktreeRoot, gitdir);
3438
3591
  const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
3439
3592
  if (!worktreesMatch) {
3440
3593
  return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
3441
3594
  }
3442
- const bareRepoPath = path8.resolve(worktreesMatch[1]);
3443
- const adminDir = path8.resolve(resolvedGitdir);
3595
+ const bareRepoPath = path9.resolve(worktreesMatch[1]);
3596
+ const adminDir = path9.resolve(resolvedGitdir);
3444
3597
  let repoUrl = null;
3445
3598
  let worktrees = [];
3446
3599
  let currentBranch = null;
@@ -3481,7 +3634,7 @@ var RepositoryContext = class {
3481
3634
  adminDir
3482
3635
  };
3483
3636
  }
3484
- const worktreeDir = path8.dirname(worktreeRoot);
3637
+ const worktreeDir = path9.dirname(worktreeRoot);
3485
3638
  const noUrlReason = "no remote origin URL detected";
3486
3639
  const capabilities = {
3487
3640
  listWorktrees: { available: true },
@@ -3517,7 +3670,7 @@ var RepositoryContext = class {
3517
3670
  cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
3518
3671
  runOnce: true
3519
3672
  };
3520
- const detectedKey = `${AUTO_DETECT_PREFIX}${path8.basename(bareRepoPath)}@${bareRepoPath}`;
3673
+ const detectedKey = `${AUTO_DETECT_PREFIX}${path9.basename(bareRepoPath)}@${bareRepoPath}`;
3521
3674
  if (!this.repos.has(detectedKey)) {
3522
3675
  this.repos.set(detectedKey, {
3523
3676
  name: detectedKey,
@@ -3609,7 +3762,7 @@ function parseWorktreeList(output, currentPath) {
3609
3762
  const foldedCurrent = normalizePathForCompare(currentPath);
3610
3763
  const results = [];
3611
3764
  for (const wt of parseWorktreeListPorcelain(output)) {
3612
- const resolved = path8.resolve(wt.path);
3765
+ const resolved = path9.resolve(wt.path);
3613
3766
  const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
3614
3767
  if (!branch) continue;
3615
3768
  results.push({
@@ -3629,10 +3782,10 @@ async function safeMtimeMs(filePath) {
3629
3782
  }
3630
3783
  }
3631
3784
  async function findWorktreeRoot(startPath) {
3632
- let current = path8.resolve(startPath);
3633
- const root = path8.parse(current).root;
3785
+ let current = path9.resolve(startPath);
3786
+ const root = path9.parse(current).root;
3634
3787
  while (true) {
3635
- const gitPath = path8.join(current, ".git");
3788
+ const gitPath = path9.join(current, ".git");
3636
3789
  try {
3637
3790
  const content = await fs7.readFile(gitPath, "utf-8");
3638
3791
  return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
@@ -3646,7 +3799,7 @@ async function findWorktreeRoot(startPath) {
3646
3799
  }
3647
3800
  }
3648
3801
  if (current === root) return null;
3649
- const parent = path8.dirname(current);
3802
+ const parent = path9.dirname(current);
3650
3803
  if (parent === current) return null;
3651
3804
  current = parent;
3652
3805
  }
@@ -3657,7 +3810,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3657
3810
  import { z } from "zod";
3658
3811
 
3659
3812
  // src/mcp/handlers.ts
3660
- import * as path9 from "path";
3813
+ import * as path10 from "path";
3661
3814
  import pLimit2 from "p-limit";
3662
3815
 
3663
3816
  // src/utils/disk-space.ts
@@ -3854,7 +4007,7 @@ async function getReadyService(ctx, repoName, options = {}) {
3854
4007
  }
3855
4008
  async function ensureRepoWorktreePath(ctx, params, git) {
3856
4009
  await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
3857
- return path9.resolve(params.path);
4010
+ return path10.resolve(params.path);
3858
4011
  }
3859
4012
  async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
3860
4013
  const discovered = ctx.getDiscoveredContext(repoName);
@@ -3915,7 +4068,7 @@ async function handleListWorktrees(ctx, params, _extra) {
3915
4068
  const results = await Promise.all(
3916
4069
  worktrees.map(
3917
4070
  (wt) => limit(async () => {
3918
- const resolvedPath = path9.resolve(wt.path);
4071
+ const resolvedPath = path10.resolve(wt.path);
3919
4072
  const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
3920
4073
  const [status, divergence, metadata, sizeBytes] = await Promise.all([
3921
4074
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
@@ -3995,7 +4148,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
3995
4148
  return formatToolResponse({
3996
4149
  success: true,
3997
4150
  branchName,
3998
- worktreePath: path9.resolve(worktreePath),
4151
+ worktreePath: path10.resolve(worktreePath),
3999
4152
  created,
4000
4153
  pushed
4001
4154
  });
@@ -4083,7 +4236,7 @@ async function handleLoadConfig(ctx, params, _extra) {
4083
4236
  }
4084
4237
  await ctx.loadConfig(configPath);
4085
4238
  return formatToolResponse({
4086
- configPath: path9.resolve(configPath),
4239
+ configPath: path10.resolve(configPath),
4087
4240
  currentRepository: ctx.getCurrentRepo(),
4088
4241
  repositories: ctx.getRepositoryList()
4089
4242
  });