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/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { realpathSync as realpathSync2 } from "fs";
|
|
5
|
-
import * as
|
|
5
|
+
import * as path23 from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import
|
|
7
|
+
import pLimit4 from "p-limit";
|
|
8
8
|
|
|
9
9
|
// src/constants.ts
|
|
10
10
|
var GIT_CONSTANTS = {
|
|
@@ -15,6 +15,10 @@ var GIT_CONSTANTS = {
|
|
|
15
15
|
COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
|
|
16
16
|
BARE_DIR_NAME: ".bare",
|
|
17
17
|
DIVERGED_DIR_NAME: ".diverged",
|
|
18
|
+
REMOVED_DIR_NAME: ".removed",
|
|
19
|
+
TRASH_DIR_NAME: ".trash",
|
|
20
|
+
TRASH_REF_PREFIX: "refs/sync-worktrees/trash/",
|
|
21
|
+
KEEP_REF_PREFIX: "refs/sync-worktrees/keep/",
|
|
18
22
|
LFS_HEADER: "version https://git-lfs.github.com/spec/",
|
|
19
23
|
SUBMODULE_STATUS_ADDED: "+",
|
|
20
24
|
SUBMODULE_STATUS_REMOVED: "-",
|
|
@@ -60,7 +64,16 @@ var DEFAULT_CONFIG = {
|
|
|
60
64
|
FETCH_TIMEOUT_MS: 3e5,
|
|
61
65
|
CLONE_TIMEOUT_MS: 9e5,
|
|
62
66
|
LOCK_STALE_MS: 6e5,
|
|
63
|
-
LOCK_UPDATE_MS: 3e4
|
|
67
|
+
LOCK_UPDATE_MS: 3e4,
|
|
68
|
+
MAINTENANCE: {
|
|
69
|
+
ENABLED: true,
|
|
70
|
+
INTERVAL: "7d"
|
|
71
|
+
},
|
|
72
|
+
TRASH: {
|
|
73
|
+
ENABLED: true,
|
|
74
|
+
RETENTION_DAYS: 30,
|
|
75
|
+
MIGRATE_LEGACY: true
|
|
76
|
+
}
|
|
64
77
|
};
|
|
65
78
|
var ERROR_MESSAGES = {
|
|
66
79
|
GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
|
|
@@ -95,6 +108,15 @@ var CONFIG_FILE_NAMES = [
|
|
|
95
108
|
"sync-worktrees.config.mjs",
|
|
96
109
|
"sync-worktrees.config.cjs"
|
|
97
110
|
];
|
|
111
|
+
var MAINTENANCE_CONSTANTS = {
|
|
112
|
+
STATE_FILENAME: "sync-worktrees-maintenance.json"
|
|
113
|
+
};
|
|
114
|
+
var TRASH_CONSTANTS = {
|
|
115
|
+
MANIFEST_FILENAME: "manifest.json",
|
|
116
|
+
PAYLOAD_DIRNAME: "payload",
|
|
117
|
+
BUNDLE_FILENAME: "commits.bundle",
|
|
118
|
+
SCHEMA_VERSION: 1
|
|
119
|
+
};
|
|
98
120
|
var METADATA_CONSTANTS = {
|
|
99
121
|
MAX_HISTORY_ENTRIES: 10,
|
|
100
122
|
METADATA_FILENAME: "sync-metadata.json",
|
|
@@ -141,6 +163,8 @@ var SyncWorktreesError = class extends Error {
|
|
|
141
163
|
Caused by: ${cause.stack}`;
|
|
142
164
|
}
|
|
143
165
|
}
|
|
166
|
+
code;
|
|
167
|
+
cause;
|
|
144
168
|
};
|
|
145
169
|
var GitError = class extends SyncWorktreesError {
|
|
146
170
|
constructor(message, code, cause) {
|
|
@@ -152,17 +176,26 @@ var GitOperationError = class extends GitError {
|
|
|
152
176
|
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
153
177
|
}
|
|
154
178
|
};
|
|
179
|
+
var FastForwardError = class extends GitError {
|
|
180
|
+
constructor(branchName, cause) {
|
|
181
|
+
super(`Cannot fast-forward branch '${branchName}'`, "FAST_FORWARD_FAILED", cause);
|
|
182
|
+
this.branchName = branchName;
|
|
183
|
+
}
|
|
184
|
+
branchName;
|
|
185
|
+
};
|
|
155
186
|
var WorktreeError = class extends SyncWorktreesError {
|
|
156
187
|
constructor(message, code, cause) {
|
|
157
188
|
super(message, `WORKTREE_${code}`, cause);
|
|
158
189
|
}
|
|
159
190
|
};
|
|
160
191
|
var WorktreeNotCleanError = class extends WorktreeError {
|
|
161
|
-
constructor(
|
|
162
|
-
super(`Worktree at '${
|
|
163
|
-
this.path =
|
|
192
|
+
constructor(path24, reasons) {
|
|
193
|
+
super(`Worktree at '${path24}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
194
|
+
this.path = path24;
|
|
164
195
|
this.reasons = reasons;
|
|
165
196
|
}
|
|
197
|
+
path;
|
|
198
|
+
reasons;
|
|
166
199
|
};
|
|
167
200
|
var ConfigError = class extends SyncWorktreesError {
|
|
168
201
|
constructor(message, code, cause) {
|
|
@@ -175,18 +208,34 @@ var ConfigValidationError = class extends ConfigError {
|
|
|
175
208
|
this.field = field;
|
|
176
209
|
this.reason = reason;
|
|
177
210
|
}
|
|
211
|
+
field;
|
|
212
|
+
reason;
|
|
178
213
|
};
|
|
179
214
|
var ConfigFileNotFoundError = class extends ConfigError {
|
|
180
215
|
constructor(configPath) {
|
|
181
216
|
super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
|
|
182
217
|
this.configPath = configPath;
|
|
183
218
|
}
|
|
219
|
+
configPath;
|
|
184
220
|
};
|
|
185
221
|
var ConfigFileExistsError = class extends ConfigError {
|
|
186
222
|
constructor(configPath) {
|
|
187
223
|
super(`Config file already exists: ${configPath}`, "FILE_EXISTS");
|
|
188
224
|
this.configPath = configPath;
|
|
189
225
|
}
|
|
226
|
+
configPath;
|
|
227
|
+
};
|
|
228
|
+
var TrashError = class extends SyncWorktreesError {
|
|
229
|
+
constructor(message, code, cause) {
|
|
230
|
+
super(message, `TRASH_${code}`, cause);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
var TrashOperationError = class extends TrashError {
|
|
234
|
+
constructor(operation, details, cause) {
|
|
235
|
+
super(`Trash operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
236
|
+
this.operation = operation;
|
|
237
|
+
}
|
|
238
|
+
operation;
|
|
190
239
|
};
|
|
191
240
|
|
|
192
241
|
// src/services/config-loader.service.ts
|
|
@@ -214,16 +263,73 @@ function filterBranchesByName(branches, include, exclude) {
|
|
|
214
263
|
return result;
|
|
215
264
|
}
|
|
216
265
|
|
|
266
|
+
// src/utils/date-filter.ts
|
|
267
|
+
function parseDuration(durationStr) {
|
|
268
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
269
|
+
if (!match) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const value = parseInt(match[1], 10);
|
|
273
|
+
const unit = match[2];
|
|
274
|
+
const multipliers = {
|
|
275
|
+
h: 60 * 60 * 1e3,
|
|
276
|
+
// hours
|
|
277
|
+
d: 24 * 60 * 60 * 1e3,
|
|
278
|
+
// days
|
|
279
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
280
|
+
// weeks
|
|
281
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
282
|
+
// months (approximate)
|
|
283
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
284
|
+
// years (approximate)
|
|
285
|
+
};
|
|
286
|
+
return value * multipliers[unit];
|
|
287
|
+
}
|
|
288
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
289
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
290
|
+
if (maxAgeMs === null) {
|
|
291
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
292
|
+
return branches;
|
|
293
|
+
}
|
|
294
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
295
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
296
|
+
}
|
|
297
|
+
function formatDuration(durationStr) {
|
|
298
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
299
|
+
if (!match) {
|
|
300
|
+
return durationStr;
|
|
301
|
+
}
|
|
302
|
+
const value = parseInt(match[1], 10);
|
|
303
|
+
const unit = match[2];
|
|
304
|
+
const unitNames = {
|
|
305
|
+
h: value === 1 ? "hour" : "hours",
|
|
306
|
+
d: value === 1 ? "day" : "days",
|
|
307
|
+
w: value === 1 ? "week" : "weeks",
|
|
308
|
+
m: value === 1 ? "month" : "months",
|
|
309
|
+
y: value === 1 ? "year" : "years"
|
|
310
|
+
};
|
|
311
|
+
return `${value} ${unitNames[unit]}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
217
314
|
// src/utils/file-exists.ts
|
|
218
315
|
import * as fs from "fs/promises";
|
|
219
|
-
async function fileExists(
|
|
316
|
+
async function fileExists(path24) {
|
|
220
317
|
try {
|
|
221
|
-
await fs.access(
|
|
318
|
+
await fs.access(path24);
|
|
222
319
|
return true;
|
|
223
320
|
} catch {
|
|
224
321
|
return false;
|
|
225
322
|
}
|
|
226
323
|
}
|
|
324
|
+
async function probePathExists(path24) {
|
|
325
|
+
try {
|
|
326
|
+
await fs.access(path24);
|
|
327
|
+
return "exists";
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const code = error.code;
|
|
330
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
227
333
|
|
|
228
334
|
// src/utils/git-url.ts
|
|
229
335
|
function extractRepoNameFromUrl(gitUrl) {
|
|
@@ -311,7 +417,8 @@ var CLONE_MODE_CONFLICTING_FIELDS = [
|
|
|
311
417
|
"branchExclude",
|
|
312
418
|
"branchMaxAge",
|
|
313
419
|
"updateExistingWorktrees",
|
|
314
|
-
"bareRepoDir"
|
|
420
|
+
"bareRepoDir",
|
|
421
|
+
"trash"
|
|
315
422
|
];
|
|
316
423
|
var ConfigLoaderService = class {
|
|
317
424
|
async findConfigUpward(startDir) {
|
|
@@ -414,6 +521,12 @@ var ConfigLoaderService = class {
|
|
|
414
521
|
if (repoObj.sparseCheckout !== void 0) {
|
|
415
522
|
this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
|
|
416
523
|
}
|
|
524
|
+
if (repoObj.maintenance !== void 0) {
|
|
525
|
+
this.validateMaintenanceConfig(repoObj.maintenance, `Repository '${repoObj.name}'`);
|
|
526
|
+
}
|
|
527
|
+
if (repoObj.trash !== void 0) {
|
|
528
|
+
this.validateTrashConfig(repoObj.trash, `Repository '${repoObj.name}'`);
|
|
529
|
+
}
|
|
417
530
|
this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
|
|
418
531
|
this.validateRepositoryMode(repoObj, configObj.defaults);
|
|
419
532
|
});
|
|
@@ -450,6 +563,12 @@ var ConfigLoaderService = class {
|
|
|
450
563
|
if (defaults.sparseCheckout !== void 0) {
|
|
451
564
|
this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
|
|
452
565
|
}
|
|
566
|
+
if (defaults.maintenance !== void 0) {
|
|
567
|
+
this.validateMaintenanceConfig(defaults.maintenance, "defaults");
|
|
568
|
+
}
|
|
569
|
+
if (defaults.trash !== void 0) {
|
|
570
|
+
this.validateTrashConfig(defaults.trash, "defaults");
|
|
571
|
+
}
|
|
453
572
|
this.validateDepth(defaults.depth, "defaults.depth");
|
|
454
573
|
if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
|
|
455
574
|
throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
|
|
@@ -477,6 +596,46 @@ var ConfigLoaderService = class {
|
|
|
477
596
|
throw new ConfigValidationError(field, "must be a positive safe integer");
|
|
478
597
|
}
|
|
479
598
|
}
|
|
599
|
+
validateMaintenanceConfig(value, context) {
|
|
600
|
+
if (value === void 0) return;
|
|
601
|
+
if (typeof value !== "object" || value === null) {
|
|
602
|
+
throw new Error(`'maintenance' in ${context} must be an object`);
|
|
603
|
+
}
|
|
604
|
+
const maintenance = value;
|
|
605
|
+
if (maintenance.enabled !== void 0 && typeof maintenance.enabled !== "boolean") {
|
|
606
|
+
throw new Error(`'maintenance.enabled' in ${context} must be a boolean`);
|
|
607
|
+
}
|
|
608
|
+
if (maintenance.aggressive !== void 0 && typeof maintenance.aggressive !== "boolean") {
|
|
609
|
+
throw new Error(`'maintenance.aggressive' in ${context} must be a boolean`);
|
|
610
|
+
}
|
|
611
|
+
if (maintenance.interval !== void 0) {
|
|
612
|
+
const parsed = typeof maintenance.interval === "string" ? parseDuration(maintenance.interval) : null;
|
|
613
|
+
if (parsed === null || parsed <= 0) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`'maintenance.interval' in ${context} must be a positive duration string like '7d', '24h', or '2w'`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
validateTrashConfig(value, context) {
|
|
621
|
+
if (value === void 0) return;
|
|
622
|
+
if (typeof value !== "object" || value === null) {
|
|
623
|
+
throw new Error(`'trash' in ${context} must be an object`);
|
|
624
|
+
}
|
|
625
|
+
const trash = value;
|
|
626
|
+
if (trash.enabled !== void 0 && typeof trash.enabled !== "boolean") {
|
|
627
|
+
throw new Error(`'trash.enabled' in ${context} must be a boolean`);
|
|
628
|
+
}
|
|
629
|
+
if (trash.migrateLegacy !== void 0 && typeof trash.migrateLegacy !== "boolean") {
|
|
630
|
+
throw new Error(`'trash.migrateLegacy' in ${context} must be a boolean`);
|
|
631
|
+
}
|
|
632
|
+
if (trash.retentionDays !== void 0 && (typeof trash.retentionDays !== "number" || !Number.isFinite(trash.retentionDays) || trash.retentionDays <= 0)) {
|
|
633
|
+
throw new Error(`'trash.retentionDays' in ${context} must be a positive number`);
|
|
634
|
+
}
|
|
635
|
+
if (trash.warnSizeBytes !== void 0 && (typeof trash.warnSizeBytes !== "number" || !Number.isFinite(trash.warnSizeBytes) || trash.warnSizeBytes <= 0)) {
|
|
636
|
+
throw new Error(`'trash.warnSizeBytes' in ${context} must be a positive number`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
480
639
|
validateRetryConfig(value, context) {
|
|
481
640
|
if (typeof value !== "object" || value === null) {
|
|
482
641
|
throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
|
|
@@ -748,6 +907,18 @@ var ConfigLoaderService = class {
|
|
|
748
907
|
if (sparse) {
|
|
749
908
|
resolved.sparseCheckout = sparse;
|
|
750
909
|
}
|
|
910
|
+
if (repo.maintenance || defaults?.maintenance) {
|
|
911
|
+
resolved.maintenance = {
|
|
912
|
+
...defaults?.maintenance || {},
|
|
913
|
+
...repo.maintenance || {}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
if (repo.trash || defaults?.trash) {
|
|
917
|
+
resolved.trash = {
|
|
918
|
+
...defaults?.trash || {},
|
|
919
|
+
...repo.trash || {}
|
|
920
|
+
};
|
|
921
|
+
}
|
|
751
922
|
return resolved;
|
|
752
923
|
}
|
|
753
924
|
isDuplicateRepoUrl(repo, all, defaults) {
|
|
@@ -811,16 +982,16 @@ var ConfigLoaderService = class {
|
|
|
811
982
|
|
|
812
983
|
// src/services/InteractiveUIService.tsx
|
|
813
984
|
import React8 from "react";
|
|
814
|
-
import * as
|
|
985
|
+
import * as path20 from "path";
|
|
815
986
|
import { render } from "ink";
|
|
816
987
|
import * as cron2 from "node-cron";
|
|
817
|
-
import
|
|
988
|
+
import pLimit3 from "p-limit";
|
|
818
989
|
import { spawn as spawn2, spawnSync } from "child_process";
|
|
819
990
|
import { existsSync as existsSync2 } from "fs";
|
|
820
991
|
|
|
821
992
|
// src/components/App.tsx
|
|
822
993
|
import React7, { useState as useState6, useEffect as useEffect6, useCallback as useCallback4, useRef as useRef5 } from "react";
|
|
823
|
-
import { Box as Box7, useInput as useInput6,
|
|
994
|
+
import { Box as Box7, useInput as useInput6, useWindowSize } from "ink";
|
|
824
995
|
|
|
825
996
|
// src/components/StatusBar.tsx
|
|
826
997
|
import React, { useState, useEffect } from "react";
|
|
@@ -829,6 +1000,7 @@ import { CronExpressionParser } from "cron-parser";
|
|
|
829
1000
|
var StatusBar = ({
|
|
830
1001
|
status,
|
|
831
1002
|
syncProgressEntries = [],
|
|
1003
|
+
activeOps = [],
|
|
832
1004
|
maxProgressLines = 2,
|
|
833
1005
|
repositoryCount,
|
|
834
1006
|
lastSyncTime,
|
|
@@ -864,17 +1036,14 @@ var StatusBar = ({
|
|
|
864
1036
|
const getStatusIcon = () => {
|
|
865
1037
|
return status === "syncing" ? "\u27F3" : "\u2713";
|
|
866
1038
|
};
|
|
867
|
-
const formatProgress = (syncProgress) => {
|
|
868
|
-
const percent = syncProgress.progress === void 0 || syncProgress.message.includes(`${syncProgress.progress}%`) ? "" : ` ${syncProgress.progress}%`;
|
|
869
|
-
return `[${syncProgress.repo}] ${syncProgress.message}${percent}`;
|
|
870
|
-
};
|
|
1039
|
+
const formatProgress = (syncProgress) => `[${syncProgress.repo}] ${syncProgress.message}`;
|
|
871
1040
|
const progressLineCount = Math.max(1, maxProgressLines);
|
|
872
1041
|
const visibleProgress = syncProgressEntries.slice(-progressLineCount);
|
|
873
1042
|
return /* @__PURE__ */ React.createElement(Box, { borderStyle: "single", paddingX: 1 }, /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: "100%" }, /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, getStatusIcon(), " Status:", " ", /* @__PURE__ */ React.createElement(Text, { color: getStatusColor() }, status === "syncing" ? "Syncing..." : "Running")), /* @__PURE__ */ React.createElement(Text, null, "Repositories: ", /* @__PURE__ */ React.createElement(Text, { bold: true, color: "cyan" }, repositoryCount))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Last Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(lastSyncTime))), cronSchedule && /* @__PURE__ */ React.createElement(Text, null, "Next Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(nextSyncTime)))), status === "syncing" && Array.from({ length: progressLineCount }).map((_, index) => {
|
|
874
1043
|
const entry = visibleProgress[index];
|
|
875
1044
|
const message = entry ? formatProgress(entry) : index === 0 ? "waiting for progress events" : "";
|
|
876
1045
|
return /* @__PURE__ */ React.createElement(Box, { key: index }, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, message ? "Progress: " : " ", message && /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, message)));
|
|
877
|
-
}), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "w"), "tree", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
|
|
1046
|
+
}), activeOps.map((label, index) => /* @__PURE__ */ React.createElement(Box, { key: `op-${index}` }, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "\u23F3 "), /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, label)))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "w"), "tree", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
|
|
878
1047
|
};
|
|
879
1048
|
var StatusBar_default = StatusBar;
|
|
880
1049
|
|
|
@@ -893,7 +1062,7 @@ var HelpModal_default = HelpModal;
|
|
|
893
1062
|
|
|
894
1063
|
// src/components/BranchCreationWizard.tsx
|
|
895
1064
|
import React3, { useState as useState2, useEffect as useEffect2, useCallback, useMemo, useRef } from "react";
|
|
896
|
-
import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
|
|
1065
|
+
import { Box as Box3, Text as Text3, useInput as useInput2, usePaste } from "ink";
|
|
897
1066
|
|
|
898
1067
|
// src/utils/git-validation.ts
|
|
899
1068
|
function isValidGitBranchName(name) {
|
|
@@ -1159,6 +1328,17 @@ var BranchCreationWizard = ({
|
|
|
1159
1328
|
onComplete(result?.success ?? false);
|
|
1160
1329
|
}
|
|
1161
1330
|
});
|
|
1331
|
+
usePaste((text) => {
|
|
1332
|
+
if (step === "SELECT_PROJECT") {
|
|
1333
|
+
setProjectFilter((prev) => prev + text);
|
|
1334
|
+
setSelectedProjectIndex(0);
|
|
1335
|
+
} else if (step === "SELECT_BRANCH") {
|
|
1336
|
+
setBranchFilter((prev) => prev + text);
|
|
1337
|
+
setSelectedBranchIndex(0);
|
|
1338
|
+
} else if (step === "ENTER_NAME") {
|
|
1339
|
+
setBranchName((prev) => prev + text.replace(/[^a-zA-Z0-9/._-]/g, ""));
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1162
1342
|
const getStepNumber = () => {
|
|
1163
1343
|
if (repositories.length === 1) {
|
|
1164
1344
|
if (step === "SELECT_BRANCH") return 1;
|
|
@@ -1252,7 +1432,7 @@ var BranchCreationWizard_default = BranchCreationWizard;
|
|
|
1252
1432
|
|
|
1253
1433
|
// src/components/OpenEditorWizard.tsx
|
|
1254
1434
|
import React4, { useState as useState3, useEffect as useEffect3, useMemo as useMemo2, useCallback as useCallback2, useRef as useRef2 } from "react";
|
|
1255
|
-
import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
|
|
1435
|
+
import { Box as Box4, Text as Text4, useInput as useInput3, usePaste as usePaste2 } from "ink";
|
|
1256
1436
|
var OpenEditorWizard = ({
|
|
1257
1437
|
repositories,
|
|
1258
1438
|
getWorktreesForRepo,
|
|
@@ -1370,6 +1550,15 @@ var OpenEditorWizard = ({
|
|
|
1370
1550
|
onClose();
|
|
1371
1551
|
}
|
|
1372
1552
|
});
|
|
1553
|
+
usePaste2((text) => {
|
|
1554
|
+
if (step === "SELECT_PROJECT") {
|
|
1555
|
+
setProjectFilter((prev) => prev + text);
|
|
1556
|
+
setSelectedProjectIndex(0);
|
|
1557
|
+
} else if (step === "SELECT_WORKTREE") {
|
|
1558
|
+
setWorktreeFilter((prev) => prev + text);
|
|
1559
|
+
setSelectedWorktreeIndex(0);
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1373
1562
|
const getStepNumber = () => {
|
|
1374
1563
|
if (repositories.length === 1) {
|
|
1375
1564
|
return 1;
|
|
@@ -1441,7 +1630,7 @@ var OpenEditorWizard_default = OpenEditorWizard;
|
|
|
1441
1630
|
|
|
1442
1631
|
// src/components/WorktreeStatusView.tsx
|
|
1443
1632
|
import React5, { useState as useState4, useEffect as useEffect4, useMemo as useMemo3, useCallback as useCallback3, useRef as useRef3 } from "react";
|
|
1444
|
-
import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
|
|
1633
|
+
import { Box as Box5, Text as Text5, useInput as useInput4, usePaste as usePaste3 } from "ink";
|
|
1445
1634
|
|
|
1446
1635
|
// src/utils/lfs-error.ts
|
|
1447
1636
|
function getErrorMessage(error) {
|
|
@@ -1486,7 +1675,7 @@ var getStatusFlags = (status) => {
|
|
|
1486
1675
|
}
|
|
1487
1676
|
if (status.hasUnpushedCommits) {
|
|
1488
1677
|
flags.push(
|
|
1489
|
-
/* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "cyan" }, "\u2191")
|
|
1678
|
+
status.fullyPushedUpstreamDeleted ? /* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "green" }, "\u21E1") : /* @__PURE__ */ React5.createElement(Text5, { key: "unpushed", color: "cyan" }, "\u2191")
|
|
1490
1679
|
);
|
|
1491
1680
|
}
|
|
1492
1681
|
if (status.hasStashedChanges) {
|
|
@@ -1519,7 +1708,9 @@ var getStatusSummary = (status) => {
|
|
|
1519
1708
|
if (fileCount > 0) parts.push(`${fileCount} changed`);
|
|
1520
1709
|
}
|
|
1521
1710
|
if (status.hasUnpushedCommits && details?.unpushedCommitCount) {
|
|
1522
|
-
parts.push(
|
|
1711
|
+
parts.push(
|
|
1712
|
+
status.fullyPushedUpstreamDeleted ? "pushed, remote branch deleted" : `${details.unpushedCommitCount} unpushed`
|
|
1713
|
+
);
|
|
1523
1714
|
}
|
|
1524
1715
|
if (status.hasStashedChanges && details?.stashCount) {
|
|
1525
1716
|
parts.push(`${details.stashCount} stash`);
|
|
@@ -1747,6 +1938,17 @@ var WorktreeStatusView = ({
|
|
|
1747
1938
|
onClose();
|
|
1748
1939
|
}
|
|
1749
1940
|
});
|
|
1941
|
+
usePaste3((text) => {
|
|
1942
|
+
if (confirmDelete !== null) return;
|
|
1943
|
+
if (step === "SELECT_PROJECT") {
|
|
1944
|
+
setProjectFilter((prev) => prev + text);
|
|
1945
|
+
setSelectedProjectIndex(0);
|
|
1946
|
+
} else if (step === "VIEW_STATUS" && !loading) {
|
|
1947
|
+
setEntryFilter((prev) => prev + text);
|
|
1948
|
+
setSelectedEntryIndex(0);
|
|
1949
|
+
setExpandedEntry(null);
|
|
1950
|
+
}
|
|
1951
|
+
});
|
|
1750
1952
|
const getStepNumber = () => {
|
|
1751
1953
|
if (repositories.length === 1) return 1;
|
|
1752
1954
|
return step === "SELECT_PROJECT" ? 1 : 2;
|
|
@@ -1770,7 +1972,7 @@ var WorktreeStatusView = ({
|
|
|
1770
1972
|
const renderDetailPanel = (entry) => {
|
|
1771
1973
|
const { status } = entry;
|
|
1772
1974
|
const details = status.details;
|
|
1773
|
-
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), details && /* @__PURE__ */ React5.createElement(React5.Fragment, null, details.modifiedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified: ", details.modifiedFiles), details.deletedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Deleted: ", details.deletedFiles), details.createdFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " Created: ", details.createdFiles), details.renamedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " Renamed: ", details.renamedFiles), details.untrackedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, " Untracked: ", details.untrackedFiles), details.conflictedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Conflicted: ", details.conflictedFiles), (details.unpushedCommitCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, " Unpushed commits: ", details.unpushedCommitCount), (details.stashCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " Stashes: ", details.stashCount), details.operationType && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Operation: ", details.operationType), details.modifiedSubmodules && details.modifiedSubmodules.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified submodules: ", details.modifiedSubmodules.join(", "))), status.upstreamGone && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Remote branch has been deleted"), status.reasons.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Reasons: ", status.reasons.join(", ")));
|
|
1975
|
+
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), details && /* @__PURE__ */ React5.createElement(React5.Fragment, null, details.modifiedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified: ", details.modifiedFiles), details.deletedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Deleted: ", details.deletedFiles), details.createdFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " Created: ", details.createdFiles), details.renamedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "blue" }, " Renamed: ", details.renamedFiles), details.untrackedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "gray" }, " Untracked: ", details.untrackedFiles), details.conflictedFiles > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Conflicted: ", details.conflictedFiles), (details.unpushedCommitCount ?? 0) > 0 && (status.fullyPushedUpstreamDeleted ? /* @__PURE__ */ React5.createElement(Text5, { color: "green" }, " ", "Fully pushed before remote branch deletion (", details.unpushedCommitCount, " commit", details.unpushedCommitCount === 1 ? "" : "s", " not on any remote \u2014 likely squash-merged)") : /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, " Unpushed commits: ", details.unpushedCommitCount)), (details.stashCount ?? 0) > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, " Stashes: ", details.stashCount), details.operationType && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Operation: ", details.operationType), details.modifiedSubmodules && details.modifiedSubmodules.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, " Modified submodules: ", details.modifiedSubmodules.join(", "))), status.upstreamGone && /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, " Remote branch has been deleted"), status.reasons.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Reasons: ", status.reasons.join(", ")));
|
|
1774
1976
|
};
|
|
1775
1977
|
const renderDivergedDetailPanel = (entry) => {
|
|
1776
1978
|
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Original branch: ", entry.originalBranch), entry.divergedAt && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Diverged: ", entry.divergedAt), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Size: ", entry.sizeFormatted));
|
|
@@ -1971,13 +2173,15 @@ var App = ({
|
|
|
1971
2173
|
const [showOpenEditorWizard, setShowOpenEditorWizard] = useState6(false);
|
|
1972
2174
|
const [showWorktreeStatus, setShowWorktreeStatus] = useState6(false);
|
|
1973
2175
|
const [status, setStatus] = useState6("idle");
|
|
2176
|
+
const [activeOps, setActiveOps] = useState6([]);
|
|
2177
|
+
const opIdRef = useRef5(0);
|
|
1974
2178
|
const [syncProgressEntries, setSyncProgressEntries] = useState6([]);
|
|
1975
2179
|
const [lastSyncTime, setLastSyncTime] = useState6(null);
|
|
1976
2180
|
const [diskSpaceUsed, setDiskSpaceUsed] = useState6(null);
|
|
1977
2181
|
const [logs, setLogs] = useState6([]);
|
|
1978
2182
|
const [repoCount, setRepoCount] = useState6(repositoryCount);
|
|
1979
2183
|
const [schedule2, setSchedule] = useState6(cronSchedule);
|
|
1980
|
-
const {
|
|
2184
|
+
const { rows } = useWindowSize();
|
|
1981
2185
|
const addLog = useCallback4((message, level = "info") => {
|
|
1982
2186
|
setLogs((prev) => {
|
|
1983
2187
|
const newLogs = [
|
|
@@ -2011,11 +2215,11 @@ var App = ({
|
|
|
2011
2215
|
onQuit().catch((err) => console.error("Quit failed:", err));
|
|
2012
2216
|
} else if (input2 === "?" || input2 === "h") {
|
|
2013
2217
|
setShowHelp(true);
|
|
2014
|
-
} else if (input2 === "c"
|
|
2218
|
+
} else if (input2 === "c") {
|
|
2015
2219
|
setShowBranchWizard(true);
|
|
2016
|
-
} else if (input2 === "o"
|
|
2220
|
+
} else if (input2 === "o") {
|
|
2017
2221
|
setShowOpenEditorWizard(true);
|
|
2018
|
-
} else if (input2 === "w" &&
|
|
2222
|
+
} else if (input2 === "w" && getWorktreeStatusForRepo) {
|
|
2019
2223
|
setShowWorktreeStatus(true);
|
|
2020
2224
|
} else if (input2 === "s" && status !== "syncing") {
|
|
2021
2225
|
setStatus("syncing");
|
|
@@ -2092,8 +2296,8 @@ var App = ({
|
|
|
2092
2296
|
};
|
|
2093
2297
|
}, []);
|
|
2094
2298
|
const progressLineCount = status === "syncing" ? Math.max(1, maxProgressLines) : 0;
|
|
2095
|
-
const statusBarHeight = 5 + progressLineCount;
|
|
2096
|
-
const terminalRows =
|
|
2299
|
+
const statusBarHeight = 5 + progressLineCount + activeOps.length;
|
|
2300
|
+
const terminalRows = rows ?? 24;
|
|
2097
2301
|
const logPanelHeight = Math.max(5, terminalRows - statusBarHeight);
|
|
2098
2302
|
const showModal = showHelp || showBranchWizard || showOpenEditorWizard || showWorktreeStatus;
|
|
2099
2303
|
return /* @__PURE__ */ React7.createElement(Box7, { flexDirection: "column", minHeight: terminalRows }, !showModal && /* @__PURE__ */ React7.createElement(LogPanel_default, { logs, height: logPanelHeight, isActive: !showModal }), showHelp && /* @__PURE__ */ React7.createElement(HelpModal_default, { onClose: () => setShowHelp(false) }), showBranchWizard && /* @__PURE__ */ React7.createElement(
|
|
@@ -2106,7 +2310,8 @@ var App = ({
|
|
|
2106
2310
|
createAndPushBranch,
|
|
2107
2311
|
onClose: () => setShowBranchWizard(false),
|
|
2108
2312
|
onBranchCreated: (context) => {
|
|
2109
|
-
|
|
2313
|
+
const opId = ++opIdRef.current;
|
|
2314
|
+
setActiveOps((prev) => [...prev, { id: opId, label: `Creating worktree ${context.newBranch}` }]);
|
|
2110
2315
|
(async () => {
|
|
2111
2316
|
try {
|
|
2112
2317
|
await createWorktreeForBranch(context.repoIndex, context.newBranch);
|
|
@@ -2135,7 +2340,7 @@ var App = ({
|
|
|
2135
2340
|
level: "error"
|
|
2136
2341
|
});
|
|
2137
2342
|
} finally {
|
|
2138
|
-
|
|
2343
|
+
setActiveOps((prev) => prev.filter((op) => op.id !== opId));
|
|
2139
2344
|
}
|
|
2140
2345
|
})().catch((err) => console.error("Branch creation unhandled error:", err));
|
|
2141
2346
|
},
|
|
@@ -2167,6 +2372,7 @@ var App = ({
|
|
|
2167
2372
|
{
|
|
2168
2373
|
status,
|
|
2169
2374
|
syncProgressEntries,
|
|
2375
|
+
activeOps: activeOps.map((op) => op.label),
|
|
2170
2376
|
maxProgressLines,
|
|
2171
2377
|
repositoryCount: repoCount,
|
|
2172
2378
|
lastSyncTime,
|
|
@@ -2177,6 +2383,34 @@ var App = ({
|
|
|
2177
2383
|
};
|
|
2178
2384
|
var App_default = App;
|
|
2179
2385
|
|
|
2386
|
+
// src/services/worktree-sync.service.ts
|
|
2387
|
+
import pLimit2 from "p-limit";
|
|
2388
|
+
|
|
2389
|
+
// src/utils/lock-path.ts
|
|
2390
|
+
import { createHash } from "crypto";
|
|
2391
|
+
import * as os from "os";
|
|
2392
|
+
import * as path3 from "path";
|
|
2393
|
+
function getCloneModeLockTarget(config) {
|
|
2394
|
+
const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
2395
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
|
|
2396
|
+
const dir = path3.join(stateBase, "sync-worktrees", "locks");
|
|
2397
|
+
return { dir, file: `${hash}.lock` };
|
|
2398
|
+
}
|
|
2399
|
+
function getRemovalAuditLogPath(config) {
|
|
2400
|
+
const name = config.name;
|
|
2401
|
+
const configDir = config.__configFileDir;
|
|
2402
|
+
const hash = createHash("sha256").update(path3.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
2403
|
+
if (configDir) {
|
|
2404
|
+
return path3.join(
|
|
2405
|
+
configDir,
|
|
2406
|
+
".sync-worktrees-state",
|
|
2407
|
+
`${sanitizeNameForPath(name ?? "repo", "removal audit log name")}-${hash}-removals.jsonl`
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path3.join(os.homedir(), ".cache");
|
|
2411
|
+
return path3.join(stateBase, "sync-worktrees", "removals", `${hash}.jsonl`);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2180
2414
|
// src/utils/retry.ts
|
|
2181
2415
|
var DEFAULT_OPTIONS = {
|
|
2182
2416
|
maxAttempts: "unlimited",
|
|
@@ -2247,7 +2481,7 @@ async function retry(fn, options = {}) {
|
|
|
2247
2481
|
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
2248
2482
|
const delay = baseDelay + jitter;
|
|
2249
2483
|
opts.onRetry(error, attempt, lfsContext);
|
|
2250
|
-
await new Promise((
|
|
2484
|
+
await new Promise((resolve14) => setTimeout(resolve14, delay));
|
|
2251
2485
|
attempt++;
|
|
2252
2486
|
}
|
|
2253
2487
|
}
|
|
@@ -2318,7 +2552,7 @@ var PhaseTimer = class {
|
|
|
2318
2552
|
return results;
|
|
2319
2553
|
}
|
|
2320
2554
|
};
|
|
2321
|
-
function
|
|
2555
|
+
function formatDuration2(ms) {
|
|
2322
2556
|
if (ms < 1e3) {
|
|
2323
2557
|
return `${ms}ms`;
|
|
2324
2558
|
}
|
|
@@ -2340,7 +2574,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2340
2574
|
}
|
|
2341
2575
|
});
|
|
2342
2576
|
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
2343
|
-
table.push(["Total Sync",
|
|
2577
|
+
table.push(["Total Sync", formatDuration2(totalDuration), ""]);
|
|
2344
2578
|
for (let i = 0; i < phaseResults.length; i++) {
|
|
2345
2579
|
const result = phaseResults[i];
|
|
2346
2580
|
const isLast = i === phaseResults.length - 1;
|
|
@@ -2348,14 +2582,14 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2348
2582
|
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
2349
2583
|
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
2350
2584
|
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
2351
|
-
table.push([name,
|
|
2585
|
+
table.push([name, formatDuration2(result.duration), efficiency]);
|
|
2352
2586
|
}
|
|
2353
2587
|
return table.toString();
|
|
2354
2588
|
}
|
|
2355
2589
|
|
|
2356
2590
|
// src/services/clone-sync.service.ts
|
|
2357
2591
|
import * as fs3 from "fs/promises";
|
|
2358
|
-
import * as
|
|
2592
|
+
import * as path5 from "path";
|
|
2359
2593
|
import simpleGit from "simple-git";
|
|
2360
2594
|
|
|
2361
2595
|
// src/utils/git-progress.ts
|
|
@@ -2384,7 +2618,7 @@ function makeGitProgressHandler(logger, emitProgress) {
|
|
|
2384
2618
|
|
|
2385
2619
|
// src/services/file-copy.service.ts
|
|
2386
2620
|
import * as fs2 from "fs/promises";
|
|
2387
|
-
import * as
|
|
2621
|
+
import * as path4 from "path";
|
|
2388
2622
|
import { glob } from "glob";
|
|
2389
2623
|
var DEFAULT_IGNORE_PATTERNS = [
|
|
2390
2624
|
"**/node_modules/**",
|
|
@@ -2411,8 +2645,8 @@ var FileCopyService = class {
|
|
|
2411
2645
|
}
|
|
2412
2646
|
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
2413
2647
|
for (const relativePath of filesToCopy) {
|
|
2414
|
-
const sourcePath =
|
|
2415
|
-
const destPath =
|
|
2648
|
+
const sourcePath = path4.join(sourceDir, relativePath);
|
|
2649
|
+
const destPath = path4.join(destDir, relativePath);
|
|
2416
2650
|
try {
|
|
2417
2651
|
const copied = await this.copyFile(sourcePath, destPath);
|
|
2418
2652
|
if (copied) {
|
|
@@ -2451,7 +2685,7 @@ var FileCopyService = class {
|
|
|
2451
2685
|
if (await fileExists(destPath)) {
|
|
2452
2686
|
return false;
|
|
2453
2687
|
}
|
|
2454
|
-
const destDir =
|
|
2688
|
+
const destDir = path4.dirname(destPath);
|
|
2455
2689
|
await fs2.mkdir(destDir, { recursive: true });
|
|
2456
2690
|
await fs2.copyFile(sourcePath, destPath);
|
|
2457
2691
|
return true;
|
|
@@ -2513,7 +2747,7 @@ var BranchCreatedActionsService = class {
|
|
|
2513
2747
|
function formatCloneSkipReason(reason) {
|
|
2514
2748
|
switch (reason.kind) {
|
|
2515
2749
|
case "branch_mismatch":
|
|
2516
|
-
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
|
|
2750
|
+
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start) \u2014 update 'branch' in the config or switch the clone back` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' \u2014 update 'branch' in the config or switch the clone back`;
|
|
2517
2751
|
case "head_unreadable":
|
|
2518
2752
|
return `could not read HEAD: ${reason.error}`;
|
|
2519
2753
|
case "dirty_tree":
|
|
@@ -2577,20 +2811,21 @@ var SyncOutcomeAccumulator = class {
|
|
|
2577
2811
|
constructor(options) {
|
|
2578
2812
|
this.options = options;
|
|
2579
2813
|
}
|
|
2814
|
+
options;
|
|
2580
2815
|
counts = cloneCounts(EMPTY_COUNTS);
|
|
2581
2816
|
actions = [];
|
|
2582
2817
|
add(action) {
|
|
2583
2818
|
this.actions.push(action);
|
|
2584
2819
|
this.counts[countKeyFor(action)]++;
|
|
2585
2820
|
}
|
|
2586
|
-
recordCreated(branch,
|
|
2587
|
-
this.add({ kind: "created", branch, path:
|
|
2821
|
+
recordCreated(branch, path24) {
|
|
2822
|
+
this.add({ kind: "created", branch, path: path24 });
|
|
2588
2823
|
}
|
|
2589
|
-
recordRemoved(branch,
|
|
2590
|
-
this.add({ kind: "removed", branch, path:
|
|
2824
|
+
recordRemoved(branch, path24, warning) {
|
|
2825
|
+
this.add({ kind: "removed", branch, path: path24, ...warning !== void 0 && { warning } });
|
|
2591
2826
|
}
|
|
2592
|
-
recordUpdated(branch,
|
|
2593
|
-
this.add({ kind: "updated", branch, path:
|
|
2827
|
+
recordUpdated(branch, path24, reason) {
|
|
2828
|
+
this.add({ kind: "updated", branch, path: path24, reason });
|
|
2594
2829
|
}
|
|
2595
2830
|
recordNoop(scope, reason, details) {
|
|
2596
2831
|
this.add({ kind: "noop", scope, reason, ...details });
|
|
@@ -2598,8 +2833,8 @@ var SyncOutcomeAccumulator = class {
|
|
|
2598
2833
|
recordSkipped(scope, reason, details) {
|
|
2599
2834
|
this.add({ kind: "skipped", scope, reason, ...details });
|
|
2600
2835
|
}
|
|
2601
|
-
recordPreservedDiverged(branch,
|
|
2602
|
-
this.add({ kind: "preserved-diverged", branch, path:
|
|
2836
|
+
recordPreservedDiverged(branch, path24, preservedPath) {
|
|
2837
|
+
this.add({ kind: "preserved-diverged", branch, path: path24, preservedPath });
|
|
2603
2838
|
}
|
|
2604
2839
|
recordFailed(scope, error, details = {}) {
|
|
2605
2840
|
this.add({ kind: "failed", scope, error, ...details });
|
|
@@ -2642,7 +2877,6 @@ function cloneSkipToOutcomeAction(reason, details = {}) {
|
|
|
2642
2877
|
}
|
|
2643
2878
|
|
|
2644
2879
|
// src/services/clone-sync.service.ts
|
|
2645
|
-
var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
2646
2880
|
var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
|
|
2647
2881
|
var CloneSyncService = class {
|
|
2648
2882
|
constructor(config, gitService, logger, options = {}) {
|
|
@@ -2653,6 +2887,9 @@ var CloneSyncService = class {
|
|
|
2653
2887
|
this.progressEmitter = options.progressEmitter;
|
|
2654
2888
|
this.onSkip = options.onSkip;
|
|
2655
2889
|
}
|
|
2890
|
+
config;
|
|
2891
|
+
gitService;
|
|
2892
|
+
logger;
|
|
2656
2893
|
initialized = false;
|
|
2657
2894
|
resolvedBranch = null;
|
|
2658
2895
|
branchCreatedActions;
|
|
@@ -2673,8 +2910,8 @@ var CloneSyncService = class {
|
|
|
2673
2910
|
this.pendingInitSkip = null;
|
|
2674
2911
|
}
|
|
2675
2912
|
async getWorktrees() {
|
|
2676
|
-
const worktreeDir =
|
|
2677
|
-
if (!await fileExists(
|
|
2913
|
+
const worktreeDir = path5.resolve(this.config.worktreeDir);
|
|
2914
|
+
if (!await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
|
|
2678
2915
|
return [];
|
|
2679
2916
|
}
|
|
2680
2917
|
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
@@ -2758,40 +2995,27 @@ var CloneSyncService = class {
|
|
|
2758
2995
|
return env;
|
|
2759
2996
|
}
|
|
2760
2997
|
buildCloneArgs(branch) {
|
|
2761
|
-
const args = ["--branch", branch, "--progress"];
|
|
2998
|
+
const args = ["--branch", branch, "--single-branch", "--no-tags", "--progress"];
|
|
2762
2999
|
if (this.config.depth !== void 0) {
|
|
2763
|
-
args.push("--depth", String(this.config.depth)
|
|
3000
|
+
args.push("--depth", String(this.config.depth));
|
|
2764
3001
|
}
|
|
2765
3002
|
return args;
|
|
2766
3003
|
}
|
|
2767
|
-
|
|
2768
|
-
|
|
3004
|
+
getBranchRefspec(branch) {
|
|
3005
|
+
return `+refs/heads/${branch}:refs/remotes/origin/${branch}`;
|
|
3006
|
+
}
|
|
3007
|
+
async buildFetchArgs(git, branch) {
|
|
3008
|
+
const args = ["origin", "--prune", "--no-tags", "--progress"];
|
|
2769
3009
|
if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
|
|
2770
3010
|
args.push("--depth", String(this.config.depth));
|
|
2771
3011
|
}
|
|
3012
|
+
args.push(this.getBranchRefspec(branch));
|
|
2772
3013
|
return args;
|
|
2773
3014
|
}
|
|
2774
|
-
async
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2779
|
-
} catch {
|
|
2780
|
-
fetchRefspecs = [];
|
|
2781
|
-
}
|
|
2782
|
-
if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
|
|
2783
|
-
const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
|
|
2784
|
-
this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
|
|
2785
|
-
await git.raw(["remote", "set-branches", "origin", "*"]);
|
|
2786
|
-
for (const refspec of customRefspecs) {
|
|
2787
|
-
await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
|
|
2788
|
-
}
|
|
2789
|
-
}
|
|
2790
|
-
isOriginRemoteBranchTrackingRefspec(refspec) {
|
|
2791
|
-
const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
|
|
2792
|
-
if (withoutForce.startsWith("^")) return false;
|
|
2793
|
-
const [source, destination] = withoutForce.split(":");
|
|
2794
|
-
return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
|
|
3015
|
+
async configureSingleBranchRemote(git, branch) {
|
|
3016
|
+
await git.raw(["config", "--replace-all", "remote.origin.fetch", this.getBranchRefspec(branch)]);
|
|
3017
|
+
await git.raw(["config", "--replace-all", "remote.origin.tagOpt", "--no-tags"]);
|
|
3018
|
+
await this.deleteStaleRemoteTrackingRefs(git, branch);
|
|
2795
3019
|
}
|
|
2796
3020
|
recordMissingRemoteRefSkip(branch) {
|
|
2797
3021
|
this.recordSkip(
|
|
@@ -2800,7 +3024,10 @@ var CloneSyncService = class {
|
|
|
2800
3024
|
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
2801
3025
|
);
|
|
2802
3026
|
}
|
|
2803
|
-
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
|
|
3027
|
+
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch, recordSkip = true) {
|
|
3028
|
+
const recordMissing = () => {
|
|
3029
|
+
if (recordSkip) this.recordMissingRemoteRefSkip(branch);
|
|
3030
|
+
};
|
|
2804
3031
|
try {
|
|
2805
3032
|
await git.fetch(fetchArgs);
|
|
2806
3033
|
return { skipped: false };
|
|
@@ -2817,14 +3044,14 @@ var CloneSyncService = class {
|
|
|
2817
3044
|
return { skipped: false };
|
|
2818
3045
|
} catch (retryError) {
|
|
2819
3046
|
if (isMissingRemoteRefError(getErrorMessage(retryError))) {
|
|
2820
|
-
|
|
3047
|
+
recordMissing();
|
|
2821
3048
|
return { skipped: true };
|
|
2822
3049
|
}
|
|
2823
3050
|
throw retryError;
|
|
2824
3051
|
}
|
|
2825
3052
|
}
|
|
2826
3053
|
if (isMissingRemoteRefError(message)) {
|
|
2827
|
-
|
|
3054
|
+
recordMissing();
|
|
2828
3055
|
return { skipped: true };
|
|
2829
3056
|
}
|
|
2830
3057
|
throw fetchError;
|
|
@@ -2852,7 +3079,7 @@ var CloneSyncService = class {
|
|
|
2852
3079
|
this.logger.info(
|
|
2853
3080
|
`[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
|
|
2854
3081
|
);
|
|
2855
|
-
await git.fetch(["--unshallow"]);
|
|
3082
|
+
await git.fetch(["--unshallow", "--no-tags"]);
|
|
2856
3083
|
}
|
|
2857
3084
|
getDeepenTargets() {
|
|
2858
3085
|
const configuredDepth = this.config.depth;
|
|
@@ -2872,8 +3099,9 @@ var CloneSyncService = class {
|
|
|
2872
3099
|
"--depth",
|
|
2873
3100
|
String(targetDepth),
|
|
2874
3101
|
"--prune",
|
|
3102
|
+
"--no-tags",
|
|
2875
3103
|
"--progress",
|
|
2876
|
-
|
|
3104
|
+
this.getBranchRefspec(branch)
|
|
2877
3105
|
]);
|
|
2878
3106
|
}
|
|
2879
3107
|
async resolveBranch() {
|
|
@@ -2890,6 +3118,153 @@ var CloneSyncService = class {
|
|
|
2890
3118
|
this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
|
|
2891
3119
|
return this.resolvedBranch;
|
|
2892
3120
|
}
|
|
3121
|
+
parseLsRemoteHeads(output) {
|
|
3122
|
+
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => line.split(/\s+/)[1] ?? "").filter((ref) => ref.startsWith("refs/heads/")).map((ref) => ref.slice("refs/heads/".length)).filter((branch) => branch.length > 0);
|
|
3123
|
+
}
|
|
3124
|
+
async getRemoteBranches() {
|
|
3125
|
+
const worktreeDir = path5.resolve(this.config.worktreeDir);
|
|
3126
|
+
const repoArg = await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR)) ? "origin" : this.config.repoUrl;
|
|
3127
|
+
const git = repoArg === "origin" ? this.clientFor(worktreeDir, this.getFetchTimeoutMs()) : simpleGit(this.buildGitOptions(this.getFetchTimeoutMs())).env(this.buildGitEnv());
|
|
3128
|
+
const output = await git.raw(["ls-remote", "--heads", repoArg]);
|
|
3129
|
+
return this.parseLsRemoteHeads(output);
|
|
3130
|
+
}
|
|
3131
|
+
async localBranchExists(git, branch) {
|
|
3132
|
+
try {
|
|
3133
|
+
await git.raw(["show-ref", "--verify", `refs/heads/${branch}`]);
|
|
3134
|
+
return true;
|
|
3135
|
+
} catch {
|
|
3136
|
+
return false;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
async localBranchCanFastForward(git, branch) {
|
|
3140
|
+
const localRef = `refs/heads/${branch}`;
|
|
3141
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
3142
|
+
let localSha;
|
|
3143
|
+
let remoteSha;
|
|
3144
|
+
try {
|
|
3145
|
+
localSha = (await git.raw(["rev-parse", localRef])).trim();
|
|
3146
|
+
remoteSha = (await git.raw(["rev-parse", remoteRef])).trim();
|
|
3147
|
+
} catch {
|
|
3148
|
+
return false;
|
|
3149
|
+
}
|
|
3150
|
+
if (localSha === remoteSha) return true;
|
|
3151
|
+
try {
|
|
3152
|
+
const mergeBase = (await git.raw(["merge-base", localRef, remoteRef])).trim();
|
|
3153
|
+
return mergeBase === localSha;
|
|
3154
|
+
} catch {
|
|
3155
|
+
return false;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
async deleteRemoteTrackingRef(git, refName) {
|
|
3159
|
+
try {
|
|
3160
|
+
await git.raw(["update-ref", "-d", refName]);
|
|
3161
|
+
} catch {
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
async deleteStaleRemoteTrackingRefs(git, branch) {
|
|
3165
|
+
let refsOutput;
|
|
3166
|
+
try {
|
|
3167
|
+
refsOutput = await git.raw(["for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
|
|
3168
|
+
} catch {
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
const keepRef = `refs/remotes/origin/${branch}`;
|
|
3172
|
+
const refsToDelete = refsOutput.split(/\r?\n/).map((ref) => ref.trim()).filter((ref) => ref && ref !== keepRef && ref !== "refs/remotes/origin/HEAD");
|
|
3173
|
+
for (const ref of refsToDelete) {
|
|
3174
|
+
await this.deleteRemoteTrackingRef(git, ref);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
async restoreBranchAfterCheckoutFailure(git, previousBranch, attemptedBranch) {
|
|
3178
|
+
if (!previousBranch || previousBranch === "HEAD" || previousBranch === attemptedBranch) return;
|
|
3179
|
+
try {
|
|
3180
|
+
await git.raw(["switch", previousBranch]);
|
|
3181
|
+
} catch (error) {
|
|
3182
|
+
this.logger.warn(
|
|
3183
|
+
`Failed to restore '${this.repoName}' to '${previousBranch}' after checkout failure: ${getErrorMessage(error)}`
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
async checkoutBranch(branch, options = {}) {
|
|
3188
|
+
if (!this.initialized) {
|
|
3189
|
+
await this.initialize();
|
|
3190
|
+
}
|
|
3191
|
+
const targetBranch = await this.resolveBranch();
|
|
3192
|
+
if (branch !== targetBranch && !options.allowConfigDrift) {
|
|
3193
|
+
throw new ConfigError(
|
|
3194
|
+
this.config.branch ? `Cannot switch '${this.repoName}' to '${branch}': clone mode tracks the configured branch '${targetBranch}'. Update 'branch' in the config file first, then run checkout to converge.` : `Cannot switch '${this.repoName}' to '${branch}': no 'branch' is configured, so this clone tracks the remote default branch '${targetBranch}'. Set branch: "${branch}" in the config file first.`,
|
|
3195
|
+
"CLONE_BRANCH_MISMATCH"
|
|
3196
|
+
);
|
|
3197
|
+
}
|
|
3198
|
+
const worktreeDir = this.config.worktreeDir;
|
|
3199
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
3200
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
3201
|
+
if (originMismatch) {
|
|
3202
|
+
throw new ConfigError(
|
|
3203
|
+
`Cannot switch '${this.repoName}' to '${branch}': ${originMismatch.progressDetail}.`,
|
|
3204
|
+
"ORIGIN_MISMATCH"
|
|
3205
|
+
);
|
|
3206
|
+
}
|
|
3207
|
+
const currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
3208
|
+
if (currentBranch === "HEAD") {
|
|
3209
|
+
throw new GitOperationError(
|
|
3210
|
+
"checkout",
|
|
3211
|
+
`'${this.repoName}' is on a detached HEAD; check out a branch manually (preserving any local commits) before switching the tracked branch`
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
if (currentBranch === branch) {
|
|
3215
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
3216
|
+
this.resolvedBranch = branch;
|
|
3217
|
+
this.pendingInitSkip = null;
|
|
3218
|
+
this.warnConfigDriftAfterCheckout(branch, targetBranch);
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
|
|
3222
|
+
if (!isClean) {
|
|
3223
|
+
throw new WorktreeNotCleanError(worktreeDir, ["working tree has local changes"]);
|
|
3224
|
+
}
|
|
3225
|
+
await this.unshallowIfDepthRemoved(git);
|
|
3226
|
+
const fetchArgs = await this.buildFetchArgs(git, branch);
|
|
3227
|
+
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch, false)).skipped) {
|
|
3228
|
+
throw new GitOperationError("checkout", `origin/${branch} is missing for '${this.repoName}'`);
|
|
3229
|
+
}
|
|
3230
|
+
if (!await this.hasRemoteBranch(git, branch)) {
|
|
3231
|
+
throw new GitOperationError(
|
|
3232
|
+
"checkout",
|
|
3233
|
+
`origin/${branch} did not materialize after fetch for '${this.repoName}'`
|
|
3234
|
+
);
|
|
3235
|
+
}
|
|
3236
|
+
if (await this.localBranchExists(git, branch)) {
|
|
3237
|
+
if (!await this.localBranchCanFastForward(git, branch)) {
|
|
3238
|
+
throw new FastForwardError(branch);
|
|
3239
|
+
}
|
|
3240
|
+
let switched = false;
|
|
3241
|
+
try {
|
|
3242
|
+
await git.raw(["switch", branch]);
|
|
3243
|
+
switched = true;
|
|
3244
|
+
await git.merge([`origin/${branch}`, "--ff-only"]);
|
|
3245
|
+
} catch (error) {
|
|
3246
|
+
if (switched) {
|
|
3247
|
+
await this.restoreBranchAfterCheckoutFailure(git, currentBranch, branch);
|
|
3248
|
+
}
|
|
3249
|
+
throw error;
|
|
3250
|
+
}
|
|
3251
|
+
} else {
|
|
3252
|
+
await git.raw(["switch", "-c", branch, "--track", `origin/${branch}`]);
|
|
3253
|
+
}
|
|
3254
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
3255
|
+
this.resolvedBranch = branch;
|
|
3256
|
+
this.pendingInitSkip = null;
|
|
3257
|
+
this.warnConfigDriftAfterCheckout(branch, targetBranch);
|
|
3258
|
+
}
|
|
3259
|
+
// resolvedBranch keeps in-session syncs on the new branch, but the config
|
|
3260
|
+
// file still names the old one: the next process start will soft-skip with
|
|
3261
|
+
// branch_mismatch on every tick until the config is updated.
|
|
3262
|
+
warnConfigDriftAfterCheckout(branch, targetBranch) {
|
|
3263
|
+
if (branch === targetBranch) return;
|
|
3264
|
+
this.logger.warn(
|
|
3265
|
+
`\u26A0\uFE0F '${this.repoName}' now tracks '${branch}', but the config ${this.config.branch ? `still says branch '${targetBranch}'` : `resolves the remote default '${targetBranch}'`}. Set branch: "${branch}" in the config file \u2014 after a restart every sync will soft-skip with branch_mismatch until it matches.`
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
2893
3268
|
async initialize(outcome) {
|
|
2894
3269
|
return this.withOutcome(outcome, () => this.initializeInternal());
|
|
2895
3270
|
}
|
|
@@ -2913,7 +3288,7 @@ var CloneSyncService = class {
|
|
|
2913
3288
|
return;
|
|
2914
3289
|
}
|
|
2915
3290
|
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2916
|
-
await this.
|
|
3291
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
2917
3292
|
this.initialized = true;
|
|
2918
3293
|
this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
|
|
2919
3294
|
return;
|
|
@@ -2941,7 +3316,7 @@ var CloneSyncService = class {
|
|
|
2941
3316
|
throw error;
|
|
2942
3317
|
}
|
|
2943
3318
|
const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2944
|
-
await this.
|
|
3319
|
+
await this.configureSingleBranchRemote(worktreeGit, branch);
|
|
2945
3320
|
this.logger.info(`\u2705 Clone successful.`);
|
|
2946
3321
|
this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
|
|
2947
3322
|
if (this.config.sparseCheckout) {
|
|
@@ -3029,7 +3404,7 @@ var CloneSyncService = class {
|
|
|
3029
3404
|
return;
|
|
3030
3405
|
}
|
|
3031
3406
|
const looksIncomplete = entries.every((e) => e.startsWith("."));
|
|
3032
|
-
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(
|
|
3407
|
+
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
|
|
3033
3408
|
if (looksIncomplete && !hasUsableGit) {
|
|
3034
3409
|
try {
|
|
3035
3410
|
await fs3.rm(worktreeDir, { recursive: true, force: true });
|
|
@@ -3044,7 +3419,7 @@ var CloneSyncService = class {
|
|
|
3044
3419
|
}
|
|
3045
3420
|
}
|
|
3046
3421
|
getInitMarkerPath(worktreeDir) {
|
|
3047
|
-
return
|
|
3422
|
+
return path5.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
|
|
3048
3423
|
}
|
|
3049
3424
|
async runInitialFileCopy(worktreeDir, branch) {
|
|
3050
3425
|
const marker = this.getInitMarkerPath(worktreeDir);
|
|
@@ -3096,7 +3471,7 @@ var CloneSyncService = class {
|
|
|
3096
3471
|
if (currentBranch !== branch) {
|
|
3097
3472
|
this.recordSkip(
|
|
3098
3473
|
{ kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
|
|
3099
|
-
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
|
|
3474
|
+
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge. Update 'branch' in the config or switch the clone back.`,
|
|
3100
3475
|
`Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
|
|
3101
3476
|
);
|
|
3102
3477
|
return;
|
|
@@ -3111,13 +3486,13 @@ var CloneSyncService = class {
|
|
|
3111
3486
|
return;
|
|
3112
3487
|
}
|
|
3113
3488
|
await this.unshallowIfDepthRemoved(git);
|
|
3114
|
-
await this.
|
|
3115
|
-
const fetchArgs = await this.buildFetchArgs(git);
|
|
3116
|
-
this.emitProgress({ phase: "fetch", message: `Fetching origin
|
|
3489
|
+
await this.configureSingleBranchRemote(git, branch);
|
|
3490
|
+
const fetchArgs = await this.buildFetchArgs(git, branch);
|
|
3491
|
+
this.emitProgress({ phase: "fetch", message: `Fetching origin/${branch} for '${this.repoName}'` });
|
|
3117
3492
|
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
|
|
3118
3493
|
return;
|
|
3119
3494
|
}
|
|
3120
|
-
this.emitProgress({ phase: "fetch", message: `Fetched origin
|
|
3495
|
+
this.emitProgress({ phase: "fetch", message: `Fetched origin/${branch} for '${this.repoName}'` });
|
|
3121
3496
|
if (!await this.hasRemoteBranch(git, branch)) {
|
|
3122
3497
|
this.recordSkip(
|
|
3123
3498
|
{ kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
|
|
@@ -3207,50 +3582,39 @@ var CloneSyncService = class {
|
|
|
3207
3582
|
}
|
|
3208
3583
|
};
|
|
3209
3584
|
|
|
3210
|
-
// src/services/git.service.ts
|
|
3211
|
-
import * as
|
|
3212
|
-
import * as
|
|
3213
|
-
import
|
|
3585
|
+
// src/services/git-maintenance.service.ts
|
|
3586
|
+
import * as fs5 from "fs/promises";
|
|
3587
|
+
import * as path6 from "path";
|
|
3588
|
+
import simpleGit2 from "simple-git";
|
|
3214
3589
|
|
|
3215
|
-
// src/utils/
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3590
|
+
// src/utils/atomic-write.ts
|
|
3591
|
+
import * as fs4 from "fs/promises";
|
|
3592
|
+
async function atomicWriteFile(filePath, content) {
|
|
3593
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3594
|
+
let renamed = false;
|
|
3595
|
+
try {
|
|
3596
|
+
const handle = await fs4.open(tmpPath, "w");
|
|
3597
|
+
try {
|
|
3598
|
+
await handle.writeFile(content, "utf-8");
|
|
3599
|
+
await handle.sync();
|
|
3600
|
+
} finally {
|
|
3601
|
+
await handle.close();
|
|
3223
3602
|
}
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
if (
|
|
3236
|
-
|
|
3237
|
-
current.path = line.substring("worktree ".length);
|
|
3238
|
-
} else if (line.startsWith("branch ")) {
|
|
3239
|
-
current.branch = line.substring("branch ".length).replace("refs/heads/", "");
|
|
3240
|
-
} else if (line.startsWith("HEAD ")) {
|
|
3241
|
-
current.head = line.substring("HEAD ".length);
|
|
3242
|
-
} else if (line === "detached") {
|
|
3243
|
-
current.detached = true;
|
|
3244
|
-
} else if (line === "prunable" || line.startsWith("prunable ")) {
|
|
3245
|
-
current.prunable = true;
|
|
3246
|
-
} else if (line === "locked" || line.startsWith("locked ")) {
|
|
3247
|
-
current.locked = true;
|
|
3248
|
-
} else if (line.trim() === "") {
|
|
3249
|
-
flush();
|
|
3603
|
+
try {
|
|
3604
|
+
await fs4.rename(tmpPath, filePath);
|
|
3605
|
+
renamed = true;
|
|
3606
|
+
} catch (err) {
|
|
3607
|
+
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
3608
|
+
await fs4.copyFile(tmpPath, filePath);
|
|
3609
|
+
} else {
|
|
3610
|
+
throw err;
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
} finally {
|
|
3614
|
+
if (!renamed) {
|
|
3615
|
+
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
3250
3616
|
}
|
|
3251
3617
|
}
|
|
3252
|
-
flush();
|
|
3253
|
-
return worktrees;
|
|
3254
3618
|
}
|
|
3255
3619
|
|
|
3256
3620
|
// src/services/logger.service.ts
|
|
@@ -3352,9 +3716,190 @@ function defaultConsoleOutput(msg, level) {
|
|
|
3352
3716
|
else console.log(msg);
|
|
3353
3717
|
}
|
|
3354
3718
|
|
|
3719
|
+
// src/services/git-maintenance.service.ts
|
|
3720
|
+
var GitMaintenanceService = class {
|
|
3721
|
+
constructor(config, gitService, logger, gitFactory = (cwd) => simpleGit2(cwd)) {
|
|
3722
|
+
this.config = config;
|
|
3723
|
+
this.gitService = gitService;
|
|
3724
|
+
this.logger = logger ?? Logger.createDefault();
|
|
3725
|
+
this.gitFactory = gitFactory;
|
|
3726
|
+
}
|
|
3727
|
+
config;
|
|
3728
|
+
gitService;
|
|
3729
|
+
logger;
|
|
3730
|
+
gitFactory;
|
|
3731
|
+
updateLogger(logger) {
|
|
3732
|
+
this.logger = logger;
|
|
3733
|
+
}
|
|
3734
|
+
isEnabled() {
|
|
3735
|
+
return this.config.maintenance?.enabled ?? DEFAULT_CONFIG.MAINTENANCE.ENABLED;
|
|
3736
|
+
}
|
|
3737
|
+
getIntervalMs() {
|
|
3738
|
+
const fallback = parseDuration(DEFAULT_CONFIG.MAINTENANCE.INTERVAL);
|
|
3739
|
+
const raw = this.config.maintenance?.interval;
|
|
3740
|
+
if (raw === void 0) {
|
|
3741
|
+
return fallback;
|
|
3742
|
+
}
|
|
3743
|
+
const parsed = parseDuration(raw);
|
|
3744
|
+
if (parsed === null || parsed <= 0) {
|
|
3745
|
+
this.logger.warn(`Invalid maintenance.interval '${raw}', using default ${DEFAULT_CONFIG.MAINTENANCE.INTERVAL}.`);
|
|
3746
|
+
return fallback;
|
|
3747
|
+
}
|
|
3748
|
+
return parsed;
|
|
3749
|
+
}
|
|
3750
|
+
resolveTarget() {
|
|
3751
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
3752
|
+
const cwd = path6.resolve(this.config.worktreeDir);
|
|
3753
|
+
return { cwd, gitDir: path6.join(cwd, PATH_CONSTANTS.GIT_DIR) };
|
|
3754
|
+
}
|
|
3755
|
+
const bare = this.gitService.getBareRepoPath();
|
|
3756
|
+
return { cwd: bare, gitDir: bare };
|
|
3757
|
+
}
|
|
3758
|
+
getStatePath(gitDir) {
|
|
3759
|
+
return path6.join(gitDir, MAINTENANCE_CONSTANTS.STATE_FILENAME);
|
|
3760
|
+
}
|
|
3761
|
+
async readState(statePath) {
|
|
3762
|
+
try {
|
|
3763
|
+
const parsed = JSON.parse(await fs5.readFile(statePath, "utf-8"));
|
|
3764
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
3765
|
+
return {};
|
|
3766
|
+
}
|
|
3767
|
+
return { ...parsed };
|
|
3768
|
+
} catch {
|
|
3769
|
+
return {};
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
async writeState(statePath, state) {
|
|
3773
|
+
try {
|
|
3774
|
+
await atomicWriteFile(statePath, JSON.stringify(state, null, 2));
|
|
3775
|
+
} catch (error) {
|
|
3776
|
+
this.logger.warn(`Failed to persist maintenance state: ${getErrorMessage(error)}`);
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
isDue(state, now) {
|
|
3780
|
+
if (!state.lastAttemptAt) {
|
|
3781
|
+
return true;
|
|
3782
|
+
}
|
|
3783
|
+
const last = new Date(state.lastAttemptAt).getTime();
|
|
3784
|
+
if (Number.isNaN(last)) {
|
|
3785
|
+
return true;
|
|
3786
|
+
}
|
|
3787
|
+
return now - last >= this.getIntervalMs();
|
|
3788
|
+
}
|
|
3789
|
+
/**
|
|
3790
|
+
* Run `git gc` if maintenance is enabled and due. MUST be called while the
|
|
3791
|
+
* repository operation lock is already held. Never throws: a gc failure is
|
|
3792
|
+
* recorded and warned so it cannot fail the surrounding sync. The attempt
|
|
3793
|
+
* timestamp is persisted even on failure, so a perpetually-failing gc is
|
|
3794
|
+
* throttled instead of retried every tick.
|
|
3795
|
+
*/
|
|
3796
|
+
async runIfDueUnlocked(now = Date.now()) {
|
|
3797
|
+
if (!this.isEnabled()) {
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
try {
|
|
3801
|
+
const { cwd, gitDir } = this.resolveTarget();
|
|
3802
|
+
try {
|
|
3803
|
+
await fs5.access(gitDir);
|
|
3804
|
+
} catch {
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
const statePath = this.getStatePath(gitDir);
|
|
3808
|
+
const state = await this.readState(statePath);
|
|
3809
|
+
if (!this.isDue(state, now)) {
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
const aggressive = this.config.maintenance?.aggressive ?? false;
|
|
3813
|
+
const args = aggressive ? ["gc", "--prune=now"] : ["gc"];
|
|
3814
|
+
const nowIso = new Date(now).toISOString();
|
|
3815
|
+
state.lastAttemptAt = nowIso;
|
|
3816
|
+
this.logger.info(`\u{1F9F9} Running git ${args.join(" ")} (maintenance)...`);
|
|
3817
|
+
try {
|
|
3818
|
+
await this.gitFactory(cwd).raw(args);
|
|
3819
|
+
state.lastSuccessAt = nowIso;
|
|
3820
|
+
delete state.lastError;
|
|
3821
|
+
this.logger.info("\u{1F9F9} Maintenance complete.");
|
|
3822
|
+
} catch (error) {
|
|
3823
|
+
state.lastFailureAt = nowIso;
|
|
3824
|
+
state.lastError = getErrorMessage(error);
|
|
3825
|
+
this.logger.warn(`\u26A0\uFE0F Maintenance (git ${args.join(" ")}) failed: ${state.lastError}`);
|
|
3826
|
+
} finally {
|
|
3827
|
+
await this.writeState(statePath, state);
|
|
3828
|
+
}
|
|
3829
|
+
} catch (error) {
|
|
3830
|
+
this.logger.warn(`\u26A0\uFE0F Maintenance skipped due to an unexpected error: ${getErrorMessage(error)}`);
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
|
|
3835
|
+
// src/services/git.service.ts
|
|
3836
|
+
import * as fs9 from "fs/promises";
|
|
3837
|
+
import * as path11 from "path";
|
|
3838
|
+
import simpleGit6 from "simple-git";
|
|
3839
|
+
|
|
3840
|
+
// src/utils/quarantine.ts
|
|
3841
|
+
import * as fs6 from "fs/promises";
|
|
3842
|
+
import * as path7 from "path";
|
|
3843
|
+
|
|
3844
|
+
// src/utils/filename-timestamp.ts
|
|
3845
|
+
function filenameTimestamp(date = /* @__PURE__ */ new Date()) {
|
|
3846
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
// src/utils/quarantine.ts
|
|
3850
|
+
async function quarantineDirectory(dirPath) {
|
|
3851
|
+
const baseDir = path7.join(path7.dirname(dirPath), GIT_CONSTANTS.REMOVED_DIR_NAME);
|
|
3852
|
+
await fs6.mkdir(baseDir, { recursive: true });
|
|
3853
|
+
const timestamp = filenameTimestamp();
|
|
3854
|
+
const quarantinePath = path7.join(baseDir, `${timestamp}-${path7.basename(dirPath)}`);
|
|
3855
|
+
await fs6.rename(dirPath, quarantinePath);
|
|
3856
|
+
return quarantinePath;
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
// src/utils/worktree-list-parser.ts
|
|
3860
|
+
function parseWorktreeListPorcelain(output) {
|
|
3861
|
+
const worktrees = [];
|
|
3862
|
+
let current = {};
|
|
3863
|
+
const flush = () => {
|
|
3864
|
+
if (!current.path) {
|
|
3865
|
+
current = {};
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
worktrees.push({
|
|
3869
|
+
path: current.path,
|
|
3870
|
+
branch: current.branch ?? null,
|
|
3871
|
+
head: current.head ?? null,
|
|
3872
|
+
detached: current.detached ?? false,
|
|
3873
|
+
prunable: current.prunable ?? false,
|
|
3874
|
+
locked: current.locked ?? false
|
|
3875
|
+
});
|
|
3876
|
+
current = {};
|
|
3877
|
+
};
|
|
3878
|
+
for (const line of output.split("\n")) {
|
|
3879
|
+
if (line.startsWith("worktree ")) {
|
|
3880
|
+
flush();
|
|
3881
|
+
current.path = line.substring("worktree ".length);
|
|
3882
|
+
} else if (line.startsWith("branch ")) {
|
|
3883
|
+
current.branch = line.substring("branch ".length).replace("refs/heads/", "");
|
|
3884
|
+
} else if (line.startsWith("HEAD ")) {
|
|
3885
|
+
current.head = line.substring("HEAD ".length);
|
|
3886
|
+
} else if (line === "detached") {
|
|
3887
|
+
current.detached = true;
|
|
3888
|
+
} else if (line === "prunable" || line.startsWith("prunable ")) {
|
|
3889
|
+
current.prunable = true;
|
|
3890
|
+
} else if (line === "locked" || line.startsWith("locked ")) {
|
|
3891
|
+
current.locked = true;
|
|
3892
|
+
} else if (line.trim() === "") {
|
|
3893
|
+
flush();
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
flush();
|
|
3897
|
+
return worktrees;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3355
3900
|
// src/services/sparse-checkout.service.ts
|
|
3356
|
-
import * as
|
|
3357
|
-
import
|
|
3901
|
+
import * as path8 from "path";
|
|
3902
|
+
import simpleGit3 from "simple-git";
|
|
3358
3903
|
var SparseCheckoutService = class {
|
|
3359
3904
|
logger;
|
|
3360
3905
|
gitFactory;
|
|
@@ -3362,7 +3907,7 @@ var SparseCheckoutService = class {
|
|
|
3362
3907
|
matcherCache = /* @__PURE__ */ new WeakMap();
|
|
3363
3908
|
constructor(logger, gitFactory) {
|
|
3364
3909
|
this.logger = logger ?? Logger.createDefault();
|
|
3365
|
-
this.gitFactory = gitFactory ?? ((p) =>
|
|
3910
|
+
this.gitFactory = gitFactory ?? ((p) => simpleGit3(p));
|
|
3366
3911
|
}
|
|
3367
3912
|
updateLogger(logger) {
|
|
3368
3913
|
this.logger = logger;
|
|
@@ -3488,7 +4033,7 @@ var SparseCheckoutService = class {
|
|
|
3488
4033
|
for (const pat of matcher.patterns) {
|
|
3489
4034
|
if (p === pat || p.startsWith(pat + "/")) return true;
|
|
3490
4035
|
}
|
|
3491
|
-
return matcher.ancestorDirs.has(
|
|
4036
|
+
return matcher.ancestorDirs.has(path8.posix.dirname(p));
|
|
3492
4037
|
});
|
|
3493
4038
|
}
|
|
3494
4039
|
getMatcher(cfg) {
|
|
@@ -3515,9 +4060,9 @@ var SparseCheckoutService = class {
|
|
|
3515
4060
|
};
|
|
3516
4061
|
|
|
3517
4062
|
// src/services/worktree-metadata.service.ts
|
|
3518
|
-
import * as
|
|
3519
|
-
import * as
|
|
3520
|
-
import
|
|
4063
|
+
import * as fs7 from "fs/promises";
|
|
4064
|
+
import * as path9 from "path";
|
|
4065
|
+
import simpleGit4 from "simple-git";
|
|
3521
4066
|
var WorktreeMetadataService = class {
|
|
3522
4067
|
logger;
|
|
3523
4068
|
constructor(logger) {
|
|
@@ -3529,7 +4074,7 @@ var WorktreeMetadataService = class {
|
|
|
3529
4074
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
3530
4075
|
*/
|
|
3531
4076
|
getWorktreeDirectoryName(worktreePath) {
|
|
3532
|
-
return
|
|
4077
|
+
return path9.basename(worktreePath);
|
|
3533
4078
|
}
|
|
3534
4079
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
3535
4080
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -3537,7 +4082,7 @@ var WorktreeMetadataService = class {
|
|
|
3537
4082
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
3538
4083
|
);
|
|
3539
4084
|
}
|
|
3540
|
-
return
|
|
4085
|
+
return path9.join(
|
|
3541
4086
|
bareRepoPath,
|
|
3542
4087
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
3543
4088
|
worktreeName,
|
|
@@ -3550,31 +4095,13 @@ var WorktreeMetadataService = class {
|
|
|
3550
4095
|
}
|
|
3551
4096
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
3552
4097
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
3553
|
-
await
|
|
3554
|
-
|
|
3555
|
-
let renamed = false;
|
|
3556
|
-
try {
|
|
3557
|
-
await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
3558
|
-
try {
|
|
3559
|
-
await fs4.rename(tmpPath, metadataPath);
|
|
3560
|
-
renamed = true;
|
|
3561
|
-
} catch (err) {
|
|
3562
|
-
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
3563
|
-
await fs4.copyFile(tmpPath, metadataPath);
|
|
3564
|
-
} else {
|
|
3565
|
-
throw err;
|
|
3566
|
-
}
|
|
3567
|
-
}
|
|
3568
|
-
} finally {
|
|
3569
|
-
if (!renamed) {
|
|
3570
|
-
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
3571
|
-
}
|
|
3572
|
-
}
|
|
4098
|
+
await fs7.mkdir(path9.dirname(metadataPath), { recursive: true });
|
|
4099
|
+
await atomicWriteFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
3573
4100
|
}
|
|
3574
4101
|
async loadMetadata(bareRepoPath, worktreeName) {
|
|
3575
4102
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
3576
4103
|
try {
|
|
3577
|
-
const content = await
|
|
4104
|
+
const content = await fs7.readFile(metadataPath, "utf-8");
|
|
3578
4105
|
return JSON.parse(content);
|
|
3579
4106
|
} catch {
|
|
3580
4107
|
return null;
|
|
@@ -3583,7 +4110,7 @@ var WorktreeMetadataService = class {
|
|
|
3583
4110
|
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
3584
4111
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
3585
4112
|
try {
|
|
3586
|
-
const content = await
|
|
4113
|
+
const content = await fs7.readFile(metadataPath, "utf-8");
|
|
3587
4114
|
const metadata = JSON.parse(content);
|
|
3588
4115
|
if (!await this.validateMetadata(metadata)) {
|
|
3589
4116
|
this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
@@ -3597,7 +4124,7 @@ var WorktreeMetadataService = class {
|
|
|
3597
4124
|
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
3598
4125
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
3599
4126
|
try {
|
|
3600
|
-
await
|
|
4127
|
+
await fs7.unlink(metadataPath);
|
|
3601
4128
|
} catch (error) {
|
|
3602
4129
|
if (error.code !== "ENOENT") {
|
|
3603
4130
|
throw error;
|
|
@@ -3607,7 +4134,7 @@ var WorktreeMetadataService = class {
|
|
|
3607
4134
|
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
3608
4135
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
3609
4136
|
try {
|
|
3610
|
-
await
|
|
4137
|
+
await fs7.unlink(metadataPath);
|
|
3611
4138
|
} catch (error) {
|
|
3612
4139
|
if (error.code !== "ENOENT") {
|
|
3613
4140
|
throw error;
|
|
@@ -3641,7 +4168,7 @@ var WorktreeMetadataService = class {
|
|
|
3641
4168
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
3642
4169
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
3643
4170
|
try {
|
|
3644
|
-
const worktreeGit =
|
|
4171
|
+
const worktreeGit = simpleGit4(worktreePath);
|
|
3645
4172
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
3646
4173
|
const branchSummary = await worktreeGit.branch();
|
|
3647
4174
|
const actualBranchName = branchSummary.current;
|
|
@@ -3688,6 +4215,25 @@ var WorktreeMetadataService = class {
|
|
|
3688
4215
|
}
|
|
3689
4216
|
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
3690
4217
|
}
|
|
4218
|
+
/**
|
|
4219
|
+
* Records the upstream tip observed during this sync. This is what later
|
|
4220
|
+
* proves "HEAD was fully pushed" after the remote branch is deleted, so it
|
|
4221
|
+
* must only ever be overwritten with a live observation — callers must not
|
|
4222
|
+
* invoke this once the upstream ref is gone.
|
|
4223
|
+
*/
|
|
4224
|
+
async recordRemoteTip(bareRepoPath, worktreePath, ref, oid) {
|
|
4225
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
4226
|
+
const existing = await this.loadMetadataFromPath(bareRepoPath, worktreePath);
|
|
4227
|
+
if (!existing) {
|
|
4228
|
+
this.logger.debug(`No metadata found for worktree ${worktreeDirName}; skipping remote tip recording`);
|
|
4229
|
+
return;
|
|
4230
|
+
}
|
|
4231
|
+
if (existing.lastKnownRemoteTip?.ref === ref && existing.lastKnownRemoteTip.oid === oid) {
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
existing.lastKnownRemoteTip = { ref, oid, recordedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4235
|
+
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
4236
|
+
}
|
|
3691
4237
|
async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
|
|
3692
4238
|
const metadata = {
|
|
3693
4239
|
lastSyncCommit: commit,
|
|
@@ -3742,9 +4288,9 @@ var WorktreeMetadataService = class {
|
|
|
3742
4288
|
};
|
|
3743
4289
|
|
|
3744
4290
|
// src/services/worktree-status.service.ts
|
|
3745
|
-
import * as
|
|
3746
|
-
import * as
|
|
3747
|
-
import
|
|
4291
|
+
import * as fs8 from "fs/promises";
|
|
4292
|
+
import * as path10 from "path";
|
|
4293
|
+
import simpleGit5 from "simple-git";
|
|
3748
4294
|
var OPERATION_FILES = [
|
|
3749
4295
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
3750
4296
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -3758,6 +4304,7 @@ var WorktreeStatusService = class {
|
|
|
3758
4304
|
this.config = config;
|
|
3759
4305
|
this.logger = logger ?? Logger.createDefault();
|
|
3760
4306
|
}
|
|
4307
|
+
config;
|
|
3761
4308
|
gitInstances = /* @__PURE__ */ new Map();
|
|
3762
4309
|
logger;
|
|
3763
4310
|
async checkWorktreeStatus(worktreePath) {
|
|
@@ -3774,8 +4321,9 @@ var WorktreeStatusService = class {
|
|
|
3774
4321
|
}
|
|
3775
4322
|
return true;
|
|
3776
4323
|
}
|
|
3777
|
-
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
3778
|
-
|
|
4324
|
+
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit, lastKnownRemoteTip) {
|
|
4325
|
+
const pathProbe = await probePathExists(worktreePath);
|
|
4326
|
+
if (pathProbe === "missing") {
|
|
3779
4327
|
return {
|
|
3780
4328
|
isClean: true,
|
|
3781
4329
|
hasUnpushedCommits: false,
|
|
@@ -3783,25 +4331,44 @@ var WorktreeStatusService = class {
|
|
|
3783
4331
|
hasOperationInProgress: false,
|
|
3784
4332
|
hasModifiedSubmodules: false,
|
|
3785
4333
|
upstreamGone: false,
|
|
4334
|
+
fullyPushedUpstreamDeleted: false,
|
|
3786
4335
|
canRemove: true,
|
|
3787
4336
|
reasons: []
|
|
3788
4337
|
};
|
|
3789
4338
|
}
|
|
3790
|
-
|
|
4339
|
+
if (pathProbe === "unknown") {
|
|
4340
|
+
return {
|
|
4341
|
+
isClean: false,
|
|
4342
|
+
hasUnpushedCommits: true,
|
|
4343
|
+
hasStashedChanges: true,
|
|
4344
|
+
hasOperationInProgress: true,
|
|
4345
|
+
hasModifiedSubmodules: true,
|
|
4346
|
+
upstreamGone: false,
|
|
4347
|
+
fullyPushedUpstreamDeleted: false,
|
|
4348
|
+
canRemove: false,
|
|
4349
|
+
reasons: ["cannot verify worktree path (filesystem probe failed)"]
|
|
4350
|
+
};
|
|
4351
|
+
}
|
|
4352
|
+
const snap = await this.collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip);
|
|
3791
4353
|
const isClean = this.deriveIsClean(snap);
|
|
3792
|
-
const
|
|
4354
|
+
const anyRemoteUnpushed = (snap.unpushedAnyRemoteCount ?? 1) > 0;
|
|
4355
|
+
const sinceSyncUnpushed = snap.sinceSyncChecked && (snap.sinceSyncCount ?? 1) > 0;
|
|
4356
|
+
const hasUnpushedCommits = !snap.detached && (anyRemoteUnpushed || sinceSyncUnpushed);
|
|
4357
|
+
const recordedRefGone = lastKnownRemoteTip !== void 0 && snap.remoteBranches.length > 0 && !snap.remoteBranches.includes(lastKnownRemoteTip.ref);
|
|
4358
|
+
const fullyPushedUpstreamDeleted = hasUnpushedCommits && recordedRefGone && snap.headPushedToRecordedTip === true;
|
|
3793
4359
|
const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
|
|
3794
|
-
const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
|
|
4360
|
+
const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null || snap.operationProbeUnknown;
|
|
3795
4361
|
const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
|
|
3796
4362
|
const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
|
|
3797
4363
|
const reasons = [];
|
|
3798
4364
|
if (!isClean) reasons.push("uncommitted changes");
|
|
3799
|
-
if (hasUnpushedCommits) reasons.push("unpushed commits");
|
|
4365
|
+
if (hasUnpushedCommits && !fullyPushedUpstreamDeleted) reasons.push("unpushed commits");
|
|
3800
4366
|
if (hasStashedChanges) reasons.push("stashed changes");
|
|
3801
4367
|
if (hasOperationInProgress) reasons.push("operation in progress");
|
|
3802
4368
|
if (hasModifiedSubmodules) reasons.push("modified submodules");
|
|
3803
4369
|
if (upstreamGone) reasons.push("upstream gone");
|
|
3804
|
-
|
|
4370
|
+
if (snap.detached) reasons.push("detached HEAD");
|
|
4371
|
+
const canRemove = isClean && (!hasUnpushedCommits || fullyPushedUpstreamDeleted) && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules && !snap.detached;
|
|
3805
4372
|
const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
|
|
3806
4373
|
return {
|
|
3807
4374
|
isClean,
|
|
@@ -3810,12 +4377,13 @@ var WorktreeStatusService = class {
|
|
|
3810
4377
|
hasOperationInProgress,
|
|
3811
4378
|
hasModifiedSubmodules,
|
|
3812
4379
|
upstreamGone,
|
|
4380
|
+
fullyPushedUpstreamDeleted,
|
|
3813
4381
|
canRemove,
|
|
3814
4382
|
reasons,
|
|
3815
4383
|
details
|
|
3816
4384
|
};
|
|
3817
4385
|
}
|
|
3818
|
-
async collectSnapshot(worktreePath, lastSyncCommit) {
|
|
4386
|
+
async collectSnapshot(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
|
|
3819
4387
|
const git = this.createGitInstance(worktreePath);
|
|
3820
4388
|
const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
|
|
3821
4389
|
git.status().catch((e) => {
|
|
@@ -3840,19 +4408,33 @@ var WorktreeStatusService = class {
|
|
|
3840
4408
|
const currentBranch = branchResult?.current ?? null;
|
|
3841
4409
|
const detached = !branchResult?.current || Boolean(branchResult?.detached);
|
|
3842
4410
|
let upstream = null;
|
|
3843
|
-
let
|
|
4411
|
+
let unpushedAnyRemoteCount = null;
|
|
4412
|
+
let sinceSyncCount = null;
|
|
4413
|
+
let headPushedToRecordedTip = null;
|
|
3844
4414
|
if (!detached && currentBranch) {
|
|
3845
|
-
const
|
|
3846
|
-
const [upstreamResult, unpushedResult] = await Promise.all([
|
|
4415
|
+
const [upstreamResult, anyRemoteResult, sinceSyncResult, recordedTipResult] = await Promise.all([
|
|
3847
4416
|
git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
|
|
3848
4417
|
(raw) => ({ ok: true, value: raw }),
|
|
3849
4418
|
(error) => ({ ok: false, error })
|
|
3850
4419
|
),
|
|
3851
|
-
git.raw(
|
|
4420
|
+
git.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]).then(
|
|
3852
4421
|
(raw) => ({ ok: true, value: raw }),
|
|
3853
4422
|
(error) => ({ ok: false, error })
|
|
3854
|
-
)
|
|
4423
|
+
),
|
|
4424
|
+
lastSyncCommit ? git.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]).then(
|
|
4425
|
+
(raw) => ({ ok: true, value: raw }),
|
|
4426
|
+
(error) => ({ ok: false, error })
|
|
4427
|
+
) : Promise.resolve(null),
|
|
4428
|
+
// Zero commits in <tip>..HEAD ⟺ HEAD is the recorded tip or behind it.
|
|
4429
|
+
// NOT merge-base --is-ancestor: simple-git resolves its silent exit-1
|
|
4430
|
+
// ("not an ancestor") as success because nothing is written to stderr.
|
|
4431
|
+
// Any failure (e.g. the recorded oid was gc'd) reads as "not proven".
|
|
4432
|
+
lastKnownRemoteTip ? git.raw(["rev-list", "--count", `${lastKnownRemoteTip.oid}..HEAD`]).then(
|
|
4433
|
+
(raw) => this.parseCount(raw) === 0,
|
|
4434
|
+
() => false
|
|
4435
|
+
) : Promise.resolve(null)
|
|
3855
4436
|
]);
|
|
4437
|
+
headPushedToRecordedTip = recordedTipResult;
|
|
3856
4438
|
if (upstreamResult.ok) {
|
|
3857
4439
|
upstream = upstreamResult.value.trim() || null;
|
|
3858
4440
|
} else {
|
|
@@ -3861,13 +4443,20 @@ var WorktreeStatusService = class {
|
|
|
3861
4443
|
this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
|
|
3862
4444
|
}
|
|
3863
4445
|
}
|
|
3864
|
-
if (
|
|
3865
|
-
|
|
4446
|
+
if (anyRemoteResult.ok) {
|
|
4447
|
+
unpushedAnyRemoteCount = this.parseCount(anyRemoteResult.value);
|
|
3866
4448
|
} else {
|
|
3867
|
-
this.logger.error(`Error checking unpushed commits`,
|
|
4449
|
+
this.logger.error(`Error checking unpushed commits`, anyRemoteResult.error);
|
|
4450
|
+
}
|
|
4451
|
+
if (sinceSyncResult) {
|
|
4452
|
+
if (sinceSyncResult.ok) {
|
|
4453
|
+
sinceSyncCount = this.parseCount(sinceSyncResult.value);
|
|
4454
|
+
} else {
|
|
4455
|
+
this.logger.error(`Error checking commits since last sync`, sinceSyncResult.error);
|
|
4456
|
+
}
|
|
3868
4457
|
}
|
|
3869
4458
|
}
|
|
3870
|
-
const
|
|
4459
|
+
const operationProbe = gitDirResult ? await this.detectOperationFile(gitDirResult) : { file: null, unknown: false };
|
|
3871
4460
|
let untrackedNotIgnored = [];
|
|
3872
4461
|
if (status && status.not_added.length > 0) {
|
|
3873
4462
|
try {
|
|
@@ -3883,14 +4472,22 @@ var WorktreeStatusService = class {
|
|
|
3883
4472
|
detached,
|
|
3884
4473
|
remoteBranches: remoteBranchesResult?.all ?? [],
|
|
3885
4474
|
upstream,
|
|
3886
|
-
|
|
4475
|
+
unpushedAnyRemoteCount,
|
|
4476
|
+
sinceSyncCount,
|
|
4477
|
+
sinceSyncChecked: lastSyncCommit !== void 0,
|
|
4478
|
+
headPushedToRecordedTip,
|
|
3887
4479
|
stashTotal: stashResult?.total ?? null,
|
|
3888
4480
|
submoduleStatus: submoduleResult,
|
|
3889
|
-
operationFile,
|
|
4481
|
+
operationFile: operationProbe.file,
|
|
4482
|
+
operationProbeUnknown: operationProbe.unknown,
|
|
3890
4483
|
gitDir: gitDirResult,
|
|
3891
4484
|
untrackedNotIgnored
|
|
3892
4485
|
};
|
|
3893
4486
|
}
|
|
4487
|
+
parseCount(raw) {
|
|
4488
|
+
const count = parseInt(raw.trim(), 10);
|
|
4489
|
+
return Number.isNaN(count) ? null : count;
|
|
4490
|
+
}
|
|
3894
4491
|
deriveIsClean(snap) {
|
|
3895
4492
|
const status = snap.status;
|
|
3896
4493
|
if (!status) return false;
|
|
@@ -3930,7 +4527,8 @@ var WorktreeStatusService = class {
|
|
|
3930
4527
|
if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
|
|
3931
4528
|
}
|
|
3932
4529
|
if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
|
|
3933
|
-
|
|
4530
|
+
const unpushedCount = snap.unpushedAnyRemoteCount ?? snap.sinceSyncCount;
|
|
4531
|
+
if (!snap.detached && unpushedCount !== null) details.unpushedCommitCount = unpushedCount;
|
|
3934
4532
|
if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
|
|
3935
4533
|
const opType = this.operationTypeFromFile(snap.operationFile);
|
|
3936
4534
|
if (opType) details.operationType = opType;
|
|
@@ -3945,34 +4543,37 @@ var WorktreeStatusService = class {
|
|
|
3945
4543
|
async detectOperationFile(gitDir) {
|
|
3946
4544
|
const results = await Promise.all(
|
|
3947
4545
|
OPERATION_FILES.map(
|
|
3948
|
-
({ file }) =>
|
|
3949
|
-
() =>
|
|
3950
|
-
() =>
|
|
4546
|
+
({ file }) => fs8.access(path10.join(gitDir, file)).then(
|
|
4547
|
+
() => "present",
|
|
4548
|
+
(error) => error.code === "ENOENT" ? "absent" : "unknown"
|
|
3951
4549
|
)
|
|
3952
4550
|
)
|
|
3953
4551
|
);
|
|
3954
|
-
const idx = results.findIndex(
|
|
3955
|
-
|
|
4552
|
+
const idx = results.findIndex((result) => result === "present");
|
|
4553
|
+
if (idx >= 0) return { file: OPERATION_FILES[idx].file, unknown: false };
|
|
4554
|
+
return { file: null, unknown: results.includes("unknown") };
|
|
3956
4555
|
}
|
|
3957
4556
|
async hasUnpushedCommits(worktreePath, lastSyncCommit) {
|
|
3958
4557
|
const worktreeGit = this.createGitInstance(worktreePath);
|
|
3959
4558
|
try {
|
|
3960
4559
|
if (await this.isDetachedHead(worktreeGit)) {
|
|
3961
|
-
return
|
|
4560
|
+
return true;
|
|
3962
4561
|
}
|
|
3963
4562
|
const branchSummary = await worktreeGit.branch();
|
|
3964
4563
|
const currentBranch = branchSummary.current;
|
|
4564
|
+
const anyRemoteResult = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
|
|
4565
|
+
const anyRemoteCount = this.parseCount(anyRemoteResult);
|
|
4566
|
+
if (anyRemoteCount === null || anyRemoteCount > 0) {
|
|
4567
|
+
return true;
|
|
4568
|
+
}
|
|
3965
4569
|
if (lastSyncCommit) {
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
return
|
|
3970
|
-
} catch {
|
|
4570
|
+
const sinceSyncResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
|
|
4571
|
+
const sinceSyncCount = this.parseCount(sinceSyncResult);
|
|
4572
|
+
if (sinceSyncCount === null || sinceSyncCount > 0) {
|
|
4573
|
+
return true;
|
|
3971
4574
|
}
|
|
3972
4575
|
}
|
|
3973
|
-
|
|
3974
|
-
const unpushedCount = parseInt(result.trim(), 10);
|
|
3975
|
-
return unpushedCount > 0;
|
|
4576
|
+
return false;
|
|
3976
4577
|
} catch (error) {
|
|
3977
4578
|
this.logger.error(`Error checking unpushed commits`, error);
|
|
3978
4579
|
return true;
|
|
@@ -4028,14 +4629,15 @@ var WorktreeStatusService = class {
|
|
|
4028
4629
|
async hasOperationInProgress(worktreePath) {
|
|
4029
4630
|
try {
|
|
4030
4631
|
const gitDir = await this.resolveGitDir(worktreePath);
|
|
4031
|
-
|
|
4632
|
+
const probe = await this.detectOperationFile(gitDir);
|
|
4633
|
+
return probe.unknown || probe.file !== null;
|
|
4032
4634
|
} catch (error) {
|
|
4033
4635
|
this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
|
|
4034
4636
|
return true;
|
|
4035
4637
|
}
|
|
4036
4638
|
}
|
|
4037
|
-
async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
|
|
4038
|
-
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
|
|
4639
|
+
async validateWorktreeForRemoval(worktreePath, lastSyncCommit, lastKnownRemoteTip) {
|
|
4640
|
+
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit, lastKnownRemoteTip);
|
|
4039
4641
|
if (!status.canRemove) {
|
|
4040
4642
|
throw new WorktreeNotCleanError(worktreePath, status.reasons);
|
|
4041
4643
|
}
|
|
@@ -4066,14 +4668,14 @@ var WorktreeStatusService = class {
|
|
|
4066
4668
|
}
|
|
4067
4669
|
}
|
|
4068
4670
|
async resolveGitDir(worktreePath) {
|
|
4069
|
-
const gitPath =
|
|
4671
|
+
const gitPath = path10.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
4070
4672
|
try {
|
|
4071
|
-
const stat3 = await
|
|
4673
|
+
const stat3 = await fs8.stat(gitPath);
|
|
4072
4674
|
if (stat3.isFile()) {
|
|
4073
|
-
const content = await
|
|
4675
|
+
const content = await fs8.readFile(gitPath, "utf-8");
|
|
4074
4676
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
4075
4677
|
if (gitdirMatch) {
|
|
4076
|
-
return
|
|
4678
|
+
return path10.resolve(worktreePath, gitdirMatch[1].trim());
|
|
4077
4679
|
}
|
|
4078
4680
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
4079
4681
|
}
|
|
@@ -4087,10 +4689,10 @@ var WorktreeStatusService = class {
|
|
|
4087
4689
|
}
|
|
4088
4690
|
}
|
|
4089
4691
|
createGitInstance(worktreePath) {
|
|
4090
|
-
const key = `${
|
|
4692
|
+
const key = `${path10.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
4091
4693
|
let git = this.gitInstances.get(key);
|
|
4092
4694
|
if (!git) {
|
|
4093
|
-
git = this.config.skipLfs ?
|
|
4695
|
+
git = this.config.skipLfs ? simpleGit5(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit5(worktreePath);
|
|
4094
4696
|
this.gitInstances.set(key, git);
|
|
4095
4697
|
}
|
|
4096
4698
|
return git;
|
|
@@ -4111,11 +4713,13 @@ var GitService = class {
|
|
|
4111
4713
|
this.progressEmitter = progressEmitter;
|
|
4112
4714
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
4113
4715
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
4114
|
-
this.mainWorktreePath =
|
|
4716
|
+
this.mainWorktreePath = path11.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
4115
4717
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
4116
4718
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
4117
4719
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
4118
4720
|
}
|
|
4721
|
+
config;
|
|
4722
|
+
progressEmitter;
|
|
4119
4723
|
git = null;
|
|
4120
4724
|
bareRepoPath;
|
|
4121
4725
|
mainWorktreePath;
|
|
@@ -4139,10 +4743,10 @@ var GitService = class {
|
|
|
4139
4743
|
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
4140
4744
|
}
|
|
4141
4745
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
4142
|
-
const key = `${
|
|
4746
|
+
const key = `${path11.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
4143
4747
|
let git = this.gitInstances.get(key);
|
|
4144
4748
|
if (!git) {
|
|
4145
|
-
const base =
|
|
4749
|
+
const base = simpleGit6(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
4146
4750
|
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
4147
4751
|
this.gitInstances.set(key, git);
|
|
4148
4752
|
}
|
|
@@ -4162,11 +4766,11 @@ var GitService = class {
|
|
|
4162
4766
|
async initialize() {
|
|
4163
4767
|
const { repoUrl } = this.config;
|
|
4164
4768
|
try {
|
|
4165
|
-
await
|
|
4769
|
+
await fs9.access(path11.join(this.bareRepoPath, "HEAD"));
|
|
4166
4770
|
} catch {
|
|
4167
4771
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
4168
|
-
await
|
|
4169
|
-
const cloneBase =
|
|
4772
|
+
await fs9.mkdir(path11.dirname(this.bareRepoPath), { recursive: true });
|
|
4773
|
+
const cloneBase = simpleGit6(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
|
|
4170
4774
|
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
4171
4775
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
|
|
4172
4776
|
this.logger.info("\u2705 Clone successful.");
|
|
@@ -4184,17 +4788,17 @@ var GitService = class {
|
|
|
4184
4788
|
this.logger.info("Fetching remote branches...");
|
|
4185
4789
|
await bareGit.fetch(["--all", "--progress"]);
|
|
4186
4790
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
4187
|
-
this.mainWorktreePath =
|
|
4791
|
+
this.mainWorktreePath = path11.join(this.config.worktreeDir, this.defaultBranch);
|
|
4188
4792
|
let needsMainWorktree = true;
|
|
4189
4793
|
try {
|
|
4190
4794
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4191
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
4795
|
+
needsMainWorktree = !worktrees.some((w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath));
|
|
4192
4796
|
} catch {
|
|
4193
4797
|
}
|
|
4194
4798
|
if (needsMainWorktree) {
|
|
4195
4799
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
4196
|
-
await
|
|
4197
|
-
const absoluteWorktreePath =
|
|
4800
|
+
await fs9.mkdir(this.config.worktreeDir, { recursive: true });
|
|
4801
|
+
const absoluteWorktreePath = path11.resolve(this.mainWorktreePath);
|
|
4198
4802
|
const branches = await bareGit.branch();
|
|
4199
4803
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
4200
4804
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -4230,7 +4834,7 @@ var GitService = class {
|
|
|
4230
4834
|
}
|
|
4231
4835
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
4232
4836
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
4233
|
-
(w) =>
|
|
4837
|
+
(w) => path11.resolve(w.path) === path11.resolve(this.mainWorktreePath)
|
|
4234
4838
|
);
|
|
4235
4839
|
if (!mainWorktreeRegistered) {
|
|
4236
4840
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -4257,7 +4861,7 @@ var GitService = class {
|
|
|
4257
4861
|
return this.bareRepoPath;
|
|
4258
4862
|
}
|
|
4259
4863
|
async getRemoteDefaultBranch(repoUrl) {
|
|
4260
|
-
const git =
|
|
4864
|
+
const git = simpleGit6(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
4261
4865
|
try {
|
|
4262
4866
|
const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
|
|
4263
4867
|
const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
|
|
@@ -4341,7 +4945,7 @@ var GitService = class {
|
|
|
4341
4945
|
return branches;
|
|
4342
4946
|
}
|
|
4343
4947
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
4344
|
-
const worktreeGit = this.config.sparseCheckout ?
|
|
4948
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit6(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
4345
4949
|
try {
|
|
4346
4950
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
4347
4951
|
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
@@ -4352,7 +4956,7 @@ var GitService = class {
|
|
|
4352
4956
|
const existence = await Promise.all(
|
|
4353
4957
|
lfsFileList.map(async (f) => {
|
|
4354
4958
|
try {
|
|
4355
|
-
await
|
|
4959
|
+
await fs9.access(path11.join(worktreePath, f));
|
|
4356
4960
|
return f;
|
|
4357
4961
|
} catch {
|
|
4358
4962
|
return null;
|
|
@@ -4380,9 +4984,9 @@ var GitService = class {
|
|
|
4380
4984
|
let allDownloaded = true;
|
|
4381
4985
|
const notDownloaded = [];
|
|
4382
4986
|
for (const file of samplesToCheck) {
|
|
4383
|
-
const filePath =
|
|
4987
|
+
const filePath = path11.join(worktreePath, file);
|
|
4384
4988
|
try {
|
|
4385
|
-
const handle = await
|
|
4989
|
+
const handle = await fs9.open(filePath, "r");
|
|
4386
4990
|
try {
|
|
4387
4991
|
const buffer = Buffer.alloc(200);
|
|
4388
4992
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
@@ -4407,7 +5011,7 @@ var GitService = class {
|
|
|
4407
5011
|
}
|
|
4408
5012
|
retries++;
|
|
4409
5013
|
if (retries < maxRetries) {
|
|
4410
|
-
await new Promise((
|
|
5014
|
+
await new Promise((resolve14) => setTimeout(resolve14, retryDelay));
|
|
4411
5015
|
}
|
|
4412
5016
|
}
|
|
4413
5017
|
this.logger.warn(
|
|
@@ -4469,20 +5073,23 @@ var GitService = class {
|
|
|
4469
5073
|
}
|
|
4470
5074
|
async addWorktree(branchName, worktreePath) {
|
|
4471
5075
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
4472
|
-
const absoluteWorktreePath =
|
|
4473
|
-
await
|
|
5076
|
+
const absoluteWorktreePath = path11.resolve(worktreePath);
|
|
5077
|
+
await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
|
|
4474
5078
|
try {
|
|
4475
|
-
await
|
|
5079
|
+
await fs9.access(absoluteWorktreePath);
|
|
4476
5080
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4477
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
5081
|
+
const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4478
5082
|
if (isValidWorktree) {
|
|
4479
5083
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
4480
5084
|
return;
|
|
4481
5085
|
} else {
|
|
4482
5086
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
4483
|
-
await
|
|
5087
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
5088
|
+
}
|
|
5089
|
+
} catch (error) {
|
|
5090
|
+
if (error instanceof GitOperationError || error instanceof WorktreeError) {
|
|
5091
|
+
throw error;
|
|
4484
5092
|
}
|
|
4485
|
-
} catch {
|
|
4486
5093
|
}
|
|
4487
5094
|
let createdNewBranch = false;
|
|
4488
5095
|
try {
|
|
@@ -4519,17 +5126,14 @@ var GitService = class {
|
|
|
4519
5126
|
}
|
|
4520
5127
|
if (errorMessage.includes("already registered worktree")) {
|
|
4521
5128
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4522
|
-
const existingWorktree = worktrees.find((w) =>
|
|
5129
|
+
const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4523
5130
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
4524
5131
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
4525
5132
|
return;
|
|
4526
5133
|
}
|
|
4527
5134
|
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
4528
5135
|
await bareGit.raw(["worktree", "prune"]);
|
|
4529
|
-
|
|
4530
|
-
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
4531
|
-
} catch {
|
|
4532
|
-
}
|
|
5136
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
4533
5137
|
let retryCreatedNewBranch = false;
|
|
4534
5138
|
try {
|
|
4535
5139
|
const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
|
|
@@ -4563,17 +5167,20 @@ var GitService = class {
|
|
|
4563
5167
|
}
|
|
4564
5168
|
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
4565
5169
|
try {
|
|
4566
|
-
await
|
|
5170
|
+
await fs9.access(absoluteWorktreePath);
|
|
4567
5171
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4568
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
5172
|
+
const isValidWorktree = worktrees.some((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4569
5173
|
if (isValidWorktree) {
|
|
4570
5174
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
4571
5175
|
return;
|
|
4572
5176
|
} else {
|
|
4573
5177
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
4574
|
-
await
|
|
5178
|
+
await this.clearStaleWorktreeDirectory(absoluteWorktreePath);
|
|
5179
|
+
}
|
|
5180
|
+
} catch (error2) {
|
|
5181
|
+
if (error2 instanceof GitOperationError || error2 instanceof WorktreeError) {
|
|
5182
|
+
throw error2;
|
|
4575
5183
|
}
|
|
4576
|
-
} catch {
|
|
4577
5184
|
}
|
|
4578
5185
|
try {
|
|
4579
5186
|
const useNoCheckout = !!this.config.sparseCheckout;
|
|
@@ -4595,7 +5202,7 @@ var GitService = class {
|
|
|
4595
5202
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
4596
5203
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
4597
5204
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
4598
|
-
const existingWorktree = worktrees.find((w) =>
|
|
5205
|
+
const existingWorktree = worktrees.find((w) => path11.resolve(w.path) === absoluteWorktreePath);
|
|
4599
5206
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
4600
5207
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
4601
5208
|
return;
|
|
@@ -4664,9 +5271,19 @@ var GitService = class {
|
|
|
4664
5271
|
wrapped.isUpstreamSetupFailure = true;
|
|
4665
5272
|
return wrapped;
|
|
4666
5273
|
}
|
|
4667
|
-
async removeWorktree(worktreePath) {
|
|
5274
|
+
async removeWorktree(worktreePath, options) {
|
|
4668
5275
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
4669
|
-
|
|
5276
|
+
const args = ["worktree", "remove", worktreePath];
|
|
5277
|
+
if (options?.force) args.push("--force");
|
|
5278
|
+
try {
|
|
5279
|
+
await bareGit.raw(args);
|
|
5280
|
+
} catch (error) {
|
|
5281
|
+
const message = getErrorMessage(error);
|
|
5282
|
+
if (!options?.force && /contains modified or untracked files|use --force/i.test(message)) {
|
|
5283
|
+
throw new WorktreeNotCleanError(worktreePath, [`git refused removal: ${message}`]);
|
|
5284
|
+
}
|
|
5285
|
+
throw error;
|
|
5286
|
+
}
|
|
4670
5287
|
this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
|
|
4671
5288
|
try {
|
|
4672
5289
|
await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
@@ -4679,6 +5296,111 @@ var GitService = class {
|
|
|
4679
5296
|
await bareGit.raw(["worktree", "prune"]);
|
|
4680
5297
|
this.logger.info("Pruned worktree metadata.");
|
|
4681
5298
|
}
|
|
5299
|
+
async updateRef(refName, sha) {
|
|
5300
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5301
|
+
await bareGit.raw(["update-ref", refName, sha]);
|
|
5302
|
+
}
|
|
5303
|
+
async deleteRef(refName) {
|
|
5304
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5305
|
+
await bareGit.raw(["update-ref", "-d", refName]);
|
|
5306
|
+
}
|
|
5307
|
+
async listRefs(prefix) {
|
|
5308
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5309
|
+
const raw = await bareGit.raw(["for-each-ref", "--format=%(refname)", prefix]);
|
|
5310
|
+
return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
5311
|
+
}
|
|
5312
|
+
async localBranchExists(branchName) {
|
|
5313
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5314
|
+
try {
|
|
5315
|
+
await bareGit.raw(["show-ref", "--verify", "--quiet", `${GIT_CONSTANTS.REFS.HEADS}${branchName}`]);
|
|
5316
|
+
return true;
|
|
5317
|
+
} catch {
|
|
5318
|
+
return false;
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
async getLocalBranchCommit(branchName) {
|
|
5322
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5323
|
+
try {
|
|
5324
|
+
return (await bareGit.raw(["rev-parse", `${GIT_CONSTANTS.REFS.HEADS}${branchName}^{commit}`])).trim();
|
|
5325
|
+
} catch {
|
|
5326
|
+
return null;
|
|
5327
|
+
}
|
|
5328
|
+
}
|
|
5329
|
+
async createBranchAt(branchName, sha) {
|
|
5330
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5331
|
+
await bareGit.raw(["branch", branchName, sha]);
|
|
5332
|
+
}
|
|
5333
|
+
async deleteLocalBranch(branchName) {
|
|
5334
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5335
|
+
await bareGit.raw(["branch", "-D", branchName]);
|
|
5336
|
+
}
|
|
5337
|
+
// Bundles only commits not reachable from any remote — for fully-pushed
|
|
5338
|
+
// refs that set is empty and `bundle create` would fail. Emptiness is
|
|
5339
|
+
// pre-checked with rev-list (locale-independent) instead of parsing git's
|
|
5340
|
+
// localized "empty bundle" stderr; after the pre-check, any bundle-create
|
|
5341
|
+
// error is a real failure the caller must treat as fail-closed.
|
|
5342
|
+
async createBundleFromRef(bundlePath, refName) {
|
|
5343
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5344
|
+
const count = (await bareGit.raw(["rev-list", "--count", refName, "--not", "--remotes"])).trim();
|
|
5345
|
+
if (count === "0") {
|
|
5346
|
+
return false;
|
|
5347
|
+
}
|
|
5348
|
+
await bareGit.raw(["bundle", "create", bundlePath, refName, "--not", "--remotes"]);
|
|
5349
|
+
return true;
|
|
5350
|
+
}
|
|
5351
|
+
// Registers the worktree and writes its .git link without populating files —
|
|
5352
|
+
// restore overlays the preserved payload instead of a fresh checkout.
|
|
5353
|
+
async addWorktreeNoCheckout(branchName, worktreePath) {
|
|
5354
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
5355
|
+
const absoluteWorktreePath = path11.resolve(worktreePath);
|
|
5356
|
+
await fs9.mkdir(path11.dirname(absoluteWorktreePath), { recursive: true });
|
|
5357
|
+
await bareGit.raw(["worktree", "add", "--no-checkout", absoluteWorktreePath, branchName]);
|
|
5358
|
+
}
|
|
5359
|
+
// Mixed reset: points the index at HEAD without touching working files, so
|
|
5360
|
+
// overlaid payload content shows up as ordinary uncommitted changes.
|
|
5361
|
+
async resetWorktreeIndex(worktreePath) {
|
|
5362
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
5363
|
+
await worktreeGit.raw(["reset"]);
|
|
5364
|
+
}
|
|
5365
|
+
// Injected by WorktreeSyncService when trash is enabled, so stale-directory
|
|
5366
|
+
// cleanup follows the same reversible-removal pipeline as everything else.
|
|
5367
|
+
// GitService cannot own a TrashService directly (TrashService depends on it).
|
|
5368
|
+
staleDirectoryTrasher = null;
|
|
5369
|
+
setStaleDirectoryTrasher(trasher) {
|
|
5370
|
+
this.staleDirectoryTrasher = trasher;
|
|
5371
|
+
}
|
|
5372
|
+
// A stale directory that contains a .git may be a live checkout that git
|
|
5373
|
+
// failed to report; quarantine it instead of deleting.
|
|
5374
|
+
async clearStaleWorktreeDirectory(absoluteWorktreePath) {
|
|
5375
|
+
const gitProbe = await probePathExists(path11.join(absoluteWorktreePath, PATH_CONSTANTS.GIT_DIR));
|
|
5376
|
+
if (gitProbe === "unknown") {
|
|
5377
|
+
throw new GitOperationError(
|
|
5378
|
+
"clear-stale-directory",
|
|
5379
|
+
`Cannot verify whether '${absoluteWorktreePath}' is a live checkout; refusing to clear it`
|
|
5380
|
+
);
|
|
5381
|
+
}
|
|
5382
|
+
if (this.staleDirectoryTrasher) {
|
|
5383
|
+
try {
|
|
5384
|
+
const trashPath = await this.staleDirectoryTrasher(absoluteWorktreePath);
|
|
5385
|
+
this.logger.info(` - Moved stale directory at '${absoluteWorktreePath}' to trash ('${trashPath}')`);
|
|
5386
|
+
return;
|
|
5387
|
+
} catch (error) {
|
|
5388
|
+
throw new GitOperationError(
|
|
5389
|
+
"clear-stale-directory",
|
|
5390
|
+
`Cannot move stale directory '${absoluteWorktreePath}' to trash: ${getErrorMessage(error)}`,
|
|
5391
|
+
error instanceof Error ? error : void 0
|
|
5392
|
+
);
|
|
5393
|
+
}
|
|
5394
|
+
}
|
|
5395
|
+
if (gitProbe === "exists") {
|
|
5396
|
+
const quarantinePath = await quarantineDirectory(absoluteWorktreePath);
|
|
5397
|
+
this.logger.warn(
|
|
5398
|
+
` - \u26A0\uFE0F Directory at '${absoluteWorktreePath}' contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
|
|
5399
|
+
);
|
|
5400
|
+
return;
|
|
5401
|
+
}
|
|
5402
|
+
await fs9.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
5403
|
+
}
|
|
4682
5404
|
async checkWorktreeStatus(worktreePath) {
|
|
4683
5405
|
return this.statusService.checkWorktreeStatus(worktreePath);
|
|
4684
5406
|
}
|
|
@@ -4694,7 +5416,37 @@ var GitService = class {
|
|
|
4694
5416
|
}
|
|
4695
5417
|
async getFullWorktreeStatus(worktreePath, includeDetails = false) {
|
|
4696
5418
|
const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
4697
|
-
return this.statusService.getFullWorktreeStatus(
|
|
5419
|
+
return this.statusService.getFullWorktreeStatus(
|
|
5420
|
+
worktreePath,
|
|
5421
|
+
includeDetails,
|
|
5422
|
+
metadata?.lastSyncCommit,
|
|
5423
|
+
metadata?.lastKnownRemoteTip
|
|
5424
|
+
);
|
|
5425
|
+
}
|
|
5426
|
+
/** Map of remote branch name (without "origin/") → tip oid, from the bare repo. */
|
|
5427
|
+
async getRemoteBranchTips() {
|
|
5428
|
+
const git = this.getGit();
|
|
5429
|
+
const raw = await git.raw(["for-each-ref", "--format=%(refname:short) %(objectname)", GIT_CONSTANTS.REFS.REMOTES]);
|
|
5430
|
+
const tips = /* @__PURE__ */ new Map();
|
|
5431
|
+
for (const line of raw.split("\n")) {
|
|
5432
|
+
const trimmed = line.trim();
|
|
5433
|
+
if (!trimmed) continue;
|
|
5434
|
+
const spaceIdx = trimmed.lastIndexOf(" ");
|
|
5435
|
+
if (spaceIdx <= 0) continue;
|
|
5436
|
+
const ref = trimmed.slice(0, spaceIdx);
|
|
5437
|
+
const oid = trimmed.slice(spaceIdx + 1);
|
|
5438
|
+
if (!ref.startsWith(GIT_CONSTANTS.REMOTE_PREFIX) || ref === `${GIT_CONSTANTS.REMOTE_PREFIX}HEAD`) continue;
|
|
5439
|
+
tips.set(ref.slice(GIT_CONSTANTS.REMOTE_PREFIX.length), oid);
|
|
5440
|
+
}
|
|
5441
|
+
return tips;
|
|
5442
|
+
}
|
|
5443
|
+
async recordRemoteTip(worktreePath, branchName, oid) {
|
|
5444
|
+
await this.metadataService.recordRemoteTip(
|
|
5445
|
+
this.bareRepoPath,
|
|
5446
|
+
worktreePath,
|
|
5447
|
+
`${GIT_CONSTANTS.REMOTE_PREFIX}${branchName}`,
|
|
5448
|
+
oid
|
|
5449
|
+
);
|
|
4698
5450
|
}
|
|
4699
5451
|
async hasModifiedSubmodules(worktreePath) {
|
|
4700
5452
|
return this.statusService.hasModifiedSubmodules(worktreePath);
|
|
@@ -4979,37 +5731,41 @@ var ProgressEmitter = class {
|
|
|
4979
5731
|
}
|
|
4980
5732
|
};
|
|
4981
5733
|
|
|
5734
|
+
// src/services/removal-audit.service.ts
|
|
5735
|
+
import * as fs10 from "fs/promises";
|
|
5736
|
+
import * as path12 from "path";
|
|
5737
|
+
var RemovalAuditService = class {
|
|
5738
|
+
constructor(logFilePath) {
|
|
5739
|
+
this.logFilePath = logFilePath;
|
|
5740
|
+
}
|
|
5741
|
+
logFilePath;
|
|
5742
|
+
async record(entry) {
|
|
5743
|
+
await fs10.mkdir(path12.dirname(this.logFilePath), { recursive: true });
|
|
5744
|
+
const line = JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
5745
|
+
const handle = await fs10.open(this.logFilePath, "a");
|
|
5746
|
+
try {
|
|
5747
|
+
await handle.appendFile(`${line}
|
|
5748
|
+
`, "utf-8");
|
|
5749
|
+
await handle.sync();
|
|
5750
|
+
} finally {
|
|
5751
|
+
await handle.close();
|
|
5752
|
+
}
|
|
5753
|
+
}
|
|
5754
|
+
};
|
|
5755
|
+
|
|
4982
5756
|
// src/services/repo-operation-lock.ts
|
|
4983
|
-
import * as
|
|
4984
|
-
import * as
|
|
5757
|
+
import * as fs11 from "fs/promises";
|
|
5758
|
+
import * as path13 from "path";
|
|
4985
5759
|
import * as lockfile from "proper-lockfile";
|
|
4986
|
-
|
|
4987
|
-
// src/utils/lock-path.ts
|
|
4988
|
-
import { createHash } from "crypto";
|
|
4989
|
-
import * as os from "os";
|
|
4990
|
-
import * as path9 from "path";
|
|
4991
|
-
function getCloneModeLockTarget(config) {
|
|
4992
|
-
const name = config.name;
|
|
4993
|
-
const configDir = config.__configFileDir;
|
|
4994
|
-
const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
4995
|
-
if (configDir) {
|
|
4996
|
-
return {
|
|
4997
|
-
dir: path9.join(configDir, ".sync-worktrees-state"),
|
|
4998
|
-
file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
|
|
4999
|
-
};
|
|
5000
|
-
}
|
|
5001
|
-
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
|
|
5002
|
-
const dir = path9.join(stateBase, "sync-worktrees", "locks");
|
|
5003
|
-
return { dir, file: `${hash}.lock` };
|
|
5004
|
-
}
|
|
5005
|
-
|
|
5006
|
-
// src/services/repo-operation-lock.ts
|
|
5007
5760
|
var RepoOperationLock = class {
|
|
5008
5761
|
constructor(config, gitService, logger = Logger.createDefault()) {
|
|
5009
5762
|
this.config = config;
|
|
5010
5763
|
this.gitService = gitService;
|
|
5011
5764
|
this.logger = logger;
|
|
5012
5765
|
}
|
|
5766
|
+
config;
|
|
5767
|
+
gitService;
|
|
5768
|
+
logger;
|
|
5013
5769
|
updateLogger(logger) {
|
|
5014
5770
|
this.logger = logger;
|
|
5015
5771
|
}
|
|
@@ -5025,10 +5781,10 @@ var RepoOperationLock = class {
|
|
|
5025
5781
|
}
|
|
5026
5782
|
async acquireCloneModeLock() {
|
|
5027
5783
|
const target = getCloneModeLockTarget(this.config);
|
|
5028
|
-
const lockTarget =
|
|
5784
|
+
const lockTarget = path13.join(target.dir, target.file);
|
|
5029
5785
|
try {
|
|
5030
|
-
await
|
|
5031
|
-
await
|
|
5786
|
+
await fs11.mkdir(target.dir, { recursive: true });
|
|
5787
|
+
await fs11.writeFile(lockTarget, "", { flag: "a" });
|
|
5032
5788
|
} catch {
|
|
5033
5789
|
return null;
|
|
5034
5790
|
}
|
|
@@ -5037,7 +5793,7 @@ var RepoOperationLock = class {
|
|
|
5037
5793
|
async acquireWorktreeModeLock() {
|
|
5038
5794
|
const barePath = this.gitService.getBareRepoPath();
|
|
5039
5795
|
try {
|
|
5040
|
-
await
|
|
5796
|
+
await fs11.mkdir(barePath, { recursive: true });
|
|
5041
5797
|
} catch {
|
|
5042
5798
|
return null;
|
|
5043
5799
|
}
|
|
@@ -5071,6 +5827,9 @@ var SyncRetryPolicy = class {
|
|
|
5071
5827
|
this.gitService = gitService;
|
|
5072
5828
|
this.logger = logger;
|
|
5073
5829
|
}
|
|
5830
|
+
config;
|
|
5831
|
+
gitService;
|
|
5832
|
+
logger;
|
|
5074
5833
|
updateLogger(logger) {
|
|
5075
5834
|
this.logger = logger;
|
|
5076
5835
|
}
|
|
@@ -5103,72 +5862,748 @@ var SyncRetryPolicy = class {
|
|
|
5103
5862
|
syncContext.lfsSkipEnabled = true;
|
|
5104
5863
|
}
|
|
5105
5864
|
}
|
|
5106
|
-
};
|
|
5865
|
+
};
|
|
5866
|
+
}
|
|
5867
|
+
resetLfsSkipIfNeeded(syncContext) {
|
|
5868
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
5869
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
5870
|
+
}
|
|
5871
|
+
}
|
|
5872
|
+
};
|
|
5873
|
+
|
|
5874
|
+
// src/services/trash-migration.service.ts
|
|
5875
|
+
import * as fs12 from "fs/promises";
|
|
5876
|
+
import * as path14 from "path";
|
|
5877
|
+
var REMOVED_ENTRY_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-(.+)$/;
|
|
5878
|
+
var TrashMigrationService = class {
|
|
5879
|
+
constructor(config, trashService, logger) {
|
|
5880
|
+
this.config = config;
|
|
5881
|
+
this.trashService = trashService;
|
|
5882
|
+
this.logger = logger;
|
|
5883
|
+
}
|
|
5884
|
+
config;
|
|
5885
|
+
trashService;
|
|
5886
|
+
logger;
|
|
5887
|
+
updateLogger(logger) {
|
|
5888
|
+
this.logger = logger;
|
|
5889
|
+
}
|
|
5890
|
+
isEnabled() {
|
|
5891
|
+
return this.trashService.isEnabled() && (this.config.trash?.migrateLegacy ?? DEFAULT_CONFIG.TRASH.MIGRATE_LEGACY);
|
|
5892
|
+
}
|
|
5893
|
+
async migrateLegacyUnlocked() {
|
|
5894
|
+
if (!this.isEnabled()) return;
|
|
5895
|
+
await this.migrateRemovedDir();
|
|
5896
|
+
await this.migrateDivergedDir();
|
|
5897
|
+
}
|
|
5898
|
+
async migrateRemovedDir() {
|
|
5899
|
+
const removedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.REMOVED_DIR_NAME);
|
|
5900
|
+
const names = await this.listDirectories(removedDir);
|
|
5901
|
+
for (const name of names) {
|
|
5902
|
+
const match = REMOVED_ENTRY_RE.exec(name);
|
|
5903
|
+
const quarantinedAt = match ? this.parseQuarantineTimestamp(match[1]) : null;
|
|
5904
|
+
if (!match || !quarantinedAt) {
|
|
5905
|
+
this.logger.warn(`\u26A0\uFE0F Leaving unrecognized entry '${name}' in ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ alone`);
|
|
5906
|
+
continue;
|
|
5907
|
+
}
|
|
5908
|
+
try {
|
|
5909
|
+
const entry = await this.trashService.trashDirectory({
|
|
5910
|
+
dirPath: path14.join(removedDir, name),
|
|
5911
|
+
reason: "legacy-adopt",
|
|
5912
|
+
source: ".removed",
|
|
5913
|
+
legacyOriginalName: name,
|
|
5914
|
+
legacyQuarantinedAt: quarantinedAt,
|
|
5915
|
+
headOid: null,
|
|
5916
|
+
originalPath: path14.join(this.config.worktreeDir, match[2]),
|
|
5917
|
+
auditAction: "trash_adopt"
|
|
5918
|
+
});
|
|
5919
|
+
this.logger.info(
|
|
5920
|
+
`\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.REMOVED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
|
|
5921
|
+
);
|
|
5922
|
+
} catch (error) {
|
|
5923
|
+
this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
await fs12.rmdir(removedDir).catch(() => void 0);
|
|
5927
|
+
}
|
|
5928
|
+
async migrateDivergedDir() {
|
|
5929
|
+
const divergedDir = path14.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5930
|
+
const names = await this.listDirectories(divergedDir);
|
|
5931
|
+
for (const name of names) {
|
|
5932
|
+
const dirPath = path14.join(divergedDir, name);
|
|
5933
|
+
const info = await this.readDivergedInfo(dirPath);
|
|
5934
|
+
const quarantinedAt = info?.divergedAt ? new Date(info.divergedAt) : null;
|
|
5935
|
+
const hasOriginalPath = typeof info?.originalPath === "string" && info.originalPath.length > 0;
|
|
5936
|
+
if (!info || !info.originalBranch || !hasOriginalPath || !quarantinedAt || Number.isNaN(quarantinedAt.getTime())) {
|
|
5937
|
+
this.logger.warn(
|
|
5938
|
+
`\u26A0\uFE0F Leaving entry '${name}' in ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ alone (no parseable ${METADATA_CONSTANTS.DIVERGED_INFO_FILE})`
|
|
5939
|
+
);
|
|
5940
|
+
continue;
|
|
5941
|
+
}
|
|
5942
|
+
try {
|
|
5943
|
+
const entry = await this.trashService.trashDirectory({
|
|
5944
|
+
dirPath,
|
|
5945
|
+
reason: "legacy-adopt",
|
|
5946
|
+
source: ".diverged",
|
|
5947
|
+
branch: info.originalBranch,
|
|
5948
|
+
legacyOriginalName: name,
|
|
5949
|
+
legacyQuarantinedAt: quarantinedAt,
|
|
5950
|
+
headOid: info.localCommit ?? null,
|
|
5951
|
+
originalPath: info.originalPath,
|
|
5952
|
+
auditAction: "trash_adopt",
|
|
5953
|
+
keepPinOnReap: true
|
|
5954
|
+
});
|
|
5955
|
+
this.logger.info(
|
|
5956
|
+
`\u267B\uFE0F Adopted '${name}' from ${GIT_CONSTANTS.DIVERGED_DIR_NAME}/ as trash entry '${entry.manifest.id}'`
|
|
5957
|
+
);
|
|
5958
|
+
} catch (error) {
|
|
5959
|
+
this.logger.warn(`\u26A0\uFE0F Failed to adopt '${name}' into trash: ${getErrorMessage(error)}`);
|
|
5960
|
+
}
|
|
5961
|
+
}
|
|
5962
|
+
await fs12.rmdir(divergedDir).catch(() => void 0);
|
|
5963
|
+
}
|
|
5964
|
+
async listDirectories(dirPath) {
|
|
5965
|
+
try {
|
|
5966
|
+
const dirents = await fs12.readdir(dirPath, { withFileTypes: true });
|
|
5967
|
+
return dirents.filter((dirent) => dirent.isDirectory() && !dirent.isSymbolicLink()).map((dirent) => dirent.name);
|
|
5968
|
+
} catch (error) {
|
|
5969
|
+
if (error.code !== "ENOENT") {
|
|
5970
|
+
this.logger.warn(`\u26A0\uFE0F Cannot scan '${dirPath}' for legacy trash adoption: ${getErrorMessage(error)}`);
|
|
5971
|
+
}
|
|
5972
|
+
return [];
|
|
5973
|
+
}
|
|
5974
|
+
}
|
|
5975
|
+
async readDivergedInfo(dirPath) {
|
|
5976
|
+
try {
|
|
5977
|
+
const raw = await fs12.readFile(path14.join(dirPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE), "utf-8");
|
|
5978
|
+
return JSON.parse(raw);
|
|
5979
|
+
} catch {
|
|
5980
|
+
return null;
|
|
5981
|
+
}
|
|
5982
|
+
}
|
|
5983
|
+
// quarantine timestamps replaced [:.] with "-": 2026-06-06T18-34-18-123Z
|
|
5984
|
+
parseQuarantineTimestamp(raw) {
|
|
5985
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(raw);
|
|
5986
|
+
if (!match) return null;
|
|
5987
|
+
const date = /* @__PURE__ */ new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`);
|
|
5988
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
5989
|
+
}
|
|
5990
|
+
};
|
|
5991
|
+
|
|
5992
|
+
// src/services/trash-reaper.service.ts
|
|
5993
|
+
import * as fs14 from "fs/promises";
|
|
5994
|
+
import * as path16 from "path";
|
|
5995
|
+
|
|
5996
|
+
// src/utils/disk-space.ts
|
|
5997
|
+
import fastFolderSize from "fast-folder-size";
|
|
5998
|
+
async function calculateDirectorySize(dirPath) {
|
|
5999
|
+
return new Promise((resolve14, reject) => {
|
|
6000
|
+
fastFolderSize(dirPath, (err, bytes) => {
|
|
6001
|
+
if (err) {
|
|
6002
|
+
reject(err);
|
|
6003
|
+
return;
|
|
6004
|
+
}
|
|
6005
|
+
if (bytes === void 0) {
|
|
6006
|
+
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
6007
|
+
return;
|
|
6008
|
+
}
|
|
6009
|
+
resolve14(bytes);
|
|
6010
|
+
});
|
|
6011
|
+
});
|
|
6012
|
+
}
|
|
6013
|
+
function formatBytes(bytes) {
|
|
6014
|
+
if (bytes === 0) return "0 B";
|
|
6015
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
6016
|
+
const k = 1024;
|
|
6017
|
+
const decimals = 2;
|
|
6018
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
6019
|
+
const value = bytes / Math.pow(k, i);
|
|
6020
|
+
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
6021
|
+
}
|
|
6022
|
+
async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
|
|
6023
|
+
try {
|
|
6024
|
+
let totalBytes = 0;
|
|
6025
|
+
for (const repoPath of repoPaths) {
|
|
6026
|
+
try {
|
|
6027
|
+
totalBytes += await calculateDirectorySize(repoPath);
|
|
6028
|
+
} catch {
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
for (const worktreeDir of worktreeDirs) {
|
|
6032
|
+
try {
|
|
6033
|
+
totalBytes += await calculateDirectorySize(worktreeDir);
|
|
6034
|
+
} catch {
|
|
6035
|
+
}
|
|
6036
|
+
}
|
|
6037
|
+
return formatBytes(totalBytes);
|
|
6038
|
+
} catch (error) {
|
|
6039
|
+
console.error("Failed to calculate disk space:", error);
|
|
6040
|
+
return "N/A";
|
|
6041
|
+
}
|
|
6042
|
+
}
|
|
6043
|
+
|
|
6044
|
+
// src/services/trash.service.ts
|
|
6045
|
+
import { randomBytes } from "crypto";
|
|
6046
|
+
import * as fs13 from "fs/promises";
|
|
6047
|
+
import * as path15 from "path";
|
|
6048
|
+
function isWorktreeRestorable(manifest) {
|
|
6049
|
+
return manifest.branch !== null && manifest.headOid !== null && manifest.pinRef !== null;
|
|
6050
|
+
}
|
|
6051
|
+
function summarizeTrashEntries(entries) {
|
|
6052
|
+
let totalSizeBytes = 0;
|
|
6053
|
+
let unknownSizeCount = 0;
|
|
6054
|
+
let soonest = null;
|
|
6055
|
+
for (const { manifest } of entries) {
|
|
6056
|
+
if (manifest.sizeBytes === null) {
|
|
6057
|
+
unknownSizeCount++;
|
|
6058
|
+
} else {
|
|
6059
|
+
totalSizeBytes += manifest.sizeBytes;
|
|
6060
|
+
}
|
|
6061
|
+
if (soonest === null || manifest.expiresAt < soonest) {
|
|
6062
|
+
soonest = manifest.expiresAt;
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
return { itemCount: entries.length, totalSizeBytes, unknownSizeCount, soonestExpiresAt: soonest };
|
|
6066
|
+
}
|
|
6067
|
+
var TrashService = class {
|
|
6068
|
+
constructor(config, gitService, logger, removalAudit) {
|
|
6069
|
+
this.config = config;
|
|
6070
|
+
this.gitService = gitService;
|
|
6071
|
+
this.logger = logger;
|
|
6072
|
+
this.removalAudit = removalAudit;
|
|
6073
|
+
}
|
|
6074
|
+
config;
|
|
6075
|
+
gitService;
|
|
6076
|
+
logger;
|
|
6077
|
+
removalAudit;
|
|
6078
|
+
updateLogger(logger) {
|
|
6079
|
+
this.logger = logger;
|
|
6080
|
+
}
|
|
6081
|
+
isEnabled() {
|
|
6082
|
+
return this.config.trash?.enabled ?? DEFAULT_CONFIG.TRASH.ENABLED;
|
|
6083
|
+
}
|
|
6084
|
+
getTrashRoot() {
|
|
6085
|
+
return path15.join(this.config.worktreeDir, GIT_CONSTANTS.TRASH_DIR_NAME);
|
|
6086
|
+
}
|
|
6087
|
+
getRetentionDays() {
|
|
6088
|
+
return this.config.trash?.retentionDays ?? DEFAULT_CONFIG.TRASH.RETENTION_DAYS;
|
|
6089
|
+
}
|
|
6090
|
+
async trashDirectory(options) {
|
|
6091
|
+
const deletedAt = /* @__PURE__ */ new Date();
|
|
6092
|
+
const expiresAt = new Date(deletedAt.getTime() + this.getRetentionDays() * 864e5);
|
|
6093
|
+
const keepPinOnReap = options.keepPinOnReap ?? false;
|
|
6094
|
+
const headOid = options.headOid !== void 0 ? options.headOid : await this.resolveHeadOid(options);
|
|
6095
|
+
if (keepPinOnReap && !headOid) {
|
|
6096
|
+
throw new TrashOperationError(
|
|
6097
|
+
"trash-directory",
|
|
6098
|
+
`cannot create keep-on-reap trash entry for '${options.dirPath}': HEAD commit could not be resolved`
|
|
6099
|
+
);
|
|
6100
|
+
}
|
|
6101
|
+
const sizeBytes = await calculateDirectorySize(options.dirPath).catch(() => null);
|
|
6102
|
+
await fs13.mkdir(this.getTrashRoot(), { recursive: true });
|
|
6103
|
+
const { id, containerPath } = await this.createContainer(deletedAt, path15.basename(options.dirPath));
|
|
6104
|
+
const manifest = {
|
|
6105
|
+
schemaVersion: TRASH_CONSTANTS.SCHEMA_VERSION,
|
|
6106
|
+
id,
|
|
6107
|
+
deletedAt: deletedAt.toISOString(),
|
|
6108
|
+
expiresAt: expiresAt.toISOString(),
|
|
6109
|
+
originalPath: path15.resolve(options.originalPath ?? options.dirPath),
|
|
6110
|
+
branch: options.branch ?? null,
|
|
6111
|
+
reason: options.reason,
|
|
6112
|
+
sizeBytes,
|
|
6113
|
+
headOid,
|
|
6114
|
+
pinRef: null,
|
|
6115
|
+
bundleFile: null,
|
|
6116
|
+
source: options.source ?? "worktree",
|
|
6117
|
+
legacyOriginalName: options.legacyOriginalName ?? null,
|
|
6118
|
+
legacyQuarantinedAt: options.legacyQuarantinedAt?.toISOString() ?? null,
|
|
6119
|
+
keepPinOnReap
|
|
6120
|
+
};
|
|
6121
|
+
try {
|
|
6122
|
+
await this.writeManifest(containerPath, manifest);
|
|
6123
|
+
} catch (error) {
|
|
6124
|
+
await this.undoPartialTrash(containerPath, null);
|
|
6125
|
+
throw new TrashOperationError(
|
|
6126
|
+
"trash-directory",
|
|
6127
|
+
`cannot write trash manifest for '${options.dirPath}': ${getErrorMessage(error)}`,
|
|
6128
|
+
error instanceof Error ? error : void 0
|
|
6129
|
+
);
|
|
6130
|
+
}
|
|
6131
|
+
const pinRef = headOid ? await this.createPinRef(id, headOid) : null;
|
|
6132
|
+
if (keepPinOnReap && !pinRef) {
|
|
6133
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
6134
|
+
throw new TrashOperationError(
|
|
6135
|
+
"trash-directory",
|
|
6136
|
+
`cannot create keep-on-reap trash entry '${id}' for '${options.dirPath}': pin ref could not be created`
|
|
6137
|
+
);
|
|
6138
|
+
}
|
|
6139
|
+
let bundleFile = null;
|
|
6140
|
+
if (keepPinOnReap && pinRef) {
|
|
6141
|
+
try {
|
|
6142
|
+
const created = await this.gitService.createBundleFromRef(
|
|
6143
|
+
path15.join(containerPath, TRASH_CONSTANTS.BUNDLE_FILENAME),
|
|
6144
|
+
pinRef
|
|
6145
|
+
);
|
|
6146
|
+
bundleFile = created ? TRASH_CONSTANTS.BUNDLE_FILENAME : null;
|
|
6147
|
+
} catch (error) {
|
|
6148
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
6149
|
+
throw new TrashOperationError(
|
|
6150
|
+
"trash-directory",
|
|
6151
|
+
`cannot bundle commits for keep-on-reap trash entry '${id}': ${getErrorMessage(error)}`,
|
|
6152
|
+
error instanceof Error ? error : void 0
|
|
6153
|
+
);
|
|
6154
|
+
}
|
|
6155
|
+
}
|
|
6156
|
+
const payloadPath = path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME);
|
|
6157
|
+
manifest.pinRef = pinRef;
|
|
6158
|
+
manifest.bundleFile = bundleFile;
|
|
6159
|
+
try {
|
|
6160
|
+
await this.writeManifest(containerPath, manifest);
|
|
6161
|
+
await fs13.rename(options.dirPath, payloadPath);
|
|
6162
|
+
} catch (error) {
|
|
6163
|
+
await this.undoPartialTrash(containerPath, pinRef);
|
|
6164
|
+
const hint = error.code === "EXDEV" ? " (trash lives inside worktreeDir; a cross-device rename means the directory is on a different filesystem \u2014 co-locate it or set trash.enabled=false)" : "";
|
|
6165
|
+
throw new TrashOperationError(
|
|
6166
|
+
"trash-directory",
|
|
6167
|
+
`cannot move '${options.dirPath}' to trash${hint}: ${getErrorMessage(error)}`,
|
|
6168
|
+
error instanceof Error ? error : void 0
|
|
6169
|
+
);
|
|
6170
|
+
}
|
|
6171
|
+
await this.removalAudit.record({
|
|
6172
|
+
action: options.auditAction ?? "trash_create",
|
|
6173
|
+
result: "success",
|
|
6174
|
+
path: manifest.originalPath,
|
|
6175
|
+
branch: manifest.branch ?? void 0,
|
|
6176
|
+
trashId: id,
|
|
6177
|
+
trashPath: payloadPath
|
|
6178
|
+
}).catch(
|
|
6179
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
6180
|
+
);
|
|
6181
|
+
return { manifest, containerPath, payloadPath };
|
|
6182
|
+
}
|
|
6183
|
+
async listEntries() {
|
|
6184
|
+
const root = this.getTrashRoot();
|
|
6185
|
+
const entries = [];
|
|
6186
|
+
const invalid = [];
|
|
6187
|
+
let dirents;
|
|
6188
|
+
try {
|
|
6189
|
+
dirents = await fs13.readdir(root, { withFileTypes: true });
|
|
6190
|
+
} catch (error) {
|
|
6191
|
+
if (error.code === "ENOENT") {
|
|
6192
|
+
return { entries, invalid };
|
|
6193
|
+
}
|
|
6194
|
+
throw error;
|
|
6195
|
+
}
|
|
6196
|
+
for (const dirent of dirents) {
|
|
6197
|
+
const containerPath = path15.join(root, dirent.name);
|
|
6198
|
+
if (dirent.isSymbolicLink()) {
|
|
6199
|
+
invalid.push(containerPath);
|
|
6200
|
+
continue;
|
|
6201
|
+
}
|
|
6202
|
+
if (!dirent.isDirectory()) {
|
|
6203
|
+
continue;
|
|
6204
|
+
}
|
|
6205
|
+
const manifest = await this.readManifest(containerPath);
|
|
6206
|
+
if (manifest === null) {
|
|
6207
|
+
invalid.push(containerPath);
|
|
6208
|
+
continue;
|
|
6209
|
+
}
|
|
6210
|
+
entries.push({
|
|
6211
|
+
manifest,
|
|
6212
|
+
containerPath,
|
|
6213
|
+
payloadPath: path15.join(containerPath, TRASH_CONSTANTS.PAYLOAD_DIRNAME)
|
|
6214
|
+
});
|
|
6215
|
+
}
|
|
6216
|
+
return { entries, invalid };
|
|
6217
|
+
}
|
|
6218
|
+
// The full reversible-removal sequence shared by prune and manual removal:
|
|
6219
|
+
// payload to trash, dangling registration cleared, branch ref deleted.
|
|
6220
|
+
// A ref-delete failure is a hygiene problem, not a failed removal — the
|
|
6221
|
+
// payload and pin ref already capture everything restore needs, and restore
|
|
6222
|
+
// tolerates a leftover ref at the trashed commit.
|
|
6223
|
+
async trashAndUnregisterWorktree(options) {
|
|
6224
|
+
const entry = await this.trashDirectory(options);
|
|
6225
|
+
await this.gitService.removeWorktree(options.dirPath, { force: true });
|
|
6226
|
+
let branchRefError;
|
|
6227
|
+
try {
|
|
6228
|
+
await this.deleteTrashedBranchRef(entry.manifest);
|
|
6229
|
+
} catch (refError) {
|
|
6230
|
+
branchRefError = getErrorMessage(refError);
|
|
6231
|
+
this.logger.warn(
|
|
6232
|
+
`\u26A0\uFE0F Leftover branch ref '${entry.manifest.branch}' after trashing '${entry.manifest.id}': ${branchRefError}`
|
|
6233
|
+
);
|
|
6234
|
+
}
|
|
6235
|
+
return { entry, branchRefError };
|
|
6236
|
+
}
|
|
6237
|
+
async restore(id) {
|
|
6238
|
+
const { entries } = await this.listEntries();
|
|
6239
|
+
const entry = entries.find((candidate) => candidate.manifest.id === id);
|
|
6240
|
+
if (!entry) {
|
|
6241
|
+
throw new TrashOperationError("restore", `no trash entry with id '${id}'`);
|
|
6242
|
+
}
|
|
6243
|
+
const { manifest, containerPath, payloadPath } = entry;
|
|
6244
|
+
if (await probePathExists(payloadPath) !== "exists") {
|
|
6245
|
+
throw new TrashOperationError("restore", `payload missing or unverifiable for '${id}' at '${payloadPath}'`);
|
|
6246
|
+
}
|
|
6247
|
+
const destinationProbe = await probePathExists(manifest.originalPath);
|
|
6248
|
+
if (destinationProbe !== "missing") {
|
|
6249
|
+
const why = destinationProbe === "exists" ? "already exists" : "cannot be verified";
|
|
6250
|
+
const hint = manifest.reason === "diverged-replace" && destinationProbe === "exists" ? " \u2014 a fresh worktree replaced this one when the branch diverged; remove that worktree first, or copy the files you need out of the trash payload manually" : "";
|
|
6251
|
+
throw new TrashOperationError("restore", `destination '${manifest.originalPath}' ${why}${hint}`);
|
|
6252
|
+
}
|
|
6253
|
+
if (isWorktreeRestorable(manifest)) {
|
|
6254
|
+
await this.restoreAsWorktree(manifest, payloadPath);
|
|
6255
|
+
} else {
|
|
6256
|
+
if (manifest.branch) {
|
|
6257
|
+
this.logger.warn(
|
|
6258
|
+
`\u26A0\uFE0F Trash entry '${id}' has no pinned commit; restoring files only \u2014 the directory will not be a registered worktree.`
|
|
6259
|
+
);
|
|
6260
|
+
}
|
|
6261
|
+
await fs13.rename(payloadPath, manifest.originalPath);
|
|
6262
|
+
}
|
|
6263
|
+
await fs13.rm(containerPath, { recursive: true, force: true }).catch(
|
|
6264
|
+
(error) => this.logger.warn(`\u26A0\uFE0F Failed to remove restored trash container '${containerPath}': ${getErrorMessage(error)}`)
|
|
6265
|
+
);
|
|
6266
|
+
if (manifest.pinRef) {
|
|
6267
|
+
await this.gitService.deleteRef(manifest.pinRef).catch(
|
|
6268
|
+
(error) => this.logger.warn(`\u26A0\uFE0F Failed to delete pin ref '${manifest.pinRef}': ${getErrorMessage(error)}`)
|
|
6269
|
+
);
|
|
6270
|
+
}
|
|
6271
|
+
await this.removalAudit.record({
|
|
6272
|
+
action: "trash_restore",
|
|
6273
|
+
result: "success",
|
|
6274
|
+
path: manifest.originalPath,
|
|
6275
|
+
branch: manifest.branch ?? void 0,
|
|
6276
|
+
trashId: id
|
|
6277
|
+
}).catch(
|
|
6278
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
6279
|
+
);
|
|
6280
|
+
return manifest;
|
|
6281
|
+
}
|
|
6282
|
+
async deleteTrashedBranchRef(manifest) {
|
|
6283
|
+
if (!manifest.branch) return;
|
|
6284
|
+
if (!manifest.pinRef) {
|
|
6285
|
+
this.logger.warn(
|
|
6286
|
+
`\u26A0\uFE0F Keeping branch ref '${manifest.branch}' after trashing '${manifest.id}': entry has no pin ref, so the ref is the only gc protection left`
|
|
6287
|
+
);
|
|
6288
|
+
return;
|
|
6289
|
+
}
|
|
6290
|
+
try {
|
|
6291
|
+
await this.gitService.deleteLocalBranch(manifest.branch);
|
|
6292
|
+
} catch (error) {
|
|
6293
|
+
throw new TrashOperationError(
|
|
6294
|
+
"trash-branch-ref",
|
|
6295
|
+
`cannot delete branch ref '${manifest.branch}' after trashing '${manifest.id}': ${getErrorMessage(error)}`,
|
|
6296
|
+
error instanceof Error ? error : void 0
|
|
6297
|
+
);
|
|
6298
|
+
}
|
|
6299
|
+
}
|
|
6300
|
+
async restoreAsWorktree(manifest, payloadPath) {
|
|
6301
|
+
const branch = manifest.branch;
|
|
6302
|
+
const headOid = manifest.headOid;
|
|
6303
|
+
const existingBranchOid = await this.gitService.getLocalBranchCommit(branch);
|
|
6304
|
+
let createdBranch = false;
|
|
6305
|
+
if (existingBranchOid !== null && existingBranchOid !== headOid) {
|
|
6306
|
+
throw new TrashOperationError(
|
|
6307
|
+
"restore",
|
|
6308
|
+
`branch '${branch}' already exists at ${existingBranchOid}; expected trashed commit ${headOid}. Restore the files manually from '${payloadPath}' or move that branch first`
|
|
6309
|
+
);
|
|
6310
|
+
}
|
|
6311
|
+
if (existingBranchOid === null) {
|
|
6312
|
+
await this.gitService.createBranchAt(branch, headOid);
|
|
6313
|
+
createdBranch = true;
|
|
6314
|
+
}
|
|
6315
|
+
try {
|
|
6316
|
+
await this.gitService.addWorktreeNoCheckout(branch, manifest.originalPath);
|
|
6317
|
+
await this.copyPayloadOver(payloadPath, manifest.originalPath);
|
|
6318
|
+
await this.gitService.resetWorktreeIndex(manifest.originalPath);
|
|
6319
|
+
if (this.config.sparseCheckout) {
|
|
6320
|
+
await this.gitService.getSparseCheckoutService().applyToWorktree(manifest.originalPath, this.config.sparseCheckout);
|
|
6321
|
+
}
|
|
6322
|
+
} catch (error) {
|
|
6323
|
+
await this.gitService.removeWorktree(manifest.originalPath, { force: true }).catch(
|
|
6324
|
+
(rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (worktree) failed: ${getErrorMessage(rollbackError)}`)
|
|
6325
|
+
);
|
|
6326
|
+
if (createdBranch) {
|
|
6327
|
+
await this.gitService.deleteLocalBranch(branch).catch(
|
|
6328
|
+
(rollbackError) => this.logger.warn(`\u26A0\uFE0F Restore rollback (branch) failed: ${getErrorMessage(rollbackError)}`)
|
|
6329
|
+
);
|
|
6330
|
+
}
|
|
6331
|
+
throw new TrashOperationError(
|
|
6332
|
+
"restore",
|
|
6333
|
+
`failed to recreate worktree for '${manifest.id}'; trash entry left intact: ${getErrorMessage(error)}`,
|
|
6334
|
+
error instanceof Error ? error : void 0
|
|
6335
|
+
);
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6338
|
+
// The payload's top-level .git link points at a pruned admin dir; the fresh
|
|
6339
|
+
// one written by `worktree add --no-checkout` must survive the overlay.
|
|
6340
|
+
async copyPayloadOver(payloadPath, destination) {
|
|
6341
|
+
await fs13.cp(payloadPath, destination, {
|
|
6342
|
+
recursive: true,
|
|
6343
|
+
force: true,
|
|
6344
|
+
filter: (source) => !(path15.dirname(source) === payloadPath && path15.basename(source) === PATH_CONSTANTS.GIT_DIR)
|
|
6345
|
+
});
|
|
6346
|
+
}
|
|
6347
|
+
async resolveHeadOid(options) {
|
|
6348
|
+
if (!options.branch) return null;
|
|
6349
|
+
try {
|
|
6350
|
+
return (await this.gitService.getCurrentCommit(options.dirPath)).trim();
|
|
6351
|
+
} catch (error) {
|
|
6352
|
+
this.logger.warn(
|
|
6353
|
+
`\u26A0\uFE0F Could not resolve HEAD for '${options.dirPath}'; trash entry will preserve files only: ${getErrorMessage(error)}`
|
|
6354
|
+
);
|
|
6355
|
+
return null;
|
|
6356
|
+
}
|
|
6357
|
+
}
|
|
6358
|
+
// Pin failure degrades to a files-only trash entry rather than blocking the
|
|
6359
|
+
// removal — the payload itself is still fully preserved either way.
|
|
6360
|
+
async createPinRef(id, headOid) {
|
|
6361
|
+
const refName = `${GIT_CONSTANTS.TRASH_REF_PREFIX}${id}`;
|
|
6362
|
+
try {
|
|
6363
|
+
await this.gitService.updateRef(refName, headOid);
|
|
6364
|
+
return refName;
|
|
6365
|
+
} catch (error) {
|
|
6366
|
+
this.logger.warn(
|
|
6367
|
+
`\u26A0\uFE0F Could not pin '${headOid}' for trash entry '${id}'; git gc may collect its objects: ${getErrorMessage(error)}`
|
|
6368
|
+
);
|
|
6369
|
+
return null;
|
|
6370
|
+
}
|
|
6371
|
+
}
|
|
6372
|
+
async writeManifest(containerPath, manifest) {
|
|
6373
|
+
const manifestPath = path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME);
|
|
6374
|
+
await atomicWriteFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
6375
|
+
}
|
|
6376
|
+
async readManifest(containerPath) {
|
|
6377
|
+
try {
|
|
6378
|
+
const raw = await fs13.readFile(path15.join(containerPath, TRASH_CONSTANTS.MANIFEST_FILENAME), "utf-8");
|
|
6379
|
+
const parsed = JSON.parse(raw);
|
|
6380
|
+
if (typeof parsed.id !== "string" || typeof parsed.expiresAt !== "string" || typeof parsed.originalPath !== "string") {
|
|
6381
|
+
return null;
|
|
6382
|
+
}
|
|
6383
|
+
return parsed;
|
|
6384
|
+
} catch {
|
|
6385
|
+
return null;
|
|
6386
|
+
}
|
|
6387
|
+
}
|
|
6388
|
+
async undoPartialTrash(containerPath, pinRef) {
|
|
6389
|
+
await fs13.rm(containerPath, { recursive: true, force: true }).catch(() => void 0);
|
|
6390
|
+
if (pinRef) {
|
|
6391
|
+
await this.gitService.deleteRef(pinRef).catch(() => void 0);
|
|
6392
|
+
}
|
|
6393
|
+
}
|
|
6394
|
+
async createContainer(deletedAt, baseName) {
|
|
6395
|
+
let lastError;
|
|
6396
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
6397
|
+
const id = this.generateId(deletedAt, baseName);
|
|
6398
|
+
const containerPath = path15.join(this.getTrashRoot(), id);
|
|
6399
|
+
try {
|
|
6400
|
+
await fs13.mkdir(containerPath);
|
|
6401
|
+
return { id, containerPath };
|
|
6402
|
+
} catch (error) {
|
|
6403
|
+
lastError = error;
|
|
6404
|
+
if (error.code !== "EEXIST") break;
|
|
6405
|
+
}
|
|
6406
|
+
}
|
|
6407
|
+
throw new TrashOperationError(
|
|
6408
|
+
"trash-directory",
|
|
6409
|
+
`cannot create trash container for '${baseName}': ${getErrorMessage(lastError)}`,
|
|
6410
|
+
lastError instanceof Error ? lastError : void 0
|
|
6411
|
+
);
|
|
6412
|
+
}
|
|
6413
|
+
// The id doubles as a refname component (refs/sync-worktrees/trash/<id>).
|
|
6414
|
+
// The timestamp prefix and hex suffix rule out leading dots and ".lock"
|
|
6415
|
+
// endings, but ".." inside the name would still make the ref invalid and
|
|
6416
|
+
// silently degrade the entry to files-only.
|
|
6417
|
+
generateId(deletedAt, baseName) {
|
|
6418
|
+
const timestamp = filenameTimestamp(deletedAt);
|
|
6419
|
+
const safeName = baseName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
|
|
6420
|
+
return `${timestamp}-${safeName}-${randomBytes(3).toString("hex")}`;
|
|
6421
|
+
}
|
|
6422
|
+
};
|
|
6423
|
+
|
|
6424
|
+
// src/services/trash-reaper.service.ts
|
|
6425
|
+
var TrashReaperService = class {
|
|
6426
|
+
constructor(config, trashService, logger, removalAudit, gitService) {
|
|
6427
|
+
this.config = config;
|
|
6428
|
+
this.trashService = trashService;
|
|
6429
|
+
this.logger = logger;
|
|
6430
|
+
this.removalAudit = removalAudit;
|
|
6431
|
+
this.gitService = gitService;
|
|
6432
|
+
}
|
|
6433
|
+
config;
|
|
6434
|
+
trashService;
|
|
6435
|
+
logger;
|
|
6436
|
+
removalAudit;
|
|
6437
|
+
gitService;
|
|
6438
|
+
updateLogger(logger) {
|
|
6439
|
+
this.logger = logger;
|
|
6440
|
+
}
|
|
6441
|
+
// Disabled trash means "don't touch my trash" — existing entries are left
|
|
6442
|
+
// alone rather than aged out behind the user's back.
|
|
6443
|
+
async reapExpiredUnlocked(now = /* @__PURE__ */ new Date()) {
|
|
6444
|
+
if (!this.trashService.isEnabled()) return;
|
|
6445
|
+
let realRoot;
|
|
6446
|
+
try {
|
|
6447
|
+
realRoot = await fs14.realpath(this.trashService.getTrashRoot());
|
|
6448
|
+
} catch (error) {
|
|
6449
|
+
if (error.code === "ENOENT") {
|
|
6450
|
+
this.logger.debug(`Trash reaper: no trash root; skipping pin-ref sweep`);
|
|
6451
|
+
return;
|
|
6452
|
+
}
|
|
6453
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper skipped: cannot resolve trash root: ${getErrorMessage(error)}`);
|
|
6454
|
+
return;
|
|
6455
|
+
}
|
|
6456
|
+
const { entries, invalid } = await this.trashService.listEntries();
|
|
6457
|
+
for (const invalidPath of invalid) {
|
|
6458
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unrecognized entry '${invalidPath}' alone (no valid manifest)`);
|
|
6459
|
+
}
|
|
6460
|
+
const reapedIds = /* @__PURE__ */ new Set();
|
|
6461
|
+
for (const entry of entries) {
|
|
6462
|
+
const expiresAt = new Date(entry.manifest.expiresAt);
|
|
6463
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
6464
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' has an unparseable expiry; skipping`);
|
|
6465
|
+
continue;
|
|
6466
|
+
}
|
|
6467
|
+
if (expiresAt.getTime() > now.getTime()) continue;
|
|
6468
|
+
try {
|
|
6469
|
+
const realEntry = await fs14.realpath(entry.containerPath);
|
|
6470
|
+
if (!realEntry.startsWith(realRoot + path16.sep)) {
|
|
6471
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: entry '${entry.manifest.id}' resolves outside the trash root; skipping`);
|
|
6472
|
+
continue;
|
|
6473
|
+
}
|
|
6474
|
+
} catch (error) {
|
|
6475
|
+
this.logger.warn(
|
|
6476
|
+
`\u26A0\uFE0F Trash reaper: cannot verify path of entry '${entry.manifest.id}'; skipping: ${getErrorMessage(error)}`
|
|
6477
|
+
);
|
|
6478
|
+
continue;
|
|
6479
|
+
}
|
|
6480
|
+
let keepRef = null;
|
|
6481
|
+
if (entry.manifest.keepPinOnReap && entry.manifest.headOid) {
|
|
6482
|
+
keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}${entry.manifest.id}`;
|
|
6483
|
+
try {
|
|
6484
|
+
await this.gitService.updateRef(keepRef, entry.manifest.headOid);
|
|
6485
|
+
} catch (error) {
|
|
6486
|
+
this.logger.warn(
|
|
6487
|
+
`\u26A0\uFE0F Trash reaper: cannot create keep ref '${keepRef}' for '${entry.manifest.id}'; deferring reap: ${getErrorMessage(error)}`
|
|
6488
|
+
);
|
|
6489
|
+
continue;
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
try {
|
|
6493
|
+
await this.removalAudit.record({
|
|
6494
|
+
action: "trash_reap",
|
|
6495
|
+
result: "attempt",
|
|
6496
|
+
path: entry.manifest.originalPath,
|
|
6497
|
+
branch: entry.manifest.branch ?? void 0,
|
|
6498
|
+
trashId: entry.manifest.id,
|
|
6499
|
+
trashPath: entry.payloadPath
|
|
6500
|
+
});
|
|
6501
|
+
} catch (auditError) {
|
|
6502
|
+
this.logger.warn(
|
|
6503
|
+
`\u26A0\uFE0F Trash reaper: cannot write audit log; skipping '${entry.manifest.id}': ${getErrorMessage(auditError)}`
|
|
6504
|
+
);
|
|
6505
|
+
continue;
|
|
6506
|
+
}
|
|
6507
|
+
try {
|
|
6508
|
+
await fs14.rm(entry.containerPath, { recursive: true, force: true });
|
|
6509
|
+
} catch (error) {
|
|
6510
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete '${entry.manifest.id}': ${getErrorMessage(error)}`);
|
|
6511
|
+
await this.removalAudit.record({
|
|
6512
|
+
action: "trash_reap",
|
|
6513
|
+
result: "failure",
|
|
6514
|
+
path: entry.manifest.originalPath,
|
|
6515
|
+
trashId: entry.manifest.id,
|
|
6516
|
+
error: getErrorMessage(error)
|
|
6517
|
+
}).catch(() => void 0);
|
|
6518
|
+
continue;
|
|
6519
|
+
}
|
|
6520
|
+
if (entry.manifest.pinRef) {
|
|
6521
|
+
await this.gitService.deleteRef(entry.manifest.pinRef).catch(
|
|
6522
|
+
(error) => this.logger.warn(
|
|
6523
|
+
`\u26A0\uFE0F Trash reaper: failed to delete pin ref '${entry.manifest.pinRef}': ${getErrorMessage(error)}`
|
|
6524
|
+
)
|
|
6525
|
+
);
|
|
6526
|
+
}
|
|
6527
|
+
reapedIds.add(entry.manifest.id);
|
|
6528
|
+
this.logger.info(
|
|
6529
|
+
`\u{1F5D1}\uFE0F Trash reaper: deleted expired entry '${entry.manifest.id}' (trashed ${entry.manifest.deletedAt})`
|
|
6530
|
+
);
|
|
6531
|
+
if (keepRef) {
|
|
6532
|
+
this.logger.info(
|
|
6533
|
+
` Commits remain recoverable at '${keepRef}' (${entry.manifest.headOid}) \u2014 recover with: git branch <name> ${entry.manifest.headOid}`
|
|
6534
|
+
);
|
|
6535
|
+
}
|
|
6536
|
+
await this.removalAudit.record({
|
|
6537
|
+
action: "trash_reap",
|
|
6538
|
+
result: "success",
|
|
6539
|
+
path: entry.manifest.originalPath,
|
|
6540
|
+
trashId: entry.manifest.id
|
|
6541
|
+
}).catch(
|
|
6542
|
+
(auditError) => this.logger.warn(`\u26A0\uFE0F Failed to write trash audit record: ${getErrorMessage(auditError)}`)
|
|
6543
|
+
);
|
|
6544
|
+
}
|
|
6545
|
+
let containerNames = null;
|
|
6546
|
+
try {
|
|
6547
|
+
containerNames = new Set(await fs14.readdir(realRoot));
|
|
6548
|
+
} catch (error) {
|
|
6549
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot scan trash root for pin-ref sweep: ${getErrorMessage(error)}`);
|
|
6550
|
+
}
|
|
6551
|
+
if (containerNames !== null) {
|
|
6552
|
+
await this.reapOrphanedPinRefs(containerNames);
|
|
6553
|
+
}
|
|
6554
|
+
this.warnIfOverThreshold(entries.filter((entry) => !reapedIds.has(entry.manifest.id)));
|
|
6555
|
+
}
|
|
6556
|
+
// Pin refs whose trash container is gone would pin objects forever (failed
|
|
6557
|
+
// ref delete during restore, manually emptied trash). Keyed on container
|
|
6558
|
+
// existence, NOT manifest validity — an invalid-manifest entry still owns
|
|
6559
|
+
// its pin because the reaper refuses to delete its payload. Deliberately
|
|
6560
|
+
// any dirent name counts (files, symlinks): deleting a pin is irreversible
|
|
6561
|
+
// once gc runs, while a stray name collision merely keeps one ref alive.
|
|
6562
|
+
async reapOrphanedPinRefs(containerNames) {
|
|
6563
|
+
let refs;
|
|
6564
|
+
try {
|
|
6565
|
+
refs = await this.gitService.listRefs(GIT_CONSTANTS.TRASH_REF_PREFIX.replace(/\/$/, ""));
|
|
6566
|
+
} catch (error) {
|
|
6567
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: cannot list pin refs: ${getErrorMessage(error)}`);
|
|
6568
|
+
return;
|
|
6569
|
+
}
|
|
6570
|
+
for (const ref of refs) {
|
|
6571
|
+
if (!ref.startsWith(GIT_CONSTANTS.TRASH_REF_PREFIX)) continue;
|
|
6572
|
+
const id = ref.slice(GIT_CONSTANTS.TRASH_REF_PREFIX.length);
|
|
6573
|
+
if (id.length === 0 || id.includes("/")) {
|
|
6574
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: leaving unexpected ref '${ref}' alone`);
|
|
6575
|
+
continue;
|
|
6576
|
+
}
|
|
6577
|
+
if (containerNames.has(id)) continue;
|
|
6578
|
+
try {
|
|
6579
|
+
await this.gitService.deleteRef(ref);
|
|
6580
|
+
this.logger.info(`\u{1F5D1}\uFE0F Trash reaper: deleted orphaned pin ref '${ref}'`);
|
|
6581
|
+
} catch (error) {
|
|
6582
|
+
this.logger.warn(`\u26A0\uFE0F Trash reaper: failed to delete orphaned pin ref '${ref}': ${getErrorMessage(error)}`);
|
|
6583
|
+
}
|
|
6584
|
+
}
|
|
5107
6585
|
}
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
6586
|
+
warnIfOverThreshold(remaining) {
|
|
6587
|
+
const warnSizeBytes = this.config.trash?.warnSizeBytes;
|
|
6588
|
+
if (warnSizeBytes === void 0) return;
|
|
6589
|
+
const summary = summarizeTrashEntries(remaining);
|
|
6590
|
+
if (summary.totalSizeBytes > warnSizeBytes) {
|
|
6591
|
+
this.logger.warn(
|
|
6592
|
+
`\u26A0\uFE0F Trash holds ${formatBytes(summary.totalSizeBytes)} across ${summary.itemCount} entries (threshold ${formatBytes(warnSizeBytes)}). Entries expire ${this.trashService.getRetentionDays()} days after removal.`
|
|
6593
|
+
);
|
|
5111
6594
|
}
|
|
5112
6595
|
}
|
|
5113
6596
|
};
|
|
5114
6597
|
|
|
5115
6598
|
// src/services/worktree-mode-sync-runner.ts
|
|
5116
|
-
import * as
|
|
5117
|
-
import * as
|
|
6599
|
+
import * as fs16 from "fs/promises";
|
|
6600
|
+
import * as path19 from "path";
|
|
5118
6601
|
import pLimit from "p-limit";
|
|
5119
6602
|
|
|
5120
|
-
// src/utils/date-filter.ts
|
|
5121
|
-
function parseDuration(durationStr) {
|
|
5122
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5123
|
-
if (!match) {
|
|
5124
|
-
return null;
|
|
5125
|
-
}
|
|
5126
|
-
const value = parseInt(match[1], 10);
|
|
5127
|
-
const unit = match[2];
|
|
5128
|
-
const multipliers = {
|
|
5129
|
-
h: 60 * 60 * 1e3,
|
|
5130
|
-
// hours
|
|
5131
|
-
d: 24 * 60 * 60 * 1e3,
|
|
5132
|
-
// days
|
|
5133
|
-
w: 7 * 24 * 60 * 60 * 1e3,
|
|
5134
|
-
// weeks
|
|
5135
|
-
m: 30 * 24 * 60 * 60 * 1e3,
|
|
5136
|
-
// months (approximate)
|
|
5137
|
-
y: 365 * 24 * 60 * 60 * 1e3
|
|
5138
|
-
// years (approximate)
|
|
5139
|
-
};
|
|
5140
|
-
return value * multipliers[unit];
|
|
5141
|
-
}
|
|
5142
|
-
function filterBranchesByAge(branches, maxAge) {
|
|
5143
|
-
const maxAgeMs = parseDuration(maxAge);
|
|
5144
|
-
if (maxAgeMs === null) {
|
|
5145
|
-
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
5146
|
-
return branches;
|
|
5147
|
-
}
|
|
5148
|
-
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
5149
|
-
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
5150
|
-
}
|
|
5151
|
-
function formatDuration2(durationStr) {
|
|
5152
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5153
|
-
if (!match) {
|
|
5154
|
-
return durationStr;
|
|
5155
|
-
}
|
|
5156
|
-
const value = parseInt(match[1], 10);
|
|
5157
|
-
const unit = match[2];
|
|
5158
|
-
const unitNames = {
|
|
5159
|
-
h: value === 1 ? "hour" : "hours",
|
|
5160
|
-
d: value === 1 ? "day" : "days",
|
|
5161
|
-
w: value === 1 ? "week" : "weeks",
|
|
5162
|
-
m: value === 1 ? "month" : "months",
|
|
5163
|
-
y: value === 1 ? "year" : "years"
|
|
5164
|
-
};
|
|
5165
|
-
return `${value} ${unitNames[unit]}`;
|
|
5166
|
-
}
|
|
5167
|
-
|
|
5168
6603
|
// src/services/path-resolution.service.ts
|
|
5169
6604
|
import { createHash as createHash2 } from "crypto";
|
|
5170
|
-
import * as
|
|
5171
|
-
import * as
|
|
6605
|
+
import * as fs15 from "fs";
|
|
6606
|
+
import * as path17 from "path";
|
|
5172
6607
|
var BRANCH_STEM_MAX = 80;
|
|
5173
6608
|
var BRANCH_HASH_LEN = 8;
|
|
5174
6609
|
var PathResolutionService = class {
|
|
@@ -5178,22 +6613,22 @@ var PathResolutionService = class {
|
|
|
5178
6613
|
return `${stem}-${hash}`;
|
|
5179
6614
|
}
|
|
5180
6615
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
5181
|
-
return
|
|
6616
|
+
return path17.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
5182
6617
|
}
|
|
5183
6618
|
resolveRealPath(inputPath) {
|
|
5184
|
-
const absolute =
|
|
6619
|
+
const absolute = path17.resolve(inputPath);
|
|
5185
6620
|
const missing = [];
|
|
5186
6621
|
let current = absolute;
|
|
5187
|
-
while (!
|
|
5188
|
-
const parent =
|
|
6622
|
+
while (!fs15.existsSync(current)) {
|
|
6623
|
+
const parent = path17.dirname(current);
|
|
5189
6624
|
if (parent === current) {
|
|
5190
6625
|
return absolute;
|
|
5191
6626
|
}
|
|
5192
|
-
missing.unshift(
|
|
6627
|
+
missing.unshift(path17.basename(current));
|
|
5193
6628
|
current = parent;
|
|
5194
6629
|
}
|
|
5195
6630
|
try {
|
|
5196
|
-
return
|
|
6631
|
+
return path17.join(fs15.realpathSync(current), ...missing);
|
|
5197
6632
|
} catch {
|
|
5198
6633
|
return absolute;
|
|
5199
6634
|
}
|
|
@@ -5203,7 +6638,7 @@ var PathResolutionService = class {
|
|
|
5203
6638
|
const a = fold(resolved);
|
|
5204
6639
|
const b = fold(resolvedBase);
|
|
5205
6640
|
if (a === b) return true;
|
|
5206
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
6641
|
+
return a.length > b.length && a.charAt(b.length) === path17.sep && a.startsWith(b);
|
|
5207
6642
|
}
|
|
5208
6643
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
5209
6644
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -5211,7 +6646,7 @@ var PathResolutionService = class {
|
|
|
5211
6646
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
5212
6647
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
5213
6648
|
}
|
|
5214
|
-
return
|
|
6649
|
+
return path17.relative(resolvedBase, resolved);
|
|
5215
6650
|
}
|
|
5216
6651
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
5217
6652
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -5224,7 +6659,7 @@ var PathResolutionService = class {
|
|
|
5224
6659
|
};
|
|
5225
6660
|
|
|
5226
6661
|
// src/services/worktree-sync-planner.ts
|
|
5227
|
-
import * as
|
|
6662
|
+
import * as path18 from "path";
|
|
5228
6663
|
function createWorktreeSyncPlan(inventory, options = {}) {
|
|
5229
6664
|
return {
|
|
5230
6665
|
create: planCreateActions(inventory, options),
|
|
@@ -5242,12 +6677,12 @@ function planCreateActions(inventory, options = {}) {
|
|
|
5242
6677
|
);
|
|
5243
6678
|
const reservedPaths = /* @__PURE__ */ new Map();
|
|
5244
6679
|
for (const worktree of inventory.existingWorktrees) {
|
|
5245
|
-
reservedPaths.set(
|
|
6680
|
+
reservedPaths.set(path18.resolve(worktree.path), worktree.branch);
|
|
5246
6681
|
}
|
|
5247
6682
|
const actions = [];
|
|
5248
6683
|
for (const branch of newBranches) {
|
|
5249
6684
|
const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
|
|
5250
|
-
const resolved =
|
|
6685
|
+
const resolved = path18.resolve(worktreePath);
|
|
5251
6686
|
const conflictingBranch = reservedPaths.get(resolved);
|
|
5252
6687
|
if (conflictingBranch && conflictingBranch !== branch) {
|
|
5253
6688
|
actions.push({
|
|
@@ -5285,21 +6720,30 @@ function planSparseActions(inventory, sparseCheckout) {
|
|
|
5285
6720
|
|
|
5286
6721
|
// src/services/worktree-mode-sync-runner.ts
|
|
5287
6722
|
var WorktreeModeSyncRunner = class {
|
|
5288
|
-
constructor(config, gitService, logger, progressEmitter) {
|
|
6723
|
+
constructor(config, gitService, logger, progressEmitter, services) {
|
|
5289
6724
|
this.config = config;
|
|
5290
6725
|
this.gitService = gitService;
|
|
5291
6726
|
this.logger = logger;
|
|
5292
6727
|
this.progressEmitter = progressEmitter;
|
|
6728
|
+
this.removalAudit = services?.removalAudit ?? new RemovalAuditService(getRemovalAuditLogPath(config));
|
|
6729
|
+
this.trashService = services?.trashService ?? new TrashService(config, gitService, logger, this.removalAudit);
|
|
5293
6730
|
}
|
|
6731
|
+
config;
|
|
6732
|
+
gitService;
|
|
6733
|
+
logger;
|
|
6734
|
+
progressEmitter;
|
|
5294
6735
|
pathResolution = new PathResolutionService();
|
|
6736
|
+
removalAudit;
|
|
6737
|
+
trashService;
|
|
5295
6738
|
updateLogger(logger) {
|
|
5296
6739
|
this.logger = logger;
|
|
6740
|
+
this.trashService.updateLogger(logger);
|
|
5297
6741
|
}
|
|
5298
6742
|
async runSyncAttempt(phaseTimer, syncContext, outcome) {
|
|
5299
6743
|
await this.gitService.pruneWorktrees();
|
|
5300
6744
|
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
5301
6745
|
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
5302
|
-
await
|
|
6746
|
+
await fs16.mkdir(this.config.worktreeDir, { recursive: true });
|
|
5303
6747
|
const worktrees = await this.gitService.getWorktrees();
|
|
5304
6748
|
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
5305
6749
|
await this.cleanupOrphanedDirectories(worktrees);
|
|
@@ -5317,6 +6761,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5317
6761
|
}
|
|
5318
6762
|
);
|
|
5319
6763
|
await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
|
|
6764
|
+
await this.recordRemoteBranchTips([...worktrees, ...syncPlan.create.filter((action) => action.kind === "create")]);
|
|
5320
6765
|
await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
|
|
5321
6766
|
if (this.config.updateExistingWorktrees !== false) {
|
|
5322
6767
|
await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
|
|
@@ -5339,7 +6784,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5339
6784
|
if (action.kind !== "check-sparse") return;
|
|
5340
6785
|
try {
|
|
5341
6786
|
try {
|
|
5342
|
-
await
|
|
6787
|
+
await fs16.access(action.path);
|
|
5343
6788
|
} catch {
|
|
5344
6789
|
return;
|
|
5345
6790
|
}
|
|
@@ -5425,7 +6870,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5425
6870
|
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
5426
6871
|
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
5427
6872
|
this.logger.info(
|
|
5428
|
-
`After filtering by age (${
|
|
6873
|
+
`After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
5429
6874
|
);
|
|
5430
6875
|
if (filteredByName.length > remoteBranches.length) {
|
|
5431
6876
|
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
@@ -5502,6 +6947,37 @@ var WorktreeModeSyncRunner = class {
|
|
|
5502
6947
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
5503
6948
|
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
5504
6949
|
}
|
|
6950
|
+
// Persist each worktree's upstream tip while the remote ref still exists.
|
|
6951
|
+
// This is the proof consulted after a squash-merge deletes the branch:
|
|
6952
|
+
// "HEAD was on the remote before the deletion" — without it every such
|
|
6953
|
+
// worktree reads as having unpushed commits forever. Best-effort: a failed
|
|
6954
|
+
// recording only means that worktree stays conservatively preserved.
|
|
6955
|
+
async recordRemoteBranchTips(worktrees) {
|
|
6956
|
+
try {
|
|
6957
|
+
const tips = await this.gitService.getRemoteBranchTips();
|
|
6958
|
+
if (tips.size === 0) return;
|
|
6959
|
+
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
6960
|
+
await Promise.all(
|
|
6961
|
+
worktrees.map(
|
|
6962
|
+
(wt) => limit(async () => {
|
|
6963
|
+
const oid = tips.get(wt.branch);
|
|
6964
|
+
if (!oid) return;
|
|
6965
|
+
await this.gitService.recordRemoteTip(wt.path, wt.branch, oid).catch(
|
|
6966
|
+
(error) => this.logger.warn(` - \u26A0\uFE0F Could not record remote tip for '${wt.branch}': ${getErrorMessage(error)}`)
|
|
6967
|
+
);
|
|
6968
|
+
})
|
|
6969
|
+
)
|
|
6970
|
+
);
|
|
6971
|
+
} catch (error) {
|
|
6972
|
+
this.logger.warn(`\u26A0\uFE0F Could not record remote branch tips: ${getErrorMessage(error)}`);
|
|
6973
|
+
}
|
|
6974
|
+
}
|
|
6975
|
+
// A removal authorized only by the fully-pushed proof must stay reversible:
|
|
6976
|
+
// without trash it would be a permanent delete of commits whose remote
|
|
6977
|
+
// branch may have been deleted unmerged.
|
|
6978
|
+
blockedByDisabledTrash(status) {
|
|
6979
|
+
return status.fullyPushedUpstreamDeleted && !this.trashService.isEnabled();
|
|
6980
|
+
}
|
|
5505
6981
|
async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
5506
6982
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
5507
6983
|
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
@@ -5531,7 +7007,18 @@ var WorktreeModeSyncRunner = class {
|
|
|
5531
7007
|
if (result.status === "fulfilled") {
|
|
5532
7008
|
const { branchName, worktreePath, status } = result.value;
|
|
5533
7009
|
if (status.canRemove) {
|
|
5534
|
-
|
|
7010
|
+
if (this.blockedByDisabledTrash(status)) {
|
|
7011
|
+
this.logger.warn(
|
|
7012
|
+
` - \u26A0\uFE0F '${branchName}' was fully pushed before its remote branch was deleted, but trash is disabled \u2014 keeping worktree. Enable trash for reversible auto-removal, or remove manually.`
|
|
7013
|
+
);
|
|
7014
|
+
outcome.recordSkipped("worktree", "fully_pushed_trash_disabled", {
|
|
7015
|
+
branch: branchName,
|
|
7016
|
+
path: worktreePath,
|
|
7017
|
+
message: "fully pushed before upstream deletion; trash disabled"
|
|
7018
|
+
});
|
|
7019
|
+
} else {
|
|
7020
|
+
toRemove.push({ branchName, worktreePath });
|
|
7021
|
+
}
|
|
5535
7022
|
} else {
|
|
5536
7023
|
toSkip.push({ branchName, worktreePath, status });
|
|
5537
7024
|
}
|
|
@@ -5554,7 +7041,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5554
7041
|
({ branchName, worktreePath }) => removeLimit(async () => {
|
|
5555
7042
|
try {
|
|
5556
7043
|
const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
|
|
5557
|
-
if (!recheck.canRemove) {
|
|
7044
|
+
if (!recheck.canRemove || this.blockedByDisabledTrash(recheck)) {
|
|
5558
7045
|
this.logger.warn(
|
|
5559
7046
|
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
5560
7047
|
);
|
|
@@ -5565,10 +7052,76 @@ var WorktreeModeSyncRunner = class {
|
|
|
5565
7052
|
});
|
|
5566
7053
|
return;
|
|
5567
7054
|
}
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
7055
|
+
try {
|
|
7056
|
+
await this.removalAudit.record({
|
|
7057
|
+
action: "prune_remove",
|
|
7058
|
+
result: "attempt",
|
|
7059
|
+
path: worktreePath,
|
|
7060
|
+
branch: branchName,
|
|
7061
|
+
status: recheck
|
|
7062
|
+
});
|
|
7063
|
+
} catch (auditError) {
|
|
7064
|
+
this.logger.warn(
|
|
7065
|
+
` \u26A0\uFE0F Skipping removal of '${branchName}' - cannot write removal audit log: ${getErrorMessage(auditError)}`
|
|
7066
|
+
);
|
|
7067
|
+
outcome.recordSkipped("worktree", "audit_log_unavailable", {
|
|
7068
|
+
branch: branchName,
|
|
7069
|
+
path: worktreePath,
|
|
7070
|
+
message: getErrorMessage(auditError)
|
|
7071
|
+
});
|
|
7072
|
+
return;
|
|
7073
|
+
}
|
|
7074
|
+
if (await probePathExists(worktreePath) === "missing") {
|
|
7075
|
+
await this.gitService.removeWorktree(worktreePath, { force: true });
|
|
7076
|
+
this.logger.info(` \u2705 Cleared dangling registration for '${branchName}' (directory already gone)`);
|
|
7077
|
+
outcome.recordRemoved(branchName, worktreePath);
|
|
7078
|
+
await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
|
|
7079
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7080
|
+
);
|
|
7081
|
+
return;
|
|
7082
|
+
}
|
|
7083
|
+
let refWarning;
|
|
7084
|
+
if (this.trashService.isEnabled()) {
|
|
7085
|
+
const { entry, branchRefError } = await this.trashService.trashAndUnregisterWorktree({
|
|
7086
|
+
dirPath: worktreePath,
|
|
7087
|
+
branch: branchName,
|
|
7088
|
+
reason: "prune",
|
|
7089
|
+
keepPinOnReap: recheck.fullyPushedUpstreamDeleted
|
|
7090
|
+
});
|
|
7091
|
+
if (branchRefError !== void 0) {
|
|
7092
|
+
refWarning = `leftover_branch_ref: could not delete branch ref '${branchName}': ${branchRefError}`;
|
|
7093
|
+
}
|
|
7094
|
+
const pushedNote = recheck.fullyPushedUpstreamDeleted ? " \u2014 was fully pushed before its remote branch was deleted" : "";
|
|
7095
|
+
this.logger.info(
|
|
7096
|
+
` \u2705 Moved worktree for '${branchName}' to trash (id: ${entry.manifest.id})${pushedNote}`
|
|
7097
|
+
);
|
|
7098
|
+
} else {
|
|
7099
|
+
await this.gitService.removeWorktree(worktreePath);
|
|
7100
|
+
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
7101
|
+
}
|
|
7102
|
+
outcome.recordRemoved(branchName, worktreePath, refWarning);
|
|
7103
|
+
await this.removalAudit.record({ action: "prune_remove", result: "success", path: worktreePath, branch: branchName }).catch(
|
|
7104
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7105
|
+
);
|
|
5571
7106
|
} catch (error) {
|
|
7107
|
+
if (error instanceof WorktreeNotCleanError) {
|
|
7108
|
+
this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - git refused: ${getErrorMessage(error)}`);
|
|
7109
|
+
outcome.recordSkipped("worktree", "git_refused_removal", {
|
|
7110
|
+
branch: branchName,
|
|
7111
|
+
path: worktreePath,
|
|
7112
|
+
message: getErrorMessage(error)
|
|
7113
|
+
});
|
|
7114
|
+
return;
|
|
7115
|
+
}
|
|
7116
|
+
if (error instanceof TrashOperationError) {
|
|
7117
|
+
this.logger.warn(` \u26A0\uFE0F Skipping removal of '${branchName}' - ${getErrorMessage(error)}`);
|
|
7118
|
+
outcome.recordSkipped("worktree", "trash_failed", {
|
|
7119
|
+
branch: branchName,
|
|
7120
|
+
path: worktreePath,
|
|
7121
|
+
message: getErrorMessage(error)
|
|
7122
|
+
});
|
|
7123
|
+
return;
|
|
7124
|
+
}
|
|
5572
7125
|
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
5573
7126
|
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5574
7127
|
reason: "remove_failed",
|
|
@@ -5694,12 +7247,12 @@ var WorktreeModeSyncRunner = class {
|
|
|
5694
7247
|
}
|
|
5695
7248
|
async updateExistingWorktrees(actions, outcome) {
|
|
5696
7249
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
5697
|
-
const divergedDir =
|
|
7250
|
+
const divergedDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5698
7251
|
try {
|
|
5699
|
-
const diverged = await
|
|
7252
|
+
const diverged = await fs16.readdir(divergedDir);
|
|
5700
7253
|
if (diverged.length > 0) {
|
|
5701
7254
|
this.logger.info(
|
|
5702
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
7255
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path19.relative(process.cwd(), divergedDir)}`
|
|
5703
7256
|
);
|
|
5704
7257
|
}
|
|
5705
7258
|
} catch {
|
|
@@ -5711,7 +7264,7 @@ var WorktreeModeSyncRunner = class {
|
|
|
5711
7264
|
(action) => limit(async () => {
|
|
5712
7265
|
const worktree = { path: action.path, branch: action.branch };
|
|
5713
7266
|
try {
|
|
5714
|
-
await
|
|
7267
|
+
await fs16.access(worktree.path);
|
|
5715
7268
|
} catch {
|
|
5716
7269
|
return { action: "skip", worktree, reason: "missing_worktree_path" };
|
|
5717
7270
|
}
|
|
@@ -5851,13 +7404,13 @@ var WorktreeModeSyncRunner = class {
|
|
|
5851
7404
|
}
|
|
5852
7405
|
async cleanupOrphanedDirectories(worktrees) {
|
|
5853
7406
|
try {
|
|
5854
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
5855
|
-
const allDirs = await
|
|
7407
|
+
const worktreeRelativePaths = worktrees.map((w) => path19.relative(this.config.worktreeDir, w.path));
|
|
7408
|
+
const allDirs = await fs16.readdir(this.config.worktreeDir);
|
|
5856
7409
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
5857
7410
|
const orphanedDirs = [];
|
|
5858
7411
|
for (const dir of regularDirs) {
|
|
5859
7412
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
5860
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
7413
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path19.sep);
|
|
5861
7414
|
});
|
|
5862
7415
|
if (!isPartOfWorktree) {
|
|
5863
7416
|
orphanedDirs.push(dir);
|
|
@@ -5866,13 +7419,46 @@ var WorktreeModeSyncRunner = class {
|
|
|
5866
7419
|
if (orphanedDirs.length > 0) {
|
|
5867
7420
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
5868
7421
|
for (const dir of orphanedDirs) {
|
|
5869
|
-
const dirPath =
|
|
7422
|
+
const dirPath = path19.join(this.config.worktreeDir, dir);
|
|
5870
7423
|
try {
|
|
5871
|
-
const stat3 = await
|
|
5872
|
-
if (stat3.isDirectory()) {
|
|
5873
|
-
|
|
5874
|
-
|
|
7424
|
+
const stat3 = await fs16.stat(dirPath);
|
|
7425
|
+
if (!stat3.isDirectory()) {
|
|
7426
|
+
continue;
|
|
7427
|
+
}
|
|
7428
|
+
const gitProbe = await probePathExists(path19.join(dirPath, PATH_CONSTANTS.GIT_DIR));
|
|
7429
|
+
if (gitProbe === "unknown") {
|
|
7430
|
+
this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir}: cannot verify it is not a live checkout`);
|
|
7431
|
+
continue;
|
|
7432
|
+
}
|
|
7433
|
+
if (this.trashService.isEnabled()) {
|
|
7434
|
+
try {
|
|
7435
|
+
const entry = await this.trashService.trashDirectory({ dirPath, reason: "orphan" });
|
|
7436
|
+
this.logger.info(` - Moved orphaned directory '${dir}' to trash (id: ${entry.manifest.id})`);
|
|
7437
|
+
} catch (trashError) {
|
|
7438
|
+
this.logger.warn(` - \u26A0\uFE0F Skipping orphaned directory ${dir} - ${getErrorMessage(trashError)}`);
|
|
7439
|
+
}
|
|
7440
|
+
continue;
|
|
7441
|
+
}
|
|
7442
|
+
if (gitProbe === "exists") {
|
|
7443
|
+
const quarantinePath = await quarantineDirectory(dirPath);
|
|
7444
|
+
this.logger.warn(
|
|
7445
|
+
` - \u26A0\uFE0F Orphaned directory ${dir} contains a .git; quarantined to '${quarantinePath}' instead of deleting.`
|
|
7446
|
+
);
|
|
7447
|
+
await this.removalAudit.record({ action: "orphan_quarantine", result: "success", path: dirPath, quarantinePath }).catch(
|
|
7448
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7449
|
+
);
|
|
7450
|
+
continue;
|
|
5875
7451
|
}
|
|
7452
|
+
try {
|
|
7453
|
+
await this.removalAudit.record({ action: "orphan_delete", result: "attempt", path: dirPath });
|
|
7454
|
+
} catch (auditError) {
|
|
7455
|
+
this.logger.warn(
|
|
7456
|
+
` - \u26A0\uFE0F Skipping orphaned directory ${dir} - cannot write removal audit log: ${getErrorMessage(auditError)}`
|
|
7457
|
+
);
|
|
7458
|
+
continue;
|
|
7459
|
+
}
|
|
7460
|
+
await fs16.rm(dirPath, { recursive: true, force: true });
|
|
7461
|
+
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
5876
7462
|
} catch (error) {
|
|
5877
7463
|
this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
|
|
5878
7464
|
}
|
|
@@ -5901,14 +7487,37 @@ var WorktreeModeSyncRunner = class {
|
|
|
5901
7487
|
outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
|
|
5902
7488
|
} else {
|
|
5903
7489
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
5904
|
-
|
|
5905
|
-
|
|
7490
|
+
let keepRef = null;
|
|
7491
|
+
if (!this.trashService.isEnabled()) {
|
|
7492
|
+
const localCommit = (await this.gitService.getCurrentCommit(worktree.path)).trim();
|
|
7493
|
+
keepRef = `${GIT_CONSTANTS.KEEP_REF_PREFIX}diverged-${Date.now().toString(36)}-${this.pathResolution.sanitizeBranchName(worktree.branch)}`;
|
|
7494
|
+
await this.gitService.updateRef(keepRef, localCommit);
|
|
7495
|
+
}
|
|
7496
|
+
const { divergedPath, manifest } = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
7497
|
+
const relativePath = path19.relative(process.cwd(), divergedPath);
|
|
5906
7498
|
outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
|
|
5907
7499
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
5908
7500
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
5909
7501
|
this.logger.info(` cd ${relativePath}`);
|
|
5910
7502
|
this.logger.info(` git diff origin/${worktree.branch}`);
|
|
5911
|
-
await this.gitService.removeWorktree(worktree.path);
|
|
7503
|
+
await this.gitService.removeWorktree(worktree.path, { force: true });
|
|
7504
|
+
if (manifest !== null) {
|
|
7505
|
+
await this.trashService.deleteTrashedBranchRef(manifest);
|
|
7506
|
+
} else {
|
|
7507
|
+
await this.gitService.deleteLocalBranch(worktree.branch);
|
|
7508
|
+
this.logger.info(
|
|
7509
|
+
` Never-pushed commits remain recoverable at '${keepRef}' \u2014 recover with: git branch <name> ${keepRef}`
|
|
7510
|
+
);
|
|
7511
|
+
}
|
|
7512
|
+
await this.removalAudit.record({
|
|
7513
|
+
action: "diverged_replace",
|
|
7514
|
+
result: "success",
|
|
7515
|
+
path: worktree.path,
|
|
7516
|
+
branch: worktree.branch,
|
|
7517
|
+
quarantinePath: divergedPath
|
|
7518
|
+
}).catch(
|
|
7519
|
+
(auditError) => this.logger.warn(` \u26A0\uFE0F Failed to write removal audit record: ${getErrorMessage(auditError)}`)
|
|
7520
|
+
);
|
|
5912
7521
|
await this.gitService.addWorktree(worktree.branch, worktree.path);
|
|
5913
7522
|
this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
|
|
5914
7523
|
}
|
|
@@ -5927,42 +7536,55 @@ var WorktreeModeSyncRunner = class {
|
|
|
5927
7536
|
}
|
|
5928
7537
|
}
|
|
5929
7538
|
async divergeWorktree(worktreePath, branchName) {
|
|
5930
|
-
|
|
7539
|
+
if (this.trashService.isEnabled()) {
|
|
7540
|
+
const entry = await this.trashService.trashDirectory({
|
|
7541
|
+
dirPath: worktreePath,
|
|
7542
|
+
branch: branchName,
|
|
7543
|
+
reason: "diverged-replace",
|
|
7544
|
+
keepPinOnReap: true
|
|
7545
|
+
});
|
|
7546
|
+
await this.writeDivergedInfoFile(entry.payloadPath, worktreePath, branchName, entry.manifest.headOid);
|
|
7547
|
+
return { divergedPath: entry.payloadPath, manifest: entry.manifest };
|
|
7548
|
+
}
|
|
7549
|
+
const divergedBaseDir = path19.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5931
7550
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
5932
7551
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
5933
7552
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
5934
7553
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
5935
|
-
const divergedPath =
|
|
5936
|
-
await
|
|
7554
|
+
const divergedPath = path19.join(divergedBaseDir, divergedName);
|
|
7555
|
+
await fs16.mkdir(divergedBaseDir, { recursive: true });
|
|
5937
7556
|
try {
|
|
5938
|
-
await
|
|
7557
|
+
await fs16.rename(worktreePath, divergedPath);
|
|
5939
7558
|
} catch (err) {
|
|
5940
7559
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
5941
|
-
await
|
|
5942
|
-
await
|
|
7560
|
+
await fs16.cp(worktreePath, divergedPath, { recursive: true });
|
|
7561
|
+
await fs16.rm(worktreePath, { recursive: true, force: true });
|
|
5943
7562
|
} else {
|
|
5944
7563
|
throw err;
|
|
5945
7564
|
}
|
|
5946
7565
|
}
|
|
7566
|
+
await this.writeDivergedInfoFile(divergedPath, worktreePath, branchName, null);
|
|
7567
|
+
return { divergedPath, manifest: null };
|
|
7568
|
+
}
|
|
7569
|
+
async writeDivergedInfoFile(preservedPath, originalPath, branchName, knownLocalCommit) {
|
|
5947
7570
|
const metadata = {
|
|
5948
7571
|
originalBranch: branchName,
|
|
5949
7572
|
divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5950
7573
|
reason: METADATA_CONSTANTS.DIVERGED_REASON,
|
|
5951
|
-
originalPath
|
|
5952
|
-
localCommit: await this.gitService.getCurrentCommit(
|
|
7574
|
+
originalPath,
|
|
7575
|
+
localCommit: knownLocalCommit ?? await this.gitService.getCurrentCommit(preservedPath),
|
|
5953
7576
|
remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
|
|
5954
7577
|
instruction: `To preserve your changes:
|
|
5955
7578
|
1. Review: git diff origin/${branchName}
|
|
5956
7579
|
2. Keep changes: git push --force-with-lease origin ${branchName}
|
|
5957
7580
|
3. Discard changes: rm -rf this directory
|
|
5958
7581
|
|
|
5959
|
-
Original worktree location: ${
|
|
7582
|
+
Original worktree location: ${originalPath}`
|
|
5960
7583
|
};
|
|
5961
|
-
await
|
|
5962
|
-
|
|
7584
|
+
await fs16.writeFile(
|
|
7585
|
+
path19.join(preservedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
5963
7586
|
JSON.stringify(metadata, null, 2)
|
|
5964
7587
|
);
|
|
5965
|
-
return divergedPath;
|
|
5966
7588
|
}
|
|
5967
7589
|
};
|
|
5968
7590
|
|
|
@@ -5973,12 +7595,26 @@ var WorktreeSyncService = class {
|
|
|
5973
7595
|
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
5974
7596
|
this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
|
|
5975
7597
|
this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
|
|
7598
|
+
this.maintenanceService = new GitMaintenanceService(config, this.gitService, this.logger);
|
|
5976
7599
|
this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
|
|
7600
|
+
const removalAudit = new RemovalAuditService(getRemovalAuditLogPath(config));
|
|
7601
|
+
this.trashService = new TrashService(config, this.gitService, this.logger, removalAudit);
|
|
7602
|
+
this.trashReaper = new TrashReaperService(config, this.trashService, this.logger, removalAudit, this.gitService);
|
|
7603
|
+
this.trashMigration = new TrashMigrationService(config, this.trashService, this.logger);
|
|
7604
|
+
if (this.trashService.isEnabled()) {
|
|
7605
|
+
this.gitService.setStaleDirectoryTrasher(
|
|
7606
|
+
async (dirPath) => (await this.trashService.trashDirectory({ dirPath, reason: "orphan" })).payloadPath
|
|
7607
|
+
);
|
|
7608
|
+
}
|
|
5977
7609
|
this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
|
|
5978
7610
|
config,
|
|
5979
7611
|
this.gitService,
|
|
5980
7612
|
this.logger,
|
|
5981
|
-
this.progressEmitter
|
|
7613
|
+
this.progressEmitter,
|
|
7614
|
+
{
|
|
7615
|
+
trashService: this.trashService,
|
|
7616
|
+
removalAudit
|
|
7617
|
+
}
|
|
5982
7618
|
);
|
|
5983
7619
|
if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
|
|
5984
7620
|
this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
|
|
@@ -5989,14 +7625,23 @@ var WorktreeSyncService = class {
|
|
|
5989
7625
|
});
|
|
5990
7626
|
}
|
|
5991
7627
|
}
|
|
7628
|
+
config;
|
|
5992
7629
|
gitService;
|
|
5993
7630
|
cloneSyncService = null;
|
|
5994
7631
|
logger;
|
|
5995
|
-
|
|
7632
|
+
// In-process FIFO serializer for all bare-repo-mutating operations (sync, init,
|
|
7633
|
+
// interactive create). One per repo. wait:true callers queue behind an in-flight op;
|
|
7634
|
+
// wait:false callers fail fast. The cross-process file lock (RepoOperationLock) is
|
|
7635
|
+
// acquired inside the mutex body for multi-process safety.
|
|
7636
|
+
repoMutex = pLimit2(1);
|
|
5996
7637
|
progressEmitter = new ProgressEmitter();
|
|
5997
7638
|
repoOperationLock;
|
|
7639
|
+
maintenanceService;
|
|
5998
7640
|
retryPolicy;
|
|
5999
7641
|
worktreeModeSyncRunner;
|
|
7642
|
+
trashService;
|
|
7643
|
+
trashReaper;
|
|
7644
|
+
trashMigration;
|
|
6000
7645
|
skipsAccumulator = [];
|
|
6001
7646
|
lastOutcome = null;
|
|
6002
7647
|
getRecordedSkips() {
|
|
@@ -6020,6 +7665,18 @@ var WorktreeSyncService = class {
|
|
|
6020
7665
|
}
|
|
6021
7666
|
return this.gitService.getWorktrees();
|
|
6022
7667
|
}
|
|
7668
|
+
async getRemoteBranches() {
|
|
7669
|
+
if (this.cloneSyncService) {
|
|
7670
|
+
return this.cloneSyncService.getRemoteBranches();
|
|
7671
|
+
}
|
|
7672
|
+
return this.gitService.getRemoteBranches();
|
|
7673
|
+
}
|
|
7674
|
+
async checkoutBranch(branchName, options = {}) {
|
|
7675
|
+
if (!this.cloneSyncService) {
|
|
7676
|
+
throw new ConfigError("checkoutBranch is only available for clone-mode repositories", "CLONE_MODE_REQUIRED");
|
|
7677
|
+
}
|
|
7678
|
+
await this.cloneSyncService.checkoutBranch(branchName, options);
|
|
7679
|
+
}
|
|
6023
7680
|
async initialize() {
|
|
6024
7681
|
if (this.isInitialized()) return;
|
|
6025
7682
|
const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
|
|
@@ -6044,11 +7701,28 @@ var WorktreeSyncService = class {
|
|
|
6044
7701
|
return this.gitService.isInitialized();
|
|
6045
7702
|
}
|
|
6046
7703
|
isSyncInProgress() {
|
|
6047
|
-
return this.
|
|
7704
|
+
return this.repoMutex.activeCount + this.repoMutex.pendingCount > 0;
|
|
6048
7705
|
}
|
|
6049
7706
|
getGitService() {
|
|
6050
7707
|
return this.gitService;
|
|
6051
7708
|
}
|
|
7709
|
+
// Restore must hold the repo lock: the reaper, prune, and gc all mutate the
|
|
7710
|
+
// same trash entries and refs at the tail of a sync. wait:true queues behind
|
|
7711
|
+
// an in-flight sync instead of failing fast — restores are explicit user
|
|
7712
|
+
// actions, not periodic work.
|
|
7713
|
+
async restoreFromTrash(id) {
|
|
7714
|
+
const result = await this.runExclusiveRepoOperation(() => this.trashService.restore(id), { wait: true });
|
|
7715
|
+
if (!result.started) {
|
|
7716
|
+
throw new TrashOperationError(
|
|
7717
|
+
"restore",
|
|
7718
|
+
`cannot restore trash entry '${id}': another process holds the repo lock`
|
|
7719
|
+
);
|
|
7720
|
+
}
|
|
7721
|
+
return result.value;
|
|
7722
|
+
}
|
|
7723
|
+
async listTrashEntries() {
|
|
7724
|
+
return this.trashService.listEntries();
|
|
7725
|
+
}
|
|
6052
7726
|
updateLogger(logger) {
|
|
6053
7727
|
this.logger = logger;
|
|
6054
7728
|
this.gitService.updateLogger(logger);
|
|
@@ -6056,44 +7730,73 @@ var WorktreeSyncService = class {
|
|
|
6056
7730
|
this.retryPolicy.updateLogger(logger);
|
|
6057
7731
|
this.worktreeModeSyncRunner.updateLogger(logger);
|
|
6058
7732
|
this.repoOperationLock.updateLogger(logger);
|
|
7733
|
+
this.maintenanceService.updateLogger(logger);
|
|
7734
|
+
this.trashService.updateLogger(logger);
|
|
7735
|
+
this.trashReaper.updateLogger(logger);
|
|
7736
|
+
this.trashMigration.updateLogger(logger);
|
|
7737
|
+
}
|
|
7738
|
+
// Runs git gc when due, inside the already-held repo lock (mirrors
|
|
7739
|
+
// initializeUnlocked — must NOT re-acquire runExclusiveRepoOperation or it
|
|
7740
|
+
// would self-deadlock/skip). Skipped under NODE_ENV=test so unit suites don't
|
|
7741
|
+
// shell out to real git; GitMaintenanceService is covered by its own tests.
|
|
7742
|
+
async runMaintenanceIfDueUnlocked() {
|
|
7743
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
7744
|
+
return;
|
|
7745
|
+
}
|
|
7746
|
+
await this.maintenanceService.runIfDueUnlocked();
|
|
7747
|
+
}
|
|
7748
|
+
// Same contract as runMaintenanceIfDueUnlocked: tail of a successful sync,
|
|
7749
|
+
// inside the held lock, never fails the sync. Runs before gc so freshly
|
|
7750
|
+
// reaped pin refs can be collected in the same maintenance window.
|
|
7751
|
+
async runTrashMaintenanceUnlocked() {
|
|
7752
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
7753
|
+
return;
|
|
7754
|
+
}
|
|
7755
|
+
if (this.cloneSyncService) {
|
|
7756
|
+
return;
|
|
7757
|
+
}
|
|
7758
|
+
try {
|
|
7759
|
+
await this.trashMigration.migrateLegacyUnlocked();
|
|
7760
|
+
await this.trashReaper.reapExpiredUnlocked();
|
|
7761
|
+
} catch (error) {
|
|
7762
|
+
this.logger.warn(`\u26A0\uFE0F Trash maintenance failed: ${getErrorMessage(error)}`);
|
|
7763
|
+
}
|
|
6059
7764
|
}
|
|
6060
7765
|
onProgress(listener) {
|
|
6061
7766
|
return this.progressEmitter.onProgress(listener);
|
|
6062
7767
|
}
|
|
6063
|
-
async runExclusiveRepoOperation(operation) {
|
|
6064
|
-
if (this.
|
|
7768
|
+
async runExclusiveRepoOperation(operation, options = {}) {
|
|
7769
|
+
if (!options.wait && this.repoMutex.activeCount + this.repoMutex.pendingCount > 0) {
|
|
6065
7770
|
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
6066
7771
|
return { started: false, reason: "in_progress" };
|
|
6067
7772
|
}
|
|
6068
|
-
this.
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
throw error;
|
|
6075
|
-
}
|
|
6076
|
-
if (release === null) {
|
|
6077
|
-
this.syncInProgress = false;
|
|
6078
|
-
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
6079
|
-
return { started: false, reason: "locked" };
|
|
6080
|
-
}
|
|
6081
|
-
try {
|
|
6082
|
-
return { started: true, value: await operation() };
|
|
6083
|
-
} finally {
|
|
7773
|
+
return this.repoMutex(async () => {
|
|
7774
|
+
const release = await this.repoOperationLock.acquire();
|
|
7775
|
+
if (release === null) {
|
|
7776
|
+
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
7777
|
+
return { started: false, reason: "locked" };
|
|
7778
|
+
}
|
|
6084
7779
|
try {
|
|
6085
|
-
await
|
|
6086
|
-
}
|
|
6087
|
-
|
|
7780
|
+
return { started: true, value: await operation() };
|
|
7781
|
+
} finally {
|
|
7782
|
+
try {
|
|
7783
|
+
await release();
|
|
7784
|
+
} catch (releaseError) {
|
|
7785
|
+
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
7786
|
+
}
|
|
6088
7787
|
}
|
|
6089
|
-
|
|
6090
|
-
|
|
7788
|
+
});
|
|
7789
|
+
}
|
|
7790
|
+
// Interactive variant: queues behind any in-flight sync/op instead of failing fast.
|
|
7791
|
+
async runQueuedRepoOperation(operation) {
|
|
7792
|
+
return this.runExclusiveRepoOperation(operation, { wait: true });
|
|
6091
7793
|
}
|
|
6092
7794
|
emitProgress(event) {
|
|
6093
7795
|
this.progressEmitter.emit(event);
|
|
6094
7796
|
}
|
|
6095
7797
|
async sync() {
|
|
6096
7798
|
const result = await this.runExclusiveRepoOperation(async () => {
|
|
7799
|
+
this.clearRecordedSkips();
|
|
6097
7800
|
const totalTimer = new Timer();
|
|
6098
7801
|
const phaseTimer = new PhaseTimer();
|
|
6099
7802
|
const outcome = new SyncOutcomeAccumulator({
|
|
@@ -6142,7 +7845,9 @@ var WorktreeSyncService = class {
|
|
|
6142
7845
|
const repoName = this.config.name;
|
|
6143
7846
|
this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
|
|
6144
7847
|
}
|
|
7848
|
+
await this.runTrashMaintenanceUnlocked();
|
|
6145
7849
|
}
|
|
7850
|
+
await this.runMaintenanceIfDueUnlocked();
|
|
6146
7851
|
return this.lastOutcome ?? outcome.toOutcome(durationMs);
|
|
6147
7852
|
});
|
|
6148
7853
|
return result.started ? { started: true, outcome: result.value } : result;
|
|
@@ -6275,54 +7980,6 @@ var HookExecutionService = class {
|
|
|
6275
7980
|
}
|
|
6276
7981
|
};
|
|
6277
7982
|
|
|
6278
|
-
// src/utils/disk-space.ts
|
|
6279
|
-
import fastFolderSize from "fast-folder-size";
|
|
6280
|
-
async function calculateDirectorySize(dirPath) {
|
|
6281
|
-
return new Promise((resolve12, reject) => {
|
|
6282
|
-
fastFolderSize(dirPath, (err, bytes) => {
|
|
6283
|
-
if (err) {
|
|
6284
|
-
reject(err);
|
|
6285
|
-
return;
|
|
6286
|
-
}
|
|
6287
|
-
if (bytes === void 0) {
|
|
6288
|
-
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
6289
|
-
return;
|
|
6290
|
-
}
|
|
6291
|
-
resolve12(bytes);
|
|
6292
|
-
});
|
|
6293
|
-
});
|
|
6294
|
-
}
|
|
6295
|
-
function formatBytes(bytes) {
|
|
6296
|
-
if (bytes === 0) return "0 B";
|
|
6297
|
-
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
6298
|
-
const k = 1024;
|
|
6299
|
-
const decimals = 2;
|
|
6300
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
6301
|
-
const value = bytes / Math.pow(k, i);
|
|
6302
|
-
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
6303
|
-
}
|
|
6304
|
-
async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
|
|
6305
|
-
try {
|
|
6306
|
-
let totalBytes = 0;
|
|
6307
|
-
for (const repoPath of repoPaths) {
|
|
6308
|
-
try {
|
|
6309
|
-
totalBytes += await calculateDirectorySize(repoPath);
|
|
6310
|
-
} catch {
|
|
6311
|
-
}
|
|
6312
|
-
}
|
|
6313
|
-
for (const worktreeDir of worktreeDirs) {
|
|
6314
|
-
try {
|
|
6315
|
-
totalBytes += await calculateDirectorySize(worktreeDir);
|
|
6316
|
-
} catch {
|
|
6317
|
-
}
|
|
6318
|
-
}
|
|
6319
|
-
return formatBytes(totalBytes);
|
|
6320
|
-
} catch (error) {
|
|
6321
|
-
console.error("Failed to calculate disk space:", error);
|
|
6322
|
-
return "N/A";
|
|
6323
|
-
}
|
|
6324
|
-
}
|
|
6325
|
-
|
|
6326
7983
|
// src/utils/app-events.ts
|
|
6327
7984
|
var AppEventEmitter = class {
|
|
6328
7985
|
listeners = /* @__PURE__ */ new Map();
|
|
@@ -6359,7 +8016,7 @@ var AppEventEmitter = class {
|
|
|
6359
8016
|
};
|
|
6360
8017
|
|
|
6361
8018
|
// src/services/InteractiveUIService.tsx
|
|
6362
|
-
import * as
|
|
8019
|
+
import * as fs17 from "fs/promises";
|
|
6363
8020
|
var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
|
|
6364
8021
|
var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
|
|
6365
8022
|
var InteractiveUIService = class {
|
|
@@ -6393,7 +8050,7 @@ var InteractiveUIService = class {
|
|
|
6393
8050
|
this.cronSchedule = cronSchedule;
|
|
6394
8051
|
this.repositoryCount = syncServices.length;
|
|
6395
8052
|
this.maxProgressLines = Math.max(1, maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
6396
|
-
this.limit =
|
|
8053
|
+
this.limit = pLimit3(this.maxProgressLines);
|
|
6397
8054
|
this.startBufferFlushCheck();
|
|
6398
8055
|
this.renderUI();
|
|
6399
8056
|
this.subscribeToServiceProgress();
|
|
@@ -6520,13 +8177,17 @@ var InteractiveUIService = class {
|
|
|
6520
8177
|
getRepositoryDiskUsage: (index) => this.getRepositoryDiskUsage(index),
|
|
6521
8178
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
6522
8179
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
6523
|
-
openEditorInWorktree: (
|
|
6524
|
-
openTerminalInWorktree: (repoIndex,
|
|
8180
|
+
openEditorInWorktree: (path24) => this.openEditorInWorktree(path24),
|
|
8181
|
+
openTerminalInWorktree: (repoIndex, path24, branchName) => this.openTerminalInWorktree(repoIndex, path24, branchName),
|
|
6525
8182
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
6526
8183
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
6527
8184
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
6528
8185
|
}
|
|
6529
|
-
)
|
|
8186
|
+
),
|
|
8187
|
+
{
|
|
8188
|
+
alternateScreen: true,
|
|
8189
|
+
incrementalRendering: true
|
|
8190
|
+
}
|
|
6530
8191
|
);
|
|
6531
8192
|
}
|
|
6532
8193
|
async handleManualSync() {
|
|
@@ -6641,14 +8302,14 @@ var InteractiveUIService = class {
|
|
|
6641
8302
|
if (Date.now() - startTime > timeoutMs) {
|
|
6642
8303
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
6643
8304
|
}
|
|
6644
|
-
await new Promise((
|
|
8305
|
+
await new Promise((resolve14) => setTimeout(resolve14, checkInterval));
|
|
6645
8306
|
}
|
|
6646
8307
|
});
|
|
6647
8308
|
try {
|
|
6648
8309
|
await Promise.all(syncChecks);
|
|
6649
8310
|
} catch {
|
|
6650
8311
|
this.addLog(
|
|
6651
|
-
`Warning: Timeout waiting for sync operations to complete after ${
|
|
8312
|
+
`Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
|
|
6652
8313
|
"warn"
|
|
6653
8314
|
);
|
|
6654
8315
|
}
|
|
@@ -6721,11 +8382,12 @@ var InteractiveUIService = class {
|
|
|
6721
8382
|
}
|
|
6722
8383
|
const sizeBytes = bareSizeBytes + worktreeSizeBytes;
|
|
6723
8384
|
const failedAllPaths = errors.length === sizeTargets.length;
|
|
8385
|
+
const partialFailure = errors.length > 0 && !failedAllPaths;
|
|
6724
8386
|
return {
|
|
6725
8387
|
repoIndex,
|
|
6726
8388
|
repoName,
|
|
6727
8389
|
sizeBytes: failedAllPaths ? null : sizeBytes,
|
|
6728
|
-
sizeFormatted: failedAllPaths ? "N/A" : formatBytes(sizeBytes),
|
|
8390
|
+
sizeFormatted: failedAllPaths ? "N/A" : partialFailure ? `\u2265${formatBytes(sizeBytes)}` : formatBytes(sizeBytes),
|
|
6729
8391
|
bareSizeBytes,
|
|
6730
8392
|
worktreeSizeBytes,
|
|
6731
8393
|
error: errors.length > 0 ? errors.join("; ") : void 0
|
|
@@ -6736,11 +8398,14 @@ var InteractiveUIService = class {
|
|
|
6736
8398
|
throw new Error(`Invalid repository index: ${repoIndex}`);
|
|
6737
8399
|
}
|
|
6738
8400
|
const service = this.syncServices[repoIndex];
|
|
6739
|
-
if (!service.isInitialized()) {
|
|
8401
|
+
if (!service.isInitialized() && !service.isCloneMode()) {
|
|
8402
|
+
return [];
|
|
8403
|
+
}
|
|
8404
|
+
try {
|
|
8405
|
+
return await service.getRemoteBranches();
|
|
8406
|
+
} catch {
|
|
6740
8407
|
return [];
|
|
6741
8408
|
}
|
|
6742
|
-
const gitService = service.getGitService();
|
|
6743
|
-
return gitService.getRemoteBranches();
|
|
6744
8409
|
}
|
|
6745
8410
|
getDefaultBranchForRepo(repoIndex) {
|
|
6746
8411
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -6755,11 +8420,18 @@ var InteractiveUIService = class {
|
|
|
6755
8420
|
throw new Error(`Invalid repository index: ${repoIndex}`);
|
|
6756
8421
|
}
|
|
6757
8422
|
const service = this.syncServices[repoIndex];
|
|
6758
|
-
|
|
6759
|
-
|
|
8423
|
+
const result = await service.runQueuedRepoOperation(async () => {
|
|
8424
|
+
if (!service.isInitialized()) {
|
|
8425
|
+
await service.initializeUnlocked();
|
|
8426
|
+
}
|
|
8427
|
+
if (service.isCloneMode()) {
|
|
8428
|
+
return;
|
|
8429
|
+
}
|
|
8430
|
+
await service.getGitService().fetchAll();
|
|
8431
|
+
});
|
|
8432
|
+
if (!result.started) {
|
|
8433
|
+
throw new Error("Another process holds the repository lock; fetch skipped. Try again.");
|
|
6760
8434
|
}
|
|
6761
|
-
const gitService = service.getGitService();
|
|
6762
|
-
await gitService.fetchAll();
|
|
6763
8435
|
}
|
|
6764
8436
|
async createAndPushBranch(repoIndex, baseBranch, branchName) {
|
|
6765
8437
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -6767,25 +8439,35 @@ var InteractiveUIService = class {
|
|
|
6767
8439
|
}
|
|
6768
8440
|
const service = this.syncServices[repoIndex];
|
|
6769
8441
|
const gitService = service.getGitService();
|
|
6770
|
-
const
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
8442
|
+
const result = await service.runQueuedRepoOperation(async () => {
|
|
8443
|
+
const maxAttempts = 10;
|
|
8444
|
+
let finalName = branchName;
|
|
8445
|
+
let suffix = 0;
|
|
8446
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
8447
|
+
try {
|
|
8448
|
+
await gitService.createBranch(finalName, baseBranch);
|
|
8449
|
+
await gitService.pushBranch(finalName);
|
|
8450
|
+
return { success: true, finalName };
|
|
8451
|
+
} catch (error) {
|
|
8452
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
8453
|
+
if (errorMessage.includes("already exists")) {
|
|
8454
|
+
suffix++;
|
|
8455
|
+
finalName = `${branchName}-${suffix}`;
|
|
8456
|
+
continue;
|
|
8457
|
+
}
|
|
8458
|
+
return { success: false, finalName: branchName, error: errorMessage };
|
|
6784
8459
|
}
|
|
6785
|
-
return { success: false, finalName: branchName, error: errorMessage };
|
|
6786
8460
|
}
|
|
8461
|
+
return { success: false, finalName: branchName, error: `Failed to create branch after ${maxAttempts} attempts` };
|
|
8462
|
+
});
|
|
8463
|
+
if (!result.started) {
|
|
8464
|
+
return {
|
|
8465
|
+
success: false,
|
|
8466
|
+
finalName: branchName,
|
|
8467
|
+
error: "Another process holds the repository lock; branch not created. Try again."
|
|
8468
|
+
};
|
|
6787
8469
|
}
|
|
6788
|
-
return
|
|
8470
|
+
return result.value;
|
|
6789
8471
|
}
|
|
6790
8472
|
async getWorktreesForRepo(repoIndex) {
|
|
6791
8473
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -6822,22 +8504,22 @@ var InteractiveUIService = class {
|
|
|
6822
8504
|
}
|
|
6823
8505
|
const service = this.syncServices[repoIndex];
|
|
6824
8506
|
const worktreeDir = service.config.worktreeDir;
|
|
6825
|
-
const divergedDir =
|
|
8507
|
+
const divergedDir = path20.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
6826
8508
|
let dirEntries;
|
|
6827
8509
|
try {
|
|
6828
|
-
dirEntries = await
|
|
8510
|
+
dirEntries = await fs17.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
6829
8511
|
} catch {
|
|
6830
8512
|
return [];
|
|
6831
8513
|
}
|
|
6832
8514
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
6833
8515
|
const results = await Promise.allSettled(
|
|
6834
8516
|
subdirs.map(async (entry) => {
|
|
6835
|
-
const fullPath =
|
|
6836
|
-
const infoFilePath =
|
|
8517
|
+
const fullPath = path20.join(divergedDir, entry.name);
|
|
8518
|
+
const infoFilePath = path20.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
6837
8519
|
let originalBranch = entry.name;
|
|
6838
8520
|
let divergedAt = "";
|
|
6839
8521
|
try {
|
|
6840
|
-
const infoContent = await
|
|
8522
|
+
const infoContent = await fs17.readFile(infoFilePath, "utf-8");
|
|
6841
8523
|
const info = JSON.parse(infoContent);
|
|
6842
8524
|
if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
|
|
6843
8525
|
if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
|
|
@@ -6868,15 +8550,15 @@ var InteractiveUIService = class {
|
|
|
6868
8550
|
}
|
|
6869
8551
|
const service = this.syncServices[repoIndex];
|
|
6870
8552
|
const worktreeDir = service.config.worktreeDir;
|
|
6871
|
-
const divergedBase =
|
|
8553
|
+
const divergedBase = path20.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
6872
8554
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
6873
8555
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
6874
8556
|
}
|
|
6875
|
-
const targetPath =
|
|
8557
|
+
const targetPath = path20.join(divergedBase, name);
|
|
6876
8558
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
6877
8559
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
6878
8560
|
}
|
|
6879
|
-
await
|
|
8561
|
+
await fs17.rm(targetPath, { recursive: true, force: true });
|
|
6880
8562
|
this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
|
|
6881
8563
|
}
|
|
6882
8564
|
async createWorktreeForBranch(repoIndex, branchName) {
|
|
@@ -6887,7 +8569,16 @@ var InteractiveUIService = class {
|
|
|
6887
8569
|
const gitService = service.getGitService();
|
|
6888
8570
|
const worktreeDir = service.config.worktreeDir;
|
|
6889
8571
|
const worktreePath = this.pathResolution.getBranchWorktreePath(worktreeDir, branchName);
|
|
6890
|
-
await
|
|
8572
|
+
const result = await service.runQueuedRepoOperation(async () => {
|
|
8573
|
+
if (service.isCloneMode()) {
|
|
8574
|
+
await service.checkoutBranch(branchName, { allowConfigDrift: true });
|
|
8575
|
+
return;
|
|
8576
|
+
}
|
|
8577
|
+
await gitService.addWorktree(branchName, worktreePath);
|
|
8578
|
+
});
|
|
8579
|
+
if (!result.started) {
|
|
8580
|
+
throw new Error("Another process holds the repository lock; worktree not created. Try again.");
|
|
8581
|
+
}
|
|
6891
8582
|
}
|
|
6892
8583
|
openEditorInWorktree(worktreePath) {
|
|
6893
8584
|
const editor = process.env.EDITOR || process.env.VISUAL || "code";
|
|
@@ -7229,8 +8920,8 @@ function parseArguments(argv = hideBin(process.argv)) {
|
|
|
7229
8920
|
}
|
|
7230
8921
|
|
|
7231
8922
|
// src/utils/config-generator.ts
|
|
7232
|
-
import * as
|
|
7233
|
-
import * as
|
|
8923
|
+
import * as fs18 from "fs/promises";
|
|
8924
|
+
import * as path21 from "path";
|
|
7234
8925
|
function serializeToESM(obj, indent = 0) {
|
|
7235
8926
|
const spaces = " ".repeat(indent);
|
|
7236
8927
|
const innerSpaces = " ".repeat(indent + 2);
|
|
@@ -7260,9 +8951,9 @@ ${spaces}}`;
|
|
|
7260
8951
|
return String(obj);
|
|
7261
8952
|
}
|
|
7262
8953
|
async function generateConfigFile(input2, configPath, options = {}) {
|
|
7263
|
-
const configDir =
|
|
7264
|
-
await
|
|
7265
|
-
const worktreeDirRelative =
|
|
8954
|
+
const configDir = path21.dirname(configPath);
|
|
8955
|
+
await fs18.mkdir(configDir, { recursive: true });
|
|
8956
|
+
const worktreeDirRelative = path21.relative(configDir, input2.worktreeDir);
|
|
7266
8957
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
7267
8958
|
const repoName = extractRepoNameFromUrl(input2.repoUrl);
|
|
7268
8959
|
const repository = {
|
|
@@ -7271,7 +8962,7 @@ async function generateConfigFile(input2, configPath, options = {}) {
|
|
|
7271
8962
|
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
|
|
7272
8963
|
};
|
|
7273
8964
|
if (input2.bareRepoDir) {
|
|
7274
|
-
const bareRepoDirRelative =
|
|
8965
|
+
const bareRepoDirRelative = path21.relative(configDir, input2.bareRepoDir);
|
|
7275
8966
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
7276
8967
|
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
|
|
7277
8968
|
}
|
|
@@ -7298,7 +8989,7 @@ const config = ${serializeToESM(configObject)};
|
|
|
7298
8989
|
export default config;
|
|
7299
8990
|
`;
|
|
7300
8991
|
try {
|
|
7301
|
-
await
|
|
8992
|
+
await fs18.writeFile(configPath, configContent, {
|
|
7302
8993
|
encoding: "utf-8",
|
|
7303
8994
|
flag: options.overwrite ? "w" : "wx"
|
|
7304
8995
|
});
|
|
@@ -7310,11 +9001,11 @@ export default config;
|
|
|
7310
9001
|
}
|
|
7311
9002
|
}
|
|
7312
9003
|
function getDefaultConfigPath() {
|
|
7313
|
-
return
|
|
9004
|
+
return path21.join(process.cwd(), "sync-worktrees.config.js");
|
|
7314
9005
|
}
|
|
7315
9006
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
7316
9007
|
for (const name of CONFIG_FILE_NAMES) {
|
|
7317
|
-
const full =
|
|
9008
|
+
const full = path21.join(cwd, name);
|
|
7318
9009
|
if (await fileExists(full)) {
|
|
7319
9010
|
return full;
|
|
7320
9011
|
}
|
|
@@ -7323,7 +9014,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
|
|
|
7323
9014
|
}
|
|
7324
9015
|
|
|
7325
9016
|
// src/utils/interactive.ts
|
|
7326
|
-
import * as
|
|
9017
|
+
import * as path22 from "path";
|
|
7327
9018
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
7328
9019
|
async function promptForInitConfig() {
|
|
7329
9020
|
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
@@ -7354,8 +9045,8 @@ async function promptForInitConfig() {
|
|
|
7354
9045
|
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
7355
9046
|
worktreeDir = defaultWorktreeDir;
|
|
7356
9047
|
}
|
|
7357
|
-
if (!
|
|
7358
|
-
worktreeDir =
|
|
9048
|
+
if (!path22.isAbsolute(worktreeDir)) {
|
|
9049
|
+
worktreeDir = path22.resolve(worktreeDir);
|
|
7359
9050
|
}
|
|
7360
9051
|
let bareRepoDir;
|
|
7361
9052
|
const askForBareDir = await confirm({
|
|
@@ -7373,8 +9064,8 @@ async function promptForInitConfig() {
|
|
|
7373
9064
|
return true;
|
|
7374
9065
|
}
|
|
7375
9066
|
});
|
|
7376
|
-
if (!
|
|
7377
|
-
bareRepoDir =
|
|
9067
|
+
if (!path22.isAbsolute(bareRepoDir)) {
|
|
9068
|
+
bareRepoDir = path22.resolve(bareRepoDir);
|
|
7378
9069
|
}
|
|
7379
9070
|
}
|
|
7380
9071
|
const runMode = await select({
|
|
@@ -7465,7 +9156,7 @@ async function runMultipleRepositories(configFile, repositories, configPath) {
|
|
|
7465
9156
|
const globalLogger = Logger.createDefault();
|
|
7466
9157
|
const runOnce = configFile.defaults?.runOnce ?? false;
|
|
7467
9158
|
const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
|
|
7468
|
-
const limit =
|
|
9159
|
+
const limit = pLimit4(maxParallel);
|
|
7469
9160
|
if (runOnce) {
|
|
7470
9161
|
globalLogger.info(`
|
|
7471
9162
|
\u{1F504} Syncing ${repositories.length} repositories...`);
|
|
@@ -7627,7 +9318,7 @@ async function runFromConfigFile(configPath, runOnceOverride = false) {
|
|
|
7627
9318
|
await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
|
|
7628
9319
|
}
|
|
7629
9320
|
async function resolveConfigOrExit(cliPath) {
|
|
7630
|
-
const resolved = cliPath ?
|
|
9321
|
+
const resolved = cliPath ? path23.resolve(cliPath) : await findConfigInCwd();
|
|
7631
9322
|
if (!resolved) {
|
|
7632
9323
|
console.error(
|
|
7633
9324
|
"\u274C No config file found. Pass --config <path>, run `sync-worktrees init` to create one, or place a sync-worktrees.config.{js,mjs,cjs} in this directory."
|
|
@@ -7643,7 +9334,7 @@ function exitConfigExists(targetPath) {
|
|
|
7643
9334
|
process.exit(1);
|
|
7644
9335
|
}
|
|
7645
9336
|
async function runInit(configPath, force) {
|
|
7646
|
-
const targetPath = configPath ?
|
|
9337
|
+
const targetPath = configPath ? path23.resolve(configPath) : getDefaultConfigPath();
|
|
7647
9338
|
if (!force && await fileExists(targetPath)) {
|
|
7648
9339
|
exitConfigExists(targetPath);
|
|
7649
9340
|
}
|
|
@@ -7656,7 +9347,7 @@ async function runInit(configPath, force) {
|
|
|
7656
9347
|
}
|
|
7657
9348
|
throw error;
|
|
7658
9349
|
}
|
|
7659
|
-
const displayPath =
|
|
9350
|
+
const displayPath = path23.relative(process.cwd(), targetPath) || targetPath;
|
|
7660
9351
|
console.log(`
|
|
7661
9352
|
\u2705 Configuration saved to: ${targetPath}`);
|
|
7662
9353
|
console.log(`
|
|
@@ -7664,7 +9355,7 @@ async function runInit(configPath, force) {
|
|
|
7664
9355
|
}
|
|
7665
9356
|
async function runSync(options) {
|
|
7666
9357
|
const configPath = await resolveConfigOrExit(options.config);
|
|
7667
|
-
const displayPath =
|
|
9358
|
+
const displayPath = path23.relative(process.cwd(), configPath) || configPath;
|
|
7668
9359
|
console.log(`\u{1F4C4} Using config: ${displayPath}`);
|
|
7669
9360
|
try {
|
|
7670
9361
|
await runFromConfigFile(configPath, options.runOnce);
|