sync-worktrees 3.2.0 → 3.3.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.
@@ -6,7 +6,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  // src/mcp/context.ts
7
7
  import * as fs7 from "fs/promises";
8
8
  import * as path8 from "path";
9
- import simpleGit4 from "simple-git";
9
+ import simpleGit5 from "simple-git";
10
10
 
11
11
  // src/constants.ts
12
12
  var GIT_CONSTANTS = {
@@ -79,6 +79,7 @@ var ERROR_MESSAGES = {
79
79
  };
80
80
  var ENV_CONSTANTS = {
81
81
  GIT_LFS_SKIP_SMUDGE: "GIT_LFS_SKIP_SMUDGE",
82
+ GIT_ATTR_SOURCE: "GIT_ATTR_SOURCE",
82
83
  NODE_ENV_TEST: "test"
83
84
  };
84
85
  var PATH_CONSTANTS = {
@@ -103,7 +104,7 @@ var METADATA_CONSTANTS = {
103
104
 
104
105
  // src/services/config-loader.service.ts
105
106
  import * as fs from "fs/promises";
106
- import * as path from "path";
107
+ import * as path2 from "path";
107
108
  import { pathToFileURL } from "url";
108
109
  import * as cron from "node-cron";
109
110
 
@@ -127,14 +128,124 @@ function filterBranchesByName(branches, include, exclude) {
127
128
  return result;
128
129
  }
129
130
 
131
+ // src/utils/git-url.ts
132
+ function extractRepoNameFromUrl(gitUrl) {
133
+ const url = gitUrl.trim();
134
+ const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
135
+ if (sshMatch) {
136
+ return sshMatch[1];
137
+ }
138
+ const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
139
+ if (sshUrlMatch) {
140
+ return sshUrlMatch[1];
141
+ }
142
+ const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
143
+ if (httpsMatch) {
144
+ return httpsMatch[1];
145
+ }
146
+ const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
147
+ if (fileMatch) {
148
+ return fileMatch[1];
149
+ }
150
+ throw new Error(`Invalid Git URL format: ${gitUrl}`);
151
+ }
152
+ function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
153
+ const repoName = extractRepoNameFromUrl(repoUrl);
154
+ return `${baseDir}/${repoName}`;
155
+ }
156
+
157
+ // src/utils/path-compare.ts
158
+ import * as path from "path";
159
+ var CASE_INSENSITIVE_PLATFORMS = /* @__PURE__ */ new Set(["darwin"]);
160
+ function isCaseInsensitiveFs(platform = process.platform) {
161
+ return CASE_INSENSITIVE_PLATFORMS.has(platform);
162
+ }
163
+ function normalizePathForCompare(p, platform = process.platform) {
164
+ const resolved = path.resolve(p);
165
+ return isCaseInsensitiveFs(platform) ? resolved.toLowerCase() : resolved;
166
+ }
167
+ function pathsEqual(a, b, platform = process.platform) {
168
+ return normalizePathForCompare(a, platform) === normalizePathForCompare(b, platform);
169
+ }
170
+
171
+ // src/errors/index.ts
172
+ var SyncWorktreesError = class extends Error {
173
+ constructor(message, code, cause) {
174
+ super(message);
175
+ this.code = code;
176
+ this.cause = cause;
177
+ this.name = this.constructor.name;
178
+ Object.setPrototypeOf(this, new.target.prototype);
179
+ if (cause && cause.stack) {
180
+ this.stack = `${this.stack}
181
+ Caused by: ${cause.stack}`;
182
+ }
183
+ }
184
+ };
185
+ var GitError = class extends SyncWorktreesError {
186
+ constructor(message, code, cause) {
187
+ super(message, `GIT_${code}`, cause);
188
+ }
189
+ };
190
+ var GitOperationError = class extends GitError {
191
+ constructor(operation, details, cause) {
192
+ super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
193
+ }
194
+ };
195
+ var WorktreeError = class extends SyncWorktreesError {
196
+ constructor(message, code, cause) {
197
+ super(message, `WORKTREE_${code}`, cause);
198
+ }
199
+ };
200
+ 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;
204
+ this.reasons = reasons;
205
+ }
206
+ };
207
+ var ConfigError = class extends SyncWorktreesError {
208
+ constructor(message, code, cause) {
209
+ super(message, `CONFIG_${code}`, cause);
210
+ }
211
+ };
212
+ var ConfigValidationError = class extends ConfigError {
213
+ constructor(field, reason) {
214
+ super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
215
+ this.field = field;
216
+ this.reason = reason;
217
+ }
218
+ };
219
+
220
+ // src/utils/sanitize-name.ts
221
+ var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
222
+ var ILLEGAL_CHARS = /[<>:"|?*\x00-\x1f]/g;
223
+ function sanitizeNameForPath(name, fieldContext = "name") {
224
+ if (!name || typeof name !== "string") {
225
+ throw new ConfigValidationError(fieldContext, "must be a non-empty string");
226
+ }
227
+ let cleaned = name.trim();
228
+ cleaned = cleaned.replace(/[/\\]/g, "-");
229
+ cleaned = cleaned.replace(/^\.+/, "");
230
+ cleaned = cleaned.replace(ILLEGAL_CHARS, "_");
231
+ cleaned = cleaned.replace(/[. ]+$/, "");
232
+ if (cleaned.length === 0) {
233
+ throw new ConfigValidationError(fieldContext, `'${name}' produces an empty path segment after sanitization`);
234
+ }
235
+ if (WINDOWS_RESERVED.test(cleaned)) {
236
+ throw new ConfigValidationError(fieldContext, `'${cleaned}' is a reserved name on Windows`);
237
+ }
238
+ return cleaned;
239
+ }
240
+
130
241
  // src/services/config-loader.service.ts
131
242
  var ConfigLoaderService = class {
132
243
  async findConfigUpward(startDir) {
133
- let current = path.resolve(startDir);
134
- const root = path.parse(current).root;
244
+ let current = path2.resolve(startDir);
245
+ const root = path2.parse(current).root;
135
246
  while (true) {
136
247
  for (const name of CONFIG_FILE_NAMES) {
137
- const candidate = path.join(current, name);
248
+ const candidate = path2.join(current, name);
138
249
  try {
139
250
  await fs.access(candidate);
140
251
  return candidate;
@@ -142,13 +253,13 @@ var ConfigLoaderService = class {
142
253
  }
143
254
  }
144
255
  if (current === root) return null;
145
- const parent = path.dirname(current);
256
+ const parent = path2.dirname(current);
146
257
  if (parent === current) return null;
147
258
  current = parent;
148
259
  }
149
260
  }
150
261
  async loadConfigFile(configPath) {
151
- const absolutePath = path.resolve(configPath);
262
+ const absolutePath = path2.resolve(configPath);
152
263
  try {
153
264
  await fs.access(absolutePath);
154
265
  } catch {
@@ -224,7 +335,11 @@ var ConfigLoaderService = class {
224
335
  if (repoObj.hooks !== void 0) {
225
336
  this.validateHooksConfig(repoObj.hooks, `Repository '${repoObj.name}'`);
226
337
  }
338
+ if (repoObj.sparseCheckout !== void 0) {
339
+ this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
340
+ }
227
341
  });
342
+ this.warnOnDuplicateRepoUrls(configObj.repositories);
228
343
  if (configObj.defaults) {
229
344
  if (typeof configObj.defaults !== "object") {
230
345
  throw new Error("'defaults' must be an object");
@@ -248,6 +363,9 @@ var ConfigLoaderService = class {
248
363
  if (defaults.hooks !== void 0) {
249
364
  this.validateHooksConfig(defaults.hooks, "defaults");
250
365
  }
366
+ if (defaults.sparseCheckout !== void 0) {
367
+ this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
368
+ }
251
369
  }
252
370
  if (configObj.retry !== void 0) {
253
371
  if (typeof configObj.retry !== "object") {
@@ -337,6 +455,60 @@ var ConfigLoaderService = class {
337
455
  }
338
456
  }
339
457
  }
458
+ validateSparseCheckoutConfig(value, context) {
459
+ if (typeof value !== "object" || value === null) {
460
+ throw new Error(`'sparseCheckout' in ${context} must be an object`);
461
+ }
462
+ const cfg = value;
463
+ if (!Array.isArray(cfg.include)) {
464
+ throw new Error(`'sparseCheckout.include' in ${context} must be an array`);
465
+ }
466
+ if (cfg.include.length === 0) {
467
+ throw new Error(`'sparseCheckout.include' in ${context} must contain at least one pattern`);
468
+ }
469
+ for (let i = 0; i < cfg.include.length; i++) {
470
+ const p = cfg.include[i];
471
+ if (typeof p !== "string" || p.trim() === "") {
472
+ throw new Error(
473
+ `'sparseCheckout.include' in ${context} must contain only non-empty strings (invalid at index ${i})`
474
+ );
475
+ }
476
+ }
477
+ if (cfg.exclude !== void 0) {
478
+ if (!Array.isArray(cfg.exclude)) {
479
+ throw new Error(`'sparseCheckout.exclude' in ${context} must be an array`);
480
+ }
481
+ for (let i = 0; i < cfg.exclude.length; i++) {
482
+ const p = cfg.exclude[i];
483
+ if (typeof p !== "string" || p.trim() === "") {
484
+ throw new Error(
485
+ `'sparseCheckout.exclude' in ${context} must contain only non-empty strings (invalid at index ${i})`
486
+ );
487
+ }
488
+ }
489
+ }
490
+ if (cfg.mode !== void 0 && cfg.mode !== "cone" && cfg.mode !== "no-cone") {
491
+ throw new Error(`'sparseCheckout.mode' in ${context} must be 'cone' or 'no-cone'`);
492
+ }
493
+ }
494
+ warnOnDuplicateRepoUrls(repositories) {
495
+ const seen = /* @__PURE__ */ new Map();
496
+ for (const repo of repositories) {
497
+ const url = typeof repo.repoUrl === "string" ? repo.repoUrl : null;
498
+ const name = typeof repo.name === "string" ? repo.name : null;
499
+ if (!url || !name) continue;
500
+ const list = seen.get(url) ?? [];
501
+ list.push(name);
502
+ seen.set(url, list);
503
+ }
504
+ for (const [url, names] of seen) {
505
+ if (names.length > 1) {
506
+ console.warn(
507
+ `[sync-worktrees] repoUrl '${url}' appears in multiple entries (${names.join(", ")}). Pin 'bareRepoDir' on duplicate entries to make config reorder-proof.`
508
+ );
509
+ }
510
+ }
511
+ }
340
512
  validateHooksConfig(hooks, context) {
341
513
  if (typeof hooks !== "object" || hooks === null) {
342
514
  throw new Error(`'hooks' in ${context} must be an object`);
@@ -356,7 +528,7 @@ var ConfigLoaderService = class {
356
528
  }
357
529
  }
358
530
  }
359
- resolveRepositoryConfig(repo, defaults, configDir, globalRetry) {
531
+ resolveRepositoryConfig(repo, defaults, configDir, globalRetry, allRepositories) {
360
532
  const resolved = {
361
533
  name: repo.name,
362
534
  repoUrl: repo.repoUrl,
@@ -366,6 +538,11 @@ var ConfigLoaderService = class {
366
538
  };
367
539
  if (repo.bareRepoDir) {
368
540
  resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
541
+ } else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories)) {
542
+ const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
543
+ resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
544
+ } else {
545
+ resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
369
546
  }
370
547
  if (repo.branchMaxAge || defaults?.branchMaxAge) {
371
548
  resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
@@ -405,8 +582,32 @@ var ConfigLoaderService = class {
405
582
  ...repo.hooks || {}
406
583
  };
407
584
  }
585
+ const sparse = repo.sparseCheckout ?? defaults?.sparseCheckout;
586
+ if (sparse) {
587
+ resolved.sparseCheckout = sparse;
588
+ }
408
589
  return resolved;
409
590
  }
591
+ isDuplicateRepoUrl(repo, all) {
592
+ const firstIndex = all.findIndex((r) => r.repoUrl === repo.repoUrl);
593
+ const myIndex = all.indexOf(repo);
594
+ return firstIndex !== -1 && myIndex !== -1 && myIndex !== firstIndex;
595
+ }
596
+ detectBareRepoDirCollisions(repositories) {
597
+ const seen = /* @__PURE__ */ new Map();
598
+ for (const repo of repositories) {
599
+ if (!repo.bareRepoDir) continue;
600
+ const key = normalizePathForCompare(repo.bareRepoDir);
601
+ const displayPath = path2.resolve(repo.bareRepoDir);
602
+ const existing = seen.get(key);
603
+ if (existing && existing.name !== repo.name) {
604
+ throw new Error(
605
+ `Repositories '${existing.name}' and '${repo.name}' resolve to the same bareRepoDir '${displayPath}'. Set distinct 'bareRepoDir' values for duplicate repoUrl entries.`
606
+ );
607
+ }
608
+ seen.set(key, { name: repo.name, displayPath });
609
+ }
610
+ }
410
611
  isValidGitUrl(url) {
411
612
  if (/^https?:\/\/.+/.test(url)) return true;
412
613
  if (/^(ssh:\/\/|git@).+/.test(url)) return true;
@@ -415,10 +616,10 @@ var ConfigLoaderService = class {
415
616
  return false;
416
617
  }
417
618
  resolvePath(inputPath, baseDir) {
418
- if (path.isAbsolute(inputPath)) {
619
+ if (path2.isAbsolute(inputPath)) {
419
620
  return inputPath;
420
621
  }
421
- return path.resolve(baseDir || process.cwd(), inputPath);
622
+ return path2.resolve(baseDir || process.cwd(), inputPath);
422
623
  }
423
624
  filterRepositories(repositories, filter) {
424
625
  if (!filter) {
@@ -431,10 +632,11 @@ var ConfigLoaderService = class {
431
632
  }
432
633
  async buildRepositories(configPath, overrides) {
433
634
  const configFile = await this.loadConfigFile(configPath);
434
- const configDir = path.dirname(path.resolve(configPath));
635
+ const configDir = path2.dirname(path2.resolve(configPath));
435
636
  let repositories = configFile.repositories.map(
436
- (repo) => this.resolveRepositoryConfig(repo, configFile.defaults, configDir, configFile.retry)
637
+ (repo) => this.resolveRepositoryConfig(repo, configFile.defaults, configDir, configFile.retry, configFile.repositories)
437
638
  );
639
+ this.detectBareRepoDirCollisions(repositories);
438
640
  if (overrides?.filter) {
439
641
  repositories = this.filterRepositories(repositories, overrides.filter);
440
642
  }
@@ -800,71 +1002,8 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
800
1002
 
801
1003
  // src/services/git.service.ts
802
1004
  import * as fs4 from "fs/promises";
803
- import * as path4 from "path";
804
- import simpleGit3 from "simple-git";
805
-
806
- // src/errors/index.ts
807
- var SyncWorktreesError = class extends Error {
808
- constructor(message, code, cause) {
809
- super(message);
810
- this.code = code;
811
- this.cause = cause;
812
- this.name = this.constructor.name;
813
- Object.setPrototypeOf(this, new.target.prototype);
814
- if (cause && cause.stack) {
815
- this.stack = `${this.stack}
816
- Caused by: ${cause.stack}`;
817
- }
818
- }
819
- };
820
- var GitError = class extends SyncWorktreesError {
821
- constructor(message, code, cause) {
822
- super(message, `GIT_${code}`, cause);
823
- }
824
- };
825
- var GitOperationError = class extends GitError {
826
- constructor(operation, details, cause) {
827
- super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
828
- }
829
- };
830
- var WorktreeError = class extends SyncWorktreesError {
831
- constructor(message, code, cause) {
832
- super(message, `WORKTREE_${code}`, cause);
833
- }
834
- };
835
- var WorktreeNotCleanError = class extends WorktreeError {
836
- constructor(path10, reasons) {
837
- super(`Worktree at '${path10}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
838
- this.path = path10;
839
- this.reasons = reasons;
840
- }
841
- };
842
-
843
- // src/utils/git-url.ts
844
- function extractRepoNameFromUrl(gitUrl) {
845
- const url = gitUrl.trim();
846
- const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
847
- if (sshMatch) {
848
- return sshMatch[1];
849
- }
850
- const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
851
- if (sshUrlMatch) {
852
- return sshUrlMatch[1];
853
- }
854
- const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
855
- if (httpsMatch) {
856
- return httpsMatch[1];
857
- }
858
- const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
859
- if (fileMatch) {
860
- return fileMatch[1];
861
- }
862
- throw new Error(`Invalid Git URL format: ${gitUrl}`);
863
- }
864
- function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
865
- const repoName = extractRepoNameFromUrl(repoUrl);
866
- return `${baseDir}/${repoName}`;
867
- }
1005
+ import * as path5 from "path";
1006
+ import simpleGit4 from "simple-git";
868
1007
 
869
1008
  // src/utils/worktree-list-parser.ts
870
1009
  function parseWorktreeListPorcelain(output) {
@@ -907,10 +1046,101 @@ function parseWorktreeListPorcelain(output) {
907
1046
  return worktrees;
908
1047
  }
909
1048
 
1049
+ // src/services/sparse-checkout.service.ts
1050
+ import simpleGit from "simple-git";
1051
+ var SparseCheckoutService = class {
1052
+ logger;
1053
+ gitFactory;
1054
+ warnedConfigs = /* @__PURE__ */ new WeakSet();
1055
+ constructor(logger, gitFactory) {
1056
+ this.logger = logger ?? Logger.createDefault();
1057
+ this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
1058
+ }
1059
+ updateLogger(logger) {
1060
+ this.logger = logger;
1061
+ }
1062
+ resolveMode(cfg) {
1063
+ const hasExclude = !!cfg.exclude && cfg.exclude.length > 0;
1064
+ const hasNegation = cfg.include.some((p) => p.trim().startsWith("!"));
1065
+ if (cfg.mode === "no-cone") return "no-cone";
1066
+ if (hasExclude || hasNegation) {
1067
+ if (cfg.mode === "cone" && !this.warnedConfigs.has(cfg)) {
1068
+ this.logger.warn(
1069
+ "sparseCheckout: mode 'cone' is incompatible with excludes or negation patterns; auto-promoting to 'no-cone'"
1070
+ );
1071
+ this.warnedConfigs.add(cfg);
1072
+ }
1073
+ return "no-cone";
1074
+ }
1075
+ return cfg.mode ?? "cone";
1076
+ }
1077
+ buildPatterns(cfg) {
1078
+ return this.buildPatternsForMode(cfg, this.resolveMode(cfg));
1079
+ }
1080
+ buildPatternsForMode(cfg, mode) {
1081
+ const includes = cfg.include.map((p) => p.trim()).filter((p) => p.length > 0);
1082
+ if (mode === "cone") {
1083
+ return includes;
1084
+ }
1085
+ const excludes = (cfg.exclude ?? []).map((p) => p.trim()).filter((p) => p.length > 0).map((p) => p.startsWith("!") ? p : `!${p}`);
1086
+ return [...includes, ...excludes];
1087
+ }
1088
+ async applyToWorktree(worktreePath, cfg) {
1089
+ const mode = this.resolveMode(cfg);
1090
+ const patterns = this.buildPatternsForMode(cfg, mode);
1091
+ if (patterns.length === 0) {
1092
+ throw new Error("sparseCheckout produced no patterns; refusing to apply empty config");
1093
+ }
1094
+ const git = this.gitFactory(worktreePath);
1095
+ await git.raw(["sparse-checkout", "init", mode === "cone" ? "--cone" : "--no-cone"]);
1096
+ await git.raw(["sparse-checkout", "set", mode === "cone" ? "--cone" : "--no-cone", ...patterns]);
1097
+ }
1098
+ async readCurrent(worktreePath) {
1099
+ const git = this.gitFactory(worktreePath);
1100
+ try {
1101
+ const out = await git.raw(["sparse-checkout", "list"]);
1102
+ const lines = out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
1103
+ return lines.length === 0 ? null : lines;
1104
+ } catch {
1105
+ return null;
1106
+ }
1107
+ }
1108
+ async needsUpdate(worktreePath, cfg) {
1109
+ const current = await this.readCurrent(worktreePath);
1110
+ const desired = this.buildPatterns(cfg);
1111
+ if (current === null) return true;
1112
+ return !this.patternsEqual(current, desired);
1113
+ }
1114
+ isNarrowing(currentPatterns, nextPatterns) {
1115
+ if (!currentPatterns || currentPatterns.length === 0) return false;
1116
+ const isNeg = (p) => p.startsWith("!");
1117
+ const trim = (xs) => xs.map((p) => p.trim()).filter((p) => p.length > 0);
1118
+ const cur = trim(currentPatterns);
1119
+ const next = trim(nextPatterns);
1120
+ const positiveCurrent = new Set(cur.filter((p) => !isNeg(p)));
1121
+ const negativeCurrent = new Set(cur.filter(isNeg));
1122
+ const positiveNext = new Set(next.filter((p) => !isNeg(p)));
1123
+ const negativeNext = new Set(next.filter(isNeg));
1124
+ for (const p of positiveCurrent) {
1125
+ if (!positiveNext.has(p)) return true;
1126
+ }
1127
+ for (const p of negativeNext) {
1128
+ if (!negativeCurrent.has(p)) return true;
1129
+ }
1130
+ return false;
1131
+ }
1132
+ patternsEqual(a, b) {
1133
+ if (a.length !== b.length) return false;
1134
+ const at = a.map((x) => x.trim());
1135
+ const bt = b.map((x) => x.trim());
1136
+ return at.every((v, i) => v === bt[i]);
1137
+ }
1138
+ };
1139
+
910
1140
  // src/services/worktree-metadata.service.ts
911
1141
  import * as fs2 from "fs/promises";
912
- import * as path2 from "path";
913
- import simpleGit from "simple-git";
1142
+ import * as path3 from "path";
1143
+ import simpleGit2 from "simple-git";
914
1144
  var WorktreeMetadataService = class {
915
1145
  logger;
916
1146
  constructor(logger) {
@@ -922,7 +1152,7 @@ var WorktreeMetadataService = class {
922
1152
  * For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
923
1153
  */
924
1154
  getWorktreeDirectoryName(worktreePath) {
925
- return path2.basename(worktreePath);
1155
+ return path3.basename(worktreePath);
926
1156
  }
927
1157
  async getMetadataPath(bareRepoPath, worktreeName) {
928
1158
  if (worktreeName.includes("/") || worktreeName.includes("\\")) {
@@ -930,7 +1160,7 @@ var WorktreeMetadataService = class {
930
1160
  `getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
931
1161
  );
932
1162
  }
933
- return path2.join(
1163
+ return path3.join(
934
1164
  bareRepoPath,
935
1165
  METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
936
1166
  worktreeName,
@@ -943,7 +1173,7 @@ var WorktreeMetadataService = class {
943
1173
  }
944
1174
  async saveMetadata(bareRepoPath, worktreeName, metadata) {
945
1175
  const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
946
- await fs2.mkdir(path2.dirname(metadataPath), { recursive: true });
1176
+ await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
947
1177
  const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
948
1178
  let renamed = false;
949
1179
  try {
@@ -1034,7 +1264,7 @@ var WorktreeMetadataService = class {
1034
1264
  this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
1035
1265
  this.logger.info(` Attempting to create initial metadata...`);
1036
1266
  try {
1037
- const worktreeGit = simpleGit(worktreePath);
1267
+ const worktreeGit = simpleGit2(worktreePath);
1038
1268
  const currentCommit = await worktreeGit.revparse(["HEAD"]);
1039
1269
  const branchSummary = await worktreeGit.branch();
1040
1270
  const actualBranchName = branchSummary.current;
@@ -1136,8 +1366,8 @@ var WorktreeMetadataService = class {
1136
1366
 
1137
1367
  // src/services/worktree-status.service.ts
1138
1368
  import * as fs3 from "fs/promises";
1139
- import * as path3 from "path";
1140
- import simpleGit2 from "simple-git";
1369
+ import * as path4 from "path";
1370
+ import simpleGit3 from "simple-git";
1141
1371
  var OPERATION_FILES = [
1142
1372
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
1143
1373
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -1339,7 +1569,7 @@ var WorktreeStatusService = class {
1339
1569
  async detectOperationFile(gitDir) {
1340
1570
  const results = await Promise.all(
1341
1571
  OPERATION_FILES.map(
1342
- ({ file }) => fs3.access(path3.join(gitDir, file)).then(
1572
+ ({ file }) => fs3.access(path4.join(gitDir, file)).then(
1343
1573
  () => true,
1344
1574
  () => false
1345
1575
  )
@@ -1460,14 +1690,14 @@ var WorktreeStatusService = class {
1460
1690
  }
1461
1691
  }
1462
1692
  async resolveGitDir(worktreePath) {
1463
- const gitPath = path3.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
1693
+ const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
1464
1694
  try {
1465
1695
  const stat4 = await fs3.stat(gitPath);
1466
1696
  if (stat4.isFile()) {
1467
1697
  const content = await fs3.readFile(gitPath, "utf-8");
1468
1698
  const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
1469
1699
  if (gitdirMatch) {
1470
- return path3.resolve(worktreePath, gitdirMatch[1].trim());
1700
+ return path4.resolve(worktreePath, gitdirMatch[1].trim());
1471
1701
  }
1472
1702
  throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
1473
1703
  }
@@ -1481,10 +1711,10 @@ var WorktreeStatusService = class {
1481
1711
  }
1482
1712
  }
1483
1713
  createGitInstance(worktreePath) {
1484
- const key = `${path3.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
1714
+ const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
1485
1715
  let git = this.gitInstances.get(key);
1486
1716
  if (!git) {
1487
- git = this.config.skipLfs ? simpleGit2(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit2(worktreePath);
1717
+ git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
1488
1718
  this.gitInstances.set(key, git);
1489
1719
  }
1490
1720
  return git;
@@ -1497,9 +1727,10 @@ var GitService = class {
1497
1727
  this.config = config;
1498
1728
  this.logger = logger ?? Logger.createDefault(void 0, config.debug);
1499
1729
  this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
1500
- this.mainWorktreePath = path4.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1730
+ this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
1501
1731
  this.metadataService = new WorktreeMetadataService(this.logger);
1502
1732
  this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
1733
+ this.sparseCheckoutService = new SparseCheckoutService(this.logger);
1503
1734
  }
1504
1735
  git = null;
1505
1736
  bareRepoPath;
@@ -1508,29 +1739,34 @@ var GitService = class {
1508
1739
  // Will be updated after detection
1509
1740
  metadataService;
1510
1741
  statusService;
1742
+ sparseCheckoutService;
1511
1743
  logger;
1512
1744
  lfsSkipOverride = false;
1513
1745
  gitInstances = /* @__PURE__ */ new Map();
1746
+ getSparseCheckoutService() {
1747
+ return this.sparseCheckoutService;
1748
+ }
1514
1749
  getCachedGit(dirPath, useLfsSkip = false) {
1515
- const key = `${path4.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1750
+ const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
1516
1751
  let git = this.gitInstances.get(key);
1517
1752
  if (!git) {
1518
- git = useLfsSkip ? simpleGit3(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(dirPath);
1753
+ git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
1519
1754
  this.gitInstances.set(key, git);
1520
1755
  }
1521
1756
  return git;
1522
1757
  }
1523
1758
  updateLogger(logger) {
1524
1759
  this.logger = logger;
1760
+ this.sparseCheckoutService.updateLogger(logger);
1525
1761
  }
1526
1762
  async initialize() {
1527
1763
  const { repoUrl } = this.config;
1528
1764
  try {
1529
- await fs4.access(path4.join(this.bareRepoPath, "HEAD"));
1765
+ await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
1530
1766
  } catch {
1531
1767
  this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
1532
- await fs4.mkdir(path4.dirname(this.bareRepoPath), { recursive: true });
1533
- const cloneGit = this.isLfsSkipEnabled() ? simpleGit3().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3();
1768
+ await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
1769
+ const cloneGit = this.isLfsSkipEnabled() ? simpleGit4().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4();
1534
1770
  await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
1535
1771
  this.logger.info("\u2705 Clone successful.");
1536
1772
  }
@@ -1547,34 +1783,39 @@ var GitService = class {
1547
1783
  this.logger.info("Fetching remote branches...");
1548
1784
  await bareGit.fetch(["--all"]);
1549
1785
  this.defaultBranch = await this.detectDefaultBranch(bareGit);
1550
- this.mainWorktreePath = path4.join(this.config.worktreeDir, this.defaultBranch);
1786
+ this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
1551
1787
  let needsMainWorktree = true;
1552
1788
  try {
1553
1789
  const worktrees = await this.getWorktreesFromBare(bareGit);
1554
- needsMainWorktree = !worktrees.some((w) => path4.resolve(w.path) === path4.resolve(this.mainWorktreePath));
1790
+ needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
1555
1791
  } catch {
1556
1792
  }
1557
1793
  if (needsMainWorktree) {
1558
1794
  this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
1559
1795
  await fs4.mkdir(this.config.worktreeDir, { recursive: true });
1560
- const absoluteWorktreePath = path4.resolve(this.mainWorktreePath);
1796
+ const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
1561
1797
  const branches = await bareGit.branch();
1562
1798
  const defaultBranchExists = branches.all.includes(this.defaultBranch);
1799
+ const useNoCheckoutMain = !!this.config.sparseCheckout;
1800
+ const noCheckoutFlagMain = useNoCheckoutMain ? ["--no-checkout"] : [];
1563
1801
  try {
1564
1802
  if (defaultBranchExists) {
1565
- await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
1803
+ await bareGit.raw(["worktree", "add", ...noCheckoutFlagMain, absoluteWorktreePath, this.defaultBranch]);
1566
1804
  const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
1567
1805
  await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
1806
+ await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, false);
1568
1807
  } else {
1569
1808
  await bareGit.raw([
1570
1809
  "worktree",
1571
1810
  "add",
1811
+ ...noCheckoutFlagMain,
1572
1812
  "--track",
1573
1813
  "-b",
1574
1814
  this.defaultBranch,
1575
1815
  absoluteWorktreePath,
1576
1816
  `origin/${this.defaultBranch}`
1577
1817
  ]);
1818
+ await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, true);
1578
1819
  }
1579
1820
  } catch (error) {
1580
1821
  const errorMessage = getErrorMessage(error);
@@ -1588,7 +1829,7 @@ var GitService = class {
1588
1829
  }
1589
1830
  const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
1590
1831
  const mainWorktreeRegistered = updatedWorktrees.some(
1591
- (w) => path4.resolve(w.path) === path4.resolve(this.mainWorktreePath)
1832
+ (w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
1592
1833
  );
1593
1834
  if (!mainWorktreeRegistered) {
1594
1835
  if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -1657,13 +1898,29 @@ var GitService = class {
1657
1898
  return branches;
1658
1899
  }
1659
1900
  async verifyLfsFilesDownloaded(worktreePath, branchName) {
1660
- const worktreeGit = this.getCachedGit(worktreePath);
1901
+ const worktreeGit = this.config.sparseCheckout ? simpleGit4(worktreePath).env({ ...process.env, [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
1661
1902
  try {
1662
1903
  const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
1663
- const lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
1904
+ let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
1664
1905
  if (lfsFileList.length === 0) {
1665
1906
  return;
1666
1907
  }
1908
+ if (this.config.sparseCheckout) {
1909
+ const existence = await Promise.all(
1910
+ lfsFileList.map(async (f) => {
1911
+ try {
1912
+ await fs4.access(path5.join(worktreePath, f));
1913
+ return f;
1914
+ } catch {
1915
+ return null;
1916
+ }
1917
+ })
1918
+ );
1919
+ lfsFileList = existence.filter((f) => f !== null);
1920
+ if (lfsFileList.length === 0) {
1921
+ return;
1922
+ }
1923
+ }
1667
1924
  if (this.config.debug) {
1668
1925
  this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
1669
1926
  }
@@ -1680,7 +1937,7 @@ var GitService = class {
1680
1937
  let allDownloaded = true;
1681
1938
  const notDownloaded = [];
1682
1939
  for (const file of samplesToCheck) {
1683
- const filePath = path4.join(worktreePath, file);
1940
+ const filePath = path5.join(worktreePath, file);
1684
1941
  try {
1685
1942
  const handle = await fs4.open(filePath, "r");
1686
1943
  try {
@@ -1717,6 +1974,38 @@ var GitService = class {
1717
1974
  this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
1718
1975
  }
1719
1976
  }
1977
+ async checkoutHead(worktreePath) {
1978
+ const git = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
1979
+ await git.raw(["checkout", "HEAD"]);
1980
+ }
1981
+ async applySparseAndCheckout(absoluteWorktreePath) {
1982
+ if (!this.config.sparseCheckout) return;
1983
+ await this.sparseCheckoutService.applyToWorktree(absoluteWorktreePath, this.config.sparseCheckout);
1984
+ const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
1985
+ await worktreeGit.raw(["checkout", "HEAD"]);
1986
+ }
1987
+ async rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch, failureContext) {
1988
+ let worktreeRemoved = true;
1989
+ try {
1990
+ await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
1991
+ } catch (rollbackError) {
1992
+ worktreeRemoved = false;
1993
+ const ctx = failureContext ? ` after ${failureContext}` : "";
1994
+ this.logger.warn(
1995
+ ` - Rollback failed for '${branchName}' at '${absoluteWorktreePath}'${ctx}: ${getErrorMessage(rollbackError)}`
1996
+ );
1997
+ }
1998
+ if (createdNewBranch) {
1999
+ try {
2000
+ await bareGit.raw(["branch", "-D", branchName]);
2001
+ } catch (branchRollbackError) {
2002
+ this.logger.warn(
2003
+ ` - Rollback (branch delete) failed for '${branchName}': ${getErrorMessage(branchRollbackError)}`
2004
+ );
2005
+ }
2006
+ }
2007
+ return { worktreeRemoved };
2008
+ }
1720
2009
  async createWorktreeMetadata(bareGit, worktreePath, branchName) {
1721
2010
  try {
1722
2011
  const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
@@ -1737,12 +2026,12 @@ var GitService = class {
1737
2026
  }
1738
2027
  async addWorktree(branchName, worktreePath) {
1739
2028
  const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
1740
- const absoluteWorktreePath = path4.resolve(worktreePath);
1741
- await fs4.mkdir(path4.dirname(absoluteWorktreePath), { recursive: true });
2029
+ const absoluteWorktreePath = path5.resolve(worktreePath);
2030
+ await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
1742
2031
  try {
1743
2032
  await fs4.access(absoluteWorktreePath);
1744
2033
  const worktrees = await this.getWorktreesFromBare(bareGit);
1745
- const isValidWorktree = worktrees.some((w) => path4.resolve(w.path) === absoluteWorktreePath);
2034
+ const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
1746
2035
  if (isValidWorktree) {
1747
2036
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
1748
2037
  return;
@@ -1752,9 +2041,10 @@ var GitService = class {
1752
2041
  }
1753
2042
  } catch {
1754
2043
  }
2044
+ let createdNewBranch = false;
1755
2045
  try {
1756
2046
  const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
1757
- await this.runWorktreeAddByMatrix(
2047
+ createdNewBranch = await this.runWorktreeAddByMatrix(
1758
2048
  bareGit,
1759
2049
  branchName,
1760
2050
  absoluteWorktreePath,
@@ -1773,10 +2063,7 @@ var GitService = class {
1773
2063
  await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
1774
2064
  } catch (metadataError) {
1775
2065
  this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
1776
- try {
1777
- await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
1778
- } catch {
1779
- }
2066
+ await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
1780
2067
  throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
1781
2068
  }
1782
2069
  } catch (error) {
@@ -1789,7 +2076,7 @@ var GitService = class {
1789
2076
  }
1790
2077
  if (errorMessage.includes("already registered worktree")) {
1791
2078
  const worktrees = await this.getWorktreesFromBare(bareGit);
1792
- const existingWorktree = worktrees.find((w) => path4.resolve(w.path) === absoluteWorktreePath);
2079
+ const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
1793
2080
  if (existingWorktree && !existingWorktree.isPrunable) {
1794
2081
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
1795
2082
  return;
@@ -1800,9 +2087,10 @@ var GitService = class {
1800
2087
  await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
1801
2088
  } catch {
1802
2089
  }
2090
+ let retryCreatedNewBranch = false;
1803
2091
  try {
1804
2092
  const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
1805
- await this.runWorktreeAddByMatrix(
2093
+ retryCreatedNewBranch = await this.runWorktreeAddByMatrix(
1806
2094
  bareGit,
1807
2095
  branchName,
1808
2096
  absoluteWorktreePath,
@@ -1817,10 +2105,7 @@ var GitService = class {
1817
2105
  await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
1818
2106
  } catch (metadataError) {
1819
2107
  this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
1820
- try {
1821
- await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
1822
- } catch {
1823
- }
2108
+ await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, retryCreatedNewBranch);
1824
2109
  throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
1825
2110
  }
1826
2111
  return;
@@ -1837,7 +2122,7 @@ var GitService = class {
1837
2122
  try {
1838
2123
  await fs4.access(absoluteWorktreePath);
1839
2124
  const worktrees = await this.getWorktreesFromBare(bareGit);
1840
- const isValidWorktree = worktrees.some((w) => path4.resolve(w.path) === absoluteWorktreePath);
2125
+ const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
1841
2126
  if (isValidWorktree) {
1842
2127
  this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
1843
2128
  return;
@@ -1848,7 +2133,10 @@ var GitService = class {
1848
2133
  } catch {
1849
2134
  }
1850
2135
  try {
1851
- await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
2136
+ const useNoCheckout = !!this.config.sparseCheckout;
2137
+ const fallbackArgs = useNoCheckout ? ["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName] : ["worktree", "add", absoluteWorktreePath, branchName];
2138
+ await bareGit.raw(fallbackArgs);
2139
+ await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
1852
2140
  this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
1853
2141
  if (!this.isLfsSkipEnabled()) {
1854
2142
  await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
@@ -1857,17 +2145,14 @@ var GitService = class {
1857
2145
  await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
1858
2146
  } catch (metadataError) {
1859
2147
  this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
1860
- try {
1861
- await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
1862
- } catch {
1863
- }
2148
+ await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, false);
1864
2149
  throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
1865
2150
  }
1866
2151
  } catch (fallbackError) {
1867
2152
  const fallbackErrorMessage = getErrorMessage(fallbackError);
1868
2153
  if (fallbackErrorMessage.includes("already registered worktree")) {
1869
2154
  const worktrees = await this.getWorktreesFromBare(bareGit);
1870
- const existingWorktree = worktrees.find((w) => path4.resolve(w.path) === absoluteWorktreePath);
2155
+ const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
1871
2156
  if (existingWorktree && !existingWorktree.isPrunable) {
1872
2157
  this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
1873
2158
  return;
@@ -1878,42 +2163,64 @@ var GitService = class {
1878
2163
  }
1879
2164
  }
1880
2165
  async runWorktreeAddByMatrix(bareGit, branchName, absoluteWorktreePath, localExists, remoteExists) {
2166
+ const useNoCheckout = !!this.config.sparseCheckout;
2167
+ const noCheckoutFlag = useNoCheckout ? ["--no-checkout"] : [];
1881
2168
  if (localExists && remoteExists) {
1882
- await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
2169
+ await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
1883
2170
  try {
1884
2171
  const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
1885
2172
  await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
1886
2173
  } catch (error) {
1887
- let rollbackFailed = false;
1888
- try {
1889
- await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
1890
- } catch (rollbackError) {
1891
- rollbackFailed = true;
1892
- this.logger.warn(
1893
- ` - Rollback failed for '${branchName}' at '${absoluteWorktreePath}' after upstream setup error: ${getErrorMessage(rollbackError)}`
1894
- );
1895
- }
1896
- const detail = getErrorMessage(error);
1897
- const suffix = rollbackFailed ? " (rollback failed; partial worktree may remain)" : "";
1898
- const wrapped = new Error(`Failed to set upstream for '${branchName}': ${detail}${suffix}`);
1899
- wrapped.isUpstreamSetupFailure = true;
1900
- throw wrapped;
2174
+ throw await this.wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, false, error);
1901
2175
  }
1902
- return;
2176
+ await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
2177
+ return false;
1903
2178
  }
1904
2179
  if (localExists) {
1905
- await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
1906
- return;
2180
+ await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
2181
+ await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
2182
+ return false;
1907
2183
  }
1908
2184
  if (remoteExists) {
1909
- await bareGit.raw(["worktree", "add", "--track", "-b", branchName, absoluteWorktreePath, `origin/${branchName}`]);
1910
- return;
2185
+ await bareGit.raw([
2186
+ "worktree",
2187
+ "add",
2188
+ ...noCheckoutFlag,
2189
+ "--track",
2190
+ "-b",
2191
+ branchName,
2192
+ absoluteWorktreePath,
2193
+ `origin/${branchName}`
2194
+ ]);
2195
+ await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, true);
2196
+ return true;
1911
2197
  }
1912
2198
  throw new WorktreeError(
1913
2199
  `Branch '${branchName}' does not exist locally or on origin; create it first`,
1914
2200
  "BRANCH_NOT_FOUND"
1915
2201
  );
1916
2202
  }
2203
+ async runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, createdNewBranch) {
2204
+ try {
2205
+ await this.applySparseAndCheckout(absoluteWorktreePath);
2206
+ } catch (sparseError) {
2207
+ await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
2208
+ throw new Error(`Sparse-checkout setup failed for '${branchName}': ${getErrorMessage(sparseError)}`);
2209
+ }
2210
+ }
2211
+ async wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, createdNewBranch, error) {
2212
+ const { worktreeRemoved } = await this.rollbackPartialWorktree(
2213
+ bareGit,
2214
+ absoluteWorktreePath,
2215
+ branchName,
2216
+ createdNewBranch,
2217
+ "upstream setup error"
2218
+ );
2219
+ const suffix = worktreeRemoved ? "" : " (rollback failed; partial worktree may remain)";
2220
+ const wrapped = new Error(`Failed to set upstream for '${branchName}': ${getErrorMessage(error)}${suffix}`);
2221
+ wrapped.isUpstreamSetupFailure = true;
2222
+ return wrapped;
2223
+ }
1917
2224
  async removeWorktree(worktreePath) {
1918
2225
  const bareGit = this.getCachedGit(this.bareRepoPath);
1919
2226
  await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
@@ -2153,22 +2460,6 @@ var GitService = class {
2153
2460
  import { createHash } from "crypto";
2154
2461
  import * as fs5 from "fs";
2155
2462
  import * as path6 from "path";
2156
-
2157
- // src/utils/path-compare.ts
2158
- import * as path5 from "path";
2159
- var CASE_INSENSITIVE_PLATFORMS = /* @__PURE__ */ new Set(["darwin"]);
2160
- function isCaseInsensitiveFs(platform = process.platform) {
2161
- return CASE_INSENSITIVE_PLATFORMS.has(platform);
2162
- }
2163
- function normalizePathForCompare(p, platform = process.platform) {
2164
- const resolved = path5.resolve(p);
2165
- return isCaseInsensitiveFs(platform) ? resolved.toLowerCase() : resolved;
2166
- }
2167
- function pathsEqual(a, b, platform = process.platform) {
2168
- return normalizePathForCompare(a, platform) === normalizePathForCompare(b, platform);
2169
- }
2170
-
2171
- // src/services/path-resolution.service.ts
2172
2463
  var BRANCH_STEM_MAX = 80;
2173
2464
  var BRANCH_HASH_LEN = 8;
2174
2465
  var PathResolutionService = class {
@@ -2337,8 +2628,50 @@ var WorktreeSyncService = class {
2337
2628
  if (this.config.updateExistingWorktrees !== false) {
2338
2629
  await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
2339
2630
  }
2631
+ if (this.config.sparseCheckout) {
2632
+ await this.reapplySparseCheckout(worktrees);
2633
+ }
2340
2634
  await this.finalizeSyncAttempt(phaseTimer);
2341
2635
  }
2636
+ async reapplySparseCheckout(worktrees) {
2637
+ const sparseConfig = this.config.sparseCheckout;
2638
+ if (!sparseConfig) return;
2639
+ this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
2640
+ const sparseService = this.gitService.getSparseCheckoutService();
2641
+ const desired = sparseService.buildPatterns(sparseConfig);
2642
+ const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
2643
+ await Promise.all(
2644
+ worktrees.map(
2645
+ (worktree) => limit(async () => {
2646
+ try {
2647
+ try {
2648
+ await fs6.access(worktree.path);
2649
+ } catch {
2650
+ return;
2651
+ }
2652
+ const current = await sparseService.readCurrent(worktree.path);
2653
+ if (current !== null && sparseService.patternsEqual(current, desired)) return;
2654
+ if (sparseService.isNarrowing(current, desired)) {
2655
+ const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
2656
+ if (!status.canRemove) {
2657
+ this.logger.warn(
2658
+ ` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
2659
+ );
2660
+ return;
2661
+ }
2662
+ }
2663
+ await sparseService.applyToWorktree(worktree.path, sparseConfig);
2664
+ await this.gitService.checkoutHead(worktree.path);
2665
+ this.logger.info(` - \u2705 Sparse-checkout updated for '${worktree.branch}'`);
2666
+ } catch (error) {
2667
+ this.logger.warn(
2668
+ ` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
2669
+ );
2670
+ }
2671
+ })
2672
+ )
2673
+ );
2674
+ }
2342
2675
  async fetchLatestRemoteData(phaseTimer, syncContext) {
2343
2676
  this.logger.info("Step 1: Fetching latest data from remote...");
2344
2677
  phaseTimer.startPhase("Phase 1: Fetch");
@@ -2915,16 +3248,27 @@ var RepositoryContext = class {
2915
3248
  const setDefaultCurrent = options.setDefaultCurrent ?? true;
2916
3249
  const absolutePath = path8.resolve(configPath);
2917
3250
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
3251
+ const configDir = path8.dirname(absolutePath);
3252
+ const globalDefaults = configFile.defaults;
3253
+ const resolvedAll = [];
3254
+ for (const repo of configFile.repositories) {
3255
+ const resolved = this.configLoader.resolveRepositoryConfig(
3256
+ repo,
3257
+ globalDefaults,
3258
+ configDir,
3259
+ configFile.retry,
3260
+ configFile.repositories
3261
+ );
3262
+ resolvedAll.push(resolved);
3263
+ }
3264
+ this.configLoader.detectBareRepoDirCollisions(resolvedAll);
2918
3265
  for (const [name, entry] of this.repos) {
2919
3266
  if (entry.source === "config") {
2920
3267
  this.repos.delete(name);
2921
3268
  }
2922
3269
  }
2923
3270
  this.configPath = absolutePath;
2924
- const configDir = path8.dirname(absolutePath);
2925
- const globalDefaults = configFile.defaults;
2926
- for (const repo of configFile.repositories) {
2927
- const resolved = this.configLoader.resolveRepositoryConfig(repo, globalDefaults, configDir, configFile.retry);
3271
+ for (const resolved of resolvedAll) {
2928
3272
  this.repos.set(resolved.name, {
2929
3273
  name: resolved.name,
2930
3274
  config: resolved,
@@ -3094,7 +3438,7 @@ var RepositoryContext = class {
3094
3438
  let worktrees = [];
3095
3439
  let currentBranch = null;
3096
3440
  try {
3097
- const bareGit = simpleGit4(bareRepoPath);
3441
+ const bareGit = simpleGit5(bareRepoPath);
3098
3442
  try {
3099
3443
  const remoteResult = await bareGit.remote(["get-url", "origin"]);
3100
3444
  const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
@@ -3436,7 +3780,7 @@ function wrapHandler(fn) {
3436
3780
  }
3437
3781
 
3438
3782
  // src/mcp/worktree-summary.ts
3439
- import simpleGit5 from "simple-git";
3783
+ import simpleGit6 from "simple-git";
3440
3784
  function deriveLabel(status, isCurrent) {
3441
3785
  if (isCurrent) return "current";
3442
3786
  if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
@@ -3457,7 +3801,7 @@ function deriveSafeToRemove(status) {
3457
3801
  }
3458
3802
  async function getDivergence(worktreePath) {
3459
3803
  try {
3460
- const git = simpleGit5(worktreePath);
3804
+ const git = simpleGit6(worktreePath);
3461
3805
  const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
3462
3806
  const [aheadStr, behindStr] = output.trim().split(/\s+/);
3463
3807
  return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };