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/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import * as
|
|
4
|
+
import * as path12 from "path";
|
|
5
5
|
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
6
6
|
import * as cron3 from "node-cron";
|
|
7
7
|
import pLimit3 from "p-limit";
|
|
@@ -77,6 +77,7 @@ var ERROR_MESSAGES = {
|
|
|
77
77
|
};
|
|
78
78
|
var ENV_CONSTANTS = {
|
|
79
79
|
GIT_LFS_SKIP_SMUDGE: "GIT_LFS_SKIP_SMUDGE",
|
|
80
|
+
GIT_ATTR_SOURCE: "GIT_ATTR_SOURCE",
|
|
80
81
|
NODE_ENV_TEST: "test"
|
|
81
82
|
};
|
|
82
83
|
var PATH_CONSTANTS = {
|
|
@@ -123,7 +124,7 @@ var HOOK_CONSTANTS = {
|
|
|
123
124
|
|
|
124
125
|
// src/services/config-loader.service.ts
|
|
125
126
|
import * as fs from "fs/promises";
|
|
126
|
-
import * as
|
|
127
|
+
import * as path2 from "path";
|
|
127
128
|
import { pathToFileURL } from "url";
|
|
128
129
|
import * as cron from "node-cron";
|
|
129
130
|
|
|
@@ -147,14 +148,121 @@ function filterBranchesByName(branches, include, exclude) {
|
|
|
147
148
|
return result;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// src/utils/git-url.ts
|
|
152
|
+
function extractRepoNameFromUrl(gitUrl) {
|
|
153
|
+
const url = gitUrl.trim();
|
|
154
|
+
const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
155
|
+
if (sshMatch) {
|
|
156
|
+
return sshMatch[1];
|
|
157
|
+
}
|
|
158
|
+
const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
159
|
+
if (sshUrlMatch) {
|
|
160
|
+
return sshUrlMatch[1];
|
|
161
|
+
}
|
|
162
|
+
const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
163
|
+
if (httpsMatch) {
|
|
164
|
+
return httpsMatch[1];
|
|
165
|
+
}
|
|
166
|
+
const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
167
|
+
if (fileMatch) {
|
|
168
|
+
return fileMatch[1];
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Invalid Git URL format: ${gitUrl}`);
|
|
171
|
+
}
|
|
172
|
+
function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
173
|
+
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
174
|
+
return `${baseDir}/${repoName}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/utils/path-compare.ts
|
|
178
|
+
import * as path from "path";
|
|
179
|
+
var CASE_INSENSITIVE_PLATFORMS = /* @__PURE__ */ new Set(["darwin"]);
|
|
180
|
+
function isCaseInsensitiveFs(platform = process.platform) {
|
|
181
|
+
return CASE_INSENSITIVE_PLATFORMS.has(platform);
|
|
182
|
+
}
|
|
183
|
+
function normalizePathForCompare(p, platform = process.platform) {
|
|
184
|
+
const resolved = path.resolve(p);
|
|
185
|
+
return isCaseInsensitiveFs(platform) ? resolved.toLowerCase() : resolved;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/errors/index.ts
|
|
189
|
+
var SyncWorktreesError = class extends Error {
|
|
190
|
+
constructor(message, code, cause) {
|
|
191
|
+
super(message);
|
|
192
|
+
this.code = code;
|
|
193
|
+
this.cause = cause;
|
|
194
|
+
this.name = this.constructor.name;
|
|
195
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
196
|
+
if (cause && cause.stack) {
|
|
197
|
+
this.stack = `${this.stack}
|
|
198
|
+
Caused by: ${cause.stack}`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
var GitError = class extends SyncWorktreesError {
|
|
203
|
+
constructor(message, code, cause) {
|
|
204
|
+
super(message, `GIT_${code}`, cause);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
var GitOperationError = class extends GitError {
|
|
208
|
+
constructor(operation, details, cause) {
|
|
209
|
+
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
var WorktreeError = class extends SyncWorktreesError {
|
|
213
|
+
constructor(message, code, cause) {
|
|
214
|
+
super(message, `WORKTREE_${code}`, cause);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
var WorktreeNotCleanError = class extends WorktreeError {
|
|
218
|
+
constructor(path13, reasons) {
|
|
219
|
+
super(`Worktree at '${path13}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
220
|
+
this.path = path13;
|
|
221
|
+
this.reasons = reasons;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
var ConfigError = class extends SyncWorktreesError {
|
|
225
|
+
constructor(message, code, cause) {
|
|
226
|
+
super(message, `CONFIG_${code}`, cause);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
var ConfigValidationError = class extends ConfigError {
|
|
230
|
+
constructor(field, reason) {
|
|
231
|
+
super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
|
|
232
|
+
this.field = field;
|
|
233
|
+
this.reason = reason;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// src/utils/sanitize-name.ts
|
|
238
|
+
var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
239
|
+
var ILLEGAL_CHARS = /[<>:"|?*\x00-\x1f]/g;
|
|
240
|
+
function sanitizeNameForPath(name, fieldContext = "name") {
|
|
241
|
+
if (!name || typeof name !== "string") {
|
|
242
|
+
throw new ConfigValidationError(fieldContext, "must be a non-empty string");
|
|
243
|
+
}
|
|
244
|
+
let cleaned = name.trim();
|
|
245
|
+
cleaned = cleaned.replace(/[/\\]/g, "-");
|
|
246
|
+
cleaned = cleaned.replace(/^\.+/, "");
|
|
247
|
+
cleaned = cleaned.replace(ILLEGAL_CHARS, "_");
|
|
248
|
+
cleaned = cleaned.replace(/[. ]+$/, "");
|
|
249
|
+
if (cleaned.length === 0) {
|
|
250
|
+
throw new ConfigValidationError(fieldContext, `'${name}' produces an empty path segment after sanitization`);
|
|
251
|
+
}
|
|
252
|
+
if (WINDOWS_RESERVED.test(cleaned)) {
|
|
253
|
+
throw new ConfigValidationError(fieldContext, `'${cleaned}' is a reserved name on Windows`);
|
|
254
|
+
}
|
|
255
|
+
return cleaned;
|
|
256
|
+
}
|
|
257
|
+
|
|
150
258
|
// src/services/config-loader.service.ts
|
|
151
259
|
var ConfigLoaderService = class {
|
|
152
260
|
async findConfigUpward(startDir) {
|
|
153
|
-
let current =
|
|
154
|
-
const root =
|
|
261
|
+
let current = path2.resolve(startDir);
|
|
262
|
+
const root = path2.parse(current).root;
|
|
155
263
|
while (true) {
|
|
156
264
|
for (const name of CONFIG_FILE_NAMES) {
|
|
157
|
-
const candidate =
|
|
265
|
+
const candidate = path2.join(current, name);
|
|
158
266
|
try {
|
|
159
267
|
await fs.access(candidate);
|
|
160
268
|
return candidate;
|
|
@@ -162,13 +270,13 @@ var ConfigLoaderService = class {
|
|
|
162
270
|
}
|
|
163
271
|
}
|
|
164
272
|
if (current === root) return null;
|
|
165
|
-
const parent =
|
|
273
|
+
const parent = path2.dirname(current);
|
|
166
274
|
if (parent === current) return null;
|
|
167
275
|
current = parent;
|
|
168
276
|
}
|
|
169
277
|
}
|
|
170
278
|
async loadConfigFile(configPath) {
|
|
171
|
-
const absolutePath =
|
|
279
|
+
const absolutePath = path2.resolve(configPath);
|
|
172
280
|
try {
|
|
173
281
|
await fs.access(absolutePath);
|
|
174
282
|
} catch {
|
|
@@ -244,7 +352,11 @@ var ConfigLoaderService = class {
|
|
|
244
352
|
if (repoObj.hooks !== void 0) {
|
|
245
353
|
this.validateHooksConfig(repoObj.hooks, `Repository '${repoObj.name}'`);
|
|
246
354
|
}
|
|
355
|
+
if (repoObj.sparseCheckout !== void 0) {
|
|
356
|
+
this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
|
|
357
|
+
}
|
|
247
358
|
});
|
|
359
|
+
this.warnOnDuplicateRepoUrls(configObj.repositories);
|
|
248
360
|
if (configObj.defaults) {
|
|
249
361
|
if (typeof configObj.defaults !== "object") {
|
|
250
362
|
throw new Error("'defaults' must be an object");
|
|
@@ -268,6 +380,9 @@ var ConfigLoaderService = class {
|
|
|
268
380
|
if (defaults.hooks !== void 0) {
|
|
269
381
|
this.validateHooksConfig(defaults.hooks, "defaults");
|
|
270
382
|
}
|
|
383
|
+
if (defaults.sparseCheckout !== void 0) {
|
|
384
|
+
this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
|
|
385
|
+
}
|
|
271
386
|
}
|
|
272
387
|
if (configObj.retry !== void 0) {
|
|
273
388
|
if (typeof configObj.retry !== "object") {
|
|
@@ -357,6 +472,60 @@ var ConfigLoaderService = class {
|
|
|
357
472
|
}
|
|
358
473
|
}
|
|
359
474
|
}
|
|
475
|
+
validateSparseCheckoutConfig(value, context) {
|
|
476
|
+
if (typeof value !== "object" || value === null) {
|
|
477
|
+
throw new Error(`'sparseCheckout' in ${context} must be an object`);
|
|
478
|
+
}
|
|
479
|
+
const cfg = value;
|
|
480
|
+
if (!Array.isArray(cfg.include)) {
|
|
481
|
+
throw new Error(`'sparseCheckout.include' in ${context} must be an array`);
|
|
482
|
+
}
|
|
483
|
+
if (cfg.include.length === 0) {
|
|
484
|
+
throw new Error(`'sparseCheckout.include' in ${context} must contain at least one pattern`);
|
|
485
|
+
}
|
|
486
|
+
for (let i = 0; i < cfg.include.length; i++) {
|
|
487
|
+
const p = cfg.include[i];
|
|
488
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`'sparseCheckout.include' in ${context} must contain only non-empty strings (invalid at index ${i})`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (cfg.exclude !== void 0) {
|
|
495
|
+
if (!Array.isArray(cfg.exclude)) {
|
|
496
|
+
throw new Error(`'sparseCheckout.exclude' in ${context} must be an array`);
|
|
497
|
+
}
|
|
498
|
+
for (let i = 0; i < cfg.exclude.length; i++) {
|
|
499
|
+
const p = cfg.exclude[i];
|
|
500
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`'sparseCheckout.exclude' in ${context} must contain only non-empty strings (invalid at index ${i})`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (cfg.mode !== void 0 && cfg.mode !== "cone" && cfg.mode !== "no-cone") {
|
|
508
|
+
throw new Error(`'sparseCheckout.mode' in ${context} must be 'cone' or 'no-cone'`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
warnOnDuplicateRepoUrls(repositories) {
|
|
512
|
+
const seen = /* @__PURE__ */ new Map();
|
|
513
|
+
for (const repo of repositories) {
|
|
514
|
+
const url = typeof repo.repoUrl === "string" ? repo.repoUrl : null;
|
|
515
|
+
const name = typeof repo.name === "string" ? repo.name : null;
|
|
516
|
+
if (!url || !name) continue;
|
|
517
|
+
const list = seen.get(url) ?? [];
|
|
518
|
+
list.push(name);
|
|
519
|
+
seen.set(url, list);
|
|
520
|
+
}
|
|
521
|
+
for (const [url, names] of seen) {
|
|
522
|
+
if (names.length > 1) {
|
|
523
|
+
console.warn(
|
|
524
|
+
`[sync-worktrees] repoUrl '${url}' appears in multiple entries (${names.join(", ")}). Pin 'bareRepoDir' on duplicate entries to make config reorder-proof.`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
360
529
|
validateHooksConfig(hooks, context) {
|
|
361
530
|
if (typeof hooks !== "object" || hooks === null) {
|
|
362
531
|
throw new Error(`'hooks' in ${context} must be an object`);
|
|
@@ -376,7 +545,7 @@ var ConfigLoaderService = class {
|
|
|
376
545
|
}
|
|
377
546
|
}
|
|
378
547
|
}
|
|
379
|
-
resolveRepositoryConfig(repo, defaults, configDir, globalRetry) {
|
|
548
|
+
resolveRepositoryConfig(repo, defaults, configDir, globalRetry, allRepositories) {
|
|
380
549
|
const resolved = {
|
|
381
550
|
name: repo.name,
|
|
382
551
|
repoUrl: repo.repoUrl,
|
|
@@ -386,6 +555,11 @@ var ConfigLoaderService = class {
|
|
|
386
555
|
};
|
|
387
556
|
if (repo.bareRepoDir) {
|
|
388
557
|
resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
|
|
558
|
+
} else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories)) {
|
|
559
|
+
const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
|
|
560
|
+
resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
|
|
561
|
+
} else {
|
|
562
|
+
resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
|
|
389
563
|
}
|
|
390
564
|
if (repo.branchMaxAge || defaults?.branchMaxAge) {
|
|
391
565
|
resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
|
|
@@ -425,8 +599,32 @@ var ConfigLoaderService = class {
|
|
|
425
599
|
...repo.hooks || {}
|
|
426
600
|
};
|
|
427
601
|
}
|
|
602
|
+
const sparse = repo.sparseCheckout ?? defaults?.sparseCheckout;
|
|
603
|
+
if (sparse) {
|
|
604
|
+
resolved.sparseCheckout = sparse;
|
|
605
|
+
}
|
|
428
606
|
return resolved;
|
|
429
607
|
}
|
|
608
|
+
isDuplicateRepoUrl(repo, all) {
|
|
609
|
+
const firstIndex = all.findIndex((r) => r.repoUrl === repo.repoUrl);
|
|
610
|
+
const myIndex = all.indexOf(repo);
|
|
611
|
+
return firstIndex !== -1 && myIndex !== -1 && myIndex !== firstIndex;
|
|
612
|
+
}
|
|
613
|
+
detectBareRepoDirCollisions(repositories) {
|
|
614
|
+
const seen = /* @__PURE__ */ new Map();
|
|
615
|
+
for (const repo of repositories) {
|
|
616
|
+
if (!repo.bareRepoDir) continue;
|
|
617
|
+
const key = normalizePathForCompare(repo.bareRepoDir);
|
|
618
|
+
const displayPath = path2.resolve(repo.bareRepoDir);
|
|
619
|
+
const existing = seen.get(key);
|
|
620
|
+
if (existing && existing.name !== repo.name) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
`Repositories '${existing.name}' and '${repo.name}' resolve to the same bareRepoDir '${displayPath}'. Set distinct 'bareRepoDir' values for duplicate repoUrl entries.`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
seen.set(key, { name: repo.name, displayPath });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
430
628
|
isValidGitUrl(url) {
|
|
431
629
|
if (/^https?:\/\/.+/.test(url)) return true;
|
|
432
630
|
if (/^(ssh:\/\/|git@).+/.test(url)) return true;
|
|
@@ -435,10 +633,10 @@ var ConfigLoaderService = class {
|
|
|
435
633
|
return false;
|
|
436
634
|
}
|
|
437
635
|
resolvePath(inputPath, baseDir) {
|
|
438
|
-
if (
|
|
636
|
+
if (path2.isAbsolute(inputPath)) {
|
|
439
637
|
return inputPath;
|
|
440
638
|
}
|
|
441
|
-
return
|
|
639
|
+
return path2.resolve(baseDir || process.cwd(), inputPath);
|
|
442
640
|
}
|
|
443
641
|
filterRepositories(repositories, filter) {
|
|
444
642
|
if (!filter) {
|
|
@@ -451,10 +649,11 @@ var ConfigLoaderService = class {
|
|
|
451
649
|
}
|
|
452
650
|
async buildRepositories(configPath, overrides) {
|
|
453
651
|
const configFile = await this.loadConfigFile(configPath);
|
|
454
|
-
const configDir =
|
|
652
|
+
const configDir = path2.dirname(path2.resolve(configPath));
|
|
455
653
|
let repositories = configFile.repositories.map(
|
|
456
|
-
(repo) => this.resolveRepositoryConfig(repo, configFile.defaults, configDir, configFile.retry)
|
|
654
|
+
(repo) => this.resolveRepositoryConfig(repo, configFile.defaults, configDir, configFile.retry, configFile.repositories)
|
|
457
655
|
);
|
|
656
|
+
this.detectBareRepoDirCollisions(repositories);
|
|
458
657
|
if (overrides?.filter) {
|
|
459
658
|
repositories = this.filterRepositories(repositories, overrides.filter);
|
|
460
659
|
}
|
|
@@ -470,7 +669,7 @@ var ConfigLoaderService = class {
|
|
|
470
669
|
|
|
471
670
|
// src/services/InteractiveUIService.tsx
|
|
472
671
|
import React8 from "react";
|
|
473
|
-
import * as
|
|
672
|
+
import * as path9 from "path";
|
|
474
673
|
import { render } from "ink";
|
|
475
674
|
import * as cron2 from "node-cron";
|
|
476
675
|
import pLimit2 from "p-limit";
|
|
@@ -1746,7 +1945,7 @@ var App_default = App;
|
|
|
1746
1945
|
|
|
1747
1946
|
// src/services/worktree-sync.service.ts
|
|
1748
1947
|
import * as fs6 from "fs/promises";
|
|
1749
|
-
import * as
|
|
1948
|
+
import * as path7 from "path";
|
|
1750
1949
|
import pLimit from "p-limit";
|
|
1751
1950
|
|
|
1752
1951
|
// src/utils/date-filter.ts
|
|
@@ -1867,7 +2066,7 @@ async function retry(fn, options = {}) {
|
|
|
1867
2066
|
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
1868
2067
|
const delay = baseDelay + jitter;
|
|
1869
2068
|
opts.onRetry(error, attempt, lfsContext);
|
|
1870
|
-
await new Promise((
|
|
2069
|
+
await new Promise((resolve9) => setTimeout(resolve9, delay));
|
|
1871
2070
|
attempt++;
|
|
1872
2071
|
}
|
|
1873
2072
|
}
|
|
@@ -1975,71 +2174,8 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
1975
2174
|
|
|
1976
2175
|
// src/services/git.service.ts
|
|
1977
2176
|
import * as fs4 from "fs/promises";
|
|
1978
|
-
import * as
|
|
1979
|
-
import
|
|
1980
|
-
|
|
1981
|
-
// src/errors/index.ts
|
|
1982
|
-
var SyncWorktreesError = class extends Error {
|
|
1983
|
-
constructor(message, code, cause) {
|
|
1984
|
-
super(message);
|
|
1985
|
-
this.code = code;
|
|
1986
|
-
this.cause = cause;
|
|
1987
|
-
this.name = this.constructor.name;
|
|
1988
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
1989
|
-
if (cause && cause.stack) {
|
|
1990
|
-
this.stack = `${this.stack}
|
|
1991
|
-
Caused by: ${cause.stack}`;
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
};
|
|
1995
|
-
var GitError = class extends SyncWorktreesError {
|
|
1996
|
-
constructor(message, code, cause) {
|
|
1997
|
-
super(message, `GIT_${code}`, cause);
|
|
1998
|
-
}
|
|
1999
|
-
};
|
|
2000
|
-
var GitOperationError = class extends GitError {
|
|
2001
|
-
constructor(operation, details, cause) {
|
|
2002
|
-
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
2003
|
-
}
|
|
2004
|
-
};
|
|
2005
|
-
var WorktreeError = class extends SyncWorktreesError {
|
|
2006
|
-
constructor(message, code, cause) {
|
|
2007
|
-
super(message, `WORKTREE_${code}`, cause);
|
|
2008
|
-
}
|
|
2009
|
-
};
|
|
2010
|
-
var WorktreeNotCleanError = class extends WorktreeError {
|
|
2011
|
-
constructor(path12, reasons) {
|
|
2012
|
-
super(`Worktree at '${path12}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
2013
|
-
this.path = path12;
|
|
2014
|
-
this.reasons = reasons;
|
|
2015
|
-
}
|
|
2016
|
-
};
|
|
2017
|
-
|
|
2018
|
-
// src/utils/git-url.ts
|
|
2019
|
-
function extractRepoNameFromUrl(gitUrl) {
|
|
2020
|
-
const url = gitUrl.trim();
|
|
2021
|
-
const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
2022
|
-
if (sshMatch) {
|
|
2023
|
-
return sshMatch[1];
|
|
2024
|
-
}
|
|
2025
|
-
const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
2026
|
-
if (sshUrlMatch) {
|
|
2027
|
-
return sshUrlMatch[1];
|
|
2028
|
-
}
|
|
2029
|
-
const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
2030
|
-
if (httpsMatch) {
|
|
2031
|
-
return httpsMatch[1];
|
|
2032
|
-
}
|
|
2033
|
-
const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
2034
|
-
if (fileMatch) {
|
|
2035
|
-
return fileMatch[1];
|
|
2036
|
-
}
|
|
2037
|
-
throw new Error(`Invalid Git URL format: ${gitUrl}`);
|
|
2038
|
-
}
|
|
2039
|
-
function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
2040
|
-
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
2041
|
-
return `${baseDir}/${repoName}`;
|
|
2042
|
-
}
|
|
2177
|
+
import * as path5 from "path";
|
|
2178
|
+
import simpleGit4 from "simple-git";
|
|
2043
2179
|
|
|
2044
2180
|
// src/utils/worktree-list-parser.ts
|
|
2045
2181
|
function parseWorktreeListPorcelain(output) {
|
|
@@ -2181,10 +2317,101 @@ function defaultConsoleOutput(msg, level) {
|
|
|
2181
2317
|
else console.log(msg);
|
|
2182
2318
|
}
|
|
2183
2319
|
|
|
2320
|
+
// src/services/sparse-checkout.service.ts
|
|
2321
|
+
import simpleGit from "simple-git";
|
|
2322
|
+
var SparseCheckoutService = class {
|
|
2323
|
+
logger;
|
|
2324
|
+
gitFactory;
|
|
2325
|
+
warnedConfigs = /* @__PURE__ */ new WeakSet();
|
|
2326
|
+
constructor(logger, gitFactory) {
|
|
2327
|
+
this.logger = logger ?? Logger.createDefault();
|
|
2328
|
+
this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
|
|
2329
|
+
}
|
|
2330
|
+
updateLogger(logger) {
|
|
2331
|
+
this.logger = logger;
|
|
2332
|
+
}
|
|
2333
|
+
resolveMode(cfg) {
|
|
2334
|
+
const hasExclude = !!cfg.exclude && cfg.exclude.length > 0;
|
|
2335
|
+
const hasNegation = cfg.include.some((p) => p.trim().startsWith("!"));
|
|
2336
|
+
if (cfg.mode === "no-cone") return "no-cone";
|
|
2337
|
+
if (hasExclude || hasNegation) {
|
|
2338
|
+
if (cfg.mode === "cone" && !this.warnedConfigs.has(cfg)) {
|
|
2339
|
+
this.logger.warn(
|
|
2340
|
+
"sparseCheckout: mode 'cone' is incompatible with excludes or negation patterns; auto-promoting to 'no-cone'"
|
|
2341
|
+
);
|
|
2342
|
+
this.warnedConfigs.add(cfg);
|
|
2343
|
+
}
|
|
2344
|
+
return "no-cone";
|
|
2345
|
+
}
|
|
2346
|
+
return cfg.mode ?? "cone";
|
|
2347
|
+
}
|
|
2348
|
+
buildPatterns(cfg) {
|
|
2349
|
+
return this.buildPatternsForMode(cfg, this.resolveMode(cfg));
|
|
2350
|
+
}
|
|
2351
|
+
buildPatternsForMode(cfg, mode) {
|
|
2352
|
+
const includes = cfg.include.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
2353
|
+
if (mode === "cone") {
|
|
2354
|
+
return includes;
|
|
2355
|
+
}
|
|
2356
|
+
const excludes = (cfg.exclude ?? []).map((p) => p.trim()).filter((p) => p.length > 0).map((p) => p.startsWith("!") ? p : `!${p}`);
|
|
2357
|
+
return [...includes, ...excludes];
|
|
2358
|
+
}
|
|
2359
|
+
async applyToWorktree(worktreePath, cfg) {
|
|
2360
|
+
const mode = this.resolveMode(cfg);
|
|
2361
|
+
const patterns = this.buildPatternsForMode(cfg, mode);
|
|
2362
|
+
if (patterns.length === 0) {
|
|
2363
|
+
throw new Error("sparseCheckout produced no patterns; refusing to apply empty config");
|
|
2364
|
+
}
|
|
2365
|
+
const git = this.gitFactory(worktreePath);
|
|
2366
|
+
await git.raw(["sparse-checkout", "init", mode === "cone" ? "--cone" : "--no-cone"]);
|
|
2367
|
+
await git.raw(["sparse-checkout", "set", mode === "cone" ? "--cone" : "--no-cone", ...patterns]);
|
|
2368
|
+
}
|
|
2369
|
+
async readCurrent(worktreePath) {
|
|
2370
|
+
const git = this.gitFactory(worktreePath);
|
|
2371
|
+
try {
|
|
2372
|
+
const out = await git.raw(["sparse-checkout", "list"]);
|
|
2373
|
+
const lines = out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
2374
|
+
return lines.length === 0 ? null : lines;
|
|
2375
|
+
} catch {
|
|
2376
|
+
return null;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
async needsUpdate(worktreePath, cfg) {
|
|
2380
|
+
const current = await this.readCurrent(worktreePath);
|
|
2381
|
+
const desired = this.buildPatterns(cfg);
|
|
2382
|
+
if (current === null) return true;
|
|
2383
|
+
return !this.patternsEqual(current, desired);
|
|
2384
|
+
}
|
|
2385
|
+
isNarrowing(currentPatterns, nextPatterns) {
|
|
2386
|
+
if (!currentPatterns || currentPatterns.length === 0) return false;
|
|
2387
|
+
const isNeg = (p) => p.startsWith("!");
|
|
2388
|
+
const trim = (xs) => xs.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
2389
|
+
const cur = trim(currentPatterns);
|
|
2390
|
+
const next = trim(nextPatterns);
|
|
2391
|
+
const positiveCurrent = new Set(cur.filter((p) => !isNeg(p)));
|
|
2392
|
+
const negativeCurrent = new Set(cur.filter(isNeg));
|
|
2393
|
+
const positiveNext = new Set(next.filter((p) => !isNeg(p)));
|
|
2394
|
+
const negativeNext = new Set(next.filter(isNeg));
|
|
2395
|
+
for (const p of positiveCurrent) {
|
|
2396
|
+
if (!positiveNext.has(p)) return true;
|
|
2397
|
+
}
|
|
2398
|
+
for (const p of negativeNext) {
|
|
2399
|
+
if (!negativeCurrent.has(p)) return true;
|
|
2400
|
+
}
|
|
2401
|
+
return false;
|
|
2402
|
+
}
|
|
2403
|
+
patternsEqual(a, b) {
|
|
2404
|
+
if (a.length !== b.length) return false;
|
|
2405
|
+
const at = a.map((x) => x.trim());
|
|
2406
|
+
const bt = b.map((x) => x.trim());
|
|
2407
|
+
return at.every((v, i) => v === bt[i]);
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
|
|
2184
2411
|
// src/services/worktree-metadata.service.ts
|
|
2185
2412
|
import * as fs2 from "fs/promises";
|
|
2186
|
-
import * as
|
|
2187
|
-
import
|
|
2413
|
+
import * as path3 from "path";
|
|
2414
|
+
import simpleGit2 from "simple-git";
|
|
2188
2415
|
var WorktreeMetadataService = class {
|
|
2189
2416
|
logger;
|
|
2190
2417
|
constructor(logger) {
|
|
@@ -2196,7 +2423,7 @@ var WorktreeMetadataService = class {
|
|
|
2196
2423
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
2197
2424
|
*/
|
|
2198
2425
|
getWorktreeDirectoryName(worktreePath) {
|
|
2199
|
-
return
|
|
2426
|
+
return path3.basename(worktreePath);
|
|
2200
2427
|
}
|
|
2201
2428
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
2202
2429
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -2204,7 +2431,7 @@ var WorktreeMetadataService = class {
|
|
|
2204
2431
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
2205
2432
|
);
|
|
2206
2433
|
}
|
|
2207
|
-
return
|
|
2434
|
+
return path3.join(
|
|
2208
2435
|
bareRepoPath,
|
|
2209
2436
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
2210
2437
|
worktreeName,
|
|
@@ -2217,7 +2444,7 @@ var WorktreeMetadataService = class {
|
|
|
2217
2444
|
}
|
|
2218
2445
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
2219
2446
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2220
|
-
await fs2.mkdir(
|
|
2447
|
+
await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
|
|
2221
2448
|
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2222
2449
|
let renamed = false;
|
|
2223
2450
|
try {
|
|
@@ -2308,7 +2535,7 @@ var WorktreeMetadataService = class {
|
|
|
2308
2535
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
2309
2536
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
2310
2537
|
try {
|
|
2311
|
-
const worktreeGit =
|
|
2538
|
+
const worktreeGit = simpleGit2(worktreePath);
|
|
2312
2539
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
2313
2540
|
const branchSummary = await worktreeGit.branch();
|
|
2314
2541
|
const actualBranchName = branchSummary.current;
|
|
@@ -2410,8 +2637,8 @@ var WorktreeMetadataService = class {
|
|
|
2410
2637
|
|
|
2411
2638
|
// src/services/worktree-status.service.ts
|
|
2412
2639
|
import * as fs3 from "fs/promises";
|
|
2413
|
-
import * as
|
|
2414
|
-
import
|
|
2640
|
+
import * as path4 from "path";
|
|
2641
|
+
import simpleGit3 from "simple-git";
|
|
2415
2642
|
var OPERATION_FILES = [
|
|
2416
2643
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
2417
2644
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -2613,7 +2840,7 @@ var WorktreeStatusService = class {
|
|
|
2613
2840
|
async detectOperationFile(gitDir) {
|
|
2614
2841
|
const results = await Promise.all(
|
|
2615
2842
|
OPERATION_FILES.map(
|
|
2616
|
-
({ file }) => fs3.access(
|
|
2843
|
+
({ file }) => fs3.access(path4.join(gitDir, file)).then(
|
|
2617
2844
|
() => true,
|
|
2618
2845
|
() => false
|
|
2619
2846
|
)
|
|
@@ -2734,14 +2961,14 @@ var WorktreeStatusService = class {
|
|
|
2734
2961
|
}
|
|
2735
2962
|
}
|
|
2736
2963
|
async resolveGitDir(worktreePath) {
|
|
2737
|
-
const gitPath =
|
|
2964
|
+
const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
2738
2965
|
try {
|
|
2739
2966
|
const stat3 = await fs3.stat(gitPath);
|
|
2740
2967
|
if (stat3.isFile()) {
|
|
2741
2968
|
const content = await fs3.readFile(gitPath, "utf-8");
|
|
2742
2969
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
2743
2970
|
if (gitdirMatch) {
|
|
2744
|
-
return
|
|
2971
|
+
return path4.resolve(worktreePath, gitdirMatch[1].trim());
|
|
2745
2972
|
}
|
|
2746
2973
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
2747
2974
|
}
|
|
@@ -2755,10 +2982,10 @@ var WorktreeStatusService = class {
|
|
|
2755
2982
|
}
|
|
2756
2983
|
}
|
|
2757
2984
|
createGitInstance(worktreePath) {
|
|
2758
|
-
const key = `${
|
|
2985
|
+
const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
2759
2986
|
let git = this.gitInstances.get(key);
|
|
2760
2987
|
if (!git) {
|
|
2761
|
-
git = this.config.skipLfs ?
|
|
2988
|
+
git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
|
|
2762
2989
|
this.gitInstances.set(key, git);
|
|
2763
2990
|
}
|
|
2764
2991
|
return git;
|
|
@@ -2766,14 +2993,22 @@ var WorktreeStatusService = class {
|
|
|
2766
2993
|
};
|
|
2767
2994
|
|
|
2768
2995
|
// src/services/git.service.ts
|
|
2996
|
+
function sanitizeGitEnv(env) {
|
|
2997
|
+
const sanitized = { ...env };
|
|
2998
|
+
delete sanitized.EDITOR;
|
|
2999
|
+
delete sanitized.GIT_EDITOR;
|
|
3000
|
+
delete sanitized.GIT_SEQUENCE_EDITOR;
|
|
3001
|
+
return sanitized;
|
|
3002
|
+
}
|
|
2769
3003
|
var GitService = class {
|
|
2770
3004
|
constructor(config, logger) {
|
|
2771
3005
|
this.config = config;
|
|
2772
3006
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
2773
3007
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
2774
|
-
this.mainWorktreePath =
|
|
3008
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
2775
3009
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
2776
3010
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
3011
|
+
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
2777
3012
|
}
|
|
2778
3013
|
git = null;
|
|
2779
3014
|
bareRepoPath;
|
|
@@ -2782,29 +3017,34 @@ var GitService = class {
|
|
|
2782
3017
|
// Will be updated after detection
|
|
2783
3018
|
metadataService;
|
|
2784
3019
|
statusService;
|
|
3020
|
+
sparseCheckoutService;
|
|
2785
3021
|
logger;
|
|
2786
3022
|
lfsSkipOverride = false;
|
|
2787
3023
|
gitInstances = /* @__PURE__ */ new Map();
|
|
3024
|
+
getSparseCheckoutService() {
|
|
3025
|
+
return this.sparseCheckoutService;
|
|
3026
|
+
}
|
|
2788
3027
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
2789
|
-
const key = `${
|
|
3028
|
+
const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
2790
3029
|
let git = this.gitInstances.get(key);
|
|
2791
3030
|
if (!git) {
|
|
2792
|
-
git = useLfsSkip ?
|
|
3031
|
+
git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
|
|
2793
3032
|
this.gitInstances.set(key, git);
|
|
2794
3033
|
}
|
|
2795
3034
|
return git;
|
|
2796
3035
|
}
|
|
2797
3036
|
updateLogger(logger) {
|
|
2798
3037
|
this.logger = logger;
|
|
3038
|
+
this.sparseCheckoutService.updateLogger(logger);
|
|
2799
3039
|
}
|
|
2800
3040
|
async initialize() {
|
|
2801
3041
|
const { repoUrl } = this.config;
|
|
2802
3042
|
try {
|
|
2803
|
-
await fs4.access(
|
|
3043
|
+
await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
|
|
2804
3044
|
} catch {
|
|
2805
3045
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
2806
|
-
await fs4.mkdir(
|
|
2807
|
-
const cloneGit = this.isLfsSkipEnabled() ?
|
|
3046
|
+
await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
|
|
3047
|
+
const cloneGit = this.isLfsSkipEnabled() ? simpleGit4().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4();
|
|
2808
3048
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
2809
3049
|
this.logger.info("\u2705 Clone successful.");
|
|
2810
3050
|
}
|
|
@@ -2821,34 +3061,39 @@ var GitService = class {
|
|
|
2821
3061
|
this.logger.info("Fetching remote branches...");
|
|
2822
3062
|
await bareGit.fetch(["--all"]);
|
|
2823
3063
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
2824
|
-
this.mainWorktreePath =
|
|
3064
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
|
|
2825
3065
|
let needsMainWorktree = true;
|
|
2826
3066
|
try {
|
|
2827
3067
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2828
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
3068
|
+
needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
|
|
2829
3069
|
} catch {
|
|
2830
3070
|
}
|
|
2831
3071
|
if (needsMainWorktree) {
|
|
2832
3072
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
2833
3073
|
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2834
|
-
const absoluteWorktreePath =
|
|
3074
|
+
const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
|
|
2835
3075
|
const branches = await bareGit.branch();
|
|
2836
3076
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
3077
|
+
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
3078
|
+
const noCheckoutFlagMain = useNoCheckoutMain ? ["--no-checkout"] : [];
|
|
2837
3079
|
try {
|
|
2838
3080
|
if (defaultBranchExists) {
|
|
2839
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
3081
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlagMain, absoluteWorktreePath, this.defaultBranch]);
|
|
2840
3082
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
2841
3083
|
await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
|
|
3084
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, false);
|
|
2842
3085
|
} else {
|
|
2843
3086
|
await bareGit.raw([
|
|
2844
3087
|
"worktree",
|
|
2845
3088
|
"add",
|
|
3089
|
+
...noCheckoutFlagMain,
|
|
2846
3090
|
"--track",
|
|
2847
3091
|
"-b",
|
|
2848
3092
|
this.defaultBranch,
|
|
2849
3093
|
absoluteWorktreePath,
|
|
2850
3094
|
`origin/${this.defaultBranch}`
|
|
2851
3095
|
]);
|
|
3096
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, true);
|
|
2852
3097
|
}
|
|
2853
3098
|
} catch (error) {
|
|
2854
3099
|
const errorMessage = getErrorMessage(error);
|
|
@@ -2862,7 +3107,7 @@ var GitService = class {
|
|
|
2862
3107
|
}
|
|
2863
3108
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
2864
3109
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
2865
|
-
(w) =>
|
|
3110
|
+
(w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
|
|
2866
3111
|
);
|
|
2867
3112
|
if (!mainWorktreeRegistered) {
|
|
2868
3113
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -2931,13 +3176,29 @@ var GitService = class {
|
|
|
2931
3176
|
return branches;
|
|
2932
3177
|
}
|
|
2933
3178
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
2934
|
-
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3179
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit4(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
2935
3180
|
try {
|
|
2936
3181
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
2937
|
-
|
|
3182
|
+
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
2938
3183
|
if (lfsFileList.length === 0) {
|
|
2939
3184
|
return;
|
|
2940
3185
|
}
|
|
3186
|
+
if (this.config.sparseCheckout) {
|
|
3187
|
+
const existence = await Promise.all(
|
|
3188
|
+
lfsFileList.map(async (f) => {
|
|
3189
|
+
try {
|
|
3190
|
+
await fs4.access(path5.join(worktreePath, f));
|
|
3191
|
+
return f;
|
|
3192
|
+
} catch {
|
|
3193
|
+
return null;
|
|
3194
|
+
}
|
|
3195
|
+
})
|
|
3196
|
+
);
|
|
3197
|
+
lfsFileList = existence.filter((f) => f !== null);
|
|
3198
|
+
if (lfsFileList.length === 0) {
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
2941
3202
|
if (this.config.debug) {
|
|
2942
3203
|
this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
|
|
2943
3204
|
}
|
|
@@ -2954,7 +3215,7 @@ var GitService = class {
|
|
|
2954
3215
|
let allDownloaded = true;
|
|
2955
3216
|
const notDownloaded = [];
|
|
2956
3217
|
for (const file of samplesToCheck) {
|
|
2957
|
-
const filePath =
|
|
3218
|
+
const filePath = path5.join(worktreePath, file);
|
|
2958
3219
|
try {
|
|
2959
3220
|
const handle = await fs4.open(filePath, "r");
|
|
2960
3221
|
try {
|
|
@@ -2981,7 +3242,7 @@ var GitService = class {
|
|
|
2981
3242
|
}
|
|
2982
3243
|
retries++;
|
|
2983
3244
|
if (retries < maxRetries) {
|
|
2984
|
-
await new Promise((
|
|
3245
|
+
await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
|
|
2985
3246
|
}
|
|
2986
3247
|
}
|
|
2987
3248
|
this.logger.warn(
|
|
@@ -2991,6 +3252,38 @@ var GitService = class {
|
|
|
2991
3252
|
this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
|
|
2992
3253
|
}
|
|
2993
3254
|
}
|
|
3255
|
+
async checkoutHead(worktreePath) {
|
|
3256
|
+
const git = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
3257
|
+
await git.raw(["checkout", "HEAD"]);
|
|
3258
|
+
}
|
|
3259
|
+
async applySparseAndCheckout(absoluteWorktreePath) {
|
|
3260
|
+
if (!this.config.sparseCheckout) return;
|
|
3261
|
+
await this.sparseCheckoutService.applyToWorktree(absoluteWorktreePath, this.config.sparseCheckout);
|
|
3262
|
+
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
3263
|
+
await worktreeGit.raw(["checkout", "HEAD"]);
|
|
3264
|
+
}
|
|
3265
|
+
async rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch, failureContext) {
|
|
3266
|
+
let worktreeRemoved = true;
|
|
3267
|
+
try {
|
|
3268
|
+
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3269
|
+
} catch (rollbackError) {
|
|
3270
|
+
worktreeRemoved = false;
|
|
3271
|
+
const ctx = failureContext ? ` after ${failureContext}` : "";
|
|
3272
|
+
this.logger.warn(
|
|
3273
|
+
` - Rollback failed for '${branchName}' at '${absoluteWorktreePath}'${ctx}: ${getErrorMessage(rollbackError)}`
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
if (createdNewBranch) {
|
|
3277
|
+
try {
|
|
3278
|
+
await bareGit.raw(["branch", "-D", branchName]);
|
|
3279
|
+
} catch (branchRollbackError) {
|
|
3280
|
+
this.logger.warn(
|
|
3281
|
+
` - Rollback (branch delete) failed for '${branchName}': ${getErrorMessage(branchRollbackError)}`
|
|
3282
|
+
);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
return { worktreeRemoved };
|
|
3286
|
+
}
|
|
2994
3287
|
async createWorktreeMetadata(bareGit, worktreePath, branchName) {
|
|
2995
3288
|
try {
|
|
2996
3289
|
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
@@ -3011,12 +3304,12 @@ var GitService = class {
|
|
|
3011
3304
|
}
|
|
3012
3305
|
async addWorktree(branchName, worktreePath) {
|
|
3013
3306
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
3014
|
-
const absoluteWorktreePath =
|
|
3015
|
-
await fs4.mkdir(
|
|
3307
|
+
const absoluteWorktreePath = path5.resolve(worktreePath);
|
|
3308
|
+
await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
|
|
3016
3309
|
try {
|
|
3017
3310
|
await fs4.access(absoluteWorktreePath);
|
|
3018
3311
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3019
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3312
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3020
3313
|
if (isValidWorktree) {
|
|
3021
3314
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3022
3315
|
return;
|
|
@@ -3026,9 +3319,10 @@ var GitService = class {
|
|
|
3026
3319
|
}
|
|
3027
3320
|
} catch {
|
|
3028
3321
|
}
|
|
3322
|
+
let createdNewBranch = false;
|
|
3029
3323
|
try {
|
|
3030
3324
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
3031
|
-
await this.runWorktreeAddByMatrix(
|
|
3325
|
+
createdNewBranch = await this.runWorktreeAddByMatrix(
|
|
3032
3326
|
bareGit,
|
|
3033
3327
|
branchName,
|
|
3034
3328
|
absoluteWorktreePath,
|
|
@@ -3047,10 +3341,7 @@ var GitService = class {
|
|
|
3047
3341
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3048
3342
|
} catch (metadataError) {
|
|
3049
3343
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
3050
|
-
|
|
3051
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3052
|
-
} catch {
|
|
3053
|
-
}
|
|
3344
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
3054
3345
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3055
3346
|
}
|
|
3056
3347
|
} catch (error) {
|
|
@@ -3063,7 +3354,7 @@ var GitService = class {
|
|
|
3063
3354
|
}
|
|
3064
3355
|
if (errorMessage.includes("already registered worktree")) {
|
|
3065
3356
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3066
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3357
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3067
3358
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3068
3359
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
3069
3360
|
return;
|
|
@@ -3074,9 +3365,10 @@ var GitService = class {
|
|
|
3074
3365
|
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3075
3366
|
} catch {
|
|
3076
3367
|
}
|
|
3368
|
+
let retryCreatedNewBranch = false;
|
|
3077
3369
|
try {
|
|
3078
3370
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
3079
|
-
await this.runWorktreeAddByMatrix(
|
|
3371
|
+
retryCreatedNewBranch = await this.runWorktreeAddByMatrix(
|
|
3080
3372
|
bareGit,
|
|
3081
3373
|
branchName,
|
|
3082
3374
|
absoluteWorktreePath,
|
|
@@ -3091,10 +3383,7 @@ var GitService = class {
|
|
|
3091
3383
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3092
3384
|
} catch (metadataError) {
|
|
3093
3385
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
3094
|
-
|
|
3095
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3096
|
-
} catch {
|
|
3097
|
-
}
|
|
3386
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, retryCreatedNewBranch);
|
|
3098
3387
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3099
3388
|
}
|
|
3100
3389
|
return;
|
|
@@ -3111,7 +3400,7 @@ var GitService = class {
|
|
|
3111
3400
|
try {
|
|
3112
3401
|
await fs4.access(absoluteWorktreePath);
|
|
3113
3402
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3114
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3403
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3115
3404
|
if (isValidWorktree) {
|
|
3116
3405
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3117
3406
|
return;
|
|
@@ -3122,7 +3411,10 @@ var GitService = class {
|
|
|
3122
3411
|
} catch {
|
|
3123
3412
|
}
|
|
3124
3413
|
try {
|
|
3125
|
-
|
|
3414
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
3415
|
+
const fallbackArgs = useNoCheckout ? ["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName] : ["worktree", "add", absoluteWorktreePath, branchName];
|
|
3416
|
+
await bareGit.raw(fallbackArgs);
|
|
3417
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3126
3418
|
this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
|
|
3127
3419
|
if (!this.isLfsSkipEnabled()) {
|
|
3128
3420
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
@@ -3131,17 +3423,14 @@ var GitService = class {
|
|
|
3131
3423
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3132
3424
|
} catch (metadataError) {
|
|
3133
3425
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
3134
|
-
|
|
3135
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3136
|
-
} catch {
|
|
3137
|
-
}
|
|
3426
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, false);
|
|
3138
3427
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3139
3428
|
}
|
|
3140
3429
|
} catch (fallbackError) {
|
|
3141
3430
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
3142
3431
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
3143
3432
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3144
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3433
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3145
3434
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3146
3435
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
3147
3436
|
return;
|
|
@@ -3152,42 +3441,64 @@ var GitService = class {
|
|
|
3152
3441
|
}
|
|
3153
3442
|
}
|
|
3154
3443
|
async runWorktreeAddByMatrix(bareGit, branchName, absoluteWorktreePath, localExists, remoteExists) {
|
|
3444
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
3445
|
+
const noCheckoutFlag = useNoCheckout ? ["--no-checkout"] : [];
|
|
3155
3446
|
if (localExists && remoteExists) {
|
|
3156
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
3447
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
3157
3448
|
try {
|
|
3158
3449
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
3159
3450
|
await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
|
|
3160
3451
|
} catch (error) {
|
|
3161
|
-
|
|
3162
|
-
try {
|
|
3163
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3164
|
-
} catch (rollbackError) {
|
|
3165
|
-
rollbackFailed = true;
|
|
3166
|
-
this.logger.warn(
|
|
3167
|
-
` - Rollback failed for '${branchName}' at '${absoluteWorktreePath}' after upstream setup error: ${getErrorMessage(rollbackError)}`
|
|
3168
|
-
);
|
|
3169
|
-
}
|
|
3170
|
-
const detail = getErrorMessage(error);
|
|
3171
|
-
const suffix = rollbackFailed ? " (rollback failed; partial worktree may remain)" : "";
|
|
3172
|
-
const wrapped = new Error(`Failed to set upstream for '${branchName}': ${detail}${suffix}`);
|
|
3173
|
-
wrapped.isUpstreamSetupFailure = true;
|
|
3174
|
-
throw wrapped;
|
|
3452
|
+
throw await this.wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, false, error);
|
|
3175
3453
|
}
|
|
3176
|
-
|
|
3454
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3455
|
+
return false;
|
|
3177
3456
|
}
|
|
3178
3457
|
if (localExists) {
|
|
3179
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
3180
|
-
|
|
3458
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
3459
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3460
|
+
return false;
|
|
3181
3461
|
}
|
|
3182
3462
|
if (remoteExists) {
|
|
3183
|
-
await bareGit.raw([
|
|
3184
|
-
|
|
3463
|
+
await bareGit.raw([
|
|
3464
|
+
"worktree",
|
|
3465
|
+
"add",
|
|
3466
|
+
...noCheckoutFlag,
|
|
3467
|
+
"--track",
|
|
3468
|
+
"-b",
|
|
3469
|
+
branchName,
|
|
3470
|
+
absoluteWorktreePath,
|
|
3471
|
+
`origin/${branchName}`
|
|
3472
|
+
]);
|
|
3473
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, true);
|
|
3474
|
+
return true;
|
|
3185
3475
|
}
|
|
3186
3476
|
throw new WorktreeError(
|
|
3187
3477
|
`Branch '${branchName}' does not exist locally or on origin; create it first`,
|
|
3188
3478
|
"BRANCH_NOT_FOUND"
|
|
3189
3479
|
);
|
|
3190
3480
|
}
|
|
3481
|
+
async runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, createdNewBranch) {
|
|
3482
|
+
try {
|
|
3483
|
+
await this.applySparseAndCheckout(absoluteWorktreePath);
|
|
3484
|
+
} catch (sparseError) {
|
|
3485
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
3486
|
+
throw new Error(`Sparse-checkout setup failed for '${branchName}': ${getErrorMessage(sparseError)}`);
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
async wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, createdNewBranch, error) {
|
|
3490
|
+
const { worktreeRemoved } = await this.rollbackPartialWorktree(
|
|
3491
|
+
bareGit,
|
|
3492
|
+
absoluteWorktreePath,
|
|
3493
|
+
branchName,
|
|
3494
|
+
createdNewBranch,
|
|
3495
|
+
"upstream setup error"
|
|
3496
|
+
);
|
|
3497
|
+
const suffix = worktreeRemoved ? "" : " (rollback failed; partial worktree may remain)";
|
|
3498
|
+
const wrapped = new Error(`Failed to set upstream for '${branchName}': ${getErrorMessage(error)}${suffix}`);
|
|
3499
|
+
wrapped.isUpstreamSetupFailure = true;
|
|
3500
|
+
return wrapped;
|
|
3501
|
+
}
|
|
3191
3502
|
async removeWorktree(worktreePath) {
|
|
3192
3503
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3193
3504
|
await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
@@ -3426,15 +3737,7 @@ var GitService = class {
|
|
|
3426
3737
|
// src/services/path-resolution.service.ts
|
|
3427
3738
|
import { createHash } from "crypto";
|
|
3428
3739
|
import * as fs5 from "fs";
|
|
3429
|
-
import * as
|
|
3430
|
-
|
|
3431
|
-
// src/utils/path-compare.ts
|
|
3432
|
-
var CASE_INSENSITIVE_PLATFORMS = /* @__PURE__ */ new Set(["darwin"]);
|
|
3433
|
-
function isCaseInsensitiveFs(platform = process.platform) {
|
|
3434
|
-
return CASE_INSENSITIVE_PLATFORMS.has(platform);
|
|
3435
|
-
}
|
|
3436
|
-
|
|
3437
|
-
// src/services/path-resolution.service.ts
|
|
3740
|
+
import * as path6 from "path";
|
|
3438
3741
|
var BRANCH_STEM_MAX = 80;
|
|
3439
3742
|
var BRANCH_HASH_LEN = 8;
|
|
3440
3743
|
var PathResolutionService = class {
|
|
@@ -3444,22 +3747,22 @@ var PathResolutionService = class {
|
|
|
3444
3747
|
return `${stem}-${hash}`;
|
|
3445
3748
|
}
|
|
3446
3749
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3447
|
-
return
|
|
3750
|
+
return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3448
3751
|
}
|
|
3449
3752
|
resolveRealPath(inputPath) {
|
|
3450
|
-
const absolute =
|
|
3753
|
+
const absolute = path6.resolve(inputPath);
|
|
3451
3754
|
const missing = [];
|
|
3452
3755
|
let current = absolute;
|
|
3453
3756
|
while (!fs5.existsSync(current)) {
|
|
3454
|
-
const parent =
|
|
3757
|
+
const parent = path6.dirname(current);
|
|
3455
3758
|
if (parent === current) {
|
|
3456
3759
|
return absolute;
|
|
3457
3760
|
}
|
|
3458
|
-
missing.unshift(
|
|
3761
|
+
missing.unshift(path6.basename(current));
|
|
3459
3762
|
current = parent;
|
|
3460
3763
|
}
|
|
3461
3764
|
try {
|
|
3462
|
-
return
|
|
3765
|
+
return path6.join(fs5.realpathSync(current), ...missing);
|
|
3463
3766
|
} catch {
|
|
3464
3767
|
return absolute;
|
|
3465
3768
|
}
|
|
@@ -3469,7 +3772,7 @@ var PathResolutionService = class {
|
|
|
3469
3772
|
const a = fold(resolved);
|
|
3470
3773
|
const b = fold(resolvedBase);
|
|
3471
3774
|
if (a === b) return true;
|
|
3472
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
3775
|
+
return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
|
|
3473
3776
|
}
|
|
3474
3777
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3475
3778
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -3477,7 +3780,7 @@ var PathResolutionService = class {
|
|
|
3477
3780
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3478
3781
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3479
3782
|
}
|
|
3480
|
-
return
|
|
3783
|
+
return path6.relative(resolvedBase, resolved);
|
|
3481
3784
|
}
|
|
3482
3785
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3483
3786
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -3603,8 +3906,50 @@ var WorktreeSyncService = class {
|
|
|
3603
3906
|
if (this.config.updateExistingWorktrees !== false) {
|
|
3604
3907
|
await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
|
|
3605
3908
|
}
|
|
3909
|
+
if (this.config.sparseCheckout) {
|
|
3910
|
+
await this.reapplySparseCheckout(worktrees);
|
|
3911
|
+
}
|
|
3606
3912
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
3607
3913
|
}
|
|
3914
|
+
async reapplySparseCheckout(worktrees) {
|
|
3915
|
+
const sparseConfig = this.config.sparseCheckout;
|
|
3916
|
+
if (!sparseConfig) return;
|
|
3917
|
+
this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
|
|
3918
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
3919
|
+
const desired = sparseService.buildPatterns(sparseConfig);
|
|
3920
|
+
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
3921
|
+
await Promise.all(
|
|
3922
|
+
worktrees.map(
|
|
3923
|
+
(worktree) => limit(async () => {
|
|
3924
|
+
try {
|
|
3925
|
+
try {
|
|
3926
|
+
await fs6.access(worktree.path);
|
|
3927
|
+
} catch {
|
|
3928
|
+
return;
|
|
3929
|
+
}
|
|
3930
|
+
const current = await sparseService.readCurrent(worktree.path);
|
|
3931
|
+
if (current !== null && sparseService.patternsEqual(current, desired)) return;
|
|
3932
|
+
if (sparseService.isNarrowing(current, desired)) {
|
|
3933
|
+
const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
|
|
3934
|
+
if (!status.canRemove) {
|
|
3935
|
+
this.logger.warn(
|
|
3936
|
+
` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
|
|
3937
|
+
);
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
await sparseService.applyToWorktree(worktree.path, sparseConfig);
|
|
3942
|
+
await this.gitService.checkoutHead(worktree.path);
|
|
3943
|
+
this.logger.info(` - \u2705 Sparse-checkout updated for '${worktree.branch}'`);
|
|
3944
|
+
} catch (error) {
|
|
3945
|
+
this.logger.warn(
|
|
3946
|
+
` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
|
|
3947
|
+
);
|
|
3948
|
+
}
|
|
3949
|
+
})
|
|
3950
|
+
)
|
|
3951
|
+
);
|
|
3952
|
+
}
|
|
3608
3953
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
3609
3954
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
3610
3955
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
@@ -3696,12 +4041,12 @@ var WorktreeSyncService = class {
|
|
|
3696
4041
|
}
|
|
3697
4042
|
const reservedPaths = /* @__PURE__ */ new Map();
|
|
3698
4043
|
for (const w of worktrees) {
|
|
3699
|
-
reservedPaths.set(
|
|
4044
|
+
reservedPaths.set(path7.resolve(w.path), w.branch);
|
|
3700
4045
|
}
|
|
3701
4046
|
const plan = [];
|
|
3702
4047
|
for (const branchName of newBranches) {
|
|
3703
4048
|
const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
|
|
3704
|
-
const resolved =
|
|
4049
|
+
const resolved = path7.resolve(worktreePath);
|
|
3705
4050
|
const conflict = reservedPaths.get(resolved);
|
|
3706
4051
|
if (conflict && conflict !== branchName) {
|
|
3707
4052
|
this.logger.error(
|
|
@@ -3906,12 +4251,12 @@ var WorktreeSyncService = class {
|
|
|
3906
4251
|
}
|
|
3907
4252
|
async updateExistingWorktrees(worktrees, remoteBranches) {
|
|
3908
4253
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
3909
|
-
const divergedDir =
|
|
4254
|
+
const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3910
4255
|
try {
|
|
3911
4256
|
const diverged = await fs6.readdir(divergedDir);
|
|
3912
4257
|
if (diverged.length > 0) {
|
|
3913
4258
|
this.logger.info(
|
|
3914
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
4259
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
|
|
3915
4260
|
);
|
|
3916
4261
|
}
|
|
3917
4262
|
} catch {
|
|
@@ -4019,13 +4364,13 @@ var WorktreeSyncService = class {
|
|
|
4019
4364
|
}
|
|
4020
4365
|
async cleanupOrphanedDirectories(worktrees) {
|
|
4021
4366
|
try {
|
|
4022
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
4367
|
+
const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
|
|
4023
4368
|
const allDirs = await fs6.readdir(this.config.worktreeDir);
|
|
4024
4369
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
4025
4370
|
const orphanedDirs = [];
|
|
4026
4371
|
for (const dir of regularDirs) {
|
|
4027
4372
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
4028
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
4373
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
|
|
4029
4374
|
});
|
|
4030
4375
|
if (!isPartOfWorktree) {
|
|
4031
4376
|
orphanedDirs.push(dir);
|
|
@@ -4034,7 +4379,7 @@ var WorktreeSyncService = class {
|
|
|
4034
4379
|
if (orphanedDirs.length > 0) {
|
|
4035
4380
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
4036
4381
|
for (const dir of orphanedDirs) {
|
|
4037
|
-
const dirPath =
|
|
4382
|
+
const dirPath = path7.join(this.config.worktreeDir, dir);
|
|
4038
4383
|
try {
|
|
4039
4384
|
const stat3 = await fs6.stat(dirPath);
|
|
4040
4385
|
if (stat3.isDirectory()) {
|
|
@@ -4068,7 +4413,7 @@ var WorktreeSyncService = class {
|
|
|
4068
4413
|
} else {
|
|
4069
4414
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
4070
4415
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
4071
|
-
const relativePath =
|
|
4416
|
+
const relativePath = path7.relative(process.cwd(), divergedPath);
|
|
4072
4417
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
4073
4418
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
4074
4419
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -4092,12 +4437,12 @@ var WorktreeSyncService = class {
|
|
|
4092
4437
|
}
|
|
4093
4438
|
}
|
|
4094
4439
|
async divergeWorktree(worktreePath, branchName) {
|
|
4095
|
-
const divergedBaseDir =
|
|
4440
|
+
const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4096
4441
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4097
4442
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4098
4443
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4099
4444
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4100
|
-
const divergedPath =
|
|
4445
|
+
const divergedPath = path7.join(divergedBaseDir, divergedName);
|
|
4101
4446
|
await fs6.mkdir(divergedBaseDir, { recursive: true });
|
|
4102
4447
|
try {
|
|
4103
4448
|
await fs6.rename(worktreePath, divergedPath);
|
|
@@ -4124,7 +4469,7 @@ var WorktreeSyncService = class {
|
|
|
4124
4469
|
Original worktree location: ${worktreePath}`
|
|
4125
4470
|
};
|
|
4126
4471
|
await fs6.writeFile(
|
|
4127
|
-
|
|
4472
|
+
path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4128
4473
|
JSON.stringify(metadata, null, 2)
|
|
4129
4474
|
);
|
|
4130
4475
|
return divergedPath;
|
|
@@ -4133,7 +4478,7 @@ var WorktreeSyncService = class {
|
|
|
4133
4478
|
|
|
4134
4479
|
// src/services/file-copy.service.ts
|
|
4135
4480
|
import * as fs7 from "fs/promises";
|
|
4136
|
-
import * as
|
|
4481
|
+
import * as path8 from "path";
|
|
4137
4482
|
import { glob } from "glob";
|
|
4138
4483
|
var DEFAULT_IGNORE_PATTERNS = [
|
|
4139
4484
|
"**/node_modules/**",
|
|
@@ -4160,8 +4505,8 @@ var FileCopyService = class {
|
|
|
4160
4505
|
}
|
|
4161
4506
|
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
4162
4507
|
for (const relativePath of filesToCopy) {
|
|
4163
|
-
const sourcePath =
|
|
4164
|
-
const destPath =
|
|
4508
|
+
const sourcePath = path8.join(sourceDir, relativePath);
|
|
4509
|
+
const destPath = path8.join(destDir, relativePath);
|
|
4165
4510
|
try {
|
|
4166
4511
|
const copied = await this.copyFile(sourcePath, destPath);
|
|
4167
4512
|
if (copied) {
|
|
@@ -4202,7 +4547,7 @@ var FileCopyService = class {
|
|
|
4202
4547
|
return false;
|
|
4203
4548
|
} catch {
|
|
4204
4549
|
}
|
|
4205
|
-
const destDir =
|
|
4550
|
+
const destDir = path8.dirname(destPath);
|
|
4206
4551
|
await fs7.mkdir(destDir, { recursive: true });
|
|
4207
4552
|
await fs7.copyFile(sourcePath, destPath);
|
|
4208
4553
|
return true;
|
|
@@ -4338,7 +4683,7 @@ var HookExecutionService = class {
|
|
|
4338
4683
|
// src/utils/disk-space.ts
|
|
4339
4684
|
import fastFolderSize from "fast-folder-size";
|
|
4340
4685
|
async function calculateDirectorySize(dirPath) {
|
|
4341
|
-
return new Promise((
|
|
4686
|
+
return new Promise((resolve9, reject) => {
|
|
4342
4687
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
4343
4688
|
if (err) {
|
|
4344
4689
|
reject(err);
|
|
@@ -4348,7 +4693,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
4348
4693
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
4349
4694
|
return;
|
|
4350
4695
|
}
|
|
4351
|
-
|
|
4696
|
+
resolve9(bytes);
|
|
4352
4697
|
});
|
|
4353
4698
|
});
|
|
4354
4699
|
}
|
|
@@ -4550,8 +4895,8 @@ var InteractiveUIService = class {
|
|
|
4550
4895
|
getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
|
|
4551
4896
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
4552
4897
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
4553
|
-
openEditorInWorktree: (
|
|
4554
|
-
openTerminalInWorktree: (repoIndex,
|
|
4898
|
+
openEditorInWorktree: (path13) => this.openEditorInWorktree(path13),
|
|
4899
|
+
openTerminalInWorktree: (repoIndex, path13, branchName) => this.openTerminalInWorktree(repoIndex, path13, branchName),
|
|
4555
4900
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
4556
4901
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
4557
4902
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
@@ -4655,7 +5000,7 @@ var InteractiveUIService = class {
|
|
|
4655
5000
|
if (Date.now() - startTime > timeout) {
|
|
4656
5001
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
4657
5002
|
}
|
|
4658
|
-
await new Promise((
|
|
5003
|
+
await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
|
|
4659
5004
|
}
|
|
4660
5005
|
});
|
|
4661
5006
|
try {
|
|
@@ -4788,7 +5133,7 @@ var InteractiveUIService = class {
|
|
|
4788
5133
|
}
|
|
4789
5134
|
const service = this.syncServices[repoIndex];
|
|
4790
5135
|
const worktreeDir = service.config.worktreeDir;
|
|
4791
|
-
const divergedDir =
|
|
5136
|
+
const divergedDir = path9.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4792
5137
|
let dirEntries;
|
|
4793
5138
|
try {
|
|
4794
5139
|
dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
@@ -4798,8 +5143,8 @@ var InteractiveUIService = class {
|
|
|
4798
5143
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
4799
5144
|
const results = await Promise.allSettled(
|
|
4800
5145
|
subdirs.map(async (entry) => {
|
|
4801
|
-
const fullPath =
|
|
4802
|
-
const infoFilePath =
|
|
5146
|
+
const fullPath = path9.join(divergedDir, entry.name);
|
|
5147
|
+
const infoFilePath = path9.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
4803
5148
|
let originalBranch = entry.name;
|
|
4804
5149
|
let divergedAt = "";
|
|
4805
5150
|
try {
|
|
@@ -4834,11 +5179,11 @@ var InteractiveUIService = class {
|
|
|
4834
5179
|
}
|
|
4835
5180
|
const service = this.syncServices[repoIndex];
|
|
4836
5181
|
const worktreeDir = service.config.worktreeDir;
|
|
4837
|
-
const divergedBase =
|
|
5182
|
+
const divergedBase = path9.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4838
5183
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
4839
5184
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
4840
5185
|
}
|
|
4841
|
-
const targetPath =
|
|
5186
|
+
const targetPath = path9.join(divergedBase, name);
|
|
4842
5187
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
4843
5188
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
4844
5189
|
}
|
|
@@ -5227,7 +5572,7 @@ function reconstructCliCommand(config) {
|
|
|
5227
5572
|
|
|
5228
5573
|
// src/utils/config-generator.ts
|
|
5229
5574
|
import * as fs9 from "fs/promises";
|
|
5230
|
-
import * as
|
|
5575
|
+
import * as path10 from "path";
|
|
5231
5576
|
function serializeToESM(obj, indent = 0) {
|
|
5232
5577
|
const spaces = " ".repeat(indent);
|
|
5233
5578
|
const innerSpaces = " ".repeat(indent + 2);
|
|
@@ -5257,9 +5602,9 @@ ${spaces}}`;
|
|
|
5257
5602
|
return String(obj);
|
|
5258
5603
|
}
|
|
5259
5604
|
async function generateConfigFile(config, configPath) {
|
|
5260
|
-
const configDir =
|
|
5605
|
+
const configDir = path10.dirname(configPath);
|
|
5261
5606
|
await fs9.mkdir(configDir, { recursive: true });
|
|
5262
|
-
const worktreeDirRelative =
|
|
5607
|
+
const worktreeDirRelative = path10.relative(configDir, config.worktreeDir);
|
|
5263
5608
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
5264
5609
|
const repoName = extractRepoNameFromUrl(config.repoUrl);
|
|
5265
5610
|
const repository = {
|
|
@@ -5268,7 +5613,7 @@ async function generateConfigFile(config, configPath) {
|
|
|
5268
5613
|
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
|
|
5269
5614
|
};
|
|
5270
5615
|
if (config.bareRepoDir) {
|
|
5271
|
-
const bareRepoDirRelative =
|
|
5616
|
+
const bareRepoDirRelative = path10.relative(configDir, config.bareRepoDir);
|
|
5272
5617
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
5273
5618
|
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
|
|
5274
5619
|
}
|
|
@@ -5289,11 +5634,11 @@ export default ${serializeToESM(configObject)};
|
|
|
5289
5634
|
await fs9.writeFile(configPath, configContent, "utf-8");
|
|
5290
5635
|
}
|
|
5291
5636
|
function getDefaultConfigPath() {
|
|
5292
|
-
return
|
|
5637
|
+
return path10.join(process.cwd(), "sync-worktrees.config.js");
|
|
5293
5638
|
}
|
|
5294
5639
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
5295
5640
|
for (const name of CONFIG_FILE_NAMES) {
|
|
5296
|
-
const full =
|
|
5641
|
+
const full = path10.join(cwd, name);
|
|
5297
5642
|
try {
|
|
5298
5643
|
await fs9.access(full);
|
|
5299
5644
|
return full;
|
|
@@ -5304,7 +5649,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
|
|
|
5304
5649
|
}
|
|
5305
5650
|
|
|
5306
5651
|
// src/utils/interactive.ts
|
|
5307
|
-
import * as
|
|
5652
|
+
import * as path11 from "path";
|
|
5308
5653
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
5309
5654
|
async function promptForConfig(partialConfig) {
|
|
5310
5655
|
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
@@ -5344,8 +5689,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5344
5689
|
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
5345
5690
|
worktreeDir = defaultWorktreeDir;
|
|
5346
5691
|
}
|
|
5347
|
-
if (!
|
|
5348
|
-
worktreeDir =
|
|
5692
|
+
if (!path11.isAbsolute(worktreeDir)) {
|
|
5693
|
+
worktreeDir = path11.resolve(worktreeDir);
|
|
5349
5694
|
}
|
|
5350
5695
|
}
|
|
5351
5696
|
let bareRepoDir = partialConfig.bareRepoDir;
|
|
@@ -5364,8 +5709,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5364
5709
|
return true;
|
|
5365
5710
|
}
|
|
5366
5711
|
});
|
|
5367
|
-
if (!
|
|
5368
|
-
bareRepoDir =
|
|
5712
|
+
if (!path11.isAbsolute(bareRepoDir)) {
|
|
5713
|
+
bareRepoDir = path11.resolve(bareRepoDir);
|
|
5369
5714
|
}
|
|
5370
5715
|
}
|
|
5371
5716
|
let runOnce = partialConfig.runOnce;
|
|
@@ -5437,8 +5782,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5437
5782
|
return true;
|
|
5438
5783
|
}
|
|
5439
5784
|
});
|
|
5440
|
-
if (!
|
|
5441
|
-
configPath =
|
|
5785
|
+
if (!path11.isAbsolute(configPath)) {
|
|
5786
|
+
configPath = path11.resolve(configPath);
|
|
5442
5787
|
}
|
|
5443
5788
|
try {
|
|
5444
5789
|
await generateConfigFile(finalConfig, configPath);
|
|
@@ -5674,7 +6019,7 @@ async function main() {
|
|
|
5674
6019
|
const discovered = await findConfigInCwd();
|
|
5675
6020
|
if (discovered) {
|
|
5676
6021
|
options.config = discovered;
|
|
5677
|
-
console.log(`\u{1F4C4} Using config: ${
|
|
6022
|
+
console.log(`\u{1F4C4} Using config: ${path12.relative(process.cwd(), discovered)}`);
|
|
5678
6023
|
}
|
|
5679
6024
|
}
|
|
5680
6025
|
if (options.config) {
|