sync-worktrees 4.1.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 +135 -55
- package/dist/components/App.d.ts.map +1 -1
- package/dist/components/BranchCreationWizard.d.ts.map +1 -1
- package/dist/components/OpenEditorWizard.d.ts.map +1 -1
- package/dist/components/StatusBar.d.ts +1 -0
- package/dist/components/StatusBar.d.ts.map +1 -1
- 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 +2201 -510
- 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 +2117 -519
- 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 +21 -2
- 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 +28 -27
package/dist/mcp-server.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
6
|
// src/mcp/context.ts
|
|
7
|
-
import * as
|
|
8
|
-
import * as
|
|
9
|
-
import
|
|
10
|
-
import
|
|
7
|
+
import * as fs17 from "fs/promises";
|
|
8
|
+
import * as path20 from "path";
|
|
9
|
+
import pLimit3 from "p-limit";
|
|
10
|
+
import simpleGit7 from "simple-git";
|
|
11
11
|
|
|
12
12
|
// src/constants.ts
|
|
13
13
|
var GIT_CONSTANTS = {
|
|
@@ -18,6 +18,10 @@ var GIT_CONSTANTS = {
|
|
|
18
18
|
COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
|
|
19
19
|
BARE_DIR_NAME: ".bare",
|
|
20
20
|
DIVERGED_DIR_NAME: ".diverged",
|
|
21
|
+
REMOVED_DIR_NAME: ".removed",
|
|
22
|
+
TRASH_DIR_NAME: ".trash",
|
|
23
|
+
TRASH_REF_PREFIX: "refs/sync-worktrees/trash/",
|
|
24
|
+
KEEP_REF_PREFIX: "refs/sync-worktrees/keep/",
|
|
21
25
|
LFS_HEADER: "version https://git-lfs.github.com/spec/",
|
|
22
26
|
SUBMODULE_STATUS_ADDED: "+",
|
|
23
27
|
SUBMODULE_STATUS_REMOVED: "-",
|
|
@@ -63,7 +67,16 @@ var DEFAULT_CONFIG = {
|
|
|
63
67
|
FETCH_TIMEOUT_MS: 3e5,
|
|
64
68
|
CLONE_TIMEOUT_MS: 9e5,
|
|
65
69
|
LOCK_STALE_MS: 6e5,
|
|
66
|
-
LOCK_UPDATE_MS: 3e4
|
|
70
|
+
LOCK_UPDATE_MS: 3e4,
|
|
71
|
+
MAINTENANCE: {
|
|
72
|
+
ENABLED: true,
|
|
73
|
+
INTERVAL: "7d"
|
|
74
|
+
},
|
|
75
|
+
TRASH: {
|
|
76
|
+
ENABLED: true,
|
|
77
|
+
RETENTION_DAYS: 30,
|
|
78
|
+
MIGRATE_LEGACY: true
|
|
79
|
+
}
|
|
67
80
|
};
|
|
68
81
|
var ERROR_MESSAGES = {
|
|
69
82
|
GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
|
|
@@ -98,6 +111,15 @@ var CONFIG_FILE_NAMES = [
|
|
|
98
111
|
"sync-worktrees.config.mjs",
|
|
99
112
|
"sync-worktrees.config.cjs"
|
|
100
113
|
];
|
|
114
|
+
var MAINTENANCE_CONSTANTS = {
|
|
115
|
+
STATE_FILENAME: "sync-worktrees-maintenance.json"
|
|
116
|
+
};
|
|
117
|
+
var TRASH_CONSTANTS = {
|
|
118
|
+
MANIFEST_FILENAME: "manifest.json",
|
|
119
|
+
PAYLOAD_DIRNAME: "payload",
|
|
120
|
+
BUNDLE_FILENAME: "commits.bundle",
|
|
121
|
+
SCHEMA_VERSION: 1
|
|
122
|
+
};
|
|
101
123
|
var METADATA_CONSTANTS = {
|
|
102
124
|
MAX_HISTORY_ENTRIES: 10,
|
|
103
125
|
METADATA_FILENAME: "sync-metadata.json",
|
|
@@ -127,6 +149,8 @@ var SyncWorktreesError = class extends Error {
|
|
|
127
149
|
Caused by: ${cause.stack}`;
|
|
128
150
|
}
|
|
129
151
|
}
|
|
152
|
+
code;
|
|
153
|
+
cause;
|
|
130
154
|
};
|
|
131
155
|
var GitError = class extends SyncWorktreesError {
|
|
132
156
|
constructor(message, code, cause) {
|
|
@@ -138,17 +162,26 @@ var GitOperationError = class extends GitError {
|
|
|
138
162
|
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
139
163
|
}
|
|
140
164
|
};
|
|
165
|
+
var FastForwardError = class extends GitError {
|
|
166
|
+
constructor(branchName, cause) {
|
|
167
|
+
super(`Cannot fast-forward branch '${branchName}'`, "FAST_FORWARD_FAILED", cause);
|
|
168
|
+
this.branchName = branchName;
|
|
169
|
+
}
|
|
170
|
+
branchName;
|
|
171
|
+
};
|
|
141
172
|
var WorktreeError = class extends SyncWorktreesError {
|
|
142
173
|
constructor(message, code, cause) {
|
|
143
174
|
super(message, `WORKTREE_${code}`, cause);
|
|
144
175
|
}
|
|
145
176
|
};
|
|
146
177
|
var WorktreeNotCleanError = class extends WorktreeError {
|
|
147
|
-
constructor(
|
|
148
|
-
super(`Worktree at '${
|
|
149
|
-
this.path =
|
|
178
|
+
constructor(path22, reasons) {
|
|
179
|
+
super(`Worktree at '${path22}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
180
|
+
this.path = path22;
|
|
150
181
|
this.reasons = reasons;
|
|
151
182
|
}
|
|
183
|
+
path;
|
|
184
|
+
reasons;
|
|
152
185
|
};
|
|
153
186
|
var ConfigError = class extends SyncWorktreesError {
|
|
154
187
|
constructor(message, code, cause) {
|
|
@@ -161,12 +194,27 @@ var ConfigValidationError = class extends ConfigError {
|
|
|
161
194
|
this.field = field;
|
|
162
195
|
this.reason = reason;
|
|
163
196
|
}
|
|
197
|
+
field;
|
|
198
|
+
reason;
|
|
164
199
|
};
|
|
165
200
|
var ConfigFileNotFoundError = class extends ConfigError {
|
|
166
201
|
constructor(configPath) {
|
|
167
202
|
super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
|
|
168
203
|
this.configPath = configPath;
|
|
169
204
|
}
|
|
205
|
+
configPath;
|
|
206
|
+
};
|
|
207
|
+
var TrashError = class extends SyncWorktreesError {
|
|
208
|
+
constructor(message, code, cause) {
|
|
209
|
+
super(message, `TRASH_${code}`, cause);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
var TrashOperationError = class extends TrashError {
|
|
213
|
+
constructor(operation, details, cause) {
|
|
214
|
+
super(`Trash operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
215
|
+
this.operation = operation;
|
|
216
|
+
}
|
|
217
|
+
operation;
|
|
170
218
|
};
|
|
171
219
|
|
|
172
220
|
// src/utils/branch-filter.ts
|
|
@@ -189,16 +237,73 @@ function filterBranchesByName(branches, include, exclude) {
|
|
|
189
237
|
return result;
|
|
190
238
|
}
|
|
191
239
|
|
|
240
|
+
// src/utils/date-filter.ts
|
|
241
|
+
function parseDuration(durationStr) {
|
|
242
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
243
|
+
if (!match) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const value = parseInt(match[1], 10);
|
|
247
|
+
const unit = match[2];
|
|
248
|
+
const multipliers = {
|
|
249
|
+
h: 60 * 60 * 1e3,
|
|
250
|
+
// hours
|
|
251
|
+
d: 24 * 60 * 60 * 1e3,
|
|
252
|
+
// days
|
|
253
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
254
|
+
// weeks
|
|
255
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
256
|
+
// months (approximate)
|
|
257
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
258
|
+
// years (approximate)
|
|
259
|
+
};
|
|
260
|
+
return value * multipliers[unit];
|
|
261
|
+
}
|
|
262
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
263
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
264
|
+
if (maxAgeMs === null) {
|
|
265
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
266
|
+
return branches;
|
|
267
|
+
}
|
|
268
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
269
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
270
|
+
}
|
|
271
|
+
function formatDuration(durationStr) {
|
|
272
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
273
|
+
if (!match) {
|
|
274
|
+
return durationStr;
|
|
275
|
+
}
|
|
276
|
+
const value = parseInt(match[1], 10);
|
|
277
|
+
const unit = match[2];
|
|
278
|
+
const unitNames = {
|
|
279
|
+
h: value === 1 ? "hour" : "hours",
|
|
280
|
+
d: value === 1 ? "day" : "days",
|
|
281
|
+
w: value === 1 ? "week" : "weeks",
|
|
282
|
+
m: value === 1 ? "month" : "months",
|
|
283
|
+
y: value === 1 ? "year" : "years"
|
|
284
|
+
};
|
|
285
|
+
return `${value} ${unitNames[unit]}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
192
288
|
// src/utils/file-exists.ts
|
|
193
289
|
import * as fs from "fs/promises";
|
|
194
|
-
async function fileExists(
|
|
290
|
+
async function fileExists(path22) {
|
|
195
291
|
try {
|
|
196
|
-
await fs.access(
|
|
292
|
+
await fs.access(path22);
|
|
197
293
|
return true;
|
|
198
294
|
} catch {
|
|
199
295
|
return false;
|
|
200
296
|
}
|
|
201
297
|
}
|
|
298
|
+
async function probePathExists(path22) {
|
|
299
|
+
try {
|
|
300
|
+
await fs.access(path22);
|
|
301
|
+
return "exists";
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const code = error.code;
|
|
304
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
|
|
305
|
+
}
|
|
306
|
+
}
|
|
202
307
|
|
|
203
308
|
// src/utils/git-url.ts
|
|
204
309
|
function extractRepoNameFromUrl(gitUrl) {
|
|
@@ -289,7 +394,8 @@ var CLONE_MODE_CONFLICTING_FIELDS = [
|
|
|
289
394
|
"branchExclude",
|
|
290
395
|
"branchMaxAge",
|
|
291
396
|
"updateExistingWorktrees",
|
|
292
|
-
"bareRepoDir"
|
|
397
|
+
"bareRepoDir",
|
|
398
|
+
"trash"
|
|
293
399
|
];
|
|
294
400
|
var ConfigLoaderService = class {
|
|
295
401
|
async findConfigUpward(startDir) {
|
|
@@ -392,6 +498,12 @@ var ConfigLoaderService = class {
|
|
|
392
498
|
if (repoObj.sparseCheckout !== void 0) {
|
|
393
499
|
this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
|
|
394
500
|
}
|
|
501
|
+
if (repoObj.maintenance !== void 0) {
|
|
502
|
+
this.validateMaintenanceConfig(repoObj.maintenance, `Repository '${repoObj.name}'`);
|
|
503
|
+
}
|
|
504
|
+
if (repoObj.trash !== void 0) {
|
|
505
|
+
this.validateTrashConfig(repoObj.trash, `Repository '${repoObj.name}'`);
|
|
506
|
+
}
|
|
395
507
|
this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
|
|
396
508
|
this.validateRepositoryMode(repoObj, configObj.defaults);
|
|
397
509
|
});
|
|
@@ -428,6 +540,12 @@ var ConfigLoaderService = class {
|
|
|
428
540
|
if (defaults.sparseCheckout !== void 0) {
|
|
429
541
|
this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
|
|
430
542
|
}
|
|
543
|
+
if (defaults.maintenance !== void 0) {
|
|
544
|
+
this.validateMaintenanceConfig(defaults.maintenance, "defaults");
|
|
545
|
+
}
|
|
546
|
+
if (defaults.trash !== void 0) {
|
|
547
|
+
this.validateTrashConfig(defaults.trash, "defaults");
|
|
548
|
+
}
|
|
431
549
|
this.validateDepth(defaults.depth, "defaults.depth");
|
|
432
550
|
if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
|
|
433
551
|
throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
|
|
@@ -455,6 +573,46 @@ var ConfigLoaderService = class {
|
|
|
455
573
|
throw new ConfigValidationError(field, "must be a positive safe integer");
|
|
456
574
|
}
|
|
457
575
|
}
|
|
576
|
+
validateMaintenanceConfig(value, context) {
|
|
577
|
+
if (value === void 0) return;
|
|
578
|
+
if (typeof value !== "object" || value === null) {
|
|
579
|
+
throw new Error(`'maintenance' in ${context} must be an object`);
|
|
580
|
+
}
|
|
581
|
+
const maintenance = value;
|
|
582
|
+
if (maintenance.enabled !== void 0 && typeof maintenance.enabled !== "boolean") {
|
|
583
|
+
throw new Error(`'maintenance.enabled' in ${context} must be a boolean`);
|
|
584
|
+
}
|
|
585
|
+
if (maintenance.aggressive !== void 0 && typeof maintenance.aggressive !== "boolean") {
|
|
586
|
+
throw new Error(`'maintenance.aggressive' in ${context} must be a boolean`);
|
|
587
|
+
}
|
|
588
|
+
if (maintenance.interval !== void 0) {
|
|
589
|
+
const parsed = typeof maintenance.interval === "string" ? parseDuration(maintenance.interval) : null;
|
|
590
|
+
if (parsed === null || parsed <= 0) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`'maintenance.interval' in ${context} must be a positive duration string like '7d', '24h', or '2w'`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
validateTrashConfig(value, context) {
|
|
598
|
+
if (value === void 0) return;
|
|
599
|
+
if (typeof value !== "object" || value === null) {
|
|
600
|
+
throw new Error(`'trash' in ${context} must be an object`);
|
|
601
|
+
}
|
|
602
|
+
const trash = value;
|
|
603
|
+
if (trash.enabled !== void 0 && typeof trash.enabled !== "boolean") {
|
|
604
|
+
throw new Error(`'trash.enabled' in ${context} must be a boolean`);
|
|
605
|
+
}
|
|
606
|
+
if (trash.migrateLegacy !== void 0 && typeof trash.migrateLegacy !== "boolean") {
|
|
607
|
+
throw new Error(`'trash.migrateLegacy' in ${context} must be a boolean`);
|
|
608
|
+
}
|
|
609
|
+
if (trash.retentionDays !== void 0 && (typeof trash.retentionDays !== "number" || !Number.isFinite(trash.retentionDays) || trash.retentionDays <= 0)) {
|
|
610
|
+
throw new Error(`'trash.retentionDays' in ${context} must be a positive number`);
|
|
611
|
+
}
|
|
612
|
+
if (trash.warnSizeBytes !== void 0 && (typeof trash.warnSizeBytes !== "number" || !Number.isFinite(trash.warnSizeBytes) || trash.warnSizeBytes <= 0)) {
|
|
613
|
+
throw new Error(`'trash.warnSizeBytes' in ${context} must be a positive number`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
458
616
|
validateRetryConfig(value, context) {
|
|
459
617
|
if (typeof value !== "object" || value === null) {
|
|
460
618
|
throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
|
|
@@ -726,6 +884,18 @@ var ConfigLoaderService = class {
|
|
|
726
884
|
if (sparse) {
|
|
727
885
|
resolved.sparseCheckout = sparse;
|
|
728
886
|
}
|
|
887
|
+
if (repo.maintenance || defaults?.maintenance) {
|
|
888
|
+
resolved.maintenance = {
|
|
889
|
+
...defaults?.maintenance || {},
|
|
890
|
+
...repo.maintenance || {}
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
if (repo.trash || defaults?.trash) {
|
|
894
|
+
resolved.trash = {
|
|
895
|
+
...defaults?.trash || {},
|
|
896
|
+
...repo.trash || {}
|
|
897
|
+
};
|
|
898
|
+
}
|
|
729
899
|
return resolved;
|
|
730
900
|
}
|
|
731
901
|
isDuplicateRepoUrl(repo, all, defaults) {
|
|
@@ -886,6 +1056,9 @@ function defaultConsoleOutput(msg, level) {
|
|
|
886
1056
|
else console.log(msg);
|
|
887
1057
|
}
|
|
888
1058
|
|
|
1059
|
+
// src/services/worktree-sync.service.ts
|
|
1060
|
+
import pLimit2 from "p-limit";
|
|
1061
|
+
|
|
889
1062
|
// src/utils/lfs-error.ts
|
|
890
1063
|
function getErrorMessage(error) {
|
|
891
1064
|
if (error instanceof Error) {
|
|
@@ -916,6 +1089,31 @@ function isMissingRemoteRefError(errorMessage) {
|
|
|
916
1089
|
return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
|
|
917
1090
|
}
|
|
918
1091
|
|
|
1092
|
+
// src/utils/lock-path.ts
|
|
1093
|
+
import { createHash } from "crypto";
|
|
1094
|
+
import * as os from "os";
|
|
1095
|
+
import * as path3 from "path";
|
|
1096
|
+
function getCloneModeLockTarget(config) {
|
|
1097
|
+
const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
1098
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
|
|
1099
|
+
const dir = path3.join(stateBase, "sync-worktrees", "locks");
|
|
1100
|
+
return { dir, file: `${hash}.lock` };
|
|
1101
|
+
}
|
|
1102
|
+
function getRemovalAuditLogPath(config) {
|
|
1103
|
+
const name = config.name;
|
|
1104
|
+
const configDir = config.__configFileDir;
|
|
1105
|
+
const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
1106
|
+
if (configDir) {
|
|
1107
|
+
return path3.join(
|
|
1108
|
+
configDir,
|
|
1109
|
+
".sync-worktrees-state",
|
|
1110
|
+
`${sanitizeNameForPath(name ?? "repo", "removal audit log name")}-${hash}-removals.jsonl`
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
|
|
1114
|
+
return path3.join(stateBase, "sync-worktrees", "removals", `${hash}.jsonl`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
919
1117
|
// src/utils/retry.ts
|
|
920
1118
|
var DEFAULT_OPTIONS = {
|
|
921
1119
|
maxAttempts: "unlimited",
|
|
@@ -986,7 +1184,7 @@ async function retry(fn, options = {}) {
|
|
|
986
1184
|
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
987
1185
|
const delay = baseDelay + jitter;
|
|
988
1186
|
opts.onRetry(error, attempt, lfsContext);
|
|
989
|
-
await new Promise((
|
|
1187
|
+
await new Promise((resolve13) => setTimeout(resolve13, delay));
|
|
990
1188
|
attempt++;
|
|
991
1189
|
}
|
|
992
1190
|
}
|
|
@@ -1057,7 +1255,7 @@ var PhaseTimer = class {
|
|
|
1057
1255
|
return results;
|
|
1058
1256
|
}
|
|
1059
1257
|
};
|
|
1060
|
-
function
|
|
1258
|
+
function formatDuration2(ms) {
|
|
1061
1259
|
if (ms < 1e3) {
|
|
1062
1260
|
return `${ms}ms`;
|
|
1063
1261
|
}
|
|
@@ -1079,7 +1277,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
1079
1277
|
}
|
|
1080
1278
|
});
|
|
1081
1279
|
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
1082
|
-
table.push(["Total Sync",
|
|
1280
|
+
table.push(["Total Sync", formatDuration2(totalDuration), ""]);
|
|
1083
1281
|
for (let i = 0; i < phaseResults.length; i++) {
|
|
1084
1282
|
const result = phaseResults[i];
|
|
1085
1283
|
const isLast = i === phaseResults.length - 1;
|
|
@@ -1087,14 +1285,14 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
1087
1285
|
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
1088
1286
|
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
1089
1287
|
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
1090
|
-
table.push([name,
|
|
1288
|
+
table.push([name, formatDuration2(result.duration), efficiency]);
|
|
1091
1289
|
}
|
|
1092
1290
|
return table.toString();
|
|
1093
1291
|
}
|
|
1094
1292
|
|
|
1095
1293
|
// src/services/clone-sync.service.ts
|
|
1096
1294
|
import * as fs3 from "fs/promises";
|
|
1097
|
-
import * as
|
|
1295
|
+
import * as path5 from "path";
|
|
1098
1296
|
import simpleGit from "simple-git";
|
|
1099
1297
|
|
|
1100
1298
|
// src/utils/git-progress.ts
|
|
@@ -1123,7 +1321,7 @@ function makeGitProgressHandler(logger, emitProgress) {
|
|
|
1123
1321
|
|
|
1124
1322
|
// src/services/file-copy.service.ts
|
|
1125
1323
|
import * as fs2 from "fs/promises";
|
|
1126
|
-
import * as
|
|
1324
|
+
import * as path4 from "path";
|
|
1127
1325
|
import { glob } from "glob";
|
|
1128
1326
|
var DEFAULT_IGNORE_PATTERNS = [
|
|
1129
1327
|
"**/node_modules/**",
|
|
@@ -1150,8 +1348,8 @@ var FileCopyService = class {
|
|
|
1150
1348
|
}
|
|
1151
1349
|
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
1152
1350
|
for (const relativePath of filesToCopy) {
|
|
1153
|
-
const sourcePath =
|
|
1154
|
-
const destPath =
|
|
1351
|
+
const sourcePath = path4.join(sourceDir, relativePath);
|
|
1352
|
+
const destPath = path4.join(destDir, relativePath);
|
|
1155
1353
|
try {
|
|
1156
1354
|
const copied = await this.copyFile(sourcePath, destPath);
|
|
1157
1355
|
if (copied) {
|
|
@@ -1190,7 +1388,7 @@ var FileCopyService = class {
|
|
|
1190
1388
|
if (await fileExists(destPath)) {
|
|
1191
1389
|
return false;
|
|
1192
1390
|
}
|
|
1193
|
-
const destDir =
|
|
1391
|
+
const destDir = path4.dirname(destPath);
|
|
1194
1392
|
await fs2.mkdir(destDir, { recursive: true });
|
|
1195
1393
|
await fs2.copyFile(sourcePath, destPath);
|
|
1196
1394
|
return true;
|
|
@@ -1252,7 +1450,7 @@ var BranchCreatedActionsService = class {
|
|
|
1252
1450
|
function formatCloneSkipReason(reason) {
|
|
1253
1451
|
switch (reason.kind) {
|
|
1254
1452
|
case "branch_mismatch":
|
|
1255
|
-
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
|
|
1453
|
+
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`;
|
|
1256
1454
|
case "head_unreadable":
|
|
1257
1455
|
return `could not read HEAD: ${reason.error}`;
|
|
1258
1456
|
case "dirty_tree":
|
|
@@ -1316,20 +1514,21 @@ var SyncOutcomeAccumulator = class {
|
|
|
1316
1514
|
constructor(options) {
|
|
1317
1515
|
this.options = options;
|
|
1318
1516
|
}
|
|
1517
|
+
options;
|
|
1319
1518
|
counts = cloneCounts(EMPTY_COUNTS);
|
|
1320
1519
|
actions = [];
|
|
1321
1520
|
add(action) {
|
|
1322
1521
|
this.actions.push(action);
|
|
1323
1522
|
this.counts[countKeyFor(action)]++;
|
|
1324
1523
|
}
|
|
1325
|
-
recordCreated(branch,
|
|
1326
|
-
this.add({ kind: "created", branch, path:
|
|
1524
|
+
recordCreated(branch, path22) {
|
|
1525
|
+
this.add({ kind: "created", branch, path: path22 });
|
|
1327
1526
|
}
|
|
1328
|
-
recordRemoved(branch,
|
|
1329
|
-
this.add({ kind: "removed", branch, path:
|
|
1527
|
+
recordRemoved(branch, path22, warning) {
|
|
1528
|
+
this.add({ kind: "removed", branch, path: path22, ...warning !== void 0 && { warning } });
|
|
1330
1529
|
}
|
|
1331
|
-
recordUpdated(branch,
|
|
1332
|
-
this.add({ kind: "updated", branch, path:
|
|
1530
|
+
recordUpdated(branch, path22, reason) {
|
|
1531
|
+
this.add({ kind: "updated", branch, path: path22, reason });
|
|
1333
1532
|
}
|
|
1334
1533
|
recordNoop(scope, reason, details) {
|
|
1335
1534
|
this.add({ kind: "noop", scope, reason, ...details });
|
|
@@ -1337,8 +1536,8 @@ var SyncOutcomeAccumulator = class {
|
|
|
1337
1536
|
recordSkipped(scope, reason, details) {
|
|
1338
1537
|
this.add({ kind: "skipped", scope, reason, ...details });
|
|
1339
1538
|
}
|
|
1340
|
-
recordPreservedDiverged(branch,
|
|
1341
|
-
this.add({ kind: "preserved-diverged", branch, path:
|
|
1539
|
+
recordPreservedDiverged(branch, path22, preservedPath) {
|
|
1540
|
+
this.add({ kind: "preserved-diverged", branch, path: path22, preservedPath });
|
|
1342
1541
|
}
|
|
1343
1542
|
recordFailed(scope, error, details = {}) {
|
|
1344
1543
|
this.add({ kind: "failed", scope, error, ...details });
|
|
@@ -1391,7 +1590,6 @@ function cloneSkipToOutcomeAction(reason, details = {}) {
|
|
|
1391
1590
|
}
|
|
1392
1591
|
|
|
1393
1592
|
// src/services/clone-sync.service.ts
|
|
1394
|
-
var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
1395
1593
|
var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
|
|
1396
1594
|
var CloneSyncService = class {
|
|
1397
1595
|
constructor(config, gitService, logger, options = {}) {
|
|
@@ -1402,6 +1600,9 @@ var CloneSyncService = class {
|
|
|
1402
1600
|
this.progressEmitter = options.progressEmitter;
|
|
1403
1601
|
this.onSkip = options.onSkip;
|
|
1404
1602
|
}
|
|
1603
|
+
config;
|
|
1604
|
+
gitService;
|
|
1605
|
+
logger;
|
|
1405
1606
|
initialized = false;
|
|
1406
1607
|
resolvedBranch = null;
|
|
1407
1608
|
branchCreatedActions;
|
|
@@ -1422,8 +1623,8 @@ var CloneSyncService = class {
|
|
|
1422
1623
|
this.pendingInitSkip = null;
|
|
1423
1624
|
}
|
|
1424
1625
|
async getWorktrees() {
|
|
1425
|
-
const worktreeDir =
|
|
1426
|
-
if (!await fileExists(
|
|
1626
|
+
const worktreeDir = path5.resolve(this.config.worktreeDir);
|
|
1627
|
+
if (!await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
|
|
1427
1628
|
return [];
|
|
1428
1629
|
}
|
|
1429
1630
|
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
@@ -1507,40 +1708,27 @@ var CloneSyncService = class {
|
|
|
1507
1708
|
return env;
|
|
1508
1709
|
}
|
|
1509
1710
|
buildCloneArgs(branch) {
|
|
1510
|
-
const args = ["--branch", branch, "--progress"];
|
|
1711
|
+
const args = ["--branch", branch, "--single-branch", "--no-tags", "--progress"];
|
|
1511
1712
|
if (this.config.depth !== void 0) {
|
|
1512
|
-
args.push("--depth", String(this.config.depth)
|
|
1713
|
+
args.push("--depth", String(this.config.depth));
|
|
1513
1714
|
}
|
|
1514
1715
|
return args;
|
|
1515
1716
|
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1717
|
+
getBranchRefspec(branch) {
|
|
1718
|
+
return `+refs/heads/${branch}:refs/remotes/origin/${branch}`;
|
|
1719
|
+
}
|
|
1720
|
+
async buildFetchArgs(git, branch) {
|
|
1721
|
+
const args = ["origin", "--prune", "--no-tags", "--progress"];
|
|
1518
1722
|
if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
|
|
1519
1723
|
args.push("--depth", String(this.config.depth));
|
|
1520
1724
|
}
|
|
1725
|
+
args.push(this.getBranchRefspec(branch));
|
|
1521
1726
|
return args;
|
|
1522
1727
|
}
|
|
1523
|
-
async
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1528
|
-
} catch {
|
|
1529
|
-
fetchRefspecs = [];
|
|
1530
|
-
}
|
|
1531
|
-
if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
|
|
1532
|
-
const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
|
|
1533
|
-
this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
|
|
1534
|
-
await git.raw(["remote", "set-branches", "origin", "*"]);
|
|
1535
|
-
for (const refspec of customRefspecs) {
|
|
1536
|
-
await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
isOriginRemoteBranchTrackingRefspec(refspec) {
|
|
1540
|
-
const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
|
|
1541
|
-
if (withoutForce.startsWith("^")) return false;
|
|
1542
|
-
const [source, destination] = withoutForce.split(":");
|
|
1543
|
-
return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
|
|
1728
|
+
async configureSingleBranchRemote(git, branch) {
|
|
1729
|
+
await git.raw(["config", "--replace-all", "remote.origin.fetch", this.getBranchRefspec(branch)]);
|
|
1730
|
+
await git.raw(["config", "--replace-all", "remote.origin.tagOpt", "--no-tags"]);
|
|
1731
|
+
await this.deleteStaleRemoteTrackingRefs(git, branch);
|
|
1544
1732
|
}
|
|
1545
1733
|
recordMissingRemoteRefSkip(branch) {
|
|
1546
1734
|
this.recordSkip(
|
|
@@ -1549,7 +1737,10 @@ var CloneSyncService = class {
|
|
|
1549
1737
|
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
1550
1738
|
);
|
|
1551
1739
|
}
|
|
1552
|
-
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
|
|
1740
|
+
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch, recordSkip = true) {
|
|
1741
|
+
const recordMissing = () => {
|
|
1742
|
+
if (recordSkip) this.recordMissingRemoteRefSkip(branch);
|
|
1743
|
+
};
|
|
1553
1744
|
try {
|
|
1554
1745
|
await git.fetch(fetchArgs);
|
|
1555
1746
|
return { skipped: false };
|
|
@@ -1566,14 +1757,14 @@ var CloneSyncService = class {
|
|
|
1566
1757
|
return { skipped: false };
|
|
1567
1758
|
} catch (retryError) {
|
|
1568
1759
|
if (isMissingRemoteRefError(getErrorMessage(retryError))) {
|
|
1569
|
-
|
|
1760
|
+
recordMissing();
|
|
1570
1761
|
return { skipped: true };
|
|
1571
1762
|
}
|
|
1572
1763
|
throw retryError;
|
|
1573
1764
|
}
|
|
1574
1765
|
}
|
|
1575
1766
|
if (isMissingRemoteRefError(message)) {
|
|
1576
|
-
|
|
1767
|
+
recordMissing();
|
|
1577
1768
|
return { skipped: true };
|
|
1578
1769
|
}
|
|
1579
1770
|
throw fetchError;
|
|
@@ -1601,7 +1792,7 @@ var CloneSyncService = class {
|
|
|
1601
1792
|
this.logger.info(
|
|
1602
1793
|
`[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
|
|
1603
1794
|
);
|
|
1604
|
-
await git.fetch(["--unshallow"]);
|
|
1795
|
+
await git.fetch(["--unshallow", "--no-tags"]);
|
|
1605
1796
|
}
|
|
1606
1797
|
getDeepenTargets() {
|
|
1607
1798
|
const configuredDepth = this.config.depth;
|
|
@@ -1621,8 +1812,9 @@ var CloneSyncService = class {
|
|
|
1621
1812
|
"--depth",
|
|
1622
1813
|
String(targetDepth),
|
|
1623
1814
|
"--prune",
|
|
1815
|
+
"--no-tags",
|
|
1624
1816
|
"--progress",
|
|
1625
|
-
|
|
1817
|
+
this.getBranchRefspec(branch)
|
|
1626
1818
|
]);
|
|
1627
1819
|
}
|
|
1628
1820
|
async resolveBranch() {
|
|
@@ -1639,6 +1831,153 @@ var CloneSyncService = class {
|
|
|
1639
1831
|
this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
|
|
1640
1832
|
return this.resolvedBranch;
|
|
1641
1833
|
}
|
|
1834
|
+
parseLsRemoteHeads(output) {
|
|
1835
|
+
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);
|
|
1836
|
+
}
|
|
1837
|
+
async getRemoteBranches() {
|
|
1838
|
+
const worktreeDir = path5.resolve(this.config.worktreeDir);
|
|
1839
|
+
const repoArg = await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR)) ? "origin" : this.config.repoUrl;
|
|
1840
|
+
const git = repoArg === "origin" ? this.clientFor(worktreeDir, this.getFetchTimeoutMs()) : simpleGit(this.buildGitOptions(this.getFetchTimeoutMs())).env(this.buildGitEnv());
|
|
1841
|
+
const output = await git.raw(["ls-remote", "--heads", repoArg]);
|
|
1842
|
+
return this.parseLsRemoteHeads(output);
|
|
1843
|
+
}
|
|
1844
|
+
async localBranchExists(git, branch) {
|
|
1845
|
+
try {
|
|
1846
|
+
await git.raw(["show-ref", "--verify", `refs/heads/${branch}`]);
|
|
1847
|
+
return true;
|
|
1848
|
+
} catch {
|
|
1849
|
+
return false;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
async localBranchCanFastForward(git, branch) {
|
|
1853
|
+
const localRef = `refs/heads/${branch}`;
|
|
1854
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
1855
|
+
let localSha;
|
|
1856
|
+
let remoteSha;
|
|
1857
|
+
try {
|
|
1858
|
+
localSha = (await git.raw(["rev-parse", localRef])).trim();
|
|
1859
|
+
remoteSha = (await git.raw(["rev-parse", remoteRef])).trim();
|
|
1860
|
+
} catch {
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
if (localSha === remoteSha) return true;
|
|
1864
|
+
try {
|
|
1865
|
+
const mergeBase = (await git.raw(["merge-base", localRef, remoteRef])).trim();
|
|
1866
|
+
return mergeBase === localSha;
|
|
1867
|
+
} catch {
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
async deleteRemoteTrackingRef(git, refName) {
|
|
1872
|
+
try {
|
|
1873
|
+
await git.raw(["update-ref", "-d", refName]);
|
|
1874
|
+
} catch {
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
async deleteStaleRemoteTrackingRefs(git, branch) {
|
|
1878
|
+
let refsOutput;
|
|
1879
|
+
try {
|
|
1880
|
+
refsOutput = await git.raw(["for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
|
|
1881
|
+
} catch {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
const keepRef = `refs/remotes/origin/${branch}`;
|
|
1885
|
+
const refsToDelete = refsOutput.split(/\r?\n/).map((ref) => ref.trim()).filter((ref) => ref && ref !== keepRef && ref !== "refs/remotes/origin/HEAD");
|
|
1886
|
+
for (const ref of refsToDelete) {
|
|
1887
|
+
await this.deleteRemoteTrackingRef(git, ref);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
async restoreBranchAfterCheckoutFailure(git, previousBranch, attemptedBranch) {
|
|
1891
|
+
if (!previousBranch || previousBranch === "HEAD" || previousBranch === attemptedBranch) return;
|
|
1892
|
+
try {
|
|
1893
|
+
await git.raw(["switch", previousBranch]);
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
this.logger.warn(
|
|
1896
|
+
`Failed to restore '${this.repoName}' to '${previousBranch}' after checkout failure: ${getErrorMessage(error)}`
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
async checkoutBranch(branch, options = {}) {
|
|
1901
|
+
if (!this.initialized) {
|
|
1902
|
+
await this.initialize();
|
|
1903
|
+
}
|
|
1904
|
+
const targetBranch = await this.resolveBranch();
|
|
1905
|
+
if (branch !== targetBranch && !options.allowConfigDrift) {
|
|
1906
|
+
throw new ConfigError(
|
|
1907
|
+
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.`,
|
|
1908
|
+
"CLONE_BRANCH_MISMATCH"
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
const worktreeDir = this.config.worktreeDir;
|
|
1912
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1913
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
1914
|
+
if (originMismatch) {
|
|
1915
|
+
throw new ConfigError(
|
|
1916
|
+
`Cannot switch '${this.repoName}' to '${branch}': ${originMismatch.progressDetail}.`,
|
|
1917
|
+
"ORIGIN_MISMATCH"
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
const currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
1921
|
+
if (currentBranch === "HEAD") {
|
|
1922
|
+
throw new GitOperationError(
|
|
1923
|
+
"checkout",
|
|
1924
|
+
`'${this.repoName}' is on a detached HEAD; check out a branch manually (preserving any local commits) before switching the tracked branch`
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
if (currentBranch === branch) {
|
|
1928
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
1929
|
+
this.resolvedBranch = branch;
|
|
1930
|
+
this.pendingInitSkip = null;
|
|
1931
|
+
this.warnConfigDriftAfterCheckout(branch, targetBranch);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
|
|
1935
|
+
if (!isClean) {
|
|
1936
|
+
throw new WorktreeNotCleanError(worktreeDir, ["working tree has local changes"]);
|
|
1937
|
+
}
|
|
1938
|
+
await this.unshallowIfDepthRemoved(git);
|
|
1939
|
+
const fetchArgs = await this.buildFetchArgs(git, branch);
|
|
1940
|
+
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch, false)).skipped) {
|
|
1941
|
+
throw new GitOperationError("checkout", `origin/${branch} is missing for '${this.repoName}'`);
|
|
1942
|
+
}
|
|
1943
|
+
if (!await this.hasRemoteBranch(git, branch)) {
|
|
1944
|
+
throw new GitOperationError(
|
|
1945
|
+
"checkout",
|
|
1946
|
+
`origin/${branch} did not materialize after fetch for '${this.repoName}'`
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
if (await this.localBranchExists(git, branch)) {
|
|
1950
|
+
if (!await this.localBranchCanFastForward(git, branch)) {
|
|
1951
|
+
throw new FastForwardError(branch);
|
|
1952
|
+
}
|
|
1953
|
+
let switched = false;
|
|
1954
|
+
try {
|
|
1955
|
+
await git.raw(["switch", branch]);
|
|
1956
|
+
switched = true;
|
|
1957
|
+
await git.merge([`origin/${branch}`, "--ff-only"]);
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
if (switched) {
|
|
1960
|
+
await this.restoreBranchAfterCheckoutFailure(git, currentBranch, branch);
|
|
1961
|
+
}
|
|
1962
|
+
throw error;
|
|
1963
|
+
}
|
|
1964
|
+
} else {
|
|
1965
|
+
await git.raw(["switch", "-c", branch, "--track", `origin/${branch}`]);
|
|
1966
|
+
}
|
|
1967
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
1968
|
+
this.resolvedBranch = branch;
|
|
1969
|
+
this.pendingInitSkip = null;
|
|
1970
|
+
this.warnConfigDriftAfterCheckout(branch, targetBranch);
|
|
1971
|
+
}
|
|
1972
|
+
// resolvedBranch keeps in-session syncs on the new branch, but the config
|
|
1973
|
+
// file still names the old one: the next process start will soft-skip with
|
|
1974
|
+
// branch_mismatch on every tick until the config is updated.
|
|
1975
|
+
warnConfigDriftAfterCheckout(branch, targetBranch) {
|
|
1976
|
+
if (branch === targetBranch) return;
|
|
1977
|
+
this.logger.warn(
|
|
1978
|
+
`\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.`
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1642
1981
|
async initialize(outcome) {
|
|
1643
1982
|
return this.withOutcome(outcome, () => this.initializeInternal());
|
|
1644
1983
|
}
|
|
@@ -1662,7 +2001,7 @@ var CloneSyncService = class {
|
|
|
1662
2001
|
return;
|
|
1663
2002
|
}
|
|
1664
2003
|
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1665
|
-
await this.
|
|
2004
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
1666
2005
|
this.initialized = true;
|
|
1667
2006
|
this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
|
|
1668
2007
|
return;
|
|
@@ -1690,7 +2029,7 @@ var CloneSyncService = class {
|
|
|
1690
2029
|
throw error;
|
|
1691
2030
|
}
|
|
1692
2031
|
const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1693
|
-
await this.
|
|
2032
|
+
await this.configureSingleBranchRemote(worktreeGit, branch);
|
|
1694
2033
|
this.logger.info(`\u2705 Clone successful.`);
|
|
1695
2034
|
this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
|
|
1696
2035
|
if (this.config.sparseCheckout) {
|
|
@@ -1778,7 +2117,7 @@ var CloneSyncService = class {
|
|
|
1778
2117
|
return;
|
|
1779
2118
|
}
|
|
1780
2119
|
const looksIncomplete = entries.every((e) => e.startsWith("."));
|
|
1781
|
-
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(
|
|
2120
|
+
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
|
|
1782
2121
|
if (looksIncomplete && !hasUsableGit) {
|
|
1783
2122
|
try {
|
|
1784
2123
|
await fs3.rm(worktreeDir, { recursive: true, force: true });
|
|
@@ -1793,7 +2132,7 @@ var CloneSyncService = class {
|
|
|
1793
2132
|
}
|
|
1794
2133
|
}
|
|
1795
2134
|
getInitMarkerPath(worktreeDir) {
|
|
1796
|
-
return
|
|
2135
|
+
return path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
|
|
1797
2136
|
}
|
|
1798
2137
|
async runInitialFileCopy(worktreeDir, branch) {
|
|
1799
2138
|
const marker = this.getInitMarkerPath(worktreeDir);
|
|
@@ -1845,7 +2184,7 @@ var CloneSyncService = class {
|
|
|
1845
2184
|
if (currentBranch !== branch) {
|
|
1846
2185
|
this.recordSkip(
|
|
1847
2186
|
{ kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
|
|
1848
|
-
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
|
|
2187
|
+
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge. Update 'branch' in the config or switch the clone back.`,
|
|
1849
2188
|
`Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
|
|
1850
2189
|
);
|
|
1851
2190
|
return;
|
|
@@ -1860,13 +2199,13 @@ var CloneSyncService = class {
|
|
|
1860
2199
|
return;
|
|
1861
2200
|
}
|
|
1862
2201
|
await this.unshallowIfDepthRemoved(git);
|
|
1863
|
-
await this.
|
|
1864
|
-
const fetchArgs = await this.buildFetchArgs(git);
|
|
1865
|
-
this.emitProgress({ phase: "fetch", message: `Fetching origin
|
|
2202
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
2203
|
+
const fetchArgs = await this.buildFetchArgs(git, branch);
|
|
2204
|
+
this.emitProgress({ phase: "fetch", message: `Fetching origin/${branch} for '${this.repoName}'` });
|
|
1866
2205
|
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
|
|
1867
2206
|
return;
|
|
1868
2207
|
}
|
|
1869
|
-
this.emitProgress({ phase: "fetch", message: `Fetched origin
|
|
2208
|
+
this.emitProgress({ phase: "fetch", message: `Fetched origin/${branch} for '${this.repoName}'` });
|
|
1870
2209
|
if (!await this.hasRemoteBranch(git, branch)) {
|
|
1871
2210
|
this.recordSkip(
|
|
1872
2211
|
{ kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
|
|
@@ -1956,55 +2295,225 @@ var CloneSyncService = class {
|
|
|
1956
2295
|
}
|
|
1957
2296
|
};
|
|
1958
2297
|
|
|
1959
|
-
// src/services/git.service.ts
|
|
1960
|
-
import * as
|
|
1961
|
-
import * as
|
|
1962
|
-
import
|
|
2298
|
+
// src/services/git-maintenance.service.ts
|
|
2299
|
+
import * as fs5 from "fs/promises";
|
|
2300
|
+
import * as path6 from "path";
|
|
2301
|
+
import simpleGit2 from "simple-git";
|
|
1963
2302
|
|
|
1964
|
-
// src/utils/
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2303
|
+
// src/utils/atomic-write.ts
|
|
2304
|
+
import * as fs4 from "fs/promises";
|
|
2305
|
+
async function atomicWriteFile(filePath, content) {
|
|
2306
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
2307
|
+
let renamed = false;
|
|
2308
|
+
try {
|
|
2309
|
+
const handle = await fs4.open(tmpPath, "w");
|
|
2310
|
+
try {
|
|
2311
|
+
await handle.writeFile(content, "utf-8");
|
|
2312
|
+
await handle.sync();
|
|
2313
|
+
} finally {
|
|
2314
|
+
await handle.close();
|
|
1972
2315
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
if (
|
|
1985
|
-
|
|
1986
|
-
current.path = line.substring("worktree ".length);
|
|
1987
|
-
} else if (line.startsWith("branch ")) {
|
|
1988
|
-
current.branch = line.substring("branch ".length).replace("refs/heads/", "");
|
|
1989
|
-
} else if (line.startsWith("HEAD ")) {
|
|
1990
|
-
current.head = line.substring("HEAD ".length);
|
|
1991
|
-
} else if (line === "detached") {
|
|
1992
|
-
current.detached = true;
|
|
1993
|
-
} else if (line === "prunable" || line.startsWith("prunable ")) {
|
|
1994
|
-
current.prunable = true;
|
|
1995
|
-
} else if (line === "locked" || line.startsWith("locked ")) {
|
|
1996
|
-
current.locked = true;
|
|
1997
|
-
} else if (line.trim() === "") {
|
|
1998
|
-
flush();
|
|
2316
|
+
try {
|
|
2317
|
+
await fs4.rename(tmpPath, filePath);
|
|
2318
|
+
renamed = true;
|
|
2319
|
+
} catch (err) {
|
|
2320
|
+
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
2321
|
+
await fs4.copyFile(tmpPath, filePath);
|
|
2322
|
+
} else {
|
|
2323
|
+
throw err;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
} finally {
|
|
2327
|
+
if (!renamed) {
|
|
2328
|
+
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
1999
2329
|
}
|
|
2000
2330
|
}
|
|
2001
|
-
flush();
|
|
2002
|
-
return worktrees;
|
|
2003
2331
|
}
|
|
2004
2332
|
|
|
2005
|
-
// src/services/
|
|
2006
|
-
|
|
2007
|
-
|
|
2333
|
+
// src/services/git-maintenance.service.ts
|
|
2334
|
+
var GitMaintenanceService = class {
|
|
2335
|
+
constructor(config, gitService, logger, gitFactory = (cwd) => simpleGit2(cwd)) {
|
|
2336
|
+
this.config = config;
|
|
2337
|
+
this.gitService = gitService;
|
|
2338
|
+
this.logger = logger ?? Logger.createDefault();
|
|
2339
|
+
this.gitFactory = gitFactory;
|
|
2340
|
+
}
|
|
2341
|
+
config;
|
|
2342
|
+
gitService;
|
|
2343
|
+
logger;
|
|
2344
|
+
gitFactory;
|
|
2345
|
+
updateLogger(logger) {
|
|
2346
|
+
this.logger = logger;
|
|
2347
|
+
}
|
|
2348
|
+
isEnabled() {
|
|
2349
|
+
return this.config.maintenance?.enabled ?? DEFAULT_CONFIG.MAINTENANCE.ENABLED;
|
|
2350
|
+
}
|
|
2351
|
+
getIntervalMs() {
|
|
2352
|
+
const fallback = parseDuration(DEFAULT_CONFIG.MAINTENANCE.INTERVAL);
|
|
2353
|
+
const raw = this.config.maintenance?.interval;
|
|
2354
|
+
if (raw === void 0) {
|
|
2355
|
+
return fallback;
|
|
2356
|
+
}
|
|
2357
|
+
const parsed = parseDuration(raw);
|
|
2358
|
+
if (parsed === null || parsed <= 0) {
|
|
2359
|
+
this.logger.warn(`Invalid maintenance.interval '${raw}', using default ${DEFAULT_CONFIG.MAINTENANCE.INTERVAL}.`);
|
|
2360
|
+
return fallback;
|
|
2361
|
+
}
|
|
2362
|
+
return parsed;
|
|
2363
|
+
}
|
|
2364
|
+
resolveTarget() {
|
|
2365
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
2366
|
+
const cwd = path6.resolve(this.config.worktreeDir);
|
|
2367
|
+
return { cwd, gitDir: path6.join(cwd, PATH_CONSTANTS.GIT_DIR) };
|
|
2368
|
+
}
|
|
2369
|
+
const bare = this.gitService.getBareRepoPath();
|
|
2370
|
+
return { cwd: bare, gitDir: bare };
|
|
2371
|
+
}
|
|
2372
|
+
getStatePath(gitDir) {
|
|
2373
|
+
return path6.join(gitDir, MAINTENANCE_CONSTANTS.STATE_FILENAME);
|
|
2374
|
+
}
|
|
2375
|
+
async readState(statePath) {
|
|
2376
|
+
try {
|
|
2377
|
+
const parsed = JSON.parse(await fs5.readFile(statePath, "utf-8"));
|
|
2378
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2379
|
+
return {};
|
|
2380
|
+
}
|
|
2381
|
+
return { ...parsed };
|
|
2382
|
+
} catch {
|
|
2383
|
+
return {};
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
async writeState(statePath, state) {
|
|
2387
|
+
try {
|
|
2388
|
+
await atomicWriteFile(statePath, JSON.stringify(state, null, 2));
|
|
2389
|
+
} catch (error) {
|
|
2390
|
+
this.logger.warn(`Failed to persist maintenance state: ${getErrorMessage(error)}`);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
isDue(state, now) {
|
|
2394
|
+
if (!state.lastAttemptAt) {
|
|
2395
|
+
return true;
|
|
2396
|
+
}
|
|
2397
|
+
const last = new Date(state.lastAttemptAt).getTime();
|
|
2398
|
+
if (Number.isNaN(last)) {
|
|
2399
|
+
return true;
|
|
2400
|
+
}
|
|
2401
|
+
return now - last >= this.getIntervalMs();
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Run `git gc` if maintenance is enabled and due. MUST be called while the
|
|
2405
|
+
* repository operation lock is already held. Never throws: a gc failure is
|
|
2406
|
+
* recorded and warned so it cannot fail the surrounding sync. The attempt
|
|
2407
|
+
* timestamp is persisted even on failure, so a perpetually-failing gc is
|
|
2408
|
+
* throttled instead of retried every tick.
|
|
2409
|
+
*/
|
|
2410
|
+
async runIfDueUnlocked(now = Date.now()) {
|
|
2411
|
+
if (!this.isEnabled()) {
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
try {
|
|
2415
|
+
const { cwd, gitDir } = this.resolveTarget();
|
|
2416
|
+
try {
|
|
2417
|
+
await fs5.access(gitDir);
|
|
2418
|
+
} catch {
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
const statePath = this.getStatePath(gitDir);
|
|
2422
|
+
const state = await this.readState(statePath);
|
|
2423
|
+
if (!this.isDue(state, now)) {
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
const aggressive = this.config.maintenance?.aggressive ?? false;
|
|
2427
|
+
const args = aggressive ? ["gc", "--prune=now"] : ["gc"];
|
|
2428
|
+
const nowIso = new Date(now).toISOString();
|
|
2429
|
+
state.lastAttemptAt = nowIso;
|
|
2430
|
+
this.logger.info(`\u{1F9F9} Running git ${args.join(" ")} (maintenance)...`);
|
|
2431
|
+
try {
|
|
2432
|
+
await this.gitFactory(cwd).raw(args);
|
|
2433
|
+
state.lastSuccessAt = nowIso;
|
|
2434
|
+
delete state.lastError;
|
|
2435
|
+
this.logger.info("\u{1F9F9} Maintenance complete.");
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
state.lastFailureAt = nowIso;
|
|
2438
|
+
state.lastError = getErrorMessage(error);
|
|
2439
|
+
this.logger.warn(`\u26A0\uFE0F Maintenance (git ${args.join(" ")}) failed: ${state.lastError}`);
|
|
2440
|
+
} finally {
|
|
2441
|
+
await this.writeState(statePath, state);
|
|
2442
|
+
}
|
|
2443
|
+
} catch (error) {
|
|
2444
|
+
this.logger.warn(`\u26A0\uFE0F Maintenance skipped due to an unexpected error: ${getErrorMessage(error)}`);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
};
|
|
2448
|
+
|
|
2449
|
+
// src/services/git.service.ts
|
|
2450
|
+
import * as fs9 from "fs/promises";
|
|
2451
|
+
import * as path11 from "path";
|
|
2452
|
+
import simpleGit6 from "simple-git";
|
|
2453
|
+
|
|
2454
|
+
// src/utils/quarantine.ts
|
|
2455
|
+
import * as fs6 from "fs/promises";
|
|
2456
|
+
import * as path7 from "path";
|
|
2457
|
+
|
|
2458
|
+
// src/utils/filename-timestamp.ts
|
|
2459
|
+
function filenameTimestamp(date = /* @__PURE__ */ new Date()) {
|
|
2460
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/utils/quarantine.ts
|
|
2464
|
+
async function quarantineDirectory(dirPath) {
|
|
2465
|
+
const baseDir = path7.join(path7.dirname(dirPath), GIT_CONSTANTS.REMOVED_DIR_NAME);
|
|
2466
|
+
await fs6.mkdir(baseDir, { recursive: true });
|
|
2467
|
+
const timestamp = filenameTimestamp();
|
|
2468
|
+
const quarantinePath = path7.join(baseDir, `${timestamp}-${path7.basename(dirPath)}`);
|
|
2469
|
+
await fs6.rename(dirPath, quarantinePath);
|
|
2470
|
+
return quarantinePath;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// src/utils/worktree-list-parser.ts
|
|
2474
|
+
function parseWorktreeListPorcelain(output) {
|
|
2475
|
+
const worktrees = [];
|
|
2476
|
+
let current = {};
|
|
2477
|
+
const flush = () => {
|
|
2478
|
+
if (!current.path) {
|
|
2479
|
+
current = {};
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
worktrees.push({
|
|
2483
|
+
path: current.path,
|
|
2484
|
+
branch: current.branch ?? null,
|
|
2485
|
+
head: current.head ?? null,
|
|
2486
|
+
detached: current.detached ?? false,
|
|
2487
|
+
prunable: current.prunable ?? false,
|
|
2488
|
+
locked: current.locked ?? false
|
|
2489
|
+
});
|
|
2490
|
+
current = {};
|
|
2491
|
+
};
|
|
2492
|
+
for (const line of output.split("\n")) {
|
|
2493
|
+
if (line.startsWith("worktree ")) {
|
|
2494
|
+
flush();
|
|
2495
|
+
current.path = line.substring("worktree ".length);
|
|
2496
|
+
} else if (line.startsWith("branch ")) {
|
|
2497
|
+
current.branch = line.substring("branch ".length).replace("refs/heads/", "");
|
|
2498
|
+
} else if (line.startsWith("HEAD ")) {
|
|
2499
|
+
current.head = line.substring("HEAD ".length);
|
|
2500
|
+
} else if (line === "detached") {
|
|
2501
|
+
current.detached = true;
|
|
2502
|
+
} else if (line === "prunable" || line.startsWith("prunable ")) {
|
|
2503
|
+
current.prunable = true;
|
|
2504
|
+
} else if (line === "locked" || line.startsWith("locked ")) {
|
|
2505
|
+
current.locked = true;
|
|
2506
|
+
} else if (line.trim() === "") {
|
|
2507
|
+
flush();
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
flush();
|
|
2511
|
+
return worktrees;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// src/services/sparse-checkout.service.ts
|
|
2515
|
+
import * as path8 from "path";
|
|
2516
|
+
import simpleGit3 from "simple-git";
|
|
2008
2517
|
var SparseCheckoutService = class {
|
|
2009
2518
|
logger;
|
|
2010
2519
|
gitFactory;
|
|
@@ -2012,7 +2521,7 @@ var SparseCheckoutService = class {
|
|
|
2012
2521
|
matcherCache = /* @__PURE__ */ new WeakMap();
|
|
2013
2522
|
constructor(logger, gitFactory) {
|
|
2014
2523
|
this.logger = logger ?? Logger.createDefault();
|
|
2015
|
-
this.gitFactory = gitFactory ?? ((p) =>
|
|
2524
|
+
this.gitFactory = gitFactory ?? ((p) => simpleGit3(p));
|
|
2016
2525
|
}
|
|
2017
2526
|
updateLogger(logger) {
|
|
2018
2527
|
this.logger = logger;
|
|
@@ -2138,7 +2647,7 @@ var SparseCheckoutService = class {
|
|
|
2138
2647
|
for (const pat of matcher.patterns) {
|
|
2139
2648
|
if (p === pat || p.startsWith(pat + "/")) return true;
|
|
2140
2649
|
}
|
|
2141
|
-
return matcher.ancestorDirs.has(
|
|
2650
|
+
return matcher.ancestorDirs.has(path8.posix.dirname(p));
|
|
2142
2651
|
});
|
|
2143
2652
|
}
|
|
2144
2653
|
getMatcher(cfg) {
|
|
@@ -2165,9 +2674,9 @@ var SparseCheckoutService = class {
|
|
|
2165
2674
|
};
|
|
2166
2675
|
|
|
2167
2676
|
// src/services/worktree-metadata.service.ts
|
|
2168
|
-
import * as
|
|
2169
|
-
import * as
|
|
2170
|
-
import
|
|
2677
|
+
import * as fs7 from "fs/promises";
|
|
2678
|
+
import * as path9 from "path";
|
|
2679
|
+
import simpleGit4 from "simple-git";
|
|
2171
2680
|
var WorktreeMetadataService = class {
|
|
2172
2681
|
logger;
|
|
2173
2682
|
constructor(logger) {
|
|
@@ -2179,7 +2688,7 @@ var WorktreeMetadataService = class {
|
|
|
2179
2688
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
2180
2689
|
*/
|
|
2181
2690
|
getWorktreeDirectoryName(worktreePath) {
|
|
2182
|
-
return
|
|
2691
|
+
return path9.basename(worktreePath);
|
|
2183
2692
|
}
|
|
2184
2693
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
2185
2694
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -2187,7 +2696,7 @@ var WorktreeMetadataService = class {
|
|
|
2187
2696
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
2188
2697
|
);
|
|
2189
2698
|
}
|
|
2190
|
-
return
|
|
2699
|
+
return path9.join(
|
|
2191
2700
|
bareRepoPath,
|
|
2192
2701
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
2193
2702
|
worktreeName,
|
|
@@ -2200,31 +2709,13 @@ var WorktreeMetadataService = class {
|
|
|
2200
2709
|
}
|
|
2201
2710
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
2202
2711
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2203
|
-
await
|
|
2204
|
-
|
|
2205
|
-
let renamed = false;
|
|
2206
|
-
try {
|
|
2207
|
-
await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
2208
|
-
try {
|
|
2209
|
-
await fs4.rename(tmpPath, metadataPath);
|
|
2210
|
-
renamed = true;
|
|
2211
|
-
} catch (err) {
|
|
2212
|
-
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
2213
|
-
await fs4.copyFile(tmpPath, metadataPath);
|
|
2214
|
-
} else {
|
|
2215
|
-
throw err;
|
|
2216
|
-
}
|
|
2217
|
-
}
|
|
2218
|
-
} finally {
|
|
2219
|
-
if (!renamed) {
|
|
2220
|
-
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2712
|
+
await fs7.mkdir(path9.dirname(metadataPath), { recursive: true });
|
|
2713
|
+
await atomicWriteFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
2223
2714
|
}
|
|
2224
2715
|
async loadMetadata(bareRepoPath, worktreeName) {
|
|
2225
2716
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2226
2717
|
try {
|
|
2227
|
-
const content = await
|
|
2718
|
+
const content = await fs7.readFile(metadataPath, "utf-8");
|
|
2228
2719
|
return JSON.parse(content);
|
|
2229
2720
|
} catch {
|
|
2230
2721
|
return null;
|
|
@@ -2233,7 +2724,7 @@ var WorktreeMetadataService = class {
|
|
|
2233
2724
|
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
2234
2725
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
2235
2726
|
try {
|
|
2236
|
-
const content = await
|
|
2727
|
+
const content = await fs7.readFile(metadataPath, "utf-8");
|
|
2237
2728
|
const metadata = JSON.parse(content);
|
|
2238
2729
|
if (!await this.validateMetadata(metadata)) {
|
|
2239
2730
|
this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
@@ -2247,7 +2738,7 @@ var WorktreeMetadataService = class {
|
|
|
2247
2738
|
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
2248
2739
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2249
2740
|
try {
|
|
2250
|
-
await
|
|
2741
|
+
await fs7.unlink(metadataPath);
|
|
2251
2742
|
} catch (error) {
|
|
2252
2743
|
if (error.code !== "ENOENT") {
|
|
2253
2744
|
throw error;
|
|
@@ -2257,7 +2748,7 @@ var WorktreeMetadataService = class {
|
|
|
2257
2748
|
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
2258
2749
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
2259
2750
|
try {
|
|
2260
|
-
await
|
|
2751
|
+
await fs7.unlink(metadataPath);
|
|
2261
2752
|
} catch (error) {
|
|
2262
2753
|
if (error.code !== "ENOENT") {
|
|
2263
2754
|
throw error;
|
|
@@ -2291,7 +2782,7 @@ var WorktreeMetadataService = class {
|
|
|
2291
2782
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
2292
2783
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
2293
2784
|
try {
|
|
2294
|
-
const worktreeGit =
|
|
2785
|
+
const worktreeGit = simpleGit4(worktreePath);
|
|
2295
2786
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
2296
2787
|
const branchSummary = await worktreeGit.branch();
|
|
2297
2788
|
const actualBranchName = branchSummary.current;
|
|
@@ -2338,6 +2829,25 @@ var WorktreeMetadataService = class {
|
|
|
2338
2829
|
}
|
|
2339
2830
|
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
2340
2831
|
}
|
|
2832
|
+
/**
|
|
2833
|
+
* Records the upstream tip observed during this sync. This is what later
|
|
2834
|
+
* proves "HEAD was fully pushed" after the remote branch is deleted, so it
|
|
2835
|
+
* must only ever be overwritten with a live observation — callers must not
|
|
2836
|
+
* invoke this once the upstream ref is gone.
|
|
2837
|
+
*/
|
|
2838
|
+
async recordRemoteTip(bareRepoPath, worktreePath, ref, oid) {
|
|
2839
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
2840
|
+
const existing = await this.loadMetadataFromPath(bareRepoPath, worktreePath);
|
|
2841
|
+
if (!existing) {
|
|
2842
|
+
this.logger.debug(`No metadata found for worktree ${worktreeDirName}; skipping remote tip recording`);
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
if (existing.lastKnownRemoteTip?.ref === ref && existing.lastKnownRemoteTip.oid === oid) {
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
existing.lastKnownRemoteTip = { ref, oid, recordedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2849
|
+
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
2850
|
+
}
|
|
2341
2851
|
async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
|
|
2342
2852
|
const metadata = {
|
|
2343
2853
|
lastSyncCommit: commit,
|
|
@@ -2392,9 +2902,9 @@ var WorktreeMetadataService = class {
|
|
|
2392
2902
|
};
|
|
2393
2903
|
|
|
2394
2904
|
// src/services/worktree-status.service.ts
|
|
2395
|
-
import * as
|
|
2396
|
-
import * as
|
|
2397
|
-
import
|
|
2905
|
+
import * as fs8 from "fs/promises";
|
|
2906
|
+
import * as path10 from "path";
|
|
2907
|
+
import simpleGit5 from "simple-git";
|
|
2398
2908
|
var OPERATION_FILES = [
|
|
2399
2909
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
2400
2910
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -2408,6 +2918,7 @@ var WorktreeStatusService = class {
|
|
|
2408
2918
|
this.config = config;
|
|
2409
2919
|
this.logger = logger ?? Logger.createDefault();
|
|
2410
2920
|
}
|
|
2921
|
+
config;
|
|
2411
2922
|
gitInstances = /* @__PURE__ */ new Map();
|
|
2412
2923
|
logger;
|
|
2413
2924
|
async checkWorktreeStatus(worktreePath) {
|
|
@@ -2424,8 +2935,9 @@ var WorktreeStatusService = class {
|
|
|
2424
2935
|
}
|
|
2425
2936
|
return true;
|
|
2426
2937
|
}
|
|
2427
|
-
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
2428
|
-
|
|
2938
|
+
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit, lastKnownRemoteTip) {
|
|
2939
|
+
const pathProbe = await probePathExists(worktreePath);
|
|
2940
|
+
if (pathProbe === "missing") {
|
|
2429
2941
|
return {
|
|
2430
2942
|
isClean: true,
|
|
2431
2943
|
hasUnpushedCommits: false,
|
|
@@ -2433,25 +2945,44 @@ var WorktreeStatusService = class {
|
|
|
2433
2945
|
hasOperationInProgress: false,
|
|
2434
2946
|
hasModifiedSubmodules: false,
|
|
2435
2947
|
upstreamGone: false,
|
|
2948
|
+
fullyPushedUpstreamDeleted: false,
|
|
2436
2949
|
canRemove: true,
|
|
2437
2950
|
reasons: []
|
|
2438
2951
|
};
|
|
2439
2952
|
}
|
|
2440
|
-
|
|
2953
|
+
if (pathProbe === "unknown") {
|
|
2954
|
+
return {
|
|
2955
|
+
isClean: false,
|
|
2956
|
+
hasUnpushedCommits: true,
|
|
2957
|
+
hasStashedChanges: true,
|
|
2958
|
+
hasOperationInProgress: true,
|
|
2959
|
+
hasModifiedSubmodules: true,
|
|
2960
|
+
upstreamGone: false,
|
|
2961
|
+
fullyPushedUpstreamDeleted: false,
|
|
2962
|
+
canRemove: false,
|
|
2963
|
+
reasons: ["cannot verify worktree path (filesystem probe failed)"]
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
const snap = await this.collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip);
|
|
2441
2967
|
const isClean = this.deriveIsClean(snap);
|
|
2442
|
-
const
|
|
2968
|
+
const anyRemoteUnpushed = (snap.unpushedAnyRemoteCount ?? 1) > 0;
|
|
2969
|
+
const sinceSyncUnpushed = snap.sinceSyncChecked && (snap.sinceSyncCount ?? 1) > 0;
|
|
2970
|
+
const hasUnpushedCommits = !snap.detached && (anyRemoteUnpushed || sinceSyncUnpushed);
|
|
2971
|
+
const recordedRefGone = lastKnownRemoteTip !== void 0 && snap.remoteBranches.length > 0 && !snap.remoteBranches.includes(lastKnownRemoteTip.ref);
|
|
2972
|
+
const fullyPushedUpstreamDeleted = hasUnpushedCommits && recordedRefGone && snap.headPushedToRecordedTip === true;
|
|
2443
2973
|
const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
|
|
2444
|
-
const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
|
|
2974
|
+
const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null || snap.operationProbeUnknown;
|
|
2445
2975
|
const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
|
|
2446
2976
|
const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
|
|
2447
2977
|
const reasons = [];
|
|
2448
2978
|
if (!isClean) reasons.push("uncommitted changes");
|
|
2449
|
-
if (hasUnpushedCommits) reasons.push("unpushed commits");
|
|
2979
|
+
if (hasUnpushedCommits && !fullyPushedUpstreamDeleted) reasons.push("unpushed commits");
|
|
2450
2980
|
if (hasStashedChanges) reasons.push("stashed changes");
|
|
2451
2981
|
if (hasOperationInProgress) reasons.push("operation in progress");
|
|
2452
2982
|
if (hasModifiedSubmodules) reasons.push("modified submodules");
|
|
2453
2983
|
if (upstreamGone) reasons.push("upstream gone");
|
|
2454
|
-
|
|
2984
|
+
if (snap.detached) reasons.push("detached HEAD");
|
|
2985
|
+
const canRemove = isClean && (!hasUnpushedCommits || fullyPushedUpstreamDeleted) && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules && !snap.detached;
|
|
2455
2986
|
const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
|
|
2456
2987
|
return {
|
|
2457
2988
|
isClean,
|
|
@@ -2460,12 +2991,13 @@ var WorktreeStatusService = class {
|
|
|
2460
2991
|
hasOperationInProgress,
|
|
2461
2992
|
hasModifiedSubmodules,
|
|
2462
2993
|
upstreamGone,
|
|
2994
|
+
fullyPushedUpstreamDeleted,
|
|
2463
2995
|
canRemove,
|
|
2464
2996
|
reasons,
|
|
2465
2997
|
details
|
|
2466
2998
|
};
|
|
2467
2999
|
}
|
|
2468
|
-
async collectSnapshot(worktreePath, lastSyncCommit) {
|
|
3000
|
+
async collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
|
|
2469
3001
|
const git = this.createGitInstance(worktreePath);
|
|
2470
3002
|
const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
|
|
2471
3003
|
git.status().catch((e) => {
|
|
@@ -2490,19 +3022,33 @@ var WorktreeStatusService = class {
|
|
|
2490
3022
|
const currentBranch = branchResult?.current ?? null;
|
|
2491
3023
|
const detached = !branchResult?.current || Boolean(branchResult?.detached);
|
|
2492
3024
|
let upstream = null;
|
|
2493
|
-
let
|
|
3025
|
+
let unpushedAnyRemoteCount = null;
|
|
3026
|
+
let sinceSyncCount = null;
|
|
3027
|
+
let headPushedToRecordedTip = null;
|
|
2494
3028
|
if (!detached && currentBranch) {
|
|
2495
|
-
const
|
|
2496
|
-
const [upstreamResult, unpushedResult] = await Promise.all([
|
|
3029
|
+
const [upstreamResult, anyRemoteResult, sinceSyncResult, recordedTipResult] = await Promise.all([
|
|
2497
3030
|
git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
|
|
2498
3031
|
(raw) => ({ ok: true, value: raw }),
|
|
2499
3032
|
(error) => ({ ok: false, error })
|
|
2500
3033
|
),
|
|
2501
|
-
git.raw(
|
|
3034
|
+
git.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]).then(
|
|
2502
3035
|
(raw) => ({ ok: true, value: raw }),
|
|
2503
3036
|
(error) => ({ ok: false, error })
|
|
2504
|
-
)
|
|
3037
|
+
),
|
|
3038
|
+
lastSyncCommit ? git.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]).then(
|
|
3039
|
+
(raw) => ({ ok: true, value: raw }),
|
|
3040
|
+
(error) => ({ ok: false, error })
|
|
3041
|
+
) : Promise.resolve(null),
|
|
3042
|
+
// Zero commits in <tip>..HEAD ⟺ HEAD is the recorded tip or behind it.
|
|
3043
|
+
// NOT merge-base --is-ancestor: simple-git resolves its silent exit-1
|
|
3044
|
+
// ("not an ancestor") as success because nothing is written to stderr.
|
|
3045
|
+
// Any failure (e.g. the recorded oid was gc'd) reads as "not proven".
|
|
3046
|
+
lastKnownRemoteTip ? git.raw(["rev-list", "--count", `${lastKnownRemoteTip.oid}..HEAD`]).then(
|
|
3047
|
+
(raw) => this.parseCount(raw) === 0,
|
|
3048
|
+
() => false
|
|
3049
|
+
) : Promise.resolve(null)
|
|
2505
3050
|
]);
|
|
3051
|
+
headPushedToRecordedTip = recordedTipResult;
|
|
2506
3052
|
if (upstreamResult.ok) {
|
|
2507
3053
|
upstream = upstreamResult.value.trim() || null;
|
|
2508
3054
|
} else {
|
|
@@ -2511,13 +3057,20 @@ var WorktreeStatusService = class {
|
|
|
2511
3057
|
this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
|
|
2512
3058
|
}
|
|
2513
3059
|
}
|
|
2514
|
-
if (
|
|
2515
|
-
|
|
3060
|
+
if (anyRemoteResult.ok) {
|
|
3061
|
+
unpushedAnyRemoteCount = this.parseCount(anyRemoteResult.value);
|
|
2516
3062
|
} else {
|
|
2517
|
-
this.logger.error(`Error checking unpushed commits`,
|
|
3063
|
+
this.logger.error(`Error checking unpushed commits`, anyRemoteResult.error);
|
|
3064
|
+
}
|
|
3065
|
+
if (sinceSyncResult) {
|
|
3066
|
+
if (sinceSyncResult.ok) {
|
|
3067
|
+
sinceSyncCount = this.parseCount(sinceSyncResult.value);
|
|
3068
|
+
} else {
|
|
3069
|
+
this.logger.error(`Error checking commits since last sync`, sinceSyncResult.error);
|
|
3070
|
+
}
|
|
2518
3071
|
}
|
|
2519
3072
|
}
|
|
2520
|
-
const
|
|
3073
|
+
const operationProbe = gitDirResult ? await this.detectOperationFile(gitDirResult) : { file: null, unknown: false };
|
|
2521
3074
|
let untrackedNotIgnored = [];
|
|
2522
3075
|
if (status && status.not_added.length > 0) {
|
|
2523
3076
|
try {
|
|
@@ -2533,14 +3086,22 @@ var WorktreeStatusService = class {
|
|
|
2533
3086
|
detached,
|
|
2534
3087
|
remoteBranches: remoteBranchesResult?.all ?? [],
|
|
2535
3088
|
upstream,
|
|
2536
|
-
|
|
3089
|
+
unpushedAnyRemoteCount,
|
|
3090
|
+
sinceSyncCount,
|
|
3091
|
+
sinceSyncChecked: lastSyncCommit !== void 0,
|
|
3092
|
+
headPushedToRecordedTip,
|
|
2537
3093
|
stashTotal: stashResult?.total ?? null,
|
|
2538
3094
|
submoduleStatus: submoduleResult,
|
|
2539
|
-
operationFile,
|
|
3095
|
+
operationFile: operationProbe.file,
|
|
3096
|
+
operationProbeUnknown: operationProbe.unknown,
|
|
2540
3097
|
gitDir: gitDirResult,
|
|
2541
3098
|
untrackedNotIgnored
|
|
2542
3099
|
};
|
|
2543
3100
|
}
|
|
3101
|
+
parseCount(raw) {
|
|
3102
|
+
const count = parseInt(raw.trim(), 10);
|
|
3103
|
+
return Number.isNaN(count) ? null : count;
|
|
3104
|
+
}
|
|
2544
3105
|
deriveIsClean(snap) {
|
|
2545
3106
|
const status = snap.status;
|
|
2546
3107
|
if (!status) return false;
|
|
@@ -2580,7 +3141,8 @@ var WorktreeStatusService = class {
|
|
|
2580
3141
|
if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
|
|
2581
3142
|
}
|
|
2582
3143
|
if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
|
|
2583
|
-
|
|
3144
|
+
const unpushedCount = snap.unpushedAnyRemoteCount ?? snap.sinceSyncCount;
|
|
3145
|
+
if (!snap.detached && unpushedCount !== null) details.unpushedCommitCount = unpushedCount;
|
|
2584
3146
|
if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
|
|
2585
3147
|
const opType = this.operationTypeFromFile(snap.operationFile);
|
|
2586
3148
|
if (opType) details.operationType = opType;
|
|
@@ -2595,34 +3157,37 @@ var WorktreeStatusService = class {
|
|
|
2595
3157
|
async detectOperationFile(gitDir) {
|
|
2596
3158
|
const results = await Promise.all(
|
|
2597
3159
|
OPERATION_FILES.map(
|
|
2598
|
-
({ file }) =>
|
|
2599
|
-
() =>
|
|
2600
|
-
() =>
|
|
3160
|
+
({ file }) => fs8.access(path10.join(gitDir, file)).then(
|
|
3161
|
+
() => "present",
|
|
3162
|
+
(error) => error.code === "ENOENT" ? "absent" : "unknown"
|
|
2601
3163
|
)
|
|
2602
3164
|
)
|
|
2603
3165
|
);
|
|
2604
|
-
const idx = results.findIndex(
|
|
2605
|
-
|
|
3166
|
+
const idx = results.findIndex((result) => result === "present");
|
|
3167
|
+
if (idx >= 0) return { file: OPERATION_FILES[idx].file, unknown: false };
|
|
3168
|
+
return { file: null, unknown: results.includes("unknown") };
|
|
2606
3169
|
}
|
|
2607
3170
|
async hasUnpushedCommits(worktreePath, lastSyncCommit) {
|
|
2608
3171
|
const worktreeGit = this.createGitInstance(worktreePath);
|
|
2609
3172
|
try {
|
|
2610
3173
|
if (await this.isDetachedHead(worktreeGit)) {
|
|
2611
|
-
return
|
|
3174
|
+
return true;
|
|
2612
3175
|
}
|
|
2613
3176
|
const branchSummary = await worktreeGit.branch();
|
|
2614
3177
|
const currentBranch = branchSummary.current;
|
|
3178
|
+
const anyRemoteResult = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
|
|
3179
|
+
const anyRemoteCount = this.parseCount(anyRemoteResult);
|
|
3180
|
+
if (anyRemoteCount === null || anyRemoteCount > 0) {
|
|
3181
|
+
return true;
|
|
3182
|
+
}
|
|
2615
3183
|
if (lastSyncCommit) {
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
return
|
|
2620
|
-
} catch {
|
|
3184
|
+
const sinceSyncResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
|
|
3185
|
+
const sinceSyncCount = this.parseCount(sinceSyncResult);
|
|
3186
|
+
if (sinceSyncCount === null || sinceSyncCount > 0) {
|
|
3187
|
+
return true;
|
|
2621
3188
|
}
|
|
2622
3189
|
}
|
|
2623
|
-
|
|
2624
|
-
const unpushedCount = parseInt(result.trim(), 10);
|
|
2625
|
-
return unpushedCount > 0;
|
|
3190
|
+
return false;
|
|
2626
3191
|
} catch (error) {
|
|
2627
3192
|
this.logger.error(`Error checking unpushed commits`, error);
|
|
2628
3193
|
return true;
|
|
@@ -2678,14 +3243,15 @@ var WorktreeStatusService = class {
|
|
|
2678
3243
|
async hasOperationInProgress(worktreePath) {
|
|
2679
3244
|
try {
|
|
2680
3245
|
const gitDir = await this.resolveGitDir(worktreePath);
|
|
2681
|
-
|
|
3246
|
+
const probe = await this.detectOperationFile(gitDir);
|
|
3247
|
+
return probe.unknown || probe.file !== null;
|
|
2682
3248
|
} catch (error) {
|
|
2683
3249
|
this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
|
|
2684
3250
|
return true;
|
|
2685
3251
|
}
|
|
2686
3252
|
}
|
|
2687
|
-
async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
|
|
2688
|
-
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
|
|
3253
|
+
async validateWorktreeForRemoval(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
|
|
3254
|
+
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit, lastKnownRemoteTip);
|
|
2689
3255
|
if (!status.canRemove) {
|
|
2690
3256
|
throw new WorktreeNotCleanError(worktreePath, status.reasons);
|
|
2691
3257
|
}
|
|
@@ -2716,14 +3282,14 @@ var WorktreeStatusService = class {
|
|
|
2716
3282
|
}
|
|
2717
3283
|
}
|
|
2718
3284
|
async resolveGitDir(worktreePath) {
|
|
2719
|
-
const gitPath =
|
|
3285
|
+
const gitPath = path10.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
2720
3286
|
try {
|
|
2721
|
-
const stat4 = await
|
|
3287
|
+
const stat4 = await fs8.stat(gitPath);
|
|
2722
3288
|
if (stat4.isFile()) {
|
|
2723
|
-
const content = await
|
|
3289
|
+
const content = await fs8.readFile(gitPath, "utf-8");
|
|
2724
3290
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
2725
3291
|
if (gitdirMatch) {
|
|
2726
|
-
return
|
|
3292
|
+
return path10.resolve(worktreePath, gitdirMatch[1].trim());
|
|
2727
3293
|
}
|
|
2728
3294
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
2729
3295
|
}
|
|
@@ -2737,10 +3303,10 @@ var WorktreeStatusService = class {
|
|
|
2737
3303
|
}
|
|
2738
3304
|
}
|
|
2739
3305
|
createGitInstance(worktreePath) {
|
|
2740
|
-
const key = `${
|
|
3306
|
+
const key = `${path10.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
2741
3307
|
let git = this.gitInstances.get(key);
|
|
2742
3308
|
if (!git) {
|
|
2743
|
-
git = this.config.skipLfs ?
|
|
3309
|
+
git = this.config.skipLfs ? simpleGit5(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit5(worktreePath);
|
|
2744
3310
|
this.gitInstances.set(key, git);
|
|
2745
3311
|
}
|
|
2746
3312
|
return git;
|
|
@@ -2761,11 +3327,13 @@ var GitService = class {
|
|
|
2761
3327
|
this.progressEmitter = progressEmitter;
|
|
2762
3328
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
2763
3329
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
2764
|
-
this.mainWorktreePath =
|
|
3330
|
+
this.mainWorktreePath = path11.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
2765
3331
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
2766
3332
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
2767
3333
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
2768
3334
|
}
|
|
3335
|
+
config;
|
|
3336
|
+
progressEmitter;
|
|
2769
3337
|
git = null;
|
|
2770
3338
|
bareRepoPath;
|
|
2771
3339
|
mainWorktreePath;
|
|
@@ -2789,10 +3357,10 @@ var GitService = class {
|
|
|
2789
3357
|
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
2790
3358
|
}
|
|
2791
3359
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
2792
|
-
const key = `${
|
|
3360
|
+
const key = `${path11.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
2793
3361
|
let git = this.gitInstances.get(key);
|
|
2794
3362
|
if (!git) {
|
|
2795
|
-
const base =
|
|
3363
|
+
const base = simpleGit6(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
2796
3364
|
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
2797
3365
|
this.gitInstances.set(key, git);
|
|
2798
3366
|
}
|
|
@@ -2812,11 +3380,11 @@ var GitService = class {
|
|
|
2812
3380
|
async initialize() {
|
|
2813
3381
|
const { repoUrl } = this.config;
|
|
2814
3382
|
try {
|
|
2815
|
-
await
|
|
3383
|
+
await fs9.access(path11.join(this.bareRepoPath, "HEAD"));
|
|
2816
3384
|
} catch {
|
|
2817
3385
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
2818
|
-
await
|
|
2819
|
-
const cloneBase =
|
|
3386
|
+
await fs9.mkdir(path11.dirname(this.bareRepoPath), { recursive: true });
|
|
3387
|
+
const cloneBase = simpleGit6(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
|
|
2820
3388
|
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
2821
3389
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
|
|
2822
3390
|
this.logger.info("\u2705 Clone successful.");
|
|
@@ -2834,17 +3402,17 @@ var GitService = class {
|
|
|
2834
3402
|
this.logger.info("Fetching remote branches...");
|
|
2835
3403
|
await bareGit.fetch(["--all", "--progress"]);
|
|
2836
3404
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
2837
|
-
this.mainWorktreePath =
|
|
3405
|
+
this.mainWorktreePath = path11.join(this.config.worktreeDir, this.defaultBranch);
|
|
2838
3406
|
let needsMainWorktree = true;
|
|
2839
3407
|
try {
|
|
2840
3408
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2841
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
3409
|
+
needsMainWorktree = !worktrees.some((w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath));
|
|
2842
3410
|
} catch {
|
|
2843
3411
|
}
|
|
2844
3412
|
if (needsMainWorktree) {
|
|
2845
3413
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
2846
|
-
await
|
|
2847
|
-
const absoluteWorktreePath =
|
|
3414
|
+
await fs9.mkdir(this.config.worktreeDir, { recursive: true });
|
|
3415
|
+
const absoluteWorktreePath = path11.resolve(this.mainWorktreePath);
|
|
2848
3416
|
const branches = await bareGit.branch();
|
|
2849
3417
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
2850
3418
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -2880,7 +3448,7 @@ var GitService = class {
|
|
|
2880
3448
|
}
|
|
2881
3449
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
2882
3450
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
2883
|
-
(w) =>
|
|
3451
|
+
(w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath)
|
|
2884
3452
|
);
|
|
2885
3453
|
if (!mainWorktreeRegistered) {
|
|
2886
3454
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -2907,7 +3475,7 @@ var GitService = class {
|
|
|
2907
3475
|
return this.bareRepoPath;
|
|
2908
3476
|
}
|
|
2909
3477
|
async getRemoteDefaultBranch(repoUrl) {
|
|
2910
|
-
const git =
|
|
3478
|
+
const git = simpleGit6(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
2911
3479
|
try {
|
|
2912
3480
|
const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
|
|
2913
3481
|
const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
|
|
@@ -2991,7 +3559,7 @@ var GitService = class {
|
|
|
2991
3559
|
return branches;
|
|
2992
3560
|
}
|
|
2993
3561
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
2994
|
-
const worktreeGit = this.config.sparseCheckout ?
|
|
3562
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit6(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
2995
3563
|
try {
|
|
2996
3564
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
2997
3565
|
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
@@ -3002,7 +3570,7 @@ var GitService = class {
|
|
|
3002
3570
|
const existence = await Promise.all(
|
|
3003
3571
|
lfsFileList.map(async (f) => {
|
|
3004
3572
|
try {
|
|
3005
|
-
await
|
|
3573
|
+
await fs9.access(path11.join(worktreePath, f));
|
|
3006
3574
|
return f;
|
|
3007
3575
|
} catch {
|
|
3008
3576
|
return null;
|
|
@@ -3030,9 +3598,9 @@ var GitService = class {
|
|
|
3030
3598
|
let allDownloaded = true;
|
|
3031
3599
|
const notDownloaded = [];
|
|
3032
3600
|
for (const file of samplesToCheck) {
|
|
3033
|
-
const filePath =
|
|
3601
|
+
const filePath = path11.join(worktreePath, file);
|
|
3034
3602
|
try {
|
|
3035
|
-
const handle = await
|
|
3603
|
+
const handle = await fs9.open(filePath, "r");
|
|
3036
3604
|
try {
|
|
3037
3605
|
const buffer = Buffer.alloc(200);
|
|
3038
3606
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
@@ -3057,7 +3625,7 @@ var GitService = class {
|
|
|
3057
3625
|
}
|
|
3058
3626
|
retries++;
|
|
3059
3627
|
if (retries < maxRetries) {
|
|
3060
|
-
await new Promise((
|
|
3628
|
+
await new Promise((resolve13) => setTimeout(resolve13, retryDelay));
|
|
3061
3629
|
}
|
|
3062
3630
|
}
|
|
3063
3631
|
this.logger.warn(
|
|
@@ -3119,20 +3687,23 @@ var GitService = class {
|
|
|
3119
3687
|
}
|
|
3120
3688
|
async addWorktree(branchName, worktreePath) {
|
|
3121
3689
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
3122
|
-
const absoluteWorktreePath =
|
|
3123
|
-
await
|
|
3690
|
+
const absoluteWorktreePath = path11.resolve(worktreePath);
|
|
3691
|
+
await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
|
|
3124
3692
|
try {
|
|
3125
|
-
await
|
|
3693
|
+
await fs9.access(absoluteWorktreePath);
|
|
3126
3694
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3127
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3695
|
+
const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
3128
3696
|
if (isValidWorktree) {
|
|
3129
3697
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3130
3698
|
return;
|
|
3131
3699
|
} else {
|
|
3132
3700
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
3133
|
-
await
|
|
3701
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
3702
|
+
}
|
|
3703
|
+
} catch (error) {
|
|
3704
|
+
if (error instanceof GitOperationError || error instanceof WorktreeError) {
|
|
3705
|
+
throw error;
|
|
3134
3706
|
}
|
|
3135
|
-
} catch {
|
|
3136
3707
|
}
|
|
3137
3708
|
let createdNewBranch = false;
|
|
3138
3709
|
try {
|
|
@@ -3169,17 +3740,14 @@ var GitService = class {
|
|
|
3169
3740
|
}
|
|
3170
3741
|
if (errorMessage.includes("already registered worktree")) {
|
|
3171
3742
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3172
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3743
|
+
const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
3173
3744
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3174
3745
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
3175
3746
|
return;
|
|
3176
3747
|
}
|
|
3177
3748
|
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
3178
3749
|
await bareGit.raw(["worktree", "prune"]);
|
|
3179
|
-
|
|
3180
|
-
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3181
|
-
} catch {
|
|
3182
|
-
}
|
|
3750
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
3183
3751
|
let retryCreatedNewBranch = false;
|
|
3184
3752
|
try {
|
|
3185
3753
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
@@ -3213,17 +3781,20 @@ var GitService = class {
|
|
|
3213
3781
|
}
|
|
3214
3782
|
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
3215
3783
|
try {
|
|
3216
|
-
await
|
|
3784
|
+
await fs9.access(absoluteWorktreePath);
|
|
3217
3785
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3218
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3786
|
+
const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
3219
3787
|
if (isValidWorktree) {
|
|
3220
3788
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3221
3789
|
return;
|
|
3222
3790
|
} else {
|
|
3223
3791
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
3224
|
-
await
|
|
3792
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
3793
|
+
}
|
|
3794
|
+
} catch (error2) {
|
|
3795
|
+
if (error2 instanceof GitOperationError || error2 instanceof WorktreeError) {
|
|
3796
|
+
throw error2;
|
|
3225
3797
|
}
|
|
3226
|
-
} catch {
|
|
3227
3798
|
}
|
|
3228
3799
|
try {
|
|
3229
3800
|
const useNoCheckout = !!this.config.sparseCheckout;
|
|
@@ -3245,7 +3816,7 @@ var GitService = class {
|
|
|
3245
3816
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
3246
3817
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
3247
3818
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3248
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3819
|
+
const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
3249
3820
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3250
3821
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
3251
3822
|
return;
|
|
@@ -3314,9 +3885,19 @@ var GitService = class {
|
|
|
3314
3885
|
wrapped.isUpstreamSetupFailure = true;
|
|
3315
3886
|
return wrapped;
|
|
3316
3887
|
}
|
|
3317
|
-
async removeWorktree(worktreePath) {
|
|
3888
|
+
async removeWorktree(worktreePath, options) {
|
|
3318
3889
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3319
|
-
|
|
3890
|
+
const args = ["worktree", "remove", worktreePath];
|
|
3891
|
+
if (options?.force) args.push("--force");
|
|
3892
|
+
try {
|
|
3893
|
+
await bareGit.raw(args);
|
|
3894
|
+
} catch (error) {
|
|
3895
|
+
const message = getErrorMessage(error);
|
|
3896
|
+
if (!options?.force && /contains modified or untracked files|use --force/i.test(message)) {
|
|
3897
|
+
throw new WorktreeNotCleanError(worktreePath, [`git refused removal: ${message}`]);
|
|
3898
|
+
}
|
|
3899
|
+
throw error;
|
|
3900
|
+
}
|
|
3320
3901
|
this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
|
|
3321
3902
|
try {
|
|
3322
3903
|
await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
@@ -3329,6 +3910,111 @@ var GitService = class {
|
|
|
3329
3910
|
await bareGit.raw(["worktree", "prune"]);
|
|
3330
3911
|
this.logger.info("Pruned worktree metadata.");
|
|
3331
3912
|
}
|
|
3913
|
+
async updateRef(refName, sha) {
|
|
3914
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3915
|
+
await bareGit.raw(["update-ref", refName, sha]);
|
|
3916
|
+
}
|
|
3917
|
+
async deleteRef(refName) {
|
|
3918
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3919
|
+
await bareGit.raw(["update-ref", "-d", refName]);
|
|
3920
|
+
}
|
|
3921
|
+
async listRefs(prefix) {
|
|
3922
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3923
|
+
const raw = await bareGit.raw(["for-each-ref", "--format=%(refname)", prefix]);
|
|
3924
|
+
return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
3925
|
+
}
|
|
3926
|
+
async localBranchExists(branchName) {
|
|
3927
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3928
|
+
try {
|
|
3929
|
+
await bareGit.raw(["show-ref", "--verify", "--quiet", `${GIT_CONSTANTS.REFS.HEADS}${branchName}`]);
|
|
3930
|
+
return true;
|
|
3931
|
+
} catch {
|
|
3932
|
+
return false;
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
async getLocalBranchCommit(branchName) {
|
|
3936
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3937
|
+
try {
|
|
3938
|
+
return (await bareGit.raw(["rev-parse", `${GIT_CONSTANTS.REFS.HEADS}${branchName}^{commit}`])).trim();
|
|
3939
|
+
} catch {
|
|
3940
|
+
return null;
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
async createBranchAt(branchName, sha) {
|
|
3944
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3945
|
+
await bareGit.raw(["branch", branchName, sha]);
|
|
3946
|
+
}
|
|
3947
|
+
async deleteLocalBranch(branchName) {
|
|
3948
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3949
|
+
await bareGit.raw(["branch", "-D", branchName]);
|
|
3950
|
+
}
|
|
3951
|
+
// Bundles only commits not reachable from any remote — for fully-pushed
|
|
3952
|
+
// refs that set is empty and `bundle create` would fail. Emptiness is
|
|
3953
|
+
// pre-checked with rev-list (locale-independent) instead of parsing git's
|
|
3954
|
+
// localized "empty bundle" stderr; after the pre-check, any bundle-create
|
|
3955
|
+
// error is a real failure the caller must treat as fail-closed.
|
|
3956
|
+
async createBundleFromRef(bundlePath, refName) {
|
|
3957
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3958
|
+
const count = (await bareGit.raw(["rev-list", "--count", refName, "--not", "--remotes"])).trim();
|
|
3959
|
+
if (count === "0") {
|
|
3960
|
+
return false;
|
|
3961
|
+
}
|
|
3962
|
+
await bareGit.raw(["bundle", "create", bundlePath, refName, "--not", "--remotes"]);
|
|
3963
|
+
return true;
|
|
3964
|
+
}
|
|
3965
|
+
// Registers the worktree and writes its .git link without populating files —
|
|
3966
|
+
// restore overlays the preserved payload instead of a fresh checkout.
|
|
3967
|
+
async addWorktreeNoCheckout(branchName, worktreePath) {
|
|
3968
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
3969
|
+
const absoluteWorktreePath = path11.resolve(worktreePath);
|
|
3970
|
+
await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
|
|
3971
|
+
await bareGit.raw(["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName]);
|
|
3972
|
+
}
|
|
3973
|
+
// Mixed reset: points the index at HEAD without touching working files, so
|
|
3974
|
+
// overlaid payload content shows up as ordinary uncommitted changes.
|
|
3975
|
+
async resetWorktreeIndex(worktreePath) {
|
|
3976
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3977
|
+
await worktreeGit.raw(["reset"]);
|
|
3978
|
+
}
|
|
3979
|
+
// Injected by WorktreeSyncService when trash is enabled, so stale-directory
|
|
3980
|
+
// cleanup follows the same reversible-removal pipeline as everything else.
|
|
3981
|
+
// GitService cannot own a TrashService directly (TrashService depends on it).
|
|
3982
|
+
staleDirectoryTrasher = null;
|
|
3983
|
+
setStaleDirectoryTrasher(trasher) {
|
|
3984
|
+
this.staleDirectoryTrasher = trasher;
|
|
3985
|
+
}
|
|
3986
|
+
// A stale directory that contains a .git may be a live checkout that git
|
|
3987
|
+
// failed to report; quarantine it instead of deleting.
|
|
3988
|
+
async clearStaleWorktreeDirectory(absoluteWorktreePath) {
|
|
3989
|
+
const gitProbe = await probePathExists(path11.join(absoluteWorktreePath, PATH_CONSTANTS.GIT_DIR));
|
|
3990
|
+
if (gitProbe === "unknown") {
|
|
3991
|
+
throw new GitOperationError(
|
|
3992
|
+
"clear-stale-directory",
|
|
3993
|
+
`Cannot verify whether '${absoluteWorktreePath}' is a live checkout; refusing to clear it`
|
|
3994
|
+
);
|
|
3995
|
+
}
|
|
3996
|
+
if (this.staleDirectoryTrasher) {
|
|
3997
|
+
try {
|
|
3998
|
+
const trashPath = await this.staleDirectoryTrasher(absoluteWorktreePath);
|
|
3999
|
+
this.logger.info(` - Moved stale directory at '${absoluteWorktreePath}' to trash ('${trashPath}')`);
|
|
4000
|
+
return;
|
|
4001
|
+
} catch (error) {
|
|
4002
|
+
throw new GitOperationError(
|
|
4003
|
+
"clear-stale-directory",
|
|
4004
|
+
`Cannot move stale directory '${absoluteWorktreePath}' to trash: ${getErrorMessage(error)}`,
|
|
4005
|
+
error instanceof Error ? error : void 0
|
|
4006
|
+
);
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
if (gitProbe === "exists") {
|
|
4010
|
+
const quarantinePath = await quarantineDirectory(absoluteWorktreePath);
|
|
4011
|
+
this.logger.warn(
|
|
4012
|
+
` - \u26A0\uFE0F Directory at '${absoluteWorktreePath}' contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
|
|
4013
|
+
);
|
|
4014
|
+
return;
|
|
4015
|
+
}
|
|
4016
|
+
await fs9.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
4017
|
+
}
|
|
3332
4018
|
async checkWorktreeStatus(worktreePath) {
|
|
3333
4019
|
return this.statusService.checkWorktreeStatus(worktreePath);
|
|
3334
4020
|
}
|
|
@@ -3344,7 +4030,37 @@ var GitService = class {
|
|
|
3344
4030
|
}
|
|
3345
4031
|
async getFullWorktreeStatus(worktreePath, includeDetails = false) {
|
|
3346
4032
|
const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
3347
|
-
return this.statusService.getFullWorktreeStatus(
|
|
4033
|
+
return this.statusService.getFullWorktreeStatus(
|
|
4034
|
+
worktreePath,
|
|
4035
|
+
includeDetails,
|
|
4036
|
+
metadata?.lastSyncCommit,
|
|
4037
|
+
metadata?.lastKnownRemoteTip
|
|
4038
|
+
);
|
|
4039
|
+
}
|
|
4040
|
+
/** Map of remote branch name (without "origin/") → tip oid, from the bare repo. */
|
|
4041
|
+
async getRemoteBranchTips() {
|
|
4042
|
+
const git = this.getGit();
|
|
4043
|
+
const raw = await git.raw(["for-each-ref", "--format=%(refname:short) %(objectname)", GIT_CONSTANTS.REFS.REMOTES]);
|
|
4044
|
+
const tips = /* @__PURE__ */ new Map();
|
|
4045
|
+
for (const line of raw.split("\n")) {
|
|
4046
|
+
const trimmed = line.trim();
|
|
4047
|
+
if (!trimmed) continue;
|
|
4048
|
+
const spaceIdx = trimmed.lastIndexOf(" ");
|
|
4049
|
+
if (spaceIdx <= 0) continue;
|
|
4050
|
+
const ref = trimmed.slice(0, spaceIdx);
|
|
4051
|
+
const oid = trimmed.slice(spaceIdx + 1);
|
|
4052
|
+
if (!ref.startsWith(GIT_CONSTANTS.REMOTE_PREFIX) || ref === `${GIT_CONSTANTS.REMOTE_PREFIX}HEAD`) continue;
|
|
4053
|
+
tips.set(ref.slice(GIT_CONSTANTS.REMOTE_PREFIX.length), oid);
|
|
4054
|
+
}
|
|
4055
|
+
return tips;
|
|
4056
|
+
}
|
|
4057
|
+
async recordRemoteTip(worktreePath, branchName, oid) {
|
|
4058
|
+
await this.metadataService.recordRemoteTip(
|
|
4059
|
+
this.bareRepoPath,
|
|
4060
|
+
worktreePath,
|
|
4061
|
+
`${GIT_CONSTANTS.REMOTE_PREFIX}${branchName}`,
|
|
4062
|
+
oid
|
|
4063
|
+
);
|
|
3348
4064
|
}
|
|
3349
4065
|
async hasModifiedSubmodules(worktreePath) {
|
|
3350
4066
|
return this.statusService.hasModifiedSubmodules(worktreePath);
|
|
@@ -3629,37 +4345,41 @@ var ProgressEmitter = class {
|
|
|
3629
4345
|
}
|
|
3630
4346
|
};
|
|
3631
4347
|
|
|
3632
|
-
// src/services/
|
|
3633
|
-
import * as
|
|
3634
|
-
import * as
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
4348
|
+
// src/services/removal-audit.service.ts
|
|
4349
|
+
import * as fs10 from "fs/promises";
|
|
4350
|
+
import * as path12 from "path";
|
|
4351
|
+
var RemovalAuditService = class {
|
|
4352
|
+
constructor(logFilePath) {
|
|
4353
|
+
this.logFilePath = logFilePath;
|
|
4354
|
+
}
|
|
4355
|
+
logFilePath;
|
|
4356
|
+
async record(entry) {
|
|
4357
|
+
await fs10.mkdir(path12.dirname(this.logFilePath), { recursive: true });
|
|
4358
|
+
const line = JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
4359
|
+
const handle = await fs10.open(this.logFilePath, "a");
|
|
4360
|
+
try {
|
|
4361
|
+
await handle.appendFile(`${line}
|
|
4362
|
+
`, "utf-8");
|
|
4363
|
+
await handle.sync();
|
|
4364
|
+
} finally {
|
|
4365
|
+
await handle.close();
|
|
4366
|
+
}
|
|
3650
4367
|
}
|
|
3651
|
-
|
|
3652
|
-
const dir = path9.join(stateBase, "sync-worktrees", "locks");
|
|
3653
|
-
return { dir, file: `${hash}.lock` };
|
|
3654
|
-
}
|
|
4368
|
+
};
|
|
3655
4369
|
|
|
3656
4370
|
// src/services/repo-operation-lock.ts
|
|
4371
|
+
import * as fs11 from "fs/promises";
|
|
4372
|
+
import * as path13 from "path";
|
|
4373
|
+
import * as lockfile from "proper-lockfile";
|
|
3657
4374
|
var RepoOperationLock = class {
|
|
3658
4375
|
constructor(config, gitService, logger = Logger.createDefault()) {
|
|
3659
4376
|
this.config = config;
|
|
3660
4377
|
this.gitService = gitService;
|
|
3661
4378
|
this.logger = logger;
|
|
3662
4379
|
}
|
|
4380
|
+
config;
|
|
4381
|
+
gitService;
|
|
4382
|
+
logger;
|
|
3663
4383
|
updateLogger(logger) {
|
|
3664
4384
|
this.logger = logger;
|
|
3665
4385
|
}
|
|
@@ -3675,10 +4395,10 @@ var RepoOperationLock = class {
|
|
|
3675
4395
|
}
|
|
3676
4396
|
async acquireCloneModeLock() {
|
|
3677
4397
|
const target = getCloneModeLockTarget(this.config);
|
|
3678
|
-
const lockTarget =
|
|
4398
|
+
const lockTarget = path13.join(target.dir, target.file);
|
|
3679
4399
|
try {
|
|
3680
|
-
await
|
|
3681
|
-
await
|
|
4400
|
+
await fs11.mkdir(target.dir, { recursive: true });
|
|
4401
|
+
await fs11.writeFile(lockTarget, "", { flag: "a" });
|
|
3682
4402
|
} catch {
|
|
3683
4403
|
return null;
|
|
3684
4404
|
}
|
|
@@ -3687,7 +4407,7 @@ var RepoOperationLock = class {
|
|
|
3687
4407
|
async acquireWorktreeModeLock() {
|
|
3688
4408
|
const barePath = this.gitService.getBareRepoPath();
|
|
3689
4409
|
try {
|
|
3690
|
-
await
|
|
4410
|
+
await fs11.mkdir(barePath, { recursive: true });
|
|
3691
4411
|
} catch {
|
|
3692
4412
|
return null;
|
|
3693
4413
|
}
|
|
@@ -3721,6 +4441,9 @@ var SyncRetryPolicy = class {
|
|
|
3721
4441
|
this.gitService = gitService;
|
|
3722
4442
|
this.logger = logger;
|
|
3723
4443
|
}
|
|
4444
|
+
config;
|
|
4445
|
+
gitService;
|
|
4446
|
+
logger;
|
|
3724
4447
|
updateLogger(logger) {
|
|
3725
4448
|
this.logger = logger;
|
|
3726
4449
|
}
|
|
@@ -3753,72 +4476,727 @@ var SyncRetryPolicy = class {
|
|
|
3753
4476
|
syncContext.lfsSkipEnabled = true;
|
|
3754
4477
|
}
|
|
3755
4478
|
}
|
|
3756
|
-
};
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
4481
|
+
resetLfsSkipIfNeeded(syncContext) {
|
|
4482
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
4483
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
};
|
|
4487
|
+
|
|
4488
|
+
// src/services/trash-migration.service.ts
|
|
4489
|
+
import * as fs12 from "fs/promises";
|
|
4490
|
+
import * as path14 from "path";
|
|
4491
|
+
var REMOVED_ENTRY_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-(.+)$/;
|
|
4492
|
+
var TrashMigrationService = class {
|
|
4493
|
+
constructor(config, trashService, logger) {
|
|
4494
|
+
this.config = config;
|
|
4495
|
+
this.trashService = trashService;
|
|
4496
|
+
this.logger = logger;
|
|
4497
|
+
}
|
|
4498
|
+
config;
|
|
4499
|
+
trashService;
|
|
4500
|
+
logger;
|
|
4501
|
+
updateLogger(logger) {
|
|
4502
|
+
this.logger = logger;
|
|
4503
|
+
}
|
|
4504
|
+
isEnabled() {
|
|
4505
|
+
return this.trashService.isEnabled() && (this.config.trash?.migrateLegacy ?? DEFAULT_CONFIG.TRASH.MIGRATE_LEGACY);
|
|
4506
|
+
}
|
|
4507
|
+
async migrateLegacyUnlocked() {
|
|
4508
|
+
if (!this.isEnabled()) return;
|
|
4509
|
+
await this.migrateRemovedDir();
|
|
4510
|
+
await this.migrateDivergedDir();
|
|
4511
|
+
}
|
|
4512
|
+
async migrateRemovedDir() {
|
|
4513
|
+
const removedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.REMOVED_DIR_NAME);
|
|
4514
|
+
const names = await this.listDirectories(removedDir);
|
|
4515
|
+
for (const name of names) {
|
|
4516
|
+
const match = REMOVED_ENTRY_RE.exec(name);
|
|
4517
|
+
const quarantinedAt = match ? this.parseQuarantineTimestamp(match[1]) : null;
|
|
4518
|
+
if (!match || !quarantinedAt) {
|
|
4519
|
+
this.logger.warn(`\u26A0\uFE0F Leaving unrecognized entry '${name}' in ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ alone`);
|
|
4520
|
+
continue;
|
|
4521
|
+
}
|
|
4522
|
+
try {
|
|
4523
|
+
const entry = await this.trashService.trashDirectory({
|
|
4524
|
+
dirPath: path14.join(removedDir, name),
|
|
4525
|
+
reason: "legacy-adopt",
|
|
4526
|
+
source: ".removed",
|
|
4527
|
+
legacyOriginalName: name,
|
|
4528
|
+
legacyQuarantinedAt: quarantinedAt,
|
|
4529
|
+
headOid: null,
|
|
4530
|
+
originalPath: path14.join(this.config.worktreeDir, match[2]),
|
|
4531
|
+
auditAction: "trash_adopt"
|
|
4532
|
+
});
|
|
4533
|
+
this.logger.info(
|
|
4534
|
+
`\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
|
|
4535
|
+
);
|
|
4536
|
+
} catch (error) {
|
|
4537
|
+
this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
await fs12.rmdir(removedDir).catch(() => void 0);
|
|
4541
|
+
}
|
|
4542
|
+
async migrateDivergedDir() {
|
|
4543
|
+
const divergedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4544
|
+
const names = await this.listDirectories(divergedDir);
|
|
4545
|
+
for (const name of names) {
|
|
4546
|
+
const dirPath = path14.join(divergedDir, name);
|
|
4547
|
+
const info = await this.readDivergedInfo(dirPath);
|
|
4548
|
+
const quarantinedAt = info?.divergedAt ? new Date(info.divergedAt) : null;
|
|
4549
|
+
const hasOriginalPath = typeof info?.originalPath === "string" && info.originalPath.length > 0;
|
|
4550
|
+
if (!info || !info.originalBranch || !hasOriginalPath || !quarantinedAt || Number.isNaN(quarantinedAt.getTime())) {
|
|
4551
|
+
this.logger.warn(
|
|
4552
|
+
`\u26A0\uFE0F Leaving entry '${name}' in ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ alone (no parseable ${METADATA_CONSTANTS.DIVERGED_INFO_FILE})`
|
|
4553
|
+
);
|
|
4554
|
+
continue;
|
|
4555
|
+
}
|
|
4556
|
+
try {
|
|
4557
|
+
const entry = await this.trashService.trashDirectory({
|
|
4558
|
+
dirPath,
|
|
4559
|
+
reason: "legacy-adopt",
|
|
4560
|
+
source: ".diverged",
|
|
4561
|
+
branch: info.originalBranch,
|
|
4562
|
+
legacyOriginalName: name,
|
|
4563
|
+
legacyQuarantinedAt: quarantinedAt,
|
|
4564
|
+
headOid: info.localCommit ?? null,
|
|
4565
|
+
originalPath: info.originalPath,
|
|
4566
|
+
auditAction: "trash_adopt",
|
|
4567
|
+
keepPinOnReap: true
|
|
4568
|
+
});
|
|
4569
|
+
this.logger.info(
|
|
4570
|
+
`\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
|
|
4571
|
+
);
|
|
4572
|
+
} catch (error) {
|
|
4573
|
+
this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
await fs12.rmdir(divergedDir).catch(() => void 0);
|
|
4577
|
+
}
|
|
4578
|
+
async listDirectories(dirPath) {
|
|
4579
|
+
try {
|
|
4580
|
+
const dirents = await fs12.readdir(dirPath, { withFileTypes: true });
|
|
4581
|
+
return dirents.filter((dirent) => dirent.isDirectory() && !dirent.isSymbolicLink()).map((dirent) => dirent.name);
|
|
4582
|
+
} catch (error) {
|
|
4583
|
+
if (error.code !== "ENOENT") {
|
|
4584
|
+
this.logger.warn(`\u26A0\uFE0F Cannot scan '${dirPath}' for legacy trash adoption: ${getErrorMessage(error)}`);
|
|
4585
|
+
}
|
|
4586
|
+
return [];
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
async readDivergedInfo(dirPath) {
|
|
4590
|
+
try {
|
|
4591
|
+
const raw = await fs12.readFile(path14.join(dirPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE), "utf-8");
|
|
4592
|
+
return JSON.parse(raw);
|
|
4593
|
+
} catch {
|
|
4594
|
+
return null;
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
// quarantine timestamps replaced [:.] with "-": 2026-06-06T18-34-18-123Z
|
|
4598
|
+
parseQuarantineTimestamp(raw) {
|
|
4599
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(raw);
|
|
4600
|
+
if (!match) return null;
|
|
4601
|
+
const date = /* @__PURE__ */ new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`);
|
|
4602
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
4603
|
+
}
|
|
4604
|
+
};
|
|
4605
|
+
|
|
4606
|
+
// src/services/trash-reaper.service.ts
|
|
4607
|
+
import * as fs14 from "fs/promises";
|
|
4608
|
+
import * as path16 from "path";
|
|
4609
|
+
|
|
4610
|
+
// src/utils/disk-space.ts
|
|
4611
|
+
import fastFolderSize from "fast-folder-size";
|
|
4612
|
+
async function calculateDirectorySize(dirPath) {
|
|
4613
|
+
return new Promise((resolve13, reject) => {
|
|
4614
|
+
fastFolderSize(dirPath, (err, bytes) => {
|
|
4615
|
+
if (err) {
|
|
4616
|
+
reject(err);
|
|
4617
|
+
return;
|
|
4618
|
+
}
|
|
4619
|
+
if (bytes === void 0) {
|
|
4620
|
+
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
4621
|
+
return;
|
|
4622
|
+
}
|
|
4623
|
+
resolve13(bytes);
|
|
4624
|
+
});
|
|
4625
|
+
});
|
|
4626
|
+
}
|
|
4627
|
+
function formatBytes(bytes) {
|
|
4628
|
+
if (bytes === 0) return "0 B";
|
|
4629
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
4630
|
+
const k = 1024;
|
|
4631
|
+
const decimals = 2;
|
|
4632
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
4633
|
+
const value = bytes / Math.pow(k, i);
|
|
4634
|
+
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
// src/services/trash.service.ts
|
|
4638
|
+
import { randomBytes } from "crypto";
|
|
4639
|
+
import * as fs13 from "fs/promises";
|
|
4640
|
+
import * as path15 from "path";
|
|
4641
|
+
function isWorktreeRestorable(manifest) {
|
|
4642
|
+
return manifest.branch !== null && manifest.headOid !== null && manifest.pinRef !== null;
|
|
4643
|
+
}
|
|
4644
|
+
function summarizeTrashEntries(entries) {
|
|
4645
|
+
let totalSizeBytes = 0;
|
|
4646
|
+
let unknownSizeCount = 0;
|
|
4647
|
+
let soonest = null;
|
|
4648
|
+
for (const { manifest } of entries) {
|
|
4649
|
+
if (manifest.sizeBytes === null) {
|
|
4650
|
+
unknownSizeCount++;
|
|
4651
|
+
} else {
|
|
4652
|
+
totalSizeBytes += manifest.sizeBytes;
|
|
4653
|
+
}
|
|
4654
|
+
if (soonest === null || manifest.expiresAt < soonest) {
|
|
4655
|
+
soonest = manifest.expiresAt;
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
return { itemCount: entries.length, totalSizeBytes, unknownSizeCount, soonestExpiresAt: soonest };
|
|
4659
|
+
}
|
|
4660
|
+
var TrashService = class {
|
|
4661
|
+
constructor(config, gitService, logger, removalAudit) {
|
|
4662
|
+
this.config = config;
|
|
4663
|
+
this.gitService = gitService;
|
|
4664
|
+
this.logger = logger;
|
|
4665
|
+
this.removalAudit = removalAudit;
|
|
4666
|
+
}
|
|
4667
|
+
config;
|
|
4668
|
+
gitService;
|
|
4669
|
+
logger;
|
|
4670
|
+
removalAudit;
|
|
4671
|
+
updateLogger(logger) {
|
|
4672
|
+
this.logger = logger;
|
|
4673
|
+
}
|
|
4674
|
+
isEnabled() {
|
|
4675
|
+
return this.config.trash?.enabled ?? DEFAULT_CONFIG.TRASH.ENABLED;
|
|
4676
|
+
}
|
|
4677
|
+
getTrashRoot() {
|
|
4678
|
+
return path15.join(this.config.worktreeDir, GIT_CONSTANTS.TRASH_DIR_NAME);
|
|
4679
|
+
}
|
|
4680
|
+
getRetentionDays() {
|
|
4681
|
+
return this.config.trash?.retentionDays ?? DEFAULT_CONFIG.TRASH.RETENTION_DAYS;
|
|
4682
|
+
}
|
|
4683
|
+
async trashDirectory(options) {
|
|
4684
|
+
const deletedAt = /* @__PURE__ */ new Date();
|
|
4685
|
+
const expiresAt = new Date(deletedAt.getTime() + this.getRetentionDays() * 864e5);
|
|
4686
|
+
const keepPinOnReap = options.keepPinOnReap ?? false;
|
|
4687
|
+
const headOid = options.headOid !== void 0 ? options.headOid : await this.resolveHeadOid(options);
|
|
4688
|
+
if (keepPinOnReap && !headOid) {
|
|
4689
|
+
throw new TrashOperationError(
|
|
4690
|
+
"trash-directory",
|
|
4691
|
+
`cannot create keep-on-reap trash entry for '${options.dirPath}': HEAD commit could not be resolved`
|
|
4692
|
+
);
|
|
4693
|
+
}
|
|
4694
|
+
const sizeBytes = await calculateDirectorySize(options.dirPath).catch(() => null);
|
|
4695
|
+
await fs13.mkdir(this.getTrashRoot(), { recursive: true });
|
|
4696
|
+
const { id, containerPath } = await this.createContainer(deletedAt, path15.basename(options.dirPath));
|
|
4697
|
+
const manifest = {
|
|
4698
|
+
schemaVersion: TRASH_CONSTANTS.SCHEMA_VERSION,
|
|
4699
|
+
id,
|
|
4700
|
+
deletedAt: deletedAt.toISOString(),
|
|
4701
|
+
expiresAt: expiresAt.toISOString(),
|
|
4702
|
+
originalPath: path15.resolve(options.originalPath ?? options.dirPath),
|
|
4703
|
+
branch: options.branch ?? null,
|
|
4704
|
+
reason: options.reason,
|
|
4705
|
+
sizeBytes,
|
|
4706
|
+
headOid,
|
|
4707
|
+
pinRef: null,
|
|
4708
|
+
bundleFile: null,
|
|
4709
|
+
source: options.source ?? "worktree",
|
|
4710
|
+
legacyOriginalName: options.legacyOriginalName ?? null,
|
|
4711
|
+
legacyQuarantinedAt: options.legacyQuarantinedAt?.toISOString() ?? null,
|
|
4712
|
+
keepPinOnReap
|
|
4713
|
+
};
|
|
4714
|
+
try {
|
|
4715
|
+
await this.writeManifest(containerPath, manifest);
|
|
4716
|
+
} catch (error) {
|
|
4717
|
+
await this.undoPartialTrash(containerPath, null);
|
|
4718
|
+
throw new TrashOperationError(
|
|
4719
|
+
"trash-directory",
|
|
4720
|
+
`cannot write trash manifest for '${options.dirPath}': ${getErrorMessage(error)}`,
|
|
4721
|
+
error instanceof Error ? error : void 0
|
|
4722
|
+
);
|
|
4723
|
+
}
|
|
4724
|
+
const pinRef = headOid ? await this.createPinRef(id, headOid) : null;
|
|
4725
|
+
if (keepPinOnReap && !pinRef) {
|
|
4726
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
4727
|
+
throw new TrashOperationError(
|
|
4728
|
+
"trash-directory",
|
|
4729
|
+
`cannot create keep-on-reap trash entry '${id}' for '${options.dirPath}': pin ref could not be created`
|
|
4730
|
+
);
|
|
4731
|
+
}
|
|
4732
|
+
let bundleFile = null;
|
|
4733
|
+
if (keepPinOnReap && pinRef) {
|
|
4734
|
+
try {
|
|
4735
|
+
const created = await this.gitService.createBundleFromRef(
|
|
4736
|
+
path15.join(containerPath, TRASH_CONSTANTS.BUNDLE_FILENAME),
|
|
4737
|
+
pinRef
|
|
4738
|
+
);
|
|
4739
|
+
bundleFile = created ? TRASH_CONSTANTS.BUNDLE_FILENAME : null;
|
|
4740
|
+
} catch (error) {
|
|
4741
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
4742
|
+
throw new TrashOperationError(
|
|
4743
|
+
"trash-directory",
|
|
4744
|
+
`cannot bundle commits for keep-on-reap trash entry '${id}': ${getErrorMessage(error)}`,
|
|
4745
|
+
error instanceof Error ? error : void 0
|
|
4746
|
+
);
|
|
4747
|
+
}
|
|
4748
|
+
}
|
|
4749
|
+
const payloadPath = path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME);
|
|
4750
|
+
manifest.pinRef = pinRef;
|
|
4751
|
+
manifest.bundleFile = bundleFile;
|
|
4752
|
+
try {
|
|
4753
|
+
await this.writeManifest(containerPath, manifest);
|
|
4754
|
+
await fs13.rename(options.dirPath, payloadPath);
|
|
4755
|
+
} catch (error) {
|
|
4756
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
4757
|
+
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)" : "";
|
|
4758
|
+
throw new TrashOperationError(
|
|
4759
|
+
"trash-directory",
|
|
4760
|
+
`cannot move '${options.dirPath}' to trash${hint}: ${getErrorMessage(error)}`,
|
|
4761
|
+
error instanceof Error ? error : void 0
|
|
4762
|
+
);
|
|
4763
|
+
}
|
|
4764
|
+
await this.removalAudit.record({
|
|
4765
|
+
action: options.auditAction ?? "trash_create",
|
|
4766
|
+
result: "success",
|
|
4767
|
+
path: manifest.originalPath,
|
|
4768
|
+
branch: manifest.branch ?? void 0,
|
|
4769
|
+
trashId: id,
|
|
4770
|
+
trashPath: payloadPath
|
|
4771
|
+
}).catch(
|
|
4772
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
4773
|
+
);
|
|
4774
|
+
return { manifest, containerPath, payloadPath };
|
|
4775
|
+
}
|
|
4776
|
+
async listEntries() {
|
|
4777
|
+
const root = this.getTrashRoot();
|
|
4778
|
+
const entries = [];
|
|
4779
|
+
const invalid = [];
|
|
4780
|
+
let dirents;
|
|
4781
|
+
try {
|
|
4782
|
+
dirents = await fs13.readdir(root, { withFileTypes: true });
|
|
4783
|
+
} catch (error) {
|
|
4784
|
+
if (error.code === "ENOENT") {
|
|
4785
|
+
return { entries, invalid };
|
|
4786
|
+
}
|
|
4787
|
+
throw error;
|
|
4788
|
+
}
|
|
4789
|
+
for (const dirent of dirents) {
|
|
4790
|
+
const containerPath = path15.join(root, dirent.name);
|
|
4791
|
+
if (dirent.isSymbolicLink()) {
|
|
4792
|
+
invalid.push(containerPath);
|
|
4793
|
+
continue;
|
|
4794
|
+
}
|
|
4795
|
+
if (!dirent.isDirectory()) {
|
|
4796
|
+
continue;
|
|
4797
|
+
}
|
|
4798
|
+
const manifest = await this.readManifest(containerPath);
|
|
4799
|
+
if (manifest === null) {
|
|
4800
|
+
invalid.push(containerPath);
|
|
4801
|
+
continue;
|
|
4802
|
+
}
|
|
4803
|
+
entries.push({
|
|
4804
|
+
manifest,
|
|
4805
|
+
containerPath,
|
|
4806
|
+
payloadPath: path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME)
|
|
4807
|
+
});
|
|
4808
|
+
}
|
|
4809
|
+
return { entries, invalid };
|
|
4810
|
+
}
|
|
4811
|
+
// The full reversible-removal sequence shared by prune and manual removal:
|
|
4812
|
+
// payload to trash, dangling registration cleared, branch ref deleted.
|
|
4813
|
+
// A ref-delete failure is a hygiene problem, not a failed removal — the
|
|
4814
|
+
// payload and pin ref already capture everything restore needs, and restore
|
|
4815
|
+
// tolerates a leftover ref at the trashed commit.
|
|
4816
|
+
async trashAndUnregisterWorktree(options) {
|
|
4817
|
+
const entry = await this.trashDirectory(options);
|
|
4818
|
+
await this.gitService.removeWorktree(options.dirPath, { force: true });
|
|
4819
|
+
let branchRefError;
|
|
4820
|
+
try {
|
|
4821
|
+
await this.deleteTrashedBranchRef(entry.manifest);
|
|
4822
|
+
} catch (refError) {
|
|
4823
|
+
branchRefError = getErrorMessage(refError);
|
|
4824
|
+
this.logger.warn(
|
|
4825
|
+
`\u26A0\uFE0F Leftover branch ref '${entry.manifest.branch}' after trashing '${entry.manifest.id}': ${branchRefError}`
|
|
4826
|
+
);
|
|
4827
|
+
}
|
|
4828
|
+
return { entry, branchRefError };
|
|
4829
|
+
}
|
|
4830
|
+
async restore(id) {
|
|
4831
|
+
const { entries } = await this.listEntries();
|
|
4832
|
+
const entry = entries.find((candidate) => candidate.manifest.id === id);
|
|
4833
|
+
if (!entry) {
|
|
4834
|
+
throw new TrashOperationError("restore", `no trash entry with id '${id}'`);
|
|
4835
|
+
}
|
|
4836
|
+
const { manifest, containerPath, payloadPath } = entry;
|
|
4837
|
+
if (await probePathExists(payloadPath) !== "exists") {
|
|
4838
|
+
throw new TrashOperationError("restore", `payload missing or unverifiable for '${id}' at '${payloadPath}'`);
|
|
4839
|
+
}
|
|
4840
|
+
const destinationProbe = await probePathExists(manifest.originalPath);
|
|
4841
|
+
if (destinationProbe !== "missing") {
|
|
4842
|
+
const why = destinationProbe === "exists" ? "already exists" : "cannot be verified";
|
|
4843
|
+
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" : "";
|
|
4844
|
+
throw new TrashOperationError("restore", `destination '${manifest.originalPath}' ${why}${hint}`);
|
|
4845
|
+
}
|
|
4846
|
+
if (isWorktreeRestorable(manifest)) {
|
|
4847
|
+
await this.restoreAsWorktree(manifest, payloadPath);
|
|
4848
|
+
} else {
|
|
4849
|
+
if (manifest.branch) {
|
|
4850
|
+
this.logger.warn(
|
|
4851
|
+
`\u26A0\uFE0F Trash entry '${id}' has no pinned commit; restoring files only \u2014 the directory will not be a registered worktree.`
|
|
4852
|
+
);
|
|
4853
|
+
}
|
|
4854
|
+
await fs13.rename(payloadPath, manifest.originalPath);
|
|
4855
|
+
}
|
|
4856
|
+
await fs13.rm(containerPath, { recursive: true, force: true }).catch(
|
|
4857
|
+
(error) => this.logger.warn(`\u26A0\uFE0F Failed to remove restored trash container '${containerPath}': ${getErrorMessage(error)}`)
|
|
4858
|
+
);
|
|
4859
|
+
if (manifest.pinRef) {
|
|
4860
|
+
await this.gitService.deleteRef(manifest.pinRef).catch(
|
|
4861
|
+
(error) => this.logger.warn(`\u26A0\uFE0F Failed to delete pin ref '${manifest.pinRef}': ${getErrorMessage(error)}`)
|
|
4862
|
+
);
|
|
4863
|
+
}
|
|
4864
|
+
await this.removalAudit.record({
|
|
4865
|
+
action: "trash_restore",
|
|
4866
|
+
result: "success",
|
|
4867
|
+
path: manifest.originalPath,
|
|
4868
|
+
branch: manifest.branch ?? void 0,
|
|
4869
|
+
trashId: id
|
|
4870
|
+
}).catch(
|
|
4871
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
4872
|
+
);
|
|
4873
|
+
return manifest;
|
|
4874
|
+
}
|
|
4875
|
+
async deleteTrashedBranchRef(manifest) {
|
|
4876
|
+
if (!manifest.branch) return;
|
|
4877
|
+
if (!manifest.pinRef) {
|
|
4878
|
+
this.logger.warn(
|
|
4879
|
+
`\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`
|
|
4880
|
+
);
|
|
4881
|
+
return;
|
|
4882
|
+
}
|
|
4883
|
+
try {
|
|
4884
|
+
await this.gitService.deleteLocalBranch(manifest.branch);
|
|
4885
|
+
} catch (error) {
|
|
4886
|
+
throw new TrashOperationError(
|
|
4887
|
+
"trash-branch-ref",
|
|
4888
|
+
`cannot delete branch ref '${manifest.branch}' after trashing '${manifest.id}': ${getErrorMessage(error)}`,
|
|
4889
|
+
error instanceof Error ? error : void 0
|
|
4890
|
+
);
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
async restoreAsWorktree(manifest, payloadPath) {
|
|
4894
|
+
const branch = manifest.branch;
|
|
4895
|
+
const headOid = manifest.headOid;
|
|
4896
|
+
const existingBranchOid = await this.gitService.getLocalBranchCommit(branch);
|
|
4897
|
+
let createdBranch = false;
|
|
4898
|
+
if (existingBranchOid !== null && existingBranchOid !== headOid) {
|
|
4899
|
+
throw new TrashOperationError(
|
|
4900
|
+
"restore",
|
|
4901
|
+
`branch '${branch}' already exists at ${existingBranchOid}; expected trashed commit ${headOid}. Restore the files manually from '${payloadPath}' or move that branch first`
|
|
4902
|
+
);
|
|
4903
|
+
}
|
|
4904
|
+
if (existingBranchOid === null) {
|
|
4905
|
+
await this.gitService.createBranchAt(branch, headOid);
|
|
4906
|
+
createdBranch = true;
|
|
4907
|
+
}
|
|
4908
|
+
try {
|
|
4909
|
+
await this.gitService.addWorktreeNoCheckout(branch, manifest.originalPath);
|
|
4910
|
+
await this.copyPayloadOver(payloadPath, manifest.originalPath);
|
|
4911
|
+
await this.gitService.resetWorktreeIndex(manifest.originalPath);
|
|
4912
|
+
if (this.config.sparseCheckout) {
|
|
4913
|
+
await this.gitService.getSparseCheckoutService().applyToWorktree(manifest.originalPath, this.config.sparseCheckout);
|
|
4914
|
+
}
|
|
4915
|
+
} catch (error) {
|
|
4916
|
+
await this.gitService.removeWorktree(manifest.originalPath, { force: true }).catch(
|
|
4917
|
+
(rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (worktree) failed: ${getErrorMessage(rollbackError)}`)
|
|
4918
|
+
);
|
|
4919
|
+
if (createdBranch) {
|
|
4920
|
+
await this.gitService.deleteLocalBranch(branch).catch(
|
|
4921
|
+
(rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (branch) failed: ${getErrorMessage(rollbackError)}`)
|
|
4922
|
+
);
|
|
4923
|
+
}
|
|
4924
|
+
throw new TrashOperationError(
|
|
4925
|
+
"restore",
|
|
4926
|
+
`failed to recreate worktree for '${manifest.id}'; trash entry left intact: ${getErrorMessage(error)}`,
|
|
4927
|
+
error instanceof Error ? error : void 0
|
|
4928
|
+
);
|
|
4929
|
+
}
|
|
4930
|
+
}
|
|
4931
|
+
// The payload's top-level .git link points at a pruned admin dir; the fresh
|
|
4932
|
+
// one written by `worktree add --no-checkout` must survive the overlay.
|
|
4933
|
+
async copyPayloadOver(payloadPath, destination) {
|
|
4934
|
+
await fs13.cp(payloadPath, destination, {
|
|
4935
|
+
recursive: true,
|
|
4936
|
+
force: true,
|
|
4937
|
+
filter: (source) => !(path15.dirname(source) === payloadPath && path15.basename(source) === PATH_CONSTANTS.GIT_DIR)
|
|
4938
|
+
});
|
|
4939
|
+
}
|
|
4940
|
+
async resolveHeadOid(options) {
|
|
4941
|
+
if (!options.branch) return null;
|
|
4942
|
+
try {
|
|
4943
|
+
return (await this.gitService.getCurrentCommit(options.dirPath)).trim();
|
|
4944
|
+
} catch (error) {
|
|
4945
|
+
this.logger.warn(
|
|
4946
|
+
`\u26A0\uFE0F Could not resolve HEAD for '${options.dirPath}'; trash entry will preserve files only: ${getErrorMessage(error)}`
|
|
4947
|
+
);
|
|
4948
|
+
return null;
|
|
4949
|
+
}
|
|
4950
|
+
}
|
|
4951
|
+
// Pin failure degrades to a files-only trash entry rather than blocking the
|
|
4952
|
+
// removal — the payload itself is still fully preserved either way.
|
|
4953
|
+
async createPinRef(id, headOid) {
|
|
4954
|
+
const refName = `${GIT_CONSTANTS.TRASH_REF_PREFIX}${id}`;
|
|
4955
|
+
try {
|
|
4956
|
+
await this.gitService.updateRef(refName, headOid);
|
|
4957
|
+
return refName;
|
|
4958
|
+
} catch (error) {
|
|
4959
|
+
this.logger.warn(
|
|
4960
|
+
`\u26A0\uFE0F Could not pin '${headOid}' for trash entry '${id}'; git gc may collect its objects: ${getErrorMessage(error)}`
|
|
4961
|
+
);
|
|
4962
|
+
return null;
|
|
4963
|
+
}
|
|
4964
|
+
}
|
|
4965
|
+
async writeManifest(containerPath, manifest) {
|
|
4966
|
+
const manifestPath = path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME);
|
|
4967
|
+
await atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
4968
|
+
}
|
|
4969
|
+
async readManifest(containerPath) {
|
|
4970
|
+
try {
|
|
4971
|
+
const raw = await fs13.readFile(path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME), "utf-8");
|
|
4972
|
+
const parsed = JSON.parse(raw);
|
|
4973
|
+
if (typeof parsed.id !== "string" || typeof parsed.expiresAt !== "string" || typeof parsed.originalPath !== "string") {
|
|
4974
|
+
return null;
|
|
4975
|
+
}
|
|
4976
|
+
return parsed;
|
|
4977
|
+
} catch {
|
|
4978
|
+
return null;
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4981
|
+
async undoPartialTrash(containerPath, pinRef) {
|
|
4982
|
+
await fs13.rm(containerPath, { recursive: true, force: true }).catch(() => void 0);
|
|
4983
|
+
if (pinRef) {
|
|
4984
|
+
await this.gitService.deleteRef(pinRef).catch(() => void 0);
|
|
4985
|
+
}
|
|
4986
|
+
}
|
|
4987
|
+
async createContainer(deletedAt, baseName) {
|
|
4988
|
+
let lastError;
|
|
4989
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4990
|
+
const id = this.generateId(deletedAt, baseName);
|
|
4991
|
+
const containerPath = path15.join(this.getTrashRoot(), id);
|
|
4992
|
+
try {
|
|
4993
|
+
await fs13.mkdir(containerPath);
|
|
4994
|
+
return { id, containerPath };
|
|
4995
|
+
} catch (error) {
|
|
4996
|
+
lastError = error;
|
|
4997
|
+
if (error.code !== "EEXIST") break;
|
|
4998
|
+
}
|
|
4999
|
+
}
|
|
5000
|
+
throw new TrashOperationError(
|
|
5001
|
+
"trash-directory",
|
|
5002
|
+
`cannot create trash container for '${baseName}': ${getErrorMessage(lastError)}`,
|
|
5003
|
+
lastError instanceof Error ? lastError : void 0
|
|
5004
|
+
);
|
|
5005
|
+
}
|
|
5006
|
+
// The id doubles as a refname component (refs/sync-worktrees/trash/<id>).
|
|
5007
|
+
// The timestamp prefix and hex suffix rule out leading dots and ".lock"
|
|
5008
|
+
// endings, but ".." inside the name would still make the ref invalid and
|
|
5009
|
+
// silently degrade the entry to files-only.
|
|
5010
|
+
generateId(deletedAt, baseName) {
|
|
5011
|
+
const timestamp = filenameTimestamp(deletedAt);
|
|
5012
|
+
const safeName = baseName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
|
|
5013
|
+
return `${timestamp}-${safeName}-${randomBytes(3).toString("hex")}`;
|
|
5014
|
+
}
|
|
5015
|
+
};
|
|
5016
|
+
|
|
5017
|
+
// src/services/trash-reaper.service.ts
|
|
5018
|
+
var TrashReaperService = class {
|
|
5019
|
+
constructor(config, trashService, logger, removalAudit, gitService) {
|
|
5020
|
+
this.config = config;
|
|
5021
|
+
this.trashService = trashService;
|
|
5022
|
+
this.logger = logger;
|
|
5023
|
+
this.removalAudit = removalAudit;
|
|
5024
|
+
this.gitService = gitService;
|
|
5025
|
+
}
|
|
5026
|
+
config;
|
|
5027
|
+
trashService;
|
|
5028
|
+
logger;
|
|
5029
|
+
removalAudit;
|
|
5030
|
+
gitService;
|
|
5031
|
+
updateLogger(logger) {
|
|
5032
|
+
this.logger = logger;
|
|
5033
|
+
}
|
|
5034
|
+
// Disabled trash means "don't touch my trash" — existing entries are left
|
|
5035
|
+
// alone rather than aged out behind the user's back.
|
|
5036
|
+
async reapExpiredUnlocked(now = /* @__PURE__ */ new Date()) {
|
|
5037
|
+
if (!this.trashService.isEnabled()) return;
|
|
5038
|
+
let realRoot;
|
|
5039
|
+
try {
|
|
5040
|
+
realRoot = await fs14.realpath(this.trashService.getTrashRoot());
|
|
5041
|
+
} catch (error) {
|
|
5042
|
+
if (error.code === "ENOENT") {
|
|
5043
|
+
this.logger.debug(`Trash reaper: no trash root; skipping pin-ref sweep`);
|
|
5044
|
+
return;
|
|
5045
|
+
}
|
|
5046
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper skipped: cannot resolve trash root: ${getErrorMessage(error)}`);
|
|
5047
|
+
return;
|
|
5048
|
+
}
|
|
5049
|
+
const { entries, invalid } = await this.trashService.listEntries();
|
|
5050
|
+
for (const invalidPath of invalid) {
|
|
5051
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unrecognized entry '${invalidPath}' alone (no valid manifest)`);
|
|
5052
|
+
}
|
|
5053
|
+
const reapedIds = /* @__PURE__ */ new Set();
|
|
5054
|
+
for (const entry of entries) {
|
|
5055
|
+
const expiresAt = new Date(entry.manifest.expiresAt);
|
|
5056
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
5057
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' has an unparseable expiry; skipping`);
|
|
5058
|
+
continue;
|
|
5059
|
+
}
|
|
5060
|
+
if (expiresAt.getTime() > now.getTime()) continue;
|
|
5061
|
+
try {
|
|
5062
|
+
const realEntry = await fs14.realpath(entry.containerPath);
|
|
5063
|
+
if (!realEntry.startsWith(realRoot + path16.sep)) {
|
|
5064
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' resolves outside the trash root; skipping`);
|
|
5065
|
+
continue;
|
|
5066
|
+
}
|
|
5067
|
+
} catch (error) {
|
|
5068
|
+
this.logger.warn(
|
|
5069
|
+
`\u26A0\uFE0F Trash reaper: cannot verify path of entry '${entry.manifest.id}'; skipping: ${getErrorMessage(error)}`
|
|
5070
|
+
);
|
|
5071
|
+
continue;
|
|
5072
|
+
}
|
|
5073
|
+
let keepRef = null;
|
|
5074
|
+
if (entry.manifest.keepPinOnReap && entry.manifest.headOid) {
|
|
5075
|
+
keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}${entry.manifest.id}`;
|
|
5076
|
+
try {
|
|
5077
|
+
await this.gitService.updateRef(keepRef, entry.manifest.headOid);
|
|
5078
|
+
} catch (error) {
|
|
5079
|
+
this.logger.warn(
|
|
5080
|
+
`\u26A0\uFE0F Trash reaper: cannot create keep ref '${keepRef}' for '${entry.manifest.id}'; deferring reap: ${getErrorMessage(error)}`
|
|
5081
|
+
);
|
|
5082
|
+
continue;
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5085
|
+
try {
|
|
5086
|
+
await this.removalAudit.record({
|
|
5087
|
+
action: "trash_reap",
|
|
5088
|
+
result: "attempt",
|
|
5089
|
+
path: entry.manifest.originalPath,
|
|
5090
|
+
branch: entry.manifest.branch ?? void 0,
|
|
5091
|
+
trashId: entry.manifest.id,
|
|
5092
|
+
trashPath: entry.payloadPath
|
|
5093
|
+
});
|
|
5094
|
+
} catch (auditError) {
|
|
5095
|
+
this.logger.warn(
|
|
5096
|
+
`\u26A0\uFE0F Trash reaper: cannot write audit log; skipping '${entry.manifest.id}': ${getErrorMessage(auditError)}`
|
|
5097
|
+
);
|
|
5098
|
+
continue;
|
|
5099
|
+
}
|
|
5100
|
+
try {
|
|
5101
|
+
await fs14.rm(entry.containerPath, { recursive: true, force: true });
|
|
5102
|
+
} catch (error) {
|
|
5103
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete '${entry.manifest.id}': ${getErrorMessage(error)}`);
|
|
5104
|
+
await this.removalAudit.record({
|
|
5105
|
+
action: "trash_reap",
|
|
5106
|
+
result: "failure",
|
|
5107
|
+
path: entry.manifest.originalPath,
|
|
5108
|
+
trashId: entry.manifest.id,
|
|
5109
|
+
error: getErrorMessage(error)
|
|
5110
|
+
}).catch(() => void 0);
|
|
5111
|
+
continue;
|
|
5112
|
+
}
|
|
5113
|
+
if (entry.manifest.pinRef) {
|
|
5114
|
+
await this.gitService.deleteRef(entry.manifest.pinRef).catch(
|
|
5115
|
+
(error) => this.logger.warn(
|
|
5116
|
+
`\u26A0\uFE0F Trash reaper: failed to delete pin ref '${entry.manifest.pinRef}': ${getErrorMessage(error)}`
|
|
5117
|
+
)
|
|
5118
|
+
);
|
|
5119
|
+
}
|
|
5120
|
+
reapedIds.add(entry.manifest.id);
|
|
5121
|
+
this.logger.info(
|
|
5122
|
+
`\u{1F5D1}\uFE0F Trash reaper: deleted expired entry '${entry.manifest.id}' (trashed ${entry.manifest.deletedAt})`
|
|
5123
|
+
);
|
|
5124
|
+
if (keepRef) {
|
|
5125
|
+
this.logger.info(
|
|
5126
|
+
` Commits remain recoverable at '${keepRef}' (${entry.manifest.headOid}) \u2014 recover with: git branch <name> ${entry.manifest.headOid}`
|
|
5127
|
+
);
|
|
5128
|
+
}
|
|
5129
|
+
await this.removalAudit.record({
|
|
5130
|
+
action: "trash_reap",
|
|
5131
|
+
result: "success",
|
|
5132
|
+
path: entry.manifest.originalPath,
|
|
5133
|
+
trashId: entry.manifest.id
|
|
5134
|
+
}).catch(
|
|
5135
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
5136
|
+
);
|
|
5137
|
+
}
|
|
5138
|
+
let containerNames = null;
|
|
5139
|
+
try {
|
|
5140
|
+
containerNames = new Set(await fs14.readdir(realRoot));
|
|
5141
|
+
} catch (error) {
|
|
5142
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot scan trash root for pin-ref sweep: ${getErrorMessage(error)}`);
|
|
5143
|
+
}
|
|
5144
|
+
if (containerNames !== null) {
|
|
5145
|
+
await this.reapOrphanedPinRefs(containerNames);
|
|
5146
|
+
}
|
|
5147
|
+
this.warnIfOverThreshold(entries.filter((entry) => !reapedIds.has(entry.manifest.id)));
|
|
3757
5148
|
}
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
5149
|
+
// Pin refs whose trash container is gone would pin objects forever (failed
|
|
5150
|
+
// ref delete during restore, manually emptied trash). Keyed on container
|
|
5151
|
+
// existence, NOT manifest validity — an invalid-manifest entry still owns
|
|
5152
|
+
// its pin because the reaper refuses to delete its payload. Deliberately
|
|
5153
|
+
// any dirent name counts (files, symlinks): deleting a pin is irreversible
|
|
5154
|
+
// once gc runs, while a stray name collision merely keeps one ref alive.
|
|
5155
|
+
async reapOrphanedPinRefs(containerNames) {
|
|
5156
|
+
let refs;
|
|
5157
|
+
try {
|
|
5158
|
+
refs = await this.gitService.listRefs(GIT_CONSTANTS.TRASH_REF_PREFIX.replace(/\/$/, ""));
|
|
5159
|
+
} catch (error) {
|
|
5160
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot list pin refs: ${getErrorMessage(error)}`);
|
|
5161
|
+
return;
|
|
5162
|
+
}
|
|
5163
|
+
for (const ref of refs) {
|
|
5164
|
+
if (!ref.startsWith(GIT_CONSTANTS.TRASH_REF_PREFIX)) continue;
|
|
5165
|
+
const id = ref.slice(GIT_CONSTANTS.TRASH_REF_PREFIX.length);
|
|
5166
|
+
if (id.length === 0 || id.includes("/")) {
|
|
5167
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unexpected ref '${ref}' alone`);
|
|
5168
|
+
continue;
|
|
5169
|
+
}
|
|
5170
|
+
if (containerNames.has(id)) continue;
|
|
5171
|
+
try {
|
|
5172
|
+
await this.gitService.deleteRef(ref);
|
|
5173
|
+
this.logger.info(`\u{1F5D1}\uFE0F Trash reaper: deleted orphaned pin ref '${ref}'`);
|
|
5174
|
+
} catch (error) {
|
|
5175
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete orphaned pin ref '${ref}': ${getErrorMessage(error)}`);
|
|
5176
|
+
}
|
|
5177
|
+
}
|
|
5178
|
+
}
|
|
5179
|
+
warnIfOverThreshold(remaining) {
|
|
5180
|
+
const warnSizeBytes = this.config.trash?.warnSizeBytes;
|
|
5181
|
+
if (warnSizeBytes === void 0) return;
|
|
5182
|
+
const summary = summarizeTrashEntries(remaining);
|
|
5183
|
+
if (summary.totalSizeBytes > warnSizeBytes) {
|
|
5184
|
+
this.logger.warn(
|
|
5185
|
+
`\u26A0\uFE0F Trash holds ${formatBytes(summary.totalSizeBytes)} across ${summary.itemCount} entries (threshold ${formatBytes(warnSizeBytes)}). Entries expire ${this.trashService.getRetentionDays()} days after removal.`
|
|
5186
|
+
);
|
|
3761
5187
|
}
|
|
3762
5188
|
}
|
|
3763
5189
|
};
|
|
3764
5190
|
|
|
3765
5191
|
// src/services/worktree-mode-sync-runner.ts
|
|
3766
|
-
import * as
|
|
3767
|
-
import * as
|
|
5192
|
+
import * as fs16 from "fs/promises";
|
|
5193
|
+
import * as path19 from "path";
|
|
3768
5194
|
import pLimit from "p-limit";
|
|
3769
5195
|
|
|
3770
|
-
// src/utils/date-filter.ts
|
|
3771
|
-
function parseDuration(durationStr) {
|
|
3772
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
3773
|
-
if (!match) {
|
|
3774
|
-
return null;
|
|
3775
|
-
}
|
|
3776
|
-
const value = parseInt(match[1], 10);
|
|
3777
|
-
const unit = match[2];
|
|
3778
|
-
const multipliers = {
|
|
3779
|
-
h: 60 * 60 * 1e3,
|
|
3780
|
-
// hours
|
|
3781
|
-
d: 24 * 60 * 60 * 1e3,
|
|
3782
|
-
// days
|
|
3783
|
-
w: 7 * 24 * 60 * 60 * 1e3,
|
|
3784
|
-
// weeks
|
|
3785
|
-
m: 30 * 24 * 60 * 60 * 1e3,
|
|
3786
|
-
// months (approximate)
|
|
3787
|
-
y: 365 * 24 * 60 * 60 * 1e3
|
|
3788
|
-
// years (approximate)
|
|
3789
|
-
};
|
|
3790
|
-
return value * multipliers[unit];
|
|
3791
|
-
}
|
|
3792
|
-
function filterBranchesByAge(branches, maxAge) {
|
|
3793
|
-
const maxAgeMs = parseDuration(maxAge);
|
|
3794
|
-
if (maxAgeMs === null) {
|
|
3795
|
-
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
3796
|
-
return branches;
|
|
3797
|
-
}
|
|
3798
|
-
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
3799
|
-
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
3800
|
-
}
|
|
3801
|
-
function formatDuration2(durationStr) {
|
|
3802
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
3803
|
-
if (!match) {
|
|
3804
|
-
return durationStr;
|
|
3805
|
-
}
|
|
3806
|
-
const value = parseInt(match[1], 10);
|
|
3807
|
-
const unit = match[2];
|
|
3808
|
-
const unitNames = {
|
|
3809
|
-
h: value === 1 ? "hour" : "hours",
|
|
3810
|
-
d: value === 1 ? "day" : "days",
|
|
3811
|
-
w: value === 1 ? "week" : "weeks",
|
|
3812
|
-
m: value === 1 ? "month" : "months",
|
|
3813
|
-
y: value === 1 ? "year" : "years"
|
|
3814
|
-
};
|
|
3815
|
-
return `${value} ${unitNames[unit]}`;
|
|
3816
|
-
}
|
|
3817
|
-
|
|
3818
5196
|
// src/services/path-resolution.service.ts
|
|
3819
5197
|
import { createHash as createHash2 } from "crypto";
|
|
3820
|
-
import * as
|
|
3821
|
-
import * as
|
|
5198
|
+
import * as fs15 from "fs";
|
|
5199
|
+
import * as path17 from "path";
|
|
3822
5200
|
var BRANCH_STEM_MAX = 80;
|
|
3823
5201
|
var BRANCH_HASH_LEN = 8;
|
|
3824
5202
|
var PathResolutionService = class {
|
|
@@ -3828,22 +5206,22 @@ var PathResolutionService = class {
|
|
|
3828
5206
|
return `${stem}-${hash}`;
|
|
3829
5207
|
}
|
|
3830
5208
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3831
|
-
return
|
|
5209
|
+
return path17.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3832
5210
|
}
|
|
3833
5211
|
resolveRealPath(inputPath) {
|
|
3834
|
-
const absolute =
|
|
5212
|
+
const absolute = path17.resolve(inputPath);
|
|
3835
5213
|
const missing = [];
|
|
3836
5214
|
let current = absolute;
|
|
3837
|
-
while (!
|
|
3838
|
-
const parent =
|
|
5215
|
+
while (!fs15.existsSync(current)) {
|
|
5216
|
+
const parent = path17.dirname(current);
|
|
3839
5217
|
if (parent === current) {
|
|
3840
5218
|
return absolute;
|
|
3841
5219
|
}
|
|
3842
|
-
missing.unshift(
|
|
5220
|
+
missing.unshift(path17.basename(current));
|
|
3843
5221
|
current = parent;
|
|
3844
5222
|
}
|
|
3845
5223
|
try {
|
|
3846
|
-
return
|
|
5224
|
+
return path17.join(fs15.realpathSync(current), ...missing);
|
|
3847
5225
|
} catch {
|
|
3848
5226
|
return absolute;
|
|
3849
5227
|
}
|
|
@@ -3853,7 +5231,7 @@ var PathResolutionService = class {
|
|
|
3853
5231
|
const a = fold(resolved);
|
|
3854
5232
|
const b = fold(resolvedBase);
|
|
3855
5233
|
if (a === b) return true;
|
|
3856
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
5234
|
+
return a.length > b.length && a.charAt(b.length) === path17.sep && a.startsWith(b);
|
|
3857
5235
|
}
|
|
3858
5236
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3859
5237
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -3861,7 +5239,7 @@ var PathResolutionService = class {
|
|
|
3861
5239
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3862
5240
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3863
5241
|
}
|
|
3864
|
-
return
|
|
5242
|
+
return path17.relative(resolvedBase, resolved);
|
|
3865
5243
|
}
|
|
3866
5244
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3867
5245
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -3874,7 +5252,7 @@ var PathResolutionService = class {
|
|
|
3874
5252
|
};
|
|
3875
5253
|
|
|
3876
5254
|
// src/services/worktree-sync-planner.ts
|
|
3877
|
-
import * as
|
|
5255
|
+
import * as path18 from "path";
|
|
3878
5256
|
function createWorktreeSyncPlan(inventory, options = {}) {
|
|
3879
5257
|
return {
|
|
3880
5258
|
create: planCreateActions(inventory, options),
|
|
@@ -3892,12 +5270,12 @@ function planCreateActions(inventory, options = {}) {
|
|
|
3892
5270
|
);
|
|
3893
5271
|
const reservedPaths = /* @__PURE__ */ new Map();
|
|
3894
5272
|
for (const worktree of inventory.existingWorktrees) {
|
|
3895
|
-
reservedPaths.set(
|
|
5273
|
+
reservedPaths.set(path18.resolve(worktree.path), worktree.branch);
|
|
3896
5274
|
}
|
|
3897
5275
|
const actions = [];
|
|
3898
5276
|
for (const branch of newBranches) {
|
|
3899
5277
|
const worktreePath = pathResolution2.getBranchWorktreePath(inventory.worktreeDir, branch);
|
|
3900
|
-
const resolved =
|
|
5278
|
+
const resolved = path18.resolve(worktreePath);
|
|
3901
5279
|
const conflictingBranch = reservedPaths.get(resolved);
|
|
3902
5280
|
if (conflictingBranch && conflictingBranch !== branch) {
|
|
3903
5281
|
actions.push({
|
|
@@ -3935,21 +5313,30 @@ function planSparseActions(inventory, sparseCheckout) {
|
|
|
3935
5313
|
|
|
3936
5314
|
// src/services/worktree-mode-sync-runner.ts
|
|
3937
5315
|
var WorktreeModeSyncRunner = class {
|
|
3938
|
-
constructor(config, gitService, logger, progressEmitter) {
|
|
5316
|
+
constructor(config, gitService, logger, progressEmitter, services) {
|
|
3939
5317
|
this.config = config;
|
|
3940
5318
|
this.gitService = gitService;
|
|
3941
5319
|
this.logger = logger;
|
|
3942
5320
|
this.progressEmitter = progressEmitter;
|
|
5321
|
+
this.removalAudit = services?.removalAudit ?? new RemovalAuditService(getRemovalAuditLogPath(config));
|
|
5322
|
+
this.trashService = services?.trashService ?? new TrashService(config, gitService, logger, this.removalAudit);
|
|
3943
5323
|
}
|
|
5324
|
+
config;
|
|
5325
|
+
gitService;
|
|
5326
|
+
logger;
|
|
5327
|
+
progressEmitter;
|
|
3944
5328
|
pathResolution = new PathResolutionService();
|
|
5329
|
+
removalAudit;
|
|
5330
|
+
trashService;
|
|
3945
5331
|
updateLogger(logger) {
|
|
3946
5332
|
this.logger = logger;
|
|
5333
|
+
this.trashService.updateLogger(logger);
|
|
3947
5334
|
}
|
|
3948
5335
|
async runSyncAttempt(phaseTimer, syncContext, outcome) {
|
|
3949
5336
|
await this.gitService.pruneWorktrees();
|
|
3950
5337
|
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
3951
5338
|
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
3952
|
-
await
|
|
5339
|
+
await fs16.mkdir(this.config.worktreeDir, { recursive: true });
|
|
3953
5340
|
const worktrees = await this.gitService.getWorktrees();
|
|
3954
5341
|
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
3955
5342
|
await this.cleanupOrphanedDirectories(worktrees);
|
|
@@ -3967,6 +5354,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
3967
5354
|
}
|
|
3968
5355
|
);
|
|
3969
5356
|
await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
|
|
5357
|
+
await this.recordRemoteBranchTips([...worktrees, ...syncPlan.create.filter((action) => action.kind === "create")]);
|
|
3970
5358
|
await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
|
|
3971
5359
|
if (this.config.updateExistingWorktrees !== false) {
|
|
3972
5360
|
await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
|
|
@@ -3989,7 +5377,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
3989
5377
|
if (action.kind !== "check-sparse") return;
|
|
3990
5378
|
try {
|
|
3991
5379
|
try {
|
|
3992
|
-
await
|
|
5380
|
+
await fs16.access(action.path);
|
|
3993
5381
|
} catch {
|
|
3994
5382
|
return;
|
|
3995
5383
|
}
|
|
@@ -4075,7 +5463,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
4075
5463
|
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
4076
5464
|
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
4077
5465
|
this.logger.info(
|
|
4078
|
-
`After filtering by age (${
|
|
5466
|
+
`After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
4079
5467
|
);
|
|
4080
5468
|
if (filteredByName.length > remoteBranches.length) {
|
|
4081
5469
|
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
@@ -4152,6 +5540,37 @@ var WorktreeModeSyncRunner = class {
|
|
|
4152
5540
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
4153
5541
|
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
4154
5542
|
}
|
|
5543
|
+
// Persist each worktree's upstream tip while the remote ref still exists.
|
|
5544
|
+
// This is the proof consulted after a squash-merge deletes the branch:
|
|
5545
|
+
// "HEAD was on the remote before the deletion" — without it every such
|
|
5546
|
+
// worktree reads as having unpushed commits forever. Best-effort: a failed
|
|
5547
|
+
// recording only means that worktree stays conservatively preserved.
|
|
5548
|
+
async recordRemoteBranchTips(worktrees) {
|
|
5549
|
+
try {
|
|
5550
|
+
const tips = await this.gitService.getRemoteBranchTips();
|
|
5551
|
+
if (tips.size === 0) return;
|
|
5552
|
+
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5553
|
+
await Promise.all(
|
|
5554
|
+
worktrees.map(
|
|
5555
|
+
(wt) => limit(async () => {
|
|
5556
|
+
const oid = tips.get(wt.branch);
|
|
5557
|
+
if (!oid) return;
|
|
5558
|
+
await this.gitService.recordRemoteTip(wt.path, wt.branch, oid).catch(
|
|
5559
|
+
(error) => this.logger.warn(` - \u26A0\uFE0F Could not record remote tip for '${wt.branch}': ${getErrorMessage(error)}`)
|
|
5560
|
+
);
|
|
5561
|
+
})
|
|
5562
|
+
)
|
|
5563
|
+
);
|
|
5564
|
+
} catch (error) {
|
|
5565
|
+
this.logger.warn(`\u26A0\uFE0F Could not record remote branch tips: ${getErrorMessage(error)}`);
|
|
5566
|
+
}
|
|
5567
|
+
}
|
|
5568
|
+
// A removal authorized only by the fully-pushed proof must stay reversible:
|
|
5569
|
+
// without trash it would be a permanent delete of commits whose remote
|
|
5570
|
+
// branch may have been deleted unmerged.
|
|
5571
|
+
blockedByDisabledTrash(status) {
|
|
5572
|
+
return status.fullyPushedUpstreamDeleted && !this.trashService.isEnabled();
|
|
5573
|
+
}
|
|
4155
5574
|
async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
4156
5575
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
4157
5576
|
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
@@ -4181,7 +5600,18 @@ var WorktreeModeSyncRunner = class {
|
|
|
4181
5600
|
if (result.status === "fulfilled") {
|
|
4182
5601
|
const { branchName, worktreePath, status } = result.value;
|
|
4183
5602
|
if (status.canRemove) {
|
|
4184
|
-
|
|
5603
|
+
if (this.blockedByDisabledTrash(status)) {
|
|
5604
|
+
this.logger.warn(
|
|
5605
|
+
` - \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.`
|
|
5606
|
+
);
|
|
5607
|
+
outcome.recordSkipped("worktree", "fully_pushed_trash_disabled", {
|
|
5608
|
+
branch: branchName,
|
|
5609
|
+
path: worktreePath,
|
|
5610
|
+
message: "fully pushed before upstream deletion; trash disabled"
|
|
5611
|
+
});
|
|
5612
|
+
} else {
|
|
5613
|
+
toRemove.push({ branchName, worktreePath });
|
|
5614
|
+
}
|
|
4185
5615
|
} else {
|
|
4186
5616
|
toSkip.push({ branchName, worktreePath, status });
|
|
4187
5617
|
}
|
|
@@ -4204,7 +5634,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
4204
5634
|
({ branchName, worktreePath }) => removeLimit(async () => {
|
|
4205
5635
|
try {
|
|
4206
5636
|
const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
|
|
4207
|
-
if (!recheck.canRemove) {
|
|
5637
|
+
if (!recheck.canRemove || this.blockedByDisabledTrash(recheck)) {
|
|
4208
5638
|
this.logger.warn(
|
|
4209
5639
|
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
4210
5640
|
);
|
|
@@ -4215,10 +5645,76 @@ var WorktreeModeSyncRunner = class {
|
|
|
4215
5645
|
});
|
|
4216
5646
|
return;
|
|
4217
5647
|
}
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
5648
|
+
try {
|
|
5649
|
+
await this.removalAudit.record({
|
|
5650
|
+
action: "prune_remove",
|
|
5651
|
+
result: "attempt",
|
|
5652
|
+
path: worktreePath,
|
|
5653
|
+
branch: branchName,
|
|
5654
|
+
status: recheck
|
|
5655
|
+
});
|
|
5656
|
+
} catch (auditError) {
|
|
5657
|
+
this.logger.warn(
|
|
5658
|
+
` \u26A0\uFE0F Skipping removal of '${branchName}' - cannot write removal audit log: ${getErrorMessage(auditError)}`
|
|
5659
|
+
);
|
|
5660
|
+
outcome.recordSkipped("worktree", "audit_log_unavailable", {
|
|
5661
|
+
branch: branchName,
|
|
5662
|
+
path: worktreePath,
|
|
5663
|
+
message: getErrorMessage(auditError)
|
|
5664
|
+
});
|
|
5665
|
+
return;
|
|
5666
|
+
}
|
|
5667
|
+
if (await probePathExists(worktreePath) === "missing") {
|
|
5668
|
+
await this.gitService.removeWorktree(worktreePath, { force: true });
|
|
5669
|
+
this.logger.info(` \u2705 Cleared dangling registration for '${branchName}' (directory already gone)`);
|
|
5670
|
+
outcome.recordRemoved(branchName, worktreePath);
|
|
5671
|
+
await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
|
|
5672
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
5673
|
+
);
|
|
5674
|
+
return;
|
|
5675
|
+
}
|
|
5676
|
+
let refWarning;
|
|
5677
|
+
if (this.trashService.isEnabled()) {
|
|
5678
|
+
const { entry, branchRefError } = await this.trashService.trashAndUnregisterWorktree({
|
|
5679
|
+
dirPath: worktreePath,
|
|
5680
|
+
branch: branchName,
|
|
5681
|
+
reason: "prune",
|
|
5682
|
+
keepPinOnReap: recheck.fullyPushedUpstreamDeleted
|
|
5683
|
+
});
|
|
5684
|
+
if (branchRefError !== void 0) {
|
|
5685
|
+
refWarning = `leftover_branch_ref: could not delete branch ref '${branchName}': ${branchRefError}`;
|
|
5686
|
+
}
|
|
5687
|
+
const pushedNote = recheck.fullyPushedUpstreamDeleted ? " \u2014 was fully pushed before its remote branch was deleted" : "";
|
|
5688
|
+
this.logger.info(
|
|
5689
|
+
` \u2705 Moved worktree for '${branchName}' to trash (id: ${entry.manifest.id})${pushedNote}`
|
|
5690
|
+
);
|
|
5691
|
+
} else {
|
|
5692
|
+
await this.gitService.removeWorktree(worktreePath);
|
|
5693
|
+
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
5694
|
+
}
|
|
5695
|
+
outcome.recordRemoved(branchName, worktreePath, refWarning);
|
|
5696
|
+
await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
|
|
5697
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
5698
|
+
);
|
|
4221
5699
|
} catch (error) {
|
|
5700
|
+
if (error instanceof WorktreeNotCleanError) {
|
|
5701
|
+
this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - git refused: ${getErrorMessage(error)}`);
|
|
5702
|
+
outcome.recordSkipped("worktree", "git_refused_removal", {
|
|
5703
|
+
branch: branchName,
|
|
5704
|
+
path: worktreePath,
|
|
5705
|
+
message: getErrorMessage(error)
|
|
5706
|
+
});
|
|
5707
|
+
return;
|
|
5708
|
+
}
|
|
5709
|
+
if (error instanceof TrashOperationError) {
|
|
5710
|
+
this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - ${getErrorMessage(error)}`);
|
|
5711
|
+
outcome.recordSkipped("worktree", "trash_failed", {
|
|
5712
|
+
branch: branchName,
|
|
5713
|
+
path: worktreePath,
|
|
5714
|
+
message: getErrorMessage(error)
|
|
5715
|
+
});
|
|
5716
|
+
return;
|
|
5717
|
+
}
|
|
4222
5718
|
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
4223
5719
|
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
4224
5720
|
reason: "remove_failed",
|
|
@@ -4344,12 +5840,12 @@ var WorktreeModeSyncRunner = class {
|
|
|
4344
5840
|
}
|
|
4345
5841
|
async updateExistingWorktrees(actions, outcome) {
|
|
4346
5842
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
4347
|
-
const divergedDir =
|
|
5843
|
+
const divergedDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4348
5844
|
try {
|
|
4349
|
-
const diverged = await
|
|
5845
|
+
const diverged = await fs16.readdir(divergedDir);
|
|
4350
5846
|
if (diverged.length > 0) {
|
|
4351
5847
|
this.logger.info(
|
|
4352
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
5848
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path19.relative(process.cwd(), divergedDir)}`
|
|
4353
5849
|
);
|
|
4354
5850
|
}
|
|
4355
5851
|
} catch {
|
|
@@ -4361,7 +5857,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
4361
5857
|
(action) => limit(async () => {
|
|
4362
5858
|
const worktree = { path: action.path, branch: action.branch };
|
|
4363
5859
|
try {
|
|
4364
|
-
await
|
|
5860
|
+
await fs16.access(worktree.path);
|
|
4365
5861
|
} catch {
|
|
4366
5862
|
return { action: "skip", worktree, reason: "missing_worktree_path" };
|
|
4367
5863
|
}
|
|
@@ -4501,13 +5997,13 @@ var WorktreeModeSyncRunner = class {
|
|
|
4501
5997
|
}
|
|
4502
5998
|
async cleanupOrphanedDirectories(worktrees) {
|
|
4503
5999
|
try {
|
|
4504
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
4505
|
-
const allDirs = await
|
|
6000
|
+
const worktreeRelativePaths = worktrees.map((w) => path19.relative(this.config.worktreeDir, w.path));
|
|
6001
|
+
const allDirs = await fs16.readdir(this.config.worktreeDir);
|
|
4506
6002
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
4507
6003
|
const orphanedDirs = [];
|
|
4508
6004
|
for (const dir of regularDirs) {
|
|
4509
6005
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
4510
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
6006
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path19.sep);
|
|
4511
6007
|
});
|
|
4512
6008
|
if (!isPartOfWorktree) {
|
|
4513
6009
|
orphanedDirs.push(dir);
|
|
@@ -4516,13 +6012,46 @@ var WorktreeModeSyncRunner = class {
|
|
|
4516
6012
|
if (orphanedDirs.length > 0) {
|
|
4517
6013
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
4518
6014
|
for (const dir of orphanedDirs) {
|
|
4519
|
-
const dirPath =
|
|
6015
|
+
const dirPath = path19.join(this.config.worktreeDir, dir);
|
|
4520
6016
|
try {
|
|
4521
|
-
const stat4 = await
|
|
4522
|
-
if (stat4.isDirectory()) {
|
|
4523
|
-
|
|
4524
|
-
|
|
6017
|
+
const stat4 = await fs16.stat(dirPath);
|
|
6018
|
+
if (!stat4.isDirectory()) {
|
|
6019
|
+
continue;
|
|
6020
|
+
}
|
|
6021
|
+
const gitProbe = await probePathExists(path19.join(dirPath, PATH_CONSTANTS.GIT_DIR));
|
|
6022
|
+
if (gitProbe === "unknown") {
|
|
6023
|
+
this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir}: cannot verify it is not a live checkout`);
|
|
6024
|
+
continue;
|
|
6025
|
+
}
|
|
6026
|
+
if (this.trashService.isEnabled()) {
|
|
6027
|
+
try {
|
|
6028
|
+
const entry = await this.trashService.trashDirectory({ dirPath, reason: "orphan" });
|
|
6029
|
+
this.logger.info(` - Moved orphaned directory '${dir}' to trash (id: ${entry.manifest.id})`);
|
|
6030
|
+
} catch (trashError) {
|
|
6031
|
+
this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir} - ${getErrorMessage(trashError)}`);
|
|
6032
|
+
}
|
|
6033
|
+
continue;
|
|
6034
|
+
}
|
|
6035
|
+
if (gitProbe === "exists") {
|
|
6036
|
+
const quarantinePath = await quarantineDirectory(dirPath);
|
|
6037
|
+
this.logger.warn(
|
|
6038
|
+
` - \u26A0\uFE0F Orphaned directory ${dir} contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
|
|
6039
|
+
);
|
|
6040
|
+
await this.removalAudit.record({ action: "orphan_quarantine", result: "success", path: dirPath, quarantinePath }).catch(
|
|
6041
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
6042
|
+
);
|
|
6043
|
+
continue;
|
|
4525
6044
|
}
|
|
6045
|
+
try {
|
|
6046
|
+
await this.removalAudit.record({ action: "orphan_delete", result: "attempt", path: dirPath });
|
|
6047
|
+
} catch (auditError) {
|
|
6048
|
+
this.logger.warn(
|
|
6049
|
+
` - \u26A0\uFE0F Skipping orphaned directory ${dir} - cannot write removal audit log: ${getErrorMessage(auditError)}`
|
|
6050
|
+
);
|
|
6051
|
+
continue;
|
|
6052
|
+
}
|
|
6053
|
+
await fs16.rm(dirPath, { recursive: true, force: true });
|
|
6054
|
+
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
4526
6055
|
} catch (error) {
|
|
4527
6056
|
this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
|
|
4528
6057
|
}
|
|
@@ -4551,14 +6080,37 @@ var WorktreeModeSyncRunner = class {
|
|
|
4551
6080
|
outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
|
|
4552
6081
|
} else {
|
|
4553
6082
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
4554
|
-
|
|
4555
|
-
|
|
6083
|
+
let keepRef = null;
|
|
6084
|
+
if (!this.trashService.isEnabled()) {
|
|
6085
|
+
const localCommit = (await this.gitService.getCurrentCommit(worktree.path)).trim();
|
|
6086
|
+
keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}diverged-${Date.now().toString(36)}-${this.pathResolution.sanitizeBranchName(worktree.branch)}`;
|
|
6087
|
+
await this.gitService.updateRef(keepRef, localCommit);
|
|
6088
|
+
}
|
|
6089
|
+
const { divergedPath, manifest } = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
6090
|
+
const relativePath = path19.relative(process.cwd(), divergedPath);
|
|
4556
6091
|
outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
|
|
4557
6092
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
4558
6093
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
4559
6094
|
this.logger.info(` cd ${relativePath}`);
|
|
4560
6095
|
this.logger.info(` git diff origin/${worktree.branch}`);
|
|
4561
|
-
await this.gitService.removeWorktree(worktree.path);
|
|
6096
|
+
await this.gitService.removeWorktree(worktree.path, { force: true });
|
|
6097
|
+
if (manifest !== null) {
|
|
6098
|
+
await this.trashService.deleteTrashedBranchRef(manifest);
|
|
6099
|
+
} else {
|
|
6100
|
+
await this.gitService.deleteLocalBranch(worktree.branch);
|
|
6101
|
+
this.logger.info(
|
|
6102
|
+
` Never-pushed commits remain recoverable at '${keepRef}' \u2014 recover with: git branch <name> ${keepRef}`
|
|
6103
|
+
);
|
|
6104
|
+
}
|
|
6105
|
+
await this.removalAudit.record({
|
|
6106
|
+
action: "diverged_replace",
|
|
6107
|
+
result: "success",
|
|
6108
|
+
path: worktree.path,
|
|
6109
|
+
branch: worktree.branch,
|
|
6110
|
+
quarantinePath: divergedPath
|
|
6111
|
+
}).catch(
|
|
6112
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
6113
|
+
);
|
|
4562
6114
|
await this.gitService.addWorktree(worktree.branch, worktree.path);
|
|
4563
6115
|
this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
|
|
4564
6116
|
}
|
|
@@ -4577,42 +6129,55 @@ var WorktreeModeSyncRunner = class {
|
|
|
4577
6129
|
}
|
|
4578
6130
|
}
|
|
4579
6131
|
async divergeWorktree(worktreePath, branchName) {
|
|
4580
|
-
|
|
6132
|
+
if (this.trashService.isEnabled()) {
|
|
6133
|
+
const entry = await this.trashService.trashDirectory({
|
|
6134
|
+
dirPath: worktreePath,
|
|
6135
|
+
branch: branchName,
|
|
6136
|
+
reason: "diverged-replace",
|
|
6137
|
+
keepPinOnReap: true
|
|
6138
|
+
});
|
|
6139
|
+
await this.writeDivergedInfoFile(entry.payloadPath, worktreePath, branchName, entry.manifest.headOid);
|
|
6140
|
+
return { divergedPath: entry.payloadPath, manifest: entry.manifest };
|
|
6141
|
+
}
|
|
6142
|
+
const divergedBaseDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4581
6143
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4582
6144
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4583
6145
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4584
6146
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4585
|
-
const divergedPath =
|
|
4586
|
-
await
|
|
6147
|
+
const divergedPath = path19.join(divergedBaseDir, divergedName);
|
|
6148
|
+
await fs16.mkdir(divergedBaseDir, { recursive: true });
|
|
4587
6149
|
try {
|
|
4588
|
-
await
|
|
6150
|
+
await fs16.rename(worktreePath, divergedPath);
|
|
4589
6151
|
} catch (err) {
|
|
4590
6152
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
4591
|
-
await
|
|
4592
|
-
await
|
|
6153
|
+
await fs16.cp(worktreePath, divergedPath, { recursive: true });
|
|
6154
|
+
await fs16.rm(worktreePath, { recursive: true, force: true });
|
|
4593
6155
|
} else {
|
|
4594
6156
|
throw err;
|
|
4595
6157
|
}
|
|
4596
6158
|
}
|
|
6159
|
+
await this.writeDivergedInfoFile(divergedPath, worktreePath, branchName, null);
|
|
6160
|
+
return { divergedPath, manifest: null };
|
|
6161
|
+
}
|
|
6162
|
+
async writeDivergedInfoFile(preservedPath, originalPath, branchName, knownLocalCommit) {
|
|
4597
6163
|
const metadata = {
|
|
4598
6164
|
originalBranch: branchName,
|
|
4599
6165
|
divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4600
6166
|
reason: METADATA_CONSTANTS.DIVERGED_REASON,
|
|
4601
|
-
originalPath
|
|
4602
|
-
localCommit: await this.gitService.getCurrentCommit(
|
|
6167
|
+
originalPath,
|
|
6168
|
+
localCommit: knownLocalCommit ?? await this.gitService.getCurrentCommit(preservedPath),
|
|
4603
6169
|
remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
|
|
4604
6170
|
instruction: `To preserve your changes:
|
|
4605
6171
|
1. Review: git diff origin/${branchName}
|
|
4606
6172
|
2. Keep changes: git push --force-with-lease origin ${branchName}
|
|
4607
6173
|
3. Discard changes: rm -rf this directory
|
|
4608
6174
|
|
|
4609
|
-
Original worktree location: ${
|
|
6175
|
+
Original worktree location: ${originalPath}`
|
|
4610
6176
|
};
|
|
4611
|
-
await
|
|
4612
|
-
|
|
6177
|
+
await fs16.writeFile(
|
|
6178
|
+
path19.join(preservedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4613
6179
|
JSON.stringify(metadata, null, 2)
|
|
4614
6180
|
);
|
|
4615
|
-
return divergedPath;
|
|
4616
6181
|
}
|
|
4617
6182
|
};
|
|
4618
6183
|
|
|
@@ -4623,12 +6188,26 @@ var WorktreeSyncService = class {
|
|
|
4623
6188
|
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
4624
6189
|
this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
|
|
4625
6190
|
this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
|
|
6191
|
+
this.maintenanceService = new GitMaintenanceService(config, this.gitService, this.logger);
|
|
4626
6192
|
this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
|
|
6193
|
+
const removalAudit = new RemovalAuditService(getRemovalAuditLogPath(config));
|
|
6194
|
+
this.trashService = new TrashService(config, this.gitService, this.logger, removalAudit);
|
|
6195
|
+
this.trashReaper = new TrashReaperService(config, this.trashService, this.logger, removalAudit, this.gitService);
|
|
6196
|
+
this.trashMigration = new TrashMigrationService(config, this.trashService, this.logger);
|
|
6197
|
+
if (this.trashService.isEnabled()) {
|
|
6198
|
+
this.gitService.setStaleDirectoryTrasher(
|
|
6199
|
+
async (dirPath) => (await this.trashService.trashDirectory({ dirPath, reason: "orphan" })).payloadPath
|
|
6200
|
+
);
|
|
6201
|
+
}
|
|
4627
6202
|
this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
|
|
4628
6203
|
config,
|
|
4629
6204
|
this.gitService,
|
|
4630
6205
|
this.logger,
|
|
4631
|
-
this.progressEmitter
|
|
6206
|
+
this.progressEmitter,
|
|
6207
|
+
{
|
|
6208
|
+
trashService: this.trashService,
|
|
6209
|
+
removalAudit
|
|
6210
|
+
}
|
|
4632
6211
|
);
|
|
4633
6212
|
if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
|
|
4634
6213
|
this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
|
|
@@ -4639,14 +6218,23 @@ var WorktreeSyncService = class {
|
|
|
4639
6218
|
});
|
|
4640
6219
|
}
|
|
4641
6220
|
}
|
|
6221
|
+
config;
|
|
4642
6222
|
gitService;
|
|
4643
6223
|
cloneSyncService = null;
|
|
4644
6224
|
logger;
|
|
4645
|
-
|
|
6225
|
+
// In-process FIFO serializer for all bare-repo-mutating operations (sync, init,
|
|
6226
|
+
// interactive create). One per repo. wait:true callers queue behind an in-flight op;
|
|
6227
|
+
// wait:false callers fail fast. The cross-process file lock (RepoOperationLock) is
|
|
6228
|
+
// acquired inside the mutex body for multi-process safety.
|
|
6229
|
+
repoMutex = pLimit2(1);
|
|
4646
6230
|
progressEmitter = new ProgressEmitter();
|
|
4647
6231
|
repoOperationLock;
|
|
6232
|
+
maintenanceService;
|
|
4648
6233
|
retryPolicy;
|
|
4649
6234
|
worktreeModeSyncRunner;
|
|
6235
|
+
trashService;
|
|
6236
|
+
trashReaper;
|
|
6237
|
+
trashMigration;
|
|
4650
6238
|
skipsAccumulator = [];
|
|
4651
6239
|
lastOutcome = null;
|
|
4652
6240
|
getRecordedSkips() {
|
|
@@ -4670,6 +6258,18 @@ var WorktreeSyncService = class {
|
|
|
4670
6258
|
}
|
|
4671
6259
|
return this.gitService.getWorktrees();
|
|
4672
6260
|
}
|
|
6261
|
+
async getRemoteBranches() {
|
|
6262
|
+
if (this.cloneSyncService) {
|
|
6263
|
+
return this.cloneSyncService.getRemoteBranches();
|
|
6264
|
+
}
|
|
6265
|
+
return this.gitService.getRemoteBranches();
|
|
6266
|
+
}
|
|
6267
|
+
async checkoutBranch(branchName, options = {}) {
|
|
6268
|
+
if (!this.cloneSyncService) {
|
|
6269
|
+
throw new ConfigError("checkoutBranch is only available for clone-mode repositories", "CLONE_MODE_REQUIRED");
|
|
6270
|
+
}
|
|
6271
|
+
await this.cloneSyncService.checkoutBranch(branchName, options);
|
|
6272
|
+
}
|
|
4673
6273
|
async initialize() {
|
|
4674
6274
|
if (this.isInitialized()) return;
|
|
4675
6275
|
const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
|
|
@@ -4694,11 +6294,28 @@ var WorktreeSyncService = class {
|
|
|
4694
6294
|
return this.gitService.isInitialized();
|
|
4695
6295
|
}
|
|
4696
6296
|
isSyncInProgress() {
|
|
4697
|
-
return this.
|
|
6297
|
+
return this.repoMutex.activeCount + this.repoMutex.pendingCount > 0;
|
|
4698
6298
|
}
|
|
4699
6299
|
getGitService() {
|
|
4700
6300
|
return this.gitService;
|
|
4701
6301
|
}
|
|
6302
|
+
// Restore must hold the repo lock: the reaper, prune, and gc all mutate the
|
|
6303
|
+
// same trash entries and refs at the tail of a sync. wait:true queues behind
|
|
6304
|
+
// an in-flight sync instead of failing fast — restores are explicit user
|
|
6305
|
+
// actions, not periodic work.
|
|
6306
|
+
async restoreFromTrash(id) {
|
|
6307
|
+
const result = await this.runExclusiveRepoOperation(() => this.trashService.restore(id), { wait: true });
|
|
6308
|
+
if (!result.started) {
|
|
6309
|
+
throw new TrashOperationError(
|
|
6310
|
+
"restore",
|
|
6311
|
+
`cannot restore trash entry '${id}': another process holds the repo lock`
|
|
6312
|
+
);
|
|
6313
|
+
}
|
|
6314
|
+
return result.value;
|
|
6315
|
+
}
|
|
6316
|
+
async listTrashEntries() {
|
|
6317
|
+
return this.trashService.listEntries();
|
|
6318
|
+
}
|
|
4702
6319
|
updateLogger(logger) {
|
|
4703
6320
|
this.logger = logger;
|
|
4704
6321
|
this.gitService.updateLogger(logger);
|
|
@@ -4706,44 +6323,73 @@ var WorktreeSyncService = class {
|
|
|
4706
6323
|
this.retryPolicy.updateLogger(logger);
|
|
4707
6324
|
this.worktreeModeSyncRunner.updateLogger(logger);
|
|
4708
6325
|
this.repoOperationLock.updateLogger(logger);
|
|
6326
|
+
this.maintenanceService.updateLogger(logger);
|
|
6327
|
+
this.trashService.updateLogger(logger);
|
|
6328
|
+
this.trashReaper.updateLogger(logger);
|
|
6329
|
+
this.trashMigration.updateLogger(logger);
|
|
6330
|
+
}
|
|
6331
|
+
// Runs git gc when due, inside the already-held repo lock (mirrors
|
|
6332
|
+
// initializeUnlocked — must NOT re-acquire runExclusiveRepoOperation or it
|
|
6333
|
+
// would self-deadlock/skip). Skipped under NODE_ENV=test so unit suites don't
|
|
6334
|
+
// shell out to real git; GitMaintenanceService is covered by its own tests.
|
|
6335
|
+
async runMaintenanceIfDueUnlocked() {
|
|
6336
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
6337
|
+
return;
|
|
6338
|
+
}
|
|
6339
|
+
await this.maintenanceService.runIfDueUnlocked();
|
|
6340
|
+
}
|
|
6341
|
+
// Same contract as runMaintenanceIfDueUnlocked: tail of a successful sync,
|
|
6342
|
+
// inside the held lock, never fails the sync. Runs before gc so freshly
|
|
6343
|
+
// reaped pin refs can be collected in the same maintenance window.
|
|
6344
|
+
async runTrashMaintenanceUnlocked() {
|
|
6345
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
6346
|
+
return;
|
|
6347
|
+
}
|
|
6348
|
+
if (this.cloneSyncService) {
|
|
6349
|
+
return;
|
|
6350
|
+
}
|
|
6351
|
+
try {
|
|
6352
|
+
await this.trashMigration.migrateLegacyUnlocked();
|
|
6353
|
+
await this.trashReaper.reapExpiredUnlocked();
|
|
6354
|
+
} catch (error) {
|
|
6355
|
+
this.logger.warn(`\u26A0\uFE0F Trash maintenance failed: ${getErrorMessage(error)}`);
|
|
6356
|
+
}
|
|
4709
6357
|
}
|
|
4710
6358
|
onProgress(listener) {
|
|
4711
6359
|
return this.progressEmitter.onProgress(listener);
|
|
4712
6360
|
}
|
|
4713
|
-
async runExclusiveRepoOperation(operation) {
|
|
4714
|
-
if (this.
|
|
6361
|
+
async runExclusiveRepoOperation(operation, options = {}) {
|
|
6362
|
+
if (!options.wait && this.repoMutex.activeCount + this.repoMutex.pendingCount > 0) {
|
|
4715
6363
|
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
4716
6364
|
return { started: false, reason: "in_progress" };
|
|
4717
6365
|
}
|
|
4718
|
-
this.
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
throw error;
|
|
4725
|
-
}
|
|
4726
|
-
if (release === null) {
|
|
4727
|
-
this.syncInProgress = false;
|
|
4728
|
-
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
4729
|
-
return { started: false, reason: "locked" };
|
|
4730
|
-
}
|
|
4731
|
-
try {
|
|
4732
|
-
return { started: true, value: await operation() };
|
|
4733
|
-
} finally {
|
|
6366
|
+
return this.repoMutex(async () => {
|
|
6367
|
+
const release = await this.repoOperationLock.acquire();
|
|
6368
|
+
if (release === null) {
|
|
6369
|
+
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
6370
|
+
return { started: false, reason: "locked" };
|
|
6371
|
+
}
|
|
4734
6372
|
try {
|
|
4735
|
-
await
|
|
4736
|
-
}
|
|
4737
|
-
|
|
6373
|
+
return { started: true, value: await operation() };
|
|
6374
|
+
} finally {
|
|
6375
|
+
try {
|
|
6376
|
+
await release();
|
|
6377
|
+
} catch (releaseError) {
|
|
6378
|
+
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
6379
|
+
}
|
|
4738
6380
|
}
|
|
4739
|
-
|
|
4740
|
-
|
|
6381
|
+
});
|
|
6382
|
+
}
|
|
6383
|
+
// Interactive variant: queues behind any in-flight sync/op instead of failing fast.
|
|
6384
|
+
async runQueuedRepoOperation(operation) {
|
|
6385
|
+
return this.runExclusiveRepoOperation(operation, { wait: true });
|
|
4741
6386
|
}
|
|
4742
6387
|
emitProgress(event) {
|
|
4743
6388
|
this.progressEmitter.emit(event);
|
|
4744
6389
|
}
|
|
4745
6390
|
async sync() {
|
|
4746
6391
|
const result = await this.runExclusiveRepoOperation(async () => {
|
|
6392
|
+
this.clearRecordedSkips();
|
|
4747
6393
|
const totalTimer = new Timer();
|
|
4748
6394
|
const phaseTimer = new PhaseTimer();
|
|
4749
6395
|
const outcome = new SyncOutcomeAccumulator({
|
|
@@ -4792,7 +6438,9 @@ var WorktreeSyncService = class {
|
|
|
4792
6438
|
const repoName = this.config.name;
|
|
4793
6439
|
this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
|
|
4794
6440
|
}
|
|
6441
|
+
await this.runTrashMaintenanceUnlocked();
|
|
4795
6442
|
}
|
|
6443
|
+
await this.runMaintenanceIfDueUnlocked();
|
|
4796
6444
|
return this.lastOutcome ?? outcome.toOutcome(durationMs);
|
|
4797
6445
|
});
|
|
4798
6446
|
return result.started ? { started: true, outcome: result.value } : result;
|
|
@@ -4808,7 +6456,6 @@ function emptyCapabilities(reason) {
|
|
|
4808
6456
|
listWorktrees: { ...state },
|
|
4809
6457
|
getStatus: { ...state },
|
|
4810
6458
|
createWorktree: { ...state },
|
|
4811
|
-
removeWorktree: { ...state },
|
|
4812
6459
|
updateWorktree: { ...state },
|
|
4813
6460
|
sync: { ...state },
|
|
4814
6461
|
initialize: { ...state }
|
|
@@ -4847,16 +6494,19 @@ var RepositoryContext = class {
|
|
|
4847
6494
|
discoveryCache = /* @__PURE__ */ new Map();
|
|
4848
6495
|
launchCwd;
|
|
4849
6496
|
constructor(options = {}) {
|
|
4850
|
-
this.launchCwd =
|
|
6497
|
+
this.launchCwd = path20.resolve(options.launchCwd ?? process.cwd());
|
|
4851
6498
|
}
|
|
4852
6499
|
getLaunchCwd() {
|
|
4853
6500
|
return this.launchCwd;
|
|
4854
6501
|
}
|
|
6502
|
+
async findConfigUpward(startDir) {
|
|
6503
|
+
return this.configLoader.findConfigUpward(startDir);
|
|
6504
|
+
}
|
|
4855
6505
|
async loadConfig(configPath, options = {}) {
|
|
4856
6506
|
const setDefaultCurrent = options.setDefaultCurrent ?? true;
|
|
4857
|
-
const absolutePath =
|
|
6507
|
+
const absolutePath = path20.resolve(configPath);
|
|
4858
6508
|
const configFile = await this.configLoader.loadConfigFile(absolutePath);
|
|
4859
|
-
const configDir =
|
|
6509
|
+
const configDir = path20.dirname(absolutePath);
|
|
4860
6510
|
const globalDefaults = configFile.defaults;
|
|
4861
6511
|
const resolvedAll = [];
|
|
4862
6512
|
for (const repo of configFile.repositories) {
|
|
@@ -4893,7 +6543,7 @@ var RepositoryContext = class {
|
|
|
4893
6543
|
return configFile.repositories;
|
|
4894
6544
|
}
|
|
4895
6545
|
async detectFromPath(dirPath) {
|
|
4896
|
-
const absolutePath =
|
|
6546
|
+
const absolutePath = path20.resolve(dirPath);
|
|
4897
6547
|
const cached = this.discoveryCache.get(absolutePath);
|
|
4898
6548
|
if (cached && await this.isCacheFresh(cached)) {
|
|
4899
6549
|
return cached.result;
|
|
@@ -4912,8 +6562,8 @@ var RepositoryContext = class {
|
|
|
4912
6562
|
const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
|
|
4913
6563
|
if (result.isWorktree && result.bareRepoPath && adminDir) {
|
|
4914
6564
|
const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
|
|
4915
|
-
safeMtimeMs(
|
|
4916
|
-
safeMtimeMs(
|
|
6565
|
+
safeMtimeMs(path20.join(adminDir, "HEAD")),
|
|
6566
|
+
safeMtimeMs(path20.join(result.bareRepoPath, "worktrees"))
|
|
4917
6567
|
]);
|
|
4918
6568
|
this.discoveryCache.set(absolutePath, {
|
|
4919
6569
|
result,
|
|
@@ -4953,7 +6603,7 @@ var RepositoryContext = class {
|
|
|
4953
6603
|
const results = /* @__PURE__ */ new Map();
|
|
4954
6604
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
4955
6605
|
const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
|
|
4956
|
-
const bareRepoPath =
|
|
6606
|
+
const bareRepoPath = path20.resolve(entry.config.bareRepoDir);
|
|
4957
6607
|
return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
|
|
4958
6608
|
}).filter((c) => c.foldedBare !== currentBare);
|
|
4959
6609
|
const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
|
|
@@ -4961,7 +6611,7 @@ var RepositoryContext = class {
|
|
|
4961
6611
|
const sibling = {
|
|
4962
6612
|
name: entry.name,
|
|
4963
6613
|
bareRepoPath,
|
|
4964
|
-
worktreeDir:
|
|
6614
|
+
worktreeDir: path20.resolve(entry.config.worktreeDir),
|
|
4965
6615
|
repoUrl: entry.config.repoUrl,
|
|
4966
6616
|
present: configPresence[i],
|
|
4967
6617
|
configMatched: true
|
|
@@ -4971,24 +6621,24 @@ var RepositoryContext = class {
|
|
|
4971
6621
|
}
|
|
4972
6622
|
results.set(foldedBare, sibling);
|
|
4973
6623
|
});
|
|
4974
|
-
const repoDir =
|
|
4975
|
-
const workspaceRoot =
|
|
6624
|
+
const repoDir = path20.dirname(currentBareRepoPath);
|
|
6625
|
+
const workspaceRoot = path20.dirname(repoDir);
|
|
4976
6626
|
if (workspaceRoot === repoDir) {
|
|
4977
6627
|
return Array.from(results.values()).sort(byName);
|
|
4978
6628
|
}
|
|
4979
6629
|
let entries;
|
|
4980
6630
|
try {
|
|
4981
|
-
entries = await
|
|
6631
|
+
entries = await fs17.readdir(workspaceRoot);
|
|
4982
6632
|
} catch {
|
|
4983
6633
|
return Array.from(results.values()).sort(byName);
|
|
4984
6634
|
}
|
|
4985
6635
|
const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
|
|
4986
6636
|
await Promise.all(
|
|
4987
6637
|
entries.map(async (entry) => {
|
|
4988
|
-
const candidate =
|
|
4989
|
-
const bareCandidate =
|
|
6638
|
+
const candidate = path20.join(workspaceRoot, entry);
|
|
6639
|
+
const bareCandidate = path20.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
|
|
4990
6640
|
if (!await isDirectory(bareCandidate)) return;
|
|
4991
|
-
const resolvedBare =
|
|
6641
|
+
const resolvedBare = path20.resolve(bareCandidate);
|
|
4992
6642
|
const foldedBare = normalizePathForCompare(resolvedBare);
|
|
4993
6643
|
if (foldedBare === currentBare || results.has(foldedBare)) return;
|
|
4994
6644
|
const matchedName = configBares.get(foldedBare);
|
|
@@ -5014,8 +6664,8 @@ var RepositoryContext = class {
|
|
|
5014
6664
|
if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
|
|
5015
6665
|
if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
|
|
5016
6666
|
const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
|
|
5017
|
-
safeMtimeMs(
|
|
5018
|
-
safeMtimeMs(
|
|
6667
|
+
safeMtimeMs(path20.join(cached.worktreeAdminDir, "HEAD")),
|
|
6668
|
+
safeMtimeMs(path20.join(cached.result.bareRepoPath, "worktrees"))
|
|
5019
6669
|
]);
|
|
5020
6670
|
return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
|
|
5021
6671
|
}
|
|
@@ -5063,18 +6713,18 @@ var RepositoryContext = class {
|
|
|
5063
6713
|
return unsupported("Invalid .git file format (missing gitdir line)");
|
|
5064
6714
|
}
|
|
5065
6715
|
const gitdir = gitdirMatch[1].trim();
|
|
5066
|
-
const resolvedGitdir =
|
|
6716
|
+
const resolvedGitdir = path20.isAbsolute(gitdir) ? gitdir : path20.resolve(worktreeRoot, gitdir);
|
|
5067
6717
|
const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
|
|
5068
6718
|
if (!worktreesMatch) {
|
|
5069
6719
|
return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
|
|
5070
6720
|
}
|
|
5071
|
-
const bareRepoPath =
|
|
5072
|
-
const adminDir =
|
|
6721
|
+
const bareRepoPath = path20.resolve(worktreesMatch[1]);
|
|
6722
|
+
const adminDir = path20.resolve(resolvedGitdir);
|
|
5073
6723
|
let repoUrl = null;
|
|
5074
6724
|
let worktrees = [];
|
|
5075
6725
|
let currentBranch = null;
|
|
5076
6726
|
try {
|
|
5077
|
-
const bareGit =
|
|
6727
|
+
const bareGit = simpleGit7(bareRepoPath);
|
|
5078
6728
|
try {
|
|
5079
6729
|
const remoteResult = await bareGit.remote(["get-url", "origin"]);
|
|
5080
6730
|
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
@@ -5110,13 +6760,12 @@ var RepositoryContext = class {
|
|
|
5110
6760
|
adminDir
|
|
5111
6761
|
};
|
|
5112
6762
|
}
|
|
5113
|
-
const worktreeDir =
|
|
6763
|
+
const worktreeDir = path20.dirname(worktreeRoot);
|
|
5114
6764
|
const noUrlReason = "no remote origin URL detected";
|
|
5115
6765
|
const capabilities = {
|
|
5116
6766
|
listWorktrees: { available: true },
|
|
5117
6767
|
getStatus: { available: true },
|
|
5118
6768
|
createWorktree: repoUrl !== null ? { available: true } : { available: false, reason: noUrlReason },
|
|
5119
|
-
removeWorktree: { available: true },
|
|
5120
6769
|
updateWorktree: { available: true },
|
|
5121
6770
|
sync: { available: false, reason: "no config and no remote URL" },
|
|
5122
6771
|
initialize: { available: false, reason: "no config and no remote URL" }
|
|
@@ -5146,7 +6795,7 @@ var RepositoryContext = class {
|
|
|
5146
6795
|
cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
5147
6796
|
runOnce: true
|
|
5148
6797
|
};
|
|
5149
|
-
const detectedKey = `${AUTO_DETECT_PREFIX}${
|
|
6798
|
+
const detectedKey = `${AUTO_DETECT_PREFIX}${path20.basename(bareRepoPath)}@${bareRepoPath}`;
|
|
5150
6799
|
if (!this.repos.has(detectedKey)) {
|
|
5151
6800
|
this.repos.set(detectedKey, {
|
|
5152
6801
|
name: detectedKey,
|
|
@@ -5333,14 +6982,14 @@ var RepositoryContext = class {
|
|
|
5333
6982
|
const mode = resolveMode(entry.config);
|
|
5334
6983
|
const isCurrent = entry.name === currentRepo;
|
|
5335
6984
|
if (mode === REPOSITORY_MODES.CLONE) {
|
|
5336
|
-
return { name: entry.name, mode: "clone", checkoutPath:
|
|
6985
|
+
return { name: entry.name, mode: "clone", checkoutPath: path20.resolve(entry.config.worktreeDir), isCurrent };
|
|
5337
6986
|
}
|
|
5338
|
-
return { name: entry.name, mode: "worktree", worktreeDir:
|
|
6987
|
+
return { name: entry.name, mode: "worktree", worktreeDir: path20.resolve(entry.config.worktreeDir), isCurrent };
|
|
5339
6988
|
};
|
|
5340
6989
|
if (!options.detailed) {
|
|
5341
6990
|
return entries.map(buildLean);
|
|
5342
6991
|
}
|
|
5343
|
-
const limit =
|
|
6992
|
+
const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5344
6993
|
return Promise.all(
|
|
5345
6994
|
entries.map(
|
|
5346
6995
|
(entry) => limit(async () => {
|
|
@@ -5360,7 +7009,7 @@ var RepositoryContext = class {
|
|
|
5360
7009
|
return summary;
|
|
5361
7010
|
}
|
|
5362
7011
|
if (entry.config.bareRepoDir) {
|
|
5363
|
-
summary.bareRepoDir =
|
|
7012
|
+
summary.bareRepoDir = path20.resolve(entry.config.bareRepoDir);
|
|
5364
7013
|
summary.localReady = await isDirectory(summary.bareRepoDir);
|
|
5365
7014
|
} else {
|
|
5366
7015
|
summary.localReady = false;
|
|
@@ -5404,27 +7053,27 @@ var RepositoryContext = class {
|
|
|
5404
7053
|
return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
|
|
5405
7054
|
}
|
|
5406
7055
|
if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
|
|
5407
|
-
const bareRepoPath =
|
|
7056
|
+
const bareRepoPath = path20.resolve(entry.config.bareRepoDir);
|
|
5408
7057
|
if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
|
|
5409
7058
|
try {
|
|
5410
|
-
const output = await
|
|
7059
|
+
const output = await simpleGit7(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
|
|
5411
7060
|
return { worktrees: parseWorktreeList(output, currentWorktreePath) };
|
|
5412
7061
|
} catch (err) {
|
|
5413
7062
|
return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
|
|
5414
7063
|
}
|
|
5415
7064
|
}
|
|
5416
7065
|
findConfiguredCloneEntry(worktreeRoot) {
|
|
5417
|
-
const foldedRoot = normalizePathForCompare(
|
|
7066
|
+
const foldedRoot = normalizePathForCompare(path20.resolve(worktreeRoot));
|
|
5418
7067
|
for (const entry of this.repos.values()) {
|
|
5419
7068
|
if (entry.source !== "config" || resolveMode(entry.config) !== REPOSITORY_MODES.CLONE) continue;
|
|
5420
|
-
if (normalizePathForCompare(
|
|
7069
|
+
if (normalizePathForCompare(path20.resolve(entry.config.worktreeDir)) === foldedRoot) {
|
|
5421
7070
|
return entry;
|
|
5422
7071
|
}
|
|
5423
7072
|
}
|
|
5424
7073
|
return null;
|
|
5425
7074
|
}
|
|
5426
7075
|
async buildCloneModeContext(entry, worktreeRoot, notes) {
|
|
5427
|
-
const resolvedRoot =
|
|
7076
|
+
const resolvedRoot = path20.resolve(worktreeRoot);
|
|
5428
7077
|
let currentBranch = null;
|
|
5429
7078
|
try {
|
|
5430
7079
|
currentBranch = await readCurrentBranch(resolvedRoot);
|
|
@@ -5437,7 +7086,6 @@ var RepositoryContext = class {
|
|
|
5437
7086
|
listWorktrees: { available: true },
|
|
5438
7087
|
getStatus: { available: true },
|
|
5439
7088
|
createWorktree: { available: false, reason: cloneModeReason },
|
|
5440
|
-
removeWorktree: { available: false, reason: cloneModeReason },
|
|
5441
7089
|
updateWorktree: { available: false, reason: cloneModeReason },
|
|
5442
7090
|
sync: { available: true },
|
|
5443
7091
|
initialize: { available: true }
|
|
@@ -5462,7 +7110,7 @@ var RepositoryContext = class {
|
|
|
5462
7110
|
return discovered;
|
|
5463
7111
|
}
|
|
5464
7112
|
async readConfiguredCloneWorktree(entry, currentWorktreePath) {
|
|
5465
|
-
const worktreePath =
|
|
7113
|
+
const worktreePath = path20.resolve(entry.config.worktreeDir);
|
|
5466
7114
|
if (!await isDirectory(worktreePath) || !await hasGitMetadata(worktreePath)) {
|
|
5467
7115
|
return { worktrees: [] };
|
|
5468
7116
|
}
|
|
@@ -5486,7 +7134,7 @@ function parseWorktreeList(output, currentPath) {
|
|
|
5486
7134
|
const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
|
|
5487
7135
|
const results = [];
|
|
5488
7136
|
for (const wt of parseWorktreeListPorcelain(output)) {
|
|
5489
|
-
const resolved =
|
|
7137
|
+
const resolved = path20.resolve(wt.path);
|
|
5490
7138
|
const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
|
|
5491
7139
|
if (!branch) continue;
|
|
5492
7140
|
results.push({
|
|
@@ -5499,7 +7147,7 @@ function parseWorktreeList(output, currentPath) {
|
|
|
5499
7147
|
}
|
|
5500
7148
|
async function safeMtimeMs(filePath) {
|
|
5501
7149
|
try {
|
|
5502
|
-
const stat4 = await
|
|
7150
|
+
const stat4 = await fs17.stat(filePath);
|
|
5503
7151
|
return stat4.mtimeMs;
|
|
5504
7152
|
} catch {
|
|
5505
7153
|
return null;
|
|
@@ -5507,7 +7155,7 @@ async function safeMtimeMs(filePath) {
|
|
|
5507
7155
|
}
|
|
5508
7156
|
async function isDirectory(filePath) {
|
|
5509
7157
|
try {
|
|
5510
|
-
const stat4 = await
|
|
7158
|
+
const stat4 = await fs17.stat(filePath);
|
|
5511
7159
|
return stat4.isDirectory();
|
|
5512
7160
|
} catch {
|
|
5513
7161
|
return false;
|
|
@@ -5515,7 +7163,7 @@ async function isDirectory(filePath) {
|
|
|
5515
7163
|
}
|
|
5516
7164
|
async function hasGitMetadata(worktreePath) {
|
|
5517
7165
|
try {
|
|
5518
|
-
await
|
|
7166
|
+
await fs17.stat(path20.join(worktreePath, ".git"));
|
|
5519
7167
|
return true;
|
|
5520
7168
|
} catch {
|
|
5521
7169
|
return false;
|
|
@@ -5524,14 +7172,14 @@ async function hasGitMetadata(worktreePath) {
|
|
|
5524
7172
|
async function isGitCheckout(checkoutPath) {
|
|
5525
7173
|
if (!await isDirectory(checkoutPath)) return false;
|
|
5526
7174
|
try {
|
|
5527
|
-
const inside = (await
|
|
7175
|
+
const inside = (await simpleGit7(checkoutPath).raw(["rev-parse", "--is-inside-work-tree"])).trim();
|
|
5528
7176
|
return inside === "true";
|
|
5529
7177
|
} catch {
|
|
5530
7178
|
return false;
|
|
5531
7179
|
}
|
|
5532
7180
|
}
|
|
5533
7181
|
async function readCurrentBranch(worktreePath) {
|
|
5534
|
-
const git =
|
|
7182
|
+
const git = simpleGit7(worktreePath);
|
|
5535
7183
|
const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
5536
7184
|
if (branch && branch !== "HEAD") {
|
|
5537
7185
|
return branch;
|
|
@@ -5540,12 +7188,12 @@ async function readCurrentBranch(worktreePath) {
|
|
|
5540
7188
|
return head ? `(detached ${head})` : "(detached)";
|
|
5541
7189
|
}
|
|
5542
7190
|
async function findWorktreeRoot(startPath) {
|
|
5543
|
-
let current =
|
|
5544
|
-
const root =
|
|
7191
|
+
let current = path20.resolve(startPath);
|
|
7192
|
+
const root = path20.parse(current).root;
|
|
5545
7193
|
while (true) {
|
|
5546
|
-
const gitPath =
|
|
7194
|
+
const gitPath = path20.join(current, ".git");
|
|
5547
7195
|
try {
|
|
5548
|
-
const content = await
|
|
7196
|
+
const content = await fs17.readFile(gitPath, "utf-8");
|
|
5549
7197
|
return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
|
|
5550
7198
|
} catch (err) {
|
|
5551
7199
|
const code = err.code;
|
|
@@ -5557,7 +7205,7 @@ async function findWorktreeRoot(startPath) {
|
|
|
5557
7205
|
}
|
|
5558
7206
|
}
|
|
5559
7207
|
if (current === root) return null;
|
|
5560
|
-
const parent =
|
|
7208
|
+
const parent = path20.dirname(current);
|
|
5561
7209
|
if (parent === current) return null;
|
|
5562
7210
|
current = parent;
|
|
5563
7211
|
}
|
|
@@ -5568,26 +7216,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5568
7216
|
import { z } from "zod";
|
|
5569
7217
|
|
|
5570
7218
|
// src/mcp/handlers.ts
|
|
5571
|
-
import * as
|
|
5572
|
-
import
|
|
5573
|
-
|
|
5574
|
-
// src/utils/disk-space.ts
|
|
5575
|
-
import fastFolderSize from "fast-folder-size";
|
|
5576
|
-
async function calculateDirectorySize(dirPath) {
|
|
5577
|
-
return new Promise((resolve11, reject) => {
|
|
5578
|
-
fastFolderSize(dirPath, (err, bytes) => {
|
|
5579
|
-
if (err) {
|
|
5580
|
-
reject(err);
|
|
5581
|
-
return;
|
|
5582
|
-
}
|
|
5583
|
-
if (bytes === void 0) {
|
|
5584
|
-
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
5585
|
-
return;
|
|
5586
|
-
}
|
|
5587
|
-
resolve11(bytes);
|
|
5588
|
-
});
|
|
5589
|
-
});
|
|
5590
|
-
}
|
|
7219
|
+
import * as path21 from "path";
|
|
7220
|
+
import pLimit4 from "p-limit";
|
|
5591
7221
|
|
|
5592
7222
|
// src/utils/git-validation.ts
|
|
5593
7223
|
function isValidGitBranchName(name) {
|
|
@@ -5698,14 +7328,18 @@ function wrapHandler(fn) {
|
|
|
5698
7328
|
}
|
|
5699
7329
|
|
|
5700
7330
|
// src/mcp/worktree-summary.ts
|
|
5701
|
-
import
|
|
7331
|
+
import simpleGit8 from "simple-git";
|
|
5702
7332
|
function deriveLabel(status, isCurrent) {
|
|
5703
7333
|
if (isCurrent) return "current";
|
|
5704
|
-
|
|
5705
|
-
if (status.
|
|
7334
|
+
const unpushedBlocks = status.hasUnpushedCommits && !status.fullyPushedUpstreamDeleted;
|
|
7335
|
+
if (!status.isClean || unpushedBlocks || status.hasStashedChanges) return "dirty";
|
|
7336
|
+
if (status.upstreamGone || status.fullyPushedUpstreamDeleted) return "stale";
|
|
5706
7337
|
return "clean";
|
|
5707
7338
|
}
|
|
5708
7339
|
function deriveSafeToRemove(status) {
|
|
7340
|
+
if (status.canRemove && status.fullyPushedUpstreamDeleted) {
|
|
7341
|
+
return { safe: true, reason: "fully pushed before its remote branch was deleted" };
|
|
7342
|
+
}
|
|
5709
7343
|
if (status.canRemove && !status.upstreamGone) {
|
|
5710
7344
|
return { safe: true, reason: "clean tree, no unpushed commits" };
|
|
5711
7345
|
}
|
|
@@ -5719,7 +7353,7 @@ function deriveSafeToRemove(status) {
|
|
|
5719
7353
|
}
|
|
5720
7354
|
async function getDivergence(worktreePath) {
|
|
5721
7355
|
try {
|
|
5722
|
-
const git =
|
|
7356
|
+
const git = simpleGit8(worktreePath);
|
|
5723
7357
|
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
5724
7358
|
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
5725
7359
|
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|
|
@@ -5767,7 +7401,7 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
|
|
|
5767
7401
|
}
|
|
5768
7402
|
async function ensureRepoWorktreePath(ctx, params, service, git) {
|
|
5769
7403
|
await ensurePathBelongsToRepo(ctx, params.path, params.repoName, service, git);
|
|
5770
|
-
return
|
|
7404
|
+
return path21.resolve(params.path);
|
|
5771
7405
|
}
|
|
5772
7406
|
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
|
|
5773
7407
|
const discovered = ctx.getDiscoveredContext(repoName);
|
|
@@ -5816,7 +7450,7 @@ async function handleDetectContext(ctx, params, _extra) {
|
|
|
5816
7450
|
return formatToolResponse(response);
|
|
5817
7451
|
}
|
|
5818
7452
|
const statusService = new WorktreeStatusService();
|
|
5819
|
-
const statusLimit =
|
|
7453
|
+
const statusLimit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5820
7454
|
const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
|
|
5821
7455
|
let allWorktreesByRepo = response.allWorktreesByRepo;
|
|
5822
7456
|
if (allWorktreesByRepo) {
|
|
@@ -5852,18 +7486,14 @@ async function enrichDetectedWorktrees(worktrees, statusService, limit) {
|
|
|
5852
7486
|
async function handleListWorktrees(ctx, params, _extra) {
|
|
5853
7487
|
const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
|
|
5854
7488
|
if (configuredRepoNames.length > 0) {
|
|
5855
|
-
const limit =
|
|
5856
|
-
const statusLimit =
|
|
7489
|
+
const limit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
7490
|
+
const statusLimit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5857
7491
|
const repositories = await Promise.all(
|
|
5858
7492
|
configuredRepoNames.map(
|
|
5859
7493
|
(repoName) => limit(async () => {
|
|
5860
7494
|
try {
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
{
|
|
5864
|
-
worktrees: await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit)
|
|
5865
|
-
}
|
|
5866
|
-
];
|
|
7495
|
+
const worktrees2 = await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit);
|
|
7496
|
+
return [repoName, { worktrees: worktrees2 }];
|
|
5867
7497
|
} catch (err) {
|
|
5868
7498
|
return [
|
|
5869
7499
|
repoName,
|
|
@@ -5878,10 +7508,10 @@ async function handleListWorktrees(ctx, params, _extra) {
|
|
|
5878
7508
|
);
|
|
5879
7509
|
return formatToolResponse({ repositories: Object.fromEntries(repositories) });
|
|
5880
7510
|
}
|
|
5881
|
-
const
|
|
5882
|
-
return formatToolResponse({ worktrees
|
|
7511
|
+
const worktrees = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
|
|
7512
|
+
return formatToolResponse({ worktrees });
|
|
5883
7513
|
}
|
|
5884
|
-
async function listWorktreesForRepo(ctx, repoName, includeSize, limit =
|
|
7514
|
+
async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit4(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
|
|
5885
7515
|
const { discovered, service, git } = await getReadyService(ctx, repoName, {
|
|
5886
7516
|
capability: "listWorktrees",
|
|
5887
7517
|
toolName: "list_worktrees"
|
|
@@ -5900,7 +7530,7 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(
|
|
|
5900
7530
|
const results = await Promise.all(
|
|
5901
7531
|
worktrees.map(
|
|
5902
7532
|
(wt) => limit(async () => {
|
|
5903
|
-
const resolvedPath =
|
|
7533
|
+
const resolvedPath = path21.resolve(wt.path);
|
|
5904
7534
|
const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
|
|
5905
7535
|
const [status, divergence, metadata, sizeBytes] = await Promise.all([
|
|
5906
7536
|
git.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
@@ -5984,37 +7614,12 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
5984
7614
|
return formatToolResponse({
|
|
5985
7615
|
success: true,
|
|
5986
7616
|
branchName,
|
|
5987
|
-
worktreePath:
|
|
7617
|
+
worktreePath: path21.resolve(worktreePath),
|
|
5988
7618
|
created,
|
|
5989
7619
|
pushed
|
|
5990
7620
|
});
|
|
5991
7621
|
});
|
|
5992
7622
|
}
|
|
5993
|
-
async function handleRemoveWorktree(ctx, params, _extra) {
|
|
5994
|
-
const { service, git } = await getReadyService(ctx, params.repoName, {
|
|
5995
|
-
capability: "removeWorktree",
|
|
5996
|
-
toolName: "remove_worktree"
|
|
5997
|
-
});
|
|
5998
|
-
ensureWorktreeModeService(service, "remove_worktree");
|
|
5999
|
-
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
6000
|
-
if (!service.isInitialized()) {
|
|
6001
|
-
await service.initializeUnlocked();
|
|
6002
|
-
}
|
|
6003
|
-
const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
6004
|
-
if (!params.force) {
|
|
6005
|
-
const status = await git.getFullWorktreeStatus(params.path, false);
|
|
6006
|
-
if (!status.canRemove) {
|
|
6007
|
-
throw new Error(`Cannot remove worktree: ${status.reasons.join(", ")}. Use force=true to override.`);
|
|
6008
|
-
}
|
|
6009
|
-
}
|
|
6010
|
-
await git.removeWorktree(params.path);
|
|
6011
|
-
ctx.invalidateDiscovered();
|
|
6012
|
-
return formatToolResponse({
|
|
6013
|
-
success: true,
|
|
6014
|
-
removedPath
|
|
6015
|
-
});
|
|
6016
|
-
});
|
|
6017
|
-
}
|
|
6018
7623
|
async function handleSync(ctx, params, extra) {
|
|
6019
7624
|
const { service } = await getReadyService(ctx, params.repoName, {
|
|
6020
7625
|
capability: "sync",
|
|
@@ -6023,7 +7628,6 @@ async function handleSync(ctx, params, extra) {
|
|
|
6023
7628
|
const dispose = attachProgressReporter(service, extra);
|
|
6024
7629
|
try {
|
|
6025
7630
|
const start = Date.now();
|
|
6026
|
-
service.clearRecordedSkips();
|
|
6027
7631
|
const result = await service.sync();
|
|
6028
7632
|
if (!result.started) {
|
|
6029
7633
|
throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
|
|
@@ -6094,17 +7698,28 @@ async function handleInitialize(ctx, params, extra) {
|
|
|
6094
7698
|
}
|
|
6095
7699
|
}
|
|
6096
7700
|
async function handleLoadConfig(ctx, params, _extra) {
|
|
6097
|
-
const configPath = params.configPath ?? process.env.SYNC_WORKTREES_CONFIG;
|
|
7701
|
+
const configPath = params.configPath ?? process.env.SYNC_WORKTREES_CONFIG ?? ctx.getConfigPath() ?? await detectConfigFromLaunchCwd(ctx);
|
|
6098
7702
|
if (!configPath) {
|
|
6099
|
-
throw new Error(
|
|
7703
|
+
throw new Error(
|
|
7704
|
+
"configPath required (or set SYNC_WORKTREES_CONFIG env var, call detect_context with a path, or launch from a sync-worktrees workspace)"
|
|
7705
|
+
);
|
|
6100
7706
|
}
|
|
6101
7707
|
await ctx.loadConfig(configPath);
|
|
6102
7708
|
return formatToolResponse({
|
|
6103
|
-
configPath:
|
|
7709
|
+
configPath: path21.resolve(configPath),
|
|
6104
7710
|
currentRepository: ctx.getCurrentRepo(),
|
|
6105
7711
|
repositories: ctx.getRepositoryList()
|
|
6106
7712
|
});
|
|
6107
7713
|
}
|
|
7714
|
+
async function detectConfigFromLaunchCwd(ctx) {
|
|
7715
|
+
try {
|
|
7716
|
+
const discovered = await ctx.detectFromPath(ctx.getLaunchCwd());
|
|
7717
|
+
if (discovered.configPath) return discovered.configPath;
|
|
7718
|
+
return await ctx.findConfigUpward(ctx.getLaunchCwd());
|
|
7719
|
+
} catch {
|
|
7720
|
+
return null;
|
|
7721
|
+
}
|
|
7722
|
+
}
|
|
6108
7723
|
async function handleSetCurrentRepository(ctx, params, _extra) {
|
|
6109
7724
|
ctx.setCurrentRepo(params.repoName);
|
|
6110
7725
|
return formatToolResponse({
|
|
@@ -6200,7 +7815,7 @@ function createServer(context, snapshot) {
|
|
|
6200
7815
|
detailed: z.boolean().optional().default(false).describe("Expand configuredRepositories with repoUrl, branch, sparseCheckout, localReady, bareRepoDir."),
|
|
6201
7816
|
includeAllWorktrees: z.boolean().optional().describe("Include allWorktreesByRepo + allWorktreeErrorsByRepo for each configured repo. Default: false."),
|
|
6202
7817
|
includeStatus: z.boolean().optional().describe(
|
|
6203
|
-
"Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Default: false."
|
|
7818
|
+
"Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Labels here are metadata-blind (no sync metadata is loaded), so a fully-pushed branch whose remote was deleted shows 'dirty'; list_worktrees gives the authoritative label/safeToRemove. Default: false."
|
|
6204
7819
|
)
|
|
6205
7820
|
},
|
|
6206
7821
|
annotations: {
|
|
@@ -6271,25 +7886,6 @@ function createServer(context, snapshot) {
|
|
|
6271
7886
|
},
|
|
6272
7887
|
wrapHandler((params, extra) => handleCreateWorktree(context, params, extra))
|
|
6273
7888
|
);
|
|
6274
|
-
server.registerTool(
|
|
6275
|
-
"remove_worktree",
|
|
6276
|
-
{
|
|
6277
|
-
description: "Remove worktree. Safety checks reject if dirty, unpushed commits, stashes, or op in progress (merge/rebase/cherry-pick/revert/bisect). force=true: `git worktree remove --force` DELETES uncommitted/untracked files in dir; branch ref + stashes + remote preserved. Returns: {success, removedPath}.",
|
|
6278
|
-
inputSchema: {
|
|
6279
|
-
path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
|
|
6280
|
-
force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
|
|
6281
|
-
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
6282
|
-
},
|
|
6283
|
-
annotations: {
|
|
6284
|
-
title: "Remove worktree",
|
|
6285
|
-
readOnlyHint: false,
|
|
6286
|
-
destructiveHint: true,
|
|
6287
|
-
idempotentHint: false,
|
|
6288
|
-
openWorldHint: false
|
|
6289
|
-
}
|
|
6290
|
-
},
|
|
6291
|
-
wrapHandler((params, extra) => handleRemoveWorktree(context, params, extra))
|
|
6292
|
-
);
|
|
6293
7889
|
server.registerTool(
|
|
6294
7890
|
"sync",
|
|
6295
7891
|
{
|
|
@@ -6345,9 +7941,11 @@ function createServer(context, snapshot) {
|
|
|
6345
7941
|
server.registerTool(
|
|
6346
7942
|
"load_config",
|
|
6347
7943
|
{
|
|
6348
|
-
description: "Load/reload sync-worktrees JS config into session. Replaces previously loaded repos.
|
|
7944
|
+
description: "Load/reload sync-worktrees JS config into session. Replaces previously loaded repos. Uses configPath, SYNC_WORKTREES_CONFIG, an already detected config, or a launch-CWD auto-detect fallback. For first discovery from an arbitrary project path, call detect_context with path. Returns: {configPath, currentRepository, repositories: [{name, repoUrl, worktreeDir, source}]}.",
|
|
6349
7945
|
inputSchema: {
|
|
6350
|
-
configPath: z.string().optional().describe(
|
|
7946
|
+
configPath: z.string().optional().describe(
|
|
7947
|
+
"Config file path. Falls back to SYNC_WORKTREES_CONFIG, an already detected config, or launch-CWD auto-detect."
|
|
7948
|
+
)
|
|
6351
7949
|
},
|
|
6352
7950
|
annotations: {
|
|
6353
7951
|
title: "Load sync-worktrees config",
|