sync-worktrees 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/index.js +542 -204
- package/dist/index.js.map +4 -4
- package/dist/mcp-server.js +512 -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;
|
|
@@ -2771,9 +2998,10 @@ var GitService = class {
|
|
|
2771
2998
|
this.config = config;
|
|
2772
2999
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
2773
3000
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
2774
|
-
this.mainWorktreePath =
|
|
3001
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
2775
3002
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
2776
3003
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
3004
|
+
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
2777
3005
|
}
|
|
2778
3006
|
git = null;
|
|
2779
3007
|
bareRepoPath;
|
|
@@ -2782,29 +3010,34 @@ var GitService = class {
|
|
|
2782
3010
|
// Will be updated after detection
|
|
2783
3011
|
metadataService;
|
|
2784
3012
|
statusService;
|
|
3013
|
+
sparseCheckoutService;
|
|
2785
3014
|
logger;
|
|
2786
3015
|
lfsSkipOverride = false;
|
|
2787
3016
|
gitInstances = /* @__PURE__ */ new Map();
|
|
3017
|
+
getSparseCheckoutService() {
|
|
3018
|
+
return this.sparseCheckoutService;
|
|
3019
|
+
}
|
|
2788
3020
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
2789
|
-
const key = `${
|
|
3021
|
+
const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
2790
3022
|
let git = this.gitInstances.get(key);
|
|
2791
3023
|
if (!git) {
|
|
2792
|
-
git = useLfsSkip ?
|
|
3024
|
+
git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
|
|
2793
3025
|
this.gitInstances.set(key, git);
|
|
2794
3026
|
}
|
|
2795
3027
|
return git;
|
|
2796
3028
|
}
|
|
2797
3029
|
updateLogger(logger) {
|
|
2798
3030
|
this.logger = logger;
|
|
3031
|
+
this.sparseCheckoutService.updateLogger(logger);
|
|
2799
3032
|
}
|
|
2800
3033
|
async initialize() {
|
|
2801
3034
|
const { repoUrl } = this.config;
|
|
2802
3035
|
try {
|
|
2803
|
-
await fs4.access(
|
|
3036
|
+
await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
|
|
2804
3037
|
} catch {
|
|
2805
3038
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
2806
|
-
await fs4.mkdir(
|
|
2807
|
-
const cloneGit = this.isLfsSkipEnabled() ?
|
|
3039
|
+
await fs4.mkdir(path5.dirname(this.bareRepoPath), { recursive: true });
|
|
3040
|
+
const cloneGit = this.isLfsSkipEnabled() ? simpleGit4().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4();
|
|
2808
3041
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
2809
3042
|
this.logger.info("\u2705 Clone successful.");
|
|
2810
3043
|
}
|
|
@@ -2821,34 +3054,39 @@ var GitService = class {
|
|
|
2821
3054
|
this.logger.info("Fetching remote branches...");
|
|
2822
3055
|
await bareGit.fetch(["--all"]);
|
|
2823
3056
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
2824
|
-
this.mainWorktreePath =
|
|
3057
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
|
|
2825
3058
|
let needsMainWorktree = true;
|
|
2826
3059
|
try {
|
|
2827
3060
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2828
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
3061
|
+
needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
|
|
2829
3062
|
} catch {
|
|
2830
3063
|
}
|
|
2831
3064
|
if (needsMainWorktree) {
|
|
2832
3065
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
2833
3066
|
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2834
|
-
const absoluteWorktreePath =
|
|
3067
|
+
const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
|
|
2835
3068
|
const branches = await bareGit.branch();
|
|
2836
3069
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
3070
|
+
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
3071
|
+
const noCheckoutFlagMain = useNoCheckoutMain ? ["--no-checkout"] : [];
|
|
2837
3072
|
try {
|
|
2838
3073
|
if (defaultBranchExists) {
|
|
2839
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
3074
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlagMain, absoluteWorktreePath, this.defaultBranch]);
|
|
2840
3075
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
2841
3076
|
await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
|
|
3077
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, false);
|
|
2842
3078
|
} else {
|
|
2843
3079
|
await bareGit.raw([
|
|
2844
3080
|
"worktree",
|
|
2845
3081
|
"add",
|
|
3082
|
+
...noCheckoutFlagMain,
|
|
2846
3083
|
"--track",
|
|
2847
3084
|
"-b",
|
|
2848
3085
|
this.defaultBranch,
|
|
2849
3086
|
absoluteWorktreePath,
|
|
2850
3087
|
`origin/${this.defaultBranch}`
|
|
2851
3088
|
]);
|
|
3089
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, true);
|
|
2852
3090
|
}
|
|
2853
3091
|
} catch (error) {
|
|
2854
3092
|
const errorMessage = getErrorMessage(error);
|
|
@@ -2862,7 +3100,7 @@ var GitService = class {
|
|
|
2862
3100
|
}
|
|
2863
3101
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
2864
3102
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
2865
|
-
(w) =>
|
|
3103
|
+
(w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
|
|
2866
3104
|
);
|
|
2867
3105
|
if (!mainWorktreeRegistered) {
|
|
2868
3106
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -2931,13 +3169,29 @@ var GitService = class {
|
|
|
2931
3169
|
return branches;
|
|
2932
3170
|
}
|
|
2933
3171
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
2934
|
-
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3172
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit4(worktreePath).env({ ...process.env, [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
2935
3173
|
try {
|
|
2936
3174
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
2937
|
-
|
|
3175
|
+
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
2938
3176
|
if (lfsFileList.length === 0) {
|
|
2939
3177
|
return;
|
|
2940
3178
|
}
|
|
3179
|
+
if (this.config.sparseCheckout) {
|
|
3180
|
+
const existence = await Promise.all(
|
|
3181
|
+
lfsFileList.map(async (f) => {
|
|
3182
|
+
try {
|
|
3183
|
+
await fs4.access(path5.join(worktreePath, f));
|
|
3184
|
+
return f;
|
|
3185
|
+
} catch {
|
|
3186
|
+
return null;
|
|
3187
|
+
}
|
|
3188
|
+
})
|
|
3189
|
+
);
|
|
3190
|
+
lfsFileList = existence.filter((f) => f !== null);
|
|
3191
|
+
if (lfsFileList.length === 0) {
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
2941
3195
|
if (this.config.debug) {
|
|
2942
3196
|
this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
|
|
2943
3197
|
}
|
|
@@ -2954,7 +3208,7 @@ var GitService = class {
|
|
|
2954
3208
|
let allDownloaded = true;
|
|
2955
3209
|
const notDownloaded = [];
|
|
2956
3210
|
for (const file of samplesToCheck) {
|
|
2957
|
-
const filePath =
|
|
3211
|
+
const filePath = path5.join(worktreePath, file);
|
|
2958
3212
|
try {
|
|
2959
3213
|
const handle = await fs4.open(filePath, "r");
|
|
2960
3214
|
try {
|
|
@@ -2981,7 +3235,7 @@ var GitService = class {
|
|
|
2981
3235
|
}
|
|
2982
3236
|
retries++;
|
|
2983
3237
|
if (retries < maxRetries) {
|
|
2984
|
-
await new Promise((
|
|
3238
|
+
await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
|
|
2985
3239
|
}
|
|
2986
3240
|
}
|
|
2987
3241
|
this.logger.warn(
|
|
@@ -2991,6 +3245,38 @@ var GitService = class {
|
|
|
2991
3245
|
this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
|
|
2992
3246
|
}
|
|
2993
3247
|
}
|
|
3248
|
+
async checkoutHead(worktreePath) {
|
|
3249
|
+
const git = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
3250
|
+
await git.raw(["checkout", "HEAD"]);
|
|
3251
|
+
}
|
|
3252
|
+
async applySparseAndCheckout(absoluteWorktreePath) {
|
|
3253
|
+
if (!this.config.sparseCheckout) return;
|
|
3254
|
+
await this.sparseCheckoutService.applyToWorktree(absoluteWorktreePath, this.config.sparseCheckout);
|
|
3255
|
+
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
3256
|
+
await worktreeGit.raw(["checkout", "HEAD"]);
|
|
3257
|
+
}
|
|
3258
|
+
async rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch, failureContext) {
|
|
3259
|
+
let worktreeRemoved = true;
|
|
3260
|
+
try {
|
|
3261
|
+
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3262
|
+
} catch (rollbackError) {
|
|
3263
|
+
worktreeRemoved = false;
|
|
3264
|
+
const ctx = failureContext ? ` after ${failureContext}` : "";
|
|
3265
|
+
this.logger.warn(
|
|
3266
|
+
` - Rollback failed for '${branchName}' at '${absoluteWorktreePath}'${ctx}: ${getErrorMessage(rollbackError)}`
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3269
|
+
if (createdNewBranch) {
|
|
3270
|
+
try {
|
|
3271
|
+
await bareGit.raw(["branch", "-D", branchName]);
|
|
3272
|
+
} catch (branchRollbackError) {
|
|
3273
|
+
this.logger.warn(
|
|
3274
|
+
` - Rollback (branch delete) failed for '${branchName}': ${getErrorMessage(branchRollbackError)}`
|
|
3275
|
+
);
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
return { worktreeRemoved };
|
|
3279
|
+
}
|
|
2994
3280
|
async createWorktreeMetadata(bareGit, worktreePath, branchName) {
|
|
2995
3281
|
try {
|
|
2996
3282
|
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
@@ -3011,12 +3297,12 @@ var GitService = class {
|
|
|
3011
3297
|
}
|
|
3012
3298
|
async addWorktree(branchName, worktreePath) {
|
|
3013
3299
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
3014
|
-
const absoluteWorktreePath =
|
|
3015
|
-
await fs4.mkdir(
|
|
3300
|
+
const absoluteWorktreePath = path5.resolve(worktreePath);
|
|
3301
|
+
await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
|
|
3016
3302
|
try {
|
|
3017
3303
|
await fs4.access(absoluteWorktreePath);
|
|
3018
3304
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3019
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3305
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3020
3306
|
if (isValidWorktree) {
|
|
3021
3307
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3022
3308
|
return;
|
|
@@ -3026,9 +3312,10 @@ var GitService = class {
|
|
|
3026
3312
|
}
|
|
3027
3313
|
} catch {
|
|
3028
3314
|
}
|
|
3315
|
+
let createdNewBranch = false;
|
|
3029
3316
|
try {
|
|
3030
3317
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
3031
|
-
await this.runWorktreeAddByMatrix(
|
|
3318
|
+
createdNewBranch = await this.runWorktreeAddByMatrix(
|
|
3032
3319
|
bareGit,
|
|
3033
3320
|
branchName,
|
|
3034
3321
|
absoluteWorktreePath,
|
|
@@ -3047,10 +3334,7 @@ var GitService = class {
|
|
|
3047
3334
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3048
3335
|
} catch (metadataError) {
|
|
3049
3336
|
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
|
-
}
|
|
3337
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
3054
3338
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3055
3339
|
}
|
|
3056
3340
|
} catch (error) {
|
|
@@ -3063,7 +3347,7 @@ var GitService = class {
|
|
|
3063
3347
|
}
|
|
3064
3348
|
if (errorMessage.includes("already registered worktree")) {
|
|
3065
3349
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3066
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3350
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3067
3351
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3068
3352
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
3069
3353
|
return;
|
|
@@ -3074,9 +3358,10 @@ var GitService = class {
|
|
|
3074
3358
|
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3075
3359
|
} catch {
|
|
3076
3360
|
}
|
|
3361
|
+
let retryCreatedNewBranch = false;
|
|
3077
3362
|
try {
|
|
3078
3363
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
3079
|
-
await this.runWorktreeAddByMatrix(
|
|
3364
|
+
retryCreatedNewBranch = await this.runWorktreeAddByMatrix(
|
|
3080
3365
|
bareGit,
|
|
3081
3366
|
branchName,
|
|
3082
3367
|
absoluteWorktreePath,
|
|
@@ -3091,10 +3376,7 @@ var GitService = class {
|
|
|
3091
3376
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3092
3377
|
} catch (metadataError) {
|
|
3093
3378
|
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
|
-
}
|
|
3379
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, retryCreatedNewBranch);
|
|
3098
3380
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3099
3381
|
}
|
|
3100
3382
|
return;
|
|
@@ -3111,7 +3393,7 @@ var GitService = class {
|
|
|
3111
3393
|
try {
|
|
3112
3394
|
await fs4.access(absoluteWorktreePath);
|
|
3113
3395
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3114
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3396
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3115
3397
|
if (isValidWorktree) {
|
|
3116
3398
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3117
3399
|
return;
|
|
@@ -3122,7 +3404,10 @@ var GitService = class {
|
|
|
3122
3404
|
} catch {
|
|
3123
3405
|
}
|
|
3124
3406
|
try {
|
|
3125
|
-
|
|
3407
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
3408
|
+
const fallbackArgs = useNoCheckout ? ["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName] : ["worktree", "add", absoluteWorktreePath, branchName];
|
|
3409
|
+
await bareGit.raw(fallbackArgs);
|
|
3410
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3126
3411
|
this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
|
|
3127
3412
|
if (!this.isLfsSkipEnabled()) {
|
|
3128
3413
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
@@ -3131,17 +3416,14 @@ var GitService = class {
|
|
|
3131
3416
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3132
3417
|
} catch (metadataError) {
|
|
3133
3418
|
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
|
-
}
|
|
3419
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, false);
|
|
3138
3420
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3139
3421
|
}
|
|
3140
3422
|
} catch (fallbackError) {
|
|
3141
3423
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
3142
3424
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
3143
3425
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3144
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3426
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3145
3427
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3146
3428
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
3147
3429
|
return;
|
|
@@ -3152,42 +3434,64 @@ var GitService = class {
|
|
|
3152
3434
|
}
|
|
3153
3435
|
}
|
|
3154
3436
|
async runWorktreeAddByMatrix(bareGit, branchName, absoluteWorktreePath, localExists, remoteExists) {
|
|
3437
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
3438
|
+
const noCheckoutFlag = useNoCheckout ? ["--no-checkout"] : [];
|
|
3155
3439
|
if (localExists && remoteExists) {
|
|
3156
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
3440
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
3157
3441
|
try {
|
|
3158
3442
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
3159
3443
|
await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
|
|
3160
3444
|
} 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;
|
|
3445
|
+
throw await this.wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, false, error);
|
|
3175
3446
|
}
|
|
3176
|
-
|
|
3447
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3448
|
+
return false;
|
|
3177
3449
|
}
|
|
3178
3450
|
if (localExists) {
|
|
3179
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
3180
|
-
|
|
3451
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
3452
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3453
|
+
return false;
|
|
3181
3454
|
}
|
|
3182
3455
|
if (remoteExists) {
|
|
3183
|
-
await bareGit.raw([
|
|
3184
|
-
|
|
3456
|
+
await bareGit.raw([
|
|
3457
|
+
"worktree",
|
|
3458
|
+
"add",
|
|
3459
|
+
...noCheckoutFlag,
|
|
3460
|
+
"--track",
|
|
3461
|
+
"-b",
|
|
3462
|
+
branchName,
|
|
3463
|
+
absoluteWorktreePath,
|
|
3464
|
+
`origin/${branchName}`
|
|
3465
|
+
]);
|
|
3466
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, true);
|
|
3467
|
+
return true;
|
|
3185
3468
|
}
|
|
3186
3469
|
throw new WorktreeError(
|
|
3187
3470
|
`Branch '${branchName}' does not exist locally or on origin; create it first`,
|
|
3188
3471
|
"BRANCH_NOT_FOUND"
|
|
3189
3472
|
);
|
|
3190
3473
|
}
|
|
3474
|
+
async runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, createdNewBranch) {
|
|
3475
|
+
try {
|
|
3476
|
+
await this.applySparseAndCheckout(absoluteWorktreePath);
|
|
3477
|
+
} catch (sparseError) {
|
|
3478
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
3479
|
+
throw new Error(`Sparse-checkout setup failed for '${branchName}': ${getErrorMessage(sparseError)}`);
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
async wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, createdNewBranch, error) {
|
|
3483
|
+
const { worktreeRemoved } = await this.rollbackPartialWorktree(
|
|
3484
|
+
bareGit,
|
|
3485
|
+
absoluteWorktreePath,
|
|
3486
|
+
branchName,
|
|
3487
|
+
createdNewBranch,
|
|
3488
|
+
"upstream setup error"
|
|
3489
|
+
);
|
|
3490
|
+
const suffix = worktreeRemoved ? "" : " (rollback failed; partial worktree may remain)";
|
|
3491
|
+
const wrapped = new Error(`Failed to set upstream for '${branchName}': ${getErrorMessage(error)}${suffix}`);
|
|
3492
|
+
wrapped.isUpstreamSetupFailure = true;
|
|
3493
|
+
return wrapped;
|
|
3494
|
+
}
|
|
3191
3495
|
async removeWorktree(worktreePath) {
|
|
3192
3496
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3193
3497
|
await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
@@ -3426,15 +3730,7 @@ var GitService = class {
|
|
|
3426
3730
|
// src/services/path-resolution.service.ts
|
|
3427
3731
|
import { createHash } from "crypto";
|
|
3428
3732
|
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
|
|
3733
|
+
import * as path6 from "path";
|
|
3438
3734
|
var BRANCH_STEM_MAX = 80;
|
|
3439
3735
|
var BRANCH_HASH_LEN = 8;
|
|
3440
3736
|
var PathResolutionService = class {
|
|
@@ -3444,22 +3740,22 @@ var PathResolutionService = class {
|
|
|
3444
3740
|
return `${stem}-${hash}`;
|
|
3445
3741
|
}
|
|
3446
3742
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3447
|
-
return
|
|
3743
|
+
return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3448
3744
|
}
|
|
3449
3745
|
resolveRealPath(inputPath) {
|
|
3450
|
-
const absolute =
|
|
3746
|
+
const absolute = path6.resolve(inputPath);
|
|
3451
3747
|
const missing = [];
|
|
3452
3748
|
let current = absolute;
|
|
3453
3749
|
while (!fs5.existsSync(current)) {
|
|
3454
|
-
const parent =
|
|
3750
|
+
const parent = path6.dirname(current);
|
|
3455
3751
|
if (parent === current) {
|
|
3456
3752
|
return absolute;
|
|
3457
3753
|
}
|
|
3458
|
-
missing.unshift(
|
|
3754
|
+
missing.unshift(path6.basename(current));
|
|
3459
3755
|
current = parent;
|
|
3460
3756
|
}
|
|
3461
3757
|
try {
|
|
3462
|
-
return
|
|
3758
|
+
return path6.join(fs5.realpathSync(current), ...missing);
|
|
3463
3759
|
} catch {
|
|
3464
3760
|
return absolute;
|
|
3465
3761
|
}
|
|
@@ -3469,7 +3765,7 @@ var PathResolutionService = class {
|
|
|
3469
3765
|
const a = fold(resolved);
|
|
3470
3766
|
const b = fold(resolvedBase);
|
|
3471
3767
|
if (a === b) return true;
|
|
3472
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
3768
|
+
return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
|
|
3473
3769
|
}
|
|
3474
3770
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3475
3771
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -3477,7 +3773,7 @@ var PathResolutionService = class {
|
|
|
3477
3773
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3478
3774
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3479
3775
|
}
|
|
3480
|
-
return
|
|
3776
|
+
return path6.relative(resolvedBase, resolved);
|
|
3481
3777
|
}
|
|
3482
3778
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3483
3779
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -3603,8 +3899,50 @@ var WorktreeSyncService = class {
|
|
|
3603
3899
|
if (this.config.updateExistingWorktrees !== false) {
|
|
3604
3900
|
await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
|
|
3605
3901
|
}
|
|
3902
|
+
if (this.config.sparseCheckout) {
|
|
3903
|
+
await this.reapplySparseCheckout(worktrees);
|
|
3904
|
+
}
|
|
3606
3905
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
3607
3906
|
}
|
|
3907
|
+
async reapplySparseCheckout(worktrees) {
|
|
3908
|
+
const sparseConfig = this.config.sparseCheckout;
|
|
3909
|
+
if (!sparseConfig) return;
|
|
3910
|
+
this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
|
|
3911
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
3912
|
+
const desired = sparseService.buildPatterns(sparseConfig);
|
|
3913
|
+
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
3914
|
+
await Promise.all(
|
|
3915
|
+
worktrees.map(
|
|
3916
|
+
(worktree) => limit(async () => {
|
|
3917
|
+
try {
|
|
3918
|
+
try {
|
|
3919
|
+
await fs6.access(worktree.path);
|
|
3920
|
+
} catch {
|
|
3921
|
+
return;
|
|
3922
|
+
}
|
|
3923
|
+
const current = await sparseService.readCurrent(worktree.path);
|
|
3924
|
+
if (current !== null && sparseService.patternsEqual(current, desired)) return;
|
|
3925
|
+
if (sparseService.isNarrowing(current, desired)) {
|
|
3926
|
+
const status = await this.gitService.getFullWorktreeStatus(worktree.path, false);
|
|
3927
|
+
if (!status.canRemove) {
|
|
3928
|
+
this.logger.warn(
|
|
3929
|
+
` - Skipping sparse-checkout narrowing for '${worktree.branch}': ${status.reasons.join(", ")}.`
|
|
3930
|
+
);
|
|
3931
|
+
return;
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
await sparseService.applyToWorktree(worktree.path, sparseConfig);
|
|
3935
|
+
await this.gitService.checkoutHead(worktree.path);
|
|
3936
|
+
this.logger.info(` - \u2705 Sparse-checkout updated for '${worktree.branch}'`);
|
|
3937
|
+
} catch (error) {
|
|
3938
|
+
this.logger.warn(
|
|
3939
|
+
` - \u26A0\uFE0F Failed to update sparse-checkout for '${worktree.branch}': ${getErrorMessage(error)}`
|
|
3940
|
+
);
|
|
3941
|
+
}
|
|
3942
|
+
})
|
|
3943
|
+
)
|
|
3944
|
+
);
|
|
3945
|
+
}
|
|
3608
3946
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
3609
3947
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
3610
3948
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
@@ -3696,12 +4034,12 @@ var WorktreeSyncService = class {
|
|
|
3696
4034
|
}
|
|
3697
4035
|
const reservedPaths = /* @__PURE__ */ new Map();
|
|
3698
4036
|
for (const w of worktrees) {
|
|
3699
|
-
reservedPaths.set(
|
|
4037
|
+
reservedPaths.set(path7.resolve(w.path), w.branch);
|
|
3700
4038
|
}
|
|
3701
4039
|
const plan = [];
|
|
3702
4040
|
for (const branchName of newBranches) {
|
|
3703
4041
|
const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
|
|
3704
|
-
const resolved =
|
|
4042
|
+
const resolved = path7.resolve(worktreePath);
|
|
3705
4043
|
const conflict = reservedPaths.get(resolved);
|
|
3706
4044
|
if (conflict && conflict !== branchName) {
|
|
3707
4045
|
this.logger.error(
|
|
@@ -3906,12 +4244,12 @@ var WorktreeSyncService = class {
|
|
|
3906
4244
|
}
|
|
3907
4245
|
async updateExistingWorktrees(worktrees, remoteBranches) {
|
|
3908
4246
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
3909
|
-
const divergedDir =
|
|
4247
|
+
const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3910
4248
|
try {
|
|
3911
4249
|
const diverged = await fs6.readdir(divergedDir);
|
|
3912
4250
|
if (diverged.length > 0) {
|
|
3913
4251
|
this.logger.info(
|
|
3914
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
4252
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
|
|
3915
4253
|
);
|
|
3916
4254
|
}
|
|
3917
4255
|
} catch {
|
|
@@ -4019,13 +4357,13 @@ var WorktreeSyncService = class {
|
|
|
4019
4357
|
}
|
|
4020
4358
|
async cleanupOrphanedDirectories(worktrees) {
|
|
4021
4359
|
try {
|
|
4022
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
4360
|
+
const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
|
|
4023
4361
|
const allDirs = await fs6.readdir(this.config.worktreeDir);
|
|
4024
4362
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
4025
4363
|
const orphanedDirs = [];
|
|
4026
4364
|
for (const dir of regularDirs) {
|
|
4027
4365
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
4028
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
4366
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
|
|
4029
4367
|
});
|
|
4030
4368
|
if (!isPartOfWorktree) {
|
|
4031
4369
|
orphanedDirs.push(dir);
|
|
@@ -4034,7 +4372,7 @@ var WorktreeSyncService = class {
|
|
|
4034
4372
|
if (orphanedDirs.length > 0) {
|
|
4035
4373
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
4036
4374
|
for (const dir of orphanedDirs) {
|
|
4037
|
-
const dirPath =
|
|
4375
|
+
const dirPath = path7.join(this.config.worktreeDir, dir);
|
|
4038
4376
|
try {
|
|
4039
4377
|
const stat3 = await fs6.stat(dirPath);
|
|
4040
4378
|
if (stat3.isDirectory()) {
|
|
@@ -4068,7 +4406,7 @@ var WorktreeSyncService = class {
|
|
|
4068
4406
|
} else {
|
|
4069
4407
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
4070
4408
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
4071
|
-
const relativePath =
|
|
4409
|
+
const relativePath = path7.relative(process.cwd(), divergedPath);
|
|
4072
4410
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
4073
4411
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
4074
4412
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -4092,12 +4430,12 @@ var WorktreeSyncService = class {
|
|
|
4092
4430
|
}
|
|
4093
4431
|
}
|
|
4094
4432
|
async divergeWorktree(worktreePath, branchName) {
|
|
4095
|
-
const divergedBaseDir =
|
|
4433
|
+
const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4096
4434
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4097
4435
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4098
4436
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4099
4437
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4100
|
-
const divergedPath =
|
|
4438
|
+
const divergedPath = path7.join(divergedBaseDir, divergedName);
|
|
4101
4439
|
await fs6.mkdir(divergedBaseDir, { recursive: true });
|
|
4102
4440
|
try {
|
|
4103
4441
|
await fs6.rename(worktreePath, divergedPath);
|
|
@@ -4124,7 +4462,7 @@ var WorktreeSyncService = class {
|
|
|
4124
4462
|
Original worktree location: ${worktreePath}`
|
|
4125
4463
|
};
|
|
4126
4464
|
await fs6.writeFile(
|
|
4127
|
-
|
|
4465
|
+
path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4128
4466
|
JSON.stringify(metadata, null, 2)
|
|
4129
4467
|
);
|
|
4130
4468
|
return divergedPath;
|
|
@@ -4133,7 +4471,7 @@ var WorktreeSyncService = class {
|
|
|
4133
4471
|
|
|
4134
4472
|
// src/services/file-copy.service.ts
|
|
4135
4473
|
import * as fs7 from "fs/promises";
|
|
4136
|
-
import * as
|
|
4474
|
+
import * as path8 from "path";
|
|
4137
4475
|
import { glob } from "glob";
|
|
4138
4476
|
var DEFAULT_IGNORE_PATTERNS = [
|
|
4139
4477
|
"**/node_modules/**",
|
|
@@ -4160,8 +4498,8 @@ var FileCopyService = class {
|
|
|
4160
4498
|
}
|
|
4161
4499
|
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
4162
4500
|
for (const relativePath of filesToCopy) {
|
|
4163
|
-
const sourcePath =
|
|
4164
|
-
const destPath =
|
|
4501
|
+
const sourcePath = path8.join(sourceDir, relativePath);
|
|
4502
|
+
const destPath = path8.join(destDir, relativePath);
|
|
4165
4503
|
try {
|
|
4166
4504
|
const copied = await this.copyFile(sourcePath, destPath);
|
|
4167
4505
|
if (copied) {
|
|
@@ -4202,7 +4540,7 @@ var FileCopyService = class {
|
|
|
4202
4540
|
return false;
|
|
4203
4541
|
} catch {
|
|
4204
4542
|
}
|
|
4205
|
-
const destDir =
|
|
4543
|
+
const destDir = path8.dirname(destPath);
|
|
4206
4544
|
await fs7.mkdir(destDir, { recursive: true });
|
|
4207
4545
|
await fs7.copyFile(sourcePath, destPath);
|
|
4208
4546
|
return true;
|
|
@@ -4338,7 +4676,7 @@ var HookExecutionService = class {
|
|
|
4338
4676
|
// src/utils/disk-space.ts
|
|
4339
4677
|
import fastFolderSize from "fast-folder-size";
|
|
4340
4678
|
async function calculateDirectorySize(dirPath) {
|
|
4341
|
-
return new Promise((
|
|
4679
|
+
return new Promise((resolve9, reject) => {
|
|
4342
4680
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
4343
4681
|
if (err) {
|
|
4344
4682
|
reject(err);
|
|
@@ -4348,7 +4686,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
4348
4686
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
4349
4687
|
return;
|
|
4350
4688
|
}
|
|
4351
|
-
|
|
4689
|
+
resolve9(bytes);
|
|
4352
4690
|
});
|
|
4353
4691
|
});
|
|
4354
4692
|
}
|
|
@@ -4550,8 +4888,8 @@ var InteractiveUIService = class {
|
|
|
4550
4888
|
getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
|
|
4551
4889
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
4552
4890
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
4553
|
-
openEditorInWorktree: (
|
|
4554
|
-
openTerminalInWorktree: (repoIndex,
|
|
4891
|
+
openEditorInWorktree: (path13) => this.openEditorInWorktree(path13),
|
|
4892
|
+
openTerminalInWorktree: (repoIndex, path13, branchName) => this.openTerminalInWorktree(repoIndex, path13, branchName),
|
|
4555
4893
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
4556
4894
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
4557
4895
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
@@ -4655,7 +4993,7 @@ var InteractiveUIService = class {
|
|
|
4655
4993
|
if (Date.now() - startTime > timeout) {
|
|
4656
4994
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
4657
4995
|
}
|
|
4658
|
-
await new Promise((
|
|
4996
|
+
await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
|
|
4659
4997
|
}
|
|
4660
4998
|
});
|
|
4661
4999
|
try {
|
|
@@ -4788,7 +5126,7 @@ var InteractiveUIService = class {
|
|
|
4788
5126
|
}
|
|
4789
5127
|
const service = this.syncServices[repoIndex];
|
|
4790
5128
|
const worktreeDir = service.config.worktreeDir;
|
|
4791
|
-
const divergedDir =
|
|
5129
|
+
const divergedDir = path9.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4792
5130
|
let dirEntries;
|
|
4793
5131
|
try {
|
|
4794
5132
|
dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
@@ -4798,8 +5136,8 @@ var InteractiveUIService = class {
|
|
|
4798
5136
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
4799
5137
|
const results = await Promise.allSettled(
|
|
4800
5138
|
subdirs.map(async (entry) => {
|
|
4801
|
-
const fullPath =
|
|
4802
|
-
const infoFilePath =
|
|
5139
|
+
const fullPath = path9.join(divergedDir, entry.name);
|
|
5140
|
+
const infoFilePath = path9.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
4803
5141
|
let originalBranch = entry.name;
|
|
4804
5142
|
let divergedAt = "";
|
|
4805
5143
|
try {
|
|
@@ -4834,11 +5172,11 @@ var InteractiveUIService = class {
|
|
|
4834
5172
|
}
|
|
4835
5173
|
const service = this.syncServices[repoIndex];
|
|
4836
5174
|
const worktreeDir = service.config.worktreeDir;
|
|
4837
|
-
const divergedBase =
|
|
5175
|
+
const divergedBase = path9.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4838
5176
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
4839
5177
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
4840
5178
|
}
|
|
4841
|
-
const targetPath =
|
|
5179
|
+
const targetPath = path9.join(divergedBase, name);
|
|
4842
5180
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
4843
5181
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
4844
5182
|
}
|
|
@@ -5227,7 +5565,7 @@ function reconstructCliCommand(config) {
|
|
|
5227
5565
|
|
|
5228
5566
|
// src/utils/config-generator.ts
|
|
5229
5567
|
import * as fs9 from "fs/promises";
|
|
5230
|
-
import * as
|
|
5568
|
+
import * as path10 from "path";
|
|
5231
5569
|
function serializeToESM(obj, indent = 0) {
|
|
5232
5570
|
const spaces = " ".repeat(indent);
|
|
5233
5571
|
const innerSpaces = " ".repeat(indent + 2);
|
|
@@ -5257,9 +5595,9 @@ ${spaces}}`;
|
|
|
5257
5595
|
return String(obj);
|
|
5258
5596
|
}
|
|
5259
5597
|
async function generateConfigFile(config, configPath) {
|
|
5260
|
-
const configDir =
|
|
5598
|
+
const configDir = path10.dirname(configPath);
|
|
5261
5599
|
await fs9.mkdir(configDir, { recursive: true });
|
|
5262
|
-
const worktreeDirRelative =
|
|
5600
|
+
const worktreeDirRelative = path10.relative(configDir, config.worktreeDir);
|
|
5263
5601
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
5264
5602
|
const repoName = extractRepoNameFromUrl(config.repoUrl);
|
|
5265
5603
|
const repository = {
|
|
@@ -5268,7 +5606,7 @@ async function generateConfigFile(config, configPath) {
|
|
|
5268
5606
|
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
|
|
5269
5607
|
};
|
|
5270
5608
|
if (config.bareRepoDir) {
|
|
5271
|
-
const bareRepoDirRelative =
|
|
5609
|
+
const bareRepoDirRelative = path10.relative(configDir, config.bareRepoDir);
|
|
5272
5610
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
5273
5611
|
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
|
|
5274
5612
|
}
|
|
@@ -5289,11 +5627,11 @@ export default ${serializeToESM(configObject)};
|
|
|
5289
5627
|
await fs9.writeFile(configPath, configContent, "utf-8");
|
|
5290
5628
|
}
|
|
5291
5629
|
function getDefaultConfigPath() {
|
|
5292
|
-
return
|
|
5630
|
+
return path10.join(process.cwd(), "sync-worktrees.config.js");
|
|
5293
5631
|
}
|
|
5294
5632
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
5295
5633
|
for (const name of CONFIG_FILE_NAMES) {
|
|
5296
|
-
const full =
|
|
5634
|
+
const full = path10.join(cwd, name);
|
|
5297
5635
|
try {
|
|
5298
5636
|
await fs9.access(full);
|
|
5299
5637
|
return full;
|
|
@@ -5304,7 +5642,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
|
|
|
5304
5642
|
}
|
|
5305
5643
|
|
|
5306
5644
|
// src/utils/interactive.ts
|
|
5307
|
-
import * as
|
|
5645
|
+
import * as path11 from "path";
|
|
5308
5646
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
5309
5647
|
async function promptForConfig(partialConfig) {
|
|
5310
5648
|
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
@@ -5344,8 +5682,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5344
5682
|
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
5345
5683
|
worktreeDir = defaultWorktreeDir;
|
|
5346
5684
|
}
|
|
5347
|
-
if (!
|
|
5348
|
-
worktreeDir =
|
|
5685
|
+
if (!path11.isAbsolute(worktreeDir)) {
|
|
5686
|
+
worktreeDir = path11.resolve(worktreeDir);
|
|
5349
5687
|
}
|
|
5350
5688
|
}
|
|
5351
5689
|
let bareRepoDir = partialConfig.bareRepoDir;
|
|
@@ -5364,8 +5702,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5364
5702
|
return true;
|
|
5365
5703
|
}
|
|
5366
5704
|
});
|
|
5367
|
-
if (!
|
|
5368
|
-
bareRepoDir =
|
|
5705
|
+
if (!path11.isAbsolute(bareRepoDir)) {
|
|
5706
|
+
bareRepoDir = path11.resolve(bareRepoDir);
|
|
5369
5707
|
}
|
|
5370
5708
|
}
|
|
5371
5709
|
let runOnce = partialConfig.runOnce;
|
|
@@ -5437,8 +5775,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5437
5775
|
return true;
|
|
5438
5776
|
}
|
|
5439
5777
|
});
|
|
5440
|
-
if (!
|
|
5441
|
-
configPath =
|
|
5778
|
+
if (!path11.isAbsolute(configPath)) {
|
|
5779
|
+
configPath = path11.resolve(configPath);
|
|
5442
5780
|
}
|
|
5443
5781
|
try {
|
|
5444
5782
|
await generateConfigFile(finalConfig, configPath);
|
|
@@ -5674,7 +6012,7 @@ async function main() {
|
|
|
5674
6012
|
const discovered = await findConfigInCwd();
|
|
5675
6013
|
if (discovered) {
|
|
5676
6014
|
options.config = discovered;
|
|
5677
|
-
console.log(`\u{1F4C4} Using config: ${
|
|
6015
|
+
console.log(`\u{1F4C4} Using config: ${path12.relative(process.cwd(), discovered)}`);
|
|
5678
6016
|
}
|
|
5679
6017
|
}
|
|
5680
6018
|
if (options.config) {
|