sync-worktrees 3.6.2 → 4.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 +388 -263
- package/dist/components/App.d.ts +52 -0
- package/dist/components/App.d.ts.map +1 -0
- package/dist/components/BranchCreationWizard.d.ts +26 -0
- package/dist/components/BranchCreationWizard.d.ts.map +1 -0
- package/dist/components/HelpModal.d.ts +7 -0
- package/dist/components/HelpModal.d.ts.map +1 -0
- package/dist/components/LogPanel.d.ts +10 -0
- package/dist/components/LogPanel.d.ts.map +1 -0
- package/dist/components/LogViewer.d.ts +9 -0
- package/dist/components/LogViewer.d.ts.map +1 -0
- package/dist/components/OpenEditorWizard.d.ts +25 -0
- package/dist/components/OpenEditorWizard.d.ts.map +1 -0
- package/dist/components/StatusBar.d.ts +11 -0
- package/dist/components/StatusBar.d.ts.map +1 -0
- package/dist/components/WorktreeStatusView.d.ts +17 -0
- package/dist/components/WorktreeStatusView.d.ts.map +1 -0
- package/dist/constants.d.ts +112 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/errors/index.d.ts +59 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2327 -1082
- package/dist/index.js.map +4 -4
- package/dist/mcp/context.d.ts +143 -0
- package/dist/mcp/context.d.ts.map +1 -0
- package/dist/mcp/handlers.d.ts +46 -0
- package/dist/mcp/handlers.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/utils.d.ts +14 -0
- package/dist/mcp/utils.d.ts.map +1 -0
- package/dist/mcp/worktree-summary.d.ts +14 -0
- package/dist/mcp/worktree-summary.d.ts.map +1 -0
- package/dist/mcp-server.js +2513 -691
- package/dist/mcp-server.js.map +4 -4
- package/dist/services/InteractiveUIService.d.ts +85 -0
- package/dist/services/InteractiveUIService.d.ts.map +1 -0
- package/dist/services/branch-created-actions.service.d.ts +27 -0
- package/dist/services/branch-created-actions.service.d.ts.map +1 -0
- package/dist/services/clone-sync.service.d.ts +93 -0
- package/dist/services/clone-sync.service.d.ts.map +1 -0
- package/dist/services/config-loader.service.d.ts +28 -0
- package/dist/services/config-loader.service.d.ts.map +1 -0
- package/dist/services/file-copy.service.d.ts +19 -0
- package/dist/services/file-copy.service.d.ts.map +1 -0
- package/dist/services/git.service.d.ts +92 -0
- package/dist/services/git.service.d.ts.map +1 -0
- package/dist/services/hook-execution.service.d.ts +20 -0
- package/dist/services/hook-execution.service.d.ts.map +1 -0
- package/dist/services/logger.service.d.ts +24 -0
- package/dist/services/logger.service.d.ts.map +1 -0
- package/dist/services/path-resolution.service.d.ts +10 -0
- package/dist/services/path-resolution.service.d.ts.map +1 -0
- package/dist/services/progress-emitter.d.ts +14 -0
- package/dist/services/progress-emitter.d.ts.map +1 -0
- package/dist/services/repo-operation-lock.d.ts +16 -0
- package/dist/services/repo-operation-lock.d.ts.map +1 -0
- package/dist/services/sparse-checkout.service.d.ts +45 -0
- package/dist/services/sparse-checkout.service.d.ts.map +1 -0
- package/dist/services/sync-outcome.d.ts +47 -0
- package/dist/services/sync-outcome.d.ts.map +1 -0
- package/dist/services/sync-retry-policy.d.ts +18 -0
- package/dist/services/sync-retry-policy.d.ts.map +1 -0
- package/dist/services/worktree-metadata.service.d.ts +25 -0
- package/dist/services/worktree-metadata.service.d.ts.map +1 -0
- package/dist/services/worktree-mode-sync-runner.d.ts +36 -0
- package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -0
- package/dist/services/worktree-status.service.d.ts +60 -0
- package/dist/services/worktree-status.service.d.ts.map +1 -0
- package/dist/services/worktree-sync-planner.d.ts +62 -0
- package/dist/services/worktree-sync-planner.d.ts.map +1 -0
- package/dist/services/worktree-sync.service.d.ts +49 -0
- package/dist/services/worktree-sync.service.d.ts.map +1 -0
- package/dist/types/index.d.ts +289 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/sync-metadata.d.ts +16 -0
- package/dist/types/sync-metadata.d.ts.map +1 -0
- package/dist/utils/app-events.d.ts +21 -0
- package/dist/utils/app-events.d.ts.map +1 -0
- package/dist/utils/branch-filter.d.ts +3 -0
- package/dist/utils/branch-filter.d.ts.map +1 -0
- package/dist/utils/cli.d.ts +21 -0
- package/dist/utils/cli.d.ts.map +1 -0
- package/dist/utils/clone-skip-format.d.ts +3 -0
- package/dist/utils/clone-skip-format.d.ts.map +1 -0
- package/dist/utils/config-generator.d.ts +10 -0
- package/dist/utils/config-generator.d.ts.map +1 -0
- package/dist/utils/date-filter.d.ts +10 -0
- package/dist/utils/date-filter.d.ts.map +1 -0
- package/dist/utils/disk-space.d.ts +23 -0
- package/dist/utils/disk-space.d.ts.map +1 -0
- package/dist/utils/file-exists.d.ts +2 -0
- package/dist/utils/file-exists.d.ts.map +1 -0
- package/dist/utils/git-progress.d.ts +25 -0
- package/dist/utils/git-progress.d.ts.map +1 -0
- package/dist/utils/git-url.d.ts +23 -0
- package/dist/utils/git-url.d.ts.map +1 -0
- package/dist/utils/git-validation.d.ts +5 -0
- package/dist/utils/git-validation.d.ts.map +1 -0
- package/dist/utils/interactive.d.ts +3 -0
- package/dist/utils/interactive.d.ts.map +1 -0
- package/dist/utils/lfs-error.d.ts +35 -0
- package/dist/utils/lfs-error.d.ts.map +1 -0
- package/dist/utils/lock-path.d.ts +9 -0
- package/dist/utils/lock-path.d.ts.map +1 -0
- package/dist/utils/path-compare.d.ts +16 -0
- package/dist/utils/path-compare.d.ts.map +1 -0
- package/dist/utils/repo-mode.d.ts +8 -0
- package/dist/utils/repo-mode.d.ts.map +1 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/sanitize-name.d.ts +2 -0
- package/dist/utils/sanitize-name.d.ts.map +1 -0
- package/dist/utils/shell-escape.d.ts +5 -0
- package/dist/utils/shell-escape.d.ts.map +1 -0
- package/dist/utils/signal-handlers.d.ts +14 -0
- package/dist/utils/signal-handlers.d.ts.map +1 -0
- package/dist/utils/timing.d.ts +24 -0
- package/dist/utils/timing.d.ts.map +1 -0
- package/dist/utils/worktree-list-parser.d.ts +10 -0
- package/dist/utils/worktree-list-parser.d.ts.map +1 -0
- package/package.json +5 -2
package/dist/mcp-server.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
6
|
// src/mcp/context.ts
|
|
7
|
-
import * as
|
|
8
|
-
import * as
|
|
9
|
-
import
|
|
7
|
+
import * as fs10 from "fs/promises";
|
|
8
|
+
import * as path14 from "path";
|
|
9
|
+
import pLimit2 from "p-limit";
|
|
10
|
+
import simpleGit6 from "simple-git";
|
|
10
11
|
|
|
11
12
|
// src/constants.ts
|
|
12
13
|
var GIT_CONSTANTS = {
|
|
@@ -89,7 +90,8 @@ var ENV_CONSTANTS = {
|
|
|
89
90
|
};
|
|
90
91
|
var PATH_CONSTANTS = {
|
|
91
92
|
GIT_DIR: ".git",
|
|
92
|
-
README: "README"
|
|
93
|
+
README: "README",
|
|
94
|
+
CLONE_INIT_MARKER: ".sync-worktrees-clone-init"
|
|
93
95
|
};
|
|
94
96
|
var CONFIG_FILE_NAMES = [
|
|
95
97
|
"sync-worktrees.config.js",
|
|
@@ -108,11 +110,65 @@ var METADATA_CONSTANTS = {
|
|
|
108
110
|
};
|
|
109
111
|
|
|
110
112
|
// src/services/config-loader.service.ts
|
|
111
|
-
import * as fs from "fs/promises";
|
|
112
113
|
import * as path2 from "path";
|
|
113
114
|
import { pathToFileURL } from "url";
|
|
114
115
|
import * as cron from "node-cron";
|
|
115
116
|
|
|
117
|
+
// src/errors/index.ts
|
|
118
|
+
var SyncWorktreesError = class extends Error {
|
|
119
|
+
constructor(message, code, cause) {
|
|
120
|
+
super(message);
|
|
121
|
+
this.code = code;
|
|
122
|
+
this.cause = cause;
|
|
123
|
+
this.name = this.constructor.name;
|
|
124
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
125
|
+
if (cause && cause.stack) {
|
|
126
|
+
this.stack = `${this.stack}
|
|
127
|
+
Caused by: ${cause.stack}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
var GitError = class extends SyncWorktreesError {
|
|
132
|
+
constructor(message, code, cause) {
|
|
133
|
+
super(message, `GIT_${code}`, cause);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var GitOperationError = class extends GitError {
|
|
137
|
+
constructor(operation, details, cause) {
|
|
138
|
+
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
var WorktreeError = class extends SyncWorktreesError {
|
|
142
|
+
constructor(message, code, cause) {
|
|
143
|
+
super(message, `WORKTREE_${code}`, cause);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
var WorktreeNotCleanError = class extends WorktreeError {
|
|
147
|
+
constructor(path16, reasons) {
|
|
148
|
+
super(`Worktree at '${path16}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
149
|
+
this.path = path16;
|
|
150
|
+
this.reasons = reasons;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
var ConfigError = class extends SyncWorktreesError {
|
|
154
|
+
constructor(message, code, cause) {
|
|
155
|
+
super(message, `CONFIG_${code}`, cause);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var ConfigValidationError = class extends ConfigError {
|
|
159
|
+
constructor(field, reason) {
|
|
160
|
+
super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
|
|
161
|
+
this.field = field;
|
|
162
|
+
this.reason = reason;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var ConfigFileNotFoundError = class extends ConfigError {
|
|
166
|
+
constructor(configPath) {
|
|
167
|
+
super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
|
|
168
|
+
this.configPath = configPath;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
116
172
|
// src/utils/branch-filter.ts
|
|
117
173
|
function matchesPattern(name, pattern) {
|
|
118
174
|
if (pattern.includes("*")) {
|
|
@@ -133,6 +189,17 @@ function filterBranchesByName(branches, include, exclude) {
|
|
|
133
189
|
return result;
|
|
134
190
|
}
|
|
135
191
|
|
|
192
|
+
// src/utils/file-exists.ts
|
|
193
|
+
import * as fs from "fs/promises";
|
|
194
|
+
async function fileExists(path16) {
|
|
195
|
+
try {
|
|
196
|
+
await fs.access(path16);
|
|
197
|
+
return true;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
136
203
|
// src/utils/git-url.ts
|
|
137
204
|
function extractRepoNameFromUrl(gitUrl) {
|
|
138
205
|
const url = gitUrl.trim();
|
|
@@ -158,6 +225,16 @@ function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
|
158
225
|
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
159
226
|
return `${baseDir}/${repoName}`;
|
|
160
227
|
}
|
|
228
|
+
function normalizeRepoUrlForComparison(url) {
|
|
229
|
+
let normalized = url.trim();
|
|
230
|
+
const isForgeUrl = /^(https?|ssh|git):\/\//i.test(normalized) || /^[\w.-]+@[^/]+:/.test(normalized);
|
|
231
|
+
normalized = normalized.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+/, (prefix) => prefix.toLowerCase());
|
|
232
|
+
normalized = normalized.replace(/\/+$/, "");
|
|
233
|
+
if (isForgeUrl) {
|
|
234
|
+
normalized = normalized.replace(/\.git$/, "");
|
|
235
|
+
}
|
|
236
|
+
return normalized;
|
|
237
|
+
}
|
|
161
238
|
|
|
162
239
|
// src/utils/path-compare.ts
|
|
163
240
|
import * as path from "path";
|
|
@@ -173,54 +250,17 @@ function pathsEqual(a, b, platform = process.platform) {
|
|
|
173
250
|
return normalizePathForCompare(a, platform) === normalizePathForCompare(b, platform);
|
|
174
251
|
}
|
|
175
252
|
|
|
176
|
-
// src/
|
|
177
|
-
var
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.code = code;
|
|
181
|
-
this.cause = cause;
|
|
182
|
-
this.name = this.constructor.name;
|
|
183
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
184
|
-
if (cause && cause.stack) {
|
|
185
|
-
this.stack = `${this.stack}
|
|
186
|
-
Caused by: ${cause.stack}`;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
var GitError = class extends SyncWorktreesError {
|
|
191
|
-
constructor(message, code, cause) {
|
|
192
|
-
super(message, `GIT_${code}`, cause);
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
var GitOperationError = class extends GitError {
|
|
196
|
-
constructor(operation, details, cause) {
|
|
197
|
-
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
var WorktreeError = class extends SyncWorktreesError {
|
|
201
|
-
constructor(message, code, cause) {
|
|
202
|
-
super(message, `WORKTREE_${code}`, cause);
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
var WorktreeNotCleanError = class extends WorktreeError {
|
|
206
|
-
constructor(path11, reasons) {
|
|
207
|
-
super(`Worktree at '${path11}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
208
|
-
this.path = path11;
|
|
209
|
-
this.reasons = reasons;
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
var ConfigError = class extends SyncWorktreesError {
|
|
213
|
-
constructor(message, code, cause) {
|
|
214
|
-
super(message, `CONFIG_${code}`, cause);
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
var ConfigValidationError = class extends ConfigError {
|
|
218
|
-
constructor(field, reason) {
|
|
219
|
-
super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
|
|
220
|
-
this.field = field;
|
|
221
|
-
this.reason = reason;
|
|
222
|
-
}
|
|
253
|
+
// src/utils/repo-mode.ts
|
|
254
|
+
var REPOSITORY_MODES = {
|
|
255
|
+
CLONE: "clone",
|
|
256
|
+
WORKTREE: "worktree"
|
|
223
257
|
};
|
|
258
|
+
function isRepositoryMode(value) {
|
|
259
|
+
return value === REPOSITORY_MODES.CLONE || value === REPOSITORY_MODES.WORKTREE;
|
|
260
|
+
}
|
|
261
|
+
function resolveMode(cfg) {
|
|
262
|
+
return cfg.mode ?? REPOSITORY_MODES.WORKTREE;
|
|
263
|
+
}
|
|
224
264
|
|
|
225
265
|
// src/utils/sanitize-name.ts
|
|
226
266
|
var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
@@ -244,6 +284,13 @@ function sanitizeNameForPath(name, fieldContext = "name") {
|
|
|
244
284
|
}
|
|
245
285
|
|
|
246
286
|
// src/services/config-loader.service.ts
|
|
287
|
+
var CLONE_MODE_CONFLICTING_FIELDS = [
|
|
288
|
+
"branchInclude",
|
|
289
|
+
"branchExclude",
|
|
290
|
+
"branchMaxAge",
|
|
291
|
+
"updateExistingWorktrees",
|
|
292
|
+
"bareRepoDir"
|
|
293
|
+
];
|
|
247
294
|
var ConfigLoaderService = class {
|
|
248
295
|
async findConfigUpward(startDir) {
|
|
249
296
|
let current = path2.resolve(startDir);
|
|
@@ -251,10 +298,8 @@ var ConfigLoaderService = class {
|
|
|
251
298
|
while (true) {
|
|
252
299
|
for (const name of CONFIG_FILE_NAMES) {
|
|
253
300
|
const candidate = path2.join(current, name);
|
|
254
|
-
|
|
255
|
-
await fs.access(candidate);
|
|
301
|
+
if (await fileExists(candidate)) {
|
|
256
302
|
return candidate;
|
|
257
|
-
} catch {
|
|
258
303
|
}
|
|
259
304
|
}
|
|
260
305
|
if (current === root) return null;
|
|
@@ -265,10 +310,8 @@ var ConfigLoaderService = class {
|
|
|
265
310
|
}
|
|
266
311
|
async loadConfigFile(configPath) {
|
|
267
312
|
const absolutePath = path2.resolve(configPath);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
} catch {
|
|
271
|
-
throw new Error(`Config file not found: ${absolutePath}`);
|
|
313
|
+
if (!await fileExists(absolutePath)) {
|
|
314
|
+
throw new ConfigFileNotFoundError(absolutePath);
|
|
272
315
|
}
|
|
273
316
|
try {
|
|
274
317
|
const fileUrl = pathToFileURL(absolutePath);
|
|
@@ -281,7 +324,7 @@ var ConfigLoaderService = class {
|
|
|
281
324
|
this.validateConfigFile(config);
|
|
282
325
|
return config;
|
|
283
326
|
} catch (error) {
|
|
284
|
-
if (error instanceof
|
|
327
|
+
if (error instanceof SyncWorktreesError) {
|
|
285
328
|
throw error;
|
|
286
329
|
}
|
|
287
330
|
throw new Error(`Failed to load config file: ${error.message}`);
|
|
@@ -334,6 +377,12 @@ var ConfigLoaderService = class {
|
|
|
334
377
|
if (repoObj.runOnce !== void 0 && typeof repoObj.runOnce !== "boolean") {
|
|
335
378
|
throw new Error(`Repository '${repoObj.name}' has invalid 'runOnce' property`);
|
|
336
379
|
}
|
|
380
|
+
if (repoObj.debug !== void 0 && typeof repoObj.debug !== "boolean") {
|
|
381
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'debug' property`);
|
|
382
|
+
}
|
|
383
|
+
if (repoObj.retry !== void 0) {
|
|
384
|
+
this.validateRetryConfig(repoObj.retry, `Repository '${repoObj.name}' retry config`);
|
|
385
|
+
}
|
|
337
386
|
if (repoObj.filesToCopyOnBranchCreate !== void 0) {
|
|
338
387
|
this.validateFilesToCopyConfig(repoObj.filesToCopyOnBranchCreate, `Repository '${repoObj.name}'`);
|
|
339
388
|
}
|
|
@@ -343,6 +392,8 @@ var ConfigLoaderService = class {
|
|
|
343
392
|
if (repoObj.sparseCheckout !== void 0) {
|
|
344
393
|
this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
|
|
345
394
|
}
|
|
395
|
+
this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
|
|
396
|
+
this.validateRepositoryMode(repoObj, configObj.defaults);
|
|
346
397
|
});
|
|
347
398
|
this.warnOnDuplicateRepoUrls(configObj.repositories);
|
|
348
399
|
if (configObj.defaults) {
|
|
@@ -359,9 +410,15 @@ var ConfigLoaderService = class {
|
|
|
359
410
|
if (defaults.runOnce !== void 0 && typeof defaults.runOnce !== "boolean") {
|
|
360
411
|
throw new Error("Invalid 'runOnce' in defaults");
|
|
361
412
|
}
|
|
413
|
+
if (defaults.debug !== void 0 && typeof defaults.debug !== "boolean") {
|
|
414
|
+
throw new Error("Invalid 'debug' in defaults");
|
|
415
|
+
}
|
|
362
416
|
if (defaults.retry !== void 0 && typeof defaults.retry !== "object") {
|
|
363
417
|
throw new Error("Invalid 'retry' in defaults");
|
|
364
418
|
}
|
|
419
|
+
if (defaults.retry !== void 0) {
|
|
420
|
+
this.validateRetryConfig(defaults.retry, "defaults retry config");
|
|
421
|
+
}
|
|
365
422
|
if (defaults.filesToCopyOnBranchCreate !== void 0) {
|
|
366
423
|
this.validateFilesToCopyConfig(defaults.filesToCopyOnBranchCreate, "defaults");
|
|
367
424
|
}
|
|
@@ -371,39 +428,17 @@ var ConfigLoaderService = class {
|
|
|
371
428
|
if (defaults.sparseCheckout !== void 0) {
|
|
372
429
|
this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
|
|
373
430
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
throw new Error("'retry' must be an object");
|
|
378
|
-
}
|
|
379
|
-
const retry2 = configObj.retry;
|
|
380
|
-
if (retry2.maxAttempts !== void 0) {
|
|
381
|
-
if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
|
|
382
|
-
throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
if (retry2.maxLfsRetries !== void 0) {
|
|
386
|
-
if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
|
|
387
|
-
throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
|
|
391
|
-
throw new Error("Invalid 'initialDelayMs' in retry config");
|
|
392
|
-
}
|
|
393
|
-
if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
|
|
394
|
-
throw new Error("Invalid 'maxDelayMs' in retry config");
|
|
431
|
+
this.validateDepth(defaults.depth, "defaults.depth");
|
|
432
|
+
if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
|
|
433
|
+
throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
|
|
395
434
|
}
|
|
396
|
-
if (
|
|
397
|
-
throw new
|
|
398
|
-
}
|
|
399
|
-
const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
|
|
400
|
-
const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
|
|
401
|
-
if (initialDelay > maxDelay) {
|
|
402
|
-
throw new Error(
|
|
403
|
-
`Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
|
|
404
|
-
);
|
|
435
|
+
if (defaults.branch !== void 0 && (typeof defaults.branch !== "string" || defaults.branch.trim() === "")) {
|
|
436
|
+
throw new ConfigValidationError("defaults.branch", "must be a non-empty string");
|
|
405
437
|
}
|
|
406
438
|
}
|
|
439
|
+
if (configObj.retry !== void 0) {
|
|
440
|
+
this.validateRetryConfig(configObj.retry, "retry config");
|
|
441
|
+
}
|
|
407
442
|
if (configObj.parallelism !== void 0) {
|
|
408
443
|
this.validateParallelismConfig(configObj.parallelism, "global");
|
|
409
444
|
}
|
|
@@ -414,6 +449,47 @@ var ConfigLoaderService = class {
|
|
|
414
449
|
}
|
|
415
450
|
}
|
|
416
451
|
}
|
|
452
|
+
validateDepth(value, field) {
|
|
453
|
+
if (value === void 0) return;
|
|
454
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
455
|
+
throw new ConfigValidationError(field, "must be a positive safe integer");
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
validateRetryConfig(value, context) {
|
|
459
|
+
if (typeof value !== "object" || value === null) {
|
|
460
|
+
throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
|
|
461
|
+
}
|
|
462
|
+
const retry2 = value;
|
|
463
|
+
if (retry2.maxAttempts !== void 0) {
|
|
464
|
+
if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
|
|
465
|
+
throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (retry2.maxLfsRetries !== void 0) {
|
|
469
|
+
if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
|
|
470
|
+
throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
|
|
474
|
+
throw new Error("Invalid 'initialDelayMs' in retry config");
|
|
475
|
+
}
|
|
476
|
+
if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
|
|
477
|
+
throw new Error("Invalid 'maxDelayMs' in retry config");
|
|
478
|
+
}
|
|
479
|
+
if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
|
|
480
|
+
throw new Error("Invalid 'backoffMultiplier' in retry config");
|
|
481
|
+
}
|
|
482
|
+
if (retry2.jitterMs !== void 0 && (typeof retry2.jitterMs !== "number" || retry2.jitterMs < 0)) {
|
|
483
|
+
throw new Error("Invalid 'jitterMs' in retry config");
|
|
484
|
+
}
|
|
485
|
+
const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
|
|
486
|
+
const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
|
|
487
|
+
if (initialDelay > maxDelay) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
417
493
|
validateParallelismConfig(parallelism, context) {
|
|
418
494
|
if (typeof parallelism !== "object" || parallelism === null) {
|
|
419
495
|
throw new Error(`'parallelism' in ${context} must be an object`);
|
|
@@ -514,6 +590,50 @@ var ConfigLoaderService = class {
|
|
|
514
590
|
}
|
|
515
591
|
}
|
|
516
592
|
}
|
|
593
|
+
validateRepositoryMode(repoObj, defaults) {
|
|
594
|
+
const repoName = repoObj.name;
|
|
595
|
+
const repoMode = repoObj.mode;
|
|
596
|
+
if (repoMode !== void 0 && !isRepositoryMode(repoMode)) {
|
|
597
|
+
throw new ConfigValidationError(`Repository '${repoName}' mode`, "must be 'clone' or 'worktree'");
|
|
598
|
+
}
|
|
599
|
+
if (repoObj.branch !== void 0 && (typeof repoObj.branch !== "string" || repoObj.branch.trim() === "")) {
|
|
600
|
+
throw new ConfigValidationError(`Repository '${repoName}' branch`, "must be a non-empty string");
|
|
601
|
+
}
|
|
602
|
+
const effectiveMode = repoMode ?? defaults?.mode;
|
|
603
|
+
if (effectiveMode !== REPOSITORY_MODES.CLONE) {
|
|
604
|
+
const depthFromRepo = repoObj.depth;
|
|
605
|
+
const depthFromDefaults = defaults?.depth;
|
|
606
|
+
if (depthFromRepo !== void 0 || depthFromDefaults !== void 0) {
|
|
607
|
+
const source = depthFromRepo !== void 0 ? "repository" : "defaults";
|
|
608
|
+
throw new ConfigValidationError(
|
|
609
|
+
`Repository '${repoName}' depth`,
|
|
610
|
+
`only supported when mode is 'clone' (set on ${source})`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const branchFromRepo = repoObj.branch;
|
|
614
|
+
const branchFromDefaults = defaults?.branch;
|
|
615
|
+
if (branchFromRepo !== void 0 || branchFromDefaults !== void 0) {
|
|
616
|
+
const source = branchFromRepo !== void 0 ? "repository" : "defaults";
|
|
617
|
+
throw new ConfigValidationError(
|
|
618
|
+
`Repository '${repoName}' branch`,
|
|
619
|
+
`only supported when mode is 'clone' (set on ${source})`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
for (const field of CLONE_MODE_CONFLICTING_FIELDS) {
|
|
625
|
+
const fromRepo = repoObj[field];
|
|
626
|
+
const fromDefaults = defaults?.[field];
|
|
627
|
+
const present = fromRepo !== void 0 || fromDefaults !== void 0;
|
|
628
|
+
if (present) {
|
|
629
|
+
const source = fromRepo !== void 0 ? "repository" : "defaults";
|
|
630
|
+
throw new ConfigValidationError(
|
|
631
|
+
`Repository '${repoName}' ${field}`,
|
|
632
|
+
`not supported when mode is 'clone' (set on ${source})`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
517
637
|
validateHooksConfig(hooks, context) {
|
|
518
638
|
if (typeof hooks !== "object" || hooks === null) {
|
|
519
639
|
throw new Error(`'hooks' in ${context} must be an object`);
|
|
@@ -534,29 +654,47 @@ var ConfigLoaderService = class {
|
|
|
534
654
|
}
|
|
535
655
|
}
|
|
536
656
|
resolveRepositoryConfig(repo, defaults, configDir, globalRetry, allRepositories) {
|
|
657
|
+
const mode = repo.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
|
|
537
658
|
const resolved = {
|
|
538
659
|
name: repo.name,
|
|
539
660
|
repoUrl: repo.repoUrl,
|
|
540
661
|
worktreeDir: this.resolvePath(repo.worktreeDir, configDir),
|
|
541
662
|
cronSchedule: repo.cronSchedule ?? defaults?.cronSchedule ?? DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
542
|
-
runOnce: repo.runOnce ?? defaults?.runOnce ?? false
|
|
663
|
+
runOnce: repo.runOnce ?? defaults?.runOnce ?? false,
|
|
664
|
+
debug: repo.debug ?? defaults?.debug,
|
|
665
|
+
mode
|
|
543
666
|
};
|
|
544
|
-
if (
|
|
545
|
-
resolved.
|
|
546
|
-
} else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories)) {
|
|
547
|
-
const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
|
|
548
|
-
resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
|
|
549
|
-
} else {
|
|
550
|
-
resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
|
|
667
|
+
if (configDir) {
|
|
668
|
+
resolved.__configFileDir = configDir;
|
|
551
669
|
}
|
|
552
|
-
if (
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
670
|
+
if (mode === REPOSITORY_MODES.CLONE) {
|
|
671
|
+
if (repo.branch ?? defaults?.branch) {
|
|
672
|
+
resolved.branch = repo.branch ?? defaults?.branch;
|
|
673
|
+
}
|
|
674
|
+
if (repo.depth !== void 0 || defaults?.depth !== void 0) {
|
|
675
|
+
resolved.depth = repo.depth ?? defaults?.depth;
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
if (repo.bareRepoDir) {
|
|
679
|
+
resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
|
|
680
|
+
} else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories, defaults)) {
|
|
681
|
+
const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
|
|
682
|
+
resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
|
|
683
|
+
} else {
|
|
684
|
+
resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
|
|
685
|
+
}
|
|
686
|
+
if (repo.branchMaxAge || defaults?.branchMaxAge) {
|
|
687
|
+
resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
|
|
688
|
+
}
|
|
689
|
+
if (repo.branchInclude || defaults?.branchInclude) {
|
|
690
|
+
resolved.branchInclude = repo.branchInclude ?? defaults?.branchInclude;
|
|
691
|
+
}
|
|
692
|
+
if (repo.branchExclude || defaults?.branchExclude) {
|
|
693
|
+
resolved.branchExclude = repo.branchExclude ?? defaults?.branchExclude;
|
|
694
|
+
}
|
|
695
|
+
if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
|
|
696
|
+
resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
|
|
697
|
+
}
|
|
560
698
|
}
|
|
561
699
|
if (repo.skipLfs !== void 0 || defaults?.skipLfs !== void 0) {
|
|
562
700
|
resolved.skipLfs = repo.skipLfs ?? defaults?.skipLfs ?? false;
|
|
@@ -574,9 +712,6 @@ var ConfigLoaderService = class {
|
|
|
574
712
|
...repo.parallelism || {}
|
|
575
713
|
};
|
|
576
714
|
}
|
|
577
|
-
if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
|
|
578
|
-
resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
|
|
579
|
-
}
|
|
580
715
|
if (repo.filesToCopyOnBranchCreate || defaults?.filesToCopyOnBranchCreate) {
|
|
581
716
|
const files = repo.filesToCopyOnBranchCreate ?? defaults?.filesToCopyOnBranchCreate;
|
|
582
717
|
resolved.filesToCopyOnBranchCreate = files?.map((f) => this.resolvePath(f, configDir));
|
|
@@ -593,8 +728,11 @@ var ConfigLoaderService = class {
|
|
|
593
728
|
}
|
|
594
729
|
return resolved;
|
|
595
730
|
}
|
|
596
|
-
isDuplicateRepoUrl(repo, all) {
|
|
597
|
-
const firstIndex = all.findIndex((r) =>
|
|
731
|
+
isDuplicateRepoUrl(repo, all, defaults) {
|
|
732
|
+
const firstIndex = all.findIndex((r) => {
|
|
733
|
+
const mode = r.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
|
|
734
|
+
return r.repoUrl === repo.repoUrl && mode === REPOSITORY_MODES.WORKTREE;
|
|
735
|
+
});
|
|
598
736
|
const myIndex = all.indexOf(repo);
|
|
599
737
|
return firstIndex !== -1 && myIndex !== -1 && myIndex !== firstIndex;
|
|
600
738
|
}
|
|
@@ -645,12 +783,6 @@ var ConfigLoaderService = class {
|
|
|
645
783
|
if (overrides?.filter) {
|
|
646
784
|
repositories = this.filterRepositories(repositories, overrides.filter);
|
|
647
785
|
}
|
|
648
|
-
if (overrides?.noUpdateExisting) {
|
|
649
|
-
repositories = repositories.map((repo) => ({ ...repo, updateExistingWorktrees: false }));
|
|
650
|
-
}
|
|
651
|
-
if (overrides?.debug) {
|
|
652
|
-
repositories = repositories.map((repo) => ({ ...repo, debug: true }));
|
|
653
|
-
}
|
|
654
786
|
return { repositories, configFile, configDir };
|
|
655
787
|
}
|
|
656
788
|
};
|
|
@@ -754,60 +886,6 @@ function defaultConsoleOutput(msg, level) {
|
|
|
754
886
|
else console.log(msg);
|
|
755
887
|
}
|
|
756
888
|
|
|
757
|
-
// src/services/worktree-sync.service.ts
|
|
758
|
-
import * as fs6 from "fs/promises";
|
|
759
|
-
import * as path8 from "path";
|
|
760
|
-
import pLimit from "p-limit";
|
|
761
|
-
import * as lockfile from "proper-lockfile";
|
|
762
|
-
|
|
763
|
-
// src/utils/date-filter.ts
|
|
764
|
-
function parseDuration(durationStr) {
|
|
765
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
766
|
-
if (!match) {
|
|
767
|
-
return null;
|
|
768
|
-
}
|
|
769
|
-
const value = parseInt(match[1], 10);
|
|
770
|
-
const unit = match[2];
|
|
771
|
-
const multipliers = {
|
|
772
|
-
h: 60 * 60 * 1e3,
|
|
773
|
-
// hours
|
|
774
|
-
d: 24 * 60 * 60 * 1e3,
|
|
775
|
-
// days
|
|
776
|
-
w: 7 * 24 * 60 * 60 * 1e3,
|
|
777
|
-
// weeks
|
|
778
|
-
m: 30 * 24 * 60 * 60 * 1e3,
|
|
779
|
-
// months (approximate)
|
|
780
|
-
y: 365 * 24 * 60 * 60 * 1e3
|
|
781
|
-
// years (approximate)
|
|
782
|
-
};
|
|
783
|
-
return value * multipliers[unit];
|
|
784
|
-
}
|
|
785
|
-
function filterBranchesByAge(branches, maxAge) {
|
|
786
|
-
const maxAgeMs = parseDuration(maxAge);
|
|
787
|
-
if (maxAgeMs === null) {
|
|
788
|
-
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
789
|
-
return branches;
|
|
790
|
-
}
|
|
791
|
-
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
792
|
-
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
793
|
-
}
|
|
794
|
-
function formatDuration(durationStr) {
|
|
795
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
796
|
-
if (!match) {
|
|
797
|
-
return durationStr;
|
|
798
|
-
}
|
|
799
|
-
const value = parseInt(match[1], 10);
|
|
800
|
-
const unit = match[2];
|
|
801
|
-
const unitNames = {
|
|
802
|
-
h: value === 1 ? "hour" : "hours",
|
|
803
|
-
d: value === 1 ? "day" : "days",
|
|
804
|
-
w: value === 1 ? "week" : "weeks",
|
|
805
|
-
m: value === 1 ? "month" : "months",
|
|
806
|
-
y: value === 1 ? "year" : "years"
|
|
807
|
-
};
|
|
808
|
-
return `${value} ${unitNames[unit]}`;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
889
|
// src/utils/lfs-error.ts
|
|
812
890
|
function getErrorMessage(error) {
|
|
813
891
|
if (error instanceof Error) {
|
|
@@ -829,6 +907,14 @@ function isLfsError(errorMessage) {
|
|
|
829
907
|
function isLfsErrorFromError(error) {
|
|
830
908
|
return isLfsError(getErrorMessage(error));
|
|
831
909
|
}
|
|
910
|
+
var MISSING_REMOTE_REF_PATTERNS = Object.freeze([
|
|
911
|
+
"couldn't find remote ref",
|
|
912
|
+
"Couldn't find remote ref",
|
|
913
|
+
"not our ref"
|
|
914
|
+
]);
|
|
915
|
+
function isMissingRemoteRefError(errorMessage) {
|
|
916
|
+
return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
|
|
917
|
+
}
|
|
832
918
|
|
|
833
919
|
// src/utils/retry.ts
|
|
834
920
|
var DEFAULT_OPTIONS = {
|
|
@@ -900,7 +986,7 @@ async function retry(fn, options = {}) {
|
|
|
900
986
|
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
901
987
|
const delay = baseDelay + jitter;
|
|
902
988
|
opts.onRetry(error, attempt, lfsContext);
|
|
903
|
-
await new Promise((
|
|
989
|
+
await new Promise((resolve11) => setTimeout(resolve11, delay));
|
|
904
990
|
attempt++;
|
|
905
991
|
}
|
|
906
992
|
}
|
|
@@ -971,7 +1057,7 @@ var PhaseTimer = class {
|
|
|
971
1057
|
return results;
|
|
972
1058
|
}
|
|
973
1059
|
};
|
|
974
|
-
function
|
|
1060
|
+
function formatDuration(ms) {
|
|
975
1061
|
if (ms < 1e3) {
|
|
976
1062
|
return `${ms}ms`;
|
|
977
1063
|
}
|
|
@@ -993,7 +1079,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
993
1079
|
}
|
|
994
1080
|
});
|
|
995
1081
|
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
996
|
-
table.push(["Total Sync",
|
|
1082
|
+
table.push(["Total Sync", formatDuration(totalDuration), ""]);
|
|
997
1083
|
for (let i = 0; i < phaseResults.length; i++) {
|
|
998
1084
|
const result = phaseResults[i];
|
|
999
1085
|
const isLast = i === phaseResults.length - 1;
|
|
@@ -1001,37 +1087,901 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
1001
1087
|
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
1002
1088
|
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
1003
1089
|
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
1004
|
-
table.push([name,
|
|
1090
|
+
table.push([name, formatDuration(result.duration), efficiency]);
|
|
1005
1091
|
}
|
|
1006
1092
|
return table.toString();
|
|
1007
1093
|
}
|
|
1008
1094
|
|
|
1009
|
-
// src/services/
|
|
1010
|
-
import * as
|
|
1011
|
-
import * as
|
|
1012
|
-
import
|
|
1095
|
+
// src/services/clone-sync.service.ts
|
|
1096
|
+
import * as fs3 from "fs/promises";
|
|
1097
|
+
import * as path4 from "path";
|
|
1098
|
+
import simpleGit from "simple-git";
|
|
1013
1099
|
|
|
1014
|
-
// src/utils/
|
|
1015
|
-
function
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1100
|
+
// src/utils/git-progress.ts
|
|
1101
|
+
function makeGitProgressHandler(logger, emitProgress) {
|
|
1102
|
+
const lastBucket = /* @__PURE__ */ new Map();
|
|
1103
|
+
return (event) => {
|
|
1104
|
+
if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
|
|
1105
|
+
const key = `${event.method}:${event.stage}`;
|
|
1106
|
+
const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
|
|
1107
|
+
let last = lastBucket.get(key) ?? -1;
|
|
1108
|
+
if (bucket < last) last = -1;
|
|
1109
|
+
if (bucket <= last && event.progress < 100) return;
|
|
1110
|
+
lastBucket.set(key, bucket);
|
|
1111
|
+
const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
|
|
1112
|
+
const message = `${event.method} ${event.stage}: ${event.progress}% (${total})`;
|
|
1113
|
+
logger.info(` \u21B3 ${message}`);
|
|
1114
|
+
emitProgress?.({
|
|
1115
|
+
phase: event.method,
|
|
1116
|
+
message,
|
|
1117
|
+
progress: event.progress,
|
|
1118
|
+
processed: event.processed,
|
|
1119
|
+
total: event.total
|
|
1030
1120
|
});
|
|
1031
|
-
current = {};
|
|
1032
1121
|
};
|
|
1033
|
-
|
|
1034
|
-
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/services/file-copy.service.ts
|
|
1125
|
+
import * as fs2 from "fs/promises";
|
|
1126
|
+
import * as path3 from "path";
|
|
1127
|
+
import { glob } from "glob";
|
|
1128
|
+
var DEFAULT_IGNORE_PATTERNS = [
|
|
1129
|
+
"**/node_modules/**",
|
|
1130
|
+
"**/.git/**",
|
|
1131
|
+
"**/dist/**",
|
|
1132
|
+
"**/build/**",
|
|
1133
|
+
"**/.next/**",
|
|
1134
|
+
"**/coverage/**"
|
|
1135
|
+
];
|
|
1136
|
+
var FileCopyService = class {
|
|
1137
|
+
/**
|
|
1138
|
+
* Copy files matching patterns from source to destination directory.
|
|
1139
|
+
* Skips files that already exist at destination.
|
|
1140
|
+
* Preserves directory structure relative to source.
|
|
1141
|
+
*/
|
|
1142
|
+
async copyFiles(sourceDir, destDir, patterns) {
|
|
1143
|
+
const result = {
|
|
1144
|
+
copied: [],
|
|
1145
|
+
skipped: [],
|
|
1146
|
+
errors: []
|
|
1147
|
+
};
|
|
1148
|
+
if (!patterns || patterns.length === 0) {
|
|
1149
|
+
return result;
|
|
1150
|
+
}
|
|
1151
|
+
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
1152
|
+
for (const relativePath of filesToCopy) {
|
|
1153
|
+
const sourcePath = path3.join(sourceDir, relativePath);
|
|
1154
|
+
const destPath = path3.join(destDir, relativePath);
|
|
1155
|
+
try {
|
|
1156
|
+
const copied = await this.copyFile(sourcePath, destPath);
|
|
1157
|
+
if (copied) {
|
|
1158
|
+
result.copied.push(relativePath);
|
|
1159
|
+
} else {
|
|
1160
|
+
result.skipped.push(relativePath);
|
|
1161
|
+
}
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
result.errors.push({
|
|
1164
|
+
file: relativePath,
|
|
1165
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return result;
|
|
1170
|
+
}
|
|
1171
|
+
async expandPatterns(sourceDir, patterns) {
|
|
1172
|
+
const allFiles = /* @__PURE__ */ new Set();
|
|
1173
|
+
for (const pattern of patterns) {
|
|
1174
|
+
try {
|
|
1175
|
+
const matches = await glob(pattern, {
|
|
1176
|
+
cwd: sourceDir,
|
|
1177
|
+
nodir: true,
|
|
1178
|
+
dot: true,
|
|
1179
|
+
ignore: DEFAULT_IGNORE_PATTERNS
|
|
1180
|
+
});
|
|
1181
|
+
for (const match of matches) {
|
|
1182
|
+
allFiles.add(match);
|
|
1183
|
+
}
|
|
1184
|
+
} catch {
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return Array.from(allFiles);
|
|
1188
|
+
}
|
|
1189
|
+
async copyFile(sourcePath, destPath) {
|
|
1190
|
+
if (await fileExists(destPath)) {
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
const destDir = path3.dirname(destPath);
|
|
1194
|
+
await fs2.mkdir(destDir, { recursive: true });
|
|
1195
|
+
await fs2.copyFile(sourcePath, destPath);
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
// src/services/branch-created-actions.service.ts
|
|
1201
|
+
var BranchCreatedActionsService = class {
|
|
1202
|
+
fileCopyService;
|
|
1203
|
+
constructor(fileCopyService) {
|
|
1204
|
+
this.fileCopyService = fileCopyService ?? new FileCopyService();
|
|
1205
|
+
}
|
|
1206
|
+
async copyFiles(params) {
|
|
1207
|
+
const { config, sourceDir, worktreePath, branchName, logger } = params;
|
|
1208
|
+
const patterns = config.filesToCopyOnBranchCreate;
|
|
1209
|
+
if (!patterns?.length) return;
|
|
1210
|
+
try {
|
|
1211
|
+
const result = await this.fileCopyService.copyFiles(sourceDir, worktreePath, patterns);
|
|
1212
|
+
if (result.copied.length > 0) {
|
|
1213
|
+
logger.info(`\u{1F4CB} Copied ${result.copied.length} file(s) to '${branchName}': ${result.copied.join(", ")}`);
|
|
1214
|
+
}
|
|
1215
|
+
if (result.errors.length > 0) {
|
|
1216
|
+
logger.warn(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s) to '${branchName}':`);
|
|
1217
|
+
for (const err of result.errors) {
|
|
1218
|
+
logger.warn(` - ${err.file}: ${err.error}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
logger.error(`Failed to copy files to '${branchName}': ${error}`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
runHooks(params) {
|
|
1226
|
+
const { config, branchName, worktreePath, repoName, baseBranch, logger, hookExecutionService } = params;
|
|
1227
|
+
if (!config.hooks?.onBranchCreated?.length) return;
|
|
1228
|
+
const context = {
|
|
1229
|
+
branchName,
|
|
1230
|
+
worktreePath,
|
|
1231
|
+
repoName,
|
|
1232
|
+
baseBranch,
|
|
1233
|
+
repoUrl: config.repoUrl
|
|
1234
|
+
};
|
|
1235
|
+
logger.info(`Running ${config.hooks.onBranchCreated.length} hook(s) for branch '${branchName}'...`);
|
|
1236
|
+
hookExecutionService.executeOnBranchCreated(config.hooks, context, {
|
|
1237
|
+
onStdout: (data) => logger.info(`[hook] ${data}`),
|
|
1238
|
+
onStderr: (data) => logger.warn(`[hook] ${data}`),
|
|
1239
|
+
onError: (command, error) => logger.error(`[hook] Failed to execute '${command}': ${error.message}`),
|
|
1240
|
+
onComplete: (command, exitCode) => {
|
|
1241
|
+
if (exitCode === 0) {
|
|
1242
|
+
logger.info(`[hook] Command completed successfully`);
|
|
1243
|
+
} else if (exitCode !== null) {
|
|
1244
|
+
logger.warn(`[hook] Command exited with code ${exitCode}`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// src/utils/clone-skip-format.ts
|
|
1252
|
+
function formatCloneSkipReason(reason) {
|
|
1253
|
+
switch (reason.kind) {
|
|
1254
|
+
case "branch_mismatch":
|
|
1255
|
+
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
|
|
1256
|
+
case "head_unreadable":
|
|
1257
|
+
return `could not read HEAD: ${reason.error}`;
|
|
1258
|
+
case "dirty_tree":
|
|
1259
|
+
return `working tree has local changes`;
|
|
1260
|
+
case "diverged":
|
|
1261
|
+
return `diverged from origin/${reason.branch}`;
|
|
1262
|
+
case "ahead_unpushed":
|
|
1263
|
+
return `unpushed commits ahead of origin/${reason.branch}`;
|
|
1264
|
+
case "missing_remote_ref":
|
|
1265
|
+
return reason.source === "fetch_error" ? `origin/${reason.branch} missing on remote (fetch error)` : `origin/${reason.branch} pruned after fetch`;
|
|
1266
|
+
case "indeterminate_shallow":
|
|
1267
|
+
return reason.deepenedTo === null ? `unable to classify origin/${reason.branch} (no deepening attempted \u2014 configured depth already at or above all deepen targets) \u2014 remove 'depth' to unshallow` : `unable to classify origin/${reason.branch} after deepening shallow history to ${reason.deepenedTo} commits \u2014 remove or raise 'depth' to unshallow`;
|
|
1268
|
+
case "origin_mismatch":
|
|
1269
|
+
return `clone origin is '${reason.actual}', expected '${reason.expected}'`;
|
|
1270
|
+
default: {
|
|
1271
|
+
const _exhaustive = reason;
|
|
1272
|
+
return _exhaustive;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// src/services/sync-outcome.ts
|
|
1278
|
+
var EMPTY_COUNTS = {
|
|
1279
|
+
created: 0,
|
|
1280
|
+
removed: 0,
|
|
1281
|
+
updated: 0,
|
|
1282
|
+
skipped: 0,
|
|
1283
|
+
preserved: 0,
|
|
1284
|
+
failed: 0,
|
|
1285
|
+
noop: 0
|
|
1286
|
+
};
|
|
1287
|
+
function cloneCounts(counts) {
|
|
1288
|
+
return { ...counts };
|
|
1289
|
+
}
|
|
1290
|
+
function cloneAction(action) {
|
|
1291
|
+
return { ...action };
|
|
1292
|
+
}
|
|
1293
|
+
function countKeyFor(action) {
|
|
1294
|
+
switch (action.kind) {
|
|
1295
|
+
case "created":
|
|
1296
|
+
return "created";
|
|
1297
|
+
case "removed":
|
|
1298
|
+
return "removed";
|
|
1299
|
+
case "updated":
|
|
1300
|
+
return "updated";
|
|
1301
|
+
case "skipped":
|
|
1302
|
+
return "skipped";
|
|
1303
|
+
case "preserved-diverged":
|
|
1304
|
+
return "preserved";
|
|
1305
|
+
case "failed":
|
|
1306
|
+
return "failed";
|
|
1307
|
+
case "noop":
|
|
1308
|
+
return "noop";
|
|
1309
|
+
default: {
|
|
1310
|
+
const _exhaustive = action;
|
|
1311
|
+
return _exhaustive;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
var SyncOutcomeAccumulator = class {
|
|
1316
|
+
constructor(options) {
|
|
1317
|
+
this.options = options;
|
|
1318
|
+
}
|
|
1319
|
+
counts = cloneCounts(EMPTY_COUNTS);
|
|
1320
|
+
actions = [];
|
|
1321
|
+
add(action) {
|
|
1322
|
+
this.actions.push(action);
|
|
1323
|
+
this.counts[countKeyFor(action)]++;
|
|
1324
|
+
}
|
|
1325
|
+
recordCreated(branch, path16) {
|
|
1326
|
+
this.add({ kind: "created", branch, path: path16 });
|
|
1327
|
+
}
|
|
1328
|
+
recordRemoved(branch, path16) {
|
|
1329
|
+
this.add({ kind: "removed", branch, path: path16 });
|
|
1330
|
+
}
|
|
1331
|
+
recordUpdated(branch, path16, reason) {
|
|
1332
|
+
this.add({ kind: "updated", branch, path: path16, reason });
|
|
1333
|
+
}
|
|
1334
|
+
recordNoop(scope, reason, details) {
|
|
1335
|
+
this.add({ kind: "noop", scope, reason, ...details });
|
|
1336
|
+
}
|
|
1337
|
+
recordSkipped(scope, reason, details) {
|
|
1338
|
+
this.add({ kind: "skipped", scope, reason, ...details });
|
|
1339
|
+
}
|
|
1340
|
+
recordPreservedDiverged(branch, path16, preservedPath) {
|
|
1341
|
+
this.add({ kind: "preserved-diverged", branch, path: path16, preservedPath });
|
|
1342
|
+
}
|
|
1343
|
+
recordFailed(scope, error, details = {}) {
|
|
1344
|
+
this.add({ kind: "failed", scope, error, ...details });
|
|
1345
|
+
}
|
|
1346
|
+
getCounts() {
|
|
1347
|
+
return cloneCounts(this.counts);
|
|
1348
|
+
}
|
|
1349
|
+
snapshot() {
|
|
1350
|
+
return {
|
|
1351
|
+
counts: cloneCounts(this.counts),
|
|
1352
|
+
actions: this.actions.map(cloneAction)
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
restore(snapshot) {
|
|
1356
|
+
this.counts = cloneCounts(snapshot.counts);
|
|
1357
|
+
this.actions = snapshot.actions.map(cloneAction);
|
|
1358
|
+
}
|
|
1359
|
+
toOutcome(durationMs) {
|
|
1360
|
+
return {
|
|
1361
|
+
repoName: this.options.repoName,
|
|
1362
|
+
mode: this.options.mode,
|
|
1363
|
+
started: true,
|
|
1364
|
+
counts: cloneCounts(this.counts),
|
|
1365
|
+
actions: this.actions.map(cloneAction),
|
|
1366
|
+
durationMs
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
function createEmptySyncOutcome(mode, repoName, durationMs) {
|
|
1371
|
+
return {
|
|
1372
|
+
repoName,
|
|
1373
|
+
mode,
|
|
1374
|
+
started: true,
|
|
1375
|
+
counts: cloneCounts(EMPTY_COUNTS),
|
|
1376
|
+
actions: [],
|
|
1377
|
+
durationMs
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
function cloneSkipToOutcomeAction(reason, details = {}) {
|
|
1381
|
+
const message = formatCloneSkipReason(reason);
|
|
1382
|
+
const branch = "branch" in reason ? reason.branch : reason.kind === "branch_mismatch" ? reason.expectedBranch : details.branch;
|
|
1383
|
+
return {
|
|
1384
|
+
kind: "skipped",
|
|
1385
|
+
scope: "repo",
|
|
1386
|
+
reason: `clone_${reason.kind}`,
|
|
1387
|
+
branch,
|
|
1388
|
+
path: details.path,
|
|
1389
|
+
message
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/services/clone-sync.service.ts
|
|
1394
|
+
var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
1395
|
+
var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
|
|
1396
|
+
var CloneSyncService = class {
|
|
1397
|
+
constructor(config, gitService, logger, options = {}) {
|
|
1398
|
+
this.config = config;
|
|
1399
|
+
this.gitService = gitService;
|
|
1400
|
+
this.logger = logger;
|
|
1401
|
+
this.branchCreatedActions = options.branchCreatedActions ?? new BranchCreatedActionsService();
|
|
1402
|
+
this.progressEmitter = options.progressEmitter;
|
|
1403
|
+
this.onSkip = options.onSkip;
|
|
1404
|
+
}
|
|
1405
|
+
initialized = false;
|
|
1406
|
+
resolvedBranch = null;
|
|
1407
|
+
branchCreatedActions;
|
|
1408
|
+
progressEmitter;
|
|
1409
|
+
onSkip;
|
|
1410
|
+
outcomeAccumulator;
|
|
1411
|
+
// One-shot suppression token. When init records a wrong-branch / unreadable-HEAD
|
|
1412
|
+
// skip for an existing clone, it sets this so the immediately following
|
|
1413
|
+
// runSyncAttempt (same sync operation) does not record the identical skip again.
|
|
1414
|
+
pendingInitSkip = null;
|
|
1415
|
+
updateLogger(logger) {
|
|
1416
|
+
this.logger = logger;
|
|
1417
|
+
}
|
|
1418
|
+
isInitialized() {
|
|
1419
|
+
return this.initialized;
|
|
1420
|
+
}
|
|
1421
|
+
clearPendingInitSkip() {
|
|
1422
|
+
this.pendingInitSkip = null;
|
|
1423
|
+
}
|
|
1424
|
+
async getWorktrees() {
|
|
1425
|
+
const worktreeDir = path4.resolve(this.config.worktreeDir);
|
|
1426
|
+
if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
|
|
1427
|
+
return [];
|
|
1428
|
+
}
|
|
1429
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1430
|
+
let branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
1431
|
+
if (!branch || branch === "HEAD") {
|
|
1432
|
+
const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
|
|
1433
|
+
branch = head ? `(detached ${head})` : "(detached)";
|
|
1434
|
+
}
|
|
1435
|
+
return [{ path: worktreeDir, branch }];
|
|
1436
|
+
}
|
|
1437
|
+
get repoName() {
|
|
1438
|
+
return this.config.name ?? this.config.repoUrl;
|
|
1439
|
+
}
|
|
1440
|
+
getCloneTimeoutMs() {
|
|
1441
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
1442
|
+
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
1443
|
+
}
|
|
1444
|
+
getFetchTimeoutMs() {
|
|
1445
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
1446
|
+
return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
|
|
1447
|
+
}
|
|
1448
|
+
isLfsSkipEnabled() {
|
|
1449
|
+
return this.config.skipLfs === true;
|
|
1450
|
+
}
|
|
1451
|
+
buildGitOptions(blockMs) {
|
|
1452
|
+
const options = {
|
|
1453
|
+
progress: makeGitProgressHandler(this.logger, (event) => this.emitProgress(event))
|
|
1454
|
+
};
|
|
1455
|
+
if (blockMs > 0) options.timeout = { block: blockMs };
|
|
1456
|
+
return options;
|
|
1457
|
+
}
|
|
1458
|
+
emitProgress(event) {
|
|
1459
|
+
try {
|
|
1460
|
+
this.progressEmitter?.(event);
|
|
1461
|
+
} catch {
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
async withOutcome(outcome, operation) {
|
|
1465
|
+
const previousOutcome = this.outcomeAccumulator;
|
|
1466
|
+
if (outcome) {
|
|
1467
|
+
this.outcomeAccumulator = outcome;
|
|
1468
|
+
}
|
|
1469
|
+
try {
|
|
1470
|
+
return await operation();
|
|
1471
|
+
} finally {
|
|
1472
|
+
if (outcome) {
|
|
1473
|
+
this.outcomeAccumulator = previousOutcome;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
recordSkip(reason, logMessage, progressMessage, logLevel = "warn") {
|
|
1478
|
+
if (logLevel === "warn") {
|
|
1479
|
+
this.logger.warn(logMessage);
|
|
1480
|
+
} else {
|
|
1481
|
+
this.logger.info(logMessage);
|
|
1482
|
+
}
|
|
1483
|
+
this.emitProgress({ phase: "skip", message: progressMessage ?? logMessage });
|
|
1484
|
+
try {
|
|
1485
|
+
this.onSkip?.(reason);
|
|
1486
|
+
} catch {
|
|
1487
|
+
}
|
|
1488
|
+
this.outcomeAccumulator?.add(
|
|
1489
|
+
cloneSkipToOutcomeAction(reason, {
|
|
1490
|
+
branch: this.resolvedBranch ?? this.config.branch,
|
|
1491
|
+
path: this.config.worktreeDir
|
|
1492
|
+
})
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
clientFor(dir, blockMs) {
|
|
1496
|
+
return simpleGit(dir, this.buildGitOptions(blockMs)).env(this.buildGitEnv());
|
|
1497
|
+
}
|
|
1498
|
+
// Force a stable C locale so git's stderr is deterministic English. The
|
|
1499
|
+
// missing-remote-ref and LFS error classification matches on those strings
|
|
1500
|
+
// and would otherwise misfire under a non-English LANG/LC_ALL. simple-git's
|
|
1501
|
+
// .env() merges this object with process.env (PATH etc. preserved).
|
|
1502
|
+
buildGitEnv(opts = {}) {
|
|
1503
|
+
const env = { LC_ALL: "C", LANG: "C" };
|
|
1504
|
+
if (opts.forceLfsSkip || this.isLfsSkipEnabled()) {
|
|
1505
|
+
env[ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE] = "1";
|
|
1506
|
+
}
|
|
1507
|
+
return env;
|
|
1508
|
+
}
|
|
1509
|
+
buildCloneArgs(branch) {
|
|
1510
|
+
const args = ["--branch", branch, "--progress"];
|
|
1511
|
+
if (this.config.depth !== void 0) {
|
|
1512
|
+
args.push("--depth", String(this.config.depth), "--no-single-branch");
|
|
1513
|
+
}
|
|
1514
|
+
return args;
|
|
1515
|
+
}
|
|
1516
|
+
async buildFetchArgs(git) {
|
|
1517
|
+
const args = ["origin", "--prune", "--progress"];
|
|
1518
|
+
if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
|
|
1519
|
+
args.push("--depth", String(this.config.depth));
|
|
1520
|
+
}
|
|
1521
|
+
return args;
|
|
1522
|
+
}
|
|
1523
|
+
async ensureAllRemoteBranchesRefspec(git) {
|
|
1524
|
+
let fetchRefspecs = [];
|
|
1525
|
+
try {
|
|
1526
|
+
const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
|
|
1527
|
+
fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1528
|
+
} catch {
|
|
1529
|
+
fetchRefspecs = [];
|
|
1530
|
+
}
|
|
1531
|
+
if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
|
|
1532
|
+
const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
|
|
1533
|
+
this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
|
|
1534
|
+
await git.raw(["remote", "set-branches", "origin", "*"]);
|
|
1535
|
+
for (const refspec of customRefspecs) {
|
|
1536
|
+
await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
isOriginRemoteBranchTrackingRefspec(refspec) {
|
|
1540
|
+
const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
|
|
1541
|
+
if (withoutForce.startsWith("^")) return false;
|
|
1542
|
+
const [source, destination] = withoutForce.split(":");
|
|
1543
|
+
return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
|
|
1544
|
+
}
|
|
1545
|
+
recordMissingRemoteRefSkip(branch) {
|
|
1546
|
+
this.recordSkip(
|
|
1547
|
+
{ kind: "missing_remote_ref", branch, source: "fetch_error" },
|
|
1548
|
+
`Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
|
|
1549
|
+
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
|
|
1553
|
+
try {
|
|
1554
|
+
await git.fetch(fetchArgs);
|
|
1555
|
+
return { skipped: false };
|
|
1556
|
+
} catch (fetchError) {
|
|
1557
|
+
const message = getErrorMessage(fetchError);
|
|
1558
|
+
if (isLfsError(message)) {
|
|
1559
|
+
this.logger.info(`\u26A0\uFE0F LFS error during fetch for '${this.repoName}'; retrying with LFS disabled.`);
|
|
1560
|
+
this.emitProgress({ phase: "fetch", message: `Retrying fetch for '${this.repoName}' with LFS disabled` });
|
|
1561
|
+
const lfsSkipGit = simpleGit(worktreeDir, this.buildGitOptions(this.getFetchTimeoutMs())).env(
|
|
1562
|
+
this.buildGitEnv({ forceLfsSkip: true })
|
|
1563
|
+
);
|
|
1564
|
+
try {
|
|
1565
|
+
await lfsSkipGit.fetch(fetchArgs);
|
|
1566
|
+
return { skipped: false };
|
|
1567
|
+
} catch (retryError) {
|
|
1568
|
+
if (isMissingRemoteRefError(getErrorMessage(retryError))) {
|
|
1569
|
+
this.recordMissingRemoteRefSkip(branch);
|
|
1570
|
+
return { skipped: true };
|
|
1571
|
+
}
|
|
1572
|
+
throw retryError;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (isMissingRemoteRefError(message)) {
|
|
1576
|
+
this.recordMissingRemoteRefSkip(branch);
|
|
1577
|
+
return { skipped: true };
|
|
1578
|
+
}
|
|
1579
|
+
throw fetchError;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
async hasRemoteBranch(git, branch) {
|
|
1583
|
+
try {
|
|
1584
|
+
await git.raw(["show-ref", "--verify", `refs/remotes/origin/${branch}`]);
|
|
1585
|
+
return true;
|
|
1586
|
+
} catch {
|
|
1587
|
+
return false;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
async isShallowRepository(git) {
|
|
1591
|
+
try {
|
|
1592
|
+
const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
|
|
1593
|
+
return output.trim() === "true";
|
|
1594
|
+
} catch {
|
|
1595
|
+
return false;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
async unshallowIfDepthRemoved(git) {
|
|
1599
|
+
if (this.config.depth !== void 0) return;
|
|
1600
|
+
if (!await this.isShallowRepository(git)) return;
|
|
1601
|
+
this.logger.info(
|
|
1602
|
+
`[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
|
|
1603
|
+
);
|
|
1604
|
+
await git.fetch(["--unshallow"]);
|
|
1605
|
+
}
|
|
1606
|
+
getDeepenTargets() {
|
|
1607
|
+
const configuredDepth = this.config.depth;
|
|
1608
|
+
if (configuredDepth === void 0) return [];
|
|
1609
|
+
return SHALLOW_RELATION_DEEPEN_TARGETS.filter((target) => target > configuredDepth);
|
|
1610
|
+
}
|
|
1611
|
+
async deepenShallowHistoryToDepth(git, branch, targetDepth) {
|
|
1612
|
+
this.logger.info(
|
|
1613
|
+
`[deepen] Shallow clone for '${this.repoName}' lacks enough history to classify origin/${branch}; refetching to depth ${targetDepth} before deciding.`
|
|
1614
|
+
);
|
|
1615
|
+
this.emitProgress({
|
|
1616
|
+
phase: "fetch",
|
|
1617
|
+
message: `Deepening '${this.repoName}' to depth ${targetDepth} before classifying origin/${branch}`
|
|
1618
|
+
});
|
|
1619
|
+
await git.fetch([
|
|
1620
|
+
"origin",
|
|
1621
|
+
"--depth",
|
|
1622
|
+
String(targetDepth),
|
|
1623
|
+
"--prune",
|
|
1624
|
+
"--progress",
|
|
1625
|
+
`+refs/heads/${branch}:refs/remotes/origin/${branch}`
|
|
1626
|
+
]);
|
|
1627
|
+
}
|
|
1628
|
+
async resolveBranch() {
|
|
1629
|
+
if (this.resolvedBranch) return this.resolvedBranch;
|
|
1630
|
+
if (this.config.branch) {
|
|
1631
|
+
this.resolvedBranch = this.config.branch;
|
|
1632
|
+
this.emitProgress({ phase: "branch", message: `Using configured branch '${this.resolvedBranch}'` });
|
|
1633
|
+
return this.resolvedBranch;
|
|
1634
|
+
}
|
|
1635
|
+
this.logger.info(`No branch configured for '${this.repoName}', detecting remote default branch...`);
|
|
1636
|
+
this.emitProgress({ phase: "branch", message: `Resolving remote default branch for '${this.repoName}'` });
|
|
1637
|
+
this.resolvedBranch = await this.gitService.getRemoteDefaultBranch(this.config.repoUrl);
|
|
1638
|
+
this.logger.info(` \u21B3 resolved default branch: ${this.resolvedBranch}`);
|
|
1639
|
+
this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
|
|
1640
|
+
return this.resolvedBranch;
|
|
1641
|
+
}
|
|
1642
|
+
async initialize(outcome) {
|
|
1643
|
+
return this.withOutcome(outcome, () => this.initializeInternal());
|
|
1644
|
+
}
|
|
1645
|
+
async initializeInternal() {
|
|
1646
|
+
this.pendingInitSkip = null;
|
|
1647
|
+
const branch = await this.resolveBranch();
|
|
1648
|
+
const worktreeDir = this.config.worktreeDir;
|
|
1649
|
+
let entries = null;
|
|
1650
|
+
try {
|
|
1651
|
+
entries = await fs3.readdir(worktreeDir);
|
|
1652
|
+
} catch {
|
|
1653
|
+
entries = null;
|
|
1654
|
+
}
|
|
1655
|
+
if (entries?.includes(PATH_CONSTANTS.GIT_DIR)) {
|
|
1656
|
+
this.emitProgress({ phase: "clone", message: `Validating existing clone for '${this.repoName}'` });
|
|
1657
|
+
const result = await this.validateExistingClone(branch);
|
|
1658
|
+
if (!result.valid) {
|
|
1659
|
+
this.recordSkip(result.skip, result.warnMessage, `Skipping '${this.repoName}': ${result.progressDetail}`);
|
|
1660
|
+
this.pendingInitSkip = result.skip;
|
|
1661
|
+
this.initialized = true;
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1665
|
+
await this.ensureAllRemoteBranchesRefspec(git);
|
|
1666
|
+
this.initialized = true;
|
|
1667
|
+
this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (entries && entries.length > 0) {
|
|
1671
|
+
throw new ConfigError(
|
|
1672
|
+
`Cannot clone into '${worktreeDir}': directory exists and is not empty. Remove existing contents or point worktreeDir at an empty path.`,
|
|
1673
|
+
"CLONE_DESTINATION_NOT_EMPTY"
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
const cloneCreatedDir = entries === null;
|
|
1677
|
+
await fs3.mkdir(worktreeDir, { recursive: true });
|
|
1678
|
+
this.logger.info(`Cloning '${this.config.repoUrl}' (${branch}) into '${worktreeDir}'...`);
|
|
1679
|
+
this.emitProgress({ phase: "clone", message: `Cloning '${this.repoName}' (${branch})` });
|
|
1680
|
+
const cloneClient = simpleGit(this.buildGitOptions(this.getCloneTimeoutMs())).env(this.buildGitEnv());
|
|
1681
|
+
try {
|
|
1682
|
+
await cloneClient.clone(this.config.repoUrl, worktreeDir, this.buildCloneArgs(branch));
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
await this.maybeCleanupPartialClone(worktreeDir, cloneCreatedDir);
|
|
1685
|
+
this.outcomeAccumulator?.recordFailed("repo", getErrorMessage(error), {
|
|
1686
|
+
reason: "clone_failed",
|
|
1687
|
+
branch,
|
|
1688
|
+
path: worktreeDir
|
|
1689
|
+
});
|
|
1690
|
+
throw error;
|
|
1691
|
+
}
|
|
1692
|
+
const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1693
|
+
await this.ensureAllRemoteBranchesRefspec(worktreeGit);
|
|
1694
|
+
this.logger.info(`\u2705 Clone successful.`);
|
|
1695
|
+
this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
|
|
1696
|
+
if (this.config.sparseCheckout) {
|
|
1697
|
+
this.logger.info(`Applying sparse-checkout patterns to '${worktreeDir}'...`);
|
|
1698
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Applying sparse-checkout for '${this.repoName}'` });
|
|
1699
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
1700
|
+
await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
|
|
1701
|
+
await worktreeGit.raw(["checkout", "HEAD"]);
|
|
1702
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout applied for '${this.repoName}'` });
|
|
1703
|
+
}
|
|
1704
|
+
this.emitProgress({ phase: "lfs", message: `Verifying LFS for '${this.repoName}'` });
|
|
1705
|
+
await this.gitService.verifyLfs(worktreeDir, branch);
|
|
1706
|
+
this.emitProgress({ phase: "lfs", message: `LFS verified for '${this.repoName}'` });
|
|
1707
|
+
await this.runInitialFileCopy(worktreeDir, branch);
|
|
1708
|
+
this.outcomeAccumulator?.recordCreated(branch, worktreeDir);
|
|
1709
|
+
this.initialized = true;
|
|
1710
|
+
}
|
|
1711
|
+
// Detects an on-disk clone whose `origin` no longer matches the configured
|
|
1712
|
+
// repoUrl (e.g. repoUrl was repointed in config). Returns a skip descriptor so
|
|
1713
|
+
// we never fetch/ff-merge from the wrong remote; null when origin matches or
|
|
1714
|
+
// can't be read. Comparison is normalized so https/.git/trailing-slash
|
|
1715
|
+
// variants don't false-positive; the raw URLs are kept in the message.
|
|
1716
|
+
async evaluateOriginMatch(git, worktreeDir) {
|
|
1717
|
+
let originUrl;
|
|
1718
|
+
try {
|
|
1719
|
+
originUrl = (await git.raw(["remote", "get-url", "origin"])).trim();
|
|
1720
|
+
} catch {
|
|
1721
|
+
this.logger.warn(`Could not read 'origin' remote URL from existing clone at '${worktreeDir}'.`);
|
|
1722
|
+
return null;
|
|
1723
|
+
}
|
|
1724
|
+
if (!originUrl || normalizeRepoUrlForComparison(originUrl) === normalizeRepoUrlForComparison(this.config.repoUrl)) {
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
return {
|
|
1728
|
+
skip: { kind: "origin_mismatch", actual: originUrl, expected: this.config.repoUrl },
|
|
1729
|
+
warnMessage: `Existing clone at '${worktreeDir}' has origin '${originUrl}', expected '${this.config.repoUrl}'. Update the remote ('git remote set-url origin <url>') or point worktreeDir at a fresh path.`,
|
|
1730
|
+
progressDetail: `origin '${originUrl}' is not '${this.config.repoUrl}'`
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
async validateExistingClone(expectedBranch) {
|
|
1734
|
+
const worktreeDir = this.config.worktreeDir;
|
|
1735
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1736
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
1737
|
+
if (originMismatch) {
|
|
1738
|
+
return { valid: false, ...originMismatch };
|
|
1739
|
+
}
|
|
1740
|
+
let currentBranch;
|
|
1741
|
+
try {
|
|
1742
|
+
currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
const errorMessage = getErrorMessage(error);
|
|
1745
|
+
return {
|
|
1746
|
+
valid: false,
|
|
1747
|
+
skip: { kind: "head_unreadable", phase: "init", error: errorMessage },
|
|
1748
|
+
warnMessage: `Existing clone at '${worktreeDir}' has a .git folder but reading HEAD failed: ${errorMessage}`,
|
|
1749
|
+
progressDetail: `could not read HEAD (${errorMessage})`
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
if (currentBranch !== expectedBranch) {
|
|
1753
|
+
return {
|
|
1754
|
+
valid: false,
|
|
1755
|
+
skip: {
|
|
1756
|
+
kind: "branch_mismatch",
|
|
1757
|
+
phase: "init",
|
|
1758
|
+
currentBranch,
|
|
1759
|
+
expectedBranch
|
|
1760
|
+
},
|
|
1761
|
+
warnMessage: `Existing clone at '${worktreeDir}' is on branch '${currentBranch}', expected '${expectedBranch}'. Switch the working tree to '${expectedBranch}' or update the config.`,
|
|
1762
|
+
progressDetail: `current branch '${currentBranch}' is not '${expectedBranch}'`
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
return { valid: true };
|
|
1766
|
+
}
|
|
1767
|
+
async maybeCleanupPartialClone(worktreeDir, cloneCreatedDir) {
|
|
1768
|
+
if (!cloneCreatedDir) {
|
|
1769
|
+
this.logger.warn(
|
|
1770
|
+
`Clone failed; leaving '${worktreeDir}' for manual inspection (directory existed before clone attempt).`
|
|
1771
|
+
);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
let entries;
|
|
1775
|
+
try {
|
|
1776
|
+
entries = await fs3.readdir(worktreeDir);
|
|
1777
|
+
} catch {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const looksIncomplete = entries.every((e) => e.startsWith("."));
|
|
1781
|
+
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
|
|
1782
|
+
if (looksIncomplete && !hasUsableGit) {
|
|
1783
|
+
try {
|
|
1784
|
+
await fs3.rm(worktreeDir, { recursive: true, force: true });
|
|
1785
|
+
this.logger.info(`Cleaned up incomplete clone at '${worktreeDir}'.`);
|
|
1786
|
+
} catch (rmError) {
|
|
1787
|
+
this.logger.warn(`Failed to clean up incomplete clone at '${worktreeDir}': ${getErrorMessage(rmError)}`);
|
|
1788
|
+
}
|
|
1789
|
+
} else {
|
|
1790
|
+
this.logger.warn(
|
|
1791
|
+
`Clone failed; leaving '${worktreeDir}' for manual inspection (post-failure contents do not look like an empty incomplete clone).`
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
getInitMarkerPath(worktreeDir) {
|
|
1796
|
+
return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
|
|
1797
|
+
}
|
|
1798
|
+
async runInitialFileCopy(worktreeDir, branch) {
|
|
1799
|
+
const marker = this.getInitMarkerPath(worktreeDir);
|
|
1800
|
+
if (await fileExists(marker)) {
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
const sourceDir = this.config.__configFileDir ?? worktreeDir;
|
|
1804
|
+
await this.branchCreatedActions.copyFiles({
|
|
1805
|
+
config: this.config,
|
|
1806
|
+
branchName: branch,
|
|
1807
|
+
worktreePath: worktreeDir,
|
|
1808
|
+
sourceDir,
|
|
1809
|
+
logger: this.logger
|
|
1810
|
+
});
|
|
1811
|
+
try {
|
|
1812
|
+
await fs3.writeFile(marker, (/* @__PURE__ */ new Date()).toISOString());
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
this.logger.warn(`Could not write clone-init marker: ${getErrorMessage(error)}`);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
async runSyncAttempt(outcome) {
|
|
1818
|
+
return this.withOutcome(outcome, () => this.runSyncAttemptInternal());
|
|
1819
|
+
}
|
|
1820
|
+
async runSyncAttemptInternal() {
|
|
1821
|
+
if (!this.initialized) {
|
|
1822
|
+
await this.initialize();
|
|
1823
|
+
this.pendingInitSkip = null;
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
if (this.pendingInitSkip) {
|
|
1827
|
+
this.pendingInitSkip = null;
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const branch = await this.resolveBranch();
|
|
1831
|
+
const worktreeDir = this.config.worktreeDir;
|
|
1832
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
1833
|
+
let currentBranch;
|
|
1834
|
+
try {
|
|
1835
|
+
currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
const errorMessage = getErrorMessage(error);
|
|
1838
|
+
this.recordSkip(
|
|
1839
|
+
{ kind: "head_unreadable", phase: "sync", error: errorMessage },
|
|
1840
|
+
`Could not read current branch from '${worktreeDir}': ${errorMessage}`,
|
|
1841
|
+
`Skipping '${this.repoName}': could not read current branch`
|
|
1842
|
+
);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (currentBranch !== branch) {
|
|
1846
|
+
this.recordSkip(
|
|
1847
|
+
{ kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
|
|
1848
|
+
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
|
|
1849
|
+
`Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
|
|
1850
|
+
);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
1854
|
+
if (originMismatch) {
|
|
1855
|
+
this.recordSkip(
|
|
1856
|
+
originMismatch.skip,
|
|
1857
|
+
originMismatch.warnMessage,
|
|
1858
|
+
`Skipping '${this.repoName}': ${originMismatch.progressDetail}`
|
|
1859
|
+
);
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
await this.unshallowIfDepthRemoved(git);
|
|
1863
|
+
await this.ensureAllRemoteBranchesRefspec(git);
|
|
1864
|
+
const fetchArgs = await this.buildFetchArgs(git);
|
|
1865
|
+
this.emitProgress({ phase: "fetch", message: `Fetching origin branches for '${this.repoName}'` });
|
|
1866
|
+
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
|
|
1870
|
+
if (!await this.hasRemoteBranch(git, branch)) {
|
|
1871
|
+
this.recordSkip(
|
|
1872
|
+
{ kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
|
|
1873
|
+
`Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
|
|
1874
|
+
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
1875
|
+
);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
if (this.config.sparseCheckout) {
|
|
1879
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
1880
|
+
try {
|
|
1881
|
+
if (await sparseService.needsUpdate(worktreeDir, this.config.sparseCheckout)) {
|
|
1882
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Updating sparse-checkout for '${this.repoName}'` });
|
|
1883
|
+
await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
|
|
1884
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout updated for '${this.repoName}'` });
|
|
1885
|
+
}
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
this.logger.warn(`Failed to reapply sparse-checkout for '${this.repoName}': ${getErrorMessage(error)}`);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
|
|
1891
|
+
if (!isClean) {
|
|
1892
|
+
this.recordSkip(
|
|
1893
|
+
{ kind: "dirty_tree" },
|
|
1894
|
+
`\u23ED\uFE0F Skipping ff-merge for '${this.repoName}' \u2014 working tree has local changes.`,
|
|
1895
|
+
`Skipping merge for '${this.repoName}': working tree has local changes`,
|
|
1896
|
+
"info"
|
|
1897
|
+
);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
let relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
|
|
1901
|
+
let lastDeepenedTo = null;
|
|
1902
|
+
if (relationship === "indeterminate_shallow") {
|
|
1903
|
+
for (const target of this.getDeepenTargets()) {
|
|
1904
|
+
await this.deepenShallowHistoryToDepth(git, branch, target);
|
|
1905
|
+
lastDeepenedTo = target;
|
|
1906
|
+
relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
|
|
1907
|
+
if (relationship !== "indeterminate_shallow") break;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
if (relationship === "up_to_date") {
|
|
1911
|
+
this.logger.info(`'${this.repoName}' already up to date with origin/${branch}.`);
|
|
1912
|
+
this.emitProgress({
|
|
1913
|
+
phase: "skip",
|
|
1914
|
+
message: `'${this.repoName}' already up to date with origin/${branch}`
|
|
1915
|
+
});
|
|
1916
|
+
this.outcomeAccumulator?.recordNoop("repo", "already_up_to_date", {
|
|
1917
|
+
branch,
|
|
1918
|
+
path: worktreeDir,
|
|
1919
|
+
message: `Already up to date with origin/${branch}`
|
|
1920
|
+
});
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
if (relationship !== "fast_forward") {
|
|
1924
|
+
if (relationship === "local_ahead") {
|
|
1925
|
+
this.recordSkip(
|
|
1926
|
+
{ kind: "ahead_unpushed", branch },
|
|
1927
|
+
`\u23ED\uFE0F '${this.repoName}' has unpushed commits ahead of origin/${branch}. Skipping merge.`,
|
|
1928
|
+
`Skipping merge for '${this.repoName}': unpushed commits ahead of origin/${branch}`,
|
|
1929
|
+
"info"
|
|
1930
|
+
);
|
|
1931
|
+
} else if (relationship === "indeterminate_shallow") {
|
|
1932
|
+
const detail = lastDeepenedTo === null ? `no deepening attempted (configured depth already at or above all deepen targets)` : `deepening to ${lastDeepenedTo} commits`;
|
|
1933
|
+
const progressDetail = lastDeepenedTo === null ? `no deepening attempted (configured depth at/above limits)` : `shallow depth budget exhausted at ${lastDeepenedTo}`;
|
|
1934
|
+
this.recordSkip(
|
|
1935
|
+
{ kind: "indeterminate_shallow", branch, deepenedTo: lastDeepenedTo },
|
|
1936
|
+
`\u23ED\uFE0F '${this.repoName}' could not classify origin/${branch} after ${detail}. Skipping merge \u2014 consider removing or raising 'depth' to unshallow.`,
|
|
1937
|
+
`Skipping merge for '${this.repoName}': ${progressDetail}`,
|
|
1938
|
+
"info"
|
|
1939
|
+
);
|
|
1940
|
+
} else {
|
|
1941
|
+
this.recordSkip(
|
|
1942
|
+
{ kind: "diverged", branch },
|
|
1943
|
+
`\u23ED\uFE0F '${this.repoName}' has diverged from origin/${branch}. Skipping merge (no auto-reset).`,
|
|
1944
|
+
`Skipping merge for '${this.repoName}': diverged from origin/${branch}`,
|
|
1945
|
+
"info"
|
|
1946
|
+
);
|
|
1947
|
+
}
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
this.logger.info(`Fast-forwarding '${this.repoName}' to origin/${branch}...`);
|
|
1951
|
+
this.emitProgress({ phase: "merge", message: `Fast-forwarding '${this.repoName}' to origin/${branch}` });
|
|
1952
|
+
await git.merge([`origin/${branch}`, "--ff-only"]);
|
|
1953
|
+
this.logger.info(`\u2705 Updated '${this.repoName}' to origin/${branch}.`);
|
|
1954
|
+
this.emitProgress({ phase: "merge", message: `Updated '${this.repoName}' to origin/${branch}` });
|
|
1955
|
+
this.outcomeAccumulator?.recordUpdated(branch, worktreeDir, "fast_forward");
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
|
|
1959
|
+
// src/services/git.service.ts
|
|
1960
|
+
import * as fs6 from "fs/promises";
|
|
1961
|
+
import * as path8 from "path";
|
|
1962
|
+
import simpleGit5 from "simple-git";
|
|
1963
|
+
|
|
1964
|
+
// src/utils/worktree-list-parser.ts
|
|
1965
|
+
function parseWorktreeListPorcelain(output) {
|
|
1966
|
+
const worktrees = [];
|
|
1967
|
+
let current = {};
|
|
1968
|
+
const flush = () => {
|
|
1969
|
+
if (!current.path) {
|
|
1970
|
+
current = {};
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
worktrees.push({
|
|
1974
|
+
path: current.path,
|
|
1975
|
+
branch: current.branch ?? null,
|
|
1976
|
+
head: current.head ?? null,
|
|
1977
|
+
detached: current.detached ?? false,
|
|
1978
|
+
prunable: current.prunable ?? false,
|
|
1979
|
+
locked: current.locked ?? false
|
|
1980
|
+
});
|
|
1981
|
+
current = {};
|
|
1982
|
+
};
|
|
1983
|
+
for (const line of output.split("\n")) {
|
|
1984
|
+
if (line.startsWith("worktree ")) {
|
|
1035
1985
|
flush();
|
|
1036
1986
|
current.path = line.substring("worktree ".length);
|
|
1037
1987
|
} else if (line.startsWith("branch ")) {
|
|
@@ -1053,8 +2003,8 @@ function parseWorktreeListPorcelain(output) {
|
|
|
1053
2003
|
}
|
|
1054
2004
|
|
|
1055
2005
|
// src/services/sparse-checkout.service.ts
|
|
1056
|
-
import * as
|
|
1057
|
-
import
|
|
2006
|
+
import * as path5 from "path";
|
|
2007
|
+
import simpleGit2 from "simple-git";
|
|
1058
2008
|
var SparseCheckoutService = class {
|
|
1059
2009
|
logger;
|
|
1060
2010
|
gitFactory;
|
|
@@ -1062,7 +2012,7 @@ var SparseCheckoutService = class {
|
|
|
1062
2012
|
matcherCache = /* @__PURE__ */ new WeakMap();
|
|
1063
2013
|
constructor(logger, gitFactory) {
|
|
1064
2014
|
this.logger = logger ?? Logger.createDefault();
|
|
1065
|
-
this.gitFactory = gitFactory ?? ((p) =>
|
|
2015
|
+
this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
|
|
1066
2016
|
}
|
|
1067
2017
|
updateLogger(logger) {
|
|
1068
2018
|
this.logger = logger;
|
|
@@ -1113,11 +2063,25 @@ var SparseCheckoutService = class {
|
|
|
1113
2063
|
return null;
|
|
1114
2064
|
}
|
|
1115
2065
|
}
|
|
2066
|
+
async readCurrentMode(worktreePath) {
|
|
2067
|
+
const git = this.gitFactory(worktreePath);
|
|
2068
|
+
try {
|
|
2069
|
+
const out = await git.raw(["config", "--bool", "--get", "core.sparseCheckoutCone"]);
|
|
2070
|
+
const value = out.trim().toLowerCase();
|
|
2071
|
+
if (value === "true") return "cone";
|
|
2072
|
+
if (value === "false") return "no-cone";
|
|
2073
|
+
return null;
|
|
2074
|
+
} catch {
|
|
2075
|
+
return null;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
1116
2078
|
async needsUpdate(worktreePath, cfg) {
|
|
2079
|
+
const desiredMode = this.resolveMode(cfg);
|
|
2080
|
+
const currentMode = await this.readCurrentMode(worktreePath);
|
|
2081
|
+
if (currentMode !== desiredMode) return true;
|
|
1117
2082
|
const current = await this.readCurrent(worktreePath);
|
|
1118
|
-
const desired = this.buildPatterns(cfg);
|
|
1119
2083
|
if (current === null) return true;
|
|
1120
|
-
return !this.patternsEqual(current,
|
|
2084
|
+
return !this.patternsEqual(current, this.buildPatternsForMode(cfg, desiredMode));
|
|
1121
2085
|
}
|
|
1122
2086
|
isNarrowing(currentPatterns, nextPatterns) {
|
|
1123
2087
|
if (!currentPatterns || currentPatterns.length === 0) return false;
|
|
@@ -1174,7 +2138,7 @@ var SparseCheckoutService = class {
|
|
|
1174
2138
|
for (const pat of matcher.patterns) {
|
|
1175
2139
|
if (p === pat || p.startsWith(pat + "/")) return true;
|
|
1176
2140
|
}
|
|
1177
|
-
return matcher.ancestorDirs.has(
|
|
2141
|
+
return matcher.ancestorDirs.has(path5.posix.dirname(p));
|
|
1178
2142
|
});
|
|
1179
2143
|
}
|
|
1180
2144
|
getMatcher(cfg) {
|
|
@@ -1201,9 +2165,9 @@ var SparseCheckoutService = class {
|
|
|
1201
2165
|
};
|
|
1202
2166
|
|
|
1203
2167
|
// src/services/worktree-metadata.service.ts
|
|
1204
|
-
import * as
|
|
1205
|
-
import * as
|
|
1206
|
-
import
|
|
2168
|
+
import * as fs4 from "fs/promises";
|
|
2169
|
+
import * as path6 from "path";
|
|
2170
|
+
import simpleGit3 from "simple-git";
|
|
1207
2171
|
var WorktreeMetadataService = class {
|
|
1208
2172
|
logger;
|
|
1209
2173
|
constructor(logger) {
|
|
@@ -1215,7 +2179,7 @@ var WorktreeMetadataService = class {
|
|
|
1215
2179
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
1216
2180
|
*/
|
|
1217
2181
|
getWorktreeDirectoryName(worktreePath) {
|
|
1218
|
-
return
|
|
2182
|
+
return path6.basename(worktreePath);
|
|
1219
2183
|
}
|
|
1220
2184
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
1221
2185
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -1223,7 +2187,7 @@ var WorktreeMetadataService = class {
|
|
|
1223
2187
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
1224
2188
|
);
|
|
1225
2189
|
}
|
|
1226
|
-
return
|
|
2190
|
+
return path6.join(
|
|
1227
2191
|
bareRepoPath,
|
|
1228
2192
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
1229
2193
|
worktreeName,
|
|
@@ -1236,31 +2200,31 @@ var WorktreeMetadataService = class {
|
|
|
1236
2200
|
}
|
|
1237
2201
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
1238
2202
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
1239
|
-
await
|
|
2203
|
+
await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
|
|
1240
2204
|
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
1241
2205
|
let renamed = false;
|
|
1242
2206
|
try {
|
|
1243
|
-
await
|
|
2207
|
+
await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
1244
2208
|
try {
|
|
1245
|
-
await
|
|
2209
|
+
await fs4.rename(tmpPath, metadataPath);
|
|
1246
2210
|
renamed = true;
|
|
1247
2211
|
} catch (err) {
|
|
1248
2212
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
1249
|
-
await
|
|
2213
|
+
await fs4.copyFile(tmpPath, metadataPath);
|
|
1250
2214
|
} else {
|
|
1251
2215
|
throw err;
|
|
1252
2216
|
}
|
|
1253
2217
|
}
|
|
1254
2218
|
} finally {
|
|
1255
2219
|
if (!renamed) {
|
|
1256
|
-
await
|
|
2220
|
+
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
1257
2221
|
}
|
|
1258
2222
|
}
|
|
1259
2223
|
}
|
|
1260
2224
|
async loadMetadata(bareRepoPath, worktreeName) {
|
|
1261
2225
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
1262
2226
|
try {
|
|
1263
|
-
const content = await
|
|
2227
|
+
const content = await fs4.readFile(metadataPath, "utf-8");
|
|
1264
2228
|
return JSON.parse(content);
|
|
1265
2229
|
} catch {
|
|
1266
2230
|
return null;
|
|
@@ -1269,7 +2233,7 @@ var WorktreeMetadataService = class {
|
|
|
1269
2233
|
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
1270
2234
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
1271
2235
|
try {
|
|
1272
|
-
const content = await
|
|
2236
|
+
const content = await fs4.readFile(metadataPath, "utf-8");
|
|
1273
2237
|
const metadata = JSON.parse(content);
|
|
1274
2238
|
if (!await this.validateMetadata(metadata)) {
|
|
1275
2239
|
this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
@@ -1283,7 +2247,7 @@ var WorktreeMetadataService = class {
|
|
|
1283
2247
|
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
1284
2248
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
1285
2249
|
try {
|
|
1286
|
-
await
|
|
2250
|
+
await fs4.unlink(metadataPath);
|
|
1287
2251
|
} catch (error) {
|
|
1288
2252
|
if (error.code !== "ENOENT") {
|
|
1289
2253
|
throw error;
|
|
@@ -1293,7 +2257,7 @@ var WorktreeMetadataService = class {
|
|
|
1293
2257
|
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
1294
2258
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
1295
2259
|
try {
|
|
1296
|
-
await
|
|
2260
|
+
await fs4.unlink(metadataPath);
|
|
1297
2261
|
} catch (error) {
|
|
1298
2262
|
if (error.code !== "ENOENT") {
|
|
1299
2263
|
throw error;
|
|
@@ -1327,7 +2291,7 @@ var WorktreeMetadataService = class {
|
|
|
1327
2291
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
1328
2292
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
1329
2293
|
try {
|
|
1330
|
-
const worktreeGit =
|
|
2294
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1331
2295
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
1332
2296
|
const branchSummary = await worktreeGit.branch();
|
|
1333
2297
|
const actualBranchName = branchSummary.current;
|
|
@@ -1428,9 +2392,9 @@ var WorktreeMetadataService = class {
|
|
|
1428
2392
|
};
|
|
1429
2393
|
|
|
1430
2394
|
// src/services/worktree-status.service.ts
|
|
1431
|
-
import * as
|
|
1432
|
-
import * as
|
|
1433
|
-
import
|
|
2395
|
+
import * as fs5 from "fs/promises";
|
|
2396
|
+
import * as path7 from "path";
|
|
2397
|
+
import simpleGit4 from "simple-git";
|
|
1434
2398
|
var OPERATION_FILES = [
|
|
1435
2399
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
1436
2400
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -1461,9 +2425,7 @@ var WorktreeStatusService = class {
|
|
|
1461
2425
|
return true;
|
|
1462
2426
|
}
|
|
1463
2427
|
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
1464
|
-
|
|
1465
|
-
await fs3.access(worktreePath);
|
|
1466
|
-
} catch {
|
|
2428
|
+
if (!await fileExists(worktreePath)) {
|
|
1467
2429
|
return {
|
|
1468
2430
|
isClean: true,
|
|
1469
2431
|
hasUnpushedCommits: false,
|
|
@@ -1633,7 +2595,7 @@ var WorktreeStatusService = class {
|
|
|
1633
2595
|
async detectOperationFile(gitDir) {
|
|
1634
2596
|
const results = await Promise.all(
|
|
1635
2597
|
OPERATION_FILES.map(
|
|
1636
|
-
({ file }) =>
|
|
2598
|
+
({ file }) => fs5.access(path7.join(gitDir, file)).then(
|
|
1637
2599
|
() => true,
|
|
1638
2600
|
() => false
|
|
1639
2601
|
)
|
|
@@ -1754,14 +2716,14 @@ var WorktreeStatusService = class {
|
|
|
1754
2716
|
}
|
|
1755
2717
|
}
|
|
1756
2718
|
async resolveGitDir(worktreePath) {
|
|
1757
|
-
const gitPath =
|
|
2719
|
+
const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
1758
2720
|
try {
|
|
1759
|
-
const stat4 = await
|
|
2721
|
+
const stat4 = await fs5.stat(gitPath);
|
|
1760
2722
|
if (stat4.isFile()) {
|
|
1761
|
-
const content = await
|
|
2723
|
+
const content = await fs5.readFile(gitPath, "utf-8");
|
|
1762
2724
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
1763
2725
|
if (gitdirMatch) {
|
|
1764
|
-
return
|
|
2726
|
+
return path7.resolve(worktreePath, gitdirMatch[1].trim());
|
|
1765
2727
|
}
|
|
1766
2728
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
1767
2729
|
}
|
|
@@ -1775,10 +2737,10 @@ var WorktreeStatusService = class {
|
|
|
1775
2737
|
}
|
|
1776
2738
|
}
|
|
1777
2739
|
createGitInstance(worktreePath) {
|
|
1778
|
-
const key = `${
|
|
2740
|
+
const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
1779
2741
|
let git = this.gitInstances.get(key);
|
|
1780
2742
|
if (!git) {
|
|
1781
|
-
git = this.config.skipLfs ?
|
|
2743
|
+
git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
|
|
1782
2744
|
this.gitInstances.set(key, git);
|
|
1783
2745
|
}
|
|
1784
2746
|
return git;
|
|
@@ -1798,7 +2760,7 @@ var GitService = class {
|
|
|
1798
2760
|
this.config = config;
|
|
1799
2761
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
1800
2762
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
1801
|
-
this.mainWorktreePath =
|
|
2763
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
1802
2764
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
1803
2765
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
1804
2766
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
@@ -1826,36 +2788,20 @@ var GitService = class {
|
|
|
1826
2788
|
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
1827
2789
|
}
|
|
1828
2790
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
1829
|
-
const key = `${
|
|
2791
|
+
const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
1830
2792
|
let git = this.gitInstances.get(key);
|
|
1831
2793
|
if (!git) {
|
|
1832
|
-
const base =
|
|
2794
|
+
const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
1833
2795
|
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
1834
2796
|
this.gitInstances.set(key, git);
|
|
1835
2797
|
}
|
|
1836
2798
|
return git;
|
|
1837
2799
|
}
|
|
1838
2800
|
buildSimpleGitOptions(blockMs) {
|
|
1839
|
-
const options = { progress: this.
|
|
2801
|
+
const options = { progress: makeGitProgressHandler(this.logger) };
|
|
1840
2802
|
if (blockMs > 0) options.timeout = { block: blockMs };
|
|
1841
2803
|
return options;
|
|
1842
2804
|
}
|
|
1843
|
-
makeProgressHandler() {
|
|
1844
|
-
const lastBucket = /* @__PURE__ */ new Map();
|
|
1845
|
-
return (event) => {
|
|
1846
|
-
if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
|
|
1847
|
-
const key = `${event.method}:${event.stage}`;
|
|
1848
|
-
const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
|
|
1849
|
-
let last = lastBucket.get(key) ?? -1;
|
|
1850
|
-
if (bucket < last) {
|
|
1851
|
-
last = -1;
|
|
1852
|
-
}
|
|
1853
|
-
if (bucket <= last && event.progress < 100) return;
|
|
1854
|
-
lastBucket.set(key, bucket);
|
|
1855
|
-
const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
|
|
1856
|
-
this.logger.info(` \u21B3 ${event.method} ${event.stage}: ${event.progress}% (${total})`);
|
|
1857
|
-
};
|
|
1858
|
-
}
|
|
1859
2805
|
updateLogger(logger) {
|
|
1860
2806
|
this.logger = logger;
|
|
1861
2807
|
this.sparseCheckoutService.updateLogger(logger);
|
|
@@ -1863,11 +2809,11 @@ var GitService = class {
|
|
|
1863
2809
|
async initialize() {
|
|
1864
2810
|
const { repoUrl } = this.config;
|
|
1865
2811
|
try {
|
|
1866
|
-
await
|
|
2812
|
+
await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
|
|
1867
2813
|
} catch {
|
|
1868
2814
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
1869
|
-
await
|
|
1870
|
-
const cloneBase =
|
|
2815
|
+
await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
|
|
2816
|
+
const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
|
|
1871
2817
|
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
1872
2818
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
|
|
1873
2819
|
this.logger.info("\u2705 Clone successful.");
|
|
@@ -1885,17 +2831,17 @@ var GitService = class {
|
|
|
1885
2831
|
this.logger.info("Fetching remote branches...");
|
|
1886
2832
|
await bareGit.fetch(["--all", "--progress"]);
|
|
1887
2833
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
1888
|
-
this.mainWorktreePath =
|
|
2834
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
|
|
1889
2835
|
let needsMainWorktree = true;
|
|
1890
2836
|
try {
|
|
1891
2837
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1892
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
2838
|
+
needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
|
|
1893
2839
|
} catch {
|
|
1894
2840
|
}
|
|
1895
2841
|
if (needsMainWorktree) {
|
|
1896
2842
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
1897
|
-
await
|
|
1898
|
-
const absoluteWorktreePath =
|
|
2843
|
+
await fs6.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2844
|
+
const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
|
|
1899
2845
|
const branches = await bareGit.branch();
|
|
1900
2846
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
1901
2847
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -1931,7 +2877,7 @@ var GitService = class {
|
|
|
1931
2877
|
}
|
|
1932
2878
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
1933
2879
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
1934
|
-
(w) =>
|
|
2880
|
+
(w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
|
|
1935
2881
|
);
|
|
1936
2882
|
if (!mainWorktreeRegistered) {
|
|
1937
2883
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -1957,6 +2903,45 @@ var GitService = class {
|
|
|
1957
2903
|
getBareRepoPath() {
|
|
1958
2904
|
return this.bareRepoPath;
|
|
1959
2905
|
}
|
|
2906
|
+
async getRemoteDefaultBranch(repoUrl) {
|
|
2907
|
+
const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
2908
|
+
try {
|
|
2909
|
+
const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
|
|
2910
|
+
const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
|
|
2911
|
+
if (match && match[1]) {
|
|
2912
|
+
return match[1];
|
|
2913
|
+
}
|
|
2914
|
+
} catch {
|
|
2915
|
+
}
|
|
2916
|
+
const existing = [];
|
|
2917
|
+
for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
|
|
2918
|
+
try {
|
|
2919
|
+
const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
|
|
2920
|
+
if (out.trim().length > 0) {
|
|
2921
|
+
existing.push(candidate);
|
|
2922
|
+
}
|
|
2923
|
+
} catch {
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
if (existing.length === 1) {
|
|
2927
|
+
this.logger.warn(
|
|
2928
|
+
`Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
|
|
2929
|
+
);
|
|
2930
|
+
return existing[0];
|
|
2931
|
+
}
|
|
2932
|
+
if (existing.length > 1) {
|
|
2933
|
+
throw new Error(
|
|
2934
|
+
`Unable to detect default branch for '${repoUrl}': symref HEAD is unavailable and multiple common branches exist (${existing.join(", ")}). Set 'branch' explicitly in the repository config.`
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
throw new Error(
|
|
2938
|
+
`Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
async verifyLfs(worktreePath, label) {
|
|
2942
|
+
if (this.isLfsSkipEnabled()) return;
|
|
2943
|
+
await this.verifyLfsFilesDownloaded(worktreePath, label);
|
|
2944
|
+
}
|
|
1960
2945
|
async fetchAll() {
|
|
1961
2946
|
this.assertInitialized();
|
|
1962
2947
|
this.logger.info("Fetching latest data from remote...");
|
|
@@ -2003,7 +2988,7 @@ var GitService = class {
|
|
|
2003
2988
|
return branches;
|
|
2004
2989
|
}
|
|
2005
2990
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
2006
|
-
const worktreeGit = this.config.sparseCheckout ?
|
|
2991
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
2007
2992
|
try {
|
|
2008
2993
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
2009
2994
|
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
@@ -2014,7 +2999,7 @@ var GitService = class {
|
|
|
2014
2999
|
const existence = await Promise.all(
|
|
2015
3000
|
lfsFileList.map(async (f) => {
|
|
2016
3001
|
try {
|
|
2017
|
-
await
|
|
3002
|
+
await fs6.access(path8.join(worktreePath, f));
|
|
2018
3003
|
return f;
|
|
2019
3004
|
} catch {
|
|
2020
3005
|
return null;
|
|
@@ -2042,9 +3027,9 @@ var GitService = class {
|
|
|
2042
3027
|
let allDownloaded = true;
|
|
2043
3028
|
const notDownloaded = [];
|
|
2044
3029
|
for (const file of samplesToCheck) {
|
|
2045
|
-
const filePath =
|
|
3030
|
+
const filePath = path8.join(worktreePath, file);
|
|
2046
3031
|
try {
|
|
2047
|
-
const handle = await
|
|
3032
|
+
const handle = await fs6.open(filePath, "r");
|
|
2048
3033
|
try {
|
|
2049
3034
|
const buffer = Buffer.alloc(200);
|
|
2050
3035
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
@@ -2069,7 +3054,7 @@ var GitService = class {
|
|
|
2069
3054
|
}
|
|
2070
3055
|
retries++;
|
|
2071
3056
|
if (retries < maxRetries) {
|
|
2072
|
-
await new Promise((
|
|
3057
|
+
await new Promise((resolve11) => setTimeout(resolve11, retryDelay));
|
|
2073
3058
|
}
|
|
2074
3059
|
}
|
|
2075
3060
|
this.logger.warn(
|
|
@@ -2131,18 +3116,18 @@ var GitService = class {
|
|
|
2131
3116
|
}
|
|
2132
3117
|
async addWorktree(branchName, worktreePath) {
|
|
2133
3118
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
2134
|
-
const absoluteWorktreePath =
|
|
2135
|
-
await
|
|
3119
|
+
const absoluteWorktreePath = path8.resolve(worktreePath);
|
|
3120
|
+
await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
|
|
2136
3121
|
try {
|
|
2137
|
-
await
|
|
3122
|
+
await fs6.access(absoluteWorktreePath);
|
|
2138
3123
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2139
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3124
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2140
3125
|
if (isValidWorktree) {
|
|
2141
3126
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
2142
3127
|
return;
|
|
2143
3128
|
} else {
|
|
2144
3129
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
2145
|
-
await
|
|
3130
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
2146
3131
|
}
|
|
2147
3132
|
} catch {
|
|
2148
3133
|
}
|
|
@@ -2181,7 +3166,7 @@ var GitService = class {
|
|
|
2181
3166
|
}
|
|
2182
3167
|
if (errorMessage.includes("already registered worktree")) {
|
|
2183
3168
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2184
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3169
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2185
3170
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
2186
3171
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
2187
3172
|
return;
|
|
@@ -2189,7 +3174,7 @@ var GitService = class {
|
|
|
2189
3174
|
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
2190
3175
|
await bareGit.raw(["worktree", "prune"]);
|
|
2191
3176
|
try {
|
|
2192
|
-
await
|
|
3177
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
2193
3178
|
} catch {
|
|
2194
3179
|
}
|
|
2195
3180
|
let retryCreatedNewBranch = false;
|
|
@@ -2225,15 +3210,15 @@ var GitService = class {
|
|
|
2225
3210
|
}
|
|
2226
3211
|
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
2227
3212
|
try {
|
|
2228
|
-
await
|
|
3213
|
+
await fs6.access(absoluteWorktreePath);
|
|
2229
3214
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2230
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3215
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2231
3216
|
if (isValidWorktree) {
|
|
2232
3217
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
2233
3218
|
return;
|
|
2234
3219
|
} else {
|
|
2235
3220
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
2236
|
-
await
|
|
3221
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
2237
3222
|
}
|
|
2238
3223
|
} catch {
|
|
2239
3224
|
}
|
|
@@ -2257,7 +3242,7 @@ var GitService = class {
|
|
|
2257
3242
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
2258
3243
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
2259
3244
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2260
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3245
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2261
3246
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
2262
3247
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
2263
3248
|
return;
|
|
@@ -2480,6 +3465,40 @@ var GitService = class {
|
|
|
2480
3465
|
return false;
|
|
2481
3466
|
}
|
|
2482
3467
|
}
|
|
3468
|
+
async classifyRemoteRelationship(worktreePath, branch) {
|
|
3469
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3470
|
+
let headSha;
|
|
3471
|
+
let remoteSha;
|
|
3472
|
+
try {
|
|
3473
|
+
headSha = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
3474
|
+
remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
|
|
3475
|
+
} catch {
|
|
3476
|
+
return "diverged";
|
|
3477
|
+
}
|
|
3478
|
+
if (headSha === remoteSha) return "up_to_date";
|
|
3479
|
+
let mergeBase = "";
|
|
3480
|
+
let mergeBaseFailed = false;
|
|
3481
|
+
try {
|
|
3482
|
+
mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
|
|
3483
|
+
} catch {
|
|
3484
|
+
mergeBaseFailed = true;
|
|
3485
|
+
}
|
|
3486
|
+
if (mergeBaseFailed || !mergeBase) {
|
|
3487
|
+
if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
|
|
3488
|
+
return "diverged";
|
|
3489
|
+
}
|
|
3490
|
+
if (mergeBase === headSha) return "fast_forward";
|
|
3491
|
+
if (mergeBase === remoteSha) return "local_ahead";
|
|
3492
|
+
return "diverged";
|
|
3493
|
+
}
|
|
3494
|
+
async isShallowRepository(git) {
|
|
3495
|
+
try {
|
|
3496
|
+
const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
|
|
3497
|
+
return output.trim() === "true";
|
|
3498
|
+
} catch {
|
|
3499
|
+
return false;
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
2483
3502
|
async getChangedPathsInRange(worktreePath, fromRef, toRef) {
|
|
2484
3503
|
const worktreeGit = this.getCachedGit(worktreePath);
|
|
2485
3504
|
try {
|
|
@@ -2569,7 +3588,7 @@ var GitService = class {
|
|
|
2569
3588
|
async createBranch(branchName, baseBranch) {
|
|
2570
3589
|
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
2571
3590
|
const baseRef = await this.resolveCreateBranchBaseRef(bareGit, baseBranch);
|
|
2572
|
-
await bareGit.raw(["branch", branchName, baseRef]);
|
|
3591
|
+
await bareGit.raw(["branch", "--no-track", branchName, baseRef]);
|
|
2573
3592
|
this.logger.info(`Created branch '${branchName}' from '${baseRef}'`);
|
|
2574
3593
|
}
|
|
2575
3594
|
async pushBranch(branchName) {
|
|
@@ -2588,37 +3607,240 @@ var GitService = class {
|
|
|
2588
3607
|
isPrunable: w.prunable
|
|
2589
3608
|
}));
|
|
2590
3609
|
}
|
|
2591
|
-
};
|
|
3610
|
+
};
|
|
3611
|
+
|
|
3612
|
+
// src/services/progress-emitter.ts
|
|
3613
|
+
var ProgressEmitter = class {
|
|
3614
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3615
|
+
onProgress(listener) {
|
|
3616
|
+
this.listeners.add(listener);
|
|
3617
|
+
return () => this.listeners.delete(listener);
|
|
3618
|
+
}
|
|
3619
|
+
emit(event) {
|
|
3620
|
+
for (const listener of [...this.listeners]) {
|
|
3621
|
+
try {
|
|
3622
|
+
listener(event);
|
|
3623
|
+
} catch {
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
};
|
|
3628
|
+
|
|
3629
|
+
// src/services/repo-operation-lock.ts
|
|
3630
|
+
import * as fs7 from "fs/promises";
|
|
3631
|
+
import * as path10 from "path";
|
|
3632
|
+
import * as lockfile from "proper-lockfile";
|
|
3633
|
+
|
|
3634
|
+
// src/utils/lock-path.ts
|
|
3635
|
+
import { createHash } from "crypto";
|
|
3636
|
+
import * as os from "os";
|
|
3637
|
+
import * as path9 from "path";
|
|
3638
|
+
function getCloneModeLockTarget(config) {
|
|
3639
|
+
const name = config.name;
|
|
3640
|
+
const configDir = config.__configFileDir;
|
|
3641
|
+
const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
3642
|
+
if (configDir) {
|
|
3643
|
+
return {
|
|
3644
|
+
dir: path9.join(configDir, ".sync-worktrees-state"),
|
|
3645
|
+
file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
|
|
3649
|
+
const dir = path9.join(stateBase, "sync-worktrees", "locks");
|
|
3650
|
+
return { dir, file: `${hash}.lock` };
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
// src/services/repo-operation-lock.ts
|
|
3654
|
+
var RepoOperationLock = class {
|
|
3655
|
+
constructor(config, gitService, logger = Logger.createDefault()) {
|
|
3656
|
+
this.config = config;
|
|
3657
|
+
this.gitService = gitService;
|
|
3658
|
+
this.logger = logger;
|
|
3659
|
+
}
|
|
3660
|
+
updateLogger(logger) {
|
|
3661
|
+
this.logger = logger;
|
|
3662
|
+
}
|
|
3663
|
+
async acquire() {
|
|
3664
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
3665
|
+
return async () => {
|
|
3666
|
+
};
|
|
3667
|
+
}
|
|
3668
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
3669
|
+
return this.acquireCloneModeLock();
|
|
3670
|
+
}
|
|
3671
|
+
return this.acquireWorktreeModeLock();
|
|
3672
|
+
}
|
|
3673
|
+
async acquireCloneModeLock() {
|
|
3674
|
+
const target = getCloneModeLockTarget(this.config);
|
|
3675
|
+
const lockTarget = path10.join(target.dir, target.file);
|
|
3676
|
+
try {
|
|
3677
|
+
await fs7.mkdir(target.dir, { recursive: true });
|
|
3678
|
+
await fs7.writeFile(lockTarget, "", { flag: "a" });
|
|
3679
|
+
} catch {
|
|
3680
|
+
return null;
|
|
3681
|
+
}
|
|
3682
|
+
return this.lockPath(lockTarget);
|
|
3683
|
+
}
|
|
3684
|
+
async acquireWorktreeModeLock() {
|
|
3685
|
+
const barePath = this.gitService.getBareRepoPath();
|
|
3686
|
+
try {
|
|
3687
|
+
await fs7.mkdir(barePath, { recursive: true });
|
|
3688
|
+
} catch {
|
|
3689
|
+
return null;
|
|
3690
|
+
}
|
|
3691
|
+
return this.lockPath(barePath);
|
|
3692
|
+
}
|
|
3693
|
+
async lockPath(lockTarget) {
|
|
3694
|
+
try {
|
|
3695
|
+
return await lockfile.lock(lockTarget, {
|
|
3696
|
+
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
3697
|
+
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
3698
|
+
retries: 0,
|
|
3699
|
+
realpath: false
|
|
3700
|
+
});
|
|
3701
|
+
} catch (error) {
|
|
3702
|
+
const code = error.code;
|
|
3703
|
+
if (code === "ELOCKED") {
|
|
3704
|
+
return null;
|
|
3705
|
+
}
|
|
3706
|
+
this.logger.warn(
|
|
3707
|
+
`Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
|
|
3708
|
+
);
|
|
3709
|
+
return null;
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
};
|
|
3713
|
+
|
|
3714
|
+
// src/services/sync-retry-policy.ts
|
|
3715
|
+
var SyncRetryPolicy = class {
|
|
3716
|
+
constructor(config, gitService, logger) {
|
|
3717
|
+
this.config = config;
|
|
3718
|
+
this.gitService = gitService;
|
|
3719
|
+
this.logger = logger;
|
|
3720
|
+
}
|
|
3721
|
+
updateLogger(logger) {
|
|
3722
|
+
this.logger = logger;
|
|
3723
|
+
}
|
|
3724
|
+
createContext() {
|
|
3725
|
+
return { lfsSkipEnabled: false };
|
|
3726
|
+
}
|
|
3727
|
+
createOptions(syncContext) {
|
|
3728
|
+
return {
|
|
3729
|
+
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
3730
|
+
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
3731
|
+
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
3732
|
+
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
3733
|
+
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
3734
|
+
jitterMs: this.config.retry?.jitterMs ?? 0,
|
|
3735
|
+
onRetry: (error, attempt, context) => {
|
|
3736
|
+
const errorMessage = getErrorMessage(error);
|
|
3737
|
+
this.logger.info(`
|
|
3738
|
+
\u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
|
|
3739
|
+
if (context?.isLfsError && !this.config.skipLfs) {
|
|
3740
|
+
this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
|
|
3741
|
+
} else {
|
|
3742
|
+
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
3743
|
+
`);
|
|
3744
|
+
}
|
|
3745
|
+
},
|
|
3746
|
+
lfsRetryHandler: () => {
|
|
3747
|
+
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
3748
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
3749
|
+
this.gitService.setLfsSkipEnabled(true);
|
|
3750
|
+
syncContext.lfsSkipEnabled = true;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
};
|
|
3754
|
+
}
|
|
3755
|
+
resetLfsSkipIfNeeded(syncContext) {
|
|
3756
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
3757
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
};
|
|
3761
|
+
|
|
3762
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
3763
|
+
import * as fs9 from "fs/promises";
|
|
3764
|
+
import * as path13 from "path";
|
|
3765
|
+
import pLimit from "p-limit";
|
|
3766
|
+
|
|
3767
|
+
// src/utils/date-filter.ts
|
|
3768
|
+
function parseDuration(durationStr) {
|
|
3769
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
3770
|
+
if (!match) {
|
|
3771
|
+
return null;
|
|
3772
|
+
}
|
|
3773
|
+
const value = parseInt(match[1], 10);
|
|
3774
|
+
const unit = match[2];
|
|
3775
|
+
const multipliers = {
|
|
3776
|
+
h: 60 * 60 * 1e3,
|
|
3777
|
+
// hours
|
|
3778
|
+
d: 24 * 60 * 60 * 1e3,
|
|
3779
|
+
// days
|
|
3780
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
3781
|
+
// weeks
|
|
3782
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
3783
|
+
// months (approximate)
|
|
3784
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
3785
|
+
// years (approximate)
|
|
3786
|
+
};
|
|
3787
|
+
return value * multipliers[unit];
|
|
3788
|
+
}
|
|
3789
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
3790
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
3791
|
+
if (maxAgeMs === null) {
|
|
3792
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
3793
|
+
return branches;
|
|
3794
|
+
}
|
|
3795
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
3796
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
3797
|
+
}
|
|
3798
|
+
function formatDuration2(durationStr) {
|
|
3799
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
3800
|
+
if (!match) {
|
|
3801
|
+
return durationStr;
|
|
3802
|
+
}
|
|
3803
|
+
const value = parseInt(match[1], 10);
|
|
3804
|
+
const unit = match[2];
|
|
3805
|
+
const unitNames = {
|
|
3806
|
+
h: value === 1 ? "hour" : "hours",
|
|
3807
|
+
d: value === 1 ? "day" : "days",
|
|
3808
|
+
w: value === 1 ? "week" : "weeks",
|
|
3809
|
+
m: value === 1 ? "month" : "months",
|
|
3810
|
+
y: value === 1 ? "year" : "years"
|
|
3811
|
+
};
|
|
3812
|
+
return `${value} ${unitNames[unit]}`;
|
|
3813
|
+
}
|
|
2592
3814
|
|
|
2593
3815
|
// src/services/path-resolution.service.ts
|
|
2594
|
-
import { createHash } from "crypto";
|
|
2595
|
-
import * as
|
|
2596
|
-
import * as
|
|
3816
|
+
import { createHash as createHash2 } from "crypto";
|
|
3817
|
+
import * as fs8 from "fs";
|
|
3818
|
+
import * as path11 from "path";
|
|
2597
3819
|
var BRANCH_STEM_MAX = 80;
|
|
2598
3820
|
var BRANCH_HASH_LEN = 8;
|
|
2599
3821
|
var PathResolutionService = class {
|
|
2600
3822
|
sanitizeBranchName(branchName) {
|
|
2601
3823
|
const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
|
|
2602
|
-
const hash =
|
|
3824
|
+
const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
|
|
2603
3825
|
return `${stem}-${hash}`;
|
|
2604
3826
|
}
|
|
2605
3827
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
2606
|
-
return
|
|
3828
|
+
return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
2607
3829
|
}
|
|
2608
3830
|
resolveRealPath(inputPath) {
|
|
2609
|
-
const absolute =
|
|
3831
|
+
const absolute = path11.resolve(inputPath);
|
|
2610
3832
|
const missing = [];
|
|
2611
3833
|
let current = absolute;
|
|
2612
|
-
while (!
|
|
2613
|
-
const parent =
|
|
3834
|
+
while (!fs8.existsSync(current)) {
|
|
3835
|
+
const parent = path11.dirname(current);
|
|
2614
3836
|
if (parent === current) {
|
|
2615
3837
|
return absolute;
|
|
2616
3838
|
}
|
|
2617
|
-
missing.unshift(
|
|
3839
|
+
missing.unshift(path11.basename(current));
|
|
2618
3840
|
current = parent;
|
|
2619
3841
|
}
|
|
2620
3842
|
try {
|
|
2621
|
-
return
|
|
3843
|
+
return path11.join(fs8.realpathSync(current), ...missing);
|
|
2622
3844
|
} catch {
|
|
2623
3845
|
return absolute;
|
|
2624
3846
|
}
|
|
@@ -2628,7 +3850,7 @@ var PathResolutionService = class {
|
|
|
2628
3850
|
const a = fold(resolved);
|
|
2629
3851
|
const b = fold(resolvedBase);
|
|
2630
3852
|
if (a === b) return true;
|
|
2631
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
3853
|
+
return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
|
|
2632
3854
|
}
|
|
2633
3855
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
2634
3856
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -2636,7 +3858,7 @@ var PathResolutionService = class {
|
|
|
2636
3858
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
2637
3859
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
2638
3860
|
}
|
|
2639
|
-
return
|
|
3861
|
+
return path11.relative(resolvedBase, resolved);
|
|
2640
3862
|
}
|
|
2641
3863
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
2642
3864
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -2648,174 +3870,110 @@ var PathResolutionService = class {
|
|
|
2648
3870
|
}
|
|
2649
3871
|
};
|
|
2650
3872
|
|
|
2651
|
-
// src/services/worktree-sync.
|
|
2652
|
-
|
|
2653
|
-
|
|
3873
|
+
// src/services/worktree-sync-planner.ts
|
|
3874
|
+
import * as path12 from "path";
|
|
3875
|
+
function createWorktreeSyncPlan(inventory, options = {}) {
|
|
3876
|
+
return {
|
|
3877
|
+
create: planCreateActions(inventory, options),
|
|
3878
|
+
prune: planPruneActions(inventory),
|
|
3879
|
+
update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
|
|
3880
|
+
sparse: planSparseActions(inventory, options.sparseCheckout),
|
|
3881
|
+
warnings: []
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
function planCreateActions(inventory, options = {}) {
|
|
3885
|
+
const pathResolution2 = options.pathResolution ?? new PathResolutionService();
|
|
3886
|
+
const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
|
|
3887
|
+
const newBranches = inventory.remoteBranches.filter(
|
|
3888
|
+
(branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
|
|
3889
|
+
);
|
|
3890
|
+
const reservedPaths = /* @__PURE__ */ new Map();
|
|
3891
|
+
for (const worktree of inventory.existingWorktrees) {
|
|
3892
|
+
reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
|
|
3893
|
+
}
|
|
3894
|
+
const actions = [];
|
|
3895
|
+
for (const branch of newBranches) {
|
|
3896
|
+
const worktreePath = pathResolution2.getBranchWorktreePath(inventory.worktreeDir, branch);
|
|
3897
|
+
const resolved = path12.resolve(worktreePath);
|
|
3898
|
+
const conflictingBranch = reservedPaths.get(resolved);
|
|
3899
|
+
if (conflictingBranch && conflictingBranch !== branch) {
|
|
3900
|
+
actions.push({
|
|
3901
|
+
kind: "skip-create",
|
|
3902
|
+
branch,
|
|
3903
|
+
path: worktreePath,
|
|
3904
|
+
reason: "path-collision",
|
|
3905
|
+
conflictingBranch
|
|
3906
|
+
});
|
|
3907
|
+
continue;
|
|
3908
|
+
}
|
|
3909
|
+
reservedPaths.set(resolved, branch);
|
|
3910
|
+
actions.push({ kind: "create", branch, path: worktreePath });
|
|
3911
|
+
}
|
|
3912
|
+
return actions;
|
|
3913
|
+
}
|
|
3914
|
+
function planPruneActions(inventory) {
|
|
3915
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
3916
|
+
return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
|
|
3917
|
+
}
|
|
3918
|
+
function planUpdateActions(inventory) {
|
|
3919
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
3920
|
+
return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
|
|
3921
|
+
}
|
|
3922
|
+
function planSparseActions(inventory, sparseCheckout) {
|
|
3923
|
+
if (!sparseCheckout) {
|
|
3924
|
+
return [];
|
|
3925
|
+
}
|
|
3926
|
+
return inventory.existingWorktrees.map((worktree) => ({
|
|
3927
|
+
kind: "check-sparse",
|
|
3928
|
+
branch: worktree.branch,
|
|
3929
|
+
path: worktree.path
|
|
3930
|
+
}));
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
3934
|
+
var WorktreeModeSyncRunner = class {
|
|
3935
|
+
constructor(config, gitService, logger, progressEmitter) {
|
|
2654
3936
|
this.config = config;
|
|
2655
|
-
this.
|
|
2656
|
-
this.
|
|
3937
|
+
this.gitService = gitService;
|
|
3938
|
+
this.logger = logger;
|
|
3939
|
+
this.progressEmitter = progressEmitter;
|
|
2657
3940
|
}
|
|
2658
|
-
gitService;
|
|
2659
|
-
logger;
|
|
2660
|
-
syncInProgress = false;
|
|
2661
3941
|
pathResolution = new PathResolutionService();
|
|
2662
|
-
progressListeners = /* @__PURE__ */ new Set();
|
|
2663
|
-
async initialize() {
|
|
2664
|
-
this.emitProgress({ phase: "initialize", message: "Initializing repository" });
|
|
2665
|
-
await this.gitService.initialize();
|
|
2666
|
-
this.emitProgress({ phase: "initialize", message: "Repository initialized" });
|
|
2667
|
-
}
|
|
2668
|
-
isInitialized() {
|
|
2669
|
-
return this.gitService.isInitialized();
|
|
2670
|
-
}
|
|
2671
|
-
isSyncInProgress() {
|
|
2672
|
-
return this.syncInProgress;
|
|
2673
|
-
}
|
|
2674
|
-
getGitService() {
|
|
2675
|
-
return this.gitService;
|
|
2676
|
-
}
|
|
2677
3942
|
updateLogger(logger) {
|
|
2678
3943
|
this.logger = logger;
|
|
2679
|
-
this.gitService.updateLogger(logger);
|
|
2680
|
-
}
|
|
2681
|
-
onProgress(listener) {
|
|
2682
|
-
this.progressListeners.add(listener);
|
|
2683
|
-
return () => this.progressListeners.delete(listener);
|
|
2684
|
-
}
|
|
2685
|
-
async runExclusiveRepoOperation(operation) {
|
|
2686
|
-
if (this.syncInProgress) {
|
|
2687
|
-
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
2688
|
-
return { started: false, reason: "in_progress" };
|
|
2689
|
-
}
|
|
2690
|
-
const release = await this.acquireBareLock();
|
|
2691
|
-
if (release === null) {
|
|
2692
|
-
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
2693
|
-
return { started: false, reason: "locked" };
|
|
2694
|
-
}
|
|
2695
|
-
this.syncInProgress = true;
|
|
2696
|
-
try {
|
|
2697
|
-
return { started: true, value: await operation() };
|
|
2698
|
-
} finally {
|
|
2699
|
-
this.syncInProgress = false;
|
|
2700
|
-
try {
|
|
2701
|
-
await release();
|
|
2702
|
-
} catch (releaseError) {
|
|
2703
|
-
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
2704
|
-
}
|
|
2705
|
-
}
|
|
2706
|
-
}
|
|
2707
|
-
emitProgress(event) {
|
|
2708
|
-
for (const listener of this.progressListeners) {
|
|
2709
|
-
try {
|
|
2710
|
-
listener(event);
|
|
2711
|
-
} catch {
|
|
2712
|
-
}
|
|
2713
|
-
}
|
|
2714
|
-
}
|
|
2715
|
-
async sync() {
|
|
2716
|
-
const result = await this.runExclusiveRepoOperation(async () => {
|
|
2717
|
-
if (!this.isInitialized()) {
|
|
2718
|
-
await this.initialize();
|
|
2719
|
-
}
|
|
2720
|
-
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
2721
|
-
const totalTimer = new Timer();
|
|
2722
|
-
const phaseTimer = new PhaseTimer();
|
|
2723
|
-
const syncContext = { lfsSkipEnabled: false };
|
|
2724
|
-
const retryOptions = this.createRetryOptions(syncContext);
|
|
2725
|
-
try {
|
|
2726
|
-
await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
|
|
2727
|
-
} catch (error) {
|
|
2728
|
-
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
2729
|
-
throw error;
|
|
2730
|
-
} finally {
|
|
2731
|
-
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
2732
|
-
this.gitService.setLfsSkipEnabled(false);
|
|
2733
|
-
}
|
|
2734
|
-
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
2735
|
-
`);
|
|
2736
|
-
if (this.config.debug) {
|
|
2737
|
-
const totalDuration = totalTimer.stop();
|
|
2738
|
-
const phaseResults = phaseTimer.getResults();
|
|
2739
|
-
const repoName = this.config.name;
|
|
2740
|
-
this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
});
|
|
2744
|
-
return result.started ? { started: true } : result;
|
|
2745
|
-
}
|
|
2746
|
-
async acquireBareLock() {
|
|
2747
|
-
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
2748
|
-
return async () => {
|
|
2749
|
-
};
|
|
2750
|
-
}
|
|
2751
|
-
if (typeof this.gitService.getBareRepoPath !== "function") {
|
|
2752
|
-
return async () => {
|
|
2753
|
-
};
|
|
2754
|
-
}
|
|
2755
|
-
const barePath = this.gitService.getBareRepoPath();
|
|
2756
|
-
await fs6.mkdir(barePath, { recursive: true });
|
|
2757
|
-
try {
|
|
2758
|
-
const release = await lockfile.lock(barePath, {
|
|
2759
|
-
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
2760
|
-
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
2761
|
-
retries: 0,
|
|
2762
|
-
realpath: false
|
|
2763
|
-
});
|
|
2764
|
-
return release;
|
|
2765
|
-
} catch (error) {
|
|
2766
|
-
const code = error.code;
|
|
2767
|
-
if (code === "ELOCKED") {
|
|
2768
|
-
return null;
|
|
2769
|
-
}
|
|
2770
|
-
throw error;
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
2773
|
-
createRetryOptions(syncContext) {
|
|
2774
|
-
return {
|
|
2775
|
-
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
2776
|
-
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
2777
|
-
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
2778
|
-
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
2779
|
-
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
2780
|
-
onRetry: (error, attempt, context) => {
|
|
2781
|
-
const errorMessage = getErrorMessage(error);
|
|
2782
|
-
this.logger.info(`
|
|
2783
|
-
\u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
|
|
2784
|
-
if (context?.isLfsError && !this.config.skipLfs) {
|
|
2785
|
-
this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
|
|
2786
|
-
} else {
|
|
2787
|
-
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
2788
|
-
`);
|
|
2789
|
-
}
|
|
2790
|
-
},
|
|
2791
|
-
lfsRetryHandler: () => {
|
|
2792
|
-
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
2793
|
-
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
2794
|
-
this.gitService.setLfsSkipEnabled(true);
|
|
2795
|
-
syncContext.lfsSkipEnabled = true;
|
|
2796
|
-
}
|
|
2797
|
-
}
|
|
2798
|
-
};
|
|
2799
3944
|
}
|
|
2800
|
-
async runSyncAttempt(phaseTimer, syncContext) {
|
|
3945
|
+
async runSyncAttempt(phaseTimer, syncContext, outcome) {
|
|
2801
3946
|
await this.gitService.pruneWorktrees();
|
|
2802
3947
|
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
2803
3948
|
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
2804
|
-
await
|
|
3949
|
+
await fs9.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2805
3950
|
const worktrees = await this.gitService.getWorktrees();
|
|
2806
3951
|
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
2807
3952
|
await this.cleanupOrphanedDirectories(worktrees);
|
|
2808
|
-
|
|
2809
|
-
|
|
3953
|
+
const syncPlan = createWorktreeSyncPlan(
|
|
3954
|
+
{
|
|
3955
|
+
remoteBranches,
|
|
3956
|
+
defaultBranch,
|
|
3957
|
+
existingWorktrees: worktrees,
|
|
3958
|
+
worktreeDir: this.config.worktreeDir
|
|
3959
|
+
},
|
|
3960
|
+
{
|
|
3961
|
+
pathResolution: this.pathResolution,
|
|
3962
|
+
updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
|
|
3963
|
+
sparseCheckout: this.config.sparseCheckout
|
|
3964
|
+
}
|
|
3965
|
+
);
|
|
3966
|
+
await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
|
|
3967
|
+
await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
|
|
2810
3968
|
if (this.config.updateExistingWorktrees !== false) {
|
|
2811
|
-
await this.updateExistingWorktreesWithTiming(
|
|
3969
|
+
await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
|
|
2812
3970
|
}
|
|
2813
3971
|
if (this.config.sparseCheckout) {
|
|
2814
|
-
await this.reapplySparseCheckout(
|
|
3972
|
+
await this.reapplySparseCheckout(syncPlan.sparse, outcome);
|
|
2815
3973
|
}
|
|
2816
3974
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
2817
3975
|
}
|
|
2818
|
-
async reapplySparseCheckout(
|
|
3976
|
+
async reapplySparseCheckout(actions, outcome) {
|
|
2819
3977
|
const sparseConfig = this.config.sparseCheckout;
|
|
2820
3978
|
if (!sparseConfig) return;
|
|
2821
3979
|
this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
|
|
@@ -2823,32 +3981,44 @@ var WorktreeSyncService = class {
|
|
|
2823
3981
|
const desired = sparseService.buildPatterns(sparseConfig);
|
|
2824
3982
|
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
2825
3983
|
await Promise.all(
|
|
2826
|
-
|
|
2827
|
-
(
|
|
3984
|
+
actions.map(
|
|
3985
|
+
(action) => limit(async () => {
|
|
3986
|
+
if (action.kind !== "check-sparse") return;
|
|
2828
3987
|
try {
|
|
2829
3988
|
try {
|
|
2830
|
-
await
|
|
3989
|
+
await fs9.access(action.path);
|
|
2831
3990
|
} catch {
|
|
2832
3991
|
return;
|
|
2833
3992
|
}
|
|
2834
|
-
const current = await sparseService.readCurrent(
|
|
3993
|
+
const current = await sparseService.readCurrent(action.path);
|
|
2835
3994
|
if (current !== null && sparseService.patternsEqual(current, desired)) return;
|
|
2836
3995
|
if (sparseService.isNarrowing(current, desired)) {
|
|
2837
|
-
const status = await this.gitService.getFullWorktreeStatus(
|
|
3996
|
+
const status = await this.gitService.getFullWorktreeStatus(action.path, false);
|
|
2838
3997
|
if (!status.canRemove) {
|
|
2839
3998
|
this.logger.warn(
|
|
2840
|
-
` - Skipping sparse-checkout narrowing for '${
|
|
3999
|
+
` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
|
|
2841
4000
|
);
|
|
4001
|
+
outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
|
|
4002
|
+
branch: action.branch,
|
|
4003
|
+
path: action.path,
|
|
4004
|
+
message: status.reasons.join(", ")
|
|
4005
|
+
});
|
|
2842
4006
|
return;
|
|
2843
4007
|
}
|
|
2844
4008
|
}
|
|
2845
|
-
await sparseService.applyToWorktree(
|
|
2846
|
-
await this.gitService.checkoutHead(
|
|
2847
|
-
this.logger.info(` - \u2705 Sparse-checkout updated for '${
|
|
4009
|
+
await sparseService.applyToWorktree(action.path, sparseConfig);
|
|
4010
|
+
await this.gitService.checkoutHead(action.path);
|
|
4011
|
+
this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
|
|
4012
|
+
outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
|
|
2848
4013
|
} catch (error) {
|
|
2849
4014
|
this.logger.warn(
|
|
2850
|
-
` - \u26A0\uFE0F Failed to update sparse-checkout for '${
|
|
4015
|
+
` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
|
|
2851
4016
|
);
|
|
4017
|
+
outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
|
|
4018
|
+
reason: "sparse_checkout_failed",
|
|
4019
|
+
branch: action.branch,
|
|
4020
|
+
path: action.path
|
|
4021
|
+
});
|
|
2852
4022
|
}
|
|
2853
4023
|
})
|
|
2854
4024
|
)
|
|
@@ -2857,7 +4027,7 @@ var WorktreeSyncService = class {
|
|
|
2857
4027
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
2858
4028
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
2859
4029
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
2860
|
-
this.
|
|
4030
|
+
this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
|
|
2861
4031
|
try {
|
|
2862
4032
|
await this.gitService.fetchAll();
|
|
2863
4033
|
} catch (fetchError) {
|
|
@@ -2902,7 +4072,7 @@ var WorktreeSyncService = class {
|
|
|
2902
4072
|
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
2903
4073
|
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
2904
4074
|
this.logger.info(
|
|
2905
|
-
`After filtering by age (${
|
|
4075
|
+
`After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
2906
4076
|
);
|
|
2907
4077
|
if (filteredByName.length > remoteBranches.length) {
|
|
2908
4078
|
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
@@ -2921,45 +4091,38 @@ var WorktreeSyncService = class {
|
|
|
2921
4091
|
}
|
|
2922
4092
|
async finalizeSyncAttempt(phaseTimer) {
|
|
2923
4093
|
phaseTimer.startPhase("Phase 5: Cleanup");
|
|
2924
|
-
this.
|
|
4094
|
+
this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
|
|
2925
4095
|
await this.gitService.pruneWorktrees();
|
|
2926
4096
|
this.logger.info("Step 5: Pruned worktree metadata.");
|
|
2927
4097
|
phaseTimer.endPhase();
|
|
2928
4098
|
}
|
|
2929
|
-
async createNewWorktreesWithTiming(
|
|
4099
|
+
async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
|
|
2930
4100
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
2931
4101
|
phaseTimer.startPhase("Phase 2: Create", maxConcurrent);
|
|
2932
|
-
this.
|
|
2933
|
-
await this.createNewWorktrees(
|
|
2934
|
-
|
|
2935
|
-
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
2936
|
-
phaseTimer.setPhaseCount("Phase 2: Create", newBranches.length);
|
|
4102
|
+
this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
|
|
4103
|
+
await this.createNewWorktrees(syncPlan.create, outcome);
|
|
4104
|
+
phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
|
|
2937
4105
|
phaseTimer.endPhase();
|
|
2938
4106
|
}
|
|
2939
|
-
async createNewWorktrees(
|
|
2940
|
-
|
|
2941
|
-
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
2942
|
-
if (newBranches.length === 0) {
|
|
4107
|
+
async createNewWorktrees(actions, outcome) {
|
|
4108
|
+
if (actions.length === 0) {
|
|
2943
4109
|
this.logger.info("Step 2: No new branches to create worktrees for.");
|
|
2944
4110
|
return;
|
|
2945
4111
|
}
|
|
2946
|
-
const reservedPaths = /* @__PURE__ */ new Map();
|
|
2947
|
-
for (const w of worktrees) {
|
|
2948
|
-
reservedPaths.set(path8.resolve(w.path), w.branch);
|
|
2949
|
-
}
|
|
2950
4112
|
const plan = [];
|
|
2951
|
-
for (const
|
|
2952
|
-
|
|
2953
|
-
const resolved = path8.resolve(worktreePath);
|
|
2954
|
-
const conflict = reservedPaths.get(resolved);
|
|
2955
|
-
if (conflict && conflict !== branchName) {
|
|
4113
|
+
for (const action of actions) {
|
|
4114
|
+
if (action.kind === "skip-create") {
|
|
2956
4115
|
this.logger.error(
|
|
2957
|
-
` \u274C Skipping '${
|
|
4116
|
+
` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
|
|
2958
4117
|
);
|
|
4118
|
+
outcome.recordSkipped("branch", "path_collision", {
|
|
4119
|
+
branch: action.branch,
|
|
4120
|
+
path: action.path,
|
|
4121
|
+
message: `Path collides with existing branch '${action.conflictingBranch}'`
|
|
4122
|
+
});
|
|
2959
4123
|
continue;
|
|
2960
4124
|
}
|
|
2961
|
-
|
|
2962
|
-
plan.push({ branchName, worktreePath });
|
|
4125
|
+
plan.push({ branchName: action.branch, worktreePath: action.path });
|
|
2963
4126
|
}
|
|
2964
4127
|
this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
|
|
2965
4128
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
@@ -2970,8 +4133,14 @@ var WorktreeSyncService = class {
|
|
|
2970
4133
|
try {
|
|
2971
4134
|
await this.gitService.addWorktree(branchName, worktreePath);
|
|
2972
4135
|
this.logger.info(` \u2705 Created worktree for '${branchName}'`);
|
|
4136
|
+
outcome.recordCreated(branchName, worktreePath);
|
|
2973
4137
|
} catch (error) {
|
|
2974
4138
|
this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
|
|
4139
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
4140
|
+
reason: "create_failed",
|
|
4141
|
+
branch: branchName,
|
|
4142
|
+
path: worktreePath
|
|
4143
|
+
});
|
|
2975
4144
|
throw error;
|
|
2976
4145
|
}
|
|
2977
4146
|
})
|
|
@@ -2980,23 +4149,21 @@ var WorktreeSyncService = class {
|
|
|
2980
4149
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
2981
4150
|
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
2982
4151
|
}
|
|
2983
|
-
async pruneOldWorktreesWithTiming(
|
|
4152
|
+
async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
2984
4153
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2985
4154
|
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
2986
|
-
this.
|
|
2987
|
-
await this.pruneOldWorktrees(
|
|
2988
|
-
|
|
2989
|
-
phaseTimer.setPhaseCount("Phase 3: Prune", deletedWorktrees.length);
|
|
4155
|
+
this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
|
|
4156
|
+
await this.pruneOldWorktrees(actions, outcome);
|
|
4157
|
+
phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
|
|
2990
4158
|
phaseTimer.endPhase();
|
|
2991
4159
|
}
|
|
2992
|
-
async pruneOldWorktrees(
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
this.logger.info(`Step 3: Checking ${deletedWorktrees.length} stale worktrees to prune...`);
|
|
4160
|
+
async pruneOldWorktrees(actions, outcome) {
|
|
4161
|
+
if (actions.length > 0) {
|
|
4162
|
+
this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
|
|
2996
4163
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2997
4164
|
const limit = pLimit(maxConcurrent);
|
|
2998
4165
|
const statusResults = await Promise.allSettled(
|
|
2999
|
-
|
|
4166
|
+
actions.map(
|
|
3000
4167
|
({ branch: branchName, path: worktreePath }) => limit(async () => {
|
|
3001
4168
|
const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
|
|
3002
4169
|
return { branchName, worktreePath, status };
|
|
@@ -3019,6 +4186,10 @@ var WorktreeSyncService = class {
|
|
|
3019
4186
|
const branchName = result.reason?.branchName ?? "unknown";
|
|
3020
4187
|
this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
|
|
3021
4188
|
this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
|
|
4189
|
+
outcome.recordSkipped("worktree", "prune_status_check_failed", {
|
|
4190
|
+
branch: branchName,
|
|
4191
|
+
message: getErrorMessage(result.reason)
|
|
4192
|
+
});
|
|
3022
4193
|
}
|
|
3023
4194
|
}
|
|
3024
4195
|
if (toRemove.length > 0) {
|
|
@@ -3034,12 +4205,23 @@ var WorktreeSyncService = class {
|
|
|
3034
4205
|
this.logger.warn(
|
|
3035
4206
|
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
3036
4207
|
);
|
|
4208
|
+
outcome.recordSkipped("worktree", "prune_status_changed", {
|
|
4209
|
+
branch: branchName,
|
|
4210
|
+
path: worktreePath,
|
|
4211
|
+
message: recheck.reasons.join(", ")
|
|
4212
|
+
});
|
|
3037
4213
|
return;
|
|
3038
4214
|
}
|
|
3039
4215
|
await this.gitService.removeWorktree(worktreePath);
|
|
3040
4216
|
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
4217
|
+
outcome.recordRemoved(branchName, worktreePath);
|
|
3041
4218
|
} catch (error) {
|
|
3042
4219
|
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
4220
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
4221
|
+
reason: "remove_failed",
|
|
4222
|
+
branch: branchName,
|
|
4223
|
+
path: worktreePath
|
|
4224
|
+
});
|
|
3043
4225
|
throw error;
|
|
3044
4226
|
}
|
|
3045
4227
|
})
|
|
@@ -3052,6 +4234,11 @@ var WorktreeSyncService = class {
|
|
|
3052
4234
|
this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
|
|
3053
4235
|
}
|
|
3054
4236
|
for (const { branchName, worktreePath, status } of toSkip) {
|
|
4237
|
+
outcome.recordSkipped("worktree", "unsafe_to_remove", {
|
|
4238
|
+
branch: branchName,
|
|
4239
|
+
path: worktreePath,
|
|
4240
|
+
message: status.reasons.join(", ")
|
|
4241
|
+
});
|
|
3055
4242
|
if (status.upstreamGone && status.hasUnpushedCommits) {
|
|
3056
4243
|
this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
|
|
3057
4244
|
this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
|
|
@@ -3144,53 +4331,52 @@ var WorktreeSyncService = class {
|
|
|
3144
4331
|
this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
|
|
3145
4332
|
}
|
|
3146
4333
|
}
|
|
3147
|
-
async updateExistingWorktreesWithTiming(
|
|
4334
|
+
async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
3148
4335
|
const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
3149
4336
|
phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
|
|
3150
|
-
this.
|
|
3151
|
-
await this.updateExistingWorktrees(
|
|
3152
|
-
|
|
3153
|
-
phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
|
|
4337
|
+
this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
|
|
4338
|
+
await this.updateExistingWorktrees(actions, outcome);
|
|
4339
|
+
phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
|
|
3154
4340
|
phaseTimer.endPhase();
|
|
3155
4341
|
}
|
|
3156
|
-
async updateExistingWorktrees(
|
|
4342
|
+
async updateExistingWorktrees(actions, outcome) {
|
|
3157
4343
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
3158
|
-
const divergedDir =
|
|
4344
|
+
const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3159
4345
|
try {
|
|
3160
|
-
const diverged = await
|
|
4346
|
+
const diverged = await fs9.readdir(divergedDir);
|
|
3161
4347
|
if (diverged.length > 0) {
|
|
3162
4348
|
this.logger.info(
|
|
3163
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
4349
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
|
|
3164
4350
|
);
|
|
3165
4351
|
}
|
|
3166
4352
|
} catch {
|
|
3167
4353
|
}
|
|
3168
|
-
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
3169
4354
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
3170
4355
|
const limit = pLimit(maxConcurrent);
|
|
3171
4356
|
const checkResults = await Promise.allSettled(
|
|
3172
|
-
|
|
3173
|
-
(
|
|
4357
|
+
actions.map(
|
|
4358
|
+
(action) => limit(async () => {
|
|
4359
|
+
const worktree = { path: action.path, branch: action.branch };
|
|
3174
4360
|
try {
|
|
3175
|
-
await
|
|
4361
|
+
await fs9.access(worktree.path);
|
|
3176
4362
|
} catch {
|
|
3177
|
-
return
|
|
4363
|
+
return { action: "skip", worktree, reason: "missing_worktree_path" };
|
|
3178
4364
|
}
|
|
3179
4365
|
const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
|
|
3180
|
-
if (hasOp) return
|
|
4366
|
+
if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
|
|
3181
4367
|
const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
|
|
3182
|
-
if (!isClean) return
|
|
4368
|
+
if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
|
|
3183
4369
|
const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
|
|
3184
4370
|
if (!canFastForward) {
|
|
3185
4371
|
const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
|
|
3186
4372
|
if (isAhead) {
|
|
3187
4373
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
|
|
3188
|
-
return
|
|
4374
|
+
return { action: "skip", worktree, reason: "local_ahead" };
|
|
3189
4375
|
}
|
|
3190
4376
|
return { action: "diverged", worktree };
|
|
3191
4377
|
}
|
|
3192
4378
|
const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
|
|
3193
|
-
if (!isBehind) return
|
|
4379
|
+
if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
|
|
3194
4380
|
const sparseCfg = this.config.sparseCheckout;
|
|
3195
4381
|
if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
|
|
3196
4382
|
const sparseService = this.gitService.getSparseCheckoutService();
|
|
@@ -3202,7 +4388,7 @@ var WorktreeSyncService = class {
|
|
|
3202
4388
|
);
|
|
3203
4389
|
if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
|
|
3204
4390
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
|
|
3205
|
-
return
|
|
4391
|
+
return { action: "skip", worktree, reason: "outside_sparse_checkout" };
|
|
3206
4392
|
}
|
|
3207
4393
|
}
|
|
3208
4394
|
}
|
|
@@ -3214,13 +4400,25 @@ var WorktreeSyncService = class {
|
|
|
3214
4400
|
const divergedWorktrees = [];
|
|
3215
4401
|
for (const result of checkResults) {
|
|
3216
4402
|
if (result.status === "fulfilled" && result.value) {
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
4403
|
+
switch (result.value.action) {
|
|
4404
|
+
case "update":
|
|
4405
|
+
worktreesToUpdate.push(result.value.worktree);
|
|
4406
|
+
break;
|
|
4407
|
+
case "diverged":
|
|
4408
|
+
divergedWorktrees.push(result.value.worktree);
|
|
4409
|
+
break;
|
|
4410
|
+
case "noop":
|
|
4411
|
+
outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
|
|
4412
|
+
break;
|
|
4413
|
+
case "skip":
|
|
4414
|
+
outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
|
|
4415
|
+
break;
|
|
3221
4416
|
}
|
|
3222
4417
|
} else if (result.status === "rejected") {
|
|
3223
4418
|
this.logger.error(` - Error checking worktree:`, result.reason);
|
|
4419
|
+
outcome.recordSkipped("worktree", "update_check_failed", {
|
|
4420
|
+
message: getErrorMessage(result.reason)
|
|
4421
|
+
});
|
|
3224
4422
|
}
|
|
3225
4423
|
}
|
|
3226
4424
|
const updateLimit = pLimit(
|
|
@@ -3234,6 +4432,7 @@ var WorktreeSyncService = class {
|
|
|
3234
4432
|
this.logger.info(` - Updating worktree '${worktree.branch}'...`);
|
|
3235
4433
|
await this.gitService.updateWorktree(worktree.path);
|
|
3236
4434
|
this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
|
|
4435
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
|
|
3237
4436
|
} catch (error) {
|
|
3238
4437
|
const errorMessage = getErrorMessage(error);
|
|
3239
4438
|
if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
|
|
@@ -3241,13 +4440,23 @@ var WorktreeSyncService = class {
|
|
|
3241
4440
|
` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
|
|
3242
4441
|
);
|
|
3243
4442
|
try {
|
|
3244
|
-
await this.handleDivergedBranch(worktree);
|
|
4443
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
3245
4444
|
} catch (divergedError) {
|
|
3246
4445
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
|
|
4446
|
+
outcome.recordFailed("worktree", getErrorMessage(divergedError), {
|
|
4447
|
+
reason: "diverged_recovery_failed",
|
|
4448
|
+
branch: worktree.branch,
|
|
4449
|
+
path: worktree.path
|
|
4450
|
+
});
|
|
3247
4451
|
throw divergedError;
|
|
3248
4452
|
}
|
|
3249
4453
|
} else {
|
|
3250
4454
|
this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
|
|
4455
|
+
outcome.recordFailed("worktree", errorMessage, {
|
|
4456
|
+
reason: "update_failed",
|
|
4457
|
+
branch: worktree.branch,
|
|
4458
|
+
path: worktree.path
|
|
4459
|
+
});
|
|
3251
4460
|
throw error;
|
|
3252
4461
|
}
|
|
3253
4462
|
}
|
|
@@ -3259,9 +4468,14 @@ var WorktreeSyncService = class {
|
|
|
3259
4468
|
mutationTasks.push(
|
|
3260
4469
|
updateLimit(async () => {
|
|
3261
4470
|
try {
|
|
3262
|
-
await this.handleDivergedBranch(worktree);
|
|
4471
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
3263
4472
|
} catch (error) {
|
|
3264
4473
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
|
|
4474
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
4475
|
+
reason: "diverged_recovery_failed",
|
|
4476
|
+
branch: worktree.branch,
|
|
4477
|
+
path: worktree.path
|
|
4478
|
+
});
|
|
3265
4479
|
throw error;
|
|
3266
4480
|
}
|
|
3267
4481
|
return { type: "diverged", branch: worktree.branch };
|
|
@@ -3284,13 +4498,13 @@ var WorktreeSyncService = class {
|
|
|
3284
4498
|
}
|
|
3285
4499
|
async cleanupOrphanedDirectories(worktrees) {
|
|
3286
4500
|
try {
|
|
3287
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
3288
|
-
const allDirs = await
|
|
4501
|
+
const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
|
|
4502
|
+
const allDirs = await fs9.readdir(this.config.worktreeDir);
|
|
3289
4503
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
3290
4504
|
const orphanedDirs = [];
|
|
3291
4505
|
for (const dir of regularDirs) {
|
|
3292
4506
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
3293
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
4507
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
|
|
3294
4508
|
});
|
|
3295
4509
|
if (!isPartOfWorktree) {
|
|
3296
4510
|
orphanedDirs.push(dir);
|
|
@@ -3299,11 +4513,11 @@ var WorktreeSyncService = class {
|
|
|
3299
4513
|
if (orphanedDirs.length > 0) {
|
|
3300
4514
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
3301
4515
|
for (const dir of orphanedDirs) {
|
|
3302
|
-
const dirPath =
|
|
4516
|
+
const dirPath = path13.join(this.config.worktreeDir, dir);
|
|
3303
4517
|
try {
|
|
3304
|
-
const stat4 = await
|
|
4518
|
+
const stat4 = await fs9.stat(dirPath);
|
|
3305
4519
|
if (stat4.isDirectory()) {
|
|
3306
|
-
await
|
|
4520
|
+
await fs9.rm(dirPath, { recursive: true, force: true });
|
|
3307
4521
|
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
3308
4522
|
}
|
|
3309
4523
|
} catch (error) {
|
|
@@ -3315,13 +4529,14 @@ var WorktreeSyncService = class {
|
|
|
3315
4529
|
this.logger.error("Error during orphaned directory cleanup:", error);
|
|
3316
4530
|
}
|
|
3317
4531
|
}
|
|
3318
|
-
async handleDivergedBranch(worktree) {
|
|
4532
|
+
async handleDivergedBranch(worktree, outcome) {
|
|
3319
4533
|
this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
|
|
3320
4534
|
const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
|
|
3321
4535
|
if (treesIdentical) {
|
|
3322
4536
|
this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
|
|
3323
4537
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
3324
4538
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
4539
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
|
|
3325
4540
|
} else {
|
|
3326
4541
|
const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
|
|
3327
4542
|
if (!hasLocalChanges) {
|
|
@@ -3330,10 +4545,12 @@ var WorktreeSyncService = class {
|
|
|
3330
4545
|
);
|
|
3331
4546
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
3332
4547
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
4548
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
|
|
3333
4549
|
} else {
|
|
3334
4550
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
3335
4551
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
3336
|
-
const relativePath =
|
|
4552
|
+
const relativePath = path13.relative(process.cwd(), divergedPath);
|
|
4553
|
+
outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
|
|
3337
4554
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
3338
4555
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
3339
4556
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -3344,55 +4561,238 @@ var WorktreeSyncService = class {
|
|
|
3344
4561
|
}
|
|
3345
4562
|
}
|
|
3346
4563
|
}
|
|
3347
|
-
async hasLocalChangesSinceLastSync(worktreePath) {
|
|
4564
|
+
async hasLocalChangesSinceLastSync(worktreePath) {
|
|
4565
|
+
try {
|
|
4566
|
+
const metadata = await this.gitService.getWorktreeMetadata(worktreePath);
|
|
4567
|
+
if (!metadata || !metadata.lastSyncCommit) {
|
|
4568
|
+
return true;
|
|
4569
|
+
}
|
|
4570
|
+
const currentCommit = await this.gitService.getCurrentCommit(worktreePath);
|
|
4571
|
+
return currentCommit !== metadata.lastSyncCommit;
|
|
4572
|
+
} catch {
|
|
4573
|
+
return true;
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
async divergeWorktree(worktreePath, branchName) {
|
|
4577
|
+
const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4578
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4579
|
+
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4580
|
+
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4581
|
+
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4582
|
+
const divergedPath = path13.join(divergedBaseDir, divergedName);
|
|
4583
|
+
await fs9.mkdir(divergedBaseDir, { recursive: true });
|
|
4584
|
+
try {
|
|
4585
|
+
await fs9.rename(worktreePath, divergedPath);
|
|
4586
|
+
} catch (err) {
|
|
4587
|
+
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
4588
|
+
await fs9.cp(worktreePath, divergedPath, { recursive: true });
|
|
4589
|
+
await fs9.rm(worktreePath, { recursive: true, force: true });
|
|
4590
|
+
} else {
|
|
4591
|
+
throw err;
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
const metadata = {
|
|
4595
|
+
originalBranch: branchName,
|
|
4596
|
+
divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4597
|
+
reason: METADATA_CONSTANTS.DIVERGED_REASON,
|
|
4598
|
+
originalPath: worktreePath,
|
|
4599
|
+
localCommit: await this.gitService.getCurrentCommit(divergedPath),
|
|
4600
|
+
remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
|
|
4601
|
+
instruction: `To preserve your changes:
|
|
4602
|
+
1. Review: git diff origin/${branchName}
|
|
4603
|
+
2. Keep changes: git push --force-with-lease origin ${branchName}
|
|
4604
|
+
3. Discard changes: rm -rf this directory
|
|
4605
|
+
|
|
4606
|
+
Original worktree location: ${worktreePath}`
|
|
4607
|
+
};
|
|
4608
|
+
await fs9.writeFile(
|
|
4609
|
+
path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4610
|
+
JSON.stringify(metadata, null, 2)
|
|
4611
|
+
);
|
|
4612
|
+
return divergedPath;
|
|
4613
|
+
}
|
|
4614
|
+
};
|
|
4615
|
+
|
|
4616
|
+
// src/services/worktree-sync.service.ts
|
|
4617
|
+
var WorktreeSyncService = class {
|
|
4618
|
+
constructor(config) {
|
|
4619
|
+
this.config = config;
|
|
4620
|
+
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
4621
|
+
this.gitService = new GitService(config, this.logger);
|
|
4622
|
+
this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
|
|
4623
|
+
this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
|
|
4624
|
+
this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
|
|
4625
|
+
config,
|
|
4626
|
+
this.gitService,
|
|
4627
|
+
this.logger,
|
|
4628
|
+
this.progressEmitter
|
|
4629
|
+
);
|
|
4630
|
+
if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
|
|
4631
|
+
this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
|
|
4632
|
+
progressEmitter: (event) => this.emitProgress(event),
|
|
4633
|
+
onSkip: (reason) => {
|
|
4634
|
+
this.skipsAccumulator.push(reason);
|
|
4635
|
+
}
|
|
4636
|
+
});
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4639
|
+
gitService;
|
|
4640
|
+
cloneSyncService = null;
|
|
4641
|
+
logger;
|
|
4642
|
+
syncInProgress = false;
|
|
4643
|
+
progressEmitter = new ProgressEmitter();
|
|
4644
|
+
repoOperationLock;
|
|
4645
|
+
retryPolicy;
|
|
4646
|
+
worktreeModeSyncRunner;
|
|
4647
|
+
skipsAccumulator = [];
|
|
4648
|
+
lastOutcome = null;
|
|
4649
|
+
getRecordedSkips() {
|
|
4650
|
+
return [...this.skipsAccumulator];
|
|
4651
|
+
}
|
|
4652
|
+
clearRecordedSkips() {
|
|
4653
|
+
this.skipsAccumulator = [];
|
|
4654
|
+
}
|
|
4655
|
+
clearPendingInitSkip() {
|
|
4656
|
+
this.cloneSyncService?.clearPendingInitSkip();
|
|
4657
|
+
}
|
|
4658
|
+
getLastOutcome() {
|
|
4659
|
+
return this.lastOutcome;
|
|
4660
|
+
}
|
|
4661
|
+
isCloneMode() {
|
|
4662
|
+
return this.cloneSyncService !== null;
|
|
4663
|
+
}
|
|
4664
|
+
async getWorktrees() {
|
|
4665
|
+
if (this.cloneSyncService) {
|
|
4666
|
+
return this.cloneSyncService.getWorktrees();
|
|
4667
|
+
}
|
|
4668
|
+
return this.gitService.getWorktrees();
|
|
4669
|
+
}
|
|
4670
|
+
async initialize() {
|
|
4671
|
+
if (this.isInitialized()) return;
|
|
4672
|
+
const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
|
|
4673
|
+
if (!result.started) {
|
|
4674
|
+
const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
|
|
4675
|
+
this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
|
|
4676
|
+
}
|
|
4677
|
+
}
|
|
4678
|
+
async initializeUnlocked(outcome) {
|
|
4679
|
+
this.emitProgress({ phase: "initialize", message: "Initializing repository" });
|
|
4680
|
+
if (this.cloneSyncService) {
|
|
4681
|
+
await this.cloneSyncService.initialize(outcome);
|
|
4682
|
+
} else {
|
|
4683
|
+
await this.gitService.initialize();
|
|
4684
|
+
}
|
|
4685
|
+
this.emitProgress({ phase: "initialize", message: "Repository initialized" });
|
|
4686
|
+
}
|
|
4687
|
+
isInitialized() {
|
|
4688
|
+
if (this.cloneSyncService) {
|
|
4689
|
+
return this.cloneSyncService.isInitialized();
|
|
4690
|
+
}
|
|
4691
|
+
return this.gitService.isInitialized();
|
|
4692
|
+
}
|
|
4693
|
+
isSyncInProgress() {
|
|
4694
|
+
return this.syncInProgress;
|
|
4695
|
+
}
|
|
4696
|
+
getGitService() {
|
|
4697
|
+
return this.gitService;
|
|
4698
|
+
}
|
|
4699
|
+
updateLogger(logger) {
|
|
4700
|
+
this.logger = logger;
|
|
4701
|
+
this.gitService.updateLogger(logger);
|
|
4702
|
+
this.cloneSyncService?.updateLogger(logger);
|
|
4703
|
+
this.retryPolicy.updateLogger(logger);
|
|
4704
|
+
this.worktreeModeSyncRunner.updateLogger(logger);
|
|
4705
|
+
this.repoOperationLock.updateLogger(logger);
|
|
4706
|
+
}
|
|
4707
|
+
onProgress(listener) {
|
|
4708
|
+
return this.progressEmitter.onProgress(listener);
|
|
4709
|
+
}
|
|
4710
|
+
async runExclusiveRepoOperation(operation) {
|
|
4711
|
+
if (this.syncInProgress) {
|
|
4712
|
+
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
4713
|
+
return { started: false, reason: "in_progress" };
|
|
4714
|
+
}
|
|
4715
|
+
this.syncInProgress = true;
|
|
4716
|
+
let release;
|
|
3348
4717
|
try {
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
4718
|
+
release = await this.repoOperationLock.acquire();
|
|
4719
|
+
} catch (error) {
|
|
4720
|
+
this.syncInProgress = false;
|
|
4721
|
+
throw error;
|
|
4722
|
+
}
|
|
4723
|
+
if (release === null) {
|
|
4724
|
+
this.syncInProgress = false;
|
|
4725
|
+
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
4726
|
+
return { started: false, reason: "locked" };
|
|
3357
4727
|
}
|
|
3358
|
-
}
|
|
3359
|
-
async divergeWorktree(worktreePath, branchName) {
|
|
3360
|
-
const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3361
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
3362
|
-
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
3363
|
-
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
3364
|
-
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
3365
|
-
const divergedPath = path8.join(divergedBaseDir, divergedName);
|
|
3366
|
-
await fs6.mkdir(divergedBaseDir, { recursive: true });
|
|
3367
4728
|
try {
|
|
3368
|
-
await
|
|
3369
|
-
}
|
|
3370
|
-
|
|
3371
|
-
await
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
throw err;
|
|
4729
|
+
return { started: true, value: await operation() };
|
|
4730
|
+
} finally {
|
|
4731
|
+
try {
|
|
4732
|
+
await release();
|
|
4733
|
+
} catch (releaseError) {
|
|
4734
|
+
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
3375
4735
|
}
|
|
4736
|
+
this.syncInProgress = false;
|
|
3376
4737
|
}
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
4738
|
+
}
|
|
4739
|
+
emitProgress(event) {
|
|
4740
|
+
this.progressEmitter.emit(event);
|
|
4741
|
+
}
|
|
4742
|
+
async sync() {
|
|
4743
|
+
const result = await this.runExclusiveRepoOperation(async () => {
|
|
4744
|
+
const totalTimer = new Timer();
|
|
4745
|
+
const phaseTimer = new PhaseTimer();
|
|
4746
|
+
const outcome = new SyncOutcomeAccumulator({
|
|
4747
|
+
mode: this.cloneSyncService ? "clone" : "worktree",
|
|
4748
|
+
repoName: this.config.name
|
|
4749
|
+
});
|
|
4750
|
+
const syncContext = this.retryPolicy.createContext();
|
|
4751
|
+
const retryOptions = this.retryPolicy.createOptions(syncContext);
|
|
4752
|
+
let durationMs;
|
|
4753
|
+
try {
|
|
4754
|
+
if (!this.isInitialized()) {
|
|
4755
|
+
await this.initializeUnlocked(outcome);
|
|
4756
|
+
}
|
|
4757
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
4758
|
+
const retryOutcomeBaseline = outcome.snapshot();
|
|
4759
|
+
const retryOptionsWithOutcomeReset = {
|
|
4760
|
+
...retryOptions,
|
|
4761
|
+
onRetry: (error, attempt, context) => {
|
|
4762
|
+
outcome.restore(retryOutcomeBaseline);
|
|
4763
|
+
retryOptions.onRetry?.(error, attempt, context);
|
|
4764
|
+
}
|
|
4765
|
+
};
|
|
4766
|
+
const cloneSync = this.cloneSyncService;
|
|
4767
|
+
if (cloneSync) {
|
|
4768
|
+
await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
|
|
4769
|
+
} else {
|
|
4770
|
+
await retry(
|
|
4771
|
+
() => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
|
|
4772
|
+
retryOptionsWithOutcomeReset
|
|
4773
|
+
);
|
|
4774
|
+
}
|
|
4775
|
+
} catch (error) {
|
|
4776
|
+
if (outcome.getCounts().failed === 0) {
|
|
4777
|
+
outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
|
|
4778
|
+
}
|
|
4779
|
+
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
4780
|
+
throw error;
|
|
4781
|
+
} finally {
|
|
4782
|
+
this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
|
|
4783
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
4784
|
+
`);
|
|
4785
|
+
durationMs = totalTimer.stop();
|
|
4786
|
+
this.lastOutcome = outcome.toOutcome(durationMs);
|
|
4787
|
+
if (this.config.debug) {
|
|
4788
|
+
const phaseResults = phaseTimer.getResults();
|
|
4789
|
+
const repoName = this.config.name;
|
|
4790
|
+
this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
return this.lastOutcome ?? outcome.toOutcome(durationMs);
|
|
4794
|
+
});
|
|
4795
|
+
return result.started ? { started: true, outcome: result.value } : result;
|
|
3396
4796
|
}
|
|
3397
4797
|
};
|
|
3398
4798
|
|
|
@@ -3442,11 +4842,18 @@ var RepositoryContext = class {
|
|
|
3442
4842
|
configPath = null;
|
|
3443
4843
|
configLoader = new ConfigLoaderService();
|
|
3444
4844
|
discoveryCache = /* @__PURE__ */ new Map();
|
|
4845
|
+
launchCwd;
|
|
4846
|
+
constructor(options = {}) {
|
|
4847
|
+
this.launchCwd = path14.resolve(options.launchCwd ?? process.cwd());
|
|
4848
|
+
}
|
|
4849
|
+
getLaunchCwd() {
|
|
4850
|
+
return this.launchCwd;
|
|
4851
|
+
}
|
|
3445
4852
|
async loadConfig(configPath, options = {}) {
|
|
3446
4853
|
const setDefaultCurrent = options.setDefaultCurrent ?? true;
|
|
3447
|
-
const absolutePath =
|
|
4854
|
+
const absolutePath = path14.resolve(configPath);
|
|
3448
4855
|
const configFile = await this.configLoader.loadConfigFile(absolutePath);
|
|
3449
|
-
const configDir =
|
|
4856
|
+
const configDir = path14.dirname(absolutePath);
|
|
3450
4857
|
const globalDefaults = configFile.defaults;
|
|
3451
4858
|
const resolvedAll = [];
|
|
3452
4859
|
for (const repo of configFile.repositories) {
|
|
@@ -3483,7 +4890,7 @@ var RepositoryContext = class {
|
|
|
3483
4890
|
return configFile.repositories;
|
|
3484
4891
|
}
|
|
3485
4892
|
async detectFromPath(dirPath) {
|
|
3486
|
-
const absolutePath =
|
|
4893
|
+
const absolutePath = path14.resolve(dirPath);
|
|
3487
4894
|
const cached = this.discoveryCache.get(absolutePath);
|
|
3488
4895
|
if (cached && await this.isCacheFresh(cached)) {
|
|
3489
4896
|
return cached.result;
|
|
@@ -3502,8 +4909,8 @@ var RepositoryContext = class {
|
|
|
3502
4909
|
const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
|
|
3503
4910
|
if (result.isWorktree && result.bareRepoPath && adminDir) {
|
|
3504
4911
|
const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
|
|
3505
|
-
safeMtimeMs(
|
|
3506
|
-
safeMtimeMs(
|
|
4912
|
+
safeMtimeMs(path14.join(adminDir, "HEAD")),
|
|
4913
|
+
safeMtimeMs(path14.join(result.bareRepoPath, "worktrees"))
|
|
3507
4914
|
]);
|
|
3508
4915
|
this.discoveryCache.set(absolutePath, {
|
|
3509
4916
|
result,
|
|
@@ -3534,44 +4941,65 @@ var RepositoryContext = class {
|
|
|
3534
4941
|
__discoveryCacheSizeForTest() {
|
|
3535
4942
|
return this.discoveryCache.size;
|
|
3536
4943
|
}
|
|
4944
|
+
/** @internal Test-only helper — exposes the internal selection state. */
|
|
4945
|
+
__getRepositorySelectionStateForTest() {
|
|
4946
|
+
return this.getRepositorySelectionState();
|
|
4947
|
+
}
|
|
3537
4948
|
async discoverSiblingRepositories(currentBareRepoPath) {
|
|
3538
|
-
const
|
|
3539
|
-
const
|
|
3540
|
-
|
|
4949
|
+
const currentBare = normalizePathForCompare(currentBareRepoPath);
|
|
4950
|
+
const results = /* @__PURE__ */ new Map();
|
|
4951
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
4952
|
+
const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
|
|
4953
|
+
const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
|
|
4954
|
+
return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
|
|
4955
|
+
}).filter((c) => c.foldedBare !== currentBare);
|
|
4956
|
+
const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
|
|
4957
|
+
configCandidates.forEach(({ entry, bareRepoPath, foldedBare }, i) => {
|
|
4958
|
+
const sibling = {
|
|
4959
|
+
name: entry.name,
|
|
4960
|
+
bareRepoPath,
|
|
4961
|
+
worktreeDir: path14.resolve(entry.config.worktreeDir),
|
|
4962
|
+
repoUrl: entry.config.repoUrl,
|
|
4963
|
+
present: configPresence[i],
|
|
4964
|
+
configMatched: true
|
|
4965
|
+
};
|
|
4966
|
+
if (entry.config.sparseCheckout) {
|
|
4967
|
+
sibling.sparseCheckout = entry.config.sparseCheckout;
|
|
4968
|
+
}
|
|
4969
|
+
results.set(foldedBare, sibling);
|
|
4970
|
+
});
|
|
4971
|
+
const repoDir = path14.dirname(currentBareRepoPath);
|
|
4972
|
+
const workspaceRoot = path14.dirname(repoDir);
|
|
4973
|
+
if (workspaceRoot === repoDir) {
|
|
4974
|
+
return Array.from(results.values()).sort(byName);
|
|
4975
|
+
}
|
|
3541
4976
|
let entries;
|
|
3542
4977
|
try {
|
|
3543
|
-
entries = await
|
|
4978
|
+
entries = await fs10.readdir(workspaceRoot);
|
|
3544
4979
|
} catch {
|
|
3545
|
-
return
|
|
3546
|
-
}
|
|
3547
|
-
const configBares = /* @__PURE__ */ new Map();
|
|
3548
|
-
for (const entry of this.repos.values()) {
|
|
3549
|
-
if (entry.source === "config" && entry.config.bareRepoDir) {
|
|
3550
|
-
configBares.set(normalizePathForCompare(entry.config.bareRepoDir), entry.name);
|
|
3551
|
-
}
|
|
4980
|
+
return Array.from(results.values()).sort(byName);
|
|
3552
4981
|
}
|
|
3553
|
-
const
|
|
4982
|
+
const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
|
|
3554
4983
|
await Promise.all(
|
|
3555
4984
|
entries.map(async (entry) => {
|
|
3556
|
-
const candidate =
|
|
3557
|
-
const bareCandidate =
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
const resolvedBare = path9.resolve(bareCandidate);
|
|
3565
|
-
const matchedName = configBares.get(normalizePathForCompare(resolvedBare));
|
|
3566
|
-
results.push({
|
|
4985
|
+
const candidate = path14.join(workspaceRoot, entry);
|
|
4986
|
+
const bareCandidate = path14.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
|
|
4987
|
+
if (!await isDirectory(bareCandidate)) return;
|
|
4988
|
+
const resolvedBare = path14.resolve(bareCandidate);
|
|
4989
|
+
const foldedBare = normalizePathForCompare(resolvedBare);
|
|
4990
|
+
if (foldedBare === currentBare || results.has(foldedBare)) return;
|
|
4991
|
+
const matchedName = configBares.get(foldedBare);
|
|
4992
|
+
results.set(foldedBare, {
|
|
3567
4993
|
name: matchedName ?? entry,
|
|
3568
4994
|
bareRepoPath: resolvedBare,
|
|
4995
|
+
worktreeDir: null,
|
|
4996
|
+
repoUrl: null,
|
|
4997
|
+
present: true,
|
|
3569
4998
|
configMatched: matchedName !== void 0
|
|
3570
4999
|
});
|
|
3571
5000
|
})
|
|
3572
5001
|
);
|
|
3573
|
-
results.
|
|
3574
|
-
return results;
|
|
5002
|
+
return Array.from(results.values()).sort(byName);
|
|
3575
5003
|
}
|
|
3576
5004
|
bootstrapCurrentRepo(candidate, force = false) {
|
|
3577
5005
|
if (this.currentRepo !== null) return;
|
|
@@ -3583,8 +5011,8 @@ var RepositoryContext = class {
|
|
|
3583
5011
|
if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
|
|
3584
5012
|
if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
|
|
3585
5013
|
const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
|
|
3586
|
-
safeMtimeMs(
|
|
3587
|
-
safeMtimeMs(
|
|
5014
|
+
safeMtimeMs(path14.join(cached.worktreeAdminDir, "HEAD")),
|
|
5015
|
+
safeMtimeMs(path14.join(cached.result.bareRepoPath, "worktrees"))
|
|
3588
5016
|
]);
|
|
3589
5017
|
return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
|
|
3590
5018
|
}
|
|
@@ -3617,6 +5045,13 @@ var RepositoryContext = class {
|
|
|
3617
5045
|
return unsupported("No .git file found in path or any parent directory");
|
|
3618
5046
|
}
|
|
3619
5047
|
if (located.kind === "regular-git-dir") {
|
|
5048
|
+
const cloneEntry = this.findConfiguredCloneEntry(worktreeRoot);
|
|
5049
|
+
if (cloneEntry) {
|
|
5050
|
+
return {
|
|
5051
|
+
result: await this.buildCloneModeContext(cloneEntry, worktreeRoot, notes),
|
|
5052
|
+
adminDir: null
|
|
5053
|
+
};
|
|
5054
|
+
}
|
|
3620
5055
|
return unsupported("Directory has .git folder (regular repo, not a sync-worktrees worktree)");
|
|
3621
5056
|
}
|
|
3622
5057
|
const gitFileContent = located.gitFileContent;
|
|
@@ -3625,18 +5060,18 @@ var RepositoryContext = class {
|
|
|
3625
5060
|
return unsupported("Invalid .git file format (missing gitdir line)");
|
|
3626
5061
|
}
|
|
3627
5062
|
const gitdir = gitdirMatch[1].trim();
|
|
3628
|
-
const resolvedGitdir =
|
|
5063
|
+
const resolvedGitdir = path14.isAbsolute(gitdir) ? gitdir : path14.resolve(worktreeRoot, gitdir);
|
|
3629
5064
|
const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
|
|
3630
5065
|
if (!worktreesMatch) {
|
|
3631
5066
|
return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
|
|
3632
5067
|
}
|
|
3633
|
-
const bareRepoPath =
|
|
3634
|
-
const adminDir =
|
|
5068
|
+
const bareRepoPath = path14.resolve(worktreesMatch[1]);
|
|
5069
|
+
const adminDir = path14.resolve(resolvedGitdir);
|
|
3635
5070
|
let repoUrl = null;
|
|
3636
5071
|
let worktrees = [];
|
|
3637
5072
|
let currentBranch = null;
|
|
3638
5073
|
try {
|
|
3639
|
-
const bareGit =
|
|
5074
|
+
const bareGit = simpleGit6(bareRepoPath);
|
|
3640
5075
|
try {
|
|
3641
5076
|
const remoteResult = await bareGit.remote(["get-url", "origin"]);
|
|
3642
5077
|
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
@@ -3672,7 +5107,7 @@ var RepositoryContext = class {
|
|
|
3672
5107
|
adminDir
|
|
3673
5108
|
};
|
|
3674
5109
|
}
|
|
3675
|
-
const worktreeDir =
|
|
5110
|
+
const worktreeDir = path14.dirname(worktreeRoot);
|
|
3676
5111
|
const noUrlReason = "no remote origin URL detected";
|
|
3677
5112
|
const capabilities = {
|
|
3678
5113
|
listWorktrees: { available: true },
|
|
@@ -3708,7 +5143,7 @@ var RepositoryContext = class {
|
|
|
3708
5143
|
cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
3709
5144
|
runOnce: true
|
|
3710
5145
|
};
|
|
3711
|
-
const detectedKey = `${AUTO_DETECT_PREFIX}${
|
|
5146
|
+
const detectedKey = `${AUTO_DETECT_PREFIX}${path14.basename(bareRepoPath)}@${bareRepoPath}`;
|
|
3712
5147
|
if (!this.repos.has(detectedKey)) {
|
|
3713
5148
|
this.repos.set(detectedKey, {
|
|
3714
5149
|
name: detectedKey,
|
|
@@ -3749,13 +5184,19 @@ var RepositoryContext = class {
|
|
|
3749
5184
|
return { result: discovered, adminDir };
|
|
3750
5185
|
}
|
|
3751
5186
|
async getService(repoName) {
|
|
5187
|
+
if (repoName) {
|
|
5188
|
+
const explicit = this.selectExplicitRepository(repoName);
|
|
5189
|
+
if (explicit.kind !== "selected") {
|
|
5190
|
+
throw new Error(this.buildRepoNotFoundError(repoName));
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
3752
5193
|
const name = repoName ?? this.currentRepo;
|
|
3753
5194
|
if (!name) {
|
|
3754
|
-
throw new Error(
|
|
5195
|
+
throw new Error(this.buildNoRepoSelectedError());
|
|
3755
5196
|
}
|
|
3756
5197
|
const entry = this.repos.get(name);
|
|
3757
5198
|
if (!entry) {
|
|
3758
|
-
throw new Error(
|
|
5199
|
+
throw new Error(this.buildRepoNotFoundError(name));
|
|
3759
5200
|
}
|
|
3760
5201
|
if (!entry.service) {
|
|
3761
5202
|
const logger = createStderrLogger(entry.name);
|
|
@@ -3766,6 +5207,93 @@ var RepositoryContext = class {
|
|
|
3766
5207
|
}
|
|
3767
5208
|
return entry.service;
|
|
3768
5209
|
}
|
|
5210
|
+
getRepositorySelectionState() {
|
|
5211
|
+
const configured = this.getConfiguredRepositoryNames();
|
|
5212
|
+
const detected = this.getDetectedRepositoryNames();
|
|
5213
|
+
return {
|
|
5214
|
+
currentRepo: this.currentRepo,
|
|
5215
|
+
configured,
|
|
5216
|
+
detected,
|
|
5217
|
+
defaultDecision: this.selectDefaultRepository(configured, detected)
|
|
5218
|
+
};
|
|
5219
|
+
}
|
|
5220
|
+
selectExplicitRepository(repoName) {
|
|
5221
|
+
if (this.repos.has(repoName)) {
|
|
5222
|
+
return { kind: "selected", repoName, source: "explicit" };
|
|
5223
|
+
}
|
|
5224
|
+
return {
|
|
5225
|
+
kind: "missing",
|
|
5226
|
+
configured: this.getConfiguredRepositoryNames(),
|
|
5227
|
+
detected: this.getDetectedRepositoryNames(),
|
|
5228
|
+
reason: `Repository '${repoName}' not found`
|
|
5229
|
+
};
|
|
5230
|
+
}
|
|
5231
|
+
selectDefaultRepository(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
|
|
5232
|
+
if (this.currentRepo !== null) {
|
|
5233
|
+
return { kind: "selected", repoName: this.currentRepo, source: "current" };
|
|
5234
|
+
}
|
|
5235
|
+
if (this.canAutoSelectSingleConfig(configured, detected)) {
|
|
5236
|
+
return { kind: "selected", repoName: configured[0], source: "single-config" };
|
|
5237
|
+
}
|
|
5238
|
+
if (configured.length === 0 && detected.length === 0) {
|
|
5239
|
+
return {
|
|
5240
|
+
kind: "missing",
|
|
5241
|
+
configured,
|
|
5242
|
+
detected,
|
|
5243
|
+
reason: "no configured or detected repositories are registered"
|
|
5244
|
+
};
|
|
5245
|
+
}
|
|
5246
|
+
return {
|
|
5247
|
+
kind: "ambiguous",
|
|
5248
|
+
configured,
|
|
5249
|
+
detected,
|
|
5250
|
+
reason: "repository selection is ambiguous without currentRepo or explicit repoName"
|
|
5251
|
+
};
|
|
5252
|
+
}
|
|
5253
|
+
canAutoSelectSingleConfig(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
|
|
5254
|
+
return this.currentRepo === null && configured.length === 1 && detected.length === 0;
|
|
5255
|
+
}
|
|
5256
|
+
getDetectedRepositoryNames() {
|
|
5257
|
+
return Array.from(this.repos.values()).filter((entry) => entry.source === "detected").map((entry) => entry.name);
|
|
5258
|
+
}
|
|
5259
|
+
formatDetectedRepositoryNames() {
|
|
5260
|
+
return Array.from(this.repos.values()).filter((e) => e.source === "detected").map((e) => {
|
|
5261
|
+
const location = e.discovered?.currentWorktreePath ?? e.config.bareRepoDir ?? e.config.worktreeDir;
|
|
5262
|
+
return location ? `${e.name} (${location})` : e.name;
|
|
5263
|
+
});
|
|
5264
|
+
}
|
|
5265
|
+
formatKnownRepositoryNames(names) {
|
|
5266
|
+
return names.length === 0 ? "[]" : `[${names.join(", ")}]`;
|
|
5267
|
+
}
|
|
5268
|
+
buildNoRepoSelectedError() {
|
|
5269
|
+
const selection = this.getRepositorySelectionState();
|
|
5270
|
+
const detected = this.formatDetectedRepositoryNames();
|
|
5271
|
+
const parts = [
|
|
5272
|
+
"No repository specified and no current repository set.",
|
|
5273
|
+
`launchCwd=${this.launchCwd}`,
|
|
5274
|
+
`configPath=${this.configPath ?? "none"}`,
|
|
5275
|
+
`loadedRepos=${this.repos.size} (config: ${selection.configured.length}, detected: ${selection.detected.length})`
|
|
5276
|
+
];
|
|
5277
|
+
if (detected.length > 0) {
|
|
5278
|
+
parts.push(`Detected repos: ${this.formatKnownRepositoryNames(detected)}.`);
|
|
5279
|
+
}
|
|
5280
|
+
if (selection.configured.length > 0) {
|
|
5281
|
+
parts.push(`Configured repos: ${this.formatKnownRepositoryNames(selection.configured)}.`);
|
|
5282
|
+
}
|
|
5283
|
+
if (selection.configured.length > 0 || detected.length > 0) {
|
|
5284
|
+
parts.push("Recovery: call set_current_repository with one of the repo names above or pass repoName explicitly.");
|
|
5285
|
+
} else {
|
|
5286
|
+
parts.push(
|
|
5287
|
+
"Recovery: call detect_context {path: <workspace>}, load_config {configPath: <file>}, set SYNC_WORKTREES_CONFIG env var, or pass repoName explicitly."
|
|
5288
|
+
);
|
|
5289
|
+
}
|
|
5290
|
+
return parts.join(" ");
|
|
5291
|
+
}
|
|
5292
|
+
buildRepoNotFoundError(name) {
|
|
5293
|
+
const known = Array.from(this.repos.keys());
|
|
5294
|
+
const knownStr = this.formatKnownRepositoryNames(known);
|
|
5295
|
+
return `Repository '${name}' not found. Known repos: ${knownStr}. Run load_config or detect_context to register it.`;
|
|
5296
|
+
}
|
|
3769
5297
|
getEntry(repoName) {
|
|
3770
5298
|
const name = repoName ?? this.currentRepo;
|
|
3771
5299
|
if (!name) return null;
|
|
@@ -3792,40 +5320,229 @@ var RepositoryContext = class {
|
|
|
3792
5320
|
source: e.source
|
|
3793
5321
|
}));
|
|
3794
5322
|
}
|
|
5323
|
+
getConfiguredRepositoryNames() {
|
|
5324
|
+
return Array.from(this.repos.values()).filter((entry) => entry.source === "config").map((entry) => entry.name);
|
|
5325
|
+
}
|
|
5326
|
+
async getConfiguredRepositorySummaries(options = {}) {
|
|
5327
|
+
const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
|
|
5328
|
+
const currentRepo = this.currentRepo;
|
|
5329
|
+
const buildLean = (entry) => {
|
|
5330
|
+
const mode = resolveMode(entry.config);
|
|
5331
|
+
const isCurrent = entry.name === currentRepo;
|
|
5332
|
+
if (mode === REPOSITORY_MODES.CLONE) {
|
|
5333
|
+
return { name: entry.name, mode: "clone", checkoutPath: path14.resolve(entry.config.worktreeDir), isCurrent };
|
|
5334
|
+
}
|
|
5335
|
+
return { name: entry.name, mode: "worktree", worktreeDir: path14.resolve(entry.config.worktreeDir), isCurrent };
|
|
5336
|
+
};
|
|
5337
|
+
if (!options.detailed) {
|
|
5338
|
+
return entries.map(buildLean);
|
|
5339
|
+
}
|
|
5340
|
+
const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5341
|
+
return Promise.all(
|
|
5342
|
+
entries.map(
|
|
5343
|
+
(entry) => limit(async () => {
|
|
5344
|
+
const summary = buildLean(entry);
|
|
5345
|
+
summary.repoUrl = entry.config.repoUrl;
|
|
5346
|
+
if (entry.config.branch) summary.branch = entry.config.branch;
|
|
5347
|
+
if (entry.config.sparseCheckout) {
|
|
5348
|
+
const sc = entry.config.sparseCheckout;
|
|
5349
|
+
summary.sparseCheckout = {
|
|
5350
|
+
...sc,
|
|
5351
|
+
include: [...sc.include],
|
|
5352
|
+
...sc.exclude ? { exclude: [...sc.exclude] } : {}
|
|
5353
|
+
};
|
|
5354
|
+
}
|
|
5355
|
+
if (summary.mode === "clone") {
|
|
5356
|
+
summary.localReady = await isGitCheckout(summary.checkoutPath);
|
|
5357
|
+
return summary;
|
|
5358
|
+
}
|
|
5359
|
+
if (entry.config.bareRepoDir) {
|
|
5360
|
+
summary.bareRepoDir = path14.resolve(entry.config.bareRepoDir);
|
|
5361
|
+
summary.localReady = await isDirectory(summary.bareRepoDir);
|
|
5362
|
+
} else {
|
|
5363
|
+
summary.localReady = false;
|
|
5364
|
+
}
|
|
5365
|
+
return summary;
|
|
5366
|
+
})
|
|
5367
|
+
)
|
|
5368
|
+
);
|
|
5369
|
+
}
|
|
5370
|
+
autoSelectCurrentRepoIfSingleConfig() {
|
|
5371
|
+
const decision = this.selectDefaultRepository();
|
|
5372
|
+
if (decision.kind !== "selected") return null;
|
|
5373
|
+
if (decision.source === "single-config") {
|
|
5374
|
+
this.currentRepo = decision.repoName;
|
|
5375
|
+
}
|
|
5376
|
+
return this.currentRepo;
|
|
5377
|
+
}
|
|
5378
|
+
async getAllConfiguredWorktreeDetails(currentWorktreePath = null) {
|
|
5379
|
+
const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
|
|
5380
|
+
const results = await Promise.all(
|
|
5381
|
+
entries.map(async (entry) => ({
|
|
5382
|
+
name: entry.name,
|
|
5383
|
+
result: await this.readConfiguredWorktrees(entry, currentWorktreePath)
|
|
5384
|
+
}))
|
|
5385
|
+
);
|
|
5386
|
+
const worktreesByRepo = {};
|
|
5387
|
+
const errorsByRepo = {};
|
|
5388
|
+
for (const entry of results) {
|
|
5389
|
+
worktreesByRepo[entry.name] = entry.result.worktrees;
|
|
5390
|
+
if (entry.result.error) {
|
|
5391
|
+
errorsByRepo[entry.name] = entry.result.error;
|
|
5392
|
+
}
|
|
5393
|
+
}
|
|
5394
|
+
return { worktreesByRepo, errorsByRepo };
|
|
5395
|
+
}
|
|
3795
5396
|
getConfigPath() {
|
|
3796
5397
|
return this.configPath;
|
|
3797
5398
|
}
|
|
5399
|
+
async readConfiguredWorktrees(entry, currentWorktreePath) {
|
|
5400
|
+
if (entry.source === "config" && resolveMode(entry.config) === REPOSITORY_MODES.CLONE) {
|
|
5401
|
+
return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
|
|
5402
|
+
}
|
|
5403
|
+
if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
|
|
5404
|
+
const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
|
|
5405
|
+
if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
|
|
5406
|
+
try {
|
|
5407
|
+
const output = await simpleGit6(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
|
|
5408
|
+
return { worktrees: parseWorktreeList(output, currentWorktreePath) };
|
|
5409
|
+
} catch (err) {
|
|
5410
|
+
return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
|
|
5411
|
+
}
|
|
5412
|
+
}
|
|
5413
|
+
findConfiguredCloneEntry(worktreeRoot) {
|
|
5414
|
+
const foldedRoot = normalizePathForCompare(path14.resolve(worktreeRoot));
|
|
5415
|
+
for (const entry of this.repos.values()) {
|
|
5416
|
+
if (entry.source !== "config" || resolveMode(entry.config) !== REPOSITORY_MODES.CLONE) continue;
|
|
5417
|
+
if (normalizePathForCompare(path14.resolve(entry.config.worktreeDir)) === foldedRoot) {
|
|
5418
|
+
return entry;
|
|
5419
|
+
}
|
|
5420
|
+
}
|
|
5421
|
+
return null;
|
|
5422
|
+
}
|
|
5423
|
+
async buildCloneModeContext(entry, worktreeRoot, notes) {
|
|
5424
|
+
const resolvedRoot = path14.resolve(worktreeRoot);
|
|
5425
|
+
let currentBranch = null;
|
|
5426
|
+
try {
|
|
5427
|
+
currentBranch = await readCurrentBranch(resolvedRoot);
|
|
5428
|
+
} catch (err) {
|
|
5429
|
+
notes.push(`Could not read clone-mode branch: ${err instanceof Error ? err.message : String(err)}`);
|
|
5430
|
+
}
|
|
5431
|
+
const branch = currentBranch ?? "unknown";
|
|
5432
|
+
const cloneModeReason = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
|
|
5433
|
+
const capabilities = {
|
|
5434
|
+
listWorktrees: { available: true },
|
|
5435
|
+
getStatus: { available: true },
|
|
5436
|
+
createWorktree: { available: false, reason: cloneModeReason },
|
|
5437
|
+
removeWorktree: { available: false, reason: cloneModeReason },
|
|
5438
|
+
updateWorktree: { available: false, reason: cloneModeReason },
|
|
5439
|
+
sync: { available: true },
|
|
5440
|
+
initialize: { available: true }
|
|
5441
|
+
};
|
|
5442
|
+
const discovered = {
|
|
5443
|
+
isWorktree: true,
|
|
5444
|
+
kind: "managed",
|
|
5445
|
+
currentBranch,
|
|
5446
|
+
currentWorktreePath: resolvedRoot,
|
|
5447
|
+
bareRepoPath: null,
|
|
5448
|
+
repoUrl: entry.config.repoUrl,
|
|
5449
|
+
worktreeDir: resolvedRoot,
|
|
5450
|
+
allWorktrees: [{ path: resolvedRoot, branch, isCurrent: true }],
|
|
5451
|
+
siblingRepositories: [],
|
|
5452
|
+
configPath: this.configPath,
|
|
5453
|
+
repoName: entry.name,
|
|
5454
|
+
capabilities,
|
|
5455
|
+
notes
|
|
5456
|
+
};
|
|
5457
|
+
entry.discovered = discovered;
|
|
5458
|
+
this.bootstrapCurrentRepo(entry.name, true);
|
|
5459
|
+
return discovered;
|
|
5460
|
+
}
|
|
5461
|
+
async readConfiguredCloneWorktree(entry, currentWorktreePath) {
|
|
5462
|
+
const worktreePath = path14.resolve(entry.config.worktreeDir);
|
|
5463
|
+
if (!await isDirectory(worktreePath) || !await hasGitMetadata(worktreePath)) {
|
|
5464
|
+
return { worktrees: [] };
|
|
5465
|
+
}
|
|
5466
|
+
try {
|
|
5467
|
+
const branch = await readCurrentBranch(worktreePath);
|
|
5468
|
+
return {
|
|
5469
|
+
worktrees: [
|
|
5470
|
+
{
|
|
5471
|
+
path: worktreePath,
|
|
5472
|
+
branch,
|
|
5473
|
+
isCurrent: currentWorktreePath !== null && normalizePathForCompare(worktreePath) === normalizePathForCompare(currentWorktreePath)
|
|
5474
|
+
}
|
|
5475
|
+
]
|
|
5476
|
+
};
|
|
5477
|
+
} catch (err) {
|
|
5478
|
+
return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
|
|
5479
|
+
}
|
|
5480
|
+
}
|
|
3798
5481
|
};
|
|
3799
5482
|
function parseWorktreeList(output, currentPath) {
|
|
3800
|
-
const foldedCurrent = normalizePathForCompare(currentPath);
|
|
5483
|
+
const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
|
|
3801
5484
|
const results = [];
|
|
3802
5485
|
for (const wt of parseWorktreeListPorcelain(output)) {
|
|
3803
|
-
const resolved =
|
|
5486
|
+
const resolved = path14.resolve(wt.path);
|
|
3804
5487
|
const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
|
|
3805
5488
|
if (!branch) continue;
|
|
3806
5489
|
results.push({
|
|
3807
5490
|
path: resolved,
|
|
3808
5491
|
branch,
|
|
3809
|
-
isCurrent: normalizePathForCompare(resolved) === foldedCurrent
|
|
5492
|
+
isCurrent: foldedCurrent !== null && normalizePathForCompare(resolved) === foldedCurrent
|
|
3810
5493
|
});
|
|
3811
5494
|
}
|
|
3812
5495
|
return results;
|
|
3813
5496
|
}
|
|
3814
5497
|
async function safeMtimeMs(filePath) {
|
|
3815
5498
|
try {
|
|
3816
|
-
const stat4 = await
|
|
5499
|
+
const stat4 = await fs10.stat(filePath);
|
|
3817
5500
|
return stat4.mtimeMs;
|
|
3818
5501
|
} catch {
|
|
3819
5502
|
return null;
|
|
3820
5503
|
}
|
|
3821
5504
|
}
|
|
5505
|
+
async function isDirectory(filePath) {
|
|
5506
|
+
try {
|
|
5507
|
+
const stat4 = await fs10.stat(filePath);
|
|
5508
|
+
return stat4.isDirectory();
|
|
5509
|
+
} catch {
|
|
5510
|
+
return false;
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
async function hasGitMetadata(worktreePath) {
|
|
5514
|
+
try {
|
|
5515
|
+
await fs10.stat(path14.join(worktreePath, ".git"));
|
|
5516
|
+
return true;
|
|
5517
|
+
} catch {
|
|
5518
|
+
return false;
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
async function isGitCheckout(checkoutPath) {
|
|
5522
|
+
if (!await isDirectory(checkoutPath)) return false;
|
|
5523
|
+
try {
|
|
5524
|
+
const inside = (await simpleGit6(checkoutPath).raw(["rev-parse", "--is-inside-work-tree"])).trim();
|
|
5525
|
+
return inside === "true";
|
|
5526
|
+
} catch {
|
|
5527
|
+
return false;
|
|
5528
|
+
}
|
|
5529
|
+
}
|
|
5530
|
+
async function readCurrentBranch(worktreePath) {
|
|
5531
|
+
const git = simpleGit6(worktreePath);
|
|
5532
|
+
const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
5533
|
+
if (branch && branch !== "HEAD") {
|
|
5534
|
+
return branch;
|
|
5535
|
+
}
|
|
5536
|
+
const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
|
|
5537
|
+
return head ? `(detached ${head})` : "(detached)";
|
|
5538
|
+
}
|
|
3822
5539
|
async function findWorktreeRoot(startPath) {
|
|
3823
|
-
let current =
|
|
3824
|
-
const root =
|
|
5540
|
+
let current = path14.resolve(startPath);
|
|
5541
|
+
const root = path14.parse(current).root;
|
|
3825
5542
|
while (true) {
|
|
3826
|
-
const gitPath =
|
|
5543
|
+
const gitPath = path14.join(current, ".git");
|
|
3827
5544
|
try {
|
|
3828
|
-
const content = await
|
|
5545
|
+
const content = await fs10.readFile(gitPath, "utf-8");
|
|
3829
5546
|
return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
|
|
3830
5547
|
} catch (err) {
|
|
3831
5548
|
const code = err.code;
|
|
@@ -3837,7 +5554,7 @@ async function findWorktreeRoot(startPath) {
|
|
|
3837
5554
|
}
|
|
3838
5555
|
}
|
|
3839
5556
|
if (current === root) return null;
|
|
3840
|
-
const parent =
|
|
5557
|
+
const parent = path14.dirname(current);
|
|
3841
5558
|
if (parent === current) return null;
|
|
3842
5559
|
current = parent;
|
|
3843
5560
|
}
|
|
@@ -3848,13 +5565,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3848
5565
|
import { z } from "zod";
|
|
3849
5566
|
|
|
3850
5567
|
// src/mcp/handlers.ts
|
|
3851
|
-
import * as
|
|
3852
|
-
import
|
|
5568
|
+
import * as path15 from "path";
|
|
5569
|
+
import pLimit3 from "p-limit";
|
|
3853
5570
|
|
|
3854
5571
|
// src/utils/disk-space.ts
|
|
3855
5572
|
import fastFolderSize from "fast-folder-size";
|
|
3856
5573
|
async function calculateDirectorySize(dirPath) {
|
|
3857
|
-
return new Promise((
|
|
5574
|
+
return new Promise((resolve11, reject) => {
|
|
3858
5575
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
3859
5576
|
if (err) {
|
|
3860
5577
|
reject(err);
|
|
@@ -3864,7 +5581,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
3864
5581
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
3865
5582
|
return;
|
|
3866
5583
|
}
|
|
3867
|
-
|
|
5584
|
+
resolve11(bytes);
|
|
3868
5585
|
});
|
|
3869
5586
|
});
|
|
3870
5587
|
}
|
|
@@ -3924,7 +5641,7 @@ function formatToolResponse(data) {
|
|
|
3924
5641
|
content: [
|
|
3925
5642
|
{
|
|
3926
5643
|
type: "text",
|
|
3927
|
-
text: JSON.stringify(data
|
|
5644
|
+
text: JSON.stringify(data)
|
|
3928
5645
|
}
|
|
3929
5646
|
]
|
|
3930
5647
|
};
|
|
@@ -3951,7 +5668,7 @@ function formatErrorResponse(error) {
|
|
|
3951
5668
|
content: [
|
|
3952
5669
|
{
|
|
3953
5670
|
type: "text",
|
|
3954
|
-
text: JSON.stringify(body
|
|
5671
|
+
text: JSON.stringify(body)
|
|
3955
5672
|
}
|
|
3956
5673
|
],
|
|
3957
5674
|
isError: true
|
|
@@ -3978,7 +5695,7 @@ function wrapHandler(fn) {
|
|
|
3978
5695
|
}
|
|
3979
5696
|
|
|
3980
5697
|
// src/mcp/worktree-summary.ts
|
|
3981
|
-
import
|
|
5698
|
+
import simpleGit7 from "simple-git";
|
|
3982
5699
|
function deriveLabel(status, isCurrent) {
|
|
3983
5700
|
if (isCurrent) return "current";
|
|
3984
5701
|
if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
|
|
@@ -3999,7 +5716,7 @@ function deriveSafeToRemove(status) {
|
|
|
3999
5716
|
}
|
|
4000
5717
|
async function getDivergence(worktreePath) {
|
|
4001
5718
|
try {
|
|
4002
|
-
const git =
|
|
5719
|
+
const git = simpleGit7(worktreePath);
|
|
4003
5720
|
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
4004
5721
|
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
4005
5722
|
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|
|
@@ -4010,6 +5727,7 @@ async function getDivergence(worktreePath) {
|
|
|
4010
5727
|
|
|
4011
5728
|
// src/mcp/handlers.ts
|
|
4012
5729
|
var pathResolution = new PathResolutionService();
|
|
5730
|
+
var CLONE_MODE_WORKTREE_MUTATION_REASON = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
|
|
4013
5731
|
function ensureCapability(discovered, key, toolName) {
|
|
4014
5732
|
if (!discovered) return;
|
|
4015
5733
|
const cap = discovered.capabilities[key];
|
|
@@ -4019,6 +5737,9 @@ function ensureCapability(discovered, key, toolName) {
|
|
|
4019
5737
|
}
|
|
4020
5738
|
}
|
|
4021
5739
|
async function getReadyService(ctx, repoName, options = {}) {
|
|
5740
|
+
if (!repoName) {
|
|
5741
|
+
ctx.autoSelectCurrentRepoIfSingleConfig();
|
|
5742
|
+
}
|
|
4022
5743
|
const discovered = ctx.getDiscoveredContext(repoName);
|
|
4023
5744
|
if (options.capability && options.toolName) {
|
|
4024
5745
|
ensureCapability(discovered, options.capability, options.toolName);
|
|
@@ -4041,33 +5762,75 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
|
|
|
4041
5762
|
}
|
|
4042
5763
|
return result.value;
|
|
4043
5764
|
}
|
|
4044
|
-
async function ensureRepoWorktreePath(ctx, params, git) {
|
|
4045
|
-
await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
|
|
4046
|
-
return
|
|
5765
|
+
async function ensureRepoWorktreePath(ctx, params, service, git) {
|
|
5766
|
+
await ensurePathBelongsToRepo(ctx, params.path, params.repoName, service, git);
|
|
5767
|
+
return path15.resolve(params.path);
|
|
4047
5768
|
}
|
|
4048
|
-
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
|
|
5769
|
+
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
|
|
4049
5770
|
const discovered = ctx.getDiscoveredContext(repoName);
|
|
4050
5771
|
if (discovered?.allWorktrees.length) {
|
|
4051
5772
|
const match = discovered.allWorktrees.some((w) => pathsEqual(w.path, targetPath));
|
|
4052
5773
|
if (match) return;
|
|
4053
5774
|
}
|
|
4054
5775
|
try {
|
|
4055
|
-
const worktrees = await git
|
|
5776
|
+
const worktrees = await getWorktreesFromService(service, git);
|
|
4056
5777
|
if (worktrees.some((w) => pathsEqual(w.path, targetPath))) return;
|
|
4057
5778
|
} catch {
|
|
4058
5779
|
}
|
|
4059
5780
|
throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
|
|
4060
5781
|
}
|
|
5782
|
+
function isCloneModeService(service) {
|
|
5783
|
+
const candidate = service;
|
|
5784
|
+
return typeof candidate.isCloneMode === "function" && candidate.isCloneMode();
|
|
5785
|
+
}
|
|
5786
|
+
function ensureWorktreeModeService(service, toolName) {
|
|
5787
|
+
if (isCloneModeService(service)) {
|
|
5788
|
+
throw new CapabilityUnavailableError(toolName, [CLONE_MODE_WORKTREE_MUTATION_REASON]);
|
|
5789
|
+
}
|
|
5790
|
+
}
|
|
5791
|
+
async function getWorktreesFromService(service, git) {
|
|
5792
|
+
const candidate = service;
|
|
5793
|
+
if (typeof candidate.getWorktrees === "function") {
|
|
5794
|
+
return candidate.getWorktrees();
|
|
5795
|
+
}
|
|
5796
|
+
return git.getWorktrees();
|
|
5797
|
+
}
|
|
4061
5798
|
async function handleDetectContext(ctx, params, _extra) {
|
|
4062
5799
|
const target = params.path ?? process.cwd();
|
|
4063
5800
|
const discovered = await ctx.detectFromPath(target);
|
|
4064
|
-
|
|
4065
|
-
|
|
5801
|
+
const configuredRepositories = await ctx.getConfiguredRepositorySummaries({ detailed: params.detailed ?? false });
|
|
5802
|
+
let response = { ...discovered, configuredRepositories };
|
|
5803
|
+
if (params.includeAllWorktrees) {
|
|
5804
|
+
const details = await ctx.getAllConfiguredWorktreeDetails(discovered.currentWorktreePath);
|
|
5805
|
+
const errorsByRepo = Object.keys(details.errorsByRepo).length > 0 ? details.errorsByRepo : void 0;
|
|
5806
|
+
response = {
|
|
5807
|
+
...response,
|
|
5808
|
+
allWorktreesByRepo: details.worktreesByRepo,
|
|
5809
|
+
allWorktreeErrorsByRepo: errorsByRepo
|
|
5810
|
+
};
|
|
5811
|
+
}
|
|
5812
|
+
if (!params.includeStatus) {
|
|
5813
|
+
return formatToolResponse(response);
|
|
4066
5814
|
}
|
|
4067
5815
|
const statusService = new WorktreeStatusService();
|
|
4068
|
-
const
|
|
4069
|
-
const enriched = await
|
|
4070
|
-
|
|
5816
|
+
const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5817
|
+
const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
|
|
5818
|
+
let allWorktreesByRepo = response.allWorktreesByRepo;
|
|
5819
|
+
if (allWorktreesByRepo) {
|
|
5820
|
+
const entries = await Promise.all(
|
|
5821
|
+
Object.entries(allWorktreesByRepo).map(async ([repoName, worktrees]) => [
|
|
5822
|
+
repoName,
|
|
5823
|
+
await enrichDetectedWorktrees(worktrees, statusService, statusLimit)
|
|
5824
|
+
])
|
|
5825
|
+
);
|
|
5826
|
+
allWorktreesByRepo = Object.fromEntries(entries);
|
|
5827
|
+
}
|
|
5828
|
+
return formatToolResponse({ ...response, allWorktrees: enriched, allWorktreesByRepo });
|
|
5829
|
+
}
|
|
5830
|
+
async function enrichDetectedWorktrees(worktrees, statusService, limit) {
|
|
5831
|
+
if (worktrees.length === 0) return worktrees;
|
|
5832
|
+
return Promise.all(
|
|
5833
|
+
worktrees.map(
|
|
4071
5834
|
(wt) => limit(async () => {
|
|
4072
5835
|
const [status, divergence] = await Promise.all([
|
|
4073
5836
|
statusService.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
@@ -4082,16 +5845,47 @@ async function handleDetectContext(ctx, params, _extra) {
|
|
|
4082
5845
|
})
|
|
4083
5846
|
)
|
|
4084
5847
|
);
|
|
4085
|
-
return formatToolResponse({ ...discovered, allWorktrees: enriched });
|
|
4086
5848
|
}
|
|
4087
5849
|
async function handleListWorktrees(ctx, params, _extra) {
|
|
4088
|
-
const
|
|
5850
|
+
const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
|
|
5851
|
+
if (configuredRepoNames.length > 0) {
|
|
5852
|
+
const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
5853
|
+
const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5854
|
+
const repositories = await Promise.all(
|
|
5855
|
+
configuredRepoNames.map(
|
|
5856
|
+
(repoName) => limit(async () => {
|
|
5857
|
+
try {
|
|
5858
|
+
return [
|
|
5859
|
+
repoName,
|
|
5860
|
+
{
|
|
5861
|
+
worktrees: await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit)
|
|
5862
|
+
}
|
|
5863
|
+
];
|
|
5864
|
+
} catch (err) {
|
|
5865
|
+
return [
|
|
5866
|
+
repoName,
|
|
5867
|
+
{
|
|
5868
|
+
worktrees: [],
|
|
5869
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5870
|
+
}
|
|
5871
|
+
];
|
|
5872
|
+
}
|
|
5873
|
+
})
|
|
5874
|
+
)
|
|
5875
|
+
);
|
|
5876
|
+
return formatToolResponse({ repositories: Object.fromEntries(repositories) });
|
|
5877
|
+
}
|
|
5878
|
+
const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
|
|
5879
|
+
return formatToolResponse({ worktrees: results });
|
|
5880
|
+
}
|
|
5881
|
+
async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
|
|
5882
|
+
const { discovered, service, git } = await getReadyService(ctx, repoName, {
|
|
4089
5883
|
capability: "listWorktrees",
|
|
4090
5884
|
toolName: "list_worktrees"
|
|
4091
5885
|
});
|
|
4092
5886
|
let worktrees;
|
|
4093
5887
|
try {
|
|
4094
|
-
worktrees = await git
|
|
5888
|
+
worktrees = await getWorktreesFromService(service, git);
|
|
4095
5889
|
} catch {
|
|
4096
5890
|
if (discovered) {
|
|
4097
5891
|
worktrees = discovered.allWorktrees.map((w) => ({ path: w.path, branch: w.branch }));
|
|
@@ -4100,17 +5894,16 @@ async function handleListWorktrees(ctx, params, _extra) {
|
|
|
4100
5894
|
}
|
|
4101
5895
|
}
|
|
4102
5896
|
const currentPath = discovered?.currentWorktreePath ?? null;
|
|
4103
|
-
const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
4104
5897
|
const results = await Promise.all(
|
|
4105
5898
|
worktrees.map(
|
|
4106
5899
|
(wt) => limit(async () => {
|
|
4107
|
-
const resolvedPath =
|
|
5900
|
+
const resolvedPath = path15.resolve(wt.path);
|
|
4108
5901
|
const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
|
|
4109
5902
|
const [status, divergence, metadata, sizeBytes] = await Promise.all([
|
|
4110
5903
|
git.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
4111
5904
|
getDivergence(wt.path),
|
|
4112
5905
|
git.getWorktreeMetadata(wt.path).catch(() => null),
|
|
4113
|
-
|
|
5906
|
+
includeSize ? calculateDirectorySize(wt.path).catch(() => null) : Promise.resolve(null)
|
|
4114
5907
|
]);
|
|
4115
5908
|
return {
|
|
4116
5909
|
path: resolvedPath,
|
|
@@ -4126,14 +5919,14 @@ async function handleListWorktrees(ctx, params, _extra) {
|
|
|
4126
5919
|
})
|
|
4127
5920
|
)
|
|
4128
5921
|
);
|
|
4129
|
-
return
|
|
5922
|
+
return results;
|
|
4130
5923
|
}
|
|
4131
5924
|
async function handleGetWorktreeStatus(ctx, params, _extra) {
|
|
4132
|
-
const { git } = await getReadyService(ctx, params.repoName, {
|
|
5925
|
+
const { service, git } = await getReadyService(ctx, params.repoName, {
|
|
4133
5926
|
capability: "getStatus",
|
|
4134
5927
|
toolName: "get_worktree_status"
|
|
4135
5928
|
});
|
|
4136
|
-
const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
5929
|
+
const resolvedPath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4137
5930
|
const [status, divergence] = await Promise.all([
|
|
4138
5931
|
git.getFullWorktreeStatus(params.path, params.includeDetails ?? false),
|
|
4139
5932
|
getDivergence(params.path)
|
|
@@ -4145,7 +5938,8 @@ async function handleGetWorktreeStatus(ctx, params, _extra) {
|
|
|
4145
5938
|
});
|
|
4146
5939
|
}
|
|
4147
5940
|
async function handleCreateWorktree(ctx, params, _extra) {
|
|
4148
|
-
const { branchName, baseBranch
|
|
5941
|
+
const { branchName, baseBranch } = params;
|
|
5942
|
+
const push = params.push ?? true;
|
|
4149
5943
|
const validation = isValidGitBranchName(branchName);
|
|
4150
5944
|
if (!validation.valid) {
|
|
4151
5945
|
throw new Error(`Invalid branch name '${branchName}': ${validation.error}`);
|
|
@@ -4154,9 +5948,10 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
4154
5948
|
capability: "createWorktree",
|
|
4155
5949
|
toolName: "create_worktree"
|
|
4156
5950
|
});
|
|
5951
|
+
ensureWorktreeModeService(service, "create_worktree");
|
|
4157
5952
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4158
5953
|
if (!service.isInitialized()) {
|
|
4159
|
-
await service.
|
|
5954
|
+
await service.initializeUnlocked();
|
|
4160
5955
|
}
|
|
4161
5956
|
const existence = await git.branchExists(branchName);
|
|
4162
5957
|
let created = false;
|
|
@@ -4186,7 +5981,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
4186
5981
|
return formatToolResponse({
|
|
4187
5982
|
success: true,
|
|
4188
5983
|
branchName,
|
|
4189
|
-
worktreePath:
|
|
5984
|
+
worktreePath: path15.resolve(worktreePath),
|
|
4190
5985
|
created,
|
|
4191
5986
|
pushed
|
|
4192
5987
|
});
|
|
@@ -4197,11 +5992,12 @@ async function handleRemoveWorktree(ctx, params, _extra) {
|
|
|
4197
5992
|
capability: "removeWorktree",
|
|
4198
5993
|
toolName: "remove_worktree"
|
|
4199
5994
|
});
|
|
5995
|
+
ensureWorktreeModeService(service, "remove_worktree");
|
|
4200
5996
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4201
5997
|
if (!service.isInitialized()) {
|
|
4202
|
-
await service.
|
|
5998
|
+
await service.initializeUnlocked();
|
|
4203
5999
|
}
|
|
4204
|
-
const removedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
6000
|
+
const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4205
6001
|
if (!params.force) {
|
|
4206
6002
|
const status = await git.getFullWorktreeStatus(params.path, false);
|
|
4207
6003
|
if (!status.canRemove) {
|
|
@@ -4224,13 +6020,31 @@ async function handleSync(ctx, params, extra) {
|
|
|
4224
6020
|
const dispose = attachProgressReporter(service, extra);
|
|
4225
6021
|
try {
|
|
4226
6022
|
const start = Date.now();
|
|
6023
|
+
service.clearRecordedSkips();
|
|
4227
6024
|
const result = await service.sync();
|
|
4228
6025
|
if (!result.started) {
|
|
4229
6026
|
throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
|
|
4230
6027
|
}
|
|
4231
6028
|
const duration = Date.now() - start;
|
|
4232
6029
|
ctx.invalidateDiscovered();
|
|
4233
|
-
|
|
6030
|
+
const outcome = result.outcome ?? createEmptySyncOutcome(
|
|
6031
|
+
isCloneModeService(service) ? "clone" : "worktree",
|
|
6032
|
+
ctx.getEntry(params.repoName)?.name ?? params.repoName,
|
|
6033
|
+
duration
|
|
6034
|
+
);
|
|
6035
|
+
const skips = service.getRecordedSkips().map((reason) => ({
|
|
6036
|
+
...reason,
|
|
6037
|
+
message: formatCloneSkipReason(reason)
|
|
6038
|
+
}));
|
|
6039
|
+
return formatToolResponse({
|
|
6040
|
+
success: true,
|
|
6041
|
+
duration,
|
|
6042
|
+
outcome: {
|
|
6043
|
+
...outcome,
|
|
6044
|
+
durationMs: outcome.durationMs ?? duration
|
|
6045
|
+
},
|
|
6046
|
+
skips
|
|
6047
|
+
});
|
|
4234
6048
|
} finally {
|
|
4235
6049
|
dispose();
|
|
4236
6050
|
}
|
|
@@ -4240,11 +6054,12 @@ async function handleUpdateWorktree(ctx, params, _extra) {
|
|
|
4240
6054
|
capability: "updateWorktree",
|
|
4241
6055
|
toolName: "update_worktree"
|
|
4242
6056
|
});
|
|
6057
|
+
ensureWorktreeModeService(service, "update_worktree");
|
|
4243
6058
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4244
6059
|
if (!service.isInitialized()) {
|
|
4245
|
-
await service.
|
|
6060
|
+
await service.initializeUnlocked();
|
|
4246
6061
|
}
|
|
4247
|
-
const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
|
|
6062
|
+
const worktreePath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4248
6063
|
await git.updateWorktree(params.path);
|
|
4249
6064
|
ctx.invalidateDiscovered();
|
|
4250
6065
|
return formatToolResponse({
|
|
@@ -4261,7 +6076,8 @@ async function handleInitialize(ctx, params, extra) {
|
|
|
4261
6076
|
const dispose = attachProgressReporter(service, extra);
|
|
4262
6077
|
try {
|
|
4263
6078
|
return await runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4264
|
-
await service.
|
|
6079
|
+
await service.initializeUnlocked();
|
|
6080
|
+
service.clearPendingInitSkip();
|
|
4265
6081
|
const git = service.getGitService();
|
|
4266
6082
|
ctx.invalidateDiscovered();
|
|
4267
6083
|
return formatToolResponse({
|
|
@@ -4281,7 +6097,7 @@ async function handleLoadConfig(ctx, params, _extra) {
|
|
|
4281
6097
|
}
|
|
4282
6098
|
await ctx.loadConfig(configPath);
|
|
4283
6099
|
return formatToolResponse({
|
|
4284
|
-
configPath:
|
|
6100
|
+
configPath: path15.resolve(configPath),
|
|
4285
6101
|
currentRepository: ctx.getCurrentRepo(),
|
|
4286
6102
|
repositories: ctx.getRepositoryList()
|
|
4287
6103
|
});
|
|
@@ -4316,20 +6132,23 @@ function attachProgressReporter(service, extra) {
|
|
|
4316
6132
|
}
|
|
4317
6133
|
|
|
4318
6134
|
// src/mcp/server.ts
|
|
4319
|
-
var REPO_NAME_DESCRIBE = "
|
|
4320
|
-
var PATH_DESCRIBE_SUFFIX = "Absolute
|
|
4321
|
-
var SERVER_INSTRUCTIONS = "
|
|
6135
|
+
var REPO_NAME_DESCRIBE = "Repo name from loaded config. Omit to use current (set via set_current_repository) or the only loaded repo.";
|
|
6136
|
+
var PATH_DESCRIBE_SUFFIX = "Absolute preferred; relative resolves from server CWD.";
|
|
6137
|
+
var SERVER_INSTRUCTIONS = "Call `detect_context` for the project map and live worktree state; `configuredRepositories` in its response is the server-wide loaded-config inventory. Use `set_current_repository` to switch repos. Auto-loads sync-worktrees.config.{js,mjs,cjs,ts} via walk-up.";
|
|
4322
6138
|
function buildInstructions(snapshot) {
|
|
4323
6139
|
const d = snapshot?.discovered;
|
|
4324
|
-
if (!d || !d.isWorktree || d.kind !== "managed")
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
if (d.
|
|
4329
|
-
if (d.
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
6140
|
+
if (!d || !d.isWorktree || d.kind !== "managed") {
|
|
6141
|
+
return SERVER_INSTRUCTIONS;
|
|
6142
|
+
}
|
|
6143
|
+
const fields = [];
|
|
6144
|
+
if (d.repoName) fields.push(`workspace=${d.repoName}`);
|
|
6145
|
+
if (d.currentWorktreePath) fields.push(`path=${d.currentWorktreePath}`);
|
|
6146
|
+
if (d.configPath) fields.push(`config=${d.configPath}`);
|
|
6147
|
+
if (typeof snapshot?.configuredRepoCount === "number") {
|
|
6148
|
+
fields.push(`configuredRepos=${snapshot.configuredRepoCount}`);
|
|
6149
|
+
}
|
|
6150
|
+
fields.push(`worktrees=${d.allWorktrees.length}`);
|
|
6151
|
+
return `${SERVER_INSTRUCTIONS} Connect-time: ${fields.join(" ")}.`;
|
|
4333
6152
|
}
|
|
4334
6153
|
function createServer(context, snapshot) {
|
|
4335
6154
|
const server = new McpServer(
|
|
@@ -4346,22 +6165,24 @@ function createServer(context, snapshot) {
|
|
|
4346
6165
|
"sync-worktrees://workspace",
|
|
4347
6166
|
{
|
|
4348
6167
|
title: "Workspace context",
|
|
4349
|
-
description: "
|
|
6168
|
+
description: "Workspace context: isWorktree, kind, currentWorktreePath, currentBranch, allWorktrees, siblingRepositories, configPath, capabilities {available,reason}, configuredRepositories (server-wide loaded-config inventory). {isWorktree:false} when outside any workspace.",
|
|
4350
6169
|
mimeType: "application/json"
|
|
4351
6170
|
},
|
|
4352
6171
|
async (uri) => {
|
|
4353
|
-
let
|
|
6172
|
+
let payload;
|
|
4354
6173
|
try {
|
|
4355
|
-
discovered = await context.detectFromPath(process.cwd());
|
|
6174
|
+
const discovered = await context.detectFromPath(process.cwd());
|
|
6175
|
+
const configuredRepositories = await context.getConfiguredRepositorySummaries();
|
|
6176
|
+
payload = { ...discovered, configuredRepositories };
|
|
4356
6177
|
} catch (err) {
|
|
4357
|
-
|
|
6178
|
+
payload = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
|
|
4358
6179
|
}
|
|
4359
6180
|
return {
|
|
4360
6181
|
contents: [
|
|
4361
6182
|
{
|
|
4362
6183
|
uri: uri.href,
|
|
4363
6184
|
mimeType: "application/json",
|
|
4364
|
-
text: JSON.stringify(
|
|
6185
|
+
text: JSON.stringify(payload)
|
|
4365
6186
|
}
|
|
4366
6187
|
]
|
|
4367
6188
|
};
|
|
@@ -4370,11 +6191,13 @@ function createServer(context, snapshot) {
|
|
|
4370
6191
|
server.registerTool(
|
|
4371
6192
|
"detect_context",
|
|
4372
6193
|
{
|
|
4373
|
-
description: "Detect sync-worktrees structure from
|
|
6194
|
+
description: "Detect sync-worktrees structure from path (default: CWD). Reads .git, resolves bare repo, walks up to auto-load sync-worktrees.config.{js,mjs,cjs,ts}. Returns: configuredRepositories (server-wide loaded-config inventory; independent of params.path), bareRepoPath, allWorktrees, siblingRepositories, currentWorktreePath, configPath, capabilities {available,reason}, notes. Lean configuredRepositories entries are mode-discriminated: clone \u2192 {name, mode:'clone', checkoutPath, isCurrent}; worktree \u2192 {name, mode:'worktree', worktreeDir, isCurrent}. detailed=true adds repoUrl, branch?, sparseCheckout?, localReady, plus bareRepoDir for worktree mode. Use at session start or to bootstrap from unknown checkout.",
|
|
4374
6195
|
inputSchema: {
|
|
4375
|
-
path: z.string().optional().describe("Directory
|
|
6196
|
+
path: z.string().optional().describe("Directory to inspect. Default: server CWD."),
|
|
6197
|
+
detailed: z.boolean().optional().default(false).describe("Expand configuredRepositories with repoUrl, branch, sparseCheckout, localReady, bareRepoDir."),
|
|
6198
|
+
includeAllWorktrees: z.boolean().optional().describe("Include allWorktreesByRepo + allWorktreeErrorsByRepo for each configured repo. Default: false."),
|
|
4376
6199
|
includeStatus: z.boolean().optional().describe(
|
|
4377
|
-
"
|
|
6200
|
+
"Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Default: false."
|
|
4378
6201
|
)
|
|
4379
6202
|
},
|
|
4380
6203
|
annotations: {
|
|
@@ -4389,11 +6212,11 @@ function createServer(context, snapshot) {
|
|
|
4389
6212
|
server.registerTool(
|
|
4390
6213
|
"list_worktrees",
|
|
4391
6214
|
{
|
|
4392
|
-
description: "List all
|
|
6215
|
+
description: "List worktrees with status. No repoName + config loaded = all configured repos grouped by repoName. With repoName = single repo. Entries: {path, branch, isCurrent, label (clean|dirty|stale|current|unknown), status, divergence, safeToRemove, lastSyncAt, sizeBytes}.",
|
|
4393
6216
|
inputSchema: {
|
|
4394
|
-
repoName: z.string().optional().describe(
|
|
6217
|
+
repoName: z.string().optional().describe("Repo name. Omit + config loaded = list all configured repos."),
|
|
4395
6218
|
includeSize: z.boolean().optional().describe(
|
|
4396
|
-
"
|
|
6219
|
+
"Compute on-disk size per worktree (bytes). Slow on large worktrees. Default: false (sizeBytes=null)."
|
|
4397
6220
|
)
|
|
4398
6221
|
},
|
|
4399
6222
|
annotations: {
|
|
@@ -4408,11 +6231,11 @@ function createServer(context, snapshot) {
|
|
|
4408
6231
|
server.registerTool(
|
|
4409
6232
|
"get_worktree_status",
|
|
4410
6233
|
{
|
|
4411
|
-
description: "
|
|
6234
|
+
description: "Detailed status for one worktree: dirty files, unpushed commits, stashes, upstream gone, ops in progress. Returns: status + divergence {ahead,behind} + resolved path.",
|
|
4412
6235
|
inputSchema: {
|
|
4413
6236
|
path: z.string().describe(`Worktree path. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4414
6237
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
|
|
4415
|
-
includeDetails: z.boolean().optional().describe("
|
|
6238
|
+
includeDetails: z.boolean().optional().describe("Include file-level lists (modified, untracked, staged). Default: false (counts only).")
|
|
4416
6239
|
},
|
|
4417
6240
|
annotations: {
|
|
4418
6241
|
title: "Get worktree status",
|
|
@@ -4426,13 +6249,13 @@ function createServer(context, snapshot) {
|
|
|
4426
6249
|
server.registerTool(
|
|
4427
6250
|
"create_worktree",
|
|
4428
6251
|
{
|
|
4429
|
-
description: "Create
|
|
6252
|
+
description: "Create worktree for a branch. Existing branch (local/remote) = checkout. New branch = create from baseBranch + push to origin (default). baseBranch required only for new branches \u2014 pass defensively if unsure. push=false opts out. Preconditions: repo initialized (auto-runs). Returns: {success, branchName, worktreePath, created, pushed}.",
|
|
4430
6253
|
inputSchema: {
|
|
4431
|
-
branchName: z.string().describe("Branch name. Slashes
|
|
6254
|
+
branchName: z.string().describe("Branch name. Slashes/special chars sanitized for dir name."),
|
|
4432
6255
|
baseBranch: z.string().optional().describe(
|
|
4433
|
-
"Base
|
|
6256
|
+
"Base for new branch. Required if branchName doesn't exist locally or remotely; ignored otherwise."
|
|
4434
6257
|
),
|
|
4435
|
-
push: z.boolean().optional().describe("Push
|
|
6258
|
+
push: z.boolean().optional().describe("Push new branch to origin. Default: true. Ignored if branch existed."),
|
|
4436
6259
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4437
6260
|
},
|
|
4438
6261
|
annotations: {
|
|
@@ -4448,12 +6271,10 @@ function createServer(context, snapshot) {
|
|
|
4448
6271
|
server.registerTool(
|
|
4449
6272
|
"remove_worktree",
|
|
4450
6273
|
{
|
|
4451
|
-
description: "Remove
|
|
6274
|
+
description: "Remove worktree. Safety checks reject if dirty, unpushed commits, stashes, or op in progress (merge/rebase/cherry-pick/revert/bisect). force=true: `git worktree remove --force` DELETES uncommitted/untracked files in dir; branch ref + stashes + remote preserved. Returns: {success, removedPath}.",
|
|
4452
6275
|
inputSchema: {
|
|
4453
6276
|
path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4454
|
-
force: z.boolean().optional().describe(
|
|
4455
|
-
"Skip safety checks and delete uncommitted/untracked files in the worktree directory. Branch ref is preserved. Default: false."
|
|
4456
|
-
),
|
|
6277
|
+
force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
|
|
4457
6278
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4458
6279
|
},
|
|
4459
6280
|
annotations: {
|
|
@@ -4469,7 +6290,7 @@ function createServer(context, snapshot) {
|
|
|
4469
6290
|
server.registerTool(
|
|
4470
6291
|
"sync",
|
|
4471
6292
|
{
|
|
4472
|
-
description: "
|
|
6293
|
+
description: "Repo-wide sync: fetch, create worktrees for new remote branches, remove pruned (clean only), fast-forward existing. Emits progress. Single worktree? Use update_worktree. Single create? Use create_worktree. Preconditions: config loaded + repo initialized (auto-runs). Returns: {success, duration, skips}.",
|
|
4473
6294
|
inputSchema: {
|
|
4474
6295
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4475
6296
|
},
|
|
@@ -4486,7 +6307,7 @@ function createServer(context, snapshot) {
|
|
|
4486
6307
|
server.registerTool(
|
|
4487
6308
|
"update_worktree",
|
|
4488
6309
|
{
|
|
4489
|
-
description: "Fast-forward one worktree to
|
|
6310
|
+
description: "Fast-forward one worktree to upstream. No merge, no rebase, aborts if not fast-forwardable. Whole repo? Use sync.",
|
|
4490
6311
|
inputSchema: {
|
|
4491
6312
|
path: z.string().describe(`Worktree path to fast-forward. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4492
6313
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
@@ -4504,7 +6325,7 @@ function createServer(context, snapshot) {
|
|
|
4504
6325
|
server.registerTool(
|
|
4505
6326
|
"initialize",
|
|
4506
6327
|
{
|
|
4507
|
-
description: "Initialize
|
|
6328
|
+
description: "Initialize repo: clone as bare if missing, create main worktree. Idempotent. Emits progress. Preconditions: config loaded. Returns: {success, defaultBranch, worktreeDir}.",
|
|
4508
6329
|
inputSchema: {
|
|
4509
6330
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4510
6331
|
},
|
|
@@ -4521,11 +6342,9 @@ function createServer(context, snapshot) {
|
|
|
4521
6342
|
server.registerTool(
|
|
4522
6343
|
"load_config",
|
|
4523
6344
|
{
|
|
4524
|
-
description: "Load
|
|
6345
|
+
description: "Load/reload sync-worktrees JS config into session. Replaces previously loaded repos. Call before sync/initialize/create_worktree in config-driven workflow. Returns: {configPath, currentRepository, repositories: [{name, repoUrl, worktreeDir, source}]}.",
|
|
4525
6346
|
inputSchema: {
|
|
4526
|
-
configPath: z.string().optional().describe(
|
|
4527
|
-
"Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."
|
|
4528
|
-
)
|
|
6347
|
+
configPath: z.string().optional().describe("Config file path. Falls back to SYNC_WORKTREES_CONFIG env var. Errors if neither set.")
|
|
4529
6348
|
},
|
|
4530
6349
|
annotations: {
|
|
4531
6350
|
title: "Load sync-worktrees config",
|
|
@@ -4540,9 +6359,9 @@ function createServer(context, snapshot) {
|
|
|
4540
6359
|
server.registerTool(
|
|
4541
6360
|
"set_current_repository",
|
|
4542
6361
|
{
|
|
4543
|
-
description: "Set
|
|
6362
|
+
description: "Set current repo for tool calls that omit repoName. Session-scoped. Preconditions: load_config called.",
|
|
4544
6363
|
inputSchema: {
|
|
4545
|
-
repoName: z.string().describe("
|
|
6364
|
+
repoName: z.string().describe("Repo name from loaded config repositories[].name.")
|
|
4546
6365
|
},
|
|
4547
6366
|
annotations: {
|
|
4548
6367
|
title: "Set current repository",
|
|
@@ -4584,7 +6403,10 @@ async function main() {
|
|
|
4584
6403
|
process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
|
|
4585
6404
|
`);
|
|
4586
6405
|
}
|
|
4587
|
-
const server = createServer(context, {
|
|
6406
|
+
const server = createServer(context, {
|
|
6407
|
+
discovered,
|
|
6408
|
+
configuredRepoCount: context.getConfiguredRepositoryNames().length
|
|
6409
|
+
});
|
|
4588
6410
|
const transport = new StdioServerTransport();
|
|
4589
6411
|
await server.connect(transport);
|
|
4590
6412
|
}
|