sync-worktrees 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/index.js +589 -211
- package/dist/index.js.map +4 -4
- package/dist/mcp-server.js +576 -179
- package/dist/mcp-server.js.map +4 -4
- package/package.json +1 -1
package/dist/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,34 +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/utils/git-url.ts
|
|
1982
|
-
function extractRepoNameFromUrl(gitUrl) {
|
|
1983
|
-
const url = gitUrl.trim();
|
|
1984
|
-
const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
1985
|
-
if (sshMatch) {
|
|
1986
|
-
return sshMatch[1];
|
|
1987
|
-
}
|
|
1988
|
-
const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
1989
|
-
if (sshUrlMatch) {
|
|
1990
|
-
return sshUrlMatch[1];
|
|
1991
|
-
}
|
|
1992
|
-
const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
1993
|
-
if (httpsMatch) {
|
|
1994
|
-
return httpsMatch[1];
|
|
1995
|
-
}
|
|
1996
|
-
const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
1997
|
-
if (fileMatch) {
|
|
1998
|
-
return fileMatch[1];
|
|
1999
|
-
}
|
|
2000
|
-
throw new Error(`Invalid Git URL format: ${gitUrl}`);
|
|
2001
|
-
}
|
|
2002
|
-
function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
2003
|
-
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
2004
|
-
return `${baseDir}/${repoName}`;
|
|
2005
|
-
}
|
|
2177
|
+
import * as path5 from "path";
|
|
2178
|
+
import simpleGit4 from "simple-git";
|
|
2006
2179
|
|
|
2007
2180
|
// src/utils/worktree-list-parser.ts
|
|
2008
2181
|
function parseWorktreeListPorcelain(output) {
|
|
@@ -2144,10 +2317,101 @@ function defaultConsoleOutput(msg, level) {
|
|
|
2144
2317
|
else console.log(msg);
|
|
2145
2318
|
}
|
|
2146
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
|
+
|
|
2147
2411
|
// src/services/worktree-metadata.service.ts
|
|
2148
2412
|
import * as fs2 from "fs/promises";
|
|
2149
|
-
import * as
|
|
2150
|
-
import
|
|
2413
|
+
import * as path3 from "path";
|
|
2414
|
+
import simpleGit2 from "simple-git";
|
|
2151
2415
|
var WorktreeMetadataService = class {
|
|
2152
2416
|
logger;
|
|
2153
2417
|
constructor(logger) {
|
|
@@ -2159,7 +2423,7 @@ var WorktreeMetadataService = class {
|
|
|
2159
2423
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
2160
2424
|
*/
|
|
2161
2425
|
getWorktreeDirectoryName(worktreePath) {
|
|
2162
|
-
return
|
|
2426
|
+
return path3.basename(worktreePath);
|
|
2163
2427
|
}
|
|
2164
2428
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
2165
2429
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -2167,7 +2431,7 @@ var WorktreeMetadataService = class {
|
|
|
2167
2431
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
2168
2432
|
);
|
|
2169
2433
|
}
|
|
2170
|
-
return
|
|
2434
|
+
return path3.join(
|
|
2171
2435
|
bareRepoPath,
|
|
2172
2436
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
2173
2437
|
worktreeName,
|
|
@@ -2180,7 +2444,7 @@ var WorktreeMetadataService = class {
|
|
|
2180
2444
|
}
|
|
2181
2445
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
2182
2446
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2183
|
-
await fs2.mkdir(
|
|
2447
|
+
await fs2.mkdir(path3.dirname(metadataPath), { recursive: true });
|
|
2184
2448
|
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2185
2449
|
let renamed = false;
|
|
2186
2450
|
try {
|
|
@@ -2271,7 +2535,7 @@ var WorktreeMetadataService = class {
|
|
|
2271
2535
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
2272
2536
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
2273
2537
|
try {
|
|
2274
|
-
const worktreeGit =
|
|
2538
|
+
const worktreeGit = simpleGit2(worktreePath);
|
|
2275
2539
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
2276
2540
|
const branchSummary = await worktreeGit.branch();
|
|
2277
2541
|
const actualBranchName = branchSummary.current;
|
|
@@ -2373,47 +2637,8 @@ var WorktreeMetadataService = class {
|
|
|
2373
2637
|
|
|
2374
2638
|
// src/services/worktree-status.service.ts
|
|
2375
2639
|
import * as fs3 from "fs/promises";
|
|
2376
|
-
import * as
|
|
2377
|
-
import
|
|
2378
|
-
|
|
2379
|
-
// src/errors/index.ts
|
|
2380
|
-
var SyncWorktreesError = class extends Error {
|
|
2381
|
-
constructor(message, code, cause) {
|
|
2382
|
-
super(message);
|
|
2383
|
-
this.code = code;
|
|
2384
|
-
this.cause = cause;
|
|
2385
|
-
this.name = this.constructor.name;
|
|
2386
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
2387
|
-
if (cause && cause.stack) {
|
|
2388
|
-
this.stack = `${this.stack}
|
|
2389
|
-
Caused by: ${cause.stack}`;
|
|
2390
|
-
}
|
|
2391
|
-
}
|
|
2392
|
-
};
|
|
2393
|
-
var GitError = class extends SyncWorktreesError {
|
|
2394
|
-
constructor(message, code, cause) {
|
|
2395
|
-
super(message, `GIT_${code}`, cause);
|
|
2396
|
-
}
|
|
2397
|
-
};
|
|
2398
|
-
var GitOperationError = class extends GitError {
|
|
2399
|
-
constructor(operation, details, cause) {
|
|
2400
|
-
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
2401
|
-
}
|
|
2402
|
-
};
|
|
2403
|
-
var WorktreeError = class extends SyncWorktreesError {
|
|
2404
|
-
constructor(message, code, cause) {
|
|
2405
|
-
super(message, `WORKTREE_${code}`, cause);
|
|
2406
|
-
}
|
|
2407
|
-
};
|
|
2408
|
-
var WorktreeNotCleanError = class extends WorktreeError {
|
|
2409
|
-
constructor(path12, reasons) {
|
|
2410
|
-
super(`Worktree at '${path12}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
2411
|
-
this.path = path12;
|
|
2412
|
-
this.reasons = reasons;
|
|
2413
|
-
}
|
|
2414
|
-
};
|
|
2415
|
-
|
|
2416
|
-
// src/services/worktree-status.service.ts
|
|
2640
|
+
import * as path4 from "path";
|
|
2641
|
+
import simpleGit3 from "simple-git";
|
|
2417
2642
|
var OPERATION_FILES = [
|
|
2418
2643
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
2419
2644
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -2615,7 +2840,7 @@ var WorktreeStatusService = class {
|
|
|
2615
2840
|
async detectOperationFile(gitDir) {
|
|
2616
2841
|
const results = await Promise.all(
|
|
2617
2842
|
OPERATION_FILES.map(
|
|
2618
|
-
({ file }) => fs3.access(
|
|
2843
|
+
({ file }) => fs3.access(path4.join(gitDir, file)).then(
|
|
2619
2844
|
() => true,
|
|
2620
2845
|
() => false
|
|
2621
2846
|
)
|
|
@@ -2736,14 +2961,14 @@ var WorktreeStatusService = class {
|
|
|
2736
2961
|
}
|
|
2737
2962
|
}
|
|
2738
2963
|
async resolveGitDir(worktreePath) {
|
|
2739
|
-
const gitPath =
|
|
2964
|
+
const gitPath = path4.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
2740
2965
|
try {
|
|
2741
2966
|
const stat3 = await fs3.stat(gitPath);
|
|
2742
2967
|
if (stat3.isFile()) {
|
|
2743
2968
|
const content = await fs3.readFile(gitPath, "utf-8");
|
|
2744
2969
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
2745
2970
|
if (gitdirMatch) {
|
|
2746
|
-
return
|
|
2971
|
+
return path4.resolve(worktreePath, gitdirMatch[1].trim());
|
|
2747
2972
|
}
|
|
2748
2973
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
2749
2974
|
}
|
|
@@ -2757,10 +2982,10 @@ var WorktreeStatusService = class {
|
|
|
2757
2982
|
}
|
|
2758
2983
|
}
|
|
2759
2984
|
createGitInstance(worktreePath) {
|
|
2760
|
-
const key = `${
|
|
2985
|
+
const key = `${path4.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
2761
2986
|
let git = this.gitInstances.get(key);
|
|
2762
2987
|
if (!git) {
|
|
2763
|
-
git = this.config.skipLfs ?
|
|
2988
|
+
git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
|
|
2764
2989
|
this.gitInstances.set(key, git);
|
|
2765
2990
|
}
|
|
2766
2991
|
return git;
|
|
@@ -2773,9 +2998,10 @@ var GitService = class {
|
|
|
2773
2998
|
this.config = config;
|
|
2774
2999
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
2775
3000
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
2776
|
-
this.mainWorktreePath =
|
|
3001
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
2777
3002
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
2778
3003
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
3004
|
+
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
2779
3005
|
}
|
|
2780
3006
|
git = null;
|
|
2781
3007
|
bareRepoPath;
|
|
@@ -2784,29 +3010,34 @@ var GitService = class {
|
|
|
2784
3010
|
// Will be updated after detection
|
|
2785
3011
|
metadataService;
|
|
2786
3012
|
statusService;
|
|
3013
|
+
sparseCheckoutService;
|
|
2787
3014
|
logger;
|
|
2788
3015
|
lfsSkipOverride = false;
|
|
2789
3016
|
gitInstances = /* @__PURE__ */ new Map();
|
|
3017
|
+
getSparseCheckoutService() {
|
|
3018
|
+
return this.sparseCheckoutService;
|
|
3019
|
+
}
|
|
2790
3020
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
2791
|
-
const key = `${
|
|
3021
|
+
const key = `${path5.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
2792
3022
|
let git = this.gitInstances.get(key);
|
|
2793
3023
|
if (!git) {
|
|
2794
|
-
git = useLfsSkip ?
|
|
3024
|
+
git = useLfsSkip ? simpleGit4(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(dirPath);
|
|
2795
3025
|
this.gitInstances.set(key, git);
|
|
2796
3026
|
}
|
|
2797
3027
|
return git;
|
|
2798
3028
|
}
|
|
2799
3029
|
updateLogger(logger) {
|
|
2800
3030
|
this.logger = logger;
|
|
3031
|
+
this.sparseCheckoutService.updateLogger(logger);
|
|
2801
3032
|
}
|
|
2802
3033
|
async initialize() {
|
|
2803
3034
|
const { repoUrl } = this.config;
|
|
2804
3035
|
try {
|
|
2805
|
-
await fs4.access(
|
|
3036
|
+
await fs4.access(path5.join(this.bareRepoPath, "HEAD"));
|
|
2806
3037
|
} catch {
|
|
2807
3038
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
2808
|
-
await fs4.mkdir(
|
|
2809
|
-
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();
|
|
2810
3041
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
2811
3042
|
this.logger.info("\u2705 Clone successful.");
|
|
2812
3043
|
}
|
|
@@ -2823,34 +3054,39 @@ var GitService = class {
|
|
|
2823
3054
|
this.logger.info("Fetching remote branches...");
|
|
2824
3055
|
await bareGit.fetch(["--all"]);
|
|
2825
3056
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
2826
|
-
this.mainWorktreePath =
|
|
3057
|
+
this.mainWorktreePath = path5.join(this.config.worktreeDir, this.defaultBranch);
|
|
2827
3058
|
let needsMainWorktree = true;
|
|
2828
3059
|
try {
|
|
2829
3060
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2830
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
3061
|
+
needsMainWorktree = !worktrees.some((w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath));
|
|
2831
3062
|
} catch {
|
|
2832
3063
|
}
|
|
2833
3064
|
if (needsMainWorktree) {
|
|
2834
3065
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
2835
3066
|
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2836
|
-
const absoluteWorktreePath =
|
|
3067
|
+
const absoluteWorktreePath = path5.resolve(this.mainWorktreePath);
|
|
2837
3068
|
const branches = await bareGit.branch();
|
|
2838
3069
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
3070
|
+
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
3071
|
+
const noCheckoutFlagMain = useNoCheckoutMain ? ["--no-checkout"] : [];
|
|
2839
3072
|
try {
|
|
2840
3073
|
if (defaultBranchExists) {
|
|
2841
|
-
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
3074
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlagMain, absoluteWorktreePath, this.defaultBranch]);
|
|
2842
3075
|
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
2843
3076
|
await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
|
|
3077
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, false);
|
|
2844
3078
|
} else {
|
|
2845
3079
|
await bareGit.raw([
|
|
2846
3080
|
"worktree",
|
|
2847
3081
|
"add",
|
|
3082
|
+
...noCheckoutFlagMain,
|
|
2848
3083
|
"--track",
|
|
2849
3084
|
"-b",
|
|
2850
3085
|
this.defaultBranch,
|
|
2851
3086
|
absoluteWorktreePath,
|
|
2852
3087
|
`origin/${this.defaultBranch}`
|
|
2853
3088
|
]);
|
|
3089
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, this.defaultBranch, true);
|
|
2854
3090
|
}
|
|
2855
3091
|
} catch (error) {
|
|
2856
3092
|
const errorMessage = getErrorMessage(error);
|
|
@@ -2864,7 +3100,7 @@ var GitService = class {
|
|
|
2864
3100
|
}
|
|
2865
3101
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
2866
3102
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
2867
|
-
(w) =>
|
|
3103
|
+
(w) => path5.resolve(w.path) === path5.resolve(this.mainWorktreePath)
|
|
2868
3104
|
);
|
|
2869
3105
|
if (!mainWorktreeRegistered) {
|
|
2870
3106
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -2933,13 +3169,29 @@ var GitService = class {
|
|
|
2933
3169
|
return branches;
|
|
2934
3170
|
}
|
|
2935
3171
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
2936
|
-
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);
|
|
2937
3173
|
try {
|
|
2938
3174
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
2939
|
-
|
|
3175
|
+
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
2940
3176
|
if (lfsFileList.length === 0) {
|
|
2941
3177
|
return;
|
|
2942
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
|
+
}
|
|
2943
3195
|
if (this.config.debug) {
|
|
2944
3196
|
this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
|
|
2945
3197
|
}
|
|
@@ -2956,7 +3208,7 @@ var GitService = class {
|
|
|
2956
3208
|
let allDownloaded = true;
|
|
2957
3209
|
const notDownloaded = [];
|
|
2958
3210
|
for (const file of samplesToCheck) {
|
|
2959
|
-
const filePath =
|
|
3211
|
+
const filePath = path5.join(worktreePath, file);
|
|
2960
3212
|
try {
|
|
2961
3213
|
const handle = await fs4.open(filePath, "r");
|
|
2962
3214
|
try {
|
|
@@ -2983,7 +3235,7 @@ var GitService = class {
|
|
|
2983
3235
|
}
|
|
2984
3236
|
retries++;
|
|
2985
3237
|
if (retries < maxRetries) {
|
|
2986
|
-
await new Promise((
|
|
3238
|
+
await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
|
|
2987
3239
|
}
|
|
2988
3240
|
}
|
|
2989
3241
|
this.logger.warn(
|
|
@@ -2993,6 +3245,38 @@ var GitService = class {
|
|
|
2993
3245
|
this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
|
|
2994
3246
|
}
|
|
2995
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
|
+
}
|
|
2996
3280
|
async createWorktreeMetadata(bareGit, worktreePath, branchName) {
|
|
2997
3281
|
try {
|
|
2998
3282
|
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
@@ -3013,12 +3297,12 @@ var GitService = class {
|
|
|
3013
3297
|
}
|
|
3014
3298
|
async addWorktree(branchName, worktreePath) {
|
|
3015
3299
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
3016
|
-
const absoluteWorktreePath =
|
|
3017
|
-
await fs4.mkdir(
|
|
3300
|
+
const absoluteWorktreePath = path5.resolve(worktreePath);
|
|
3301
|
+
await fs4.mkdir(path5.dirname(absoluteWorktreePath), { recursive: true });
|
|
3018
3302
|
try {
|
|
3019
3303
|
await fs4.access(absoluteWorktreePath);
|
|
3020
3304
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3021
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3305
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3022
3306
|
if (isValidWorktree) {
|
|
3023
3307
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3024
3308
|
return;
|
|
@@ -3028,25 +3312,21 @@ var GitService = class {
|
|
|
3028
3312
|
}
|
|
3029
3313
|
} catch {
|
|
3030
3314
|
}
|
|
3315
|
+
let createdNewBranch = false;
|
|
3031
3316
|
try {
|
|
3032
|
-
const
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3317
|
+
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
3318
|
+
createdNewBranch = await this.runWorktreeAddByMatrix(
|
|
3319
|
+
bareGit,
|
|
3320
|
+
branchName,
|
|
3321
|
+
absoluteWorktreePath,
|
|
3322
|
+
localBranchExists,
|
|
3323
|
+
remoteBranchExists
|
|
3324
|
+
);
|
|
3325
|
+
if (localBranchExists && !remoteBranchExists) {
|
|
3326
|
+
this.logger.info(` - Created worktree for '${branchName}' (no remote yet \u2014 push to set upstream)`);
|
|
3038
3327
|
} else {
|
|
3039
|
-
|
|
3040
|
-
"worktree",
|
|
3041
|
-
"add",
|
|
3042
|
-
"--track",
|
|
3043
|
-
"-b",
|
|
3044
|
-
branchName,
|
|
3045
|
-
absoluteWorktreePath,
|
|
3046
|
-
`origin/${branchName}`
|
|
3047
|
-
]);
|
|
3328
|
+
this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
|
|
3048
3329
|
}
|
|
3049
|
-
this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
|
|
3050
3330
|
if (!this.isLfsSkipEnabled()) {
|
|
3051
3331
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
3052
3332
|
}
|
|
@@ -3054,20 +3334,20 @@ var GitService = class {
|
|
|
3054
3334
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3055
3335
|
} catch (metadataError) {
|
|
3056
3336
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
3057
|
-
|
|
3058
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3059
|
-
} catch {
|
|
3060
|
-
}
|
|
3337
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, createdNewBranch);
|
|
3061
3338
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3062
3339
|
}
|
|
3063
3340
|
} catch (error) {
|
|
3064
3341
|
const errorMessage = getErrorMessage(error);
|
|
3342
|
+
if (error?.isUpstreamSetupFailure) {
|
|
3343
|
+
throw error;
|
|
3344
|
+
}
|
|
3065
3345
|
if (errorMessage.includes("Metadata creation failed")) {
|
|
3066
3346
|
throw error;
|
|
3067
3347
|
}
|
|
3068
3348
|
if (errorMessage.includes("already registered worktree")) {
|
|
3069
3349
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3070
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3350
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3071
3351
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3072
3352
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
3073
3353
|
return;
|
|
@@ -3078,16 +3358,16 @@ var GitService = class {
|
|
|
3078
3358
|
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3079
3359
|
} catch {
|
|
3080
3360
|
}
|
|
3361
|
+
let retryCreatedNewBranch = false;
|
|
3081
3362
|
try {
|
|
3082
|
-
await
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
"--track",
|
|
3086
|
-
"-b",
|
|
3363
|
+
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
3364
|
+
retryCreatedNewBranch = await this.runWorktreeAddByMatrix(
|
|
3365
|
+
bareGit,
|
|
3087
3366
|
branchName,
|
|
3088
3367
|
absoluteWorktreePath,
|
|
3089
|
-
|
|
3090
|
-
|
|
3368
|
+
localBranchExists,
|
|
3369
|
+
remoteBranchExists
|
|
3370
|
+
);
|
|
3091
3371
|
this.logger.info(` - Created worktree for '${branchName}' after pruning`);
|
|
3092
3372
|
if (!this.isLfsSkipEnabled()) {
|
|
3093
3373
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
@@ -3096,10 +3376,7 @@ var GitService = class {
|
|
|
3096
3376
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3097
3377
|
} catch (metadataError) {
|
|
3098
3378
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
3099
|
-
|
|
3100
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3101
|
-
} catch {
|
|
3102
|
-
}
|
|
3379
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, retryCreatedNewBranch);
|
|
3103
3380
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3104
3381
|
}
|
|
3105
3382
|
return;
|
|
@@ -3116,7 +3393,7 @@ var GitService = class {
|
|
|
3116
3393
|
try {
|
|
3117
3394
|
await fs4.access(absoluteWorktreePath);
|
|
3118
3395
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3119
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3396
|
+
const isValidWorktree = worktrees.some((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3120
3397
|
if (isValidWorktree) {
|
|
3121
3398
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3122
3399
|
return;
|
|
@@ -3127,7 +3404,10 @@ var GitService = class {
|
|
|
3127
3404
|
} catch {
|
|
3128
3405
|
}
|
|
3129
3406
|
try {
|
|
3130
|
-
|
|
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);
|
|
3131
3411
|
this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
|
|
3132
3412
|
if (!this.isLfsSkipEnabled()) {
|
|
3133
3413
|
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
@@ -3136,17 +3416,14 @@ var GitService = class {
|
|
|
3136
3416
|
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
3137
3417
|
} catch (metadataError) {
|
|
3138
3418
|
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
3139
|
-
|
|
3140
|
-
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
3141
|
-
} catch {
|
|
3142
|
-
}
|
|
3419
|
+
await this.rollbackPartialWorktree(bareGit, absoluteWorktreePath, branchName, false);
|
|
3143
3420
|
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
3144
3421
|
}
|
|
3145
3422
|
} catch (fallbackError) {
|
|
3146
3423
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
3147
3424
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
3148
3425
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3149
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3426
|
+
const existingWorktree = worktrees.find((w) => path5.resolve(w.path) === absoluteWorktreePath);
|
|
3150
3427
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3151
3428
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
3152
3429
|
return;
|
|
@@ -3156,6 +3433,65 @@ var GitService = class {
|
|
|
3156
3433
|
}
|
|
3157
3434
|
}
|
|
3158
3435
|
}
|
|
3436
|
+
async runWorktreeAddByMatrix(bareGit, branchName, absoluteWorktreePath, localExists, remoteExists) {
|
|
3437
|
+
const useNoCheckout = !!this.config.sparseCheckout;
|
|
3438
|
+
const noCheckoutFlag = useNoCheckout ? ["--no-checkout"] : [];
|
|
3439
|
+
if (localExists && remoteExists) {
|
|
3440
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
3441
|
+
try {
|
|
3442
|
+
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
3443
|
+
await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
throw await this.wrapUpstreamFailure(bareGit, absoluteWorktreePath, branchName, false, error);
|
|
3446
|
+
}
|
|
3447
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3448
|
+
return false;
|
|
3449
|
+
}
|
|
3450
|
+
if (localExists) {
|
|
3451
|
+
await bareGit.raw(["worktree", "add", ...noCheckoutFlag, absoluteWorktreePath, branchName]);
|
|
3452
|
+
await this.runSparseStepWithRollback(bareGit, absoluteWorktreePath, branchName, false);
|
|
3453
|
+
return false;
|
|
3454
|
+
}
|
|
3455
|
+
if (remoteExists) {
|
|
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;
|
|
3468
|
+
}
|
|
3469
|
+
throw new WorktreeError(
|
|
3470
|
+
`Branch '${branchName}' does not exist locally or on origin; create it first`,
|
|
3471
|
+
"BRANCH_NOT_FOUND"
|
|
3472
|
+
);
|
|
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
|
+
}
|
|
3159
3495
|
async removeWorktree(worktreePath) {
|
|
3160
3496
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3161
3497
|
await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
@@ -3349,10 +3685,18 @@ var GitService = class {
|
|
|
3349
3685
|
}
|
|
3350
3686
|
async branchExists(branchName) {
|
|
3351
3687
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3352
|
-
const
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3688
|
+
const checkRef = async (ref) => {
|
|
3689
|
+
try {
|
|
3690
|
+
await bareGit.raw(["show-ref", "--verify", "--quiet", ref]);
|
|
3691
|
+
return true;
|
|
3692
|
+
} catch {
|
|
3693
|
+
return false;
|
|
3694
|
+
}
|
|
3695
|
+
};
|
|
3696
|
+
const [local, remote] = await Promise.all([
|
|
3697
|
+
checkRef(`${GIT_CONSTANTS.REFS.HEADS}${branchName}`),
|
|
3698
|
+
checkRef(`${GIT_CONSTANTS.REFS.REMOTES}/${branchName}`)
|
|
3699
|
+
]);
|
|
3356
3700
|
return { local, remote };
|
|
3357
3701
|
}
|
|
3358
3702
|
async getLocalBranches() {
|
|
@@ -3386,15 +3730,7 @@ var GitService = class {
|
|
|
3386
3730
|
// src/services/path-resolution.service.ts
|
|
3387
3731
|
import { createHash } from "crypto";
|
|
3388
3732
|
import * as fs5 from "fs";
|
|
3389
|
-
import * as
|
|
3390
|
-
|
|
3391
|
-
// src/utils/path-compare.ts
|
|
3392
|
-
var CASE_INSENSITIVE_PLATFORMS = /* @__PURE__ */ new Set(["darwin"]);
|
|
3393
|
-
function isCaseInsensitiveFs(platform = process.platform) {
|
|
3394
|
-
return CASE_INSENSITIVE_PLATFORMS.has(platform);
|
|
3395
|
-
}
|
|
3396
|
-
|
|
3397
|
-
// src/services/path-resolution.service.ts
|
|
3733
|
+
import * as path6 from "path";
|
|
3398
3734
|
var BRANCH_STEM_MAX = 80;
|
|
3399
3735
|
var BRANCH_HASH_LEN = 8;
|
|
3400
3736
|
var PathResolutionService = class {
|
|
@@ -3404,22 +3740,22 @@ var PathResolutionService = class {
|
|
|
3404
3740
|
return `${stem}-${hash}`;
|
|
3405
3741
|
}
|
|
3406
3742
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3407
|
-
return
|
|
3743
|
+
return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3408
3744
|
}
|
|
3409
3745
|
resolveRealPath(inputPath) {
|
|
3410
|
-
const absolute =
|
|
3746
|
+
const absolute = path6.resolve(inputPath);
|
|
3411
3747
|
const missing = [];
|
|
3412
3748
|
let current = absolute;
|
|
3413
3749
|
while (!fs5.existsSync(current)) {
|
|
3414
|
-
const parent =
|
|
3750
|
+
const parent = path6.dirname(current);
|
|
3415
3751
|
if (parent === current) {
|
|
3416
3752
|
return absolute;
|
|
3417
3753
|
}
|
|
3418
|
-
missing.unshift(
|
|
3754
|
+
missing.unshift(path6.basename(current));
|
|
3419
3755
|
current = parent;
|
|
3420
3756
|
}
|
|
3421
3757
|
try {
|
|
3422
|
-
return
|
|
3758
|
+
return path6.join(fs5.realpathSync(current), ...missing);
|
|
3423
3759
|
} catch {
|
|
3424
3760
|
return absolute;
|
|
3425
3761
|
}
|
|
@@ -3429,7 +3765,7 @@ var PathResolutionService = class {
|
|
|
3429
3765
|
const a = fold(resolved);
|
|
3430
3766
|
const b = fold(resolvedBase);
|
|
3431
3767
|
if (a === b) return true;
|
|
3432
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
3768
|
+
return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
|
|
3433
3769
|
}
|
|
3434
3770
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3435
3771
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -3437,7 +3773,7 @@ var PathResolutionService = class {
|
|
|
3437
3773
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3438
3774
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3439
3775
|
}
|
|
3440
|
-
return
|
|
3776
|
+
return path6.relative(resolvedBase, resolved);
|
|
3441
3777
|
}
|
|
3442
3778
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3443
3779
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -3563,8 +3899,50 @@ var WorktreeSyncService = class {
|
|
|
3563
3899
|
if (this.config.updateExistingWorktrees !== false) {
|
|
3564
3900
|
await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
|
|
3565
3901
|
}
|
|
3902
|
+
if (this.config.sparseCheckout) {
|
|
3903
|
+
await this.reapplySparseCheckout(worktrees);
|
|
3904
|
+
}
|
|
3566
3905
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
3567
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
|
+
}
|
|
3568
3946
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
3569
3947
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
3570
3948
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
@@ -3656,12 +4034,12 @@ var WorktreeSyncService = class {
|
|
|
3656
4034
|
}
|
|
3657
4035
|
const reservedPaths = /* @__PURE__ */ new Map();
|
|
3658
4036
|
for (const w of worktrees) {
|
|
3659
|
-
reservedPaths.set(
|
|
4037
|
+
reservedPaths.set(path7.resolve(w.path), w.branch);
|
|
3660
4038
|
}
|
|
3661
4039
|
const plan = [];
|
|
3662
4040
|
for (const branchName of newBranches) {
|
|
3663
4041
|
const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
|
|
3664
|
-
const resolved =
|
|
4042
|
+
const resolved = path7.resolve(worktreePath);
|
|
3665
4043
|
const conflict = reservedPaths.get(resolved);
|
|
3666
4044
|
if (conflict && conflict !== branchName) {
|
|
3667
4045
|
this.logger.error(
|
|
@@ -3866,12 +4244,12 @@ var WorktreeSyncService = class {
|
|
|
3866
4244
|
}
|
|
3867
4245
|
async updateExistingWorktrees(worktrees, remoteBranches) {
|
|
3868
4246
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
3869
|
-
const divergedDir =
|
|
4247
|
+
const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3870
4248
|
try {
|
|
3871
4249
|
const diverged = await fs6.readdir(divergedDir);
|
|
3872
4250
|
if (diverged.length > 0) {
|
|
3873
4251
|
this.logger.info(
|
|
3874
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
4252
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
|
|
3875
4253
|
);
|
|
3876
4254
|
}
|
|
3877
4255
|
} catch {
|
|
@@ -3979,13 +4357,13 @@ var WorktreeSyncService = class {
|
|
|
3979
4357
|
}
|
|
3980
4358
|
async cleanupOrphanedDirectories(worktrees) {
|
|
3981
4359
|
try {
|
|
3982
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
4360
|
+
const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
|
|
3983
4361
|
const allDirs = await fs6.readdir(this.config.worktreeDir);
|
|
3984
4362
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
3985
4363
|
const orphanedDirs = [];
|
|
3986
4364
|
for (const dir of regularDirs) {
|
|
3987
4365
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
3988
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
4366
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
|
|
3989
4367
|
});
|
|
3990
4368
|
if (!isPartOfWorktree) {
|
|
3991
4369
|
orphanedDirs.push(dir);
|
|
@@ -3994,7 +4372,7 @@ var WorktreeSyncService = class {
|
|
|
3994
4372
|
if (orphanedDirs.length > 0) {
|
|
3995
4373
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
3996
4374
|
for (const dir of orphanedDirs) {
|
|
3997
|
-
const dirPath =
|
|
4375
|
+
const dirPath = path7.join(this.config.worktreeDir, dir);
|
|
3998
4376
|
try {
|
|
3999
4377
|
const stat3 = await fs6.stat(dirPath);
|
|
4000
4378
|
if (stat3.isDirectory()) {
|
|
@@ -4028,7 +4406,7 @@ var WorktreeSyncService = class {
|
|
|
4028
4406
|
} else {
|
|
4029
4407
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
4030
4408
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
4031
|
-
const relativePath =
|
|
4409
|
+
const relativePath = path7.relative(process.cwd(), divergedPath);
|
|
4032
4410
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
4033
4411
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
4034
4412
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -4052,12 +4430,12 @@ var WorktreeSyncService = class {
|
|
|
4052
4430
|
}
|
|
4053
4431
|
}
|
|
4054
4432
|
async divergeWorktree(worktreePath, branchName) {
|
|
4055
|
-
const divergedBaseDir =
|
|
4433
|
+
const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4056
4434
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4057
4435
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4058
4436
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4059
4437
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4060
|
-
const divergedPath =
|
|
4438
|
+
const divergedPath = path7.join(divergedBaseDir, divergedName);
|
|
4061
4439
|
await fs6.mkdir(divergedBaseDir, { recursive: true });
|
|
4062
4440
|
try {
|
|
4063
4441
|
await fs6.rename(worktreePath, divergedPath);
|
|
@@ -4084,7 +4462,7 @@ var WorktreeSyncService = class {
|
|
|
4084
4462
|
Original worktree location: ${worktreePath}`
|
|
4085
4463
|
};
|
|
4086
4464
|
await fs6.writeFile(
|
|
4087
|
-
|
|
4465
|
+
path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4088
4466
|
JSON.stringify(metadata, null, 2)
|
|
4089
4467
|
);
|
|
4090
4468
|
return divergedPath;
|
|
@@ -4093,7 +4471,7 @@ var WorktreeSyncService = class {
|
|
|
4093
4471
|
|
|
4094
4472
|
// src/services/file-copy.service.ts
|
|
4095
4473
|
import * as fs7 from "fs/promises";
|
|
4096
|
-
import * as
|
|
4474
|
+
import * as path8 from "path";
|
|
4097
4475
|
import { glob } from "glob";
|
|
4098
4476
|
var DEFAULT_IGNORE_PATTERNS = [
|
|
4099
4477
|
"**/node_modules/**",
|
|
@@ -4120,8 +4498,8 @@ var FileCopyService = class {
|
|
|
4120
4498
|
}
|
|
4121
4499
|
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
4122
4500
|
for (const relativePath of filesToCopy) {
|
|
4123
|
-
const sourcePath =
|
|
4124
|
-
const destPath =
|
|
4501
|
+
const sourcePath = path8.join(sourceDir, relativePath);
|
|
4502
|
+
const destPath = path8.join(destDir, relativePath);
|
|
4125
4503
|
try {
|
|
4126
4504
|
const copied = await this.copyFile(sourcePath, destPath);
|
|
4127
4505
|
if (copied) {
|
|
@@ -4162,7 +4540,7 @@ var FileCopyService = class {
|
|
|
4162
4540
|
return false;
|
|
4163
4541
|
} catch {
|
|
4164
4542
|
}
|
|
4165
|
-
const destDir =
|
|
4543
|
+
const destDir = path8.dirname(destPath);
|
|
4166
4544
|
await fs7.mkdir(destDir, { recursive: true });
|
|
4167
4545
|
await fs7.copyFile(sourcePath, destPath);
|
|
4168
4546
|
return true;
|
|
@@ -4298,7 +4676,7 @@ var HookExecutionService = class {
|
|
|
4298
4676
|
// src/utils/disk-space.ts
|
|
4299
4677
|
import fastFolderSize from "fast-folder-size";
|
|
4300
4678
|
async function calculateDirectorySize(dirPath) {
|
|
4301
|
-
return new Promise((
|
|
4679
|
+
return new Promise((resolve9, reject) => {
|
|
4302
4680
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
4303
4681
|
if (err) {
|
|
4304
4682
|
reject(err);
|
|
@@ -4308,7 +4686,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
4308
4686
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
4309
4687
|
return;
|
|
4310
4688
|
}
|
|
4311
|
-
|
|
4689
|
+
resolve9(bytes);
|
|
4312
4690
|
});
|
|
4313
4691
|
});
|
|
4314
4692
|
}
|
|
@@ -4510,8 +4888,8 @@ var InteractiveUIService = class {
|
|
|
4510
4888
|
getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
|
|
4511
4889
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
4512
4890
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
4513
|
-
openEditorInWorktree: (
|
|
4514
|
-
openTerminalInWorktree: (repoIndex,
|
|
4891
|
+
openEditorInWorktree: (path13) => this.openEditorInWorktree(path13),
|
|
4892
|
+
openTerminalInWorktree: (repoIndex, path13, branchName) => this.openTerminalInWorktree(repoIndex, path13, branchName),
|
|
4515
4893
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
4516
4894
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
4517
4895
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
@@ -4615,7 +4993,7 @@ var InteractiveUIService = class {
|
|
|
4615
4993
|
if (Date.now() - startTime > timeout) {
|
|
4616
4994
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
4617
4995
|
}
|
|
4618
|
-
await new Promise((
|
|
4996
|
+
await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
|
|
4619
4997
|
}
|
|
4620
4998
|
});
|
|
4621
4999
|
try {
|
|
@@ -4748,7 +5126,7 @@ var InteractiveUIService = class {
|
|
|
4748
5126
|
}
|
|
4749
5127
|
const service = this.syncServices[repoIndex];
|
|
4750
5128
|
const worktreeDir = service.config.worktreeDir;
|
|
4751
|
-
const divergedDir =
|
|
5129
|
+
const divergedDir = path9.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4752
5130
|
let dirEntries;
|
|
4753
5131
|
try {
|
|
4754
5132
|
dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
@@ -4758,8 +5136,8 @@ var InteractiveUIService = class {
|
|
|
4758
5136
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
4759
5137
|
const results = await Promise.allSettled(
|
|
4760
5138
|
subdirs.map(async (entry) => {
|
|
4761
|
-
const fullPath =
|
|
4762
|
-
const infoFilePath =
|
|
5139
|
+
const fullPath = path9.join(divergedDir, entry.name);
|
|
5140
|
+
const infoFilePath = path9.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
4763
5141
|
let originalBranch = entry.name;
|
|
4764
5142
|
let divergedAt = "";
|
|
4765
5143
|
try {
|
|
@@ -4794,11 +5172,11 @@ var InteractiveUIService = class {
|
|
|
4794
5172
|
}
|
|
4795
5173
|
const service = this.syncServices[repoIndex];
|
|
4796
5174
|
const worktreeDir = service.config.worktreeDir;
|
|
4797
|
-
const divergedBase =
|
|
5175
|
+
const divergedBase = path9.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4798
5176
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
4799
5177
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
4800
5178
|
}
|
|
4801
|
-
const targetPath =
|
|
5179
|
+
const targetPath = path9.join(divergedBase, name);
|
|
4802
5180
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
4803
5181
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
4804
5182
|
}
|
|
@@ -5187,7 +5565,7 @@ function reconstructCliCommand(config) {
|
|
|
5187
5565
|
|
|
5188
5566
|
// src/utils/config-generator.ts
|
|
5189
5567
|
import * as fs9 from "fs/promises";
|
|
5190
|
-
import * as
|
|
5568
|
+
import * as path10 from "path";
|
|
5191
5569
|
function serializeToESM(obj, indent = 0) {
|
|
5192
5570
|
const spaces = " ".repeat(indent);
|
|
5193
5571
|
const innerSpaces = " ".repeat(indent + 2);
|
|
@@ -5217,9 +5595,9 @@ ${spaces}}`;
|
|
|
5217
5595
|
return String(obj);
|
|
5218
5596
|
}
|
|
5219
5597
|
async function generateConfigFile(config, configPath) {
|
|
5220
|
-
const configDir =
|
|
5598
|
+
const configDir = path10.dirname(configPath);
|
|
5221
5599
|
await fs9.mkdir(configDir, { recursive: true });
|
|
5222
|
-
const worktreeDirRelative =
|
|
5600
|
+
const worktreeDirRelative = path10.relative(configDir, config.worktreeDir);
|
|
5223
5601
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
5224
5602
|
const repoName = extractRepoNameFromUrl(config.repoUrl);
|
|
5225
5603
|
const repository = {
|
|
@@ -5228,7 +5606,7 @@ async function generateConfigFile(config, configPath) {
|
|
|
5228
5606
|
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
|
|
5229
5607
|
};
|
|
5230
5608
|
if (config.bareRepoDir) {
|
|
5231
|
-
const bareRepoDirRelative =
|
|
5609
|
+
const bareRepoDirRelative = path10.relative(configDir, config.bareRepoDir);
|
|
5232
5610
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
5233
5611
|
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
|
|
5234
5612
|
}
|
|
@@ -5249,11 +5627,11 @@ export default ${serializeToESM(configObject)};
|
|
|
5249
5627
|
await fs9.writeFile(configPath, configContent, "utf-8");
|
|
5250
5628
|
}
|
|
5251
5629
|
function getDefaultConfigPath() {
|
|
5252
|
-
return
|
|
5630
|
+
return path10.join(process.cwd(), "sync-worktrees.config.js");
|
|
5253
5631
|
}
|
|
5254
5632
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
5255
5633
|
for (const name of CONFIG_FILE_NAMES) {
|
|
5256
|
-
const full =
|
|
5634
|
+
const full = path10.join(cwd, name);
|
|
5257
5635
|
try {
|
|
5258
5636
|
await fs9.access(full);
|
|
5259
5637
|
return full;
|
|
@@ -5264,7 +5642,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
|
|
|
5264
5642
|
}
|
|
5265
5643
|
|
|
5266
5644
|
// src/utils/interactive.ts
|
|
5267
|
-
import * as
|
|
5645
|
+
import * as path11 from "path";
|
|
5268
5646
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
5269
5647
|
async function promptForConfig(partialConfig) {
|
|
5270
5648
|
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
@@ -5304,8 +5682,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5304
5682
|
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
5305
5683
|
worktreeDir = defaultWorktreeDir;
|
|
5306
5684
|
}
|
|
5307
|
-
if (!
|
|
5308
|
-
worktreeDir =
|
|
5685
|
+
if (!path11.isAbsolute(worktreeDir)) {
|
|
5686
|
+
worktreeDir = path11.resolve(worktreeDir);
|
|
5309
5687
|
}
|
|
5310
5688
|
}
|
|
5311
5689
|
let bareRepoDir = partialConfig.bareRepoDir;
|
|
@@ -5324,8 +5702,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5324
5702
|
return true;
|
|
5325
5703
|
}
|
|
5326
5704
|
});
|
|
5327
|
-
if (!
|
|
5328
|
-
bareRepoDir =
|
|
5705
|
+
if (!path11.isAbsolute(bareRepoDir)) {
|
|
5706
|
+
bareRepoDir = path11.resolve(bareRepoDir);
|
|
5329
5707
|
}
|
|
5330
5708
|
}
|
|
5331
5709
|
let runOnce = partialConfig.runOnce;
|
|
@@ -5397,8 +5775,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5397
5775
|
return true;
|
|
5398
5776
|
}
|
|
5399
5777
|
});
|
|
5400
|
-
if (!
|
|
5401
|
-
configPath =
|
|
5778
|
+
if (!path11.isAbsolute(configPath)) {
|
|
5779
|
+
configPath = path11.resolve(configPath);
|
|
5402
5780
|
}
|
|
5403
5781
|
try {
|
|
5404
5782
|
await generateConfigFile(finalConfig, configPath);
|
|
@@ -5634,7 +6012,7 @@ async function main() {
|
|
|
5634
6012
|
const discovered = await findConfigInCwd();
|
|
5635
6013
|
if (discovered) {
|
|
5636
6014
|
options.config = discovered;
|
|
5637
|
-
console.log(`\u{1F4C4} Using config: ${
|
|
6015
|
+
console.log(`\u{1F4C4} Using config: ${path12.relative(process.cwd(), discovered)}`);
|
|
5638
6016
|
}
|
|
5639
6017
|
}
|
|
5640
6018
|
if (options.config) {
|