sync-worktrees 3.2.0 → 3.3.1
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 +549 -204
- package/dist/index.js.map +4 -4
- package/dist/mcp-server.js +519 -168
- 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,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
|
|
804
|
-
import
|
|
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
|
|
913
|
-
import
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
1140
|
-
import
|
|
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(
|
|
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 =
|
|
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
|
|
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 = `${
|
|
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 ?
|
|
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;
|
|
@@ -1492,14 +1722,22 @@ var WorktreeStatusService = class {
|
|
|
1492
1722
|
};
|
|
1493
1723
|
|
|
1494
1724
|
// src/services/git.service.ts
|
|
1725
|
+
function sanitizeGitEnv(env) {
|
|
1726
|
+
const sanitized = { ...env };
|
|
1727
|
+
delete sanitized.EDITOR;
|
|
1728
|
+
delete sanitized.GIT_EDITOR;
|
|
1729
|
+
delete sanitized.GIT_SEQUENCE_EDITOR;
|
|
1730
|
+
return sanitized;
|
|
1731
|
+
}
|
|
1495
1732
|
var GitService = class {
|
|
1496
1733
|
constructor(config, logger) {
|
|
1497
1734
|
this.config = config;
|
|
1498
1735
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
1499
1736
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
1500
|
-
this.mainWorktreePath =
|
|
1737
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
1501
1738
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
1502
1739
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
1740
|
+
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
1503
1741
|
}
|
|
1504
1742
|
git = null;
|
|
1505
1743
|
bareRepoPath;
|
|
@@ -1508,29 +1746,34 @@ var GitService = class {
|
|
|
1508
1746
|
// Will be updated after detection
|
|
1509
1747
|
metadataService;
|
|
1510
1748
|
statusService;
|
|
1749
|
+
sparseCheckoutService;
|
|
1511
1750
|
logger;
|
|
1512
1751
|
lfsSkipOverride = false;
|
|
1513
1752
|
gitInstances = /* @__PURE__ */ new Map();
|
|
1753
|
+
getSparseCheckoutService() {
|
|
1754
|
+
return this.sparseCheckoutService;
|
|
1755
|
+
}
|
|
1514
1756
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
1515
|
-
const key = `${
|
|
1757
|
+
const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
1516
1758
|
let git = this.gitInstances.get(key);
|
|
1517
1759
|
if (!git) {
|
|
1518
|
-
git = useLfsSkip ?
|
|
1760
|
+
git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
|
|
1519
1761
|
this.gitInstances.set(key, git);
|
|
1520
1762
|
}
|
|
1521
1763
|
return git;
|
|
1522
1764
|
}
|
|
1523
1765
|
updateLogger(logger) {
|
|
1524
1766
|
this.logger = logger;
|
|
1767
|
+
this.sparseCheckoutService.updateLogger(logger);
|
|
1525
1768
|
}
|
|
1526
1769
|
async initialize() {
|
|
1527
1770
|
const { repoUrl } = this.config;
|
|
1528
1771
|
try {
|
|
1529
|
-
await fs4.access(
|
|
1772
|
+
await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
|
|
1530
1773
|
} catch {
|
|
1531
1774
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
1532
|
-
await fs4.mkdir(
|
|
1533
|
-
const cloneGit = this.isLfsSkipEnabled() ?
|
|
1775
|
+
await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
|
|
1776
|
+
const cloneGit = this.isLfsSkipEnabled() ? simpleGit4().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4();
|
|
1534
1777
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
1535
1778
|
this.logger.info("\u2705 Clone successful.");
|
|
1536
1779
|
}
|
|
@@ -1547,34 +1790,39 @@ var GitService = class {
|
|
|
1547
1790
|
this.logger.info("Fetching remote branches...");
|
|
1548
1791
|
await bareGit.fetch(["--all"]);
|
|
1549
1792
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
1550
|
-
this.mainWorktreePath =
|
|
1793
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
|
|
1551
1794
|
let needsMainWorktree = true;
|
|
1552
1795
|
try {
|
|
1553
1796
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1554
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
1797
|
+
needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
|
|
1555
1798
|
} catch {
|
|
1556
1799
|
}
|
|
1557
1800
|
if (needsMainWorktree) {
|
|
1558
1801
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
1559
1802
|
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
1560
|
-
const absoluteWorktreePath =
|
|
1803
|
+
const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
|
|
1561
1804
|
const branches = await bareGit.branch();
|
|
1562
1805
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
1806
|
+
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
1807
|
+
const noCheckoutFlagMain = useNoCheckoutMain ? ["--no-checkout"] : [];
|
|
1563
1808
|
try {
|
|
1564
1809
|
if (defaultBranchExists) {
|
|
1565
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
1810
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlagMain, absoluteWorktreePath, this.defaultBranch]);
|
|
1566
1811
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
1567
1812
|
await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
|
|
1813
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, false);
|
|
1568
1814
|
} else {
|
|
1569
1815
|
await bareGit.raw([
|
|
1570
1816
|
"worktree",
|
|
1571
1817
|
"add",
|
|
1818
|
+
...noCheckoutFlagMain,
|
|
1572
1819
|
"--track",
|
|
1573
1820
|
"-b",
|
|
1574
1821
|
this.defaultBranch,
|
|
1575
1822
|
absoluteWorktreePath,
|
|
1576
1823
|
`origin/${this.defaultBranch}`
|
|
1577
1824
|
]);
|
|
1825
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, true);
|
|
1578
1826
|
}
|
|
1579
1827
|
} catch (error) {
|
|
1580
1828
|
const errorMessage = getErrorMessage(error);
|
|
@@ -1588,7 +1836,7 @@ var GitService = class {
|
|
|
1588
1836
|
}
|
|
1589
1837
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
1590
1838
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
1591
|
-
(w) =>
|
|
1839
|
+
(w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
|
|
1592
1840
|
);
|
|
1593
1841
|
if (!mainWorktreeRegistered) {
|
|
1594
1842
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -1657,13 +1905,29 @@ var GitService = class {
|
|
|
1657
1905
|
return branches;
|
|
1658
1906
|
}
|
|
1659
1907
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
1660
|
-
const worktreeGit = this.getCachedGit(worktreePath);
|
|
1908
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit4(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
1661
1909
|
try {
|
|
1662
1910
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
1663
|
-
|
|
1911
|
+
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
1664
1912
|
if (lfsFileList.length === 0) {
|
|
1665
1913
|
return;
|
|
1666
1914
|
}
|
|
1915
|
+
if (this.config.sparseCheckout) {
|
|
1916
|
+
const existence = await Promise.all(
|
|
1917
|
+
lfsFileList.map(async (f) => {
|
|
1918
|
+
try {
|
|
1919
|
+
await fs4.access(path5.join(worktreePath, f));
|
|
1920
|
+
return f;
|
|
1921
|
+
} catch {
|
|
1922
|
+
return null;
|
|
1923
|
+
}
|
|
1924
|
+
})
|
|
1925
|
+
);
|
|
1926
|
+
lfsFileList = existence.filter((f) => f !== null);
|
|
1927
|
+
if (lfsFileList.length === 0) {
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1667
1931
|
if (this.config.debug) {
|
|
1668
1932
|
this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
|
|
1669
1933
|
}
|
|
@@ -1680,7 +1944,7 @@ var GitService = class {
|
|
|
1680
1944
|
let allDownloaded = true;
|
|
1681
1945
|
const notDownloaded = [];
|
|
1682
1946
|
for (const file of samplesToCheck) {
|
|
1683
|
-
const filePath =
|
|
1947
|
+
const filePath = path5.join(worktreePath, file);
|
|
1684
1948
|
try {
|
|
1685
1949
|
const handle = await fs4.open(filePath, "r");
|
|
1686
1950
|
try {
|
|
@@ -1717,6 +1981,38 @@ var GitService = class {
|
|
|
1717
1981
|
this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
|
|
1718
1982
|
}
|
|
1719
1983
|
}
|
|
1984
|
+
async checkoutHead(worktreePath) {
|
|
1985
|
+
const git = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
1986
|
+
await git.raw(["checkout", "HEAD"]);
|
|
1987
|
+
}
|
|
1988
|
+
async applySparseAndCheckout(absoluteWorktreePath) {
|
|
1989
|
+
if (!this.config.sparseCheckout) return;
|
|
1990
|
+
await this.sparseCheckoutService.applyToWorktree(absoluteWorktreePath, this.config.sparseCheckout);
|
|
1991
|
+
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
1992
|
+
await worktreeGit.raw(["checkout", "HEAD"]);
|
|
1993
|
+
}
|
|
1994
|
+
async rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch, failureContext) {
|
|
1995
|
+
let worktreeRemoved = true;
|
|
1996
|
+
try {
|
|
1997
|
+
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1998
|
+
} catch (rollbackError) {
|
|
1999
|
+
worktreeRemoved = false;
|
|
2000
|
+
const ctx = failureContext ? ` after ${failureContext}` : "";
|
|
2001
|
+
this.logger.warn(
|
|
2002
|
+
` - Rollback failed for '${branchName}' at '${absoluteWorktreePath}'${ctx}: ${getErrorMessage(rollbackError)}`
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
if (createdNewBranch) {
|
|
2006
|
+
try {
|
|
2007
|
+
await bareGit.raw(["branch", "-D", branchName]);
|
|
2008
|
+
} catch (branchRollbackError) {
|
|
2009
|
+
this.logger.warn(
|
|
2010
|
+
` - Rollback (branch delete) failed for '${branchName}': ${getErrorMessage(branchRollbackError)}`
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
return { worktreeRemoved };
|
|
2015
|
+
}
|
|
1720
2016
|
async createWorktreeMetadata(bareGit, worktreePath, branchName) {
|
|
1721
2017
|
try {
|
|
1722
2018
|
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
@@ -1737,12 +2033,12 @@ var GitService = class {
|
|
|
1737
2033
|
}
|
|
1738
2034
|
async addWorktree(branchName, worktreePath) {
|
|
1739
2035
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
1740
|
-
const absoluteWorktreePath =
|
|
1741
|
-
await fs4.mkdir(
|
|
2036
|
+
const absoluteWorktreePath = path5.resolve(worktreePath);
|
|
2037
|
+
await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
|
|
1742
2038
|
try {
|
|
1743
2039
|
await fs4.access(absoluteWorktreePath);
|
|
1744
2040
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1745
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
2041
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1746
2042
|
if (isValidWorktree) {
|
|
1747
2043
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1748
2044
|
return;
|
|
@@ -1752,9 +2048,10 @@ var GitService = class {
|
|
|
1752
2048
|
}
|
|
1753
2049
|
} catch {
|
|
1754
2050
|
}
|
|
2051
|
+
let createdNewBranch = false;
|
|
1755
2052
|
try {
|
|
1756
2053
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
1757
|
-
await this.runWorktreeAddByMatrix(
|
|
2054
|
+
createdNewBranch = await this.runWorktreeAddByMatrix(
|
|
1758
2055
|
bareGit,
|
|
1759
2056
|
branchName,
|
|
1760
2057
|
absoluteWorktreePath,
|
|
@@ -1773,10 +2070,7 @@ var GitService = class {
|
|
|
1773
2070
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1774
2071
|
} catch (metadataError) {
|
|
1775
2072
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1776
|
-
|
|
1777
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1778
|
-
} catch {
|
|
1779
|
-
}
|
|
2073
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
1780
2074
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1781
2075
|
}
|
|
1782
2076
|
} catch (error) {
|
|
@@ -1789,7 +2083,7 @@ var GitService = class {
|
|
|
1789
2083
|
}
|
|
1790
2084
|
if (errorMessage.includes("already registered worktree")) {
|
|
1791
2085
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1792
|
-
const existingWorktree = worktrees.find((w) =>
|
|
2086
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1793
2087
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1794
2088
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
1795
2089
|
return;
|
|
@@ -1800,9 +2094,10 @@ var GitService = class {
|
|
|
1800
2094
|
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1801
2095
|
} catch {
|
|
1802
2096
|
}
|
|
2097
|
+
let retryCreatedNewBranch = false;
|
|
1803
2098
|
try {
|
|
1804
2099
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
1805
|
-
await this.runWorktreeAddByMatrix(
|
|
2100
|
+
retryCreatedNewBranch = await this.runWorktreeAddByMatrix(
|
|
1806
2101
|
bareGit,
|
|
1807
2102
|
branchName,
|
|
1808
2103
|
absoluteWorktreePath,
|
|
@@ -1817,10 +2112,7 @@ var GitService = class {
|
|
|
1817
2112
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1818
2113
|
} catch (metadataError) {
|
|
1819
2114
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1820
|
-
|
|
1821
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1822
|
-
} catch {
|
|
1823
|
-
}
|
|
2115
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, retryCreatedNewBranch);
|
|
1824
2116
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1825
2117
|
}
|
|
1826
2118
|
return;
|
|
@@ -1837,7 +2129,7 @@ var GitService = class {
|
|
|
1837
2129
|
try {
|
|
1838
2130
|
await fs4.access(absoluteWorktreePath);
|
|
1839
2131
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1840
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
2132
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1841
2133
|
if (isValidWorktree) {
|
|
1842
2134
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1843
2135
|
return;
|
|
@@ -1848,7 +2140,10 @@ var GitService = class {
|
|
|
1848
2140
|
} catch {
|
|
1849
2141
|
}
|
|
1850
2142
|
try {
|
|
1851
|
-
|
|
2143
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
2144
|
+
const fallbackArgs = useNoCheckout ? ["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName] : ["worktree", "add", absoluteWorktreePath, branchName];
|
|
2145
|
+
await bareGit.raw(fallbackArgs);
|
|
2146
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
1852
2147
|
this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
|
|
1853
2148
|
if (!this.isLfsSkipEnabled()) {
|
|
1854
2149
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
@@ -1857,17 +2152,14 @@ var GitService = class {
|
|
|
1857
2152
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1858
2153
|
} catch (metadataError) {
|
|
1859
2154
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1860
|
-
|
|
1861
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1862
|
-
} catch {
|
|
1863
|
-
}
|
|
2155
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, false);
|
|
1864
2156
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1865
2157
|
}
|
|
1866
2158
|
} catch (fallbackError) {
|
|
1867
2159
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
1868
2160
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
1869
2161
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1870
|
-
const existingWorktree = worktrees.find((w) =>
|
|
2162
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
1871
2163
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1872
2164
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
1873
2165
|
return;
|
|
@@ -1878,42 +2170,64 @@ var GitService = class {
|
|
|
1878
2170
|
}
|
|
1879
2171
|
}
|
|
1880
2172
|
async runWorktreeAddByMatrix(bareGit, branchName, absoluteWorktreePath, localExists, remoteExists) {
|
|
2173
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
2174
|
+
const noCheckoutFlag = useNoCheckout ? ["--no-checkout"] : [];
|
|
1881
2175
|
if (localExists && remoteExists) {
|
|
1882
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
2176
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
1883
2177
|
try {
|
|
1884
2178
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
1885
2179
|
await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
|
|
1886
2180
|
} catch (error) {
|
|
1887
|
-
|
|
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;
|
|
2181
|
+
throw await this.wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, false, error);
|
|
1901
2182
|
}
|
|
1902
|
-
|
|
2183
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
2184
|
+
return false;
|
|
1903
2185
|
}
|
|
1904
2186
|
if (localExists) {
|
|
1905
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
1906
|
-
|
|
2187
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
2188
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
2189
|
+
return false;
|
|
1907
2190
|
}
|
|
1908
2191
|
if (remoteExists) {
|
|
1909
|
-
await bareGit.raw([
|
|
1910
|
-
|
|
2192
|
+
await bareGit.raw([
|
|
2193
|
+
"worktree",
|
|
2194
|
+
"add",
|
|
2195
|
+
...noCheckoutFlag,
|
|
2196
|
+
"--track",
|
|
2197
|
+
"-b",
|
|
2198
|
+
branchName,
|
|
2199
|
+
absoluteWorktreePath,
|
|
2200
|
+
`origin/${branchName}`
|
|
2201
|
+
]);
|
|
2202
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, true);
|
|
2203
|
+
return true;
|
|
1911
2204
|
}
|
|
1912
2205
|
throw new WorktreeError(
|
|
1913
2206
|
`Branch '${branchName}' does not exist locally or on origin; create it first`,
|
|
1914
2207
|
"BRANCH_NOT_FOUND"
|
|
1915
2208
|
);
|
|
1916
2209
|
}
|
|
2210
|
+
async runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, createdNewBranch) {
|
|
2211
|
+
try {
|
|
2212
|
+
await this.applySparseAndCheckout(absoluteWorktreePath);
|
|
2213
|
+
} catch (sparseError) {
|
|
2214
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
2215
|
+
throw new Error(`Sparse-checkout setup failed for '${branchName}': ${getErrorMessage(sparseError)}`);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
async wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, createdNewBranch, error) {
|
|
2219
|
+
const { worktreeRemoved } = await this.rollbackPartialWorktree(
|
|
2220
|
+
bareGit,
|
|
2221
|
+
absoluteWorktreePath,
|
|
2222
|
+
branchName,
|
|
2223
|
+
createdNewBranch,
|
|
2224
|
+
"upstream setup error"
|
|
2225
|
+
);
|
|
2226
|
+
const suffix = worktreeRemoved ? "" : " (rollback failed; partial worktree may remain)";
|
|
2227
|
+
const wrapped = new Error(`Failed to set upstream for '${branchName}': ${getErrorMessage(error)}${suffix}`);
|
|
2228
|
+
wrapped.isUpstreamSetupFailure = true;
|
|
2229
|
+
return wrapped;
|
|
2230
|
+
}
|
|
1917
2231
|
async removeWorktree(worktreePath) {
|
|
1918
2232
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
1919
2233
|
await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
@@ -2153,22 +2467,6 @@ var GitService = class {
|
|
|
2153
2467
|
import { createHash } from "crypto";
|
|
2154
2468
|
import * as fs5 from "fs";
|
|
2155
2469
|
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
2470
|
var BRANCH_STEM_MAX = 80;
|
|
2173
2471
|
var BRANCH_HASH_LEN = 8;
|
|
2174
2472
|
var PathResolutionService = class {
|
|
@@ -2337,8 +2635,50 @@ var WorktreeSyncService = class {
|
|
|
2337
2635
|
if (this.config.updateExistingWorktrees !== false) {
|
|
2338
2636
|
await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
|
|
2339
2637
|
}
|
|
2638
|
+
if (this.config.sparseCheckout) {
|
|
2639
|
+
await this.reapplySparseCheckout(worktrees);
|
|
2640
|
+
}
|
|
2340
2641
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
2341
2642
|
}
|
|
2643
|
+
async reapplySparseCheckout(worktrees) {
|
|
2644
|
+
const sparseConfig = this.config.sparseCheckout;
|
|
2645
|
+
if (!sparseConfig) return;
|
|
2646
|
+
this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
|
|
2647
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
2648
|
+
const desired = sparseService.buildPatterns(sparseConfig);
|
|
2649
|
+
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
2650
|
+
await Promise.all(
|
|
2651
|
+
worktrees.map(
|
|
2652
|
+
(worktree) => limit(async () => {
|
|
2653
|
+
try {
|
|
2654
|
+
try {
|
|
2655
|
+
await fs6.access(worktree.path);
|
|
2656
|
+
} catch {
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
const current = await sparseService.readCurrent(worktree.path);
|
|
2660
|
+
if (current !== null && sparseService.patternsEqual(current, desired)) return;
|
|
2661
|
+
if (sparseService.isNarrowing(current, desired)) {
|
|
2662
|
+
const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
|
|
2663
|
+
if (!status.canRemove) {
|
|
2664
|
+
this.logger.warn(
|
|
2665
|
+
` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
|
|
2666
|
+
);
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
await sparseService.applyToWorktree(worktree.path, sparseConfig);
|
|
2671
|
+
await this.gitService.checkoutHead(worktree.path);
|
|
2672
|
+
this.logger.info(` - \u2705 Sparse-checkout updated for '${worktree.branch}'`);
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
this.logger.warn(
|
|
2675
|
+
` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
|
|
2676
|
+
);
|
|
2677
|
+
}
|
|
2678
|
+
})
|
|
2679
|
+
)
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2342
2682
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
2343
2683
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
2344
2684
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
@@ -2915,16 +3255,27 @@ var RepositoryContext = class {
|
|
|
2915
3255
|
const setDefaultCurrent = options.setDefaultCurrent ?? true;
|
|
2916
3256
|
const absolutePath = path8.resolve(configPath);
|
|
2917
3257
|
const configFile = await this.configLoader.loadConfigFile(absolutePath);
|
|
3258
|
+
const configDir = path8.dirname(absolutePath);
|
|
3259
|
+
const globalDefaults = configFile.defaults;
|
|
3260
|
+
const resolvedAll = [];
|
|
3261
|
+
for (const repo of configFile.repositories) {
|
|
3262
|
+
const resolved = this.configLoader.resolveRepositoryConfig(
|
|
3263
|
+
repo,
|
|
3264
|
+
globalDefaults,
|
|
3265
|
+
configDir,
|
|
3266
|
+
configFile.retry,
|
|
3267
|
+
configFile.repositories
|
|
3268
|
+
);
|
|
3269
|
+
resolvedAll.push(resolved);
|
|
3270
|
+
}
|
|
3271
|
+
this.configLoader.detectBareRepoDirCollisions(resolvedAll);
|
|
2918
3272
|
for (const [name, entry] of this.repos) {
|
|
2919
3273
|
if (entry.source === "config") {
|
|
2920
3274
|
this.repos.delete(name);
|
|
2921
3275
|
}
|
|
2922
3276
|
}
|
|
2923
3277
|
this.configPath = absolutePath;
|
|
2924
|
-
const
|
|
2925
|
-
const globalDefaults = configFile.defaults;
|
|
2926
|
-
for (const repo of configFile.repositories) {
|
|
2927
|
-
const resolved = this.configLoader.resolveRepositoryConfig(repo, globalDefaults, configDir, configFile.retry);
|
|
3278
|
+
for (const resolved of resolvedAll) {
|
|
2928
3279
|
this.repos.set(resolved.name, {
|
|
2929
3280
|
name: resolved.name,
|
|
2930
3281
|
config: resolved,
|
|
@@ -3094,7 +3445,7 @@ var RepositoryContext = class {
|
|
|
3094
3445
|
let worktrees = [];
|
|
3095
3446
|
let currentBranch = null;
|
|
3096
3447
|
try {
|
|
3097
|
-
const bareGit =
|
|
3448
|
+
const bareGit = simpleGit5(bareRepoPath);
|
|
3098
3449
|
try {
|
|
3099
3450
|
const remoteResult = await bareGit.remote(["get-url", "origin"]);
|
|
3100
3451
|
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
@@ -3436,7 +3787,7 @@ function wrapHandler(fn) {
|
|
|
3436
3787
|
}
|
|
3437
3788
|
|
|
3438
3789
|
// src/mcp/worktree-summary.ts
|
|
3439
|
-
import
|
|
3790
|
+
import simpleGit6 from "simple-git";
|
|
3440
3791
|
function deriveLabel(status, isCurrent) {
|
|
3441
3792
|
if (isCurrent) return "current";
|
|
3442
3793
|
if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
|
|
@@ -3457,7 +3808,7 @@ function deriveSafeToRemove(status) {
|
|
|
3457
3808
|
}
|
|
3458
3809
|
async function getDivergence(worktreePath) {
|
|
3459
3810
|
try {
|
|
3460
|
-
const git =
|
|
3811
|
+
const git = simpleGit6(worktreePath);
|
|
3461
3812
|
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
3462
3813
|
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
3463
3814
|
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|