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.
@@ -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
@@ -27,7 +27,8 @@ var GIT_CONSTANTS = {
27
27
  REMOTES: "refs/remotes/origin",
28
28
  REMOTES_ORIGIN: "refs/remotes/origin/*"
29
29
  },
30
- FETCH_CONFIG: "+refs/heads/*:refs/remotes/origin/*"
30
+ FETCH_CONFIG: "+refs/heads/*:refs/remotes/origin/*",
31
+ PROGRESS_BUCKET_PERCENT: 25
31
32
  };
32
33
  var GIT_OPERATIONS = {
33
34
  MERGE_HEAD: "MERGE_HEAD",
@@ -202,9 +203,9 @@ var WorktreeError = class extends SyncWorktreesError {
202
203
  }
203
204
  };
204
205
  var WorktreeNotCleanError = class extends WorktreeError {
205
- constructor(path10, reasons) {
206
- super(`Worktree at '${path10}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
207
- this.path = path10;
206
+ constructor(path11, reasons) {
207
+ super(`Worktree at '${path11}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
208
+ this.path = path11;
208
209
  this.reasons = reasons;
209
210
  }
210
211
  };
@@ -755,7 +756,7 @@ function defaultConsoleOutput(msg, level) {
755
756
 
756
757
  // src/services/worktree-sync.service.ts
757
758
  import * as fs6 from "fs/promises";
758
- import * as path7 from "path";
759
+ import * as path8 from "path";
759
760
  import pLimit from "p-limit";
760
761
  import * as lockfile from "proper-lockfile";
761
762
 
@@ -1007,7 +1008,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
1007
1008
 
1008
1009
  // src/services/git.service.ts
1009
1010
  import * as fs4 from "fs/promises";
1010
- import * as path5 from "path";
1011
+ import * as path6 from "path";
1011
1012
  import simpleGit4 from "simple-git";
1012
1013
 
1013
1014
  // src/utils/worktree-list-parser.ts
@@ -1052,11 +1053,13 @@ function parseWorktreeListPorcelain(output) {
1052
1053
  }
1053
1054
 
1054
1055
  // src/services/sparse-checkout.service.ts
1056
+ import * as path3 from "path";
1055
1057
  import simpleGit from "simple-git";
1056
1058
  var SparseCheckoutService = class {
1057
1059
  logger;
1058
1060
  gitFactory;
1059
1061
  warnedConfigs = /* @__PURE__ */ new WeakSet();
1062
+ matcherCache = /* @__PURE__ */ new WeakMap();
1060
1063
  constructor(logger, gitFactory) {
1061
1064
  this.logger = logger ?? Logger.createDefault();
1062
1065
  this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
@@ -1140,11 +1143,66 @@ var SparseCheckoutService = class {
1140
1143
  const bt = b.map((x) => x.trim());
1141
1144
  return at.every((v, i) => v === bt[i]);
1142
1145
  }
1146
+ /**
1147
+ * Decide whether a list of changed file paths intersects the sparse-checkout
1148
+ * set defined by `cfg`. Used to skip fast-forward updates when upstream
1149
+ * commits only touch files outside the materialized worktree.
1150
+ *
1151
+ * Cone mode materializes:
1152
+ * - all files at the repository root,
1153
+ * - all files directly inside every ancestor of an included directory
1154
+ * (e.g. include `tools/build` keeps `tools/foo.txt` checked out too),
1155
+ * - everything inside an included directory.
1156
+ * We mirror those rules here. Missing the ancestor-files case would let
1157
+ * stale files linger when only those parent files change upstream.
1158
+ *
1159
+ * No-cone mode: gitignore-style matching with negation is non-trivial and
1160
+ * not implemented here yet. We return `true` so the caller falls back to
1161
+ * the safe behavior of always running the update.
1162
+ *
1163
+ * The matcher derived from `cfg` is cached on the cfg object identity
1164
+ * (WeakMap), so callers should reuse the same `cfg` reference across
1165
+ * invocations to benefit from the cache.
1166
+ */
1167
+ pathsTouchSparse(changedPaths, cfg) {
1168
+ if (changedPaths.length === 0) return false;
1169
+ const matcher = this.getMatcher(cfg);
1170
+ if (matcher.mode === "no-cone") return true;
1171
+ if (matcher.patterns.length === 0) return true;
1172
+ return changedPaths.some((p) => {
1173
+ if (!p.includes("/")) return true;
1174
+ for (const pat of matcher.patterns) {
1175
+ if (p === pat || p.startsWith(pat + "/")) return true;
1176
+ }
1177
+ return matcher.ancestorDirs.has(path3.posix.dirname(p));
1178
+ });
1179
+ }
1180
+ getMatcher(cfg) {
1181
+ const cached = this.matcherCache.get(cfg);
1182
+ if (cached) return cached;
1183
+ const mode = this.resolveMode(cfg);
1184
+ if (mode === "no-cone") {
1185
+ const matcher2 = { mode, patterns: [], ancestorDirs: /* @__PURE__ */ new Set() };
1186
+ this.matcherCache.set(cfg, matcher2);
1187
+ return matcher2;
1188
+ }
1189
+ const patterns = cfg.include.map((p) => p.trim()).filter((p) => p.length > 0).map((p) => p.endsWith("/") ? p.slice(0, -1) : p);
1190
+ const ancestorDirs = /* @__PURE__ */ new Set();
1191
+ for (const pat of patterns) {
1192
+ const parts = pat.split("/");
1193
+ for (let i = 1; i < parts.length; i++) {
1194
+ ancestorDirs.add(parts.slice(0, i).join("/"));
1195
+ }
1196
+ }
1197
+ const matcher = { mode, patterns, ancestorDirs };
1198
+ this.matcherCache.set(cfg, matcher);
1199
+ return matcher;
1200
+ }
1143
1201
  };
1144
1202
 
1145
1203
  // src/services/worktree-metadata.service.ts
1146
1204
  import * as fs2 from "fs/promises";
1147
- import * as path3 from "path";
1205
+ import * as path4 from "path";
1148
1206
  import simpleGit2 from "simple-git";
1149
1207
  var WorktreeMetadataService = class {
1150
1208
  logger;
@@ -1157,7 +1215,7 @@ var WorktreeMetadataService = class {
1157
1215
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
1158
1216
  */
1159
1217
  getWorktreeDirectoryName(worktreePath) {
1160
- return path3.basename(worktreePath);
1218
+ return path4.basename(worktreePath);
1161
1219
  }
1162
1220
  async getMetadataPath(bareRepoPath, worktreeName) {
1163
1221
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -1165,7 +1223,7 @@ var WorktreeMetadataService = class {
1165
1223
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
1166
1224
  );
1167
1225
  }
1168
- return path3.join(
1226
+ return path4.join(
1169
1227
  bareRepoPath,
1170
1228
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
1171
1229
  worktreeName,
@@ -1178,7 +1236,7 @@ var WorktreeMetadataService = class {
1178
1236
  }
1179
1237
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
1180
1238
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
1181
- await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
1239
+ await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
1182
1240
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
1183
1241
  let renamed = false;
1184
1242
  try {
@@ -1371,7 +1429,7 @@ var WorktreeMetadataService = class {
1371
1429
 
1372
1430
  // src/services/worktree-status.service.ts
1373
1431
  import * as fs3 from "fs/promises";
1374
- import * as path4 from "path";
1432
+ import * as path5 from "path";
1375
1433
  import simpleGit3 from "simple-git";
1376
1434
  var OPERATION_FILES = [
1377
1435
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
@@ -1574,7 +1632,7 @@ var WorktreeStatusService = class {
1574
1632
  async detectOperationFile(gitDir) {
1575
1633
  const results = await Promise.all(
1576
1634
  OPERATION_FILES.map(
1577
- ({ file }) => fs3.access(path4.join(gitDir, file)).then(
1635
+ ({ file }) => fs3.access(path5.join(gitDir, file)).then(
1578
1636
  () => true,
1579
1637
  () => false
1580
1638
  )
@@ -1695,14 +1753,14 @@ var WorktreeStatusService = class {
1695
1753
  }
1696
1754
  }
1697
1755
  async resolveGitDir(worktreePath) {
1698
- const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
1756
+ const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
1699
1757
  try {
1700
1758
  const stat4 = await fs3.stat(gitPath);
1701
1759
  if (stat4.isFile()) {
1702
1760
  const content = await fs3.readFile(gitPath, "utf-8");
1703
1761
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
1704
1762
  if (gitdirMatch) {
1705
- return path4.resolve(worktreePath, gitdirMatch[1].trim());
1763
+ return path5.resolve(worktreePath, gitdirMatch[1].trim());
1706
1764
  }
1707
1765
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
1708
1766
  }
@@ -1716,7 +1774,7 @@ var WorktreeStatusService = class {
1716
1774
  }
1717
1775
  }
1718
1776
  createGitInstance(worktreePath) {
1719
- const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
1777
+ const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
1720
1778
  let git = this.gitInstances.get(key);
1721
1779
  if (!git) {
1722
1780
  git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
@@ -1739,7 +1797,7 @@ var GitService = class {
1739
1797
  this.config = config;
1740
1798
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
1741
1799
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
1742
- this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1800
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1743
1801
  this.metadataService = new WorktreeMetadataService(this.logger);
1744
1802
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
1745
1803
  this.sparseCheckoutService = new SparseCheckoutService(this.logger);
@@ -1767,16 +1825,36 @@ var GitService = class {
1767
1825
  return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
1768
1826
  }
1769
1827
  getCachedGit(dirPath, useLfsSkip = false) {
1770
- const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1828
+ const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1771
1829
  let git = this.gitInstances.get(key);
1772
1830
  if (!git) {
1773
- const block = this.getFetchTimeoutMs();
1774
- const base = block > 0 ? simpleGit4(dirPath, { timeout: { block } }) : simpleGit4(dirPath);
1831
+ const base = simpleGit4(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
1775
1832
  git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
1776
1833
  this.gitInstances.set(key, git);
1777
1834
  }
1778
1835
  return git;
1779
1836
  }
1837
+ buildSimpleGitOptions(blockMs) {
1838
+ const options = { progress: this.makeProgressHandler() };
1839
+ if (blockMs > 0) options.timeout = { block: blockMs };
1840
+ return options;
1841
+ }
1842
+ makeProgressHandler() {
1843
+ const lastBucket = /* @__PURE__ */ new Map();
1844
+ return (event) => {
1845
+ if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
1846
+ const key = `${event.method}:${event.stage}`;
1847
+ const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
1848
+ let last = lastBucket.get(key) ?? -1;
1849
+ if (bucket < last) {
1850
+ last = -1;
1851
+ }
1852
+ if (bucket <= last && event.progress < 100) return;
1853
+ lastBucket.set(key, bucket);
1854
+ const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
1855
+ this.logger.info(` \u21B3 ${event.method} ${event.stage}: ${event.progress}% (${total})`);
1856
+ };
1857
+ }
1780
1858
  updateLogger(logger) {
1781
1859
  this.logger = logger;
1782
1860
  this.sparseCheckoutService.updateLogger(logger);
@@ -1784,14 +1862,13 @@ var GitService = class {
1784
1862
  async initialize() {
1785
1863
  const { repoUrl } = this.config;
1786
1864
  try {
1787
- await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
1865
+ await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
1788
1866
  } catch {
1789
1867
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
1790
- await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
1791
- const cloneBlock = this.getCloneTimeoutMs();
1792
- const cloneBase = cloneBlock > 0 ? simpleGit4({ timeout: { block: cloneBlock } }) : simpleGit4();
1868
+ await fs4.mkdir(path6.dirname(this.bareRepoPath), { recursive: true });
1869
+ const cloneBase = simpleGit4(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
1793
1870
  const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
1794
- await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
1871
+ await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
1795
1872
  this.logger.info("\u2705 Clone successful.");
1796
1873
  }
1797
1874
  const bareGit = this.getCachedGit(this.bareRepoPath);
@@ -1805,19 +1882,19 @@ var GitService = class {
1805
1882
  await bareGit.addConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*");
1806
1883
  }
1807
1884
  this.logger.info("Fetching remote branches...");
1808
- await bareGit.fetch(["--all"]);
1885
+ await bareGit.fetch(["--all", "--progress"]);
1809
1886
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
1810
- this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
1887
+ this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
1811
1888
  let needsMainWorktree = true;
1812
1889
  try {
1813
1890
  const worktrees = await this.getWorktreesFromBare(bareGit);
1814
- needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
1891
+ needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
1815
1892
  } catch {
1816
1893
  }
1817
1894
  if (needsMainWorktree) {
1818
1895
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
1819
1896
  await fs4.mkdir(this.config.worktreeDir, { recursive: true });
1820
- const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
1897
+ const absoluteWorktreePath = path6.resolve(this.mainWorktreePath);
1821
1898
  const branches = await bareGit.branch();
1822
1899
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
1823
1900
  const useNoCheckoutMain = !!this.config.sparseCheckout;
@@ -1853,7 +1930,7 @@ var GitService = class {
1853
1930
  }
1854
1931
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
1855
1932
  const mainWorktreeRegistered = updatedWorktrees.some(
1856
- (w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
1933
+ (w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
1857
1934
  );
1858
1935
  if (!mainWorktreeRegistered) {
1859
1936
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -1883,12 +1960,12 @@ var GitService = class {
1883
1960
  this.assertInitialized();
1884
1961
  this.logger.info("Fetching latest data from remote...");
1885
1962
  const git = this.getCachedGit(this.mainWorktreePath, this.isLfsSkipEnabled());
1886
- await git.fetch(["--all", "--prune"]);
1963
+ await git.fetch(["--all", "--prune", "--progress"]);
1887
1964
  }
1888
1965
  async fetchBranch(branchName) {
1889
1966
  this.assertInitialized();
1890
1967
  const git = this.getCachedGit(this.mainWorktreePath, this.isLfsSkipEnabled());
1891
- await git.fetch(["origin", branchName, "--prune"]);
1968
+ await git.fetch(["origin", branchName, "--prune", "--progress"]);
1892
1969
  }
1893
1970
  assertInitialized() {
1894
1971
  if (!this.git) {
@@ -1936,7 +2013,7 @@ var GitService = class {
1936
2013
  const existence = await Promise.all(
1937
2014
  lfsFileList.map(async (f) => {
1938
2015
  try {
1939
- await fs4.access(path5.join(worktreePath, f));
2016
+ await fs4.access(path6.join(worktreePath, f));
1940
2017
  return f;
1941
2018
  } catch {
1942
2019
  return null;
@@ -1964,7 +2041,7 @@ var GitService = class {
1964
2041
  let allDownloaded = true;
1965
2042
  const notDownloaded = [];
1966
2043
  for (const file of samplesToCheck) {
1967
- const filePath = path5.join(worktreePath, file);
2044
+ const filePath = path6.join(worktreePath, file);
1968
2045
  try {
1969
2046
  const handle = await fs4.open(filePath, "r");
1970
2047
  try {
@@ -2053,12 +2130,12 @@ var GitService = class {
2053
2130
  }
2054
2131
  async addWorktree(branchName, worktreePath) {
2055
2132
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
2056
- const absoluteWorktreePath = path5.resolve(worktreePath);
2057
- await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
2133
+ const absoluteWorktreePath = path6.resolve(worktreePath);
2134
+ await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
2058
2135
  try {
2059
2136
  await fs4.access(absoluteWorktreePath);
2060
2137
  const worktrees = await this.getWorktreesFromBare(bareGit);
2061
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
2138
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
2062
2139
  if (isValidWorktree) {
2063
2140
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2064
2141
  return;
@@ -2103,7 +2180,7 @@ var GitService = class {
2103
2180
  }
2104
2181
  if (errorMessage.includes("already registered worktree")) {
2105
2182
  const worktrees = await this.getWorktreesFromBare(bareGit);
2106
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
2183
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
2107
2184
  if (existingWorktree && !existingWorktree.isPrunable) {
2108
2185
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
2109
2186
  return;
@@ -2149,7 +2226,7 @@ var GitService = class {
2149
2226
  try {
2150
2227
  await fs4.access(absoluteWorktreePath);
2151
2228
  const worktrees = await this.getWorktreesFromBare(bareGit);
2152
- const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
2229
+ const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
2153
2230
  if (isValidWorktree) {
2154
2231
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
2155
2232
  return;
@@ -2179,7 +2256,7 @@ var GitService = class {
2179
2256
  const fallbackErrorMessage = getErrorMessage(fallbackError);
2180
2257
  if (fallbackErrorMessage.includes("already registered worktree")) {
2181
2258
  const worktrees = await this.getWorktreesFromBare(bareGit);
2182
- const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
2259
+ const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
2183
2260
  if (existingWorktree && !existingWorktree.isPrunable) {
2184
2261
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
2185
2262
  return;
@@ -2402,6 +2479,23 @@ var GitService = class {
2402
2479
  return false;
2403
2480
  }
2404
2481
  }
2482
+ async getChangedPathsInRange(worktreePath, fromRef, toRef) {
2483
+ const worktreeGit = this.getCachedGit(worktreePath);
2484
+ try {
2485
+ const out = await worktreeGit.raw([
2486
+ "-c",
2487
+ "core.quotePath=false",
2488
+ "diff",
2489
+ "--name-only",
2490
+ "--no-renames",
2491
+ `${fromRef}..${toRef}`
2492
+ ]);
2493
+ return out.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.length > 0);
2494
+ } catch (error) {
2495
+ this.logger.warn(`Failed to compute diff ${fromRef}..${toRef} in ${worktreePath}: ${getErrorMessage(error)}`);
2496
+ return null;
2497
+ }
2498
+ }
2405
2499
  async compareTreeContent(worktreePath, branch) {
2406
2500
  const worktreeGit = this.getCachedGit(worktreePath);
2407
2501
  try {
@@ -2486,7 +2580,7 @@ var GitService = class {
2486
2580
  // src/services/path-resolution.service.ts
2487
2581
  import { createHash } from "crypto";
2488
2582
  import * as fs5 from "fs";
2489
- import * as path6 from "path";
2583
+ import * as path7 from "path";
2490
2584
  var BRANCH_STEM_MAX = 80;
2491
2585
  var BRANCH_HASH_LEN = 8;
2492
2586
  var PathResolutionService = class {
@@ -2496,22 +2590,22 @@ var PathResolutionService = class {
2496
2590
  return `${stem}-${hash}`;
2497
2591
  }
2498
2592
  getBranchWorktreePath(worktreeDir, branchName) {
2499
- return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
2593
+ return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
2500
2594
  }
2501
2595
  resolveRealPath(inputPath) {
2502
- const absolute = path6.resolve(inputPath);
2596
+ const absolute = path7.resolve(inputPath);
2503
2597
  const missing = [];
2504
2598
  let current = absolute;
2505
2599
  while (!fs5.existsSync(current)) {
2506
- const parent = path6.dirname(current);
2600
+ const parent = path7.dirname(current);
2507
2601
  if (parent === current) {
2508
2602
  return absolute;
2509
2603
  }
2510
- missing.unshift(path6.basename(current));
2604
+ missing.unshift(path7.basename(current));
2511
2605
  current = parent;
2512
2606
  }
2513
2607
  try {
2514
- return path6.join(fs5.realpathSync(current), ...missing);
2608
+ return path7.join(fs5.realpathSync(current), ...missing);
2515
2609
  } catch {
2516
2610
  return absolute;
2517
2611
  }
@@ -2521,7 +2615,7 @@ var PathResolutionService = class {
2521
2615
  const a = fold(resolved);
2522
2616
  const b = fold(resolvedBase);
2523
2617
  if (a === b) return true;
2524
- return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
2618
+ return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
2525
2619
  }
2526
2620
  normalizeWorktreePath(worktreePath, worktreeBaseDir) {
2527
2621
  const resolved = this.resolveRealPath(worktreePath);
@@ -2529,7 +2623,7 @@ var PathResolutionService = class {
2529
2623
  if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
2530
2624
  throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
2531
2625
  }
2532
- return path6.relative(resolvedBase, resolved);
2626
+ return path7.relative(resolvedBase, resolved);
2533
2627
  }
2534
2628
  isPathInsideBaseDir(targetPath, baseDir) {
2535
2629
  const resolved = this.resolveRealPath(targetPath);
@@ -2635,7 +2729,7 @@ var WorktreeSyncService = class {
2635
2729
  };
2636
2730
  }
2637
2731
  const barePath = this.gitService.getBareRepoPath();
2638
- const lockTarget = path7.join(barePath, "HEAD");
2732
+ const lockTarget = path8.join(barePath, "HEAD");
2639
2733
  try {
2640
2734
  await fs6.access(lockTarget);
2641
2735
  } catch {
@@ -2833,12 +2927,12 @@ var WorktreeSyncService = class {
2833
2927
  }
2834
2928
  const reservedPaths = /* @__PURE__ */ new Map();
2835
2929
  for (const w of worktrees) {
2836
- reservedPaths.set(path7.resolve(w.path), w.branch);
2930
+ reservedPaths.set(path8.resolve(w.path), w.branch);
2837
2931
  }
2838
2932
  const plan = [];
2839
2933
  for (const branchName of newBranches) {
2840
2934
  const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
2841
- const resolved = path7.resolve(worktreePath);
2935
+ const resolved = path8.resolve(worktreePath);
2842
2936
  const conflict = reservedPaths.get(resolved);
2843
2937
  if (conflict && conflict !== branchName) {
2844
2938
  this.logger.error(
@@ -3043,12 +3137,12 @@ var WorktreeSyncService = class {
3043
3137
  }
3044
3138
  async updateExistingWorktrees(worktrees, remoteBranches) {
3045
3139
  this.logger.info("Step 4: Checking for worktrees that need updates...");
3046
- const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3140
+ const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3047
3141
  try {
3048
3142
  const diverged = await fs6.readdir(divergedDir);
3049
3143
  if (diverged.length > 0) {
3050
3144
  this.logger.info(
3051
- `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
3145
+ `\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
3052
3146
  );
3053
3147
  }
3054
3148
  } catch {
@@ -3078,7 +3172,23 @@ var WorktreeSyncService = class {
3078
3172
  return { action: "diverged", worktree };
3079
3173
  }
3080
3174
  const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
3081
- return isBehind ? { action: "update", worktree } : null;
3175
+ if (!isBehind) return null;
3176
+ const sparseCfg = this.config.sparseCheckout;
3177
+ if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
3178
+ const sparseService = this.gitService.getSparseCheckoutService();
3179
+ if (sparseService.resolveMode(sparseCfg) === "cone") {
3180
+ const diff = await this.gitService.getChangedPathsInRange(
3181
+ worktree.path,
3182
+ "HEAD",
3183
+ `origin/${worktree.branch}`
3184
+ );
3185
+ if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
3186
+ this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
3187
+ return null;
3188
+ }
3189
+ }
3190
+ }
3191
+ return { action: "update", worktree };
3082
3192
  })
3083
3193
  )
3084
3194
  );
@@ -3156,13 +3266,13 @@ var WorktreeSyncService = class {
3156
3266
  }
3157
3267
  async cleanupOrphanedDirectories(worktrees) {
3158
3268
  try {
3159
- const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
3269
+ const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
3160
3270
  const allDirs = await fs6.readdir(this.config.worktreeDir);
3161
3271
  const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
3162
3272
  const orphanedDirs = [];
3163
3273
  for (const dir of regularDirs) {
3164
3274
  const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
3165
- return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
3275
+ return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
3166
3276
  });
3167
3277
  if (!isPartOfWorktree) {
3168
3278
  orphanedDirs.push(dir);
@@ -3171,7 +3281,7 @@ var WorktreeSyncService = class {
3171
3281
  if (orphanedDirs.length > 0) {
3172
3282
  this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
3173
3283
  for (const dir of orphanedDirs) {
3174
- const dirPath = path7.join(this.config.worktreeDir, dir);
3284
+ const dirPath = path8.join(this.config.worktreeDir, dir);
3175
3285
  try {
3176
3286
  const stat4 = await fs6.stat(dirPath);
3177
3287
  if (stat4.isDirectory()) {
@@ -3205,7 +3315,7 @@ var WorktreeSyncService = class {
3205
3315
  } else {
3206
3316
  this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
3207
3317
  const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
3208
- const relativePath = path7.relative(process.cwd(), divergedPath);
3318
+ const relativePath = path8.relative(process.cwd(), divergedPath);
3209
3319
  this.logger.info(` Moved to: ${relativePath}`);
3210
3320
  this.logger.info(` Your local changes are preserved. To review:`);
3211
3321
  this.logger.info(` cd ${relativePath}`);
@@ -3229,12 +3339,12 @@ var WorktreeSyncService = class {
3229
3339
  }
3230
3340
  }
3231
3341
  async divergeWorktree(worktreePath, branchName) {
3232
- const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3342
+ const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
3233
3343
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
3234
3344
  const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
3235
3345
  const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
3236
3346
  const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
3237
- const divergedPath = path7.join(divergedBaseDir, divergedName);
3347
+ const divergedPath = path8.join(divergedBaseDir, divergedName);
3238
3348
  await fs6.mkdir(divergedBaseDir, { recursive: true });
3239
3349
  try {
3240
3350
  await fs6.rename(worktreePath, divergedPath);
@@ -3261,7 +3371,7 @@ var WorktreeSyncService = class {
3261
3371
  Original worktree location: ${worktreePath}`
3262
3372
  };
3263
3373
  await fs6.writeFile(
3264
- path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
3374
+ path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
3265
3375
  JSON.stringify(metadata, null, 2)
3266
3376
  );
3267
3377
  return divergedPath;
@@ -3316,9 +3426,9 @@ var RepositoryContext = class {
3316
3426
  discoveryCache = /* @__PURE__ */ new Map();
3317
3427
  async loadConfig(configPath, options = {}) {
3318
3428
  const setDefaultCurrent = options.setDefaultCurrent ?? true;
3319
- const absolutePath = path8.resolve(configPath);
3429
+ const absolutePath = path9.resolve(configPath);
3320
3430
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
3321
- const configDir = path8.dirname(absolutePath);
3431
+ const configDir = path9.dirname(absolutePath);
3322
3432
  const globalDefaults = configFile.defaults;
3323
3433
  const resolvedAll = [];
3324
3434
  for (const repo of configFile.repositories) {
@@ -3355,7 +3465,7 @@ var RepositoryContext = class {
3355
3465
  return configFile.repositories;
3356
3466
  }
3357
3467
  async detectFromPath(dirPath) {
3358
- const absolutePath = path8.resolve(dirPath);
3468
+ const absolutePath = path9.resolve(dirPath);
3359
3469
  const cached = this.discoveryCache.get(absolutePath);
3360
3470
  if (cached && await this.isCacheFresh(cached)) {
3361
3471
  return cached.result;
@@ -3374,8 +3484,8 @@ var RepositoryContext = class {
3374
3484
  const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
3375
3485
  if (result.isWorktree && result.bareRepoPath && adminDir) {
3376
3486
  const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
3377
- safeMtimeMs(path8.join(adminDir, "HEAD")),
3378
- safeMtimeMs(path8.join(result.bareRepoPath, "worktrees"))
3487
+ safeMtimeMs(path9.join(adminDir, "HEAD")),
3488
+ safeMtimeMs(path9.join(result.bareRepoPath, "worktrees"))
3379
3489
  ]);
3380
3490
  this.discoveryCache.set(absolutePath, {
3381
3491
  result,
@@ -3407,8 +3517,8 @@ var RepositoryContext = class {
3407
3517
  return this.discoveryCache.size;
3408
3518
  }
3409
3519
  async discoverSiblingRepositories(currentBareRepoPath) {
3410
- const repoDir = path8.dirname(currentBareRepoPath);
3411
- const workspaceRoot = path8.dirname(repoDir);
3520
+ const repoDir = path9.dirname(currentBareRepoPath);
3521
+ const workspaceRoot = path9.dirname(repoDir);
3412
3522
  if (workspaceRoot === repoDir) return [];
3413
3523
  let entries;
3414
3524
  try {
@@ -3425,15 +3535,15 @@ var RepositoryContext = class {
3425
3535
  const results = [];
3426
3536
  await Promise.all(
3427
3537
  entries.map(async (entry) => {
3428
- const candidate = path8.join(workspaceRoot, entry);
3429
- const bareCandidate = path8.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3538
+ const candidate = path9.join(workspaceRoot, entry);
3539
+ const bareCandidate = path9.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3430
3540
  try {
3431
3541
  const stat4 = await fs7.stat(bareCandidate);
3432
3542
  if (!stat4.isDirectory()) return;
3433
3543
  } catch {
3434
3544
  return;
3435
3545
  }
3436
- const resolvedBare = path8.resolve(bareCandidate);
3546
+ const resolvedBare = path9.resolve(bareCandidate);
3437
3547
  const matchedName = configBares.get(normalizePathForCompare(resolvedBare));
3438
3548
  results.push({
3439
3549
  name: matchedName ?? entry,
@@ -3455,8 +3565,8 @@ var RepositoryContext = class {
3455
3565
  if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
3456
3566
  if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
3457
3567
  const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
3458
- safeMtimeMs(path8.join(cached.worktreeAdminDir, "HEAD")),
3459
- safeMtimeMs(path8.join(cached.result.bareRepoPath, "worktrees"))
3568
+ safeMtimeMs(path9.join(cached.worktreeAdminDir, "HEAD")),
3569
+ safeMtimeMs(path9.join(cached.result.bareRepoPath, "worktrees"))
3460
3570
  ]);
3461
3571
  return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
3462
3572
  }
@@ -3497,13 +3607,13 @@ var RepositoryContext = class {
3497
3607
  return unsupported("Invalid .git file format (missing gitdir line)");
3498
3608
  }
3499
3609
  const gitdir = gitdirMatch[1].trim();
3500
- const resolvedGitdir = path8.isAbsolute(gitdir) ? gitdir : path8.resolve(worktreeRoot, gitdir);
3610
+ const resolvedGitdir = path9.isAbsolute(gitdir) ? gitdir : path9.resolve(worktreeRoot, gitdir);
3501
3611
  const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
3502
3612
  if (!worktreesMatch) {
3503
3613
  return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
3504
3614
  }
3505
- const bareRepoPath = path8.resolve(worktreesMatch[1]);
3506
- const adminDir = path8.resolve(resolvedGitdir);
3615
+ const bareRepoPath = path9.resolve(worktreesMatch[1]);
3616
+ const adminDir = path9.resolve(resolvedGitdir);
3507
3617
  let repoUrl = null;
3508
3618
  let worktrees = [];
3509
3619
  let currentBranch = null;
@@ -3544,7 +3654,7 @@ var RepositoryContext = class {
3544
3654
  adminDir
3545
3655
  };
3546
3656
  }
3547
- const worktreeDir = path8.dirname(worktreeRoot);
3657
+ const worktreeDir = path9.dirname(worktreeRoot);
3548
3658
  const noUrlReason = "no remote origin URL detected";
3549
3659
  const capabilities = {
3550
3660
  listWorktrees: { available: true },
@@ -3580,7 +3690,7 @@ var RepositoryContext = class {
3580
3690
  cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
3581
3691
  runOnce: true
3582
3692
  };
3583
- const detectedKey = `${AUTO_DETECT_PREFIX}${path8.basename(bareRepoPath)}@${bareRepoPath}`;
3693
+ const detectedKey = `${AUTO_DETECT_PREFIX}${path9.basename(bareRepoPath)}@${bareRepoPath}`;
3584
3694
  if (!this.repos.has(detectedKey)) {
3585
3695
  this.repos.set(detectedKey, {
3586
3696
  name: detectedKey,
@@ -3672,7 +3782,7 @@ function parseWorktreeList(output, currentPath) {
3672
3782
  const foldedCurrent = normalizePathForCompare(currentPath);
3673
3783
  const results = [];
3674
3784
  for (const wt of parseWorktreeListPorcelain(output)) {
3675
- const resolved = path8.resolve(wt.path);
3785
+ const resolved = path9.resolve(wt.path);
3676
3786
  const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
3677
3787
  if (!branch) continue;
3678
3788
  results.push({
@@ -3692,10 +3802,10 @@ async function safeMtimeMs(filePath) {
3692
3802
  }
3693
3803
  }
3694
3804
  async function findWorktreeRoot(startPath) {
3695
- let current = path8.resolve(startPath);
3696
- const root = path8.parse(current).root;
3805
+ let current = path9.resolve(startPath);
3806
+ const root = path9.parse(current).root;
3697
3807
  while (true) {
3698
- const gitPath = path8.join(current, ".git");
3808
+ const gitPath = path9.join(current, ".git");
3699
3809
  try {
3700
3810
  const content = await fs7.readFile(gitPath, "utf-8");
3701
3811
  return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
@@ -3709,7 +3819,7 @@ async function findWorktreeRoot(startPath) {
3709
3819
  }
3710
3820
  }
3711
3821
  if (current === root) return null;
3712
- const parent = path8.dirname(current);
3822
+ const parent = path9.dirname(current);
3713
3823
  if (parent === current) return null;
3714
3824
  current = parent;
3715
3825
  }
@@ -3720,7 +3830,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3720
3830
  import { z } from "zod";
3721
3831
 
3722
3832
  // src/mcp/handlers.ts
3723
- import * as path9 from "path";
3833
+ import * as path10 from "path";
3724
3834
  import pLimit2 from "p-limit";
3725
3835
 
3726
3836
  // src/utils/disk-space.ts
@@ -3917,7 +4027,7 @@ async function getReadyService(ctx, repoName, options = {}) {
3917
4027
  }
3918
4028
  async function ensureRepoWorktreePath(ctx, params, git) {
3919
4029
  await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
3920
- return path9.resolve(params.path);
4030
+ return path10.resolve(params.path);
3921
4031
  }
3922
4032
  async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
3923
4033
  const discovered = ctx.getDiscoveredContext(repoName);
@@ -3978,7 +4088,7 @@ async function handleListWorktrees(ctx, params, _extra) {
3978
4088
  const results = await Promise.all(
3979
4089
  worktrees.map(
3980
4090
  (wt) => limit(async () => {
3981
- const resolvedPath = path9.resolve(wt.path);
4091
+ const resolvedPath = path10.resolve(wt.path);
3982
4092
  const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
3983
4093
  const [status, divergence, metadata, sizeBytes] = await Promise.all([
3984
4094
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
@@ -4058,7 +4168,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
4058
4168
  return formatToolResponse({
4059
4169
  success: true,
4060
4170
  branchName,
4061
- worktreePath: path9.resolve(worktreePath),
4171
+ worktreePath: path10.resolve(worktreePath),
4062
4172
  created,
4063
4173
  pushed
4064
4174
  });
@@ -4146,7 +4256,7 @@ async function handleLoadConfig(ctx, params, _extra) {
4146
4256
  }
4147
4257
  await ctx.loadConfig(configPath);
4148
4258
  return formatToolResponse({
4149
- configPath: path9.resolve(configPath),
4259
+ configPath: path10.resolve(configPath),
4150
4260
  currentRepository: ctx.getCurrentRepo(),
4151
4261
  repositories: ctx.getRepositoryList()
4152
4262
  });