sync-worktrees 4.2.0 → 5.0.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 +133 -55
- package/dist/components/WorktreeStatusView.d.ts.map +1 -1
- package/dist/constants.d.ts +22 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/errors/index.d.ts +7 -0
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2052 -448
- package/dist/index.js.map +4 -4
- package/dist/mcp/context.d.ts +1 -1
- package/dist/mcp/context.d.ts.map +1 -1
- package/dist/mcp/handlers.d.ts +0 -5
- package/dist/mcp/handlers.d.ts.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/worktree-summary.d.ts.map +1 -1
- package/dist/mcp-server.js +2068 -499
- package/dist/mcp-server.js.map +4 -4
- package/dist/services/InteractiveUIService.d.ts.map +1 -1
- package/dist/services/clone-sync.service.d.ts +13 -2
- package/dist/services/clone-sync.service.d.ts.map +1 -1
- package/dist/services/config-loader.service.d.ts +2 -0
- package/dist/services/config-loader.service.d.ts.map +1 -1
- package/dist/services/git-maintenance.service.d.ts +44 -0
- package/dist/services/git-maintenance.service.d.ts.map +1 -0
- package/dist/services/git.service.d.ts +19 -1
- package/dist/services/git.service.d.ts.map +1 -1
- package/dist/services/removal-audit.service.d.ts +19 -0
- package/dist/services/removal-audit.service.d.ts.map +1 -0
- package/dist/services/sync-outcome.d.ts +1 -1
- package/dist/services/sync-outcome.d.ts.map +1 -1
- package/dist/services/trash-migration.service.d.ts +18 -0
- package/dist/services/trash-migration.service.d.ts.map +1 -0
- package/dist/services/trash-reaper.service.d.ts +18 -0
- package/dist/services/trash-reaper.service.d.ts.map +1 -0
- package/dist/services/trash.service.d.ts +91 -0
- package/dist/services/trash.service.d.ts.map +1 -0
- package/dist/services/worktree-metadata.service.d.ts +7 -0
- package/dist/services/worktree-metadata.service.d.ts.map +1 -1
- package/dist/services/worktree-mode-sync-runner.d.ts +11 -1
- package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -1
- package/dist/services/worktree-status.service.d.ts +5 -2
- package/dist/services/worktree-status.service.d.ts.map +1 -1
- package/dist/services/worktree-sync.service.d.ts +16 -0
- package/dist/services/worktree-sync.service.d.ts.map +1 -1
- package/dist/types/index.d.ts +60 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/sync-metadata.d.ts +6 -0
- package/dist/types/sync-metadata.d.ts.map +1 -1
- package/dist/utils/atomic-write.d.ts +2 -0
- package/dist/utils/atomic-write.d.ts.map +1 -0
- package/dist/utils/file-exists.d.ts +2 -0
- package/dist/utils/file-exists.d.ts.map +1 -1
- package/dist/utils/filename-timestamp.d.ts +2 -0
- package/dist/utils/filename-timestamp.d.ts.map +1 -0
- package/dist/utils/lock-path.d.ts +1 -0
- package/dist/utils/lock-path.d.ts.map +1 -1
- package/dist/utils/quarantine.d.ts +2 -0
- package/dist/utils/quarantine.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { realpathSync as realpathSync2 } from "fs";
|
|
5
|
-
import * as
|
|
5
|
+
import * as path23 from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import pLimit4 from "p-limit";
|
|
8
8
|
|
|
@@ -15,6 +15,10 @@ var GIT_CONSTANTS = {
|
|
|
15
15
|
COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
|
|
16
16
|
BARE_DIR_NAME: ".bare",
|
|
17
17
|
DIVERGED_DIR_NAME: ".diverged",
|
|
18
|
+
REMOVED_DIR_NAME: ".removed",
|
|
19
|
+
TRASH_DIR_NAME: ".trash",
|
|
20
|
+
TRASH_REF_PREFIX: "refs/sync-worktrees/trash/",
|
|
21
|
+
KEEP_REF_PREFIX: "refs/sync-worktrees/keep/",
|
|
18
22
|
LFS_HEADER: "version https://git-lfs.github.com/spec/",
|
|
19
23
|
SUBMODULE_STATUS_ADDED: "+",
|
|
20
24
|
SUBMODULE_STATUS_REMOVED: "-",
|
|
@@ -60,7 +64,16 @@ var DEFAULT_CONFIG = {
|
|
|
60
64
|
FETCH_TIMEOUT_MS: 3e5,
|
|
61
65
|
CLONE_TIMEOUT_MS: 9e5,
|
|
62
66
|
LOCK_STALE_MS: 6e5,
|
|
63
|
-
LOCK_UPDATE_MS: 3e4
|
|
67
|
+
LOCK_UPDATE_MS: 3e4,
|
|
68
|
+
MAINTENANCE: {
|
|
69
|
+
ENABLED: true,
|
|
70
|
+
INTERVAL: "7d"
|
|
71
|
+
},
|
|
72
|
+
TRASH: {
|
|
73
|
+
ENABLED: true,
|
|
74
|
+
RETENTION_DAYS: 30,
|
|
75
|
+
MIGRATE_LEGACY: true
|
|
76
|
+
}
|
|
64
77
|
};
|
|
65
78
|
var ERROR_MESSAGES = {
|
|
66
79
|
GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
|
|
@@ -95,6 +108,15 @@ var CONFIG_FILE_NAMES = [
|
|
|
95
108
|
"sync-worktrees.config.mjs",
|
|
96
109
|
"sync-worktrees.config.cjs"
|
|
97
110
|
];
|
|
111
|
+
var MAINTENANCE_CONSTANTS = {
|
|
112
|
+
STATE_FILENAME: "sync-worktrees-maintenance.json"
|
|
113
|
+
};
|
|
114
|
+
var TRASH_CONSTANTS = {
|
|
115
|
+
MANIFEST_FILENAME: "manifest.json",
|
|
116
|
+
PAYLOAD_DIRNAME: "payload",
|
|
117
|
+
BUNDLE_FILENAME: "commits.bundle",
|
|
118
|
+
SCHEMA_VERSION: 1
|
|
119
|
+
};
|
|
98
120
|
var METADATA_CONSTANTS = {
|
|
99
121
|
MAX_HISTORY_ENTRIES: 10,
|
|
100
122
|
METADATA_FILENAME: "sync-metadata.json",
|
|
@@ -154,15 +176,22 @@ var GitOperationError = class extends GitError {
|
|
|
154
176
|
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
155
177
|
}
|
|
156
178
|
};
|
|
179
|
+
var FastForwardError = class extends GitError {
|
|
180
|
+
constructor(branchName, cause) {
|
|
181
|
+
super(`Cannot fast-forward branch '${branchName}'`, "FAST_FORWARD_FAILED", cause);
|
|
182
|
+
this.branchName = branchName;
|
|
183
|
+
}
|
|
184
|
+
branchName;
|
|
185
|
+
};
|
|
157
186
|
var WorktreeError = class extends SyncWorktreesError {
|
|
158
187
|
constructor(message, code, cause) {
|
|
159
188
|
super(message, `WORKTREE_${code}`, cause);
|
|
160
189
|
}
|
|
161
190
|
};
|
|
162
191
|
var WorktreeNotCleanError = class extends WorktreeError {
|
|
163
|
-
constructor(
|
|
164
|
-
super(`Worktree at '${
|
|
165
|
-
this.path =
|
|
192
|
+
constructor(path24, reasons) {
|
|
193
|
+
super(`Worktree at '${path24}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
194
|
+
this.path = path24;
|
|
166
195
|
this.reasons = reasons;
|
|
167
196
|
}
|
|
168
197
|
path;
|
|
@@ -196,6 +225,18 @@ var ConfigFileExistsError = class extends ConfigError {
|
|
|
196
225
|
}
|
|
197
226
|
configPath;
|
|
198
227
|
};
|
|
228
|
+
var TrashError = class extends SyncWorktreesError {
|
|
229
|
+
constructor(message, code, cause) {
|
|
230
|
+
super(message, `TRASH_${code}`, cause);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
var TrashOperationError = class extends TrashError {
|
|
234
|
+
constructor(operation, details, cause) {
|
|
235
|
+
super(`Trash operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
236
|
+
this.operation = operation;
|
|
237
|
+
}
|
|
238
|
+
operation;
|
|
239
|
+
};
|
|
199
240
|
|
|
200
241
|
// src/services/config-loader.service.ts
|
|
201
242
|
import * as path2 from "path";
|
|
@@ -222,16 +263,73 @@ function filterBranchesByName(branches, include, exclude) {
|
|
|
222
263
|
return result;
|
|
223
264
|
}
|
|
224
265
|
|
|
266
|
+
// src/utils/date-filter.ts
|
|
267
|
+
function parseDuration(durationStr) {
|
|
268
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
269
|
+
if (!match) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const value = parseInt(match[1], 10);
|
|
273
|
+
const unit = match[2];
|
|
274
|
+
const multipliers = {
|
|
275
|
+
h: 60 * 60 * 1e3,
|
|
276
|
+
// hours
|
|
277
|
+
d: 24 * 60 * 60 * 1e3,
|
|
278
|
+
// days
|
|
279
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
280
|
+
// weeks
|
|
281
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
282
|
+
// months (approximate)
|
|
283
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
284
|
+
// years (approximate)
|
|
285
|
+
};
|
|
286
|
+
return value * multipliers[unit];
|
|
287
|
+
}
|
|
288
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
289
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
290
|
+
if (maxAgeMs === null) {
|
|
291
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
292
|
+
return branches;
|
|
293
|
+
}
|
|
294
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
295
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
296
|
+
}
|
|
297
|
+
function formatDuration(durationStr) {
|
|
298
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
299
|
+
if (!match) {
|
|
300
|
+
return durationStr;
|
|
301
|
+
}
|
|
302
|
+
const value = parseInt(match[1], 10);
|
|
303
|
+
const unit = match[2];
|
|
304
|
+
const unitNames = {
|
|
305
|
+
h: value === 1 ? "hour" : "hours",
|
|
306
|
+
d: value === 1 ? "day" : "days",
|
|
307
|
+
w: value === 1 ? "week" : "weeks",
|
|
308
|
+
m: value === 1 ? "month" : "months",
|
|
309
|
+
y: value === 1 ? "year" : "years"
|
|
310
|
+
};
|
|
311
|
+
return `${value} ${unitNames[unit]}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
225
314
|
// src/utils/file-exists.ts
|
|
226
315
|
import * as fs from "fs/promises";
|
|
227
|
-
async function fileExists(
|
|
316
|
+
async function fileExists(path24) {
|
|
228
317
|
try {
|
|
229
|
-
await fs.access(
|
|
318
|
+
await fs.access(path24);
|
|
230
319
|
return true;
|
|
231
320
|
} catch {
|
|
232
321
|
return false;
|
|
233
322
|
}
|
|
234
323
|
}
|
|
324
|
+
async function probePathExists(path24) {
|
|
325
|
+
try {
|
|
326
|
+
await fs.access(path24);
|
|
327
|
+
return "exists";
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const code = error.code;
|
|
330
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
235
333
|
|
|
236
334
|
// src/utils/git-url.ts
|
|
237
335
|
function extractRepoNameFromUrl(gitUrl) {
|
|
@@ -319,7 +417,8 @@ var CLONE_MODE_CONFLICTING_FIELDS = [
|
|
|
319
417
|
"branchExclude",
|
|
320
418
|
"branchMaxAge",
|
|
321
419
|
"updateExistingWorktrees",
|
|
322
|
-
"bareRepoDir"
|
|
420
|
+
"bareRepoDir",
|
|
421
|
+
"trash"
|
|
323
422
|
];
|
|
324
423
|
var ConfigLoaderService = class {
|
|
325
424
|
async findConfigUpward(startDir) {
|
|
@@ -422,6 +521,12 @@ var ConfigLoaderService = class {
|
|
|
422
521
|
if (repoObj.sparseCheckout !== void 0) {
|
|
423
522
|
this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
|
|
424
523
|
}
|
|
524
|
+
if (repoObj.maintenance !== void 0) {
|
|
525
|
+
this.validateMaintenanceConfig(repoObj.maintenance, `Repository '${repoObj.name}'`);
|
|
526
|
+
}
|
|
527
|
+
if (repoObj.trash !== void 0) {
|
|
528
|
+
this.validateTrashConfig(repoObj.trash, `Repository '${repoObj.name}'`);
|
|
529
|
+
}
|
|
425
530
|
this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
|
|
426
531
|
this.validateRepositoryMode(repoObj, configObj.defaults);
|
|
427
532
|
});
|
|
@@ -458,6 +563,12 @@ var ConfigLoaderService = class {
|
|
|
458
563
|
if (defaults.sparseCheckout !== void 0) {
|
|
459
564
|
this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
|
|
460
565
|
}
|
|
566
|
+
if (defaults.maintenance !== void 0) {
|
|
567
|
+
this.validateMaintenanceConfig(defaults.maintenance, "defaults");
|
|
568
|
+
}
|
|
569
|
+
if (defaults.trash !== void 0) {
|
|
570
|
+
this.validateTrashConfig(defaults.trash, "defaults");
|
|
571
|
+
}
|
|
461
572
|
this.validateDepth(defaults.depth, "defaults.depth");
|
|
462
573
|
if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
|
|
463
574
|
throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
|
|
@@ -485,6 +596,46 @@ var ConfigLoaderService = class {
|
|
|
485
596
|
throw new ConfigValidationError(field, "must be a positive safe integer");
|
|
486
597
|
}
|
|
487
598
|
}
|
|
599
|
+
validateMaintenanceConfig(value, context) {
|
|
600
|
+
if (value === void 0) return;
|
|
601
|
+
if (typeof value !== "object" || value === null) {
|
|
602
|
+
throw new Error(`'maintenance' in ${context} must be an object`);
|
|
603
|
+
}
|
|
604
|
+
const maintenance = value;
|
|
605
|
+
if (maintenance.enabled !== void 0 && typeof maintenance.enabled !== "boolean") {
|
|
606
|
+
throw new Error(`'maintenance.enabled' in ${context} must be a boolean`);
|
|
607
|
+
}
|
|
608
|
+
if (maintenance.aggressive !== void 0 && typeof maintenance.aggressive !== "boolean") {
|
|
609
|
+
throw new Error(`'maintenance.aggressive' in ${context} must be a boolean`);
|
|
610
|
+
}
|
|
611
|
+
if (maintenance.interval !== void 0) {
|
|
612
|
+
const parsed = typeof maintenance.interval === "string" ? parseDuration(maintenance.interval) : null;
|
|
613
|
+
if (parsed === null || parsed <= 0) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`'maintenance.interval' in ${context} must be a positive duration string like '7d', '24h', or '2w'`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
validateTrashConfig(value, context) {
|
|
621
|
+
if (value === void 0) return;
|
|
622
|
+
if (typeof value !== "object" || value === null) {
|
|
623
|
+
throw new Error(`'trash' in ${context} must be an object`);
|
|
624
|
+
}
|
|
625
|
+
const trash = value;
|
|
626
|
+
if (trash.enabled !== void 0 && typeof trash.enabled !== "boolean") {
|
|
627
|
+
throw new Error(`'trash.enabled' in ${context} must be a boolean`);
|
|
628
|
+
}
|
|
629
|
+
if (trash.migrateLegacy !== void 0 && typeof trash.migrateLegacy !== "boolean") {
|
|
630
|
+
throw new Error(`'trash.migrateLegacy' in ${context} must be a boolean`);
|
|
631
|
+
}
|
|
632
|
+
if (trash.retentionDays !== void 0 && (typeof trash.retentionDays !== "number" || !Number.isFinite(trash.retentionDays) || trash.retentionDays <= 0)) {
|
|
633
|
+
throw new Error(`'trash.retentionDays' in ${context} must be a positive number`);
|
|
634
|
+
}
|
|
635
|
+
if (trash.warnSizeBytes !== void 0 && (typeof trash.warnSizeBytes !== "number" || !Number.isFinite(trash.warnSizeBytes) || trash.warnSizeBytes <= 0)) {
|
|
636
|
+
throw new Error(`'trash.warnSizeBytes' in ${context} must be a positive number`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
488
639
|
validateRetryConfig(value, context) {
|
|
489
640
|
if (typeof value !== "object" || value === null) {
|
|
490
641
|
throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
|
|
@@ -756,6 +907,18 @@ var ConfigLoaderService = class {
|
|
|
756
907
|
if (sparse) {
|
|
757
908
|
resolved.sparseCheckout = sparse;
|
|
758
909
|
}
|
|
910
|
+
if (repo.maintenance || defaults?.maintenance) {
|
|
911
|
+
resolved.maintenance = {
|
|
912
|
+
...defaults?.maintenance || {},
|
|
913
|
+
...repo.maintenance || {}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
if (repo.trash || defaults?.trash) {
|
|
917
|
+
resolved.trash = {
|
|
918
|
+
...defaults?.trash || {},
|
|
919
|
+
...repo.trash || {}
|
|
920
|
+
};
|
|
921
|
+
}
|
|
759
922
|
return resolved;
|
|
760
923
|
}
|
|
761
924
|
isDuplicateRepoUrl(repo, all, defaults) {
|
|
@@ -819,7 +982,7 @@ var ConfigLoaderService = class {
|
|
|
819
982
|
|
|
820
983
|
// src/services/InteractiveUIService.tsx
|
|
821
984
|
import React8 from "react";
|
|
822
|
-
import * as
|
|
985
|
+
import * as path20 from "path";
|
|
823
986
|
import { render } from "ink";
|
|
824
987
|
import * as cron2 from "node-cron";
|
|
825
988
|
import pLimit3 from "p-limit";
|
|
@@ -1512,7 +1675,7 @@ var getStatusFlags = (status) => {
|
|
|
1512
1675
|
}
|
|
1513
1676
|
if (status.hasUnpushedCommits) {
|
|
1514
1677
|
flags.push(
|
|
1515
|
-
/* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "cyan" }, "\u2191")
|
|
1678
|
+
status.fullyPushedUpstreamDeleted ? /* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "green" }, "\u21E1") : /* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "cyan" }, "\u2191")
|
|
1516
1679
|
);
|
|
1517
1680
|
}
|
|
1518
1681
|
if (status.hasStashedChanges) {
|
|
@@ -1545,7 +1708,9 @@ var getStatusSummary = (status) => {
|
|
|
1545
1708
|
if (fileCount > 0) parts.push(`${fileCount} changed`);
|
|
1546
1709
|
}
|
|
1547
1710
|
if (status.hasUnpushedCommits && details?.unpushedCommitCount) {
|
|
1548
|
-
parts.push(
|
|
1711
|
+
parts.push(
|
|
1712
|
+
status.fullyPushedUpstreamDeleted ? "pushed, remote branch deleted" : `${details.unpushedCommitCount} unpushed`
|
|
1713
|
+
);
|
|
1549
1714
|
}
|
|
1550
1715
|
if (status.hasStashedChanges && details?.stashCount) {
|
|
1551
1716
|
parts.push(`${details.stashCount} stash`);
|
|
@@ -1807,7 +1972,7 @@ var WorktreeStatusView = ({
|
|
|
1807
1972
|
const renderDetailPanel = (entry) => {
|
|
1808
1973
|
const { status } = entry;
|
|
1809
1974
|
const details = status.details;
|
|
1810
|
-
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), details && /* @__PURE__ */ React5.createElement(React5.Fragment, null, details.modifiedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified: ", details.modifiedFiles), details.deletedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Deleted: ", details.deletedFiles), details.createdFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " Created: ", details.createdFiles), details.renamedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " Renamed: ", details.renamedFiles), details.untrackedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, " Untracked: ", details.untrackedFiles), details.conflictedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Conflicted: ", details.conflictedFiles), (details.unpushedCommitCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, " Unpushed commits: ", details.unpushedCommitCount), (details.stashCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " Stashes: ", details.stashCount), details.operationType && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Operation: ", details.operationType), details.modifiedSubmodules && details.modifiedSubmodules.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified submodules: ", details.modifiedSubmodules.join(", "))), status.upstreamGone && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Remote branch has been deleted"), status.reasons.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Reasons: ", status.reasons.join(", ")));
|
|
1975
|
+
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), details && /* @__PURE__ */ React5.createElement(React5.Fragment, null, details.modifiedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified: ", details.modifiedFiles), details.deletedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Deleted: ", details.deletedFiles), details.createdFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " Created: ", details.createdFiles), details.renamedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " Renamed: ", details.renamedFiles), details.untrackedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, " Untracked: ", details.untrackedFiles), details.conflictedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Conflicted: ", details.conflictedFiles), (details.unpushedCommitCount ?? 0) > 0 && (status.fullyPushedUpstreamDeleted ? /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " ", "Fully pushed before remote branch deletion (", details.unpushedCommitCount, " commit", details.unpushedCommitCount === 1 ? "" : "s", " not on any remote \u2014 likely squash-merged)") : /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, " Unpushed commits: ", details.unpushedCommitCount)), (details.stashCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " Stashes: ", details.stashCount), details.operationType && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Operation: ", details.operationType), details.modifiedSubmodules && details.modifiedSubmodules.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified submodules: ", details.modifiedSubmodules.join(", "))), status.upstreamGone && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Remote branch has been deleted"), status.reasons.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Reasons: ", status.reasons.join(", ")));
|
|
1811
1976
|
};
|
|
1812
1977
|
const renderDivergedDetailPanel = (entry) => {
|
|
1813
1978
|
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Original branch: ", entry.originalBranch), entry.divergedAt && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Diverged: ", entry.divergedAt), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Size: ", entry.sizeFormatted));
|
|
@@ -2221,6 +2386,31 @@ var App_default = App;
|
|
|
2221
2386
|
// src/services/worktree-sync.service.ts
|
|
2222
2387
|
import pLimit2 from "p-limit";
|
|
2223
2388
|
|
|
2389
|
+
// src/utils/lock-path.ts
|
|
2390
|
+
import { createHash } from "crypto";
|
|
2391
|
+
import * as os from "os";
|
|
2392
|
+
import * as path3 from "path";
|
|
2393
|
+
function getCloneModeLockTarget(config) {
|
|
2394
|
+
const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
2395
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
|
|
2396
|
+
const dir = path3.join(stateBase, "sync-worktrees", "locks");
|
|
2397
|
+
return { dir, file: `${hash}.lock` };
|
|
2398
|
+
}
|
|
2399
|
+
function getRemovalAuditLogPath(config) {
|
|
2400
|
+
const name = config.name;
|
|
2401
|
+
const configDir = config.__configFileDir;
|
|
2402
|
+
const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
2403
|
+
if (configDir) {
|
|
2404
|
+
return path3.join(
|
|
2405
|
+
configDir,
|
|
2406
|
+
".sync-worktrees-state",
|
|
2407
|
+
`${sanitizeNameForPath(name ?? "repo", "removal audit log name")}-${hash}-removals.jsonl`
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
|
|
2411
|
+
return path3.join(stateBase, "sync-worktrees", "removals", `${hash}.jsonl`);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2224
2414
|
// src/utils/retry.ts
|
|
2225
2415
|
var DEFAULT_OPTIONS = {
|
|
2226
2416
|
maxAttempts: "unlimited",
|
|
@@ -2291,7 +2481,7 @@ async function retry(fn, options = {}) {
|
|
|
2291
2481
|
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
2292
2482
|
const delay = baseDelay + jitter;
|
|
2293
2483
|
opts.onRetry(error, attempt, lfsContext);
|
|
2294
|
-
await new Promise((
|
|
2484
|
+
await new Promise((resolve14) => setTimeout(resolve14, delay));
|
|
2295
2485
|
attempt++;
|
|
2296
2486
|
}
|
|
2297
2487
|
}
|
|
@@ -2362,7 +2552,7 @@ var PhaseTimer = class {
|
|
|
2362
2552
|
return results;
|
|
2363
2553
|
}
|
|
2364
2554
|
};
|
|
2365
|
-
function
|
|
2555
|
+
function formatDuration2(ms) {
|
|
2366
2556
|
if (ms < 1e3) {
|
|
2367
2557
|
return `${ms}ms`;
|
|
2368
2558
|
}
|
|
@@ -2384,7 +2574,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2384
2574
|
}
|
|
2385
2575
|
});
|
|
2386
2576
|
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
2387
|
-
table.push(["Total Sync",
|
|
2577
|
+
table.push(["Total Sync", formatDuration2(totalDuration), ""]);
|
|
2388
2578
|
for (let i = 0; i < phaseResults.length; i++) {
|
|
2389
2579
|
const result = phaseResults[i];
|
|
2390
2580
|
const isLast = i === phaseResults.length - 1;
|
|
@@ -2392,14 +2582,14 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2392
2582
|
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
2393
2583
|
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
2394
2584
|
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
2395
|
-
table.push([name,
|
|
2585
|
+
table.push([name, formatDuration2(result.duration), efficiency]);
|
|
2396
2586
|
}
|
|
2397
2587
|
return table.toString();
|
|
2398
2588
|
}
|
|
2399
2589
|
|
|
2400
2590
|
// src/services/clone-sync.service.ts
|
|
2401
2591
|
import * as fs3 from "fs/promises";
|
|
2402
|
-
import * as
|
|
2592
|
+
import * as path5 from "path";
|
|
2403
2593
|
import simpleGit from "simple-git";
|
|
2404
2594
|
|
|
2405
2595
|
// src/utils/git-progress.ts
|
|
@@ -2428,7 +2618,7 @@ function makeGitProgressHandler(logger, emitProgress) {
|
|
|
2428
2618
|
|
|
2429
2619
|
// src/services/file-copy.service.ts
|
|
2430
2620
|
import * as fs2 from "fs/promises";
|
|
2431
|
-
import * as
|
|
2621
|
+
import * as path4 from "path";
|
|
2432
2622
|
import { glob } from "glob";
|
|
2433
2623
|
var DEFAULT_IGNORE_PATTERNS = [
|
|
2434
2624
|
"**/node_modules/**",
|
|
@@ -2455,8 +2645,8 @@ var FileCopyService = class {
|
|
|
2455
2645
|
}
|
|
2456
2646
|
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
2457
2647
|
for (const relativePath of filesToCopy) {
|
|
2458
|
-
const sourcePath =
|
|
2459
|
-
const destPath =
|
|
2648
|
+
const sourcePath = path4.join(sourceDir, relativePath);
|
|
2649
|
+
const destPath = path4.join(destDir, relativePath);
|
|
2460
2650
|
try {
|
|
2461
2651
|
const copied = await this.copyFile(sourcePath, destPath);
|
|
2462
2652
|
if (copied) {
|
|
@@ -2495,7 +2685,7 @@ var FileCopyService = class {
|
|
|
2495
2685
|
if (await fileExists(destPath)) {
|
|
2496
2686
|
return false;
|
|
2497
2687
|
}
|
|
2498
|
-
const destDir =
|
|
2688
|
+
const destDir = path4.dirname(destPath);
|
|
2499
2689
|
await fs2.mkdir(destDir, { recursive: true });
|
|
2500
2690
|
await fs2.copyFile(sourcePath, destPath);
|
|
2501
2691
|
return true;
|
|
@@ -2557,7 +2747,7 @@ var BranchCreatedActionsService = class {
|
|
|
2557
2747
|
function formatCloneSkipReason(reason) {
|
|
2558
2748
|
switch (reason.kind) {
|
|
2559
2749
|
case "branch_mismatch":
|
|
2560
|
-
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
|
|
2750
|
+
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start) \u2014 update 'branch' in the config or switch the clone back` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' \u2014 update 'branch' in the config or switch the clone back`;
|
|
2561
2751
|
case "head_unreadable":
|
|
2562
2752
|
return `could not read HEAD: ${reason.error}`;
|
|
2563
2753
|
case "dirty_tree":
|
|
@@ -2628,14 +2818,14 @@ var SyncOutcomeAccumulator = class {
|
|
|
2628
2818
|
this.actions.push(action);
|
|
2629
2819
|
this.counts[countKeyFor(action)]++;
|
|
2630
2820
|
}
|
|
2631
|
-
recordCreated(branch,
|
|
2632
|
-
this.add({ kind: "created", branch, path:
|
|
2821
|
+
recordCreated(branch, path24) {
|
|
2822
|
+
this.add({ kind: "created", branch, path: path24 });
|
|
2633
2823
|
}
|
|
2634
|
-
recordRemoved(branch,
|
|
2635
|
-
this.add({ kind: "removed", branch, path:
|
|
2824
|
+
recordRemoved(branch, path24, warning) {
|
|
2825
|
+
this.add({ kind: "removed", branch, path: path24, ...warning !== void 0 && { warning } });
|
|
2636
2826
|
}
|
|
2637
|
-
recordUpdated(branch,
|
|
2638
|
-
this.add({ kind: "updated", branch, path:
|
|
2827
|
+
recordUpdated(branch, path24, reason) {
|
|
2828
|
+
this.add({ kind: "updated", branch, path: path24, reason });
|
|
2639
2829
|
}
|
|
2640
2830
|
recordNoop(scope, reason, details) {
|
|
2641
2831
|
this.add({ kind: "noop", scope, reason, ...details });
|
|
@@ -2643,8 +2833,8 @@ var SyncOutcomeAccumulator = class {
|
|
|
2643
2833
|
recordSkipped(scope, reason, details) {
|
|
2644
2834
|
this.add({ kind: "skipped", scope, reason, ...details });
|
|
2645
2835
|
}
|
|
2646
|
-
recordPreservedDiverged(branch,
|
|
2647
|
-
this.add({ kind: "preserved-diverged", branch, path:
|
|
2836
|
+
recordPreservedDiverged(branch, path24, preservedPath) {
|
|
2837
|
+
this.add({ kind: "preserved-diverged", branch, path: path24, preservedPath });
|
|
2648
2838
|
}
|
|
2649
2839
|
recordFailed(scope, error, details = {}) {
|
|
2650
2840
|
this.add({ kind: "failed", scope, error, ...details });
|
|
@@ -2687,7 +2877,6 @@ function cloneSkipToOutcomeAction(reason, details = {}) {
|
|
|
2687
2877
|
}
|
|
2688
2878
|
|
|
2689
2879
|
// src/services/clone-sync.service.ts
|
|
2690
|
-
var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
2691
2880
|
var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
|
|
2692
2881
|
var CloneSyncService = class {
|
|
2693
2882
|
constructor(config, gitService, logger, options = {}) {
|
|
@@ -2721,8 +2910,8 @@ var CloneSyncService = class {
|
|
|
2721
2910
|
this.pendingInitSkip = null;
|
|
2722
2911
|
}
|
|
2723
2912
|
async getWorktrees() {
|
|
2724
|
-
const worktreeDir =
|
|
2725
|
-
if (!await fileExists(
|
|
2913
|
+
const worktreeDir = path5.resolve(this.config.worktreeDir);
|
|
2914
|
+
if (!await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
|
|
2726
2915
|
return [];
|
|
2727
2916
|
}
|
|
2728
2917
|
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
@@ -2806,40 +2995,27 @@ var CloneSyncService = class {
|
|
|
2806
2995
|
return env;
|
|
2807
2996
|
}
|
|
2808
2997
|
buildCloneArgs(branch) {
|
|
2809
|
-
const args = ["--branch", branch, "--progress"];
|
|
2998
|
+
const args = ["--branch", branch, "--single-branch", "--no-tags", "--progress"];
|
|
2810
2999
|
if (this.config.depth !== void 0) {
|
|
2811
|
-
args.push("--depth", String(this.config.depth)
|
|
3000
|
+
args.push("--depth", String(this.config.depth));
|
|
2812
3001
|
}
|
|
2813
3002
|
return args;
|
|
2814
3003
|
}
|
|
2815
|
-
|
|
2816
|
-
|
|
3004
|
+
getBranchRefspec(branch) {
|
|
3005
|
+
return `+refs/heads/${branch}:refs/remotes/origin/${branch}`;
|
|
3006
|
+
}
|
|
3007
|
+
async buildFetchArgs(git, branch) {
|
|
3008
|
+
const args = ["origin", "--prune", "--no-tags", "--progress"];
|
|
2817
3009
|
if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
|
|
2818
3010
|
args.push("--depth", String(this.config.depth));
|
|
2819
3011
|
}
|
|
3012
|
+
args.push(this.getBranchRefspec(branch));
|
|
2820
3013
|
return args;
|
|
2821
3014
|
}
|
|
2822
|
-
async
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2827
|
-
} catch {
|
|
2828
|
-
fetchRefspecs = [];
|
|
2829
|
-
}
|
|
2830
|
-
if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
|
|
2831
|
-
const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
|
|
2832
|
-
this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
|
|
2833
|
-
await git.raw(["remote", "set-branches", "origin", "*"]);
|
|
2834
|
-
for (const refspec of customRefspecs) {
|
|
2835
|
-
await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
|
|
2836
|
-
}
|
|
2837
|
-
}
|
|
2838
|
-
isOriginRemoteBranchTrackingRefspec(refspec) {
|
|
2839
|
-
const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
|
|
2840
|
-
if (withoutForce.startsWith("^")) return false;
|
|
2841
|
-
const [source, destination] = withoutForce.split(":");
|
|
2842
|
-
return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
|
|
3015
|
+
async configureSingleBranchRemote(git, branch) {
|
|
3016
|
+
await git.raw(["config", "--replace-all", "remote.origin.fetch", this.getBranchRefspec(branch)]);
|
|
3017
|
+
await git.raw(["config", "--replace-all", "remote.origin.tagOpt", "--no-tags"]);
|
|
3018
|
+
await this.deleteStaleRemoteTrackingRefs(git, branch);
|
|
2843
3019
|
}
|
|
2844
3020
|
recordMissingRemoteRefSkip(branch) {
|
|
2845
3021
|
this.recordSkip(
|
|
@@ -2848,7 +3024,10 @@ var CloneSyncService = class {
|
|
|
2848
3024
|
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
2849
3025
|
);
|
|
2850
3026
|
}
|
|
2851
|
-
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
|
|
3027
|
+
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch, recordSkip = true) {
|
|
3028
|
+
const recordMissing = () => {
|
|
3029
|
+
if (recordSkip) this.recordMissingRemoteRefSkip(branch);
|
|
3030
|
+
};
|
|
2852
3031
|
try {
|
|
2853
3032
|
await git.fetch(fetchArgs);
|
|
2854
3033
|
return { skipped: false };
|
|
@@ -2865,14 +3044,14 @@ var CloneSyncService = class {
|
|
|
2865
3044
|
return { skipped: false };
|
|
2866
3045
|
} catch (retryError) {
|
|
2867
3046
|
if (isMissingRemoteRefError(getErrorMessage(retryError))) {
|
|
2868
|
-
|
|
3047
|
+
recordMissing();
|
|
2869
3048
|
return { skipped: true };
|
|
2870
3049
|
}
|
|
2871
3050
|
throw retryError;
|
|
2872
3051
|
}
|
|
2873
3052
|
}
|
|
2874
3053
|
if (isMissingRemoteRefError(message)) {
|
|
2875
|
-
|
|
3054
|
+
recordMissing();
|
|
2876
3055
|
return { skipped: true };
|
|
2877
3056
|
}
|
|
2878
3057
|
throw fetchError;
|
|
@@ -2900,7 +3079,7 @@ var CloneSyncService = class {
|
|
|
2900
3079
|
this.logger.info(
|
|
2901
3080
|
`[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
|
|
2902
3081
|
);
|
|
2903
|
-
await git.fetch(["--unshallow"]);
|
|
3082
|
+
await git.fetch(["--unshallow", "--no-tags"]);
|
|
2904
3083
|
}
|
|
2905
3084
|
getDeepenTargets() {
|
|
2906
3085
|
const configuredDepth = this.config.depth;
|
|
@@ -2920,8 +3099,9 @@ var CloneSyncService = class {
|
|
|
2920
3099
|
"--depth",
|
|
2921
3100
|
String(targetDepth),
|
|
2922
3101
|
"--prune",
|
|
3102
|
+
"--no-tags",
|
|
2923
3103
|
"--progress",
|
|
2924
|
-
|
|
3104
|
+
this.getBranchRefspec(branch)
|
|
2925
3105
|
]);
|
|
2926
3106
|
}
|
|
2927
3107
|
async resolveBranch() {
|
|
@@ -2938,6 +3118,153 @@ var CloneSyncService = class {
|
|
|
2938
3118
|
this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
|
|
2939
3119
|
return this.resolvedBranch;
|
|
2940
3120
|
}
|
|
3121
|
+
parseLsRemoteHeads(output) {
|
|
3122
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => line.split(/\s+/)[1] ?? "").filter((ref) => ref.startsWith("refs/heads/")).map((ref) => ref.slice("refs/heads/".length)).filter((branch) => branch.length > 0);
|
|
3123
|
+
}
|
|
3124
|
+
async getRemoteBranches() {
|
|
3125
|
+
const worktreeDir = path5.resolve(this.config.worktreeDir);
|
|
3126
|
+
const repoArg = await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR)) ? "origin" : this.config.repoUrl;
|
|
3127
|
+
const git = repoArg === "origin" ? this.clientFor(worktreeDir, this.getFetchTimeoutMs()) : simpleGit(this.buildGitOptions(this.getFetchTimeoutMs())).env(this.buildGitEnv());
|
|
3128
|
+
const output = await git.raw(["ls-remote", "--heads", repoArg]);
|
|
3129
|
+
return this.parseLsRemoteHeads(output);
|
|
3130
|
+
}
|
|
3131
|
+
async localBranchExists(git, branch) {
|
|
3132
|
+
try {
|
|
3133
|
+
await git.raw(["show-ref", "--verify", `refs/heads/${branch}`]);
|
|
3134
|
+
return true;
|
|
3135
|
+
} catch {
|
|
3136
|
+
return false;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
async localBranchCanFastForward(git, branch) {
|
|
3140
|
+
const localRef = `refs/heads/${branch}`;
|
|
3141
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
3142
|
+
let localSha;
|
|
3143
|
+
let remoteSha;
|
|
3144
|
+
try {
|
|
3145
|
+
localSha = (await git.raw(["rev-parse", localRef])).trim();
|
|
3146
|
+
remoteSha = (await git.raw(["rev-parse", remoteRef])).trim();
|
|
3147
|
+
} catch {
|
|
3148
|
+
return false;
|
|
3149
|
+
}
|
|
3150
|
+
if (localSha === remoteSha) return true;
|
|
3151
|
+
try {
|
|
3152
|
+
const mergeBase = (await git.raw(["merge-base", localRef, remoteRef])).trim();
|
|
3153
|
+
return mergeBase === localSha;
|
|
3154
|
+
} catch {
|
|
3155
|
+
return false;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
async deleteRemoteTrackingRef(git, refName) {
|
|
3159
|
+
try {
|
|
3160
|
+
await git.raw(["update-ref", "-d", refName]);
|
|
3161
|
+
} catch {
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
async deleteStaleRemoteTrackingRefs(git, branch) {
|
|
3165
|
+
let refsOutput;
|
|
3166
|
+
try {
|
|
3167
|
+
refsOutput = await git.raw(["for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
|
|
3168
|
+
} catch {
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
const keepRef = `refs/remotes/origin/${branch}`;
|
|
3172
|
+
const refsToDelete = refsOutput.split(/\r?\n/).map((ref) => ref.trim()).filter((ref) => ref && ref !== keepRef && ref !== "refs/remotes/origin/HEAD");
|
|
3173
|
+
for (const ref of refsToDelete) {
|
|
3174
|
+
await this.deleteRemoteTrackingRef(git, ref);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
async restoreBranchAfterCheckoutFailure(git, previousBranch, attemptedBranch) {
|
|
3178
|
+
if (!previousBranch || previousBranch === "HEAD" || previousBranch === attemptedBranch) return;
|
|
3179
|
+
try {
|
|
3180
|
+
await git.raw(["switch", previousBranch]);
|
|
3181
|
+
} catch (error) {
|
|
3182
|
+
this.logger.warn(
|
|
3183
|
+
`Failed to restore '${this.repoName}' to '${previousBranch}' after checkout failure: ${getErrorMessage(error)}`
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
async checkoutBranch(branch, options = {}) {
|
|
3188
|
+
if (!this.initialized) {
|
|
3189
|
+
await this.initialize();
|
|
3190
|
+
}
|
|
3191
|
+
const targetBranch = await this.resolveBranch();
|
|
3192
|
+
if (branch !== targetBranch && !options.allowConfigDrift) {
|
|
3193
|
+
throw new ConfigError(
|
|
3194
|
+
this.config.branch ? `Cannot switch '${this.repoName}' to '${branch}': clone mode tracks the configured branch '${targetBranch}'. Update 'branch' in the config file first, then run checkout to converge.` : `Cannot switch '${this.repoName}' to '${branch}': no 'branch' is configured, so this clone tracks the remote default branch '${targetBranch}'. Set branch: "${branch}" in the config file first.`,
|
|
3195
|
+
"CLONE_BRANCH_MISMATCH"
|
|
3196
|
+
);
|
|
3197
|
+
}
|
|
3198
|
+
const worktreeDir = this.config.worktreeDir;
|
|
3199
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
3200
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
3201
|
+
if (originMismatch) {
|
|
3202
|
+
throw new ConfigError(
|
|
3203
|
+
`Cannot switch '${this.repoName}' to '${branch}': ${originMismatch.progressDetail}.`,
|
|
3204
|
+
"ORIGIN_MISMATCH"
|
|
3205
|
+
);
|
|
3206
|
+
}
|
|
3207
|
+
const currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
3208
|
+
if (currentBranch === "HEAD") {
|
|
3209
|
+
throw new GitOperationError(
|
|
3210
|
+
"checkout",
|
|
3211
|
+
`'${this.repoName}' is on a detached HEAD; check out a branch manually (preserving any local commits) before switching the tracked branch`
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
if (currentBranch === branch) {
|
|
3215
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
3216
|
+
this.resolvedBranch = branch;
|
|
3217
|
+
this.pendingInitSkip = null;
|
|
3218
|
+
this.warnConfigDriftAfterCheckout(branch, targetBranch);
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
|
|
3222
|
+
if (!isClean) {
|
|
3223
|
+
throw new WorktreeNotCleanError(worktreeDir, ["working tree has local changes"]);
|
|
3224
|
+
}
|
|
3225
|
+
await this.unshallowIfDepthRemoved(git);
|
|
3226
|
+
const fetchArgs = await this.buildFetchArgs(git, branch);
|
|
3227
|
+
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch, false)).skipped) {
|
|
3228
|
+
throw new GitOperationError("checkout", `origin/${branch} is missing for '${this.repoName}'`);
|
|
3229
|
+
}
|
|
3230
|
+
if (!await this.hasRemoteBranch(git, branch)) {
|
|
3231
|
+
throw new GitOperationError(
|
|
3232
|
+
"checkout",
|
|
3233
|
+
`origin/${branch} did not materialize after fetch for '${this.repoName}'`
|
|
3234
|
+
);
|
|
3235
|
+
}
|
|
3236
|
+
if (await this.localBranchExists(git, branch)) {
|
|
3237
|
+
if (!await this.localBranchCanFastForward(git, branch)) {
|
|
3238
|
+
throw new FastForwardError(branch);
|
|
3239
|
+
}
|
|
3240
|
+
let switched = false;
|
|
3241
|
+
try {
|
|
3242
|
+
await git.raw(["switch", branch]);
|
|
3243
|
+
switched = true;
|
|
3244
|
+
await git.merge([`origin/${branch}`, "--ff-only"]);
|
|
3245
|
+
} catch (error) {
|
|
3246
|
+
if (switched) {
|
|
3247
|
+
await this.restoreBranchAfterCheckoutFailure(git, currentBranch, branch);
|
|
3248
|
+
}
|
|
3249
|
+
throw error;
|
|
3250
|
+
}
|
|
3251
|
+
} else {
|
|
3252
|
+
await git.raw(["switch", "-c", branch, "--track", `origin/${branch}`]);
|
|
3253
|
+
}
|
|
3254
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
3255
|
+
this.resolvedBranch = branch;
|
|
3256
|
+
this.pendingInitSkip = null;
|
|
3257
|
+
this.warnConfigDriftAfterCheckout(branch, targetBranch);
|
|
3258
|
+
}
|
|
3259
|
+
// resolvedBranch keeps in-session syncs on the new branch, but the config
|
|
3260
|
+
// file still names the old one: the next process start will soft-skip with
|
|
3261
|
+
// branch_mismatch on every tick until the config is updated.
|
|
3262
|
+
warnConfigDriftAfterCheckout(branch, targetBranch) {
|
|
3263
|
+
if (branch === targetBranch) return;
|
|
3264
|
+
this.logger.warn(
|
|
3265
|
+
`\u26A0\uFE0F '${this.repoName}' now tracks '${branch}', but the config ${this.config.branch ? `still says branch '${targetBranch}'` : `resolves the remote default '${targetBranch}'`}. Set branch: "${branch}" in the config file \u2014 after a restart every sync will soft-skip with branch_mismatch until it matches.`
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
2941
3268
|
async initialize(outcome) {
|
|
2942
3269
|
return this.withOutcome(outcome, () => this.initializeInternal());
|
|
2943
3270
|
}
|
|
@@ -2961,7 +3288,7 @@ var CloneSyncService = class {
|
|
|
2961
3288
|
return;
|
|
2962
3289
|
}
|
|
2963
3290
|
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2964
|
-
await this.
|
|
3291
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
2965
3292
|
this.initialized = true;
|
|
2966
3293
|
this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
|
|
2967
3294
|
return;
|
|
@@ -2989,7 +3316,7 @@ var CloneSyncService = class {
|
|
|
2989
3316
|
throw error;
|
|
2990
3317
|
}
|
|
2991
3318
|
const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2992
|
-
await this.
|
|
3319
|
+
await this.configureSingleBranchRemote(worktreeGit, branch);
|
|
2993
3320
|
this.logger.info(`\u2705 Clone successful.`);
|
|
2994
3321
|
this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
|
|
2995
3322
|
if (this.config.sparseCheckout) {
|
|
@@ -3077,7 +3404,7 @@ var CloneSyncService = class {
|
|
|
3077
3404
|
return;
|
|
3078
3405
|
}
|
|
3079
3406
|
const looksIncomplete = entries.every((e) => e.startsWith("."));
|
|
3080
|
-
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(
|
|
3407
|
+
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
|
|
3081
3408
|
if (looksIncomplete && !hasUsableGit) {
|
|
3082
3409
|
try {
|
|
3083
3410
|
await fs3.rm(worktreeDir, { recursive: true, force: true });
|
|
@@ -3092,7 +3419,7 @@ var CloneSyncService = class {
|
|
|
3092
3419
|
}
|
|
3093
3420
|
}
|
|
3094
3421
|
getInitMarkerPath(worktreeDir) {
|
|
3095
|
-
return
|
|
3422
|
+
return path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
|
|
3096
3423
|
}
|
|
3097
3424
|
async runInitialFileCopy(worktreeDir, branch) {
|
|
3098
3425
|
const marker = this.getInitMarkerPath(worktreeDir);
|
|
@@ -3144,7 +3471,7 @@ var CloneSyncService = class {
|
|
|
3144
3471
|
if (currentBranch !== branch) {
|
|
3145
3472
|
this.recordSkip(
|
|
3146
3473
|
{ kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
|
|
3147
|
-
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
|
|
3474
|
+
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge. Update 'branch' in the config or switch the clone back.`,
|
|
3148
3475
|
`Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
|
|
3149
3476
|
);
|
|
3150
3477
|
return;
|
|
@@ -3159,13 +3486,13 @@ var CloneSyncService = class {
|
|
|
3159
3486
|
return;
|
|
3160
3487
|
}
|
|
3161
3488
|
await this.unshallowIfDepthRemoved(git);
|
|
3162
|
-
await this.
|
|
3163
|
-
const fetchArgs = await this.buildFetchArgs(git);
|
|
3164
|
-
this.emitProgress({ phase: "fetch", message: `Fetching origin
|
|
3489
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
3490
|
+
const fetchArgs = await this.buildFetchArgs(git, branch);
|
|
3491
|
+
this.emitProgress({ phase: "fetch", message: `Fetching origin/${branch} for '${this.repoName}'` });
|
|
3165
3492
|
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
|
|
3166
3493
|
return;
|
|
3167
3494
|
}
|
|
3168
|
-
this.emitProgress({ phase: "fetch", message: `Fetched origin
|
|
3495
|
+
this.emitProgress({ phase: "fetch", message: `Fetched origin/${branch} for '${this.repoName}'` });
|
|
3169
3496
|
if (!await this.hasRemoteBranch(git, branch)) {
|
|
3170
3497
|
this.recordSkip(
|
|
3171
3498
|
{ kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
|
|
@@ -3255,50 +3582,39 @@ var CloneSyncService = class {
|
|
|
3255
3582
|
}
|
|
3256
3583
|
};
|
|
3257
3584
|
|
|
3258
|
-
// src/services/git.service.ts
|
|
3259
|
-
import * as
|
|
3260
|
-
import * as
|
|
3261
|
-
import
|
|
3585
|
+
// src/services/git-maintenance.service.ts
|
|
3586
|
+
import * as fs5 from "fs/promises";
|
|
3587
|
+
import * as path6 from "path";
|
|
3588
|
+
import simpleGit2 from "simple-git";
|
|
3262
3589
|
|
|
3263
|
-
// src/utils/
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3590
|
+
// src/utils/atomic-write.ts
|
|
3591
|
+
import * as fs4 from "fs/promises";
|
|
3592
|
+
async function atomicWriteFile(filePath, content) {
|
|
3593
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3594
|
+
let renamed = false;
|
|
3595
|
+
try {
|
|
3596
|
+
const handle = await fs4.open(tmpPath, "w");
|
|
3597
|
+
try {
|
|
3598
|
+
await handle.writeFile(content, "utf-8");
|
|
3599
|
+
await handle.sync();
|
|
3600
|
+
} finally {
|
|
3601
|
+
await handle.close();
|
|
3271
3602
|
}
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
if (
|
|
3284
|
-
|
|
3285
|
-
current.path = line.substring("worktree ".length);
|
|
3286
|
-
} else if (line.startsWith("branch ")) {
|
|
3287
|
-
current.branch = line.substring("branch ".length).replace("refs/heads/", "");
|
|
3288
|
-
} else if (line.startsWith("HEAD ")) {
|
|
3289
|
-
current.head = line.substring("HEAD ".length);
|
|
3290
|
-
} else if (line === "detached") {
|
|
3291
|
-
current.detached = true;
|
|
3292
|
-
} else if (line === "prunable" || line.startsWith("prunable ")) {
|
|
3293
|
-
current.prunable = true;
|
|
3294
|
-
} else if (line === "locked" || line.startsWith("locked ")) {
|
|
3295
|
-
current.locked = true;
|
|
3296
|
-
} else if (line.trim() === "") {
|
|
3297
|
-
flush();
|
|
3603
|
+
try {
|
|
3604
|
+
await fs4.rename(tmpPath, filePath);
|
|
3605
|
+
renamed = true;
|
|
3606
|
+
} catch (err) {
|
|
3607
|
+
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
3608
|
+
await fs4.copyFile(tmpPath, filePath);
|
|
3609
|
+
} else {
|
|
3610
|
+
throw err;
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
} finally {
|
|
3614
|
+
if (!renamed) {
|
|
3615
|
+
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
3298
3616
|
}
|
|
3299
3617
|
}
|
|
3300
|
-
flush();
|
|
3301
|
-
return worktrees;
|
|
3302
3618
|
}
|
|
3303
3619
|
|
|
3304
3620
|
// src/services/logger.service.ts
|
|
@@ -3400,17 +3716,198 @@ function defaultConsoleOutput(msg, level) {
|
|
|
3400
3716
|
else console.log(msg);
|
|
3401
3717
|
}
|
|
3402
3718
|
|
|
3403
|
-
// src/services/
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3719
|
+
// src/services/git-maintenance.service.ts
|
|
3720
|
+
var GitMaintenanceService = class {
|
|
3721
|
+
constructor(config, gitService, logger, gitFactory = (cwd) => simpleGit2(cwd)) {
|
|
3722
|
+
this.config = config;
|
|
3723
|
+
this.gitService = gitService;
|
|
3724
|
+
this.logger = logger ?? Logger.createDefault();
|
|
3725
|
+
this.gitFactory = gitFactory;
|
|
3726
|
+
}
|
|
3727
|
+
config;
|
|
3728
|
+
gitService;
|
|
3729
|
+
logger;
|
|
3730
|
+
gitFactory;
|
|
3731
|
+
updateLogger(logger) {
|
|
3732
|
+
this.logger = logger;
|
|
3733
|
+
}
|
|
3734
|
+
isEnabled() {
|
|
3735
|
+
return this.config.maintenance?.enabled ?? DEFAULT_CONFIG.MAINTENANCE.ENABLED;
|
|
3736
|
+
}
|
|
3737
|
+
getIntervalMs() {
|
|
3738
|
+
const fallback = parseDuration(DEFAULT_CONFIG.MAINTENANCE.INTERVAL);
|
|
3739
|
+
const raw = this.config.maintenance?.interval;
|
|
3740
|
+
if (raw === void 0) {
|
|
3741
|
+
return fallback;
|
|
3742
|
+
}
|
|
3743
|
+
const parsed = parseDuration(raw);
|
|
3744
|
+
if (parsed === null || parsed <= 0) {
|
|
3745
|
+
this.logger.warn(`Invalid maintenance.interval '${raw}', using default ${DEFAULT_CONFIG.MAINTENANCE.INTERVAL}.`);
|
|
3746
|
+
return fallback;
|
|
3747
|
+
}
|
|
3748
|
+
return parsed;
|
|
3749
|
+
}
|
|
3750
|
+
resolveTarget() {
|
|
3751
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
3752
|
+
const cwd = path6.resolve(this.config.worktreeDir);
|
|
3753
|
+
return { cwd, gitDir: path6.join(cwd, PATH_CONSTANTS.GIT_DIR) };
|
|
3754
|
+
}
|
|
3755
|
+
const bare = this.gitService.getBareRepoPath();
|
|
3756
|
+
return { cwd: bare, gitDir: bare };
|
|
3757
|
+
}
|
|
3758
|
+
getStatePath(gitDir) {
|
|
3759
|
+
return path6.join(gitDir, MAINTENANCE_CONSTANTS.STATE_FILENAME);
|
|
3760
|
+
}
|
|
3761
|
+
async readState(statePath) {
|
|
3762
|
+
try {
|
|
3763
|
+
const parsed = JSON.parse(await fs5.readFile(statePath, "utf-8"));
|
|
3764
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
3765
|
+
return {};
|
|
3766
|
+
}
|
|
3767
|
+
return { ...parsed };
|
|
3768
|
+
} catch {
|
|
3769
|
+
return {};
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
async writeState(statePath, state) {
|
|
3773
|
+
try {
|
|
3774
|
+
await atomicWriteFile(statePath, JSON.stringify(state, null, 2));
|
|
3775
|
+
} catch (error) {
|
|
3776
|
+
this.logger.warn(`Failed to persist maintenance state: ${getErrorMessage(error)}`);
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
isDue(state, now) {
|
|
3780
|
+
if (!state.lastAttemptAt) {
|
|
3781
|
+
return true;
|
|
3782
|
+
}
|
|
3783
|
+
const last = new Date(state.lastAttemptAt).getTime();
|
|
3784
|
+
if (Number.isNaN(last)) {
|
|
3785
|
+
return true;
|
|
3786
|
+
}
|
|
3787
|
+
return now - last >= this.getIntervalMs();
|
|
3788
|
+
}
|
|
3789
|
+
/**
|
|
3790
|
+
* Run `git gc` if maintenance is enabled and due. MUST be called while the
|
|
3791
|
+
* repository operation lock is already held. Never throws: a gc failure is
|
|
3792
|
+
* recorded and warned so it cannot fail the surrounding sync. The attempt
|
|
3793
|
+
* timestamp is persisted even on failure, so a perpetually-failing gc is
|
|
3794
|
+
* throttled instead of retried every tick.
|
|
3795
|
+
*/
|
|
3796
|
+
async runIfDueUnlocked(now = Date.now()) {
|
|
3797
|
+
if (!this.isEnabled()) {
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
try {
|
|
3801
|
+
const { cwd, gitDir } = this.resolveTarget();
|
|
3802
|
+
try {
|
|
3803
|
+
await fs5.access(gitDir);
|
|
3804
|
+
} catch {
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
const statePath = this.getStatePath(gitDir);
|
|
3808
|
+
const state = await this.readState(statePath);
|
|
3809
|
+
if (!this.isDue(state, now)) {
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
const aggressive = this.config.maintenance?.aggressive ?? false;
|
|
3813
|
+
const args = aggressive ? ["gc", "--prune=now"] : ["gc"];
|
|
3814
|
+
const nowIso = new Date(now).toISOString();
|
|
3815
|
+
state.lastAttemptAt = nowIso;
|
|
3816
|
+
this.logger.info(`\u{1F9F9} Running git ${args.join(" ")} (maintenance)...`);
|
|
3817
|
+
try {
|
|
3818
|
+
await this.gitFactory(cwd).raw(args);
|
|
3819
|
+
state.lastSuccessAt = nowIso;
|
|
3820
|
+
delete state.lastError;
|
|
3821
|
+
this.logger.info("\u{1F9F9} Maintenance complete.");
|
|
3822
|
+
} catch (error) {
|
|
3823
|
+
state.lastFailureAt = nowIso;
|
|
3824
|
+
state.lastError = getErrorMessage(error);
|
|
3825
|
+
this.logger.warn(`\u26A0\uFE0F Maintenance (git ${args.join(" ")}) failed: ${state.lastError}`);
|
|
3826
|
+
} finally {
|
|
3827
|
+
await this.writeState(statePath, state);
|
|
3828
|
+
}
|
|
3829
|
+
} catch (error) {
|
|
3830
|
+
this.logger.warn(`\u26A0\uFE0F Maintenance skipped due to an unexpected error: ${getErrorMessage(error)}`);
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
|
|
3835
|
+
// src/services/git.service.ts
|
|
3836
|
+
import * as fs9 from "fs/promises";
|
|
3837
|
+
import * as path11 from "path";
|
|
3838
|
+
import simpleGit6 from "simple-git";
|
|
3839
|
+
|
|
3840
|
+
// src/utils/quarantine.ts
|
|
3841
|
+
import * as fs6 from "fs/promises";
|
|
3842
|
+
import * as path7 from "path";
|
|
3843
|
+
|
|
3844
|
+
// src/utils/filename-timestamp.ts
|
|
3845
|
+
function filenameTimestamp(date = /* @__PURE__ */ new Date()) {
|
|
3846
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
// src/utils/quarantine.ts
|
|
3850
|
+
async function quarantineDirectory(dirPath) {
|
|
3851
|
+
const baseDir = path7.join(path7.dirname(dirPath), GIT_CONSTANTS.REMOVED_DIR_NAME);
|
|
3852
|
+
await fs6.mkdir(baseDir, { recursive: true });
|
|
3853
|
+
const timestamp = filenameTimestamp();
|
|
3854
|
+
const quarantinePath = path7.join(baseDir, `${timestamp}-${path7.basename(dirPath)}`);
|
|
3855
|
+
await fs6.rename(dirPath, quarantinePath);
|
|
3856
|
+
return quarantinePath;
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
// src/utils/worktree-list-parser.ts
|
|
3860
|
+
function parseWorktreeListPorcelain(output) {
|
|
3861
|
+
const worktrees = [];
|
|
3862
|
+
let current = {};
|
|
3863
|
+
const flush = () => {
|
|
3864
|
+
if (!current.path) {
|
|
3865
|
+
current = {};
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
worktrees.push({
|
|
3869
|
+
path: current.path,
|
|
3870
|
+
branch: current.branch ?? null,
|
|
3871
|
+
head: current.head ?? null,
|
|
3872
|
+
detached: current.detached ?? false,
|
|
3873
|
+
prunable: current.prunable ?? false,
|
|
3874
|
+
locked: current.locked ?? false
|
|
3875
|
+
});
|
|
3876
|
+
current = {};
|
|
3877
|
+
};
|
|
3878
|
+
for (const line of output.split("\n")) {
|
|
3879
|
+
if (line.startsWith("worktree ")) {
|
|
3880
|
+
flush();
|
|
3881
|
+
current.path = line.substring("worktree ".length);
|
|
3882
|
+
} else if (line.startsWith("branch ")) {
|
|
3883
|
+
current.branch = line.substring("branch ".length).replace("refs/heads/", "");
|
|
3884
|
+
} else if (line.startsWith("HEAD ")) {
|
|
3885
|
+
current.head = line.substring("HEAD ".length);
|
|
3886
|
+
} else if (line === "detached") {
|
|
3887
|
+
current.detached = true;
|
|
3888
|
+
} else if (line === "prunable" || line.startsWith("prunable ")) {
|
|
3889
|
+
current.prunable = true;
|
|
3890
|
+
} else if (line === "locked" || line.startsWith("locked ")) {
|
|
3891
|
+
current.locked = true;
|
|
3892
|
+
} else if (line.trim() === "") {
|
|
3893
|
+
flush();
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
flush();
|
|
3897
|
+
return worktrees;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
// src/services/sparse-checkout.service.ts
|
|
3901
|
+
import * as path8 from "path";
|
|
3902
|
+
import simpleGit3 from "simple-git";
|
|
3903
|
+
var SparseCheckoutService = class {
|
|
3407
3904
|
logger;
|
|
3408
3905
|
gitFactory;
|
|
3409
3906
|
warnedConfigs = /* @__PURE__ */ new WeakSet();
|
|
3410
3907
|
matcherCache = /* @__PURE__ */ new WeakMap();
|
|
3411
3908
|
constructor(logger, gitFactory) {
|
|
3412
3909
|
this.logger = logger ?? Logger.createDefault();
|
|
3413
|
-
this.gitFactory = gitFactory ?? ((p) =>
|
|
3910
|
+
this.gitFactory = gitFactory ?? ((p) => simpleGit3(p));
|
|
3414
3911
|
}
|
|
3415
3912
|
updateLogger(logger) {
|
|
3416
3913
|
this.logger = logger;
|
|
@@ -3536,7 +4033,7 @@ var SparseCheckoutService = class {
|
|
|
3536
4033
|
for (const pat of matcher.patterns) {
|
|
3537
4034
|
if (p === pat || p.startsWith(pat + "/")) return true;
|
|
3538
4035
|
}
|
|
3539
|
-
return matcher.ancestorDirs.has(
|
|
4036
|
+
return matcher.ancestorDirs.has(path8.posix.dirname(p));
|
|
3540
4037
|
});
|
|
3541
4038
|
}
|
|
3542
4039
|
getMatcher(cfg) {
|
|
@@ -3563,9 +4060,9 @@ var SparseCheckoutService = class {
|
|
|
3563
4060
|
};
|
|
3564
4061
|
|
|
3565
4062
|
// src/services/worktree-metadata.service.ts
|
|
3566
|
-
import * as
|
|
3567
|
-
import * as
|
|
3568
|
-
import
|
|
4063
|
+
import * as fs7 from "fs/promises";
|
|
4064
|
+
import * as path9 from "path";
|
|
4065
|
+
import simpleGit4 from "simple-git";
|
|
3569
4066
|
var WorktreeMetadataService = class {
|
|
3570
4067
|
logger;
|
|
3571
4068
|
constructor(logger) {
|
|
@@ -3577,7 +4074,7 @@ var WorktreeMetadataService = class {
|
|
|
3577
4074
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
3578
4075
|
*/
|
|
3579
4076
|
getWorktreeDirectoryName(worktreePath) {
|
|
3580
|
-
return
|
|
4077
|
+
return path9.basename(worktreePath);
|
|
3581
4078
|
}
|
|
3582
4079
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
3583
4080
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -3585,7 +4082,7 @@ var WorktreeMetadataService = class {
|
|
|
3585
4082
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
3586
4083
|
);
|
|
3587
4084
|
}
|
|
3588
|
-
return
|
|
4085
|
+
return path9.join(
|
|
3589
4086
|
bareRepoPath,
|
|
3590
4087
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
3591
4088
|
worktreeName,
|
|
@@ -3598,31 +4095,13 @@ var WorktreeMetadataService = class {
|
|
|
3598
4095
|
}
|
|
3599
4096
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
3600
4097
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
3601
|
-
await
|
|
3602
|
-
|
|
3603
|
-
let renamed = false;
|
|
3604
|
-
try {
|
|
3605
|
-
await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
3606
|
-
try {
|
|
3607
|
-
await fs4.rename(tmpPath, metadataPath);
|
|
3608
|
-
renamed = true;
|
|
3609
|
-
} catch (err) {
|
|
3610
|
-
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
3611
|
-
await fs4.copyFile(tmpPath, metadataPath);
|
|
3612
|
-
} else {
|
|
3613
|
-
throw err;
|
|
3614
|
-
}
|
|
3615
|
-
}
|
|
3616
|
-
} finally {
|
|
3617
|
-
if (!renamed) {
|
|
3618
|
-
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
3619
|
-
}
|
|
3620
|
-
}
|
|
4098
|
+
await fs7.mkdir(path9.dirname(metadataPath), { recursive: true });
|
|
4099
|
+
await atomicWriteFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
3621
4100
|
}
|
|
3622
4101
|
async loadMetadata(bareRepoPath, worktreeName) {
|
|
3623
4102
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
3624
4103
|
try {
|
|
3625
|
-
const content = await
|
|
4104
|
+
const content = await fs7.readFile(metadataPath, "utf-8");
|
|
3626
4105
|
return JSON.parse(content);
|
|
3627
4106
|
} catch {
|
|
3628
4107
|
return null;
|
|
@@ -3631,7 +4110,7 @@ var WorktreeMetadataService = class {
|
|
|
3631
4110
|
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
3632
4111
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
3633
4112
|
try {
|
|
3634
|
-
const content = await
|
|
4113
|
+
const content = await fs7.readFile(metadataPath, "utf-8");
|
|
3635
4114
|
const metadata = JSON.parse(content);
|
|
3636
4115
|
if (!await this.validateMetadata(metadata)) {
|
|
3637
4116
|
this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
@@ -3645,7 +4124,7 @@ var WorktreeMetadataService = class {
|
|
|
3645
4124
|
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
3646
4125
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
3647
4126
|
try {
|
|
3648
|
-
await
|
|
4127
|
+
await fs7.unlink(metadataPath);
|
|
3649
4128
|
} catch (error) {
|
|
3650
4129
|
if (error.code !== "ENOENT") {
|
|
3651
4130
|
throw error;
|
|
@@ -3655,7 +4134,7 @@ var WorktreeMetadataService = class {
|
|
|
3655
4134
|
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
3656
4135
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
3657
4136
|
try {
|
|
3658
|
-
await
|
|
4137
|
+
await fs7.unlink(metadataPath);
|
|
3659
4138
|
} catch (error) {
|
|
3660
4139
|
if (error.code !== "ENOENT") {
|
|
3661
4140
|
throw error;
|
|
@@ -3689,7 +4168,7 @@ var WorktreeMetadataService = class {
|
|
|
3689
4168
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
3690
4169
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
3691
4170
|
try {
|
|
3692
|
-
const worktreeGit =
|
|
4171
|
+
const worktreeGit = simpleGit4(worktreePath);
|
|
3693
4172
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
3694
4173
|
const branchSummary = await worktreeGit.branch();
|
|
3695
4174
|
const actualBranchName = branchSummary.current;
|
|
@@ -3736,6 +4215,25 @@ var WorktreeMetadataService = class {
|
|
|
3736
4215
|
}
|
|
3737
4216
|
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
3738
4217
|
}
|
|
4218
|
+
/**
|
|
4219
|
+
* Records the upstream tip observed during this sync. This is what later
|
|
4220
|
+
* proves "HEAD was fully pushed" after the remote branch is deleted, so it
|
|
4221
|
+
* must only ever be overwritten with a live observation — callers must not
|
|
4222
|
+
* invoke this once the upstream ref is gone.
|
|
4223
|
+
*/
|
|
4224
|
+
async recordRemoteTip(bareRepoPath, worktreePath, ref, oid) {
|
|
4225
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
4226
|
+
const existing = await this.loadMetadataFromPath(bareRepoPath, worktreePath);
|
|
4227
|
+
if (!existing) {
|
|
4228
|
+
this.logger.debug(`No metadata found for worktree ${worktreeDirName}; skipping remote tip recording`);
|
|
4229
|
+
return;
|
|
4230
|
+
}
|
|
4231
|
+
if (existing.lastKnownRemoteTip?.ref === ref && existing.lastKnownRemoteTip.oid === oid) {
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
existing.lastKnownRemoteTip = { ref, oid, recordedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4235
|
+
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
4236
|
+
}
|
|
3739
4237
|
async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
|
|
3740
4238
|
const metadata = {
|
|
3741
4239
|
lastSyncCommit: commit,
|
|
@@ -3790,9 +4288,9 @@ var WorktreeMetadataService = class {
|
|
|
3790
4288
|
};
|
|
3791
4289
|
|
|
3792
4290
|
// src/services/worktree-status.service.ts
|
|
3793
|
-
import * as
|
|
3794
|
-
import * as
|
|
3795
|
-
import
|
|
4291
|
+
import * as fs8 from "fs/promises";
|
|
4292
|
+
import * as path10 from "path";
|
|
4293
|
+
import simpleGit5 from "simple-git";
|
|
3796
4294
|
var OPERATION_FILES = [
|
|
3797
4295
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
3798
4296
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -3823,8 +4321,9 @@ var WorktreeStatusService = class {
|
|
|
3823
4321
|
}
|
|
3824
4322
|
return true;
|
|
3825
4323
|
}
|
|
3826
|
-
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
3827
|
-
|
|
4324
|
+
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit, lastKnownRemoteTip) {
|
|
4325
|
+
const pathProbe = await probePathExists(worktreePath);
|
|
4326
|
+
if (pathProbe === "missing") {
|
|
3828
4327
|
return {
|
|
3829
4328
|
isClean: true,
|
|
3830
4329
|
hasUnpushedCommits: false,
|
|
@@ -3832,25 +4331,44 @@ var WorktreeStatusService = class {
|
|
|
3832
4331
|
hasOperationInProgress: false,
|
|
3833
4332
|
hasModifiedSubmodules: false,
|
|
3834
4333
|
upstreamGone: false,
|
|
4334
|
+
fullyPushedUpstreamDeleted: false,
|
|
3835
4335
|
canRemove: true,
|
|
3836
4336
|
reasons: []
|
|
3837
4337
|
};
|
|
3838
4338
|
}
|
|
3839
|
-
|
|
4339
|
+
if (pathProbe === "unknown") {
|
|
4340
|
+
return {
|
|
4341
|
+
isClean: false,
|
|
4342
|
+
hasUnpushedCommits: true,
|
|
4343
|
+
hasStashedChanges: true,
|
|
4344
|
+
hasOperationInProgress: true,
|
|
4345
|
+
hasModifiedSubmodules: true,
|
|
4346
|
+
upstreamGone: false,
|
|
4347
|
+
fullyPushedUpstreamDeleted: false,
|
|
4348
|
+
canRemove: false,
|
|
4349
|
+
reasons: ["cannot verify worktree path (filesystem probe failed)"]
|
|
4350
|
+
};
|
|
4351
|
+
}
|
|
4352
|
+
const snap = await this.collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip);
|
|
3840
4353
|
const isClean = this.deriveIsClean(snap);
|
|
3841
|
-
const
|
|
4354
|
+
const anyRemoteUnpushed = (snap.unpushedAnyRemoteCount ?? 1) > 0;
|
|
4355
|
+
const sinceSyncUnpushed = snap.sinceSyncChecked && (snap.sinceSyncCount ?? 1) > 0;
|
|
4356
|
+
const hasUnpushedCommits = !snap.detached && (anyRemoteUnpushed || sinceSyncUnpushed);
|
|
4357
|
+
const recordedRefGone = lastKnownRemoteTip !== void 0 && snap.remoteBranches.length > 0 && !snap.remoteBranches.includes(lastKnownRemoteTip.ref);
|
|
4358
|
+
const fullyPushedUpstreamDeleted = hasUnpushedCommits && recordedRefGone && snap.headPushedToRecordedTip === true;
|
|
3842
4359
|
const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
|
|
3843
|
-
const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
|
|
4360
|
+
const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null || snap.operationProbeUnknown;
|
|
3844
4361
|
const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
|
|
3845
4362
|
const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
|
|
3846
4363
|
const reasons = [];
|
|
3847
4364
|
if (!isClean) reasons.push("uncommitted changes");
|
|
3848
|
-
if (hasUnpushedCommits) reasons.push("unpushed commits");
|
|
4365
|
+
if (hasUnpushedCommits && !fullyPushedUpstreamDeleted) reasons.push("unpushed commits");
|
|
3849
4366
|
if (hasStashedChanges) reasons.push("stashed changes");
|
|
3850
4367
|
if (hasOperationInProgress) reasons.push("operation in progress");
|
|
3851
4368
|
if (hasModifiedSubmodules) reasons.push("modified submodules");
|
|
3852
4369
|
if (upstreamGone) reasons.push("upstream gone");
|
|
3853
|
-
|
|
4370
|
+
if (snap.detached) reasons.push("detached HEAD");
|
|
4371
|
+
const canRemove = isClean && (!hasUnpushedCommits || fullyPushedUpstreamDeleted) && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules && !snap.detached;
|
|
3854
4372
|
const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
|
|
3855
4373
|
return {
|
|
3856
4374
|
isClean,
|
|
@@ -3859,12 +4377,13 @@ var WorktreeStatusService = class {
|
|
|
3859
4377
|
hasOperationInProgress,
|
|
3860
4378
|
hasModifiedSubmodules,
|
|
3861
4379
|
upstreamGone,
|
|
4380
|
+
fullyPushedUpstreamDeleted,
|
|
3862
4381
|
canRemove,
|
|
3863
4382
|
reasons,
|
|
3864
4383
|
details
|
|
3865
4384
|
};
|
|
3866
4385
|
}
|
|
3867
|
-
async collectSnapshot(worktreePath, lastSyncCommit) {
|
|
4386
|
+
async collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
|
|
3868
4387
|
const git = this.createGitInstance(worktreePath);
|
|
3869
4388
|
const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
|
|
3870
4389
|
git.status().catch((e) => {
|
|
@@ -3889,19 +4408,33 @@ var WorktreeStatusService = class {
|
|
|
3889
4408
|
const currentBranch = branchResult?.current ?? null;
|
|
3890
4409
|
const detached = !branchResult?.current || Boolean(branchResult?.detached);
|
|
3891
4410
|
let upstream = null;
|
|
3892
|
-
let
|
|
4411
|
+
let unpushedAnyRemoteCount = null;
|
|
4412
|
+
let sinceSyncCount = null;
|
|
4413
|
+
let headPushedToRecordedTip = null;
|
|
3893
4414
|
if (!detached && currentBranch) {
|
|
3894
|
-
const
|
|
3895
|
-
const [upstreamResult, unpushedResult] = await Promise.all([
|
|
4415
|
+
const [upstreamResult, anyRemoteResult, sinceSyncResult, recordedTipResult] = await Promise.all([
|
|
3896
4416
|
git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
|
|
3897
4417
|
(raw) => ({ ok: true, value: raw }),
|
|
3898
4418
|
(error) => ({ ok: false, error })
|
|
3899
4419
|
),
|
|
3900
|
-
git.raw(
|
|
4420
|
+
git.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]).then(
|
|
3901
4421
|
(raw) => ({ ok: true, value: raw }),
|
|
3902
4422
|
(error) => ({ ok: false, error })
|
|
3903
|
-
)
|
|
4423
|
+
),
|
|
4424
|
+
lastSyncCommit ? git.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]).then(
|
|
4425
|
+
(raw) => ({ ok: true, value: raw }),
|
|
4426
|
+
(error) => ({ ok: false, error })
|
|
4427
|
+
) : Promise.resolve(null),
|
|
4428
|
+
// Zero commits in <tip>..HEAD ⟺ HEAD is the recorded tip or behind it.
|
|
4429
|
+
// NOT merge-base --is-ancestor: simple-git resolves its silent exit-1
|
|
4430
|
+
// ("not an ancestor") as success because nothing is written to stderr.
|
|
4431
|
+
// Any failure (e.g. the recorded oid was gc'd) reads as "not proven".
|
|
4432
|
+
lastKnownRemoteTip ? git.raw(["rev-list", "--count", `${lastKnownRemoteTip.oid}..HEAD`]).then(
|
|
4433
|
+
(raw) => this.parseCount(raw) === 0,
|
|
4434
|
+
() => false
|
|
4435
|
+
) : Promise.resolve(null)
|
|
3904
4436
|
]);
|
|
4437
|
+
headPushedToRecordedTip = recordedTipResult;
|
|
3905
4438
|
if (upstreamResult.ok) {
|
|
3906
4439
|
upstream = upstreamResult.value.trim() || null;
|
|
3907
4440
|
} else {
|
|
@@ -3910,13 +4443,20 @@ var WorktreeStatusService = class {
|
|
|
3910
4443
|
this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
|
|
3911
4444
|
}
|
|
3912
4445
|
}
|
|
3913
|
-
if (
|
|
3914
|
-
|
|
4446
|
+
if (anyRemoteResult.ok) {
|
|
4447
|
+
unpushedAnyRemoteCount = this.parseCount(anyRemoteResult.value);
|
|
3915
4448
|
} else {
|
|
3916
|
-
this.logger.error(`Error checking unpushed commits`,
|
|
4449
|
+
this.logger.error(`Error checking unpushed commits`, anyRemoteResult.error);
|
|
4450
|
+
}
|
|
4451
|
+
if (sinceSyncResult) {
|
|
4452
|
+
if (sinceSyncResult.ok) {
|
|
4453
|
+
sinceSyncCount = this.parseCount(sinceSyncResult.value);
|
|
4454
|
+
} else {
|
|
4455
|
+
this.logger.error(`Error checking commits since last sync`, sinceSyncResult.error);
|
|
4456
|
+
}
|
|
3917
4457
|
}
|
|
3918
4458
|
}
|
|
3919
|
-
const
|
|
4459
|
+
const operationProbe = gitDirResult ? await this.detectOperationFile(gitDirResult) : { file: null, unknown: false };
|
|
3920
4460
|
let untrackedNotIgnored = [];
|
|
3921
4461
|
if (status && status.not_added.length > 0) {
|
|
3922
4462
|
try {
|
|
@@ -3932,14 +4472,22 @@ var WorktreeStatusService = class {
|
|
|
3932
4472
|
detached,
|
|
3933
4473
|
remoteBranches: remoteBranchesResult?.all ?? [],
|
|
3934
4474
|
upstream,
|
|
3935
|
-
|
|
4475
|
+
unpushedAnyRemoteCount,
|
|
4476
|
+
sinceSyncCount,
|
|
4477
|
+
sinceSyncChecked: lastSyncCommit !== void 0,
|
|
4478
|
+
headPushedToRecordedTip,
|
|
3936
4479
|
stashTotal: stashResult?.total ?? null,
|
|
3937
4480
|
submoduleStatus: submoduleResult,
|
|
3938
|
-
operationFile,
|
|
4481
|
+
operationFile: operationProbe.file,
|
|
4482
|
+
operationProbeUnknown: operationProbe.unknown,
|
|
3939
4483
|
gitDir: gitDirResult,
|
|
3940
4484
|
untrackedNotIgnored
|
|
3941
4485
|
};
|
|
3942
4486
|
}
|
|
4487
|
+
parseCount(raw) {
|
|
4488
|
+
const count = parseInt(raw.trim(), 10);
|
|
4489
|
+
return Number.isNaN(count) ? null : count;
|
|
4490
|
+
}
|
|
3943
4491
|
deriveIsClean(snap) {
|
|
3944
4492
|
const status = snap.status;
|
|
3945
4493
|
if (!status) return false;
|
|
@@ -3979,7 +4527,8 @@ var WorktreeStatusService = class {
|
|
|
3979
4527
|
if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
|
|
3980
4528
|
}
|
|
3981
4529
|
if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
|
|
3982
|
-
|
|
4530
|
+
const unpushedCount = snap.unpushedAnyRemoteCount ?? snap.sinceSyncCount;
|
|
4531
|
+
if (!snap.detached && unpushedCount !== null) details.unpushedCommitCount = unpushedCount;
|
|
3983
4532
|
if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
|
|
3984
4533
|
const opType = this.operationTypeFromFile(snap.operationFile);
|
|
3985
4534
|
if (opType) details.operationType = opType;
|
|
@@ -3994,34 +4543,37 @@ var WorktreeStatusService = class {
|
|
|
3994
4543
|
async detectOperationFile(gitDir) {
|
|
3995
4544
|
const results = await Promise.all(
|
|
3996
4545
|
OPERATION_FILES.map(
|
|
3997
|
-
({ file }) =>
|
|
3998
|
-
() =>
|
|
3999
|
-
() =>
|
|
4546
|
+
({ file }) => fs8.access(path10.join(gitDir, file)).then(
|
|
4547
|
+
() => "present",
|
|
4548
|
+
(error) => error.code === "ENOENT" ? "absent" : "unknown"
|
|
4000
4549
|
)
|
|
4001
4550
|
)
|
|
4002
4551
|
);
|
|
4003
|
-
const idx = results.findIndex(
|
|
4004
|
-
|
|
4552
|
+
const idx = results.findIndex((result) => result === "present");
|
|
4553
|
+
if (idx >= 0) return { file: OPERATION_FILES[idx].file, unknown: false };
|
|
4554
|
+
return { file: null, unknown: results.includes("unknown") };
|
|
4005
4555
|
}
|
|
4006
4556
|
async hasUnpushedCommits(worktreePath, lastSyncCommit) {
|
|
4007
4557
|
const worktreeGit = this.createGitInstance(worktreePath);
|
|
4008
4558
|
try {
|
|
4009
4559
|
if (await this.isDetachedHead(worktreeGit)) {
|
|
4010
|
-
return
|
|
4560
|
+
return true;
|
|
4011
4561
|
}
|
|
4012
4562
|
const branchSummary = await worktreeGit.branch();
|
|
4013
4563
|
const currentBranch = branchSummary.current;
|
|
4564
|
+
const anyRemoteResult = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
|
|
4565
|
+
const anyRemoteCount = this.parseCount(anyRemoteResult);
|
|
4566
|
+
if (anyRemoteCount === null || anyRemoteCount > 0) {
|
|
4567
|
+
return true;
|
|
4568
|
+
}
|
|
4014
4569
|
if (lastSyncCommit) {
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
return
|
|
4019
|
-
} catch {
|
|
4570
|
+
const sinceSyncResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
|
|
4571
|
+
const sinceSyncCount = this.parseCount(sinceSyncResult);
|
|
4572
|
+
if (sinceSyncCount === null || sinceSyncCount > 0) {
|
|
4573
|
+
return true;
|
|
4020
4574
|
}
|
|
4021
4575
|
}
|
|
4022
|
-
|
|
4023
|
-
const unpushedCount = parseInt(result.trim(), 10);
|
|
4024
|
-
return unpushedCount > 0;
|
|
4576
|
+
return false;
|
|
4025
4577
|
} catch (error) {
|
|
4026
4578
|
this.logger.error(`Error checking unpushed commits`, error);
|
|
4027
4579
|
return true;
|
|
@@ -4077,14 +4629,15 @@ var WorktreeStatusService = class {
|
|
|
4077
4629
|
async hasOperationInProgress(worktreePath) {
|
|
4078
4630
|
try {
|
|
4079
4631
|
const gitDir = await this.resolveGitDir(worktreePath);
|
|
4080
|
-
|
|
4632
|
+
const probe = await this.detectOperationFile(gitDir);
|
|
4633
|
+
return probe.unknown || probe.file !== null;
|
|
4081
4634
|
} catch (error) {
|
|
4082
4635
|
this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
|
|
4083
4636
|
return true;
|
|
4084
4637
|
}
|
|
4085
4638
|
}
|
|
4086
|
-
async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
|
|
4087
|
-
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
|
|
4639
|
+
async validateWorktreeForRemoval(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
|
|
4640
|
+
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit, lastKnownRemoteTip);
|
|
4088
4641
|
if (!status.canRemove) {
|
|
4089
4642
|
throw new WorktreeNotCleanError(worktreePath, status.reasons);
|
|
4090
4643
|
}
|
|
@@ -4115,14 +4668,14 @@ var WorktreeStatusService = class {
|
|
|
4115
4668
|
}
|
|
4116
4669
|
}
|
|
4117
4670
|
async resolveGitDir(worktreePath) {
|
|
4118
|
-
const gitPath =
|
|
4671
|
+
const gitPath = path10.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
4119
4672
|
try {
|
|
4120
|
-
const stat3 = await
|
|
4673
|
+
const stat3 = await fs8.stat(gitPath);
|
|
4121
4674
|
if (stat3.isFile()) {
|
|
4122
|
-
const content = await
|
|
4675
|
+
const content = await fs8.readFile(gitPath, "utf-8");
|
|
4123
4676
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
4124
4677
|
if (gitdirMatch) {
|
|
4125
|
-
return
|
|
4678
|
+
return path10.resolve(worktreePath, gitdirMatch[1].trim());
|
|
4126
4679
|
}
|
|
4127
4680
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
4128
4681
|
}
|
|
@@ -4136,10 +4689,10 @@ var WorktreeStatusService = class {
|
|
|
4136
4689
|
}
|
|
4137
4690
|
}
|
|
4138
4691
|
createGitInstance(worktreePath) {
|
|
4139
|
-
const key = `${
|
|
4692
|
+
const key = `${path10.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
4140
4693
|
let git = this.gitInstances.get(key);
|
|
4141
4694
|
if (!git) {
|
|
4142
|
-
git = this.config.skipLfs ?
|
|
4695
|
+
git = this.config.skipLfs ? simpleGit5(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit5(worktreePath);
|
|
4143
4696
|
this.gitInstances.set(key, git);
|
|
4144
4697
|
}
|
|
4145
4698
|
return git;
|
|
@@ -4160,7 +4713,7 @@ var GitService = class {
|
|
|
4160
4713
|
this.progressEmitter = progressEmitter;
|
|
4161
4714
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
4162
4715
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
4163
|
-
this.mainWorktreePath =
|
|
4716
|
+
this.mainWorktreePath = path11.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
4164
4717
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
4165
4718
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
4166
4719
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
@@ -4190,10 +4743,10 @@ var GitService = class {
|
|
|
4190
4743
|
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
4191
4744
|
}
|
|
4192
4745
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
4193
|
-
const key = `${
|
|
4746
|
+
const key = `${path11.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
4194
4747
|
let git = this.gitInstances.get(key);
|
|
4195
4748
|
if (!git) {
|
|
4196
|
-
const base =
|
|
4749
|
+
const base = simpleGit6(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
4197
4750
|
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
4198
4751
|
this.gitInstances.set(key, git);
|
|
4199
4752
|
}
|
|
@@ -4213,11 +4766,11 @@ var GitService = class {
|
|
|
4213
4766
|
async initialize() {
|
|
4214
4767
|
const { repoUrl } = this.config;
|
|
4215
4768
|
try {
|
|
4216
|
-
await
|
|
4769
|
+
await fs9.access(path11.join(this.bareRepoPath, "HEAD"));
|
|
4217
4770
|
} catch {
|
|
4218
4771
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
4219
|
-
await
|
|
4220
|
-
const cloneBase =
|
|
4772
|
+
await fs9.mkdir(path11.dirname(this.bareRepoPath), { recursive: true });
|
|
4773
|
+
const cloneBase = simpleGit6(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
|
|
4221
4774
|
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
4222
4775
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
|
|
4223
4776
|
this.logger.info("\u2705 Clone successful.");
|
|
@@ -4235,17 +4788,17 @@ var GitService = class {
|
|
|
4235
4788
|
this.logger.info("Fetching remote branches...");
|
|
4236
4789
|
await bareGit.fetch(["--all", "--progress"]);
|
|
4237
4790
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
4238
|
-
this.mainWorktreePath =
|
|
4791
|
+
this.mainWorktreePath = path11.join(this.config.worktreeDir, this.defaultBranch);
|
|
4239
4792
|
let needsMainWorktree = true;
|
|
4240
4793
|
try {
|
|
4241
4794
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4242
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
4795
|
+
needsMainWorktree = !worktrees.some((w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath));
|
|
4243
4796
|
} catch {
|
|
4244
4797
|
}
|
|
4245
4798
|
if (needsMainWorktree) {
|
|
4246
4799
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
4247
|
-
await
|
|
4248
|
-
const absoluteWorktreePath =
|
|
4800
|
+
await fs9.mkdir(this.config.worktreeDir, { recursive: true });
|
|
4801
|
+
const absoluteWorktreePath = path11.resolve(this.mainWorktreePath);
|
|
4249
4802
|
const branches = await bareGit.branch();
|
|
4250
4803
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
4251
4804
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -4281,7 +4834,7 @@ var GitService = class {
|
|
|
4281
4834
|
}
|
|
4282
4835
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
4283
4836
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
4284
|
-
(w) =>
|
|
4837
|
+
(w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath)
|
|
4285
4838
|
);
|
|
4286
4839
|
if (!mainWorktreeRegistered) {
|
|
4287
4840
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -4308,7 +4861,7 @@ var GitService = class {
|
|
|
4308
4861
|
return this.bareRepoPath;
|
|
4309
4862
|
}
|
|
4310
4863
|
async getRemoteDefaultBranch(repoUrl) {
|
|
4311
|
-
const git =
|
|
4864
|
+
const git = simpleGit6(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
4312
4865
|
try {
|
|
4313
4866
|
const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
|
|
4314
4867
|
const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
|
|
@@ -4392,7 +4945,7 @@ var GitService = class {
|
|
|
4392
4945
|
return branches;
|
|
4393
4946
|
}
|
|
4394
4947
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
4395
|
-
const worktreeGit = this.config.sparseCheckout ?
|
|
4948
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit6(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
4396
4949
|
try {
|
|
4397
4950
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
4398
4951
|
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
@@ -4403,7 +4956,7 @@ var GitService = class {
|
|
|
4403
4956
|
const existence = await Promise.all(
|
|
4404
4957
|
lfsFileList.map(async (f) => {
|
|
4405
4958
|
try {
|
|
4406
|
-
await
|
|
4959
|
+
await fs9.access(path11.join(worktreePath, f));
|
|
4407
4960
|
return f;
|
|
4408
4961
|
} catch {
|
|
4409
4962
|
return null;
|
|
@@ -4431,9 +4984,9 @@ var GitService = class {
|
|
|
4431
4984
|
let allDownloaded = true;
|
|
4432
4985
|
const notDownloaded = [];
|
|
4433
4986
|
for (const file of samplesToCheck) {
|
|
4434
|
-
const filePath =
|
|
4987
|
+
const filePath = path11.join(worktreePath, file);
|
|
4435
4988
|
try {
|
|
4436
|
-
const handle = await
|
|
4989
|
+
const handle = await fs9.open(filePath, "r");
|
|
4437
4990
|
try {
|
|
4438
4991
|
const buffer = Buffer.alloc(200);
|
|
4439
4992
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
@@ -4458,7 +5011,7 @@ var GitService = class {
|
|
|
4458
5011
|
}
|
|
4459
5012
|
retries++;
|
|
4460
5013
|
if (retries < maxRetries) {
|
|
4461
|
-
await new Promise((
|
|
5014
|
+
await new Promise((resolve14) => setTimeout(resolve14, retryDelay));
|
|
4462
5015
|
}
|
|
4463
5016
|
}
|
|
4464
5017
|
this.logger.warn(
|
|
@@ -4520,20 +5073,23 @@ var GitService = class {
|
|
|
4520
5073
|
}
|
|
4521
5074
|
async addWorktree(branchName, worktreePath) {
|
|
4522
5075
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
4523
|
-
const absoluteWorktreePath =
|
|
4524
|
-
await
|
|
5076
|
+
const absoluteWorktreePath = path11.resolve(worktreePath);
|
|
5077
|
+
await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
|
|
4525
5078
|
try {
|
|
4526
|
-
await
|
|
5079
|
+
await fs9.access(absoluteWorktreePath);
|
|
4527
5080
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4528
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
5081
|
+
const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4529
5082
|
if (isValidWorktree) {
|
|
4530
5083
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
4531
5084
|
return;
|
|
4532
5085
|
} else {
|
|
4533
5086
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
4534
|
-
await
|
|
5087
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
5088
|
+
}
|
|
5089
|
+
} catch (error) {
|
|
5090
|
+
if (error instanceof GitOperationError || error instanceof WorktreeError) {
|
|
5091
|
+
throw error;
|
|
4535
5092
|
}
|
|
4536
|
-
} catch {
|
|
4537
5093
|
}
|
|
4538
5094
|
let createdNewBranch = false;
|
|
4539
5095
|
try {
|
|
@@ -4570,17 +5126,14 @@ var GitService = class {
|
|
|
4570
5126
|
}
|
|
4571
5127
|
if (errorMessage.includes("already registered worktree")) {
|
|
4572
5128
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4573
|
-
const existingWorktree = worktrees.find((w) =>
|
|
5129
|
+
const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4574
5130
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
4575
5131
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
4576
5132
|
return;
|
|
4577
5133
|
}
|
|
4578
5134
|
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
4579
5135
|
await bareGit.raw(["worktree", "prune"]);
|
|
4580
|
-
|
|
4581
|
-
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
4582
|
-
} catch {
|
|
4583
|
-
}
|
|
5136
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
4584
5137
|
let retryCreatedNewBranch = false;
|
|
4585
5138
|
try {
|
|
4586
5139
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
@@ -4614,17 +5167,20 @@ var GitService = class {
|
|
|
4614
5167
|
}
|
|
4615
5168
|
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
4616
5169
|
try {
|
|
4617
|
-
await
|
|
5170
|
+
await fs9.access(absoluteWorktreePath);
|
|
4618
5171
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4619
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
5172
|
+
const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4620
5173
|
if (isValidWorktree) {
|
|
4621
5174
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
4622
5175
|
return;
|
|
4623
5176
|
} else {
|
|
4624
5177
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
4625
|
-
await
|
|
5178
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
5179
|
+
}
|
|
5180
|
+
} catch (error2) {
|
|
5181
|
+
if (error2 instanceof GitOperationError || error2 instanceof WorktreeError) {
|
|
5182
|
+
throw error2;
|
|
4626
5183
|
}
|
|
4627
|
-
} catch {
|
|
4628
5184
|
}
|
|
4629
5185
|
try {
|
|
4630
5186
|
const useNoCheckout = !!this.config.sparseCheckout;
|
|
@@ -4646,7 +5202,7 @@ var GitService = class {
|
|
|
4646
5202
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
4647
5203
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
4648
5204
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4649
|
-
const existingWorktree = worktrees.find((w) =>
|
|
5205
|
+
const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4650
5206
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
4651
5207
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
4652
5208
|
return;
|
|
@@ -4715,9 +5271,19 @@ var GitService = class {
|
|
|
4715
5271
|
wrapped.isUpstreamSetupFailure = true;
|
|
4716
5272
|
return wrapped;
|
|
4717
5273
|
}
|
|
4718
|
-
async removeWorktree(worktreePath) {
|
|
5274
|
+
async removeWorktree(worktreePath, options) {
|
|
4719
5275
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
4720
|
-
|
|
5276
|
+
const args = ["worktree", "remove", worktreePath];
|
|
5277
|
+
if (options?.force) args.push("--force");
|
|
5278
|
+
try {
|
|
5279
|
+
await bareGit.raw(args);
|
|
5280
|
+
} catch (error) {
|
|
5281
|
+
const message = getErrorMessage(error);
|
|
5282
|
+
if (!options?.force && /contains modified or untracked files|use --force/i.test(message)) {
|
|
5283
|
+
throw new WorktreeNotCleanError(worktreePath, [`git refused removal: ${message}`]);
|
|
5284
|
+
}
|
|
5285
|
+
throw error;
|
|
5286
|
+
}
|
|
4721
5287
|
this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
|
|
4722
5288
|
try {
|
|
4723
5289
|
await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
@@ -4730,6 +5296,111 @@ var GitService = class {
|
|
|
4730
5296
|
await bareGit.raw(["worktree", "prune"]);
|
|
4731
5297
|
this.logger.info("Pruned worktree metadata.");
|
|
4732
5298
|
}
|
|
5299
|
+
async updateRef(refName, sha) {
|
|
5300
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5301
|
+
await bareGit.raw(["update-ref", refName, sha]);
|
|
5302
|
+
}
|
|
5303
|
+
async deleteRef(refName) {
|
|
5304
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5305
|
+
await bareGit.raw(["update-ref", "-d", refName]);
|
|
5306
|
+
}
|
|
5307
|
+
async listRefs(prefix) {
|
|
5308
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5309
|
+
const raw = await bareGit.raw(["for-each-ref", "--format=%(refname)", prefix]);
|
|
5310
|
+
return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
5311
|
+
}
|
|
5312
|
+
async localBranchExists(branchName) {
|
|
5313
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5314
|
+
try {
|
|
5315
|
+
await bareGit.raw(["show-ref", "--verify", "--quiet", `${GIT_CONSTANTS.REFS.HEADS}${branchName}`]);
|
|
5316
|
+
return true;
|
|
5317
|
+
} catch {
|
|
5318
|
+
return false;
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
async getLocalBranchCommit(branchName) {
|
|
5322
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5323
|
+
try {
|
|
5324
|
+
return (await bareGit.raw(["rev-parse", `${GIT_CONSTANTS.REFS.HEADS}${branchName}^{commit}`])).trim();
|
|
5325
|
+
} catch {
|
|
5326
|
+
return null;
|
|
5327
|
+
}
|
|
5328
|
+
}
|
|
5329
|
+
async createBranchAt(branchName, sha) {
|
|
5330
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5331
|
+
await bareGit.raw(["branch", branchName, sha]);
|
|
5332
|
+
}
|
|
5333
|
+
async deleteLocalBranch(branchName) {
|
|
5334
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5335
|
+
await bareGit.raw(["branch", "-D", branchName]);
|
|
5336
|
+
}
|
|
5337
|
+
// Bundles only commits not reachable from any remote — for fully-pushed
|
|
5338
|
+
// refs that set is empty and `bundle create` would fail. Emptiness is
|
|
5339
|
+
// pre-checked with rev-list (locale-independent) instead of parsing git's
|
|
5340
|
+
// localized "empty bundle" stderr; after the pre-check, any bundle-create
|
|
5341
|
+
// error is a real failure the caller must treat as fail-closed.
|
|
5342
|
+
async createBundleFromRef(bundlePath, refName) {
|
|
5343
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5344
|
+
const count = (await bareGit.raw(["rev-list", "--count", refName, "--not", "--remotes"])).trim();
|
|
5345
|
+
if (count === "0") {
|
|
5346
|
+
return false;
|
|
5347
|
+
}
|
|
5348
|
+
await bareGit.raw(["bundle", "create", bundlePath, refName, "--not", "--remotes"]);
|
|
5349
|
+
return true;
|
|
5350
|
+
}
|
|
5351
|
+
// Registers the worktree and writes its .git link without populating files —
|
|
5352
|
+
// restore overlays the preserved payload instead of a fresh checkout.
|
|
5353
|
+
async addWorktreeNoCheckout(branchName, worktreePath) {
|
|
5354
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5355
|
+
const absoluteWorktreePath = path11.resolve(worktreePath);
|
|
5356
|
+
await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
|
|
5357
|
+
await bareGit.raw(["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName]);
|
|
5358
|
+
}
|
|
5359
|
+
// Mixed reset: points the index at HEAD without touching working files, so
|
|
5360
|
+
// overlaid payload content shows up as ordinary uncommitted changes.
|
|
5361
|
+
async resetWorktreeIndex(worktreePath) {
|
|
5362
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
5363
|
+
await worktreeGit.raw(["reset"]);
|
|
5364
|
+
}
|
|
5365
|
+
// Injected by WorktreeSyncService when trash is enabled, so stale-directory
|
|
5366
|
+
// cleanup follows the same reversible-removal pipeline as everything else.
|
|
5367
|
+
// GitService cannot own a TrashService directly (TrashService depends on it).
|
|
5368
|
+
staleDirectoryTrasher = null;
|
|
5369
|
+
setStaleDirectoryTrasher(trasher) {
|
|
5370
|
+
this.staleDirectoryTrasher = trasher;
|
|
5371
|
+
}
|
|
5372
|
+
// A stale directory that contains a .git may be a live checkout that git
|
|
5373
|
+
// failed to report; quarantine it instead of deleting.
|
|
5374
|
+
async clearStaleWorktreeDirectory(absoluteWorktreePath) {
|
|
5375
|
+
const gitProbe = await probePathExists(path11.join(absoluteWorktreePath, PATH_CONSTANTS.GIT_DIR));
|
|
5376
|
+
if (gitProbe === "unknown") {
|
|
5377
|
+
throw new GitOperationError(
|
|
5378
|
+
"clear-stale-directory",
|
|
5379
|
+
`Cannot verify whether '${absoluteWorktreePath}' is a live checkout; refusing to clear it`
|
|
5380
|
+
);
|
|
5381
|
+
}
|
|
5382
|
+
if (this.staleDirectoryTrasher) {
|
|
5383
|
+
try {
|
|
5384
|
+
const trashPath = await this.staleDirectoryTrasher(absoluteWorktreePath);
|
|
5385
|
+
this.logger.info(` - Moved stale directory at '${absoluteWorktreePath}' to trash ('${trashPath}')`);
|
|
5386
|
+
return;
|
|
5387
|
+
} catch (error) {
|
|
5388
|
+
throw new GitOperationError(
|
|
5389
|
+
"clear-stale-directory",
|
|
5390
|
+
`Cannot move stale directory '${absoluteWorktreePath}' to trash: ${getErrorMessage(error)}`,
|
|
5391
|
+
error instanceof Error ? error : void 0
|
|
5392
|
+
);
|
|
5393
|
+
}
|
|
5394
|
+
}
|
|
5395
|
+
if (gitProbe === "exists") {
|
|
5396
|
+
const quarantinePath = await quarantineDirectory(absoluteWorktreePath);
|
|
5397
|
+
this.logger.warn(
|
|
5398
|
+
` - \u26A0\uFE0F Directory at '${absoluteWorktreePath}' contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
|
|
5399
|
+
);
|
|
5400
|
+
return;
|
|
5401
|
+
}
|
|
5402
|
+
await fs9.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
5403
|
+
}
|
|
4733
5404
|
async checkWorktreeStatus(worktreePath) {
|
|
4734
5405
|
return this.statusService.checkWorktreeStatus(worktreePath);
|
|
4735
5406
|
}
|
|
@@ -4745,7 +5416,37 @@ var GitService = class {
|
|
|
4745
5416
|
}
|
|
4746
5417
|
async getFullWorktreeStatus(worktreePath, includeDetails = false) {
|
|
4747
5418
|
const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
4748
|
-
return this.statusService.getFullWorktreeStatus(
|
|
5419
|
+
return this.statusService.getFullWorktreeStatus(
|
|
5420
|
+
worktreePath,
|
|
5421
|
+
includeDetails,
|
|
5422
|
+
metadata?.lastSyncCommit,
|
|
5423
|
+
metadata?.lastKnownRemoteTip
|
|
5424
|
+
);
|
|
5425
|
+
}
|
|
5426
|
+
/** Map of remote branch name (without "origin/") → tip oid, from the bare repo. */
|
|
5427
|
+
async getRemoteBranchTips() {
|
|
5428
|
+
const git = this.getGit();
|
|
5429
|
+
const raw = await git.raw(["for-each-ref", "--format=%(refname:short) %(objectname)", GIT_CONSTANTS.REFS.REMOTES]);
|
|
5430
|
+
const tips = /* @__PURE__ */ new Map();
|
|
5431
|
+
for (const line of raw.split("\n")) {
|
|
5432
|
+
const trimmed = line.trim();
|
|
5433
|
+
if (!trimmed) continue;
|
|
5434
|
+
const spaceIdx = trimmed.lastIndexOf(" ");
|
|
5435
|
+
if (spaceIdx <= 0) continue;
|
|
5436
|
+
const ref = trimmed.slice(0, spaceIdx);
|
|
5437
|
+
const oid = trimmed.slice(spaceIdx + 1);
|
|
5438
|
+
if (!ref.startsWith(GIT_CONSTANTS.REMOTE_PREFIX) || ref === `${GIT_CONSTANTS.REMOTE_PREFIX}HEAD`) continue;
|
|
5439
|
+
tips.set(ref.slice(GIT_CONSTANTS.REMOTE_PREFIX.length), oid);
|
|
5440
|
+
}
|
|
5441
|
+
return tips;
|
|
5442
|
+
}
|
|
5443
|
+
async recordRemoteTip(worktreePath, branchName, oid) {
|
|
5444
|
+
await this.metadataService.recordRemoteTip(
|
|
5445
|
+
this.bareRepoPath,
|
|
5446
|
+
worktreePath,
|
|
5447
|
+
`${GIT_CONSTANTS.REMOTE_PREFIX}${branchName}`,
|
|
5448
|
+
oid
|
|
5449
|
+
);
|
|
4749
5450
|
}
|
|
4750
5451
|
async hasModifiedSubmodules(worktreePath) {
|
|
4751
5452
|
return this.statusService.hasModifiedSubmodules(worktreePath);
|
|
@@ -5030,31 +5731,32 @@ var ProgressEmitter = class {
|
|
|
5030
5731
|
}
|
|
5031
5732
|
};
|
|
5032
5733
|
|
|
5033
|
-
// src/services/
|
|
5034
|
-
import * as
|
|
5035
|
-
import * as
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5734
|
+
// src/services/removal-audit.service.ts
|
|
5735
|
+
import * as fs10 from "fs/promises";
|
|
5736
|
+
import * as path12 from "path";
|
|
5737
|
+
var RemovalAuditService = class {
|
|
5738
|
+
constructor(logFilePath) {
|
|
5739
|
+
this.logFilePath = logFilePath;
|
|
5740
|
+
}
|
|
5741
|
+
logFilePath;
|
|
5742
|
+
async record(entry) {
|
|
5743
|
+
await fs10.mkdir(path12.dirname(this.logFilePath), { recursive: true });
|
|
5744
|
+
const line = JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
5745
|
+
const handle = await fs10.open(this.logFilePath, "a");
|
|
5746
|
+
try {
|
|
5747
|
+
await handle.appendFile(`${line}
|
|
5748
|
+
`, "utf-8");
|
|
5749
|
+
await handle.sync();
|
|
5750
|
+
} finally {
|
|
5751
|
+
await handle.close();
|
|
5752
|
+
}
|
|
5051
5753
|
}
|
|
5052
|
-
|
|
5053
|
-
const dir = path9.join(stateBase, "sync-worktrees", "locks");
|
|
5054
|
-
return { dir, file: `${hash}.lock` };
|
|
5055
|
-
}
|
|
5754
|
+
};
|
|
5056
5755
|
|
|
5057
5756
|
// src/services/repo-operation-lock.ts
|
|
5757
|
+
import * as fs11 from "fs/promises";
|
|
5758
|
+
import * as path13 from "path";
|
|
5759
|
+
import * as lockfile from "proper-lockfile";
|
|
5058
5760
|
var RepoOperationLock = class {
|
|
5059
5761
|
constructor(config, gitService, logger = Logger.createDefault()) {
|
|
5060
5762
|
this.config = config;
|
|
@@ -5079,10 +5781,10 @@ var RepoOperationLock = class {
|
|
|
5079
5781
|
}
|
|
5080
5782
|
async acquireCloneModeLock() {
|
|
5081
5783
|
const target = getCloneModeLockTarget(this.config);
|
|
5082
|
-
const lockTarget =
|
|
5784
|
+
const lockTarget = path13.join(target.dir, target.file);
|
|
5083
5785
|
try {
|
|
5084
|
-
await
|
|
5085
|
-
await
|
|
5786
|
+
await fs11.mkdir(target.dir, { recursive: true });
|
|
5787
|
+
await fs11.writeFile(lockTarget, "", { flag: "a" });
|
|
5086
5788
|
} catch {
|
|
5087
5789
|
return null;
|
|
5088
5790
|
}
|
|
@@ -5091,7 +5793,7 @@ var RepoOperationLock = class {
|
|
|
5091
5793
|
async acquireWorktreeModeLock() {
|
|
5092
5794
|
const barePath = this.gitService.getBareRepoPath();
|
|
5093
5795
|
try {
|
|
5094
|
-
await
|
|
5796
|
+
await fs11.mkdir(barePath, { recursive: true });
|
|
5095
5797
|
} catch {
|
|
5096
5798
|
return null;
|
|
5097
5799
|
}
|
|
@@ -5152,80 +5854,756 @@ var SyncRetryPolicy = class {
|
|
|
5152
5854
|
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
5153
5855
|
`);
|
|
5154
5856
|
}
|
|
5155
|
-
},
|
|
5156
|
-
lfsRetryHandler: () => {
|
|
5157
|
-
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
5158
|
-
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
5159
|
-
this.gitService.setLfsSkipEnabled(true);
|
|
5160
|
-
syncContext.lfsSkipEnabled = true;
|
|
5857
|
+
},
|
|
5858
|
+
lfsRetryHandler: () => {
|
|
5859
|
+
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
5860
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
5861
|
+
this.gitService.setLfsSkipEnabled(true);
|
|
5862
|
+
syncContext.lfsSkipEnabled = true;
|
|
5863
|
+
}
|
|
5864
|
+
}
|
|
5865
|
+
};
|
|
5866
|
+
}
|
|
5867
|
+
resetLfsSkipIfNeeded(syncContext) {
|
|
5868
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
5869
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
5870
|
+
}
|
|
5871
|
+
}
|
|
5872
|
+
};
|
|
5873
|
+
|
|
5874
|
+
// src/services/trash-migration.service.ts
|
|
5875
|
+
import * as fs12 from "fs/promises";
|
|
5876
|
+
import * as path14 from "path";
|
|
5877
|
+
var REMOVED_ENTRY_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-(.+)$/;
|
|
5878
|
+
var TrashMigrationService = class {
|
|
5879
|
+
constructor(config, trashService, logger) {
|
|
5880
|
+
this.config = config;
|
|
5881
|
+
this.trashService = trashService;
|
|
5882
|
+
this.logger = logger;
|
|
5883
|
+
}
|
|
5884
|
+
config;
|
|
5885
|
+
trashService;
|
|
5886
|
+
logger;
|
|
5887
|
+
updateLogger(logger) {
|
|
5888
|
+
this.logger = logger;
|
|
5889
|
+
}
|
|
5890
|
+
isEnabled() {
|
|
5891
|
+
return this.trashService.isEnabled() && (this.config.trash?.migrateLegacy ?? DEFAULT_CONFIG.TRASH.MIGRATE_LEGACY);
|
|
5892
|
+
}
|
|
5893
|
+
async migrateLegacyUnlocked() {
|
|
5894
|
+
if (!this.isEnabled()) return;
|
|
5895
|
+
await this.migrateRemovedDir();
|
|
5896
|
+
await this.migrateDivergedDir();
|
|
5897
|
+
}
|
|
5898
|
+
async migrateRemovedDir() {
|
|
5899
|
+
const removedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.REMOVED_DIR_NAME);
|
|
5900
|
+
const names = await this.listDirectories(removedDir);
|
|
5901
|
+
for (const name of names) {
|
|
5902
|
+
const match = REMOVED_ENTRY_RE.exec(name);
|
|
5903
|
+
const quarantinedAt = match ? this.parseQuarantineTimestamp(match[1]) : null;
|
|
5904
|
+
if (!match || !quarantinedAt) {
|
|
5905
|
+
this.logger.warn(`\u26A0\uFE0F Leaving unrecognized entry '${name}' in ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ alone`);
|
|
5906
|
+
continue;
|
|
5907
|
+
}
|
|
5908
|
+
try {
|
|
5909
|
+
const entry = await this.trashService.trashDirectory({
|
|
5910
|
+
dirPath: path14.join(removedDir, name),
|
|
5911
|
+
reason: "legacy-adopt",
|
|
5912
|
+
source: ".removed",
|
|
5913
|
+
legacyOriginalName: name,
|
|
5914
|
+
legacyQuarantinedAt: quarantinedAt,
|
|
5915
|
+
headOid: null,
|
|
5916
|
+
originalPath: path14.join(this.config.worktreeDir, match[2]),
|
|
5917
|
+
auditAction: "trash_adopt"
|
|
5918
|
+
});
|
|
5919
|
+
this.logger.info(
|
|
5920
|
+
`\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
|
|
5921
|
+
);
|
|
5922
|
+
} catch (error) {
|
|
5923
|
+
this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
await fs12.rmdir(removedDir).catch(() => void 0);
|
|
5927
|
+
}
|
|
5928
|
+
async migrateDivergedDir() {
|
|
5929
|
+
const divergedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5930
|
+
const names = await this.listDirectories(divergedDir);
|
|
5931
|
+
for (const name of names) {
|
|
5932
|
+
const dirPath = path14.join(divergedDir, name);
|
|
5933
|
+
const info = await this.readDivergedInfo(dirPath);
|
|
5934
|
+
const quarantinedAt = info?.divergedAt ? new Date(info.divergedAt) : null;
|
|
5935
|
+
const hasOriginalPath = typeof info?.originalPath === "string" && info.originalPath.length > 0;
|
|
5936
|
+
if (!info || !info.originalBranch || !hasOriginalPath || !quarantinedAt || Number.isNaN(quarantinedAt.getTime())) {
|
|
5937
|
+
this.logger.warn(
|
|
5938
|
+
`\u26A0\uFE0F Leaving entry '${name}' in ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ alone (no parseable ${METADATA_CONSTANTS.DIVERGED_INFO_FILE})`
|
|
5939
|
+
);
|
|
5940
|
+
continue;
|
|
5941
|
+
}
|
|
5942
|
+
try {
|
|
5943
|
+
const entry = await this.trashService.trashDirectory({
|
|
5944
|
+
dirPath,
|
|
5945
|
+
reason: "legacy-adopt",
|
|
5946
|
+
source: ".diverged",
|
|
5947
|
+
branch: info.originalBranch,
|
|
5948
|
+
legacyOriginalName: name,
|
|
5949
|
+
legacyQuarantinedAt: quarantinedAt,
|
|
5950
|
+
headOid: info.localCommit ?? null,
|
|
5951
|
+
originalPath: info.originalPath,
|
|
5952
|
+
auditAction: "trash_adopt",
|
|
5953
|
+
keepPinOnReap: true
|
|
5954
|
+
});
|
|
5955
|
+
this.logger.info(
|
|
5956
|
+
`\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
|
|
5957
|
+
);
|
|
5958
|
+
} catch (error) {
|
|
5959
|
+
this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
|
|
5960
|
+
}
|
|
5961
|
+
}
|
|
5962
|
+
await fs12.rmdir(divergedDir).catch(() => void 0);
|
|
5963
|
+
}
|
|
5964
|
+
async listDirectories(dirPath) {
|
|
5965
|
+
try {
|
|
5966
|
+
const dirents = await fs12.readdir(dirPath, { withFileTypes: true });
|
|
5967
|
+
return dirents.filter((dirent) => dirent.isDirectory() && !dirent.isSymbolicLink()).map((dirent) => dirent.name);
|
|
5968
|
+
} catch (error) {
|
|
5969
|
+
if (error.code !== "ENOENT") {
|
|
5970
|
+
this.logger.warn(`\u26A0\uFE0F Cannot scan '${dirPath}' for legacy trash adoption: ${getErrorMessage(error)}`);
|
|
5971
|
+
}
|
|
5972
|
+
return [];
|
|
5973
|
+
}
|
|
5974
|
+
}
|
|
5975
|
+
async readDivergedInfo(dirPath) {
|
|
5976
|
+
try {
|
|
5977
|
+
const raw = await fs12.readFile(path14.join(dirPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE), "utf-8");
|
|
5978
|
+
return JSON.parse(raw);
|
|
5979
|
+
} catch {
|
|
5980
|
+
return null;
|
|
5981
|
+
}
|
|
5982
|
+
}
|
|
5983
|
+
// quarantine timestamps replaced [:.] with "-": 2026-06-06T18-34-18-123Z
|
|
5984
|
+
parseQuarantineTimestamp(raw) {
|
|
5985
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(raw);
|
|
5986
|
+
if (!match) return null;
|
|
5987
|
+
const date = /* @__PURE__ */ new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`);
|
|
5988
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
5989
|
+
}
|
|
5990
|
+
};
|
|
5991
|
+
|
|
5992
|
+
// src/services/trash-reaper.service.ts
|
|
5993
|
+
import * as fs14 from "fs/promises";
|
|
5994
|
+
import * as path16 from "path";
|
|
5995
|
+
|
|
5996
|
+
// src/utils/disk-space.ts
|
|
5997
|
+
import fastFolderSize from "fast-folder-size";
|
|
5998
|
+
async function calculateDirectorySize(dirPath) {
|
|
5999
|
+
return new Promise((resolve14, reject) => {
|
|
6000
|
+
fastFolderSize(dirPath, (err, bytes) => {
|
|
6001
|
+
if (err) {
|
|
6002
|
+
reject(err);
|
|
6003
|
+
return;
|
|
6004
|
+
}
|
|
6005
|
+
if (bytes === void 0) {
|
|
6006
|
+
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
6007
|
+
return;
|
|
6008
|
+
}
|
|
6009
|
+
resolve14(bytes);
|
|
6010
|
+
});
|
|
6011
|
+
});
|
|
6012
|
+
}
|
|
6013
|
+
function formatBytes(bytes) {
|
|
6014
|
+
if (bytes === 0) return "0 B";
|
|
6015
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
6016
|
+
const k = 1024;
|
|
6017
|
+
const decimals = 2;
|
|
6018
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
6019
|
+
const value = bytes / Math.pow(k, i);
|
|
6020
|
+
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
6021
|
+
}
|
|
6022
|
+
async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
|
|
6023
|
+
try {
|
|
6024
|
+
let totalBytes = 0;
|
|
6025
|
+
for (const repoPath of repoPaths) {
|
|
6026
|
+
try {
|
|
6027
|
+
totalBytes += await calculateDirectorySize(repoPath);
|
|
6028
|
+
} catch {
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
for (const worktreeDir of worktreeDirs) {
|
|
6032
|
+
try {
|
|
6033
|
+
totalBytes += await calculateDirectorySize(worktreeDir);
|
|
6034
|
+
} catch {
|
|
6035
|
+
}
|
|
6036
|
+
}
|
|
6037
|
+
return formatBytes(totalBytes);
|
|
6038
|
+
} catch (error) {
|
|
6039
|
+
console.error("Failed to calculate disk space:", error);
|
|
6040
|
+
return "N/A";
|
|
6041
|
+
}
|
|
6042
|
+
}
|
|
6043
|
+
|
|
6044
|
+
// src/services/trash.service.ts
|
|
6045
|
+
import { randomBytes } from "crypto";
|
|
6046
|
+
import * as fs13 from "fs/promises";
|
|
6047
|
+
import * as path15 from "path";
|
|
6048
|
+
function isWorktreeRestorable(manifest) {
|
|
6049
|
+
return manifest.branch !== null && manifest.headOid !== null && manifest.pinRef !== null;
|
|
6050
|
+
}
|
|
6051
|
+
function summarizeTrashEntries(entries) {
|
|
6052
|
+
let totalSizeBytes = 0;
|
|
6053
|
+
let unknownSizeCount = 0;
|
|
6054
|
+
let soonest = null;
|
|
6055
|
+
for (const { manifest } of entries) {
|
|
6056
|
+
if (manifest.sizeBytes === null) {
|
|
6057
|
+
unknownSizeCount++;
|
|
6058
|
+
} else {
|
|
6059
|
+
totalSizeBytes += manifest.sizeBytes;
|
|
6060
|
+
}
|
|
6061
|
+
if (soonest === null || manifest.expiresAt < soonest) {
|
|
6062
|
+
soonest = manifest.expiresAt;
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
return { itemCount: entries.length, totalSizeBytes, unknownSizeCount, soonestExpiresAt: soonest };
|
|
6066
|
+
}
|
|
6067
|
+
var TrashService = class {
|
|
6068
|
+
constructor(config, gitService, logger, removalAudit) {
|
|
6069
|
+
this.config = config;
|
|
6070
|
+
this.gitService = gitService;
|
|
6071
|
+
this.logger = logger;
|
|
6072
|
+
this.removalAudit = removalAudit;
|
|
6073
|
+
}
|
|
6074
|
+
config;
|
|
6075
|
+
gitService;
|
|
6076
|
+
logger;
|
|
6077
|
+
removalAudit;
|
|
6078
|
+
updateLogger(logger) {
|
|
6079
|
+
this.logger = logger;
|
|
6080
|
+
}
|
|
6081
|
+
isEnabled() {
|
|
6082
|
+
return this.config.trash?.enabled ?? DEFAULT_CONFIG.TRASH.ENABLED;
|
|
6083
|
+
}
|
|
6084
|
+
getTrashRoot() {
|
|
6085
|
+
return path15.join(this.config.worktreeDir, GIT_CONSTANTS.TRASH_DIR_NAME);
|
|
6086
|
+
}
|
|
6087
|
+
getRetentionDays() {
|
|
6088
|
+
return this.config.trash?.retentionDays ?? DEFAULT_CONFIG.TRASH.RETENTION_DAYS;
|
|
6089
|
+
}
|
|
6090
|
+
async trashDirectory(options) {
|
|
6091
|
+
const deletedAt = /* @__PURE__ */ new Date();
|
|
6092
|
+
const expiresAt = new Date(deletedAt.getTime() + this.getRetentionDays() * 864e5);
|
|
6093
|
+
const keepPinOnReap = options.keepPinOnReap ?? false;
|
|
6094
|
+
const headOid = options.headOid !== void 0 ? options.headOid : await this.resolveHeadOid(options);
|
|
6095
|
+
if (keepPinOnReap && !headOid) {
|
|
6096
|
+
throw new TrashOperationError(
|
|
6097
|
+
"trash-directory",
|
|
6098
|
+
`cannot create keep-on-reap trash entry for '${options.dirPath}': HEAD commit could not be resolved`
|
|
6099
|
+
);
|
|
6100
|
+
}
|
|
6101
|
+
const sizeBytes = await calculateDirectorySize(options.dirPath).catch(() => null);
|
|
6102
|
+
await fs13.mkdir(this.getTrashRoot(), { recursive: true });
|
|
6103
|
+
const { id, containerPath } = await this.createContainer(deletedAt, path15.basename(options.dirPath));
|
|
6104
|
+
const manifest = {
|
|
6105
|
+
schemaVersion: TRASH_CONSTANTS.SCHEMA_VERSION,
|
|
6106
|
+
id,
|
|
6107
|
+
deletedAt: deletedAt.toISOString(),
|
|
6108
|
+
expiresAt: expiresAt.toISOString(),
|
|
6109
|
+
originalPath: path15.resolve(options.originalPath ?? options.dirPath),
|
|
6110
|
+
branch: options.branch ?? null,
|
|
6111
|
+
reason: options.reason,
|
|
6112
|
+
sizeBytes,
|
|
6113
|
+
headOid,
|
|
6114
|
+
pinRef: null,
|
|
6115
|
+
bundleFile: null,
|
|
6116
|
+
source: options.source ?? "worktree",
|
|
6117
|
+
legacyOriginalName: options.legacyOriginalName ?? null,
|
|
6118
|
+
legacyQuarantinedAt: options.legacyQuarantinedAt?.toISOString() ?? null,
|
|
6119
|
+
keepPinOnReap
|
|
6120
|
+
};
|
|
6121
|
+
try {
|
|
6122
|
+
await this.writeManifest(containerPath, manifest);
|
|
6123
|
+
} catch (error) {
|
|
6124
|
+
await this.undoPartialTrash(containerPath, null);
|
|
6125
|
+
throw new TrashOperationError(
|
|
6126
|
+
"trash-directory",
|
|
6127
|
+
`cannot write trash manifest for '${options.dirPath}': ${getErrorMessage(error)}`,
|
|
6128
|
+
error instanceof Error ? error : void 0
|
|
6129
|
+
);
|
|
6130
|
+
}
|
|
6131
|
+
const pinRef = headOid ? await this.createPinRef(id, headOid) : null;
|
|
6132
|
+
if (keepPinOnReap && !pinRef) {
|
|
6133
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
6134
|
+
throw new TrashOperationError(
|
|
6135
|
+
"trash-directory",
|
|
6136
|
+
`cannot create keep-on-reap trash entry '${id}' for '${options.dirPath}': pin ref could not be created`
|
|
6137
|
+
);
|
|
6138
|
+
}
|
|
6139
|
+
let bundleFile = null;
|
|
6140
|
+
if (keepPinOnReap && pinRef) {
|
|
6141
|
+
try {
|
|
6142
|
+
const created = await this.gitService.createBundleFromRef(
|
|
6143
|
+
path15.join(containerPath, TRASH_CONSTANTS.BUNDLE_FILENAME),
|
|
6144
|
+
pinRef
|
|
6145
|
+
);
|
|
6146
|
+
bundleFile = created ? TRASH_CONSTANTS.BUNDLE_FILENAME : null;
|
|
6147
|
+
} catch (error) {
|
|
6148
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
6149
|
+
throw new TrashOperationError(
|
|
6150
|
+
"trash-directory",
|
|
6151
|
+
`cannot bundle commits for keep-on-reap trash entry '${id}': ${getErrorMessage(error)}`,
|
|
6152
|
+
error instanceof Error ? error : void 0
|
|
6153
|
+
);
|
|
6154
|
+
}
|
|
6155
|
+
}
|
|
6156
|
+
const payloadPath = path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME);
|
|
6157
|
+
manifest.pinRef = pinRef;
|
|
6158
|
+
manifest.bundleFile = bundleFile;
|
|
6159
|
+
try {
|
|
6160
|
+
await this.writeManifest(containerPath, manifest);
|
|
6161
|
+
await fs13.rename(options.dirPath, payloadPath);
|
|
6162
|
+
} catch (error) {
|
|
6163
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
6164
|
+
const hint = error.code === "EXDEV" ? " (trash lives inside worktreeDir; a cross-device rename means the directory is on a different filesystem \u2014 co-locate it or set trash.enabled=false)" : "";
|
|
6165
|
+
throw new TrashOperationError(
|
|
6166
|
+
"trash-directory",
|
|
6167
|
+
`cannot move '${options.dirPath}' to trash${hint}: ${getErrorMessage(error)}`,
|
|
6168
|
+
error instanceof Error ? error : void 0
|
|
6169
|
+
);
|
|
6170
|
+
}
|
|
6171
|
+
await this.removalAudit.record({
|
|
6172
|
+
action: options.auditAction ?? "trash_create",
|
|
6173
|
+
result: "success",
|
|
6174
|
+
path: manifest.originalPath,
|
|
6175
|
+
branch: manifest.branch ?? void 0,
|
|
6176
|
+
trashId: id,
|
|
6177
|
+
trashPath: payloadPath
|
|
6178
|
+
}).catch(
|
|
6179
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
6180
|
+
);
|
|
6181
|
+
return { manifest, containerPath, payloadPath };
|
|
6182
|
+
}
|
|
6183
|
+
async listEntries() {
|
|
6184
|
+
const root = this.getTrashRoot();
|
|
6185
|
+
const entries = [];
|
|
6186
|
+
const invalid = [];
|
|
6187
|
+
let dirents;
|
|
6188
|
+
try {
|
|
6189
|
+
dirents = await fs13.readdir(root, { withFileTypes: true });
|
|
6190
|
+
} catch (error) {
|
|
6191
|
+
if (error.code === "ENOENT") {
|
|
6192
|
+
return { entries, invalid };
|
|
6193
|
+
}
|
|
6194
|
+
throw error;
|
|
6195
|
+
}
|
|
6196
|
+
for (const dirent of dirents) {
|
|
6197
|
+
const containerPath = path15.join(root, dirent.name);
|
|
6198
|
+
if (dirent.isSymbolicLink()) {
|
|
6199
|
+
invalid.push(containerPath);
|
|
6200
|
+
continue;
|
|
6201
|
+
}
|
|
6202
|
+
if (!dirent.isDirectory()) {
|
|
6203
|
+
continue;
|
|
6204
|
+
}
|
|
6205
|
+
const manifest = await this.readManifest(containerPath);
|
|
6206
|
+
if (manifest === null) {
|
|
6207
|
+
invalid.push(containerPath);
|
|
6208
|
+
continue;
|
|
6209
|
+
}
|
|
6210
|
+
entries.push({
|
|
6211
|
+
manifest,
|
|
6212
|
+
containerPath,
|
|
6213
|
+
payloadPath: path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME)
|
|
6214
|
+
});
|
|
6215
|
+
}
|
|
6216
|
+
return { entries, invalid };
|
|
6217
|
+
}
|
|
6218
|
+
// The full reversible-removal sequence shared by prune and manual removal:
|
|
6219
|
+
// payload to trash, dangling registration cleared, branch ref deleted.
|
|
6220
|
+
// A ref-delete failure is a hygiene problem, not a failed removal — the
|
|
6221
|
+
// payload and pin ref already capture everything restore needs, and restore
|
|
6222
|
+
// tolerates a leftover ref at the trashed commit.
|
|
6223
|
+
async trashAndUnregisterWorktree(options) {
|
|
6224
|
+
const entry = await this.trashDirectory(options);
|
|
6225
|
+
await this.gitService.removeWorktree(options.dirPath, { force: true });
|
|
6226
|
+
let branchRefError;
|
|
6227
|
+
try {
|
|
6228
|
+
await this.deleteTrashedBranchRef(entry.manifest);
|
|
6229
|
+
} catch (refError) {
|
|
6230
|
+
branchRefError = getErrorMessage(refError);
|
|
6231
|
+
this.logger.warn(
|
|
6232
|
+
`\u26A0\uFE0F Leftover branch ref '${entry.manifest.branch}' after trashing '${entry.manifest.id}': ${branchRefError}`
|
|
6233
|
+
);
|
|
6234
|
+
}
|
|
6235
|
+
return { entry, branchRefError };
|
|
6236
|
+
}
|
|
6237
|
+
async restore(id) {
|
|
6238
|
+
const { entries } = await this.listEntries();
|
|
6239
|
+
const entry = entries.find((candidate) => candidate.manifest.id === id);
|
|
6240
|
+
if (!entry) {
|
|
6241
|
+
throw new TrashOperationError("restore", `no trash entry with id '${id}'`);
|
|
6242
|
+
}
|
|
6243
|
+
const { manifest, containerPath, payloadPath } = entry;
|
|
6244
|
+
if (await probePathExists(payloadPath) !== "exists") {
|
|
6245
|
+
throw new TrashOperationError("restore", `payload missing or unverifiable for '${id}' at '${payloadPath}'`);
|
|
6246
|
+
}
|
|
6247
|
+
const destinationProbe = await probePathExists(manifest.originalPath);
|
|
6248
|
+
if (destinationProbe !== "missing") {
|
|
6249
|
+
const why = destinationProbe === "exists" ? "already exists" : "cannot be verified";
|
|
6250
|
+
const hint = manifest.reason === "diverged-replace" && destinationProbe === "exists" ? " \u2014 a fresh worktree replaced this one when the branch diverged; remove that worktree first, or copy the files you need out of the trash payload manually" : "";
|
|
6251
|
+
throw new TrashOperationError("restore", `destination '${manifest.originalPath}' ${why}${hint}`);
|
|
6252
|
+
}
|
|
6253
|
+
if (isWorktreeRestorable(manifest)) {
|
|
6254
|
+
await this.restoreAsWorktree(manifest, payloadPath);
|
|
6255
|
+
} else {
|
|
6256
|
+
if (manifest.branch) {
|
|
6257
|
+
this.logger.warn(
|
|
6258
|
+
`\u26A0\uFE0F Trash entry '${id}' has no pinned commit; restoring files only \u2014 the directory will not be a registered worktree.`
|
|
6259
|
+
);
|
|
6260
|
+
}
|
|
6261
|
+
await fs13.rename(payloadPath, manifest.originalPath);
|
|
6262
|
+
}
|
|
6263
|
+
await fs13.rm(containerPath, { recursive: true, force: true }).catch(
|
|
6264
|
+
(error) => this.logger.warn(`\u26A0\uFE0F Failed to remove restored trash container '${containerPath}': ${getErrorMessage(error)}`)
|
|
6265
|
+
);
|
|
6266
|
+
if (manifest.pinRef) {
|
|
6267
|
+
await this.gitService.deleteRef(manifest.pinRef).catch(
|
|
6268
|
+
(error) => this.logger.warn(`\u26A0\uFE0F Failed to delete pin ref '${manifest.pinRef}': ${getErrorMessage(error)}`)
|
|
6269
|
+
);
|
|
6270
|
+
}
|
|
6271
|
+
await this.removalAudit.record({
|
|
6272
|
+
action: "trash_restore",
|
|
6273
|
+
result: "success",
|
|
6274
|
+
path: manifest.originalPath,
|
|
6275
|
+
branch: manifest.branch ?? void 0,
|
|
6276
|
+
trashId: id
|
|
6277
|
+
}).catch(
|
|
6278
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
6279
|
+
);
|
|
6280
|
+
return manifest;
|
|
6281
|
+
}
|
|
6282
|
+
async deleteTrashedBranchRef(manifest) {
|
|
6283
|
+
if (!manifest.branch) return;
|
|
6284
|
+
if (!manifest.pinRef) {
|
|
6285
|
+
this.logger.warn(
|
|
6286
|
+
`\u26A0\uFE0F Keeping branch ref '${manifest.branch}' after trashing '${manifest.id}': entry has no pin ref, so the ref is the only gc protection left`
|
|
6287
|
+
);
|
|
6288
|
+
return;
|
|
6289
|
+
}
|
|
6290
|
+
try {
|
|
6291
|
+
await this.gitService.deleteLocalBranch(manifest.branch);
|
|
6292
|
+
} catch (error) {
|
|
6293
|
+
throw new TrashOperationError(
|
|
6294
|
+
"trash-branch-ref",
|
|
6295
|
+
`cannot delete branch ref '${manifest.branch}' after trashing '${manifest.id}': ${getErrorMessage(error)}`,
|
|
6296
|
+
error instanceof Error ? error : void 0
|
|
6297
|
+
);
|
|
6298
|
+
}
|
|
6299
|
+
}
|
|
6300
|
+
async restoreAsWorktree(manifest, payloadPath) {
|
|
6301
|
+
const branch = manifest.branch;
|
|
6302
|
+
const headOid = manifest.headOid;
|
|
6303
|
+
const existingBranchOid = await this.gitService.getLocalBranchCommit(branch);
|
|
6304
|
+
let createdBranch = false;
|
|
6305
|
+
if (existingBranchOid !== null && existingBranchOid !== headOid) {
|
|
6306
|
+
throw new TrashOperationError(
|
|
6307
|
+
"restore",
|
|
6308
|
+
`branch '${branch}' already exists at ${existingBranchOid}; expected trashed commit ${headOid}. Restore the files manually from '${payloadPath}' or move that branch first`
|
|
6309
|
+
);
|
|
6310
|
+
}
|
|
6311
|
+
if (existingBranchOid === null) {
|
|
6312
|
+
await this.gitService.createBranchAt(branch, headOid);
|
|
6313
|
+
createdBranch = true;
|
|
6314
|
+
}
|
|
6315
|
+
try {
|
|
6316
|
+
await this.gitService.addWorktreeNoCheckout(branch, manifest.originalPath);
|
|
6317
|
+
await this.copyPayloadOver(payloadPath, manifest.originalPath);
|
|
6318
|
+
await this.gitService.resetWorktreeIndex(manifest.originalPath);
|
|
6319
|
+
if (this.config.sparseCheckout) {
|
|
6320
|
+
await this.gitService.getSparseCheckoutService().applyToWorktree(manifest.originalPath, this.config.sparseCheckout);
|
|
6321
|
+
}
|
|
6322
|
+
} catch (error) {
|
|
6323
|
+
await this.gitService.removeWorktree(manifest.originalPath, { force: true }).catch(
|
|
6324
|
+
(rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (worktree) failed: ${getErrorMessage(rollbackError)}`)
|
|
6325
|
+
);
|
|
6326
|
+
if (createdBranch) {
|
|
6327
|
+
await this.gitService.deleteLocalBranch(branch).catch(
|
|
6328
|
+
(rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (branch) failed: ${getErrorMessage(rollbackError)}`)
|
|
6329
|
+
);
|
|
6330
|
+
}
|
|
6331
|
+
throw new TrashOperationError(
|
|
6332
|
+
"restore",
|
|
6333
|
+
`failed to recreate worktree for '${manifest.id}'; trash entry left intact: ${getErrorMessage(error)}`,
|
|
6334
|
+
error instanceof Error ? error : void 0
|
|
6335
|
+
);
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6338
|
+
// The payload's top-level .git link points at a pruned admin dir; the fresh
|
|
6339
|
+
// one written by `worktree add --no-checkout` must survive the overlay.
|
|
6340
|
+
async copyPayloadOver(payloadPath, destination) {
|
|
6341
|
+
await fs13.cp(payloadPath, destination, {
|
|
6342
|
+
recursive: true,
|
|
6343
|
+
force: true,
|
|
6344
|
+
filter: (source) => !(path15.dirname(source) === payloadPath && path15.basename(source) === PATH_CONSTANTS.GIT_DIR)
|
|
6345
|
+
});
|
|
6346
|
+
}
|
|
6347
|
+
async resolveHeadOid(options) {
|
|
6348
|
+
if (!options.branch) return null;
|
|
6349
|
+
try {
|
|
6350
|
+
return (await this.gitService.getCurrentCommit(options.dirPath)).trim();
|
|
6351
|
+
} catch (error) {
|
|
6352
|
+
this.logger.warn(
|
|
6353
|
+
`\u26A0\uFE0F Could not resolve HEAD for '${options.dirPath}'; trash entry will preserve files only: ${getErrorMessage(error)}`
|
|
6354
|
+
);
|
|
6355
|
+
return null;
|
|
6356
|
+
}
|
|
6357
|
+
}
|
|
6358
|
+
// Pin failure degrades to a files-only trash entry rather than blocking the
|
|
6359
|
+
// removal — the payload itself is still fully preserved either way.
|
|
6360
|
+
async createPinRef(id, headOid) {
|
|
6361
|
+
const refName = `${GIT_CONSTANTS.TRASH_REF_PREFIX}${id}`;
|
|
6362
|
+
try {
|
|
6363
|
+
await this.gitService.updateRef(refName, headOid);
|
|
6364
|
+
return refName;
|
|
6365
|
+
} catch (error) {
|
|
6366
|
+
this.logger.warn(
|
|
6367
|
+
`\u26A0\uFE0F Could not pin '${headOid}' for trash entry '${id}'; git gc may collect its objects: ${getErrorMessage(error)}`
|
|
6368
|
+
);
|
|
6369
|
+
return null;
|
|
6370
|
+
}
|
|
6371
|
+
}
|
|
6372
|
+
async writeManifest(containerPath, manifest) {
|
|
6373
|
+
const manifestPath = path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME);
|
|
6374
|
+
await atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
6375
|
+
}
|
|
6376
|
+
async readManifest(containerPath) {
|
|
6377
|
+
try {
|
|
6378
|
+
const raw = await fs13.readFile(path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME), "utf-8");
|
|
6379
|
+
const parsed = JSON.parse(raw);
|
|
6380
|
+
if (typeof parsed.id !== "string" || typeof parsed.expiresAt !== "string" || typeof parsed.originalPath !== "string") {
|
|
6381
|
+
return null;
|
|
6382
|
+
}
|
|
6383
|
+
return parsed;
|
|
6384
|
+
} catch {
|
|
6385
|
+
return null;
|
|
6386
|
+
}
|
|
6387
|
+
}
|
|
6388
|
+
async undoPartialTrash(containerPath, pinRef) {
|
|
6389
|
+
await fs13.rm(containerPath, { recursive: true, force: true }).catch(() => void 0);
|
|
6390
|
+
if (pinRef) {
|
|
6391
|
+
await this.gitService.deleteRef(pinRef).catch(() => void 0);
|
|
6392
|
+
}
|
|
6393
|
+
}
|
|
6394
|
+
async createContainer(deletedAt, baseName) {
|
|
6395
|
+
let lastError;
|
|
6396
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
6397
|
+
const id = this.generateId(deletedAt, baseName);
|
|
6398
|
+
const containerPath = path15.join(this.getTrashRoot(), id);
|
|
6399
|
+
try {
|
|
6400
|
+
await fs13.mkdir(containerPath);
|
|
6401
|
+
return { id, containerPath };
|
|
6402
|
+
} catch (error) {
|
|
6403
|
+
lastError = error;
|
|
6404
|
+
if (error.code !== "EEXIST") break;
|
|
6405
|
+
}
|
|
6406
|
+
}
|
|
6407
|
+
throw new TrashOperationError(
|
|
6408
|
+
"trash-directory",
|
|
6409
|
+
`cannot create trash container for '${baseName}': ${getErrorMessage(lastError)}`,
|
|
6410
|
+
lastError instanceof Error ? lastError : void 0
|
|
6411
|
+
);
|
|
6412
|
+
}
|
|
6413
|
+
// The id doubles as a refname component (refs/sync-worktrees/trash/<id>).
|
|
6414
|
+
// The timestamp prefix and hex suffix rule out leading dots and ".lock"
|
|
6415
|
+
// endings, but ".." inside the name would still make the ref invalid and
|
|
6416
|
+
// silently degrade the entry to files-only.
|
|
6417
|
+
generateId(deletedAt, baseName) {
|
|
6418
|
+
const timestamp = filenameTimestamp(deletedAt);
|
|
6419
|
+
const safeName = baseName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
|
|
6420
|
+
return `${timestamp}-${safeName}-${randomBytes(3).toString("hex")}`;
|
|
6421
|
+
}
|
|
6422
|
+
};
|
|
6423
|
+
|
|
6424
|
+
// src/services/trash-reaper.service.ts
|
|
6425
|
+
var TrashReaperService = class {
|
|
6426
|
+
constructor(config, trashService, logger, removalAudit, gitService) {
|
|
6427
|
+
this.config = config;
|
|
6428
|
+
this.trashService = trashService;
|
|
6429
|
+
this.logger = logger;
|
|
6430
|
+
this.removalAudit = removalAudit;
|
|
6431
|
+
this.gitService = gitService;
|
|
6432
|
+
}
|
|
6433
|
+
config;
|
|
6434
|
+
trashService;
|
|
6435
|
+
logger;
|
|
6436
|
+
removalAudit;
|
|
6437
|
+
gitService;
|
|
6438
|
+
updateLogger(logger) {
|
|
6439
|
+
this.logger = logger;
|
|
6440
|
+
}
|
|
6441
|
+
// Disabled trash means "don't touch my trash" — existing entries are left
|
|
6442
|
+
// alone rather than aged out behind the user's back.
|
|
6443
|
+
async reapExpiredUnlocked(now = /* @__PURE__ */ new Date()) {
|
|
6444
|
+
if (!this.trashService.isEnabled()) return;
|
|
6445
|
+
let realRoot;
|
|
6446
|
+
try {
|
|
6447
|
+
realRoot = await fs14.realpath(this.trashService.getTrashRoot());
|
|
6448
|
+
} catch (error) {
|
|
6449
|
+
if (error.code === "ENOENT") {
|
|
6450
|
+
this.logger.debug(`Trash reaper: no trash root; skipping pin-ref sweep`);
|
|
6451
|
+
return;
|
|
6452
|
+
}
|
|
6453
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper skipped: cannot resolve trash root: ${getErrorMessage(error)}`);
|
|
6454
|
+
return;
|
|
6455
|
+
}
|
|
6456
|
+
const { entries, invalid } = await this.trashService.listEntries();
|
|
6457
|
+
for (const invalidPath of invalid) {
|
|
6458
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unrecognized entry '${invalidPath}' alone (no valid manifest)`);
|
|
6459
|
+
}
|
|
6460
|
+
const reapedIds = /* @__PURE__ */ new Set();
|
|
6461
|
+
for (const entry of entries) {
|
|
6462
|
+
const expiresAt = new Date(entry.manifest.expiresAt);
|
|
6463
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
6464
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' has an unparseable expiry; skipping`);
|
|
6465
|
+
continue;
|
|
6466
|
+
}
|
|
6467
|
+
if (expiresAt.getTime() > now.getTime()) continue;
|
|
6468
|
+
try {
|
|
6469
|
+
const realEntry = await fs14.realpath(entry.containerPath);
|
|
6470
|
+
if (!realEntry.startsWith(realRoot + path16.sep)) {
|
|
6471
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' resolves outside the trash root; skipping`);
|
|
6472
|
+
continue;
|
|
6473
|
+
}
|
|
6474
|
+
} catch (error) {
|
|
6475
|
+
this.logger.warn(
|
|
6476
|
+
`\u26A0\uFE0F Trash reaper: cannot verify path of entry '${entry.manifest.id}'; skipping: ${getErrorMessage(error)}`
|
|
6477
|
+
);
|
|
6478
|
+
continue;
|
|
6479
|
+
}
|
|
6480
|
+
let keepRef = null;
|
|
6481
|
+
if (entry.manifest.keepPinOnReap && entry.manifest.headOid) {
|
|
6482
|
+
keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}${entry.manifest.id}`;
|
|
6483
|
+
try {
|
|
6484
|
+
await this.gitService.updateRef(keepRef, entry.manifest.headOid);
|
|
6485
|
+
} catch (error) {
|
|
6486
|
+
this.logger.warn(
|
|
6487
|
+
`\u26A0\uFE0F Trash reaper: cannot create keep ref '${keepRef}' for '${entry.manifest.id}'; deferring reap: ${getErrorMessage(error)}`
|
|
6488
|
+
);
|
|
6489
|
+
continue;
|
|
5161
6490
|
}
|
|
5162
6491
|
}
|
|
5163
|
-
|
|
6492
|
+
try {
|
|
6493
|
+
await this.removalAudit.record({
|
|
6494
|
+
action: "trash_reap",
|
|
6495
|
+
result: "attempt",
|
|
6496
|
+
path: entry.manifest.originalPath,
|
|
6497
|
+
branch: entry.manifest.branch ?? void 0,
|
|
6498
|
+
trashId: entry.manifest.id,
|
|
6499
|
+
trashPath: entry.payloadPath
|
|
6500
|
+
});
|
|
6501
|
+
} catch (auditError) {
|
|
6502
|
+
this.logger.warn(
|
|
6503
|
+
`\u26A0\uFE0F Trash reaper: cannot write audit log; skipping '${entry.manifest.id}': ${getErrorMessage(auditError)}`
|
|
6504
|
+
);
|
|
6505
|
+
continue;
|
|
6506
|
+
}
|
|
6507
|
+
try {
|
|
6508
|
+
await fs14.rm(entry.containerPath, { recursive: true, force: true });
|
|
6509
|
+
} catch (error) {
|
|
6510
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete '${entry.manifest.id}': ${getErrorMessage(error)}`);
|
|
6511
|
+
await this.removalAudit.record({
|
|
6512
|
+
action: "trash_reap",
|
|
6513
|
+
result: "failure",
|
|
6514
|
+
path: entry.manifest.originalPath,
|
|
6515
|
+
trashId: entry.manifest.id,
|
|
6516
|
+
error: getErrorMessage(error)
|
|
6517
|
+
}).catch(() => void 0);
|
|
6518
|
+
continue;
|
|
6519
|
+
}
|
|
6520
|
+
if (entry.manifest.pinRef) {
|
|
6521
|
+
await this.gitService.deleteRef(entry.manifest.pinRef).catch(
|
|
6522
|
+
(error) => this.logger.warn(
|
|
6523
|
+
`\u26A0\uFE0F Trash reaper: failed to delete pin ref '${entry.manifest.pinRef}': ${getErrorMessage(error)}`
|
|
6524
|
+
)
|
|
6525
|
+
);
|
|
6526
|
+
}
|
|
6527
|
+
reapedIds.add(entry.manifest.id);
|
|
6528
|
+
this.logger.info(
|
|
6529
|
+
`\u{1F5D1}\uFE0F Trash reaper: deleted expired entry '${entry.manifest.id}' (trashed ${entry.manifest.deletedAt})`
|
|
6530
|
+
);
|
|
6531
|
+
if (keepRef) {
|
|
6532
|
+
this.logger.info(
|
|
6533
|
+
` Commits remain recoverable at '${keepRef}' (${entry.manifest.headOid}) \u2014 recover with: git branch <name> ${entry.manifest.headOid}`
|
|
6534
|
+
);
|
|
6535
|
+
}
|
|
6536
|
+
await this.removalAudit.record({
|
|
6537
|
+
action: "trash_reap",
|
|
6538
|
+
result: "success",
|
|
6539
|
+
path: entry.manifest.originalPath,
|
|
6540
|
+
trashId: entry.manifest.id
|
|
6541
|
+
}).catch(
|
|
6542
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
6543
|
+
);
|
|
6544
|
+
}
|
|
6545
|
+
let containerNames = null;
|
|
6546
|
+
try {
|
|
6547
|
+
containerNames = new Set(await fs14.readdir(realRoot));
|
|
6548
|
+
} catch (error) {
|
|
6549
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot scan trash root for pin-ref sweep: ${getErrorMessage(error)}`);
|
|
6550
|
+
}
|
|
6551
|
+
if (containerNames !== null) {
|
|
6552
|
+
await this.reapOrphanedPinRefs(containerNames);
|
|
6553
|
+
}
|
|
6554
|
+
this.warnIfOverThreshold(entries.filter((entry) => !reapedIds.has(entry.manifest.id)));
|
|
5164
6555
|
}
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
6556
|
+
// Pin refs whose trash container is gone would pin objects forever (failed
|
|
6557
|
+
// ref delete during restore, manually emptied trash). Keyed on container
|
|
6558
|
+
// existence, NOT manifest validity — an invalid-manifest entry still owns
|
|
6559
|
+
// its pin because the reaper refuses to delete its payload. Deliberately
|
|
6560
|
+
// any dirent name counts (files, symlinks): deleting a pin is irreversible
|
|
6561
|
+
// once gc runs, while a stray name collision merely keeps one ref alive.
|
|
6562
|
+
async reapOrphanedPinRefs(containerNames) {
|
|
6563
|
+
let refs;
|
|
6564
|
+
try {
|
|
6565
|
+
refs = await this.gitService.listRefs(GIT_CONSTANTS.TRASH_REF_PREFIX.replace(/\/$/, ""));
|
|
6566
|
+
} catch (error) {
|
|
6567
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot list pin refs: ${getErrorMessage(error)}`);
|
|
6568
|
+
return;
|
|
6569
|
+
}
|
|
6570
|
+
for (const ref of refs) {
|
|
6571
|
+
if (!ref.startsWith(GIT_CONSTANTS.TRASH_REF_PREFIX)) continue;
|
|
6572
|
+
const id = ref.slice(GIT_CONSTANTS.TRASH_REF_PREFIX.length);
|
|
6573
|
+
if (id.length === 0 || id.includes("/")) {
|
|
6574
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unexpected ref '${ref}' alone`);
|
|
6575
|
+
continue;
|
|
6576
|
+
}
|
|
6577
|
+
if (containerNames.has(id)) continue;
|
|
6578
|
+
try {
|
|
6579
|
+
await this.gitService.deleteRef(ref);
|
|
6580
|
+
this.logger.info(`\u{1F5D1}\uFE0F Trash reaper: deleted orphaned pin ref '${ref}'`);
|
|
6581
|
+
} catch (error) {
|
|
6582
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete orphaned pin ref '${ref}': ${getErrorMessage(error)}`);
|
|
6583
|
+
}
|
|
6584
|
+
}
|
|
6585
|
+
}
|
|
6586
|
+
warnIfOverThreshold(remaining) {
|
|
6587
|
+
const warnSizeBytes = this.config.trash?.warnSizeBytes;
|
|
6588
|
+
if (warnSizeBytes === void 0) return;
|
|
6589
|
+
const summary = summarizeTrashEntries(remaining);
|
|
6590
|
+
if (summary.totalSizeBytes > warnSizeBytes) {
|
|
6591
|
+
this.logger.warn(
|
|
6592
|
+
`\u26A0\uFE0F Trash holds ${formatBytes(summary.totalSizeBytes)} across ${summary.itemCount} entries (threshold ${formatBytes(warnSizeBytes)}). Entries expire ${this.trashService.getRetentionDays()} days after removal.`
|
|
6593
|
+
);
|
|
5168
6594
|
}
|
|
5169
6595
|
}
|
|
5170
6596
|
};
|
|
5171
6597
|
|
|
5172
6598
|
// src/services/worktree-mode-sync-runner.ts
|
|
5173
|
-
import * as
|
|
5174
|
-
import * as
|
|
6599
|
+
import * as fs16 from "fs/promises";
|
|
6600
|
+
import * as path19 from "path";
|
|
5175
6601
|
import pLimit from "p-limit";
|
|
5176
6602
|
|
|
5177
|
-
// src/utils/date-filter.ts
|
|
5178
|
-
function parseDuration(durationStr) {
|
|
5179
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5180
|
-
if (!match) {
|
|
5181
|
-
return null;
|
|
5182
|
-
}
|
|
5183
|
-
const value = parseInt(match[1], 10);
|
|
5184
|
-
const unit = match[2];
|
|
5185
|
-
const multipliers = {
|
|
5186
|
-
h: 60 * 60 * 1e3,
|
|
5187
|
-
// hours
|
|
5188
|
-
d: 24 * 60 * 60 * 1e3,
|
|
5189
|
-
// days
|
|
5190
|
-
w: 7 * 24 * 60 * 60 * 1e3,
|
|
5191
|
-
// weeks
|
|
5192
|
-
m: 30 * 24 * 60 * 60 * 1e3,
|
|
5193
|
-
// months (approximate)
|
|
5194
|
-
y: 365 * 24 * 60 * 60 * 1e3
|
|
5195
|
-
// years (approximate)
|
|
5196
|
-
};
|
|
5197
|
-
return value * multipliers[unit];
|
|
5198
|
-
}
|
|
5199
|
-
function filterBranchesByAge(branches, maxAge) {
|
|
5200
|
-
const maxAgeMs = parseDuration(maxAge);
|
|
5201
|
-
if (maxAgeMs === null) {
|
|
5202
|
-
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
5203
|
-
return branches;
|
|
5204
|
-
}
|
|
5205
|
-
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
5206
|
-
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
5207
|
-
}
|
|
5208
|
-
function formatDuration2(durationStr) {
|
|
5209
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5210
|
-
if (!match) {
|
|
5211
|
-
return durationStr;
|
|
5212
|
-
}
|
|
5213
|
-
const value = parseInt(match[1], 10);
|
|
5214
|
-
const unit = match[2];
|
|
5215
|
-
const unitNames = {
|
|
5216
|
-
h: value === 1 ? "hour" : "hours",
|
|
5217
|
-
d: value === 1 ? "day" : "days",
|
|
5218
|
-
w: value === 1 ? "week" : "weeks",
|
|
5219
|
-
m: value === 1 ? "month" : "months",
|
|
5220
|
-
y: value === 1 ? "year" : "years"
|
|
5221
|
-
};
|
|
5222
|
-
return `${value} ${unitNames[unit]}`;
|
|
5223
|
-
}
|
|
5224
|
-
|
|
5225
6603
|
// src/services/path-resolution.service.ts
|
|
5226
6604
|
import { createHash as createHash2 } from "crypto";
|
|
5227
|
-
import * as
|
|
5228
|
-
import * as
|
|
6605
|
+
import * as fs15 from "fs";
|
|
6606
|
+
import * as path17 from "path";
|
|
5229
6607
|
var BRANCH_STEM_MAX = 80;
|
|
5230
6608
|
var BRANCH_HASH_LEN = 8;
|
|
5231
6609
|
var PathResolutionService = class {
|
|
@@ -5235,22 +6613,22 @@ var PathResolutionService = class {
|
|
|
5235
6613
|
return `${stem}-${hash}`;
|
|
5236
6614
|
}
|
|
5237
6615
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
5238
|
-
return
|
|
6616
|
+
return path17.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
5239
6617
|
}
|
|
5240
6618
|
resolveRealPath(inputPath) {
|
|
5241
|
-
const absolute =
|
|
6619
|
+
const absolute = path17.resolve(inputPath);
|
|
5242
6620
|
const missing = [];
|
|
5243
6621
|
let current = absolute;
|
|
5244
|
-
while (!
|
|
5245
|
-
const parent =
|
|
6622
|
+
while (!fs15.existsSync(current)) {
|
|
6623
|
+
const parent = path17.dirname(current);
|
|
5246
6624
|
if (parent === current) {
|
|
5247
6625
|
return absolute;
|
|
5248
6626
|
}
|
|
5249
|
-
missing.unshift(
|
|
6627
|
+
missing.unshift(path17.basename(current));
|
|
5250
6628
|
current = parent;
|
|
5251
6629
|
}
|
|
5252
6630
|
try {
|
|
5253
|
-
return
|
|
6631
|
+
return path17.join(fs15.realpathSync(current), ...missing);
|
|
5254
6632
|
} catch {
|
|
5255
6633
|
return absolute;
|
|
5256
6634
|
}
|
|
@@ -5260,7 +6638,7 @@ var PathResolutionService = class {
|
|
|
5260
6638
|
const a = fold(resolved);
|
|
5261
6639
|
const b = fold(resolvedBase);
|
|
5262
6640
|
if (a === b) return true;
|
|
5263
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
6641
|
+
return a.length > b.length && a.charAt(b.length) === path17.sep && a.startsWith(b);
|
|
5264
6642
|
}
|
|
5265
6643
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
5266
6644
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -5268,7 +6646,7 @@ var PathResolutionService = class {
|
|
|
5268
6646
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
5269
6647
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
5270
6648
|
}
|
|
5271
|
-
return
|
|
6649
|
+
return path17.relative(resolvedBase, resolved);
|
|
5272
6650
|
}
|
|
5273
6651
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
5274
6652
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -5281,7 +6659,7 @@ var PathResolutionService = class {
|
|
|
5281
6659
|
};
|
|
5282
6660
|
|
|
5283
6661
|
// src/services/worktree-sync-planner.ts
|
|
5284
|
-
import * as
|
|
6662
|
+
import * as path18 from "path";
|
|
5285
6663
|
function createWorktreeSyncPlan(inventory, options = {}) {
|
|
5286
6664
|
return {
|
|
5287
6665
|
create: planCreateActions(inventory, options),
|
|
@@ -5299,12 +6677,12 @@ function planCreateActions(inventory, options = {}) {
|
|
|
5299
6677
|
);
|
|
5300
6678
|
const reservedPaths = /* @__PURE__ */ new Map();
|
|
5301
6679
|
for (const worktree of inventory.existingWorktrees) {
|
|
5302
|
-
reservedPaths.set(
|
|
6680
|
+
reservedPaths.set(path18.resolve(worktree.path), worktree.branch);
|
|
5303
6681
|
}
|
|
5304
6682
|
const actions = [];
|
|
5305
6683
|
for (const branch of newBranches) {
|
|
5306
6684
|
const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
|
|
5307
|
-
const resolved =
|
|
6685
|
+
const resolved = path18.resolve(worktreePath);
|
|
5308
6686
|
const conflictingBranch = reservedPaths.get(resolved);
|
|
5309
6687
|
if (conflictingBranch && conflictingBranch !== branch) {
|
|
5310
6688
|
actions.push({
|
|
@@ -5342,25 +6720,30 @@ function planSparseActions(inventory, sparseCheckout) {
|
|
|
5342
6720
|
|
|
5343
6721
|
// src/services/worktree-mode-sync-runner.ts
|
|
5344
6722
|
var WorktreeModeSyncRunner = class {
|
|
5345
|
-
constructor(config, gitService, logger, progressEmitter) {
|
|
6723
|
+
constructor(config, gitService, logger, progressEmitter, services) {
|
|
5346
6724
|
this.config = config;
|
|
5347
6725
|
this.gitService = gitService;
|
|
5348
6726
|
this.logger = logger;
|
|
5349
6727
|
this.progressEmitter = progressEmitter;
|
|
6728
|
+
this.removalAudit = services?.removalAudit ?? new RemovalAuditService(getRemovalAuditLogPath(config));
|
|
6729
|
+
this.trashService = services?.trashService ?? new TrashService(config, gitService, logger, this.removalAudit);
|
|
5350
6730
|
}
|
|
5351
6731
|
config;
|
|
5352
6732
|
gitService;
|
|
5353
6733
|
logger;
|
|
5354
6734
|
progressEmitter;
|
|
5355
6735
|
pathResolution = new PathResolutionService();
|
|
6736
|
+
removalAudit;
|
|
6737
|
+
trashService;
|
|
5356
6738
|
updateLogger(logger) {
|
|
5357
6739
|
this.logger = logger;
|
|
6740
|
+
this.trashService.updateLogger(logger);
|
|
5358
6741
|
}
|
|
5359
6742
|
async runSyncAttempt(phaseTimer, syncContext, outcome) {
|
|
5360
6743
|
await this.gitService.pruneWorktrees();
|
|
5361
6744
|
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
5362
6745
|
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
5363
|
-
await
|
|
6746
|
+
await fs16.mkdir(this.config.worktreeDir, { recursive: true });
|
|
5364
6747
|
const worktrees = await this.gitService.getWorktrees();
|
|
5365
6748
|
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
5366
6749
|
await this.cleanupOrphanedDirectories(worktrees);
|
|
@@ -5378,6 +6761,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5378
6761
|
}
|
|
5379
6762
|
);
|
|
5380
6763
|
await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
|
|
6764
|
+
await this.recordRemoteBranchTips([...worktrees, ...syncPlan.create.filter((action) => action.kind === "create")]);
|
|
5381
6765
|
await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
|
|
5382
6766
|
if (this.config.updateExistingWorktrees !== false) {
|
|
5383
6767
|
await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
|
|
@@ -5400,7 +6784,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5400
6784
|
if (action.kind !== "check-sparse") return;
|
|
5401
6785
|
try {
|
|
5402
6786
|
try {
|
|
5403
|
-
await
|
|
6787
|
+
await fs16.access(action.path);
|
|
5404
6788
|
} catch {
|
|
5405
6789
|
return;
|
|
5406
6790
|
}
|
|
@@ -5486,7 +6870,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5486
6870
|
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
5487
6871
|
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
5488
6872
|
this.logger.info(
|
|
5489
|
-
`After filtering by age (${
|
|
6873
|
+
`After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
5490
6874
|
);
|
|
5491
6875
|
if (filteredByName.length > remoteBranches.length) {
|
|
5492
6876
|
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
@@ -5563,6 +6947,37 @@ var WorktreeModeSyncRunner = class {
|
|
|
5563
6947
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
5564
6948
|
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
5565
6949
|
}
|
|
6950
|
+
// Persist each worktree's upstream tip while the remote ref still exists.
|
|
6951
|
+
// This is the proof consulted after a squash-merge deletes the branch:
|
|
6952
|
+
// "HEAD was on the remote before the deletion" — without it every such
|
|
6953
|
+
// worktree reads as having unpushed commits forever. Best-effort: a failed
|
|
6954
|
+
// recording only means that worktree stays conservatively preserved.
|
|
6955
|
+
async recordRemoteBranchTips(worktrees) {
|
|
6956
|
+
try {
|
|
6957
|
+
const tips = await this.gitService.getRemoteBranchTips();
|
|
6958
|
+
if (tips.size === 0) return;
|
|
6959
|
+
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
6960
|
+
await Promise.all(
|
|
6961
|
+
worktrees.map(
|
|
6962
|
+
(wt) => limit(async () => {
|
|
6963
|
+
const oid = tips.get(wt.branch);
|
|
6964
|
+
if (!oid) return;
|
|
6965
|
+
await this.gitService.recordRemoteTip(wt.path, wt.branch, oid).catch(
|
|
6966
|
+
(error) => this.logger.warn(` - \u26A0\uFE0F Could not record remote tip for '${wt.branch}': ${getErrorMessage(error)}`)
|
|
6967
|
+
);
|
|
6968
|
+
})
|
|
6969
|
+
)
|
|
6970
|
+
);
|
|
6971
|
+
} catch (error) {
|
|
6972
|
+
this.logger.warn(`\u26A0\uFE0F Could not record remote branch tips: ${getErrorMessage(error)}`);
|
|
6973
|
+
}
|
|
6974
|
+
}
|
|
6975
|
+
// A removal authorized only by the fully-pushed proof must stay reversible:
|
|
6976
|
+
// without trash it would be a permanent delete of commits whose remote
|
|
6977
|
+
// branch may have been deleted unmerged.
|
|
6978
|
+
blockedByDisabledTrash(status) {
|
|
6979
|
+
return status.fullyPushedUpstreamDeleted && !this.trashService.isEnabled();
|
|
6980
|
+
}
|
|
5566
6981
|
async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
5567
6982
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
5568
6983
|
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
@@ -5592,7 +7007,18 @@ var WorktreeModeSyncRunner = class {
|
|
|
5592
7007
|
if (result.status === "fulfilled") {
|
|
5593
7008
|
const { branchName, worktreePath, status } = result.value;
|
|
5594
7009
|
if (status.canRemove) {
|
|
5595
|
-
|
|
7010
|
+
if (this.blockedByDisabledTrash(status)) {
|
|
7011
|
+
this.logger.warn(
|
|
7012
|
+
` - \u26A0\uFE0F '${branchName}' was fully pushed before its remote branch was deleted, but trash is disabled \u2014 keeping worktree. Enable trash for reversible auto-removal, or remove manually.`
|
|
7013
|
+
);
|
|
7014
|
+
outcome.recordSkipped("worktree", "fully_pushed_trash_disabled", {
|
|
7015
|
+
branch: branchName,
|
|
7016
|
+
path: worktreePath,
|
|
7017
|
+
message: "fully pushed before upstream deletion; trash disabled"
|
|
7018
|
+
});
|
|
7019
|
+
} else {
|
|
7020
|
+
toRemove.push({ branchName, worktreePath });
|
|
7021
|
+
}
|
|
5596
7022
|
} else {
|
|
5597
7023
|
toSkip.push({ branchName, worktreePath, status });
|
|
5598
7024
|
}
|
|
@@ -5615,7 +7041,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5615
7041
|
({ branchName, worktreePath }) => removeLimit(async () => {
|
|
5616
7042
|
try {
|
|
5617
7043
|
const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
|
|
5618
|
-
if (!recheck.canRemove) {
|
|
7044
|
+
if (!recheck.canRemove || this.blockedByDisabledTrash(recheck)) {
|
|
5619
7045
|
this.logger.warn(
|
|
5620
7046
|
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
5621
7047
|
);
|
|
@@ -5626,10 +7052,76 @@ var WorktreeModeSyncRunner = class {
|
|
|
5626
7052
|
});
|
|
5627
7053
|
return;
|
|
5628
7054
|
}
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
7055
|
+
try {
|
|
7056
|
+
await this.removalAudit.record({
|
|
7057
|
+
action: "prune_remove",
|
|
7058
|
+
result: "attempt",
|
|
7059
|
+
path: worktreePath,
|
|
7060
|
+
branch: branchName,
|
|
7061
|
+
status: recheck
|
|
7062
|
+
});
|
|
7063
|
+
} catch (auditError) {
|
|
7064
|
+
this.logger.warn(
|
|
7065
|
+
` \u26A0\uFE0F Skipping removal of '${branchName}' - cannot write removal audit log: ${getErrorMessage(auditError)}`
|
|
7066
|
+
);
|
|
7067
|
+
outcome.recordSkipped("worktree", "audit_log_unavailable", {
|
|
7068
|
+
branch: branchName,
|
|
7069
|
+
path: worktreePath,
|
|
7070
|
+
message: getErrorMessage(auditError)
|
|
7071
|
+
});
|
|
7072
|
+
return;
|
|
7073
|
+
}
|
|
7074
|
+
if (await probePathExists(worktreePath) === "missing") {
|
|
7075
|
+
await this.gitService.removeWorktree(worktreePath, { force: true });
|
|
7076
|
+
this.logger.info(` \u2705 Cleared dangling registration for '${branchName}' (directory already gone)`);
|
|
7077
|
+
outcome.recordRemoved(branchName, worktreePath);
|
|
7078
|
+
await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
|
|
7079
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7080
|
+
);
|
|
7081
|
+
return;
|
|
7082
|
+
}
|
|
7083
|
+
let refWarning;
|
|
7084
|
+
if (this.trashService.isEnabled()) {
|
|
7085
|
+
const { entry, branchRefError } = await this.trashService.trashAndUnregisterWorktree({
|
|
7086
|
+
dirPath: worktreePath,
|
|
7087
|
+
branch: branchName,
|
|
7088
|
+
reason: "prune",
|
|
7089
|
+
keepPinOnReap: recheck.fullyPushedUpstreamDeleted
|
|
7090
|
+
});
|
|
7091
|
+
if (branchRefError !== void 0) {
|
|
7092
|
+
refWarning = `leftover_branch_ref: could not delete branch ref '${branchName}': ${branchRefError}`;
|
|
7093
|
+
}
|
|
7094
|
+
const pushedNote = recheck.fullyPushedUpstreamDeleted ? " \u2014 was fully pushed before its remote branch was deleted" : "";
|
|
7095
|
+
this.logger.info(
|
|
7096
|
+
` \u2705 Moved worktree for '${branchName}' to trash (id: ${entry.manifest.id})${pushedNote}`
|
|
7097
|
+
);
|
|
7098
|
+
} else {
|
|
7099
|
+
await this.gitService.removeWorktree(worktreePath);
|
|
7100
|
+
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
7101
|
+
}
|
|
7102
|
+
outcome.recordRemoved(branchName, worktreePath, refWarning);
|
|
7103
|
+
await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
|
|
7104
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7105
|
+
);
|
|
5632
7106
|
} catch (error) {
|
|
7107
|
+
if (error instanceof WorktreeNotCleanError) {
|
|
7108
|
+
this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - git refused: ${getErrorMessage(error)}`);
|
|
7109
|
+
outcome.recordSkipped("worktree", "git_refused_removal", {
|
|
7110
|
+
branch: branchName,
|
|
7111
|
+
path: worktreePath,
|
|
7112
|
+
message: getErrorMessage(error)
|
|
7113
|
+
});
|
|
7114
|
+
return;
|
|
7115
|
+
}
|
|
7116
|
+
if (error instanceof TrashOperationError) {
|
|
7117
|
+
this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - ${getErrorMessage(error)}`);
|
|
7118
|
+
outcome.recordSkipped("worktree", "trash_failed", {
|
|
7119
|
+
branch: branchName,
|
|
7120
|
+
path: worktreePath,
|
|
7121
|
+
message: getErrorMessage(error)
|
|
7122
|
+
});
|
|
7123
|
+
return;
|
|
7124
|
+
}
|
|
5633
7125
|
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
5634
7126
|
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5635
7127
|
reason: "remove_failed",
|
|
@@ -5755,12 +7247,12 @@ var WorktreeModeSyncRunner = class {
|
|
|
5755
7247
|
}
|
|
5756
7248
|
async updateExistingWorktrees(actions, outcome) {
|
|
5757
7249
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
5758
|
-
const divergedDir =
|
|
7250
|
+
const divergedDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5759
7251
|
try {
|
|
5760
|
-
const diverged = await
|
|
7252
|
+
const diverged = await fs16.readdir(divergedDir);
|
|
5761
7253
|
if (diverged.length > 0) {
|
|
5762
7254
|
this.logger.info(
|
|
5763
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
7255
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path19.relative(process.cwd(), divergedDir)}`
|
|
5764
7256
|
);
|
|
5765
7257
|
}
|
|
5766
7258
|
} catch {
|
|
@@ -5772,7 +7264,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5772
7264
|
(action) => limit(async () => {
|
|
5773
7265
|
const worktree = { path: action.path, branch: action.branch };
|
|
5774
7266
|
try {
|
|
5775
|
-
await
|
|
7267
|
+
await fs16.access(worktree.path);
|
|
5776
7268
|
} catch {
|
|
5777
7269
|
return { action: "skip", worktree, reason: "missing_worktree_path" };
|
|
5778
7270
|
}
|
|
@@ -5912,13 +7404,13 @@ var WorktreeModeSyncRunner = class {
|
|
|
5912
7404
|
}
|
|
5913
7405
|
async cleanupOrphanedDirectories(worktrees) {
|
|
5914
7406
|
try {
|
|
5915
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
5916
|
-
const allDirs = await
|
|
7407
|
+
const worktreeRelativePaths = worktrees.map((w) => path19.relative(this.config.worktreeDir, w.path));
|
|
7408
|
+
const allDirs = await fs16.readdir(this.config.worktreeDir);
|
|
5917
7409
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
5918
7410
|
const orphanedDirs = [];
|
|
5919
7411
|
for (const dir of regularDirs) {
|
|
5920
7412
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
5921
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
7413
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path19.sep);
|
|
5922
7414
|
});
|
|
5923
7415
|
if (!isPartOfWorktree) {
|
|
5924
7416
|
orphanedDirs.push(dir);
|
|
@@ -5927,13 +7419,46 @@ var WorktreeModeSyncRunner = class {
|
|
|
5927
7419
|
if (orphanedDirs.length > 0) {
|
|
5928
7420
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
5929
7421
|
for (const dir of orphanedDirs) {
|
|
5930
|
-
const dirPath =
|
|
7422
|
+
const dirPath = path19.join(this.config.worktreeDir, dir);
|
|
5931
7423
|
try {
|
|
5932
|
-
const stat3 = await
|
|
5933
|
-
if (stat3.isDirectory()) {
|
|
5934
|
-
|
|
5935
|
-
|
|
7424
|
+
const stat3 = await fs16.stat(dirPath);
|
|
7425
|
+
if (!stat3.isDirectory()) {
|
|
7426
|
+
continue;
|
|
7427
|
+
}
|
|
7428
|
+
const gitProbe = await probePathExists(path19.join(dirPath, PATH_CONSTANTS.GIT_DIR));
|
|
7429
|
+
if (gitProbe === "unknown") {
|
|
7430
|
+
this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir}: cannot verify it is not a live checkout`);
|
|
7431
|
+
continue;
|
|
7432
|
+
}
|
|
7433
|
+
if (this.trashService.isEnabled()) {
|
|
7434
|
+
try {
|
|
7435
|
+
const entry = await this.trashService.trashDirectory({ dirPath, reason: "orphan" });
|
|
7436
|
+
this.logger.info(` - Moved orphaned directory '${dir}' to trash (id: ${entry.manifest.id})`);
|
|
7437
|
+
} catch (trashError) {
|
|
7438
|
+
this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir} - ${getErrorMessage(trashError)}`);
|
|
7439
|
+
}
|
|
7440
|
+
continue;
|
|
7441
|
+
}
|
|
7442
|
+
if (gitProbe === "exists") {
|
|
7443
|
+
const quarantinePath = await quarantineDirectory(dirPath);
|
|
7444
|
+
this.logger.warn(
|
|
7445
|
+
` - \u26A0\uFE0F Orphaned directory ${dir} contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
|
|
7446
|
+
);
|
|
7447
|
+
await this.removalAudit.record({ action: "orphan_quarantine", result: "success", path: dirPath, quarantinePath }).catch(
|
|
7448
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7449
|
+
);
|
|
7450
|
+
continue;
|
|
5936
7451
|
}
|
|
7452
|
+
try {
|
|
7453
|
+
await this.removalAudit.record({ action: "orphan_delete", result: "attempt", path: dirPath });
|
|
7454
|
+
} catch (auditError) {
|
|
7455
|
+
this.logger.warn(
|
|
7456
|
+
` - \u26A0\uFE0F Skipping orphaned directory ${dir} - cannot write removal audit log: ${getErrorMessage(auditError)}`
|
|
7457
|
+
);
|
|
7458
|
+
continue;
|
|
7459
|
+
}
|
|
7460
|
+
await fs16.rm(dirPath, { recursive: true, force: true });
|
|
7461
|
+
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
5937
7462
|
} catch (error) {
|
|
5938
7463
|
this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
|
|
5939
7464
|
}
|
|
@@ -5962,14 +7487,37 @@ var WorktreeModeSyncRunner = class {
|
|
|
5962
7487
|
outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
|
|
5963
7488
|
} else {
|
|
5964
7489
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
5965
|
-
|
|
5966
|
-
|
|
7490
|
+
let keepRef = null;
|
|
7491
|
+
if (!this.trashService.isEnabled()) {
|
|
7492
|
+
const localCommit = (await this.gitService.getCurrentCommit(worktree.path)).trim();
|
|
7493
|
+
keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}diverged-${Date.now().toString(36)}-${this.pathResolution.sanitizeBranchName(worktree.branch)}`;
|
|
7494
|
+
await this.gitService.updateRef(keepRef, localCommit);
|
|
7495
|
+
}
|
|
7496
|
+
const { divergedPath, manifest } = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
7497
|
+
const relativePath = path19.relative(process.cwd(), divergedPath);
|
|
5967
7498
|
outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
|
|
5968
7499
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
5969
7500
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
5970
7501
|
this.logger.info(` cd ${relativePath}`);
|
|
5971
7502
|
this.logger.info(` git diff origin/${worktree.branch}`);
|
|
5972
|
-
await this.gitService.removeWorktree(worktree.path);
|
|
7503
|
+
await this.gitService.removeWorktree(worktree.path, { force: true });
|
|
7504
|
+
if (manifest !== null) {
|
|
7505
|
+
await this.trashService.deleteTrashedBranchRef(manifest);
|
|
7506
|
+
} else {
|
|
7507
|
+
await this.gitService.deleteLocalBranch(worktree.branch);
|
|
7508
|
+
this.logger.info(
|
|
7509
|
+
` Never-pushed commits remain recoverable at '${keepRef}' \u2014 recover with: git branch <name> ${keepRef}`
|
|
7510
|
+
);
|
|
7511
|
+
}
|
|
7512
|
+
await this.removalAudit.record({
|
|
7513
|
+
action: "diverged_replace",
|
|
7514
|
+
result: "success",
|
|
7515
|
+
path: worktree.path,
|
|
7516
|
+
branch: worktree.branch,
|
|
7517
|
+
quarantinePath: divergedPath
|
|
7518
|
+
}).catch(
|
|
7519
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7520
|
+
);
|
|
5973
7521
|
await this.gitService.addWorktree(worktree.branch, worktree.path);
|
|
5974
7522
|
this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
|
|
5975
7523
|
}
|
|
@@ -5988,42 +7536,55 @@ var WorktreeModeSyncRunner = class {
|
|
|
5988
7536
|
}
|
|
5989
7537
|
}
|
|
5990
7538
|
async divergeWorktree(worktreePath, branchName) {
|
|
5991
|
-
|
|
7539
|
+
if (this.trashService.isEnabled()) {
|
|
7540
|
+
const entry = await this.trashService.trashDirectory({
|
|
7541
|
+
dirPath: worktreePath,
|
|
7542
|
+
branch: branchName,
|
|
7543
|
+
reason: "diverged-replace",
|
|
7544
|
+
keepPinOnReap: true
|
|
7545
|
+
});
|
|
7546
|
+
await this.writeDivergedInfoFile(entry.payloadPath, worktreePath, branchName, entry.manifest.headOid);
|
|
7547
|
+
return { divergedPath: entry.payloadPath, manifest: entry.manifest };
|
|
7548
|
+
}
|
|
7549
|
+
const divergedBaseDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5992
7550
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
5993
7551
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
5994
7552
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
5995
7553
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
5996
|
-
const divergedPath =
|
|
5997
|
-
await
|
|
7554
|
+
const divergedPath = path19.join(divergedBaseDir, divergedName);
|
|
7555
|
+
await fs16.mkdir(divergedBaseDir, { recursive: true });
|
|
5998
7556
|
try {
|
|
5999
|
-
await
|
|
7557
|
+
await fs16.rename(worktreePath, divergedPath);
|
|
6000
7558
|
} catch (err) {
|
|
6001
7559
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
6002
|
-
await
|
|
6003
|
-
await
|
|
7560
|
+
await fs16.cp(worktreePath, divergedPath, { recursive: true });
|
|
7561
|
+
await fs16.rm(worktreePath, { recursive: true, force: true });
|
|
6004
7562
|
} else {
|
|
6005
7563
|
throw err;
|
|
6006
7564
|
}
|
|
6007
7565
|
}
|
|
7566
|
+
await this.writeDivergedInfoFile(divergedPath, worktreePath, branchName, null);
|
|
7567
|
+
return { divergedPath, manifest: null };
|
|
7568
|
+
}
|
|
7569
|
+
async writeDivergedInfoFile(preservedPath, originalPath, branchName, knownLocalCommit) {
|
|
6008
7570
|
const metadata = {
|
|
6009
7571
|
originalBranch: branchName,
|
|
6010
7572
|
divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6011
7573
|
reason: METADATA_CONSTANTS.DIVERGED_REASON,
|
|
6012
|
-
originalPath
|
|
6013
|
-
localCommit: await this.gitService.getCurrentCommit(
|
|
7574
|
+
originalPath,
|
|
7575
|
+
localCommit: knownLocalCommit ?? await this.gitService.getCurrentCommit(preservedPath),
|
|
6014
7576
|
remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
|
|
6015
7577
|
instruction: `To preserve your changes:
|
|
6016
7578
|
1. Review: git diff origin/${branchName}
|
|
6017
7579
|
2. Keep changes: git push --force-with-lease origin ${branchName}
|
|
6018
7580
|
3. Discard changes: rm -rf this directory
|
|
6019
7581
|
|
|
6020
|
-
Original worktree location: ${
|
|
7582
|
+
Original worktree location: ${originalPath}`
|
|
6021
7583
|
};
|
|
6022
|
-
await
|
|
6023
|
-
|
|
7584
|
+
await fs16.writeFile(
|
|
7585
|
+
path19.join(preservedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
6024
7586
|
JSON.stringify(metadata, null, 2)
|
|
6025
7587
|
);
|
|
6026
|
-
return divergedPath;
|
|
6027
7588
|
}
|
|
6028
7589
|
};
|
|
6029
7590
|
|
|
@@ -6034,12 +7595,26 @@ var WorktreeSyncService = class {
|
|
|
6034
7595
|
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
6035
7596
|
this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
|
|
6036
7597
|
this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
|
|
7598
|
+
this.maintenanceService = new GitMaintenanceService(config, this.gitService, this.logger);
|
|
6037
7599
|
this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
|
|
7600
|
+
const removalAudit = new RemovalAuditService(getRemovalAuditLogPath(config));
|
|
7601
|
+
this.trashService = new TrashService(config, this.gitService, this.logger, removalAudit);
|
|
7602
|
+
this.trashReaper = new TrashReaperService(config, this.trashService, this.logger, removalAudit, this.gitService);
|
|
7603
|
+
this.trashMigration = new TrashMigrationService(config, this.trashService, this.logger);
|
|
7604
|
+
if (this.trashService.isEnabled()) {
|
|
7605
|
+
this.gitService.setStaleDirectoryTrasher(
|
|
7606
|
+
async (dirPath) => (await this.trashService.trashDirectory({ dirPath, reason: "orphan" })).payloadPath
|
|
7607
|
+
);
|
|
7608
|
+
}
|
|
6038
7609
|
this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
|
|
6039
7610
|
config,
|
|
6040
7611
|
this.gitService,
|
|
6041
7612
|
this.logger,
|
|
6042
|
-
this.progressEmitter
|
|
7613
|
+
this.progressEmitter,
|
|
7614
|
+
{
|
|
7615
|
+
trashService: this.trashService,
|
|
7616
|
+
removalAudit
|
|
7617
|
+
}
|
|
6043
7618
|
);
|
|
6044
7619
|
if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
|
|
6045
7620
|
this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
|
|
@@ -6061,8 +7636,12 @@ var WorktreeSyncService = class {
|
|
|
6061
7636
|
repoMutex = pLimit2(1);
|
|
6062
7637
|
progressEmitter = new ProgressEmitter();
|
|
6063
7638
|
repoOperationLock;
|
|
7639
|
+
maintenanceService;
|
|
6064
7640
|
retryPolicy;
|
|
6065
7641
|
worktreeModeSyncRunner;
|
|
7642
|
+
trashService;
|
|
7643
|
+
trashReaper;
|
|
7644
|
+
trashMigration;
|
|
6066
7645
|
skipsAccumulator = [];
|
|
6067
7646
|
lastOutcome = null;
|
|
6068
7647
|
getRecordedSkips() {
|
|
@@ -6086,6 +7665,18 @@ var WorktreeSyncService = class {
|
|
|
6086
7665
|
}
|
|
6087
7666
|
return this.gitService.getWorktrees();
|
|
6088
7667
|
}
|
|
7668
|
+
async getRemoteBranches() {
|
|
7669
|
+
if (this.cloneSyncService) {
|
|
7670
|
+
return this.cloneSyncService.getRemoteBranches();
|
|
7671
|
+
}
|
|
7672
|
+
return this.gitService.getRemoteBranches();
|
|
7673
|
+
}
|
|
7674
|
+
async checkoutBranch(branchName, options = {}) {
|
|
7675
|
+
if (!this.cloneSyncService) {
|
|
7676
|
+
throw new ConfigError("checkoutBranch is only available for clone-mode repositories", "CLONE_MODE_REQUIRED");
|
|
7677
|
+
}
|
|
7678
|
+
await this.cloneSyncService.checkoutBranch(branchName, options);
|
|
7679
|
+
}
|
|
6089
7680
|
async initialize() {
|
|
6090
7681
|
if (this.isInitialized()) return;
|
|
6091
7682
|
const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
|
|
@@ -6115,6 +7706,23 @@ var WorktreeSyncService = class {
|
|
|
6115
7706
|
getGitService() {
|
|
6116
7707
|
return this.gitService;
|
|
6117
7708
|
}
|
|
7709
|
+
// Restore must hold the repo lock: the reaper, prune, and gc all mutate the
|
|
7710
|
+
// same trash entries and refs at the tail of a sync. wait:true queues behind
|
|
7711
|
+
// an in-flight sync instead of failing fast — restores are explicit user
|
|
7712
|
+
// actions, not periodic work.
|
|
7713
|
+
async restoreFromTrash(id) {
|
|
7714
|
+
const result = await this.runExclusiveRepoOperation(() => this.trashService.restore(id), { wait: true });
|
|
7715
|
+
if (!result.started) {
|
|
7716
|
+
throw new TrashOperationError(
|
|
7717
|
+
"restore",
|
|
7718
|
+
`cannot restore trash entry '${id}': another process holds the repo lock`
|
|
7719
|
+
);
|
|
7720
|
+
}
|
|
7721
|
+
return result.value;
|
|
7722
|
+
}
|
|
7723
|
+
async listTrashEntries() {
|
|
7724
|
+
return this.trashService.listEntries();
|
|
7725
|
+
}
|
|
6118
7726
|
updateLogger(logger) {
|
|
6119
7727
|
this.logger = logger;
|
|
6120
7728
|
this.gitService.updateLogger(logger);
|
|
@@ -6122,6 +7730,37 @@ var WorktreeSyncService = class {
|
|
|
6122
7730
|
this.retryPolicy.updateLogger(logger);
|
|
6123
7731
|
this.worktreeModeSyncRunner.updateLogger(logger);
|
|
6124
7732
|
this.repoOperationLock.updateLogger(logger);
|
|
7733
|
+
this.maintenanceService.updateLogger(logger);
|
|
7734
|
+
this.trashService.updateLogger(logger);
|
|
7735
|
+
this.trashReaper.updateLogger(logger);
|
|
7736
|
+
this.trashMigration.updateLogger(logger);
|
|
7737
|
+
}
|
|
7738
|
+
// Runs git gc when due, inside the already-held repo lock (mirrors
|
|
7739
|
+
// initializeUnlocked — must NOT re-acquire runExclusiveRepoOperation or it
|
|
7740
|
+
// would self-deadlock/skip). Skipped under NODE_ENV=test so unit suites don't
|
|
7741
|
+
// shell out to real git; GitMaintenanceService is covered by its own tests.
|
|
7742
|
+
async runMaintenanceIfDueUnlocked() {
|
|
7743
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
7744
|
+
return;
|
|
7745
|
+
}
|
|
7746
|
+
await this.maintenanceService.runIfDueUnlocked();
|
|
7747
|
+
}
|
|
7748
|
+
// Same contract as runMaintenanceIfDueUnlocked: tail of a successful sync,
|
|
7749
|
+
// inside the held lock, never fails the sync. Runs before gc so freshly
|
|
7750
|
+
// reaped pin refs can be collected in the same maintenance window.
|
|
7751
|
+
async runTrashMaintenanceUnlocked() {
|
|
7752
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
7753
|
+
return;
|
|
7754
|
+
}
|
|
7755
|
+
if (this.cloneSyncService) {
|
|
7756
|
+
return;
|
|
7757
|
+
}
|
|
7758
|
+
try {
|
|
7759
|
+
await this.trashMigration.migrateLegacyUnlocked();
|
|
7760
|
+
await this.trashReaper.reapExpiredUnlocked();
|
|
7761
|
+
} catch (error) {
|
|
7762
|
+
this.logger.warn(`\u26A0\uFE0F Trash maintenance failed: ${getErrorMessage(error)}`);
|
|
7763
|
+
}
|
|
6125
7764
|
}
|
|
6126
7765
|
onProgress(listener) {
|
|
6127
7766
|
return this.progressEmitter.onProgress(listener);
|
|
@@ -6157,6 +7796,7 @@ var WorktreeSyncService = class {
|
|
|
6157
7796
|
}
|
|
6158
7797
|
async sync() {
|
|
6159
7798
|
const result = await this.runExclusiveRepoOperation(async () => {
|
|
7799
|
+
this.clearRecordedSkips();
|
|
6160
7800
|
const totalTimer = new Timer();
|
|
6161
7801
|
const phaseTimer = new PhaseTimer();
|
|
6162
7802
|
const outcome = new SyncOutcomeAccumulator({
|
|
@@ -6205,7 +7845,9 @@ var WorktreeSyncService = class {
|
|
|
6205
7845
|
const repoName = this.config.name;
|
|
6206
7846
|
this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
|
|
6207
7847
|
}
|
|
7848
|
+
await this.runTrashMaintenanceUnlocked();
|
|
6208
7849
|
}
|
|
7850
|
+
await this.runMaintenanceIfDueUnlocked();
|
|
6209
7851
|
return this.lastOutcome ?? outcome.toOutcome(durationMs);
|
|
6210
7852
|
});
|
|
6211
7853
|
return result.started ? { started: true, outcome: result.value } : result;
|
|
@@ -6338,54 +7980,6 @@ var HookExecutionService = class {
|
|
|
6338
7980
|
}
|
|
6339
7981
|
};
|
|
6340
7982
|
|
|
6341
|
-
// src/utils/disk-space.ts
|
|
6342
|
-
import fastFolderSize from "fast-folder-size";
|
|
6343
|
-
async function calculateDirectorySize(dirPath) {
|
|
6344
|
-
return new Promise((resolve12, reject) => {
|
|
6345
|
-
fastFolderSize(dirPath, (err, bytes) => {
|
|
6346
|
-
if (err) {
|
|
6347
|
-
reject(err);
|
|
6348
|
-
return;
|
|
6349
|
-
}
|
|
6350
|
-
if (bytes === void 0) {
|
|
6351
|
-
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
6352
|
-
return;
|
|
6353
|
-
}
|
|
6354
|
-
resolve12(bytes);
|
|
6355
|
-
});
|
|
6356
|
-
});
|
|
6357
|
-
}
|
|
6358
|
-
function formatBytes(bytes) {
|
|
6359
|
-
if (bytes === 0) return "0 B";
|
|
6360
|
-
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
6361
|
-
const k = 1024;
|
|
6362
|
-
const decimals = 2;
|
|
6363
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
6364
|
-
const value = bytes / Math.pow(k, i);
|
|
6365
|
-
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
6366
|
-
}
|
|
6367
|
-
async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
|
|
6368
|
-
try {
|
|
6369
|
-
let totalBytes = 0;
|
|
6370
|
-
for (const repoPath of repoPaths) {
|
|
6371
|
-
try {
|
|
6372
|
-
totalBytes += await calculateDirectorySize(repoPath);
|
|
6373
|
-
} catch {
|
|
6374
|
-
}
|
|
6375
|
-
}
|
|
6376
|
-
for (const worktreeDir of worktreeDirs) {
|
|
6377
|
-
try {
|
|
6378
|
-
totalBytes += await calculateDirectorySize(worktreeDir);
|
|
6379
|
-
} catch {
|
|
6380
|
-
}
|
|
6381
|
-
}
|
|
6382
|
-
return formatBytes(totalBytes);
|
|
6383
|
-
} catch (error) {
|
|
6384
|
-
console.error("Failed to calculate disk space:", error);
|
|
6385
|
-
return "N/A";
|
|
6386
|
-
}
|
|
6387
|
-
}
|
|
6388
|
-
|
|
6389
7983
|
// src/utils/app-events.ts
|
|
6390
7984
|
var AppEventEmitter = class {
|
|
6391
7985
|
listeners = /* @__PURE__ */ new Map();
|
|
@@ -6422,7 +8016,7 @@ var AppEventEmitter = class {
|
|
|
6422
8016
|
};
|
|
6423
8017
|
|
|
6424
8018
|
// src/services/InteractiveUIService.tsx
|
|
6425
|
-
import * as
|
|
8019
|
+
import * as fs17 from "fs/promises";
|
|
6426
8020
|
var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
|
|
6427
8021
|
var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
|
|
6428
8022
|
var InteractiveUIService = class {
|
|
@@ -6583,8 +8177,8 @@ var InteractiveUIService = class {
|
|
|
6583
8177
|
getRepositoryDiskUsage: (index) => this.getRepositoryDiskUsage(index),
|
|
6584
8178
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
6585
8179
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
6586
|
-
openEditorInWorktree: (
|
|
6587
|
-
openTerminalInWorktree: (repoIndex,
|
|
8180
|
+
openEditorInWorktree: (path24) => this.openEditorInWorktree(path24),
|
|
8181
|
+
openTerminalInWorktree: (repoIndex, path24, branchName) => this.openTerminalInWorktree(repoIndex, path24, branchName),
|
|
6588
8182
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
6589
8183
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
6590
8184
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
@@ -6708,14 +8302,14 @@ var InteractiveUIService = class {
|
|
|
6708
8302
|
if (Date.now() - startTime > timeoutMs) {
|
|
6709
8303
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
6710
8304
|
}
|
|
6711
|
-
await new Promise((
|
|
8305
|
+
await new Promise((resolve14) => setTimeout(resolve14, checkInterval));
|
|
6712
8306
|
}
|
|
6713
8307
|
});
|
|
6714
8308
|
try {
|
|
6715
8309
|
await Promise.all(syncChecks);
|
|
6716
8310
|
} catch {
|
|
6717
8311
|
this.addLog(
|
|
6718
|
-
`Warning: Timeout waiting for sync operations to complete after ${
|
|
8312
|
+
`Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
|
|
6719
8313
|
"warn"
|
|
6720
8314
|
);
|
|
6721
8315
|
}
|
|
@@ -6804,11 +8398,14 @@ var InteractiveUIService = class {
|
|
|
6804
8398
|
throw new Error(`Invalid repository index: ${repoIndex}`);
|
|
6805
8399
|
}
|
|
6806
8400
|
const service = this.syncServices[repoIndex];
|
|
6807
|
-
if (!service.isInitialized()) {
|
|
8401
|
+
if (!service.isInitialized() && !service.isCloneMode()) {
|
|
8402
|
+
return [];
|
|
8403
|
+
}
|
|
8404
|
+
try {
|
|
8405
|
+
return await service.getRemoteBranches();
|
|
8406
|
+
} catch {
|
|
6808
8407
|
return [];
|
|
6809
8408
|
}
|
|
6810
|
-
const gitService = service.getGitService();
|
|
6811
|
-
return gitService.getRemoteBranches();
|
|
6812
8409
|
}
|
|
6813
8410
|
getDefaultBranchForRepo(repoIndex) {
|
|
6814
8411
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -6827,6 +8424,9 @@ var InteractiveUIService = class {
|
|
|
6827
8424
|
if (!service.isInitialized()) {
|
|
6828
8425
|
await service.initializeUnlocked();
|
|
6829
8426
|
}
|
|
8427
|
+
if (service.isCloneMode()) {
|
|
8428
|
+
return;
|
|
8429
|
+
}
|
|
6830
8430
|
await service.getGitService().fetchAll();
|
|
6831
8431
|
});
|
|
6832
8432
|
if (!result.started) {
|
|
@@ -6904,22 +8504,22 @@ var InteractiveUIService = class {
|
|
|
6904
8504
|
}
|
|
6905
8505
|
const service = this.syncServices[repoIndex];
|
|
6906
8506
|
const worktreeDir = service.config.worktreeDir;
|
|
6907
|
-
const divergedDir =
|
|
8507
|
+
const divergedDir = path20.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
6908
8508
|
let dirEntries;
|
|
6909
8509
|
try {
|
|
6910
|
-
dirEntries = await
|
|
8510
|
+
dirEntries = await fs17.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
6911
8511
|
} catch {
|
|
6912
8512
|
return [];
|
|
6913
8513
|
}
|
|
6914
8514
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
6915
8515
|
const results = await Promise.allSettled(
|
|
6916
8516
|
subdirs.map(async (entry) => {
|
|
6917
|
-
const fullPath =
|
|
6918
|
-
const infoFilePath =
|
|
8517
|
+
const fullPath = path20.join(divergedDir, entry.name);
|
|
8518
|
+
const infoFilePath = path20.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
6919
8519
|
let originalBranch = entry.name;
|
|
6920
8520
|
let divergedAt = "";
|
|
6921
8521
|
try {
|
|
6922
|
-
const infoContent = await
|
|
8522
|
+
const infoContent = await fs17.readFile(infoFilePath, "utf-8");
|
|
6923
8523
|
const info = JSON.parse(infoContent);
|
|
6924
8524
|
if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
|
|
6925
8525
|
if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
|
|
@@ -6950,15 +8550,15 @@ var InteractiveUIService = class {
|
|
|
6950
8550
|
}
|
|
6951
8551
|
const service = this.syncServices[repoIndex];
|
|
6952
8552
|
const worktreeDir = service.config.worktreeDir;
|
|
6953
|
-
const divergedBase =
|
|
8553
|
+
const divergedBase = path20.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
6954
8554
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
6955
8555
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
6956
8556
|
}
|
|
6957
|
-
const targetPath =
|
|
8557
|
+
const targetPath = path20.join(divergedBase, name);
|
|
6958
8558
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
6959
8559
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
6960
8560
|
}
|
|
6961
|
-
await
|
|
8561
|
+
await fs17.rm(targetPath, { recursive: true, force: true });
|
|
6962
8562
|
this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
|
|
6963
8563
|
}
|
|
6964
8564
|
async createWorktreeForBranch(repoIndex, branchName) {
|
|
@@ -6970,6 +8570,10 @@ var InteractiveUIService = class {
|
|
|
6970
8570
|
const worktreeDir = service.config.worktreeDir;
|
|
6971
8571
|
const worktreePath = this.pathResolution.getBranchWorktreePath(worktreeDir, branchName);
|
|
6972
8572
|
const result = await service.runQueuedRepoOperation(async () => {
|
|
8573
|
+
if (service.isCloneMode()) {
|
|
8574
|
+
await service.checkoutBranch(branchName, { allowConfigDrift: true });
|
|
8575
|
+
return;
|
|
8576
|
+
}
|
|
6973
8577
|
await gitService.addWorktree(branchName, worktreePath);
|
|
6974
8578
|
});
|
|
6975
8579
|
if (!result.started) {
|
|
@@ -7316,8 +8920,8 @@ function parseArguments(argv = hideBin(process.argv)) {
|
|
|
7316
8920
|
}
|
|
7317
8921
|
|
|
7318
8922
|
// src/utils/config-generator.ts
|
|
7319
|
-
import * as
|
|
7320
|
-
import * as
|
|
8923
|
+
import * as fs18 from "fs/promises";
|
|
8924
|
+
import * as path21 from "path";
|
|
7321
8925
|
function serializeToESM(obj, indent = 0) {
|
|
7322
8926
|
const spaces = " ".repeat(indent);
|
|
7323
8927
|
const innerSpaces = " ".repeat(indent + 2);
|
|
@@ -7347,9 +8951,9 @@ ${spaces}}`;
|
|
|
7347
8951
|
return String(obj);
|
|
7348
8952
|
}
|
|
7349
8953
|
async function generateConfigFile(input2, configPath, options = {}) {
|
|
7350
|
-
const configDir =
|
|
7351
|
-
await
|
|
7352
|
-
const worktreeDirRelative =
|
|
8954
|
+
const configDir = path21.dirname(configPath);
|
|
8955
|
+
await fs18.mkdir(configDir, { recursive: true });
|
|
8956
|
+
const worktreeDirRelative = path21.relative(configDir, input2.worktreeDir);
|
|
7353
8957
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
7354
8958
|
const repoName = extractRepoNameFromUrl(input2.repoUrl);
|
|
7355
8959
|
const repository = {
|
|
@@ -7358,7 +8962,7 @@ async function generateConfigFile(input2, configPath, options = {}) {
|
|
|
7358
8962
|
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
|
|
7359
8963
|
};
|
|
7360
8964
|
if (input2.bareRepoDir) {
|
|
7361
|
-
const bareRepoDirRelative =
|
|
8965
|
+
const bareRepoDirRelative = path21.relative(configDir, input2.bareRepoDir);
|
|
7362
8966
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
7363
8967
|
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
|
|
7364
8968
|
}
|
|
@@ -7385,7 +8989,7 @@ const config = ${serializeToESM(configObject)};
|
|
|
7385
8989
|
export default config;
|
|
7386
8990
|
`;
|
|
7387
8991
|
try {
|
|
7388
|
-
await
|
|
8992
|
+
await fs18.writeFile(configPath, configContent, {
|
|
7389
8993
|
encoding: "utf-8",
|
|
7390
8994
|
flag: options.overwrite ? "w" : "wx"
|
|
7391
8995
|
});
|
|
@@ -7397,11 +9001,11 @@ export default config;
|
|
|
7397
9001
|
}
|
|
7398
9002
|
}
|
|
7399
9003
|
function getDefaultConfigPath() {
|
|
7400
|
-
return
|
|
9004
|
+
return path21.join(process.cwd(), "sync-worktrees.config.js");
|
|
7401
9005
|
}
|
|
7402
9006
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
7403
9007
|
for (const name of CONFIG_FILE_NAMES) {
|
|
7404
|
-
const full =
|
|
9008
|
+
const full = path21.join(cwd, name);
|
|
7405
9009
|
if (await fileExists(full)) {
|
|
7406
9010
|
return full;
|
|
7407
9011
|
}
|
|
@@ -7410,7 +9014,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
|
|
|
7410
9014
|
}
|
|
7411
9015
|
|
|
7412
9016
|
// src/utils/interactive.ts
|
|
7413
|
-
import * as
|
|
9017
|
+
import * as path22 from "path";
|
|
7414
9018
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
7415
9019
|
async function promptForInitConfig() {
|
|
7416
9020
|
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
@@ -7441,8 +9045,8 @@ async function promptForInitConfig() {
|
|
|
7441
9045
|
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
7442
9046
|
worktreeDir = defaultWorktreeDir;
|
|
7443
9047
|
}
|
|
7444
|
-
if (!
|
|
7445
|
-
worktreeDir =
|
|
9048
|
+
if (!path22.isAbsolute(worktreeDir)) {
|
|
9049
|
+
worktreeDir = path22.resolve(worktreeDir);
|
|
7446
9050
|
}
|
|
7447
9051
|
let bareRepoDir;
|
|
7448
9052
|
const askForBareDir = await confirm({
|
|
@@ -7460,8 +9064,8 @@ async function promptForInitConfig() {
|
|
|
7460
9064
|
return true;
|
|
7461
9065
|
}
|
|
7462
9066
|
});
|
|
7463
|
-
if (!
|
|
7464
|
-
bareRepoDir =
|
|
9067
|
+
if (!path22.isAbsolute(bareRepoDir)) {
|
|
9068
|
+
bareRepoDir = path22.resolve(bareRepoDir);
|
|
7465
9069
|
}
|
|
7466
9070
|
}
|
|
7467
9071
|
const runMode = await select({
|
|
@@ -7714,7 +9318,7 @@ async function runFromConfigFile(configPath, runOnceOverride = false) {
|
|
|
7714
9318
|
await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
|
|
7715
9319
|
}
|
|
7716
9320
|
async function resolveConfigOrExit(cliPath) {
|
|
7717
|
-
const resolved = cliPath ?
|
|
9321
|
+
const resolved = cliPath ? path23.resolve(cliPath) : await findConfigInCwd();
|
|
7718
9322
|
if (!resolved) {
|
|
7719
9323
|
console.error(
|
|
7720
9324
|
"\u274C No config file found. Pass --config <path>, run `sync-worktrees init` to create one, or place a sync-worktrees.config.{js,mjs,cjs} in this directory."
|
|
@@ -7730,7 +9334,7 @@ function exitConfigExists(targetPath) {
|
|
|
7730
9334
|
process.exit(1);
|
|
7731
9335
|
}
|
|
7732
9336
|
async function runInit(configPath, force) {
|
|
7733
|
-
const targetPath = configPath ?
|
|
9337
|
+
const targetPath = configPath ? path23.resolve(configPath) : getDefaultConfigPath();
|
|
7734
9338
|
if (!force && await fileExists(targetPath)) {
|
|
7735
9339
|
exitConfigExists(targetPath);
|
|
7736
9340
|
}
|
|
@@ -7743,7 +9347,7 @@ async function runInit(configPath, force) {
|
|
|
7743
9347
|
}
|
|
7744
9348
|
throw error;
|
|
7745
9349
|
}
|
|
7746
|
-
const displayPath =
|
|
9350
|
+
const displayPath = path23.relative(process.cwd(), targetPath) || targetPath;
|
|
7747
9351
|
console.log(`
|
|
7748
9352
|
\u2705 Configuration saved to: ${targetPath}`);
|
|
7749
9353
|
console.log(`
|
|
@@ -7751,7 +9355,7 @@ async function runInit(configPath, force) {
|
|
|
7751
9355
|
}
|
|
7752
9356
|
async function runSync(options) {
|
|
7753
9357
|
const configPath = await resolveConfigOrExit(options.config);
|
|
7754
|
-
const displayPath =
|
|
9358
|
+
const displayPath = path23.relative(process.cwd(), configPath) || configPath;
|
|
7755
9359
|
console.log(`\u{1F4C4} Using config: ${displayPath}`);
|
|
7756
9360
|
try {
|
|
7757
9361
|
await runFromConfigFile(configPath, options.runOnce);
|