sync-worktrees 3.6.3 → 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 +383 -261
- 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 +2326 -1081
- 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 +2341 -637
- 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;
|
|
@@ -2471,11 +3456,45 @@ var GitService = class {
|
|
|
2471
3456
|
async isLocalAheadOfRemote(worktreePath, branch) {
|
|
2472
3457
|
const worktreeGit = this.getCachedGit(worktreePath);
|
|
2473
3458
|
try {
|
|
2474
|
-
const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
|
|
2475
|
-
const mergeBaseSha = mergeBase.trim();
|
|
2476
|
-
const remoteSha = await worktreeGit.revparse([`origin/${branch}`]);
|
|
2477
|
-
const remoteShaTrimmed = remoteSha.trim();
|
|
2478
|
-
return mergeBaseSha === remoteShaTrimmed;
|
|
3459
|
+
const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
|
|
3460
|
+
const mergeBaseSha = mergeBase.trim();
|
|
3461
|
+
const remoteSha = await worktreeGit.revparse([`origin/${branch}`]);
|
|
3462
|
+
const remoteShaTrimmed = remoteSha.trim();
|
|
3463
|
+
return mergeBaseSha === remoteShaTrimmed;
|
|
3464
|
+
} catch {
|
|
3465
|
+
return false;
|
|
3466
|
+
}
|
|
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";
|
|
2479
3498
|
} catch {
|
|
2480
3499
|
return false;
|
|
2481
3500
|
}
|
|
@@ -2590,193 +3609,129 @@ var GitService = class {
|
|
|
2590
3609
|
}
|
|
2591
3610
|
};
|
|
2592
3611
|
|
|
2593
|
-
// src/services/
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
var PathResolutionService = class {
|
|
2600
|
-
sanitizeBranchName(branchName) {
|
|
2601
|
-
const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
|
|
2602
|
-
const hash = createHash("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
|
|
2603
|
-
return `${stem}-${hash}`;
|
|
2604
|
-
}
|
|
2605
|
-
getBranchWorktreePath(worktreeDir, branchName) {
|
|
2606
|
-
return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
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);
|
|
2607
3618
|
}
|
|
2608
|
-
|
|
2609
|
-
const
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
const parent = path7.dirname(current);
|
|
2614
|
-
if (parent === current) {
|
|
2615
|
-
return absolute;
|
|
3619
|
+
emit(event) {
|
|
3620
|
+
for (const listener of [...this.listeners]) {
|
|
3621
|
+
try {
|
|
3622
|
+
listener(event);
|
|
3623
|
+
} catch {
|
|
2616
3624
|
}
|
|
2617
|
-
missing.unshift(path7.basename(current));
|
|
2618
|
-
current = parent;
|
|
2619
|
-
}
|
|
2620
|
-
try {
|
|
2621
|
-
return path7.join(fs5.realpathSync(current), ...missing);
|
|
2622
|
-
} catch {
|
|
2623
|
-
return absolute;
|
|
2624
|
-
}
|
|
2625
|
-
}
|
|
2626
|
-
isResolvedPathInsideBase(resolved, resolvedBase) {
|
|
2627
|
-
const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
2628
|
-
const a = fold(resolved);
|
|
2629
|
-
const b = fold(resolvedBase);
|
|
2630
|
-
if (a === b) return true;
|
|
2631
|
-
return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
|
|
2632
|
-
}
|
|
2633
|
-
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
2634
|
-
const resolved = this.resolveRealPath(worktreePath);
|
|
2635
|
-
const resolvedBase = this.resolveRealPath(worktreeBaseDir);
|
|
2636
|
-
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
2637
|
-
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
2638
3625
|
}
|
|
2639
|
-
return path7.relative(resolvedBase, resolved);
|
|
2640
|
-
}
|
|
2641
|
-
isPathInsideBaseDir(targetPath, baseDir) {
|
|
2642
|
-
const resolved = this.resolveRealPath(targetPath);
|
|
2643
|
-
const resolvedBase = this.resolveRealPath(baseDir);
|
|
2644
|
-
return this.isResolvedPathInsideBase(resolved, resolvedBase);
|
|
2645
|
-
}
|
|
2646
|
-
extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
|
|
2647
|
-
return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
|
|
2648
3626
|
}
|
|
2649
3627
|
};
|
|
2650
3628
|
|
|
2651
|
-
// src/services/
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
return this.gitService.isInitialized();
|
|
2670
|
-
}
|
|
2671
|
-
isSyncInProgress() {
|
|
2672
|
-
return this.syncInProgress;
|
|
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
|
+
};
|
|
2673
3647
|
}
|
|
2674
|
-
|
|
2675
|
-
|
|
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;
|
|
2676
3659
|
}
|
|
2677
3660
|
updateLogger(logger) {
|
|
2678
3661
|
this.logger = logger;
|
|
2679
|
-
this.gitService.updateLogger(logger);
|
|
2680
|
-
}
|
|
2681
|
-
onProgress(listener) {
|
|
2682
|
-
this.progressListeners.add(listener);
|
|
2683
|
-
return () => this.progressListeners.delete(listener);
|
|
2684
3662
|
}
|
|
2685
|
-
async
|
|
2686
|
-
if (
|
|
2687
|
-
|
|
2688
|
-
|
|
3663
|
+
async acquire() {
|
|
3664
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
3665
|
+
return async () => {
|
|
3666
|
+
};
|
|
2689
3667
|
}
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
2693
|
-
return { started: false, reason: "locked" };
|
|
3668
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
3669
|
+
return this.acquireCloneModeLock();
|
|
2694
3670
|
}
|
|
2695
|
-
this.
|
|
3671
|
+
return this.acquireWorktreeModeLock();
|
|
3672
|
+
}
|
|
3673
|
+
async acquireCloneModeLock() {
|
|
3674
|
+
const target = getCloneModeLockTarget(this.config);
|
|
3675
|
+
const lockTarget = path10.join(target.dir, target.file);
|
|
2696
3676
|
try {
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
await release();
|
|
2702
|
-
} catch (releaseError) {
|
|
2703
|
-
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
2704
|
-
}
|
|
3677
|
+
await fs7.mkdir(target.dir, { recursive: true });
|
|
3678
|
+
await fs7.writeFile(lockTarget, "", { flag: "a" });
|
|
3679
|
+
} catch {
|
|
3680
|
+
return null;
|
|
2705
3681
|
}
|
|
3682
|
+
return this.lockPath(lockTarget);
|
|
2706
3683
|
}
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
3684
|
+
async acquireWorktreeModeLock() {
|
|
3685
|
+
const barePath = this.gitService.getBareRepoPath();
|
|
3686
|
+
try {
|
|
3687
|
+
await fs7.mkdir(barePath, { recursive: true });
|
|
3688
|
+
} catch {
|
|
3689
|
+
return null;
|
|
2713
3690
|
}
|
|
3691
|
+
return this.lockPath(barePath);
|
|
2714
3692
|
}
|
|
2715
|
-
async
|
|
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 });
|
|
3693
|
+
async lockPath(lockTarget) {
|
|
2757
3694
|
try {
|
|
2758
|
-
|
|
3695
|
+
return await lockfile.lock(lockTarget, {
|
|
2759
3696
|
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
2760
3697
|
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
2761
3698
|
retries: 0,
|
|
2762
3699
|
realpath: false
|
|
2763
3700
|
});
|
|
2764
|
-
return release;
|
|
2765
3701
|
} catch (error) {
|
|
2766
3702
|
const code = error.code;
|
|
2767
3703
|
if (code === "ELOCKED") {
|
|
2768
3704
|
return null;
|
|
2769
3705
|
}
|
|
2770
|
-
|
|
3706
|
+
this.logger.warn(
|
|
3707
|
+
`Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
|
|
3708
|
+
);
|
|
3709
|
+
return null;
|
|
2771
3710
|
}
|
|
2772
3711
|
}
|
|
2773
|
-
|
|
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) {
|
|
2774
3728
|
return {
|
|
2775
3729
|
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
2776
3730
|
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
2777
3731
|
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
2778
3732
|
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
2779
3733
|
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
3734
|
+
jitterMs: this.config.retry?.jitterMs ?? 0,
|
|
2780
3735
|
onRetry: (error, attempt, context) => {
|
|
2781
3736
|
const errorMessage = getErrorMessage(error);
|
|
2782
3737
|
this.logger.info(`
|
|
@@ -2797,25 +3752,228 @@ var WorktreeSyncService = class {
|
|
|
2797
3752
|
}
|
|
2798
3753
|
};
|
|
2799
3754
|
}
|
|
2800
|
-
|
|
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
|
+
}
|
|
3814
|
+
|
|
3815
|
+
// src/services/path-resolution.service.ts
|
|
3816
|
+
import { createHash as createHash2 } from "crypto";
|
|
3817
|
+
import * as fs8 from "fs";
|
|
3818
|
+
import * as path11 from "path";
|
|
3819
|
+
var BRANCH_STEM_MAX = 80;
|
|
3820
|
+
var BRANCH_HASH_LEN = 8;
|
|
3821
|
+
var PathResolutionService = class {
|
|
3822
|
+
sanitizeBranchName(branchName) {
|
|
3823
|
+
const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
|
|
3824
|
+
const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
|
|
3825
|
+
return `${stem}-${hash}`;
|
|
3826
|
+
}
|
|
3827
|
+
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3828
|
+
return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3829
|
+
}
|
|
3830
|
+
resolveRealPath(inputPath) {
|
|
3831
|
+
const absolute = path11.resolve(inputPath);
|
|
3832
|
+
const missing = [];
|
|
3833
|
+
let current = absolute;
|
|
3834
|
+
while (!fs8.existsSync(current)) {
|
|
3835
|
+
const parent = path11.dirname(current);
|
|
3836
|
+
if (parent === current) {
|
|
3837
|
+
return absolute;
|
|
3838
|
+
}
|
|
3839
|
+
missing.unshift(path11.basename(current));
|
|
3840
|
+
current = parent;
|
|
3841
|
+
}
|
|
3842
|
+
try {
|
|
3843
|
+
return path11.join(fs8.realpathSync(current), ...missing);
|
|
3844
|
+
} catch {
|
|
3845
|
+
return absolute;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
isResolvedPathInsideBase(resolved, resolvedBase) {
|
|
3849
|
+
const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
3850
|
+
const a = fold(resolved);
|
|
3851
|
+
const b = fold(resolvedBase);
|
|
3852
|
+
if (a === b) return true;
|
|
3853
|
+
return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
|
|
3854
|
+
}
|
|
3855
|
+
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3856
|
+
const resolved = this.resolveRealPath(worktreePath);
|
|
3857
|
+
const resolvedBase = this.resolveRealPath(worktreeBaseDir);
|
|
3858
|
+
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3859
|
+
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3860
|
+
}
|
|
3861
|
+
return path11.relative(resolvedBase, resolved);
|
|
3862
|
+
}
|
|
3863
|
+
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3864
|
+
const resolved = this.resolveRealPath(targetPath);
|
|
3865
|
+
const resolvedBase = this.resolveRealPath(baseDir);
|
|
3866
|
+
return this.isResolvedPathInsideBase(resolved, resolvedBase);
|
|
3867
|
+
}
|
|
3868
|
+
extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3869
|
+
return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
|
|
3870
|
+
}
|
|
3871
|
+
};
|
|
3872
|
+
|
|
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) {
|
|
3936
|
+
this.config = config;
|
|
3937
|
+
this.gitService = gitService;
|
|
3938
|
+
this.logger = logger;
|
|
3939
|
+
this.progressEmitter = progressEmitter;
|
|
3940
|
+
}
|
|
3941
|
+
pathResolution = new PathResolutionService();
|
|
3942
|
+
updateLogger(logger) {
|
|
3943
|
+
this.logger = logger;
|
|
3944
|
+
}
|
|
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}`);
|
|
@@ -3357,19 +4574,19 @@ var WorktreeSyncService = class {
|
|
|
3357
4574
|
}
|
|
3358
4575
|
}
|
|
3359
4576
|
async divergeWorktree(worktreePath, branchName) {
|
|
3360
|
-
const divergedBaseDir =
|
|
4577
|
+
const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3361
4578
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
3362
4579
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
3363
4580
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
3364
4581
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
3365
|
-
const divergedPath =
|
|
3366
|
-
await
|
|
4582
|
+
const divergedPath = path13.join(divergedBaseDir, divergedName);
|
|
4583
|
+
await fs9.mkdir(divergedBaseDir, { recursive: true });
|
|
3367
4584
|
try {
|
|
3368
|
-
await
|
|
4585
|
+
await fs9.rename(worktreePath, divergedPath);
|
|
3369
4586
|
} catch (err) {
|
|
3370
4587
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
3371
|
-
await
|
|
3372
|
-
await
|
|
4588
|
+
await fs9.cp(worktreePath, divergedPath, { recursive: true });
|
|
4589
|
+
await fs9.rm(worktreePath, { recursive: true, force: true });
|
|
3373
4590
|
} else {
|
|
3374
4591
|
throw err;
|
|
3375
4592
|
}
|
|
@@ -3388,14 +4605,197 @@ var WorktreeSyncService = class {
|
|
|
3388
4605
|
|
|
3389
4606
|
Original worktree location: ${worktreePath}`
|
|
3390
4607
|
};
|
|
3391
|
-
await
|
|
3392
|
-
|
|
4608
|
+
await fs9.writeFile(
|
|
4609
|
+
path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
3393
4610
|
JSON.stringify(metadata, null, 2)
|
|
3394
4611
|
);
|
|
3395
4612
|
return divergedPath;
|
|
3396
4613
|
}
|
|
3397
4614
|
};
|
|
3398
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;
|
|
4717
|
+
try {
|
|
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" };
|
|
4727
|
+
}
|
|
4728
|
+
try {
|
|
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)}`);
|
|
4735
|
+
}
|
|
4736
|
+
this.syncInProgress = false;
|
|
4737
|
+
}
|
|
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;
|
|
4796
|
+
}
|
|
4797
|
+
};
|
|
4798
|
+
|
|
3399
4799
|
// src/mcp/context.ts
|
|
3400
4800
|
var AUTO_DETECT_PREFIX = "__auto_detected__:";
|
|
3401
4801
|
var DISCOVERY_CACHE_TTL_MS = 5e3;
|
|
@@ -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,12 +4941,16 @@ 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
4949
|
const currentBare = normalizePathForCompare(currentBareRepoPath);
|
|
3539
4950
|
const results = /* @__PURE__ */ new Map();
|
|
3540
4951
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
3541
4952
|
const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
|
|
3542
|
-
const bareRepoPath =
|
|
4953
|
+
const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
|
|
3543
4954
|
return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
|
|
3544
4955
|
}).filter((c) => c.foldedBare !== currentBare);
|
|
3545
4956
|
const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
|
|
@@ -3547,7 +4958,7 @@ var RepositoryContext = class {
|
|
|
3547
4958
|
const sibling = {
|
|
3548
4959
|
name: entry.name,
|
|
3549
4960
|
bareRepoPath,
|
|
3550
|
-
worktreeDir:
|
|
4961
|
+
worktreeDir: path14.resolve(entry.config.worktreeDir),
|
|
3551
4962
|
repoUrl: entry.config.repoUrl,
|
|
3552
4963
|
present: configPresence[i],
|
|
3553
4964
|
configMatched: true
|
|
@@ -3557,24 +4968,24 @@ var RepositoryContext = class {
|
|
|
3557
4968
|
}
|
|
3558
4969
|
results.set(foldedBare, sibling);
|
|
3559
4970
|
});
|
|
3560
|
-
const repoDir =
|
|
3561
|
-
const workspaceRoot =
|
|
4971
|
+
const repoDir = path14.dirname(currentBareRepoPath);
|
|
4972
|
+
const workspaceRoot = path14.dirname(repoDir);
|
|
3562
4973
|
if (workspaceRoot === repoDir) {
|
|
3563
4974
|
return Array.from(results.values()).sort(byName);
|
|
3564
4975
|
}
|
|
3565
4976
|
let entries;
|
|
3566
4977
|
try {
|
|
3567
|
-
entries = await
|
|
4978
|
+
entries = await fs10.readdir(workspaceRoot);
|
|
3568
4979
|
} catch {
|
|
3569
4980
|
return Array.from(results.values()).sort(byName);
|
|
3570
4981
|
}
|
|
3571
4982
|
const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
|
|
3572
4983
|
await Promise.all(
|
|
3573
4984
|
entries.map(async (entry) => {
|
|
3574
|
-
const candidate =
|
|
3575
|
-
const bareCandidate =
|
|
4985
|
+
const candidate = path14.join(workspaceRoot, entry);
|
|
4986
|
+
const bareCandidate = path14.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
|
|
3576
4987
|
if (!await isDirectory(bareCandidate)) return;
|
|
3577
|
-
const resolvedBare =
|
|
4988
|
+
const resolvedBare = path14.resolve(bareCandidate);
|
|
3578
4989
|
const foldedBare = normalizePathForCompare(resolvedBare);
|
|
3579
4990
|
if (foldedBare === currentBare || results.has(foldedBare)) return;
|
|
3580
4991
|
const matchedName = configBares.get(foldedBare);
|
|
@@ -3600,8 +5011,8 @@ var RepositoryContext = class {
|
|
|
3600
5011
|
if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
|
|
3601
5012
|
if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
|
|
3602
5013
|
const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
|
|
3603
|
-
safeMtimeMs(
|
|
3604
|
-
safeMtimeMs(
|
|
5014
|
+
safeMtimeMs(path14.join(cached.worktreeAdminDir, "HEAD")),
|
|
5015
|
+
safeMtimeMs(path14.join(cached.result.bareRepoPath, "worktrees"))
|
|
3605
5016
|
]);
|
|
3606
5017
|
return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
|
|
3607
5018
|
}
|
|
@@ -3634,6 +5045,13 @@ var RepositoryContext = class {
|
|
|
3634
5045
|
return unsupported("No .git file found in path or any parent directory");
|
|
3635
5046
|
}
|
|
3636
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
|
+
}
|
|
3637
5055
|
return unsupported("Directory has .git folder (regular repo, not a sync-worktrees worktree)");
|
|
3638
5056
|
}
|
|
3639
5057
|
const gitFileContent = located.gitFileContent;
|
|
@@ -3642,18 +5060,18 @@ var RepositoryContext = class {
|
|
|
3642
5060
|
return unsupported("Invalid .git file format (missing gitdir line)");
|
|
3643
5061
|
}
|
|
3644
5062
|
const gitdir = gitdirMatch[1].trim();
|
|
3645
|
-
const resolvedGitdir =
|
|
5063
|
+
const resolvedGitdir = path14.isAbsolute(gitdir) ? gitdir : path14.resolve(worktreeRoot, gitdir);
|
|
3646
5064
|
const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
|
|
3647
5065
|
if (!worktreesMatch) {
|
|
3648
5066
|
return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
|
|
3649
5067
|
}
|
|
3650
|
-
const bareRepoPath =
|
|
3651
|
-
const adminDir =
|
|
5068
|
+
const bareRepoPath = path14.resolve(worktreesMatch[1]);
|
|
5069
|
+
const adminDir = path14.resolve(resolvedGitdir);
|
|
3652
5070
|
let repoUrl = null;
|
|
3653
5071
|
let worktrees = [];
|
|
3654
5072
|
let currentBranch = null;
|
|
3655
5073
|
try {
|
|
3656
|
-
const bareGit =
|
|
5074
|
+
const bareGit = simpleGit6(bareRepoPath);
|
|
3657
5075
|
try {
|
|
3658
5076
|
const remoteResult = await bareGit.remote(["get-url", "origin"]);
|
|
3659
5077
|
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
@@ -3689,7 +5107,7 @@ var RepositoryContext = class {
|
|
|
3689
5107
|
adminDir
|
|
3690
5108
|
};
|
|
3691
5109
|
}
|
|
3692
|
-
const worktreeDir =
|
|
5110
|
+
const worktreeDir = path14.dirname(worktreeRoot);
|
|
3693
5111
|
const noUrlReason = "no remote origin URL detected";
|
|
3694
5112
|
const capabilities = {
|
|
3695
5113
|
listWorktrees: { available: true },
|
|
@@ -3725,7 +5143,7 @@ var RepositoryContext = class {
|
|
|
3725
5143
|
cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
3726
5144
|
runOnce: true
|
|
3727
5145
|
};
|
|
3728
|
-
const detectedKey = `${AUTO_DETECT_PREFIX}${
|
|
5146
|
+
const detectedKey = `${AUTO_DETECT_PREFIX}${path14.basename(bareRepoPath)}@${bareRepoPath}`;
|
|
3729
5147
|
if (!this.repos.has(detectedKey)) {
|
|
3730
5148
|
this.repos.set(detectedKey, {
|
|
3731
5149
|
name: detectedKey,
|
|
@@ -3766,13 +5184,19 @@ var RepositoryContext = class {
|
|
|
3766
5184
|
return { result: discovered, adminDir };
|
|
3767
5185
|
}
|
|
3768
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
|
+
}
|
|
3769
5193
|
const name = repoName ?? this.currentRepo;
|
|
3770
5194
|
if (!name) {
|
|
3771
|
-
throw new Error(
|
|
5195
|
+
throw new Error(this.buildNoRepoSelectedError());
|
|
3772
5196
|
}
|
|
3773
5197
|
const entry = this.repos.get(name);
|
|
3774
5198
|
if (!entry) {
|
|
3775
|
-
throw new Error(
|
|
5199
|
+
throw new Error(this.buildRepoNotFoundError(name));
|
|
3776
5200
|
}
|
|
3777
5201
|
if (!entry.service) {
|
|
3778
5202
|
const logger = createStderrLogger(entry.name);
|
|
@@ -3783,6 +5207,93 @@ var RepositoryContext = class {
|
|
|
3783
5207
|
}
|
|
3784
5208
|
return entry.service;
|
|
3785
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
|
+
}
|
|
3786
5297
|
getEntry(repoName) {
|
|
3787
5298
|
const name = repoName ?? this.currentRepo;
|
|
3788
5299
|
if (!name) return null;
|
|
@@ -3812,6 +5323,58 @@ var RepositoryContext = class {
|
|
|
3812
5323
|
getConfiguredRepositoryNames() {
|
|
3813
5324
|
return Array.from(this.repos.values()).filter((entry) => entry.source === "config").map((entry) => entry.name);
|
|
3814
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
|
+
}
|
|
3815
5378
|
async getAllConfiguredWorktreeDetails(currentWorktreePath = null) {
|
|
3816
5379
|
const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
|
|
3817
5380
|
const results = await Promise.all(
|
|
@@ -3834,22 +5397,93 @@ var RepositoryContext = class {
|
|
|
3834
5397
|
return this.configPath;
|
|
3835
5398
|
}
|
|
3836
5399
|
async readConfiguredWorktrees(entry, currentWorktreePath) {
|
|
5400
|
+
if (entry.source === "config" && resolveMode(entry.config) === REPOSITORY_MODES.CLONE) {
|
|
5401
|
+
return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
|
|
5402
|
+
}
|
|
3837
5403
|
if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
|
|
3838
|
-
const bareRepoPath =
|
|
5404
|
+
const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
|
|
3839
5405
|
if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
|
|
3840
5406
|
try {
|
|
3841
|
-
const output = await
|
|
5407
|
+
const output = await simpleGit6(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
|
|
3842
5408
|
return { worktrees: parseWorktreeList(output, currentWorktreePath) };
|
|
3843
5409
|
} catch (err) {
|
|
3844
5410
|
return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
|
|
3845
5411
|
}
|
|
3846
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
|
+
}
|
|
3847
5481
|
};
|
|
3848
5482
|
function parseWorktreeList(output, currentPath) {
|
|
3849
5483
|
const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
|
|
3850
5484
|
const results = [];
|
|
3851
5485
|
for (const wt of parseWorktreeListPorcelain(output)) {
|
|
3852
|
-
const resolved =
|
|
5486
|
+
const resolved = path14.resolve(wt.path);
|
|
3853
5487
|
const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
|
|
3854
5488
|
if (!branch) continue;
|
|
3855
5489
|
results.push({
|
|
@@ -3862,7 +5496,7 @@ function parseWorktreeList(output, currentPath) {
|
|
|
3862
5496
|
}
|
|
3863
5497
|
async function safeMtimeMs(filePath) {
|
|
3864
5498
|
try {
|
|
3865
|
-
const stat4 = await
|
|
5499
|
+
const stat4 = await fs10.stat(filePath);
|
|
3866
5500
|
return stat4.mtimeMs;
|
|
3867
5501
|
} catch {
|
|
3868
5502
|
return null;
|
|
@@ -3870,19 +5504,45 @@ async function safeMtimeMs(filePath) {
|
|
|
3870
5504
|
}
|
|
3871
5505
|
async function isDirectory(filePath) {
|
|
3872
5506
|
try {
|
|
3873
|
-
const stat4 = await
|
|
5507
|
+
const stat4 = await fs10.stat(filePath);
|
|
3874
5508
|
return stat4.isDirectory();
|
|
3875
5509
|
} catch {
|
|
3876
5510
|
return false;
|
|
3877
5511
|
}
|
|
3878
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
|
+
}
|
|
3879
5539
|
async function findWorktreeRoot(startPath) {
|
|
3880
|
-
let current =
|
|
3881
|
-
const root =
|
|
5540
|
+
let current = path14.resolve(startPath);
|
|
5541
|
+
const root = path14.parse(current).root;
|
|
3882
5542
|
while (true) {
|
|
3883
|
-
const gitPath =
|
|
5543
|
+
const gitPath = path14.join(current, ".git");
|
|
3884
5544
|
try {
|
|
3885
|
-
const content = await
|
|
5545
|
+
const content = await fs10.readFile(gitPath, "utf-8");
|
|
3886
5546
|
return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
|
|
3887
5547
|
} catch (err) {
|
|
3888
5548
|
const code = err.code;
|
|
@@ -3894,7 +5554,7 @@ async function findWorktreeRoot(startPath) {
|
|
|
3894
5554
|
}
|
|
3895
5555
|
}
|
|
3896
5556
|
if (current === root) return null;
|
|
3897
|
-
const parent =
|
|
5557
|
+
const parent = path14.dirname(current);
|
|
3898
5558
|
if (parent === current) return null;
|
|
3899
5559
|
current = parent;
|
|
3900
5560
|
}
|
|
@@ -3905,13 +5565,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3905
5565
|
import { z } from "zod";
|
|
3906
5566
|
|
|
3907
5567
|
// src/mcp/handlers.ts
|
|
3908
|
-
import * as
|
|
3909
|
-
import
|
|
5568
|
+
import * as path15 from "path";
|
|
5569
|
+
import pLimit3 from "p-limit";
|
|
3910
5570
|
|
|
3911
5571
|
// src/utils/disk-space.ts
|
|
3912
5572
|
import fastFolderSize from "fast-folder-size";
|
|
3913
5573
|
async function calculateDirectorySize(dirPath) {
|
|
3914
|
-
return new Promise((
|
|
5574
|
+
return new Promise((resolve11, reject) => {
|
|
3915
5575
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
3916
5576
|
if (err) {
|
|
3917
5577
|
reject(err);
|
|
@@ -3921,7 +5581,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
3921
5581
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
3922
5582
|
return;
|
|
3923
5583
|
}
|
|
3924
|
-
|
|
5584
|
+
resolve11(bytes);
|
|
3925
5585
|
});
|
|
3926
5586
|
});
|
|
3927
5587
|
}
|
|
@@ -3981,7 +5641,7 @@ function formatToolResponse(data) {
|
|
|
3981
5641
|
content: [
|
|
3982
5642
|
{
|
|
3983
5643
|
type: "text",
|
|
3984
|
-
text: JSON.stringify(data
|
|
5644
|
+
text: JSON.stringify(data)
|
|
3985
5645
|
}
|
|
3986
5646
|
]
|
|
3987
5647
|
};
|
|
@@ -4008,7 +5668,7 @@ function formatErrorResponse(error) {
|
|
|
4008
5668
|
content: [
|
|
4009
5669
|
{
|
|
4010
5670
|
type: "text",
|
|
4011
|
-
text: JSON.stringify(body
|
|
5671
|
+
text: JSON.stringify(body)
|
|
4012
5672
|
}
|
|
4013
5673
|
],
|
|
4014
5674
|
isError: true
|
|
@@ -4035,7 +5695,7 @@ function wrapHandler(fn) {
|
|
|
4035
5695
|
}
|
|
4036
5696
|
|
|
4037
5697
|
// src/mcp/worktree-summary.ts
|
|
4038
|
-
import
|
|
5698
|
+
import simpleGit7 from "simple-git";
|
|
4039
5699
|
function deriveLabel(status, isCurrent) {
|
|
4040
5700
|
if (isCurrent) return "current";
|
|
4041
5701
|
if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
|
|
@@ -4056,7 +5716,7 @@ function deriveSafeToRemove(status) {
|
|
|
4056
5716
|
}
|
|
4057
5717
|
async function getDivergence(worktreePath) {
|
|
4058
5718
|
try {
|
|
4059
|
-
const git =
|
|
5719
|
+
const git = simpleGit7(worktreePath);
|
|
4060
5720
|
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
4061
5721
|
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
4062
5722
|
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|
|
@@ -4067,6 +5727,7 @@ async function getDivergence(worktreePath) {
|
|
|
4067
5727
|
|
|
4068
5728
|
// src/mcp/handlers.ts
|
|
4069
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";
|
|
4070
5731
|
function ensureCapability(discovered, key, toolName) {
|
|
4071
5732
|
if (!discovered) return;
|
|
4072
5733
|
const cap = discovered.capabilities[key];
|
|
@@ -4076,6 +5737,9 @@ function ensureCapability(discovered, key, toolName) {
|
|
|
4076
5737
|
}
|
|
4077
5738
|
}
|
|
4078
5739
|
async function getReadyService(ctx, repoName, options = {}) {
|
|
5740
|
+
if (!repoName) {
|
|
5741
|
+
ctx.autoSelectCurrentRepoIfSingleConfig();
|
|
5742
|
+
}
|
|
4079
5743
|
const discovered = ctx.getDiscoveredContext(repoName);
|
|
4080
5744
|
if (options.capability && options.toolName) {
|
|
4081
5745
|
ensureCapability(discovered, options.capability, options.toolName);
|
|
@@ -4098,27 +5762,44 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
|
|
|
4098
5762
|
}
|
|
4099
5763
|
return result.value;
|
|
4100
5764
|
}
|
|
4101
|
-
async function ensureRepoWorktreePath(ctx, params, git) {
|
|
4102
|
-
await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
|
|
4103
|
-
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);
|
|
4104
5768
|
}
|
|
4105
|
-
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
|
|
5769
|
+
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
|
|
4106
5770
|
const discovered = ctx.getDiscoveredContext(repoName);
|
|
4107
5771
|
if (discovered?.allWorktrees.length) {
|
|
4108
5772
|
const match = discovered.allWorktrees.some((w) => pathsEqual(w.path, targetPath));
|
|
4109
5773
|
if (match) return;
|
|
4110
5774
|
}
|
|
4111
5775
|
try {
|
|
4112
|
-
const worktrees = await git
|
|
5776
|
+
const worktrees = await getWorktreesFromService(service, git);
|
|
4113
5777
|
if (worktrees.some((w) => pathsEqual(w.path, targetPath))) return;
|
|
4114
5778
|
} catch {
|
|
4115
5779
|
}
|
|
4116
5780
|
throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
|
|
4117
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
|
+
}
|
|
4118
5798
|
async function handleDetectContext(ctx, params, _extra) {
|
|
4119
5799
|
const target = params.path ?? process.cwd();
|
|
4120
5800
|
const discovered = await ctx.detectFromPath(target);
|
|
4121
|
-
|
|
5801
|
+
const configuredRepositories = await ctx.getConfiguredRepositorySummaries({ detailed: params.detailed ?? false });
|
|
5802
|
+
let response = { ...discovered, configuredRepositories };
|
|
4122
5803
|
if (params.includeAllWorktrees) {
|
|
4123
5804
|
const details = await ctx.getAllConfiguredWorktreeDetails(discovered.currentWorktreePath);
|
|
4124
5805
|
const errorsByRepo = Object.keys(details.errorsByRepo).length > 0 ? details.errorsByRepo : void 0;
|
|
@@ -4132,7 +5813,7 @@ async function handleDetectContext(ctx, params, _extra) {
|
|
|
4132
5813
|
return formatToolResponse(response);
|
|
4133
5814
|
}
|
|
4134
5815
|
const statusService = new WorktreeStatusService();
|
|
4135
|
-
const statusLimit =
|
|
5816
|
+
const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
4136
5817
|
const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
|
|
4137
5818
|
let allWorktreesByRepo = response.allWorktreesByRepo;
|
|
4138
5819
|
if (allWorktreesByRepo) {
|
|
@@ -4168,8 +5849,8 @@ async function enrichDetectedWorktrees(worktrees, statusService, limit) {
|
|
|
4168
5849
|
async function handleListWorktrees(ctx, params, _extra) {
|
|
4169
5850
|
const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
|
|
4170
5851
|
if (configuredRepoNames.length > 0) {
|
|
4171
|
-
const limit =
|
|
4172
|
-
const statusLimit =
|
|
5852
|
+
const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
5853
|
+
const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
4173
5854
|
const repositories = await Promise.all(
|
|
4174
5855
|
configuredRepoNames.map(
|
|
4175
5856
|
(repoName) => limit(async () => {
|
|
@@ -4197,14 +5878,14 @@ async function handleListWorktrees(ctx, params, _extra) {
|
|
|
4197
5878
|
const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
|
|
4198
5879
|
return formatToolResponse({ worktrees: results });
|
|
4199
5880
|
}
|
|
4200
|
-
async function listWorktreesForRepo(ctx, repoName, includeSize, limit =
|
|
4201
|
-
const { discovered, git } = await getReadyService(ctx, repoName, {
|
|
5881
|
+
async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
|
|
5882
|
+
const { discovered, service, git } = await getReadyService(ctx, repoName, {
|
|
4202
5883
|
capability: "listWorktrees",
|
|
4203
5884
|
toolName: "list_worktrees"
|
|
4204
5885
|
});
|
|
4205
5886
|
let worktrees;
|
|
4206
5887
|
try {
|
|
4207
|
-
worktrees = await git
|
|
5888
|
+
worktrees = await getWorktreesFromService(service, git);
|
|
4208
5889
|
} catch {
|
|
4209
5890
|
if (discovered) {
|
|
4210
5891
|
worktrees = discovered.allWorktrees.map((w) => ({ path: w.path, branch: w.branch }));
|
|
@@ -4216,7 +5897,7 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(
|
|
|
4216
5897
|
const results = await Promise.all(
|
|
4217
5898
|
worktrees.map(
|
|
4218
5899
|
(wt) => limit(async () => {
|
|
4219
|
-
const resolvedPath =
|
|
5900
|
+
const resolvedPath = path15.resolve(wt.path);
|
|
4220
5901
|
const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
|
|
4221
5902
|
const [status, divergence, metadata, sizeBytes] = await Promise.all([
|
|
4222
5903
|
git.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
@@ -4241,11 +5922,11 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(
|
|
|
4241
5922
|
return results;
|
|
4242
5923
|
}
|
|
4243
5924
|
async function handleGetWorktreeStatus(ctx, params, _extra) {
|
|
4244
|
-
const { git } = await getReadyService(ctx, params.repoName, {
|
|
5925
|
+
const { service, git } = await getReadyService(ctx, params.repoName, {
|
|
4245
5926
|
capability: "getStatus",
|
|
4246
5927
|
toolName: "get_worktree_status"
|
|
4247
5928
|
});
|
|
4248
|
-
const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
5929
|
+
const resolvedPath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4249
5930
|
const [status, divergence] = await Promise.all([
|
|
4250
5931
|
git.getFullWorktreeStatus(params.path, params.includeDetails ?? false),
|
|
4251
5932
|
getDivergence(params.path)
|
|
@@ -4267,9 +5948,10 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
4267
5948
|
capability: "createWorktree",
|
|
4268
5949
|
toolName: "create_worktree"
|
|
4269
5950
|
});
|
|
5951
|
+
ensureWorktreeModeService(service, "create_worktree");
|
|
4270
5952
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4271
5953
|
if (!service.isInitialized()) {
|
|
4272
|
-
await service.
|
|
5954
|
+
await service.initializeUnlocked();
|
|
4273
5955
|
}
|
|
4274
5956
|
const existence = await git.branchExists(branchName);
|
|
4275
5957
|
let created = false;
|
|
@@ -4299,7 +5981,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
4299
5981
|
return formatToolResponse({
|
|
4300
5982
|
success: true,
|
|
4301
5983
|
branchName,
|
|
4302
|
-
worktreePath:
|
|
5984
|
+
worktreePath: path15.resolve(worktreePath),
|
|
4303
5985
|
created,
|
|
4304
5986
|
pushed
|
|
4305
5987
|
});
|
|
@@ -4310,11 +5992,12 @@ async function handleRemoveWorktree(ctx, params, _extra) {
|
|
|
4310
5992
|
capability: "removeWorktree",
|
|
4311
5993
|
toolName: "remove_worktree"
|
|
4312
5994
|
});
|
|
5995
|
+
ensureWorktreeModeService(service, "remove_worktree");
|
|
4313
5996
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4314
5997
|
if (!service.isInitialized()) {
|
|
4315
|
-
await service.
|
|
5998
|
+
await service.initializeUnlocked();
|
|
4316
5999
|
}
|
|
4317
|
-
const removedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
6000
|
+
const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4318
6001
|
if (!params.force) {
|
|
4319
6002
|
const status = await git.getFullWorktreeStatus(params.path, false);
|
|
4320
6003
|
if (!status.canRemove) {
|
|
@@ -4337,13 +6020,31 @@ async function handleSync(ctx, params, extra) {
|
|
|
4337
6020
|
const dispose = attachProgressReporter(service, extra);
|
|
4338
6021
|
try {
|
|
4339
6022
|
const start = Date.now();
|
|
6023
|
+
service.clearRecordedSkips();
|
|
4340
6024
|
const result = await service.sync();
|
|
4341
6025
|
if (!result.started) {
|
|
4342
6026
|
throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
|
|
4343
6027
|
}
|
|
4344
6028
|
const duration = Date.now() - start;
|
|
4345
6029
|
ctx.invalidateDiscovered();
|
|
4346
|
-
|
|
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
|
+
});
|
|
4347
6048
|
} finally {
|
|
4348
6049
|
dispose();
|
|
4349
6050
|
}
|
|
@@ -4353,11 +6054,12 @@ async function handleUpdateWorktree(ctx, params, _extra) {
|
|
|
4353
6054
|
capability: "updateWorktree",
|
|
4354
6055
|
toolName: "update_worktree"
|
|
4355
6056
|
});
|
|
6057
|
+
ensureWorktreeModeService(service, "update_worktree");
|
|
4356
6058
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4357
6059
|
if (!service.isInitialized()) {
|
|
4358
|
-
await service.
|
|
6060
|
+
await service.initializeUnlocked();
|
|
4359
6061
|
}
|
|
4360
|
-
const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
|
|
6062
|
+
const worktreePath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4361
6063
|
await git.updateWorktree(params.path);
|
|
4362
6064
|
ctx.invalidateDiscovered();
|
|
4363
6065
|
return formatToolResponse({
|
|
@@ -4374,7 +6076,8 @@ async function handleInitialize(ctx, params, extra) {
|
|
|
4374
6076
|
const dispose = attachProgressReporter(service, extra);
|
|
4375
6077
|
try {
|
|
4376
6078
|
return await runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4377
|
-
await service.
|
|
6079
|
+
await service.initializeUnlocked();
|
|
6080
|
+
service.clearPendingInitSkip();
|
|
4378
6081
|
const git = service.getGitService();
|
|
4379
6082
|
ctx.invalidateDiscovered();
|
|
4380
6083
|
return formatToolResponse({
|
|
@@ -4394,7 +6097,7 @@ async function handleLoadConfig(ctx, params, _extra) {
|
|
|
4394
6097
|
}
|
|
4395
6098
|
await ctx.loadConfig(configPath);
|
|
4396
6099
|
return formatToolResponse({
|
|
4397
|
-
configPath:
|
|
6100
|
+
configPath: path15.resolve(configPath),
|
|
4398
6101
|
currentRepository: ctx.getCurrentRepo(),
|
|
4399
6102
|
repositories: ctx.getRepositoryList()
|
|
4400
6103
|
});
|
|
@@ -4429,20 +6132,23 @@ function attachProgressReporter(service, extra) {
|
|
|
4429
6132
|
}
|
|
4430
6133
|
|
|
4431
6134
|
// src/mcp/server.ts
|
|
4432
|
-
var REPO_NAME_DESCRIBE = "
|
|
4433
|
-
var PATH_DESCRIBE_SUFFIX = "Absolute
|
|
4434
|
-
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.";
|
|
4435
6138
|
function buildInstructions(snapshot) {
|
|
4436
6139
|
const d = snapshot?.discovered;
|
|
4437
|
-
if (!d || !d.isWorktree || d.kind !== "managed")
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
if (d.
|
|
4442
|
-
if (d.
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
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(" ")}.`;
|
|
4446
6152
|
}
|
|
4447
6153
|
function createServer(context, snapshot) {
|
|
4448
6154
|
const server = new McpServer(
|
|
@@ -4459,22 +6165,24 @@ function createServer(context, snapshot) {
|
|
|
4459
6165
|
"sync-worktrees://workspace",
|
|
4460
6166
|
{
|
|
4461
6167
|
title: "Workspace context",
|
|
4462
|
-
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.",
|
|
4463
6169
|
mimeType: "application/json"
|
|
4464
6170
|
},
|
|
4465
6171
|
async (uri) => {
|
|
4466
|
-
let
|
|
6172
|
+
let payload;
|
|
4467
6173
|
try {
|
|
4468
|
-
discovered = await context.detectFromPath(process.cwd());
|
|
6174
|
+
const discovered = await context.detectFromPath(process.cwd());
|
|
6175
|
+
const configuredRepositories = await context.getConfiguredRepositorySummaries();
|
|
6176
|
+
payload = { ...discovered, configuredRepositories };
|
|
4469
6177
|
} catch (err) {
|
|
4470
|
-
|
|
6178
|
+
payload = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
|
|
4471
6179
|
}
|
|
4472
6180
|
return {
|
|
4473
6181
|
contents: [
|
|
4474
6182
|
{
|
|
4475
6183
|
uri: uri.href,
|
|
4476
6184
|
mimeType: "application/json",
|
|
4477
|
-
text: JSON.stringify(
|
|
6185
|
+
text: JSON.stringify(payload)
|
|
4478
6186
|
}
|
|
4479
6187
|
]
|
|
4480
6188
|
};
|
|
@@ -4483,14 +6191,13 @@ function createServer(context, snapshot) {
|
|
|
4483
6191
|
server.registerTool(
|
|
4484
6192
|
"detect_context",
|
|
4485
6193
|
{
|
|
4486
|
-
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.",
|
|
4487
6195
|
inputSchema: {
|
|
4488
|
-
path: z.string().optional().describe("Directory
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
),
|
|
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."),
|
|
4492
6199
|
includeStatus: z.boolean().optional().describe(
|
|
4493
|
-
"
|
|
6200
|
+
"Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Default: false."
|
|
4494
6201
|
)
|
|
4495
6202
|
},
|
|
4496
6203
|
annotations: {
|
|
@@ -4505,13 +6212,11 @@ function createServer(context, snapshot) {
|
|
|
4505
6212
|
server.registerTool(
|
|
4506
6213
|
"list_worktrees",
|
|
4507
6214
|
{
|
|
4508
|
-
description: "List worktrees with
|
|
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}.",
|
|
4509
6216
|
inputSchema: {
|
|
4510
|
-
repoName: z.string().optional().describe(
|
|
4511
|
-
"Repository name from loaded config. If omitted and a config is loaded, lists all configured repos."
|
|
4512
|
-
),
|
|
6217
|
+
repoName: z.string().optional().describe("Repo name. Omit + config loaded = list all configured repos."),
|
|
4513
6218
|
includeSize: z.boolean().optional().describe(
|
|
4514
|
-
"
|
|
6219
|
+
"Compute on-disk size per worktree (bytes). Slow on large worktrees. Default: false (sizeBytes=null)."
|
|
4515
6220
|
)
|
|
4516
6221
|
},
|
|
4517
6222
|
annotations: {
|
|
@@ -4526,11 +6231,11 @@ function createServer(context, snapshot) {
|
|
|
4526
6231
|
server.registerTool(
|
|
4527
6232
|
"get_worktree_status",
|
|
4528
6233
|
{
|
|
4529
|
-
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.",
|
|
4530
6235
|
inputSchema: {
|
|
4531
6236
|
path: z.string().describe(`Worktree path. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4532
6237
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
|
|
4533
|
-
includeDetails: z.boolean().optional().describe("
|
|
6238
|
+
includeDetails: z.boolean().optional().describe("Include file-level lists (modified, untracked, staged). Default: false (counts only).")
|
|
4534
6239
|
},
|
|
4535
6240
|
annotations: {
|
|
4536
6241
|
title: "Get worktree status",
|
|
@@ -4544,13 +6249,13 @@ function createServer(context, snapshot) {
|
|
|
4544
6249
|
server.registerTool(
|
|
4545
6250
|
"create_worktree",
|
|
4546
6251
|
{
|
|
4547
|
-
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}.",
|
|
4548
6253
|
inputSchema: {
|
|
4549
|
-
branchName: z.string().describe("Branch name. Slashes
|
|
6254
|
+
branchName: z.string().describe("Branch name. Slashes/special chars sanitized for dir name."),
|
|
4550
6255
|
baseBranch: z.string().optional().describe(
|
|
4551
|
-
"Base
|
|
6256
|
+
"Base for new branch. Required if branchName doesn't exist locally or remotely; ignored otherwise."
|
|
4552
6257
|
),
|
|
4553
|
-
push: z.boolean().optional().describe("Push
|
|
6258
|
+
push: z.boolean().optional().describe("Push new branch to origin. Default: true. Ignored if branch existed."),
|
|
4554
6259
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4555
6260
|
},
|
|
4556
6261
|
annotations: {
|
|
@@ -4566,12 +6271,10 @@ function createServer(context, snapshot) {
|
|
|
4566
6271
|
server.registerTool(
|
|
4567
6272
|
"remove_worktree",
|
|
4568
6273
|
{
|
|
4569
|
-
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}.",
|
|
4570
6275
|
inputSchema: {
|
|
4571
6276
|
path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4572
|
-
force: z.boolean().optional().describe(
|
|
4573
|
-
"Skip safety checks and delete uncommitted/untracked files in the worktree directory. Branch ref is preserved. Default: false."
|
|
4574
|
-
),
|
|
6277
|
+
force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
|
|
4575
6278
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4576
6279
|
},
|
|
4577
6280
|
annotations: {
|
|
@@ -4587,7 +6290,7 @@ function createServer(context, snapshot) {
|
|
|
4587
6290
|
server.registerTool(
|
|
4588
6291
|
"sync",
|
|
4589
6292
|
{
|
|
4590
|
-
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}.",
|
|
4591
6294
|
inputSchema: {
|
|
4592
6295
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4593
6296
|
},
|
|
@@ -4604,7 +6307,7 @@ function createServer(context, snapshot) {
|
|
|
4604
6307
|
server.registerTool(
|
|
4605
6308
|
"update_worktree",
|
|
4606
6309
|
{
|
|
4607
|
-
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.",
|
|
4608
6311
|
inputSchema: {
|
|
4609
6312
|
path: z.string().describe(`Worktree path to fast-forward. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4610
6313
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
@@ -4622,7 +6325,7 @@ function createServer(context, snapshot) {
|
|
|
4622
6325
|
server.registerTool(
|
|
4623
6326
|
"initialize",
|
|
4624
6327
|
{
|
|
4625
|
-
description: "Initialize
|
|
6328
|
+
description: "Initialize repo: clone as bare if missing, create main worktree. Idempotent. Emits progress. Preconditions: config loaded. Returns: {success, defaultBranch, worktreeDir}.",
|
|
4626
6329
|
inputSchema: {
|
|
4627
6330
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4628
6331
|
},
|
|
@@ -4639,11 +6342,9 @@ function createServer(context, snapshot) {
|
|
|
4639
6342
|
server.registerTool(
|
|
4640
6343
|
"load_config",
|
|
4641
6344
|
{
|
|
4642
|
-
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}]}.",
|
|
4643
6346
|
inputSchema: {
|
|
4644
|
-
configPath: z.string().optional().describe(
|
|
4645
|
-
"Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."
|
|
4646
|
-
)
|
|
6347
|
+
configPath: z.string().optional().describe("Config file path. Falls back to SYNC_WORKTREES_CONFIG env var. Errors if neither set.")
|
|
4647
6348
|
},
|
|
4648
6349
|
annotations: {
|
|
4649
6350
|
title: "Load sync-worktrees config",
|
|
@@ -4658,9 +6359,9 @@ function createServer(context, snapshot) {
|
|
|
4658
6359
|
server.registerTool(
|
|
4659
6360
|
"set_current_repository",
|
|
4660
6361
|
{
|
|
4661
|
-
description: "Set
|
|
6362
|
+
description: "Set current repo for tool calls that omit repoName. Session-scoped. Preconditions: load_config called.",
|
|
4662
6363
|
inputSchema: {
|
|
4663
|
-
repoName: z.string().describe("
|
|
6364
|
+
repoName: z.string().describe("Repo name from loaded config repositories[].name.")
|
|
4664
6365
|
},
|
|
4665
6366
|
annotations: {
|
|
4666
6367
|
title: "Set current repository",
|
|
@@ -4702,7 +6403,10 @@ async function main() {
|
|
|
4702
6403
|
process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
|
|
4703
6404
|
`);
|
|
4704
6405
|
}
|
|
4705
|
-
const server = createServer(context, {
|
|
6406
|
+
const server = createServer(context, {
|
|
6407
|
+
discovered,
|
|
6408
|
+
configuredRepoCount: context.getConfiguredRepositoryNames().length
|
|
6409
|
+
});
|
|
4706
6410
|
const transport = new StdioServerTransport();
|
|
4707
6411
|
await server.connect(transport);
|
|
4708
6412
|
}
|