sync-worktrees 3.1.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.
- package/README.md +39 -0
- package/dist/index.js +589 -211
- package/dist/index.js.map +4 -4
- package/dist/mcp-server.js +576 -179
- package/dist/mcp-server.js.map +4 -4
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
134
|
-
const 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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
619
|
+
if (path2.isAbsolute(inputPath)) {
|
|
419
620
|
return inputPath;
|
|
420
621
|
}
|
|
421
|
-
return
|
|
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 =
|
|
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,34 +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
|
|
804
|
-
import
|
|
805
|
-
|
|
806
|
-
// src/utils/git-url.ts
|
|
807
|
-
function extractRepoNameFromUrl(gitUrl) {
|
|
808
|
-
const url = gitUrl.trim();
|
|
809
|
-
const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
810
|
-
if (sshMatch) {
|
|
811
|
-
return sshMatch[1];
|
|
812
|
-
}
|
|
813
|
-
const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
814
|
-
if (sshUrlMatch) {
|
|
815
|
-
return sshUrlMatch[1];
|
|
816
|
-
}
|
|
817
|
-
const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
818
|
-
if (httpsMatch) {
|
|
819
|
-
return httpsMatch[1];
|
|
820
|
-
}
|
|
821
|
-
const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
822
|
-
if (fileMatch) {
|
|
823
|
-
return fileMatch[1];
|
|
824
|
-
}
|
|
825
|
-
throw new Error(`Invalid Git URL format: ${gitUrl}`);
|
|
826
|
-
}
|
|
827
|
-
function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
828
|
-
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
829
|
-
return `${baseDir}/${repoName}`;
|
|
830
|
-
}
|
|
1005
|
+
import * as path5 from "path";
|
|
1006
|
+
import simpleGit4 from "simple-git";
|
|
831
1007
|
|
|
832
1008
|
// src/utils/worktree-list-parser.ts
|
|
833
1009
|
function parseWorktreeListPorcelain(output) {
|
|
@@ -870,10 +1046,101 @@ function parseWorktreeListPorcelain(output) {
|
|
|
870
1046
|
return worktrees;
|
|
871
1047
|
}
|
|
872
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
|
+
|
|
873
1140
|
// src/services/worktree-metadata.service.ts
|
|
874
1141
|
import * as fs2 from "fs/promises";
|
|
875
|
-
import * as
|
|
876
|
-
import
|
|
1142
|
+
import * as path3 from "path";
|
|
1143
|
+
import simpleGit2 from "simple-git";
|
|
877
1144
|
var WorktreeMetadataService = class {
|
|
878
1145
|
logger;
|
|
879
1146
|
constructor(logger) {
|
|
@@ -885,7 +1152,7 @@ var WorktreeMetadataService = class {
|
|
|
885
1152
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
886
1153
|
*/
|
|
887
1154
|
getWorktreeDirectoryName(worktreePath) {
|
|
888
|
-
return
|
|
1155
|
+
return path3.basename(worktreePath);
|
|
889
1156
|
}
|
|
890
1157
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
891
1158
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -893,7 +1160,7 @@ var WorktreeMetadataService = class {
|
|
|
893
1160
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
894
1161
|
);
|
|
895
1162
|
}
|
|
896
|
-
return
|
|
1163
|
+
return path3.join(
|
|
897
1164
|
bareRepoPath,
|
|
898
1165
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
899
1166
|
worktreeName,
|
|
@@ -906,7 +1173,7 @@ var WorktreeMetadataService = class {
|
|
|
906
1173
|
}
|
|
907
1174
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
908
1175
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
909
|
-
await fs2.mkdir(
|
|
1176
|
+
await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
|
|
910
1177
|
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
911
1178
|
let renamed = false;
|
|
912
1179
|
try {
|
|
@@ -997,7 +1264,7 @@ var WorktreeMetadataService = class {
|
|
|
997
1264
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
998
1265
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
999
1266
|
try {
|
|
1000
|
-
const worktreeGit =
|
|
1267
|
+
const worktreeGit = simpleGit2(worktreePath);
|
|
1001
1268
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
1002
1269
|
const branchSummary = await worktreeGit.branch();
|
|
1003
1270
|
const actualBranchName = branchSummary.current;
|
|
@@ -1099,47 +1366,8 @@ var WorktreeMetadataService = class {
|
|
|
1099
1366
|
|
|
1100
1367
|
// src/services/worktree-status.service.ts
|
|
1101
1368
|
import * as fs3 from "fs/promises";
|
|
1102
|
-
import * as
|
|
1103
|
-
import
|
|
1104
|
-
|
|
1105
|
-
// src/errors/index.ts
|
|
1106
|
-
var SyncWorktreesError = class extends Error {
|
|
1107
|
-
constructor(message, code, cause) {
|
|
1108
|
-
super(message);
|
|
1109
|
-
this.code = code;
|
|
1110
|
-
this.cause = cause;
|
|
1111
|
-
this.name = this.constructor.name;
|
|
1112
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
1113
|
-
if (cause && cause.stack) {
|
|
1114
|
-
this.stack = `${this.stack}
|
|
1115
|
-
Caused by: ${cause.stack}`;
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
};
|
|
1119
|
-
var GitError = class extends SyncWorktreesError {
|
|
1120
|
-
constructor(message, code, cause) {
|
|
1121
|
-
super(message, `GIT_${code}`, cause);
|
|
1122
|
-
}
|
|
1123
|
-
};
|
|
1124
|
-
var GitOperationError = class extends GitError {
|
|
1125
|
-
constructor(operation, details, cause) {
|
|
1126
|
-
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
1127
|
-
}
|
|
1128
|
-
};
|
|
1129
|
-
var WorktreeError = class extends SyncWorktreesError {
|
|
1130
|
-
constructor(message, code, cause) {
|
|
1131
|
-
super(message, `WORKTREE_${code}`, cause);
|
|
1132
|
-
}
|
|
1133
|
-
};
|
|
1134
|
-
var WorktreeNotCleanError = class extends WorktreeError {
|
|
1135
|
-
constructor(path10, reasons) {
|
|
1136
|
-
super(`Worktree at '${path10}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
1137
|
-
this.path = path10;
|
|
1138
|
-
this.reasons = reasons;
|
|
1139
|
-
}
|
|
1140
|
-
};
|
|
1141
|
-
|
|
1142
|
-
// src/services/worktree-status.service.ts
|
|
1369
|
+
import * as path4 from "path";
|
|
1370
|
+
import simpleGit3 from "simple-git";
|
|
1143
1371
|
var OPERATION_FILES = [
|
|
1144
1372
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
1145
1373
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -1341,7 +1569,7 @@ var WorktreeStatusService = class {
|
|
|
1341
1569
|
async detectOperationFile(gitDir) {
|
|
1342
1570
|
const results = await Promise.all(
|
|
1343
1571
|
OPERATION_FILES.map(
|
|
1344
|
-
({ file }) => fs3.access(
|
|
1572
|
+
({ file }) => fs3.access(path4.join(gitDir, file)).then(
|
|
1345
1573
|
() => true,
|
|
1346
1574
|
() => false
|
|
1347
1575
|
)
|
|
@@ -1462,14 +1690,14 @@ var WorktreeStatusService = class {
|
|
|
1462
1690
|
}
|
|
1463
1691
|
}
|
|
1464
1692
|
async resolveGitDir(worktreePath) {
|
|
1465
|
-
const gitPath =
|
|
1693
|
+
const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
1466
1694
|
try {
|
|
1467
1695
|
const stat4 = await fs3.stat(gitPath);
|
|
1468
1696
|
if (stat4.isFile()) {
|
|
1469
1697
|
const content = await fs3.readFile(gitPath, "utf-8");
|
|
1470
1698
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
1471
1699
|
if (gitdirMatch) {
|
|
1472
|
-
return
|
|
1700
|
+
return path4.resolve(worktreePath, gitdirMatch[1].trim());
|
|
1473
1701
|
}
|
|
1474
1702
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
1475
1703
|
}
|
|
@@ -1483,10 +1711,10 @@ var WorktreeStatusService = class {
|
|
|
1483
1711
|
}
|
|
1484
1712
|
}
|
|
1485
1713
|
createGitInstance(worktreePath) {
|
|
1486
|
-
const key = `${
|
|
1714
|
+
const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
1487
1715
|
let git = this.gitInstances.get(key);
|
|
1488
1716
|
if (!git) {
|
|
1489
|
-
git = this.config.skipLfs ?
|
|
1717
|
+
git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
|
|
1490
1718
|
this.gitInstances.set(key, git);
|
|
1491
1719
|
}
|
|
1492
1720
|
return git;
|
|
@@ -1499,9 +1727,10 @@ var GitService = class {
|
|
|
1499
1727
|
this.config = config;
|
|
1500
1728
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
1501
1729
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
1502
|
-
this.mainWorktreePath =
|
|
1730
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
1503
1731
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
1504
1732
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
1733
|
+
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
1505
1734
|
}
|
|
1506
1735
|
git = null;
|
|
1507
1736
|
bareRepoPath;
|
|
@@ -1510,29 +1739,34 @@ var GitService = class {
|
|
|
1510
1739
|
// Will be updated after detection
|
|
1511
1740
|
metadataService;
|
|
1512
1741
|
statusService;
|
|
1742
|
+
sparseCheckoutService;
|
|
1513
1743
|
logger;
|
|
1514
1744
|
lfsSkipOverride = false;
|
|
1515
1745
|
gitInstances = /* @__PURE__ */ new Map();
|
|
1746
|
+
getSparseCheckoutService() {
|
|
1747
|
+
return this.sparseCheckoutService;
|
|
1748
|
+
}
|
|
1516
1749
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
1517
|
-
const key = `${
|
|
1750
|
+
const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
1518
1751
|
let git = this.gitInstances.get(key);
|
|
1519
1752
|
if (!git) {
|
|
1520
|
-
git = useLfsSkip ?
|
|
1753
|
+
git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
|
|
1521
1754
|
this.gitInstances.set(key, git);
|
|
1522
1755
|
}
|
|
1523
1756
|
return git;
|
|
1524
1757
|
}
|
|
1525
1758
|
updateLogger(logger) {
|
|
1526
1759
|
this.logger = logger;
|
|
1760
|
+
this.sparseCheckoutService.updateLogger(logger);
|
|
1527
1761
|
}
|
|
1528
1762
|
async initialize() {
|
|
1529
1763
|
const { repoUrl } = this.config;
|
|
1530
1764
|
try {
|
|
1531
|
-
await fs4.access(
|
|
1765
|
+
await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
|
|
1532
1766
|
} catch {
|
|
1533
1767
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
1534
|
-
await fs4.mkdir(
|
|
1535
|
-
const cloneGit = this.isLfsSkipEnabled() ?
|
|
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();
|
|
1536
1770
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
1537
1771
|
this.logger.info("\u2705 Clone successful.");
|
|
1538
1772
|
}
|
|
@@ -1549,34 +1783,39 @@ var GitService = class {
|
|
|
1549
1783
|
this.logger.info("Fetching remote branches...");
|
|
1550
1784
|
await bareGit.fetch(["--all"]);
|
|
1551
1785
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
1552
|
-
this.mainWorktreePath =
|
|
1786
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
|
|
1553
1787
|
let needsMainWorktree = true;
|
|
1554
1788
|
try {
|
|
1555
1789
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1556
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
1790
|
+
needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
|
|
1557
1791
|
} catch {
|
|
1558
1792
|
}
|
|
1559
1793
|
if (needsMainWorktree) {
|
|
1560
1794
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
1561
1795
|
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
1562
|
-
const absoluteWorktreePath =
|
|
1796
|
+
const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
|
|
1563
1797
|
const branches = await bareGit.branch();
|
|
1564
1798
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
1799
|
+
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
1800
|
+
const noCheckoutFlagMain = useNoCheckoutMain ? ["--no-checkout"] : [];
|
|
1565
1801
|
try {
|
|
1566
1802
|
if (defaultBranchExists) {
|
|
1567
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
1803
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlagMain, absoluteWorktreePath, this.defaultBranch]);
|
|
1568
1804
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
1569
1805
|
await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
|
|
1806
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, false);
|
|
1570
1807
|
} else {
|
|
1571
1808
|
await bareGit.raw([
|
|
1572
1809
|
"worktree",
|
|
1573
1810
|
"add",
|
|
1811
|
+
...noCheckoutFlagMain,
|
|
1574
1812
|
"--track",
|
|
1575
1813
|
"-b",
|
|
1576
1814
|
this.defaultBranch,
|
|
1577
1815
|
absoluteWorktreePath,
|
|
1578
1816
|
`origin/${this.defaultBranch}`
|
|
1579
1817
|
]);
|
|
1818
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, true);
|
|
1580
1819
|
}
|
|
1581
1820
|
} catch (error) {
|
|
1582
1821
|
const errorMessage = getErrorMessage(error);
|
|
@@ -1590,7 +1829,7 @@ var GitService = class {
|
|
|
1590
1829
|
}
|
|
1591
1830
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
1592
1831
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
1593
|
-
(w) =>
|
|
1832
|
+
(w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
|
|
1594
1833
|
);
|
|
1595
1834
|
if (!mainWorktreeRegistered) {
|
|
1596
1835
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -1659,13 +1898,29 @@ var GitService = class {
|
|
|
1659
1898
|
return branches;
|
|
1660
1899
|
}
|
|
1661
1900
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
1662
|
-
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);
|
|
1663
1902
|
try {
|
|
1664
1903
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
1665
|
-
|
|
1904
|
+
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
1666
1905
|
if (lfsFileList.length === 0) {
|
|
1667
1906
|
return;
|
|
1668
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
|
+
}
|
|
1669
1924
|
if (this.config.debug) {
|
|
1670
1925
|
this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
|
|
1671
1926
|
}
|
|
@@ -1682,7 +1937,7 @@ var GitService = class {
|
|
|
1682
1937
|
let allDownloaded = true;
|
|
1683
1938
|
const notDownloaded = [];
|
|
1684
1939
|
for (const file of samplesToCheck) {
|
|
1685
|
-
const filePath =
|
|
1940
|
+
const filePath = path5.join(worktreePath, file);
|
|
1686
1941
|
try {
|
|
1687
1942
|
const handle = await fs4.open(filePath, "r");
|
|
1688
1943
|
try {
|
|
@@ -1719,6 +1974,38 @@ var GitService = class {
|
|
|
1719
1974
|
this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
|
|
1720
1975
|
}
|
|
1721
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
|
+
}
|
|
1722
2009
|
async createWorktreeMetadata(bareGit, worktreePath, branchName) {
|
|
1723
2010
|
try {
|
|
1724
2011
|
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
@@ -1739,12 +2026,12 @@ var GitService = class {
|
|
|
1739
2026
|
}
|
|
1740
2027
|
async addWorktree(branchName, worktreePath) {
|
|
1741
2028
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
1742
|
-
const absoluteWorktreePath =
|
|
1743
|
-
await fs4.mkdir(
|
|
2029
|
+
const absoluteWorktreePath = path5.resolve(worktreePath);
|
|
2030
|
+
await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
|
|
1744
2031
|
try {
|
|
1745
2032
|
await fs4.access(absoluteWorktreePath);
|
|
1746
2033
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1747
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
2034
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1748
2035
|
if (isValidWorktree) {
|
|
1749
2036
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1750
2037
|
return;
|
|
@@ -1754,25 +2041,21 @@ var GitService = class {
|
|
|
1754
2041
|
}
|
|
1755
2042
|
} catch {
|
|
1756
2043
|
}
|
|
2044
|
+
let createdNewBranch = false;
|
|
1757
2045
|
try {
|
|
1758
|
-
const
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
2046
|
+
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
2047
|
+
createdNewBranch = await this.runWorktreeAddByMatrix(
|
|
2048
|
+
bareGit,
|
|
2049
|
+
branchName,
|
|
2050
|
+
absoluteWorktreePath,
|
|
2051
|
+
localBranchExists,
|
|
2052
|
+
remoteBranchExists
|
|
2053
|
+
);
|
|
2054
|
+
if (localBranchExists && !remoteBranchExists) {
|
|
2055
|
+
this.logger.info(` - Created worktree for '${branchName}' (no remote yet \u2014 push to set upstream)`);
|
|
1764
2056
|
} else {
|
|
1765
|
-
|
|
1766
|
-
"worktree",
|
|
1767
|
-
"add",
|
|
1768
|
-
"--track",
|
|
1769
|
-
"-b",
|
|
1770
|
-
branchName,
|
|
1771
|
-
absoluteWorktreePath,
|
|
1772
|
-
`origin/${branchName}`
|
|
1773
|
-
]);
|
|
2057
|
+
this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
|
|
1774
2058
|
}
|
|
1775
|
-
this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
|
|
1776
2059
|
if (!this.isLfsSkipEnabled()) {
|
|
1777
2060
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
1778
2061
|
}
|
|
@@ -1780,20 +2063,20 @@ var GitService = class {
|
|
|
1780
2063
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1781
2064
|
} catch (metadataError) {
|
|
1782
2065
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1783
|
-
|
|
1784
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1785
|
-
} catch {
|
|
1786
|
-
}
|
|
2066
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
1787
2067
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1788
2068
|
}
|
|
1789
2069
|
} catch (error) {
|
|
1790
2070
|
const errorMessage = getErrorMessage(error);
|
|
2071
|
+
if (error?.isUpstreamSetupFailure) {
|
|
2072
|
+
throw error;
|
|
2073
|
+
}
|
|
1791
2074
|
if (errorMessage.includes("Metadata creation failed")) {
|
|
1792
2075
|
throw error;
|
|
1793
2076
|
}
|
|
1794
2077
|
if (errorMessage.includes("already registered worktree")) {
|
|
1795
2078
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1796
|
-
const existingWorktree = worktrees.find((w) =>
|
|
2079
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1797
2080
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1798
2081
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
1799
2082
|
return;
|
|
@@ -1804,16 +2087,16 @@ var GitService = class {
|
|
|
1804
2087
|
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1805
2088
|
} catch {
|
|
1806
2089
|
}
|
|
2090
|
+
let retryCreatedNewBranch = false;
|
|
1807
2091
|
try {
|
|
1808
|
-
await
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
"--track",
|
|
1812
|
-
"-b",
|
|
2092
|
+
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
2093
|
+
retryCreatedNewBranch = await this.runWorktreeAddByMatrix(
|
|
2094
|
+
bareGit,
|
|
1813
2095
|
branchName,
|
|
1814
2096
|
absoluteWorktreePath,
|
|
1815
|
-
|
|
1816
|
-
|
|
2097
|
+
localBranchExists,
|
|
2098
|
+
remoteBranchExists
|
|
2099
|
+
);
|
|
1817
2100
|
this.logger.info(` - Created worktree for '${branchName}' after pruning`);
|
|
1818
2101
|
if (!this.isLfsSkipEnabled()) {
|
|
1819
2102
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
@@ -1822,10 +2105,7 @@ var GitService = class {
|
|
|
1822
2105
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1823
2106
|
} catch (metadataError) {
|
|
1824
2107
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1825
|
-
|
|
1826
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1827
|
-
} catch {
|
|
1828
|
-
}
|
|
2108
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, retryCreatedNewBranch);
|
|
1829
2109
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1830
2110
|
}
|
|
1831
2111
|
return;
|
|
@@ -1842,7 +2122,7 @@ var GitService = class {
|
|
|
1842
2122
|
try {
|
|
1843
2123
|
await fs4.access(absoluteWorktreePath);
|
|
1844
2124
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1845
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
2125
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1846
2126
|
if (isValidWorktree) {
|
|
1847
2127
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1848
2128
|
return;
|
|
@@ -1853,7 +2133,10 @@ var GitService = class {
|
|
|
1853
2133
|
} catch {
|
|
1854
2134
|
}
|
|
1855
2135
|
try {
|
|
1856
|
-
|
|
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);
|
|
1857
2140
|
this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
|
|
1858
2141
|
if (!this.isLfsSkipEnabled()) {
|
|
1859
2142
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
@@ -1862,17 +2145,14 @@ var GitService = class {
|
|
|
1862
2145
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1863
2146
|
} catch (metadataError) {
|
|
1864
2147
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1865
|
-
|
|
1866
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1867
|
-
} catch {
|
|
1868
|
-
}
|
|
2148
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, false);
|
|
1869
2149
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1870
2150
|
}
|
|
1871
2151
|
} catch (fallbackError) {
|
|
1872
2152
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
1873
2153
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
1874
2154
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1875
|
-
const existingWorktree = worktrees.find((w) =>
|
|
2155
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1876
2156
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1877
2157
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
1878
2158
|
return;
|
|
@@ -1882,6 +2162,65 @@ var GitService = class {
|
|
|
1882
2162
|
}
|
|
1883
2163
|
}
|
|
1884
2164
|
}
|
|
2165
|
+
async runWorktreeAddByMatrix(bareGit, branchName, absoluteWorktreePath, localExists, remoteExists) {
|
|
2166
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
2167
|
+
const noCheckoutFlag = useNoCheckout ? ["--no-checkout"] : [];
|
|
2168
|
+
if (localExists && remoteExists) {
|
|
2169
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
2170
|
+
try {
|
|
2171
|
+
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
2172
|
+
await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
throw await this.wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, false, error);
|
|
2175
|
+
}
|
|
2176
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
2177
|
+
return false;
|
|
2178
|
+
}
|
|
2179
|
+
if (localExists) {
|
|
2180
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
2181
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
2182
|
+
return false;
|
|
2183
|
+
}
|
|
2184
|
+
if (remoteExists) {
|
|
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;
|
|
2197
|
+
}
|
|
2198
|
+
throw new WorktreeError(
|
|
2199
|
+
`Branch '${branchName}' does not exist locally or on origin; create it first`,
|
|
2200
|
+
"BRANCH_NOT_FOUND"
|
|
2201
|
+
);
|
|
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
|
+
}
|
|
1885
2224
|
async removeWorktree(worktreePath) {
|
|
1886
2225
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
1887
2226
|
await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
@@ -2075,10 +2414,18 @@ var GitService = class {
|
|
|
2075
2414
|
}
|
|
2076
2415
|
async branchExists(branchName) {
|
|
2077
2416
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
2078
|
-
const
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2417
|
+
const checkRef = async (ref) => {
|
|
2418
|
+
try {
|
|
2419
|
+
await bareGit.raw(["show-ref", "--verify", "--quiet", ref]);
|
|
2420
|
+
return true;
|
|
2421
|
+
} catch {
|
|
2422
|
+
return false;
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
const [local, remote] = await Promise.all([
|
|
2426
|
+
checkRef(`${GIT_CONSTANTS.REFS.HEADS}${branchName}`),
|
|
2427
|
+
checkRef(`${GIT_CONSTANTS.REFS.REMOTES}/${branchName}`)
|
|
2428
|
+
]);
|
|
2082
2429
|
return { local, remote };
|
|
2083
2430
|
}
|
|
2084
2431
|
async getLocalBranches() {
|
|
@@ -2113,22 +2460,6 @@ var GitService = class {
|
|
|
2113
2460
|
import { createHash } from "crypto";
|
|
2114
2461
|
import * as fs5 from "fs";
|
|
2115
2462
|
import * as path6 from "path";
|
|
2116
|
-
|
|
2117
|
-
// src/utils/path-compare.ts
|
|
2118
|
-
import * as path5 from "path";
|
|
2119
|
-
var CASE_INSENSITIVE_PLATFORMS = /* @__PURE__ */ new Set(["darwin"]);
|
|
2120
|
-
function isCaseInsensitiveFs(platform = process.platform) {
|
|
2121
|
-
return CASE_INSENSITIVE_PLATFORMS.has(platform);
|
|
2122
|
-
}
|
|
2123
|
-
function normalizePathForCompare(p, platform = process.platform) {
|
|
2124
|
-
const resolved = path5.resolve(p);
|
|
2125
|
-
return isCaseInsensitiveFs(platform) ? resolved.toLowerCase() : resolved;
|
|
2126
|
-
}
|
|
2127
|
-
function pathsEqual(a, b, platform = process.platform) {
|
|
2128
|
-
return normalizePathForCompare(a, platform) === normalizePathForCompare(b, platform);
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
// src/services/path-resolution.service.ts
|
|
2132
2463
|
var BRANCH_STEM_MAX = 80;
|
|
2133
2464
|
var BRANCH_HASH_LEN = 8;
|
|
2134
2465
|
var PathResolutionService = class {
|
|
@@ -2297,8 +2628,50 @@ var WorktreeSyncService = class {
|
|
|
2297
2628
|
if (this.config.updateExistingWorktrees !== false) {
|
|
2298
2629
|
await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
|
|
2299
2630
|
}
|
|
2631
|
+
if (this.config.sparseCheckout) {
|
|
2632
|
+
await this.reapplySparseCheckout(worktrees);
|
|
2633
|
+
}
|
|
2300
2634
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
2301
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
|
+
}
|
|
2302
2675
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
2303
2676
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
2304
2677
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
@@ -2875,16 +3248,27 @@ var RepositoryContext = class {
|
|
|
2875
3248
|
const setDefaultCurrent = options.setDefaultCurrent ?? true;
|
|
2876
3249
|
const absolutePath = path8.resolve(configPath);
|
|
2877
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);
|
|
2878
3265
|
for (const [name, entry] of this.repos) {
|
|
2879
3266
|
if (entry.source === "config") {
|
|
2880
3267
|
this.repos.delete(name);
|
|
2881
3268
|
}
|
|
2882
3269
|
}
|
|
2883
3270
|
this.configPath = absolutePath;
|
|
2884
|
-
const
|
|
2885
|
-
const globalDefaults = configFile.defaults;
|
|
2886
|
-
for (const repo of configFile.repositories) {
|
|
2887
|
-
const resolved = this.configLoader.resolveRepositoryConfig(repo, globalDefaults, configDir, configFile.retry);
|
|
3271
|
+
for (const resolved of resolvedAll) {
|
|
2888
3272
|
this.repos.set(resolved.name, {
|
|
2889
3273
|
name: resolved.name,
|
|
2890
3274
|
config: resolved,
|
|
@@ -3054,7 +3438,7 @@ var RepositoryContext = class {
|
|
|
3054
3438
|
let worktrees = [];
|
|
3055
3439
|
let currentBranch = null;
|
|
3056
3440
|
try {
|
|
3057
|
-
const bareGit =
|
|
3441
|
+
const bareGit = simpleGit5(bareRepoPath);
|
|
3058
3442
|
try {
|
|
3059
3443
|
const remoteResult = await bareGit.remote(["get-url", "origin"]);
|
|
3060
3444
|
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
@@ -3396,7 +3780,7 @@ function wrapHandler(fn) {
|
|
|
3396
3780
|
}
|
|
3397
3781
|
|
|
3398
3782
|
// src/mcp/worktree-summary.ts
|
|
3399
|
-
import
|
|
3783
|
+
import simpleGit6 from "simple-git";
|
|
3400
3784
|
function deriveLabel(status, isCurrent) {
|
|
3401
3785
|
if (isCurrent) return "current";
|
|
3402
3786
|
if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
|
|
@@ -3417,7 +3801,7 @@ function deriveSafeToRemove(status) {
|
|
|
3417
3801
|
}
|
|
3418
3802
|
async function getDivergence(worktreePath) {
|
|
3419
3803
|
try {
|
|
3420
|
-
const git =
|
|
3804
|
+
const git = simpleGit6(worktreePath);
|
|
3421
3805
|
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
3422
3806
|
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
3423
3807
|
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|
|
@@ -3730,14 +4114,26 @@ function attachProgressReporter(service, extra) {
|
|
|
3730
4114
|
var REPO_NAME_DESCRIBE = "Repository name from loaded config. If omitted, uses the current repository set via set_current_repository or the only loaded repo.";
|
|
3731
4115
|
var PATH_DESCRIBE_SUFFIX = "Absolute path preferred; relative paths resolve from the server's CWD.";
|
|
3732
4116
|
var SERVER_INSTRUCTIONS = "Before running git worktree operations, call `detect_context` to learn the current repo, current branch, sibling repositories under the workspace root, and which capabilities are available. It walks up to auto-discover sync-worktrees.config.{js,mjs,cjs,ts}, lists sibling worktrees, and reports per-capability {available, reason} so you can tell which tool is gated and why.";
|
|
3733
|
-
function
|
|
4117
|
+
function buildInstructions(snapshot) {
|
|
4118
|
+
const d = snapshot?.discovered;
|
|
4119
|
+
if (!d || !d.isWorktree || d.kind !== "managed") return SERVER_INSTRUCTIONS;
|
|
4120
|
+
const lines = ["Connect-time context (call `detect_context` for live state):"];
|
|
4121
|
+
if (d.kind) lines.push(`- kind: ${d.kind}`);
|
|
4122
|
+
if (d.currentWorktreePath) lines.push(`- currentWorktreePath: ${d.currentWorktreePath}`);
|
|
4123
|
+
if (d.currentBranch) lines.push(`- currentBranch: ${d.currentBranch}`);
|
|
4124
|
+
if (d.configPath) lines.push(`- configPath: ${d.configPath}`);
|
|
4125
|
+
return `${SERVER_INSTRUCTIONS}
|
|
4126
|
+
|
|
4127
|
+
${lines.join("\n")}`;
|
|
4128
|
+
}
|
|
4129
|
+
function createServer(context, snapshot) {
|
|
3734
4130
|
const server = new McpServer(
|
|
3735
4131
|
{
|
|
3736
4132
|
name: "sync-worktrees",
|
|
3737
4133
|
version: "1.0.0"
|
|
3738
4134
|
},
|
|
3739
4135
|
{
|
|
3740
|
-
instructions:
|
|
4136
|
+
instructions: buildInstructions(snapshot)
|
|
3741
4137
|
}
|
|
3742
4138
|
);
|
|
3743
4139
|
server.registerResource(
|
|
@@ -3970,8 +4366,9 @@ async function main() {
|
|
|
3970
4366
|
`);
|
|
3971
4367
|
}
|
|
3972
4368
|
}
|
|
4369
|
+
let discovered = null;
|
|
3973
4370
|
try {
|
|
3974
|
-
|
|
4371
|
+
discovered = await context.detectFromPath(process.cwd());
|
|
3975
4372
|
if (discovered.isWorktree) {
|
|
3976
4373
|
process.stderr.write(
|
|
3977
4374
|
`[sync-worktrees-mcp] Auto-detected ${discovered.kind} worktree at ${discovered.currentWorktreePath} (branch: ${discovered.currentBranch})
|
|
@@ -3982,7 +4379,7 @@ async function main() {
|
|
|
3982
4379
|
process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
|
|
3983
4380
|
`);
|
|
3984
4381
|
}
|
|
3985
|
-
const server = createServer(context);
|
|
4382
|
+
const server = createServer(context, { discovered });
|
|
3986
4383
|
const transport = new StdioServerTransport();
|
|
3987
4384
|
await server.connect(transport);
|
|
3988
4385
|
}
|