sync-worktrees 3.6.3 → 4.1.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 +50 -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 +14 -0
- package/dist/components/StatusBar.d.ts.map +1 -0
- package/dist/components/WorktreeStatusView.d.ts +14 -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 +2523 -1106
- 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 +2347 -640
- 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 +94 -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 +303 -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 +31 -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.debug(` \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;
|
|
@@ -1794,11 +2756,12 @@ function sanitizeGitEnv(env) {
|
|
|
1794
2756
|
return sanitized;
|
|
1795
2757
|
}
|
|
1796
2758
|
var GitService = class {
|
|
1797
|
-
constructor(config, logger) {
|
|
2759
|
+
constructor(config, logger, progressEmitter) {
|
|
1798
2760
|
this.config = config;
|
|
2761
|
+
this.progressEmitter = progressEmitter;
|
|
1799
2762
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
1800
2763
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
1801
|
-
this.mainWorktreePath =
|
|
2764
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
1802
2765
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
1803
2766
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
1804
2767
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
@@ -1826,36 +2789,22 @@ var GitService = class {
|
|
|
1826
2789
|
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
1827
2790
|
}
|
|
1828
2791
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
1829
|
-
const key = `${
|
|
2792
|
+
const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
1830
2793
|
let git = this.gitInstances.get(key);
|
|
1831
2794
|
if (!git) {
|
|
1832
|
-
const base =
|
|
2795
|
+
const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
1833
2796
|
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
1834
2797
|
this.gitInstances.set(key, git);
|
|
1835
2798
|
}
|
|
1836
2799
|
return git;
|
|
1837
2800
|
}
|
|
1838
2801
|
buildSimpleGitOptions(blockMs) {
|
|
1839
|
-
const options = {
|
|
2802
|
+
const options = {
|
|
2803
|
+
progress: makeGitProgressHandler(this.logger, (event) => this.progressEmitter?.(event))
|
|
2804
|
+
};
|
|
1840
2805
|
if (blockMs > 0) options.timeout = { block: blockMs };
|
|
1841
2806
|
return options;
|
|
1842
2807
|
}
|
|
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
2808
|
updateLogger(logger) {
|
|
1860
2809
|
this.logger = logger;
|
|
1861
2810
|
this.sparseCheckoutService.updateLogger(logger);
|
|
@@ -1863,11 +2812,11 @@ var GitService = class {
|
|
|
1863
2812
|
async initialize() {
|
|
1864
2813
|
const { repoUrl } = this.config;
|
|
1865
2814
|
try {
|
|
1866
|
-
await
|
|
2815
|
+
await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
|
|
1867
2816
|
} catch {
|
|
1868
2817
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
1869
|
-
await
|
|
1870
|
-
const cloneBase =
|
|
2818
|
+
await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
|
|
2819
|
+
const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
|
|
1871
2820
|
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
1872
2821
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
|
|
1873
2822
|
this.logger.info("\u2705 Clone successful.");
|
|
@@ -1885,17 +2834,17 @@ var GitService = class {
|
|
|
1885
2834
|
this.logger.info("Fetching remote branches...");
|
|
1886
2835
|
await bareGit.fetch(["--all", "--progress"]);
|
|
1887
2836
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
1888
|
-
this.mainWorktreePath =
|
|
2837
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
|
|
1889
2838
|
let needsMainWorktree = true;
|
|
1890
2839
|
try {
|
|
1891
2840
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1892
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
2841
|
+
needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
|
|
1893
2842
|
} catch {
|
|
1894
2843
|
}
|
|
1895
2844
|
if (needsMainWorktree) {
|
|
1896
2845
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
1897
|
-
await
|
|
1898
|
-
const absoluteWorktreePath =
|
|
2846
|
+
await fs6.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2847
|
+
const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
|
|
1899
2848
|
const branches = await bareGit.branch();
|
|
1900
2849
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
1901
2850
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -1931,7 +2880,7 @@ var GitService = class {
|
|
|
1931
2880
|
}
|
|
1932
2881
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
1933
2882
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
1934
|
-
(w) =>
|
|
2883
|
+
(w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
|
|
1935
2884
|
);
|
|
1936
2885
|
if (!mainWorktreeRegistered) {
|
|
1937
2886
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -1957,6 +2906,45 @@ var GitService = class {
|
|
|
1957
2906
|
getBareRepoPath() {
|
|
1958
2907
|
return this.bareRepoPath;
|
|
1959
2908
|
}
|
|
2909
|
+
async getRemoteDefaultBranch(repoUrl) {
|
|
2910
|
+
const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
2911
|
+
try {
|
|
2912
|
+
const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
|
|
2913
|
+
const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
|
|
2914
|
+
if (match && match[1]) {
|
|
2915
|
+
return match[1];
|
|
2916
|
+
}
|
|
2917
|
+
} catch {
|
|
2918
|
+
}
|
|
2919
|
+
const existing = [];
|
|
2920
|
+
for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
|
|
2921
|
+
try {
|
|
2922
|
+
const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
|
|
2923
|
+
if (out.trim().length > 0) {
|
|
2924
|
+
existing.push(candidate);
|
|
2925
|
+
}
|
|
2926
|
+
} catch {
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
if (existing.length === 1) {
|
|
2930
|
+
this.logger.warn(
|
|
2931
|
+
`Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
|
|
2932
|
+
);
|
|
2933
|
+
return existing[0];
|
|
2934
|
+
}
|
|
2935
|
+
if (existing.length > 1) {
|
|
2936
|
+
throw new Error(
|
|
2937
|
+
`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.`
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
throw new Error(
|
|
2941
|
+
`Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
async verifyLfs(worktreePath, label) {
|
|
2945
|
+
if (this.isLfsSkipEnabled()) return;
|
|
2946
|
+
await this.verifyLfsFilesDownloaded(worktreePath, label);
|
|
2947
|
+
}
|
|
1960
2948
|
async fetchAll() {
|
|
1961
2949
|
this.assertInitialized();
|
|
1962
2950
|
this.logger.info("Fetching latest data from remote...");
|
|
@@ -2003,7 +2991,7 @@ var GitService = class {
|
|
|
2003
2991
|
return branches;
|
|
2004
2992
|
}
|
|
2005
2993
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
2006
|
-
const worktreeGit = this.config.sparseCheckout ?
|
|
2994
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
2007
2995
|
try {
|
|
2008
2996
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
2009
2997
|
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
@@ -2014,7 +3002,7 @@ var GitService = class {
|
|
|
2014
3002
|
const existence = await Promise.all(
|
|
2015
3003
|
lfsFileList.map(async (f) => {
|
|
2016
3004
|
try {
|
|
2017
|
-
await
|
|
3005
|
+
await fs6.access(path8.join(worktreePath, f));
|
|
2018
3006
|
return f;
|
|
2019
3007
|
} catch {
|
|
2020
3008
|
return null;
|
|
@@ -2042,9 +3030,9 @@ var GitService = class {
|
|
|
2042
3030
|
let allDownloaded = true;
|
|
2043
3031
|
const notDownloaded = [];
|
|
2044
3032
|
for (const file of samplesToCheck) {
|
|
2045
|
-
const filePath =
|
|
3033
|
+
const filePath = path8.join(worktreePath, file);
|
|
2046
3034
|
try {
|
|
2047
|
-
const handle = await
|
|
3035
|
+
const handle = await fs6.open(filePath, "r");
|
|
2048
3036
|
try {
|
|
2049
3037
|
const buffer = Buffer.alloc(200);
|
|
2050
3038
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
@@ -2069,7 +3057,7 @@ var GitService = class {
|
|
|
2069
3057
|
}
|
|
2070
3058
|
retries++;
|
|
2071
3059
|
if (retries < maxRetries) {
|
|
2072
|
-
await new Promise((
|
|
3060
|
+
await new Promise((resolve11) => setTimeout(resolve11, retryDelay));
|
|
2073
3061
|
}
|
|
2074
3062
|
}
|
|
2075
3063
|
this.logger.warn(
|
|
@@ -2131,18 +3119,18 @@ var GitService = class {
|
|
|
2131
3119
|
}
|
|
2132
3120
|
async addWorktree(branchName, worktreePath) {
|
|
2133
3121
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
2134
|
-
const absoluteWorktreePath =
|
|
2135
|
-
await
|
|
3122
|
+
const absoluteWorktreePath = path8.resolve(worktreePath);
|
|
3123
|
+
await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
|
|
2136
3124
|
try {
|
|
2137
|
-
await
|
|
3125
|
+
await fs6.access(absoluteWorktreePath);
|
|
2138
3126
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2139
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3127
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2140
3128
|
if (isValidWorktree) {
|
|
2141
3129
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
2142
3130
|
return;
|
|
2143
3131
|
} else {
|
|
2144
3132
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
2145
|
-
await
|
|
3133
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
2146
3134
|
}
|
|
2147
3135
|
} catch {
|
|
2148
3136
|
}
|
|
@@ -2181,7 +3169,7 @@ var GitService = class {
|
|
|
2181
3169
|
}
|
|
2182
3170
|
if (errorMessage.includes("already registered worktree")) {
|
|
2183
3171
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2184
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3172
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2185
3173
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
2186
3174
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
2187
3175
|
return;
|
|
@@ -2189,7 +3177,7 @@ var GitService = class {
|
|
|
2189
3177
|
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
2190
3178
|
await bareGit.raw(["worktree", "prune"]);
|
|
2191
3179
|
try {
|
|
2192
|
-
await
|
|
3180
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
2193
3181
|
} catch {
|
|
2194
3182
|
}
|
|
2195
3183
|
let retryCreatedNewBranch = false;
|
|
@@ -2225,15 +3213,15 @@ var GitService = class {
|
|
|
2225
3213
|
}
|
|
2226
3214
|
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
2227
3215
|
try {
|
|
2228
|
-
await
|
|
3216
|
+
await fs6.access(absoluteWorktreePath);
|
|
2229
3217
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2230
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3218
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2231
3219
|
if (isValidWorktree) {
|
|
2232
3220
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
2233
3221
|
return;
|
|
2234
3222
|
} else {
|
|
2235
3223
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
2236
|
-
await
|
|
3224
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
2237
3225
|
}
|
|
2238
3226
|
} catch {
|
|
2239
3227
|
}
|
|
@@ -2257,7 +3245,7 @@ var GitService = class {
|
|
|
2257
3245
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
2258
3246
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
2259
3247
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
2260
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3248
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
2261
3249
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
2262
3250
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
2263
3251
|
return;
|
|
@@ -2468,14 +3456,48 @@ var GitService = class {
|
|
|
2468
3456
|
return false;
|
|
2469
3457
|
}
|
|
2470
3458
|
}
|
|
2471
|
-
async isLocalAheadOfRemote(worktreePath, branch) {
|
|
2472
|
-
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3459
|
+
async isLocalAheadOfRemote(worktreePath, branch) {
|
|
3460
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3461
|
+
try {
|
|
3462
|
+
const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
|
|
3463
|
+
const mergeBaseSha = mergeBase.trim();
|
|
3464
|
+
const remoteSha = await worktreeGit.revparse([`origin/${branch}`]);
|
|
3465
|
+
const remoteShaTrimmed = remoteSha.trim();
|
|
3466
|
+
return mergeBaseSha === remoteShaTrimmed;
|
|
3467
|
+
} catch {
|
|
3468
|
+
return false;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
async classifyRemoteRelationship(worktreePath, branch) {
|
|
3472
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3473
|
+
let headSha;
|
|
3474
|
+
let remoteSha;
|
|
3475
|
+
try {
|
|
3476
|
+
headSha = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
3477
|
+
remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
|
|
3478
|
+
} catch {
|
|
3479
|
+
return "diverged";
|
|
3480
|
+
}
|
|
3481
|
+
if (headSha === remoteSha) return "up_to_date";
|
|
3482
|
+
let mergeBase = "";
|
|
3483
|
+
let mergeBaseFailed = false;
|
|
3484
|
+
try {
|
|
3485
|
+
mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
|
|
3486
|
+
} catch {
|
|
3487
|
+
mergeBaseFailed = true;
|
|
3488
|
+
}
|
|
3489
|
+
if (mergeBaseFailed || !mergeBase) {
|
|
3490
|
+
if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
|
|
3491
|
+
return "diverged";
|
|
3492
|
+
}
|
|
3493
|
+
if (mergeBase === headSha) return "fast_forward";
|
|
3494
|
+
if (mergeBase === remoteSha) return "local_ahead";
|
|
3495
|
+
return "diverged";
|
|
3496
|
+
}
|
|
3497
|
+
async isShallowRepository(git) {
|
|
2473
3498
|
try {
|
|
2474
|
-
const
|
|
2475
|
-
|
|
2476
|
-
const remoteSha = await worktreeGit.revparse([`origin/${branch}`]);
|
|
2477
|
-
const remoteShaTrimmed = remoteSha.trim();
|
|
2478
|
-
return mergeBaseSha === remoteShaTrimmed;
|
|
3499
|
+
const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
|
|
3500
|
+
return output.trim() === "true";
|
|
2479
3501
|
} catch {
|
|
2480
3502
|
return false;
|
|
2481
3503
|
}
|
|
@@ -2590,193 +3612,129 @@ var GitService = class {
|
|
|
2590
3612
|
}
|
|
2591
3613
|
};
|
|
2592
3614
|
|
|
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));
|
|
3615
|
+
// src/services/progress-emitter.ts
|
|
3616
|
+
var ProgressEmitter = class {
|
|
3617
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3618
|
+
onProgress(listener) {
|
|
3619
|
+
this.listeners.add(listener);
|
|
3620
|
+
return () => this.listeners.delete(listener);
|
|
2607
3621
|
}
|
|
2608
|
-
|
|
2609
|
-
const
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
const parent = path7.dirname(current);
|
|
2614
|
-
if (parent === current) {
|
|
2615
|
-
return absolute;
|
|
3622
|
+
emit(event) {
|
|
3623
|
+
for (const listener of [...this.listeners]) {
|
|
3624
|
+
try {
|
|
3625
|
+
listener(event);
|
|
3626
|
+
} catch {
|
|
2616
3627
|
}
|
|
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
3628
|
}
|
|
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
3629
|
}
|
|
2649
3630
|
};
|
|
2650
3631
|
|
|
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;
|
|
3632
|
+
// src/services/repo-operation-lock.ts
|
|
3633
|
+
import * as fs7 from "fs/promises";
|
|
3634
|
+
import * as path10 from "path";
|
|
3635
|
+
import * as lockfile from "proper-lockfile";
|
|
3636
|
+
|
|
3637
|
+
// src/utils/lock-path.ts
|
|
3638
|
+
import { createHash } from "crypto";
|
|
3639
|
+
import * as os from "os";
|
|
3640
|
+
import * as path9 from "path";
|
|
3641
|
+
function getCloneModeLockTarget(config) {
|
|
3642
|
+
const name = config.name;
|
|
3643
|
+
const configDir = config.__configFileDir;
|
|
3644
|
+
const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
3645
|
+
if (configDir) {
|
|
3646
|
+
return {
|
|
3647
|
+
dir: path9.join(configDir, ".sync-worktrees-state"),
|
|
3648
|
+
file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
|
|
3649
|
+
};
|
|
2673
3650
|
}
|
|
2674
|
-
|
|
2675
|
-
|
|
3651
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
|
|
3652
|
+
const dir = path9.join(stateBase, "sync-worktrees", "locks");
|
|
3653
|
+
return { dir, file: `${hash}.lock` };
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
// src/services/repo-operation-lock.ts
|
|
3657
|
+
var RepoOperationLock = class {
|
|
3658
|
+
constructor(config, gitService, logger = Logger.createDefault()) {
|
|
3659
|
+
this.config = config;
|
|
3660
|
+
this.gitService = gitService;
|
|
3661
|
+
this.logger = logger;
|
|
2676
3662
|
}
|
|
2677
3663
|
updateLogger(logger) {
|
|
2678
3664
|
this.logger = logger;
|
|
2679
|
-
this.gitService.updateLogger(logger);
|
|
2680
3665
|
}
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
async runExclusiveRepoOperation(operation) {
|
|
2686
|
-
if (this.syncInProgress) {
|
|
2687
|
-
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
2688
|
-
return { started: false, reason: "in_progress" };
|
|
3666
|
+
async acquire() {
|
|
3667
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
3668
|
+
return async () => {
|
|
3669
|
+
};
|
|
2689
3670
|
}
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
2693
|
-
return { started: false, reason: "locked" };
|
|
3671
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
3672
|
+
return this.acquireCloneModeLock();
|
|
2694
3673
|
}
|
|
2695
|
-
this.
|
|
3674
|
+
return this.acquireWorktreeModeLock();
|
|
3675
|
+
}
|
|
3676
|
+
async acquireCloneModeLock() {
|
|
3677
|
+
const target = getCloneModeLockTarget(this.config);
|
|
3678
|
+
const lockTarget = path10.join(target.dir, target.file);
|
|
2696
3679
|
try {
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
await release();
|
|
2702
|
-
} catch (releaseError) {
|
|
2703
|
-
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
2704
|
-
}
|
|
3680
|
+
await fs7.mkdir(target.dir, { recursive: true });
|
|
3681
|
+
await fs7.writeFile(lockTarget, "", { flag: "a" });
|
|
3682
|
+
} catch {
|
|
3683
|
+
return null;
|
|
2705
3684
|
}
|
|
3685
|
+
return this.lockPath(lockTarget);
|
|
2706
3686
|
}
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
3687
|
+
async acquireWorktreeModeLock() {
|
|
3688
|
+
const barePath = this.gitService.getBareRepoPath();
|
|
3689
|
+
try {
|
|
3690
|
+
await fs7.mkdir(barePath, { recursive: true });
|
|
3691
|
+
} catch {
|
|
3692
|
+
return null;
|
|
2713
3693
|
}
|
|
3694
|
+
return this.lockPath(barePath);
|
|
2714
3695
|
}
|
|
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 });
|
|
3696
|
+
async lockPath(lockTarget) {
|
|
2757
3697
|
try {
|
|
2758
|
-
|
|
3698
|
+
return await lockfile.lock(lockTarget, {
|
|
2759
3699
|
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
2760
3700
|
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
2761
3701
|
retries: 0,
|
|
2762
3702
|
realpath: false
|
|
2763
3703
|
});
|
|
2764
|
-
return release;
|
|
2765
3704
|
} catch (error) {
|
|
2766
3705
|
const code = error.code;
|
|
2767
3706
|
if (code === "ELOCKED") {
|
|
2768
3707
|
return null;
|
|
2769
3708
|
}
|
|
2770
|
-
|
|
3709
|
+
this.logger.warn(
|
|
3710
|
+
`Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
|
|
3711
|
+
);
|
|
3712
|
+
return null;
|
|
2771
3713
|
}
|
|
2772
3714
|
}
|
|
2773
|
-
|
|
3715
|
+
};
|
|
3716
|
+
|
|
3717
|
+
// src/services/sync-retry-policy.ts
|
|
3718
|
+
var SyncRetryPolicy = class {
|
|
3719
|
+
constructor(config, gitService, logger) {
|
|
3720
|
+
this.config = config;
|
|
3721
|
+
this.gitService = gitService;
|
|
3722
|
+
this.logger = logger;
|
|
3723
|
+
}
|
|
3724
|
+
updateLogger(logger) {
|
|
3725
|
+
this.logger = logger;
|
|
3726
|
+
}
|
|
3727
|
+
createContext() {
|
|
3728
|
+
return { lfsSkipEnabled: false };
|
|
3729
|
+
}
|
|
3730
|
+
createOptions(syncContext) {
|
|
2774
3731
|
return {
|
|
2775
3732
|
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
2776
3733
|
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
2777
3734
|
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
2778
3735
|
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
2779
3736
|
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
3737
|
+
jitterMs: this.config.retry?.jitterMs ?? 0,
|
|
2780
3738
|
onRetry: (error, attempt, context) => {
|
|
2781
3739
|
const errorMessage = getErrorMessage(error);
|
|
2782
3740
|
this.logger.info(`
|
|
@@ -2797,25 +3755,228 @@ var WorktreeSyncService = class {
|
|
|
2797
3755
|
}
|
|
2798
3756
|
};
|
|
2799
3757
|
}
|
|
2800
|
-
|
|
3758
|
+
resetLfsSkipIfNeeded(syncContext) {
|
|
3759
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
3760
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
};
|
|
3764
|
+
|
|
3765
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
3766
|
+
import * as fs9 from "fs/promises";
|
|
3767
|
+
import * as path13 from "path";
|
|
3768
|
+
import pLimit from "p-limit";
|
|
3769
|
+
|
|
3770
|
+
// src/utils/date-filter.ts
|
|
3771
|
+
function parseDuration(durationStr) {
|
|
3772
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
3773
|
+
if (!match) {
|
|
3774
|
+
return null;
|
|
3775
|
+
}
|
|
3776
|
+
const value = parseInt(match[1], 10);
|
|
3777
|
+
const unit = match[2];
|
|
3778
|
+
const multipliers = {
|
|
3779
|
+
h: 60 * 60 * 1e3,
|
|
3780
|
+
// hours
|
|
3781
|
+
d: 24 * 60 * 60 * 1e3,
|
|
3782
|
+
// days
|
|
3783
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
3784
|
+
// weeks
|
|
3785
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
3786
|
+
// months (approximate)
|
|
3787
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
3788
|
+
// years (approximate)
|
|
3789
|
+
};
|
|
3790
|
+
return value * multipliers[unit];
|
|
3791
|
+
}
|
|
3792
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
3793
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
3794
|
+
if (maxAgeMs === null) {
|
|
3795
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
3796
|
+
return branches;
|
|
3797
|
+
}
|
|
3798
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
3799
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
3800
|
+
}
|
|
3801
|
+
function formatDuration2(durationStr) {
|
|
3802
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
3803
|
+
if (!match) {
|
|
3804
|
+
return durationStr;
|
|
3805
|
+
}
|
|
3806
|
+
const value = parseInt(match[1], 10);
|
|
3807
|
+
const unit = match[2];
|
|
3808
|
+
const unitNames = {
|
|
3809
|
+
h: value === 1 ? "hour" : "hours",
|
|
3810
|
+
d: value === 1 ? "day" : "days",
|
|
3811
|
+
w: value === 1 ? "week" : "weeks",
|
|
3812
|
+
m: value === 1 ? "month" : "months",
|
|
3813
|
+
y: value === 1 ? "year" : "years"
|
|
3814
|
+
};
|
|
3815
|
+
return `${value} ${unitNames[unit]}`;
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
// src/services/path-resolution.service.ts
|
|
3819
|
+
import { createHash as createHash2 } from "crypto";
|
|
3820
|
+
import * as fs8 from "fs";
|
|
3821
|
+
import * as path11 from "path";
|
|
3822
|
+
var BRANCH_STEM_MAX = 80;
|
|
3823
|
+
var BRANCH_HASH_LEN = 8;
|
|
3824
|
+
var PathResolutionService = class {
|
|
3825
|
+
sanitizeBranchName(branchName) {
|
|
3826
|
+
const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
|
|
3827
|
+
const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
|
|
3828
|
+
return `${stem}-${hash}`;
|
|
3829
|
+
}
|
|
3830
|
+
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3831
|
+
return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3832
|
+
}
|
|
3833
|
+
resolveRealPath(inputPath) {
|
|
3834
|
+
const absolute = path11.resolve(inputPath);
|
|
3835
|
+
const missing = [];
|
|
3836
|
+
let current = absolute;
|
|
3837
|
+
while (!fs8.existsSync(current)) {
|
|
3838
|
+
const parent = path11.dirname(current);
|
|
3839
|
+
if (parent === current) {
|
|
3840
|
+
return absolute;
|
|
3841
|
+
}
|
|
3842
|
+
missing.unshift(path11.basename(current));
|
|
3843
|
+
current = parent;
|
|
3844
|
+
}
|
|
3845
|
+
try {
|
|
3846
|
+
return path11.join(fs8.realpathSync(current), ...missing);
|
|
3847
|
+
} catch {
|
|
3848
|
+
return absolute;
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
isResolvedPathInsideBase(resolved, resolvedBase) {
|
|
3852
|
+
const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
3853
|
+
const a = fold(resolved);
|
|
3854
|
+
const b = fold(resolvedBase);
|
|
3855
|
+
if (a === b) return true;
|
|
3856
|
+
return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
|
|
3857
|
+
}
|
|
3858
|
+
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3859
|
+
const resolved = this.resolveRealPath(worktreePath);
|
|
3860
|
+
const resolvedBase = this.resolveRealPath(worktreeBaseDir);
|
|
3861
|
+
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3862
|
+
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3863
|
+
}
|
|
3864
|
+
return path11.relative(resolvedBase, resolved);
|
|
3865
|
+
}
|
|
3866
|
+
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3867
|
+
const resolved = this.resolveRealPath(targetPath);
|
|
3868
|
+
const resolvedBase = this.resolveRealPath(baseDir);
|
|
3869
|
+
return this.isResolvedPathInsideBase(resolved, resolvedBase);
|
|
3870
|
+
}
|
|
3871
|
+
extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3872
|
+
return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
|
|
3873
|
+
}
|
|
3874
|
+
};
|
|
3875
|
+
|
|
3876
|
+
// src/services/worktree-sync-planner.ts
|
|
3877
|
+
import * as path12 from "path";
|
|
3878
|
+
function createWorktreeSyncPlan(inventory, options = {}) {
|
|
3879
|
+
return {
|
|
3880
|
+
create: planCreateActions(inventory, options),
|
|
3881
|
+
prune: planPruneActions(inventory),
|
|
3882
|
+
update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
|
|
3883
|
+
sparse: planSparseActions(inventory, options.sparseCheckout),
|
|
3884
|
+
warnings: []
|
|
3885
|
+
};
|
|
3886
|
+
}
|
|
3887
|
+
function planCreateActions(inventory, options = {}) {
|
|
3888
|
+
const pathResolution2 = options.pathResolution ?? new PathResolutionService();
|
|
3889
|
+
const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
|
|
3890
|
+
const newBranches = inventory.remoteBranches.filter(
|
|
3891
|
+
(branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
|
|
3892
|
+
);
|
|
3893
|
+
const reservedPaths = /* @__PURE__ */ new Map();
|
|
3894
|
+
for (const worktree of inventory.existingWorktrees) {
|
|
3895
|
+
reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
|
|
3896
|
+
}
|
|
3897
|
+
const actions = [];
|
|
3898
|
+
for (const branch of newBranches) {
|
|
3899
|
+
const worktreePath = pathResolution2.getBranchWorktreePath(inventory.worktreeDir, branch);
|
|
3900
|
+
const resolved = path12.resolve(worktreePath);
|
|
3901
|
+
const conflictingBranch = reservedPaths.get(resolved);
|
|
3902
|
+
if (conflictingBranch && conflictingBranch !== branch) {
|
|
3903
|
+
actions.push({
|
|
3904
|
+
kind: "skip-create",
|
|
3905
|
+
branch,
|
|
3906
|
+
path: worktreePath,
|
|
3907
|
+
reason: "path-collision",
|
|
3908
|
+
conflictingBranch
|
|
3909
|
+
});
|
|
3910
|
+
continue;
|
|
3911
|
+
}
|
|
3912
|
+
reservedPaths.set(resolved, branch);
|
|
3913
|
+
actions.push({ kind: "create", branch, path: worktreePath });
|
|
3914
|
+
}
|
|
3915
|
+
return actions;
|
|
3916
|
+
}
|
|
3917
|
+
function planPruneActions(inventory) {
|
|
3918
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
3919
|
+
return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
|
|
3920
|
+
}
|
|
3921
|
+
function planUpdateActions(inventory) {
|
|
3922
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
3923
|
+
return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
|
|
3924
|
+
}
|
|
3925
|
+
function planSparseActions(inventory, sparseCheckout) {
|
|
3926
|
+
if (!sparseCheckout) {
|
|
3927
|
+
return [];
|
|
3928
|
+
}
|
|
3929
|
+
return inventory.existingWorktrees.map((worktree) => ({
|
|
3930
|
+
kind: "check-sparse",
|
|
3931
|
+
branch: worktree.branch,
|
|
3932
|
+
path: worktree.path
|
|
3933
|
+
}));
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
3937
|
+
var WorktreeModeSyncRunner = class {
|
|
3938
|
+
constructor(config, gitService, logger, progressEmitter) {
|
|
3939
|
+
this.config = config;
|
|
3940
|
+
this.gitService = gitService;
|
|
3941
|
+
this.logger = logger;
|
|
3942
|
+
this.progressEmitter = progressEmitter;
|
|
3943
|
+
}
|
|
3944
|
+
pathResolution = new PathResolutionService();
|
|
3945
|
+
updateLogger(logger) {
|
|
3946
|
+
this.logger = logger;
|
|
3947
|
+
}
|
|
3948
|
+
async runSyncAttempt(phaseTimer, syncContext, outcome) {
|
|
2801
3949
|
await this.gitService.pruneWorktrees();
|
|
2802
3950
|
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
2803
3951
|
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
2804
|
-
await
|
|
3952
|
+
await fs9.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2805
3953
|
const worktrees = await this.gitService.getWorktrees();
|
|
2806
3954
|
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
2807
3955
|
await this.cleanupOrphanedDirectories(worktrees);
|
|
2808
|
-
|
|
2809
|
-
|
|
3956
|
+
const syncPlan = createWorktreeSyncPlan(
|
|
3957
|
+
{
|
|
3958
|
+
remoteBranches,
|
|
3959
|
+
defaultBranch,
|
|
3960
|
+
existingWorktrees: worktrees,
|
|
3961
|
+
worktreeDir: this.config.worktreeDir
|
|
3962
|
+
},
|
|
3963
|
+
{
|
|
3964
|
+
pathResolution: this.pathResolution,
|
|
3965
|
+
updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
|
|
3966
|
+
sparseCheckout: this.config.sparseCheckout
|
|
3967
|
+
}
|
|
3968
|
+
);
|
|
3969
|
+
await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
|
|
3970
|
+
await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
|
|
2810
3971
|
if (this.config.updateExistingWorktrees !== false) {
|
|
2811
|
-
await this.updateExistingWorktreesWithTiming(
|
|
3972
|
+
await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
|
|
2812
3973
|
}
|
|
2813
3974
|
if (this.config.sparseCheckout) {
|
|
2814
|
-
await this.reapplySparseCheckout(
|
|
3975
|
+
await this.reapplySparseCheckout(syncPlan.sparse, outcome);
|
|
2815
3976
|
}
|
|
2816
3977
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
2817
3978
|
}
|
|
2818
|
-
async reapplySparseCheckout(
|
|
3979
|
+
async reapplySparseCheckout(actions, outcome) {
|
|
2819
3980
|
const sparseConfig = this.config.sparseCheckout;
|
|
2820
3981
|
if (!sparseConfig) return;
|
|
2821
3982
|
this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
|
|
@@ -2823,32 +3984,44 @@ var WorktreeSyncService = class {
|
|
|
2823
3984
|
const desired = sparseService.buildPatterns(sparseConfig);
|
|
2824
3985
|
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
2825
3986
|
await Promise.all(
|
|
2826
|
-
|
|
2827
|
-
(
|
|
3987
|
+
actions.map(
|
|
3988
|
+
(action) => limit(async () => {
|
|
3989
|
+
if (action.kind !== "check-sparse") return;
|
|
2828
3990
|
try {
|
|
2829
3991
|
try {
|
|
2830
|
-
await
|
|
3992
|
+
await fs9.access(action.path);
|
|
2831
3993
|
} catch {
|
|
2832
3994
|
return;
|
|
2833
3995
|
}
|
|
2834
|
-
const current = await sparseService.readCurrent(
|
|
3996
|
+
const current = await sparseService.readCurrent(action.path);
|
|
2835
3997
|
if (current !== null && sparseService.patternsEqual(current, desired)) return;
|
|
2836
3998
|
if (sparseService.isNarrowing(current, desired)) {
|
|
2837
|
-
const status = await this.gitService.getFullWorktreeStatus(
|
|
3999
|
+
const status = await this.gitService.getFullWorktreeStatus(action.path, false);
|
|
2838
4000
|
if (!status.canRemove) {
|
|
2839
4001
|
this.logger.warn(
|
|
2840
|
-
` - Skipping sparse-checkout narrowing for '${
|
|
4002
|
+
` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
|
|
2841
4003
|
);
|
|
4004
|
+
outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
|
|
4005
|
+
branch: action.branch,
|
|
4006
|
+
path: action.path,
|
|
4007
|
+
message: status.reasons.join(", ")
|
|
4008
|
+
});
|
|
2842
4009
|
return;
|
|
2843
4010
|
}
|
|
2844
4011
|
}
|
|
2845
|
-
await sparseService.applyToWorktree(
|
|
2846
|
-
await this.gitService.checkoutHead(
|
|
2847
|
-
this.logger.info(` - \u2705 Sparse-checkout updated for '${
|
|
4012
|
+
await sparseService.applyToWorktree(action.path, sparseConfig);
|
|
4013
|
+
await this.gitService.checkoutHead(action.path);
|
|
4014
|
+
this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
|
|
4015
|
+
outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
|
|
2848
4016
|
} catch (error) {
|
|
2849
4017
|
this.logger.warn(
|
|
2850
|
-
` - \u26A0\uFE0F Failed to update sparse-checkout for '${
|
|
4018
|
+
` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
|
|
2851
4019
|
);
|
|
4020
|
+
outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
|
|
4021
|
+
reason: "sparse_checkout_failed",
|
|
4022
|
+
branch: action.branch,
|
|
4023
|
+
path: action.path
|
|
4024
|
+
});
|
|
2852
4025
|
}
|
|
2853
4026
|
})
|
|
2854
4027
|
)
|
|
@@ -2857,7 +4030,7 @@ var WorktreeSyncService = class {
|
|
|
2857
4030
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
2858
4031
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
2859
4032
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
2860
|
-
this.
|
|
4033
|
+
this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
|
|
2861
4034
|
try {
|
|
2862
4035
|
await this.gitService.fetchAll();
|
|
2863
4036
|
} catch (fetchError) {
|
|
@@ -2902,7 +4075,7 @@ var WorktreeSyncService = class {
|
|
|
2902
4075
|
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
2903
4076
|
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
2904
4077
|
this.logger.info(
|
|
2905
|
-
`After filtering by age (${
|
|
4078
|
+
`After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
2906
4079
|
);
|
|
2907
4080
|
if (filteredByName.length > remoteBranches.length) {
|
|
2908
4081
|
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
@@ -2921,45 +4094,38 @@ var WorktreeSyncService = class {
|
|
|
2921
4094
|
}
|
|
2922
4095
|
async finalizeSyncAttempt(phaseTimer) {
|
|
2923
4096
|
phaseTimer.startPhase("Phase 5: Cleanup");
|
|
2924
|
-
this.
|
|
4097
|
+
this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
|
|
2925
4098
|
await this.gitService.pruneWorktrees();
|
|
2926
4099
|
this.logger.info("Step 5: Pruned worktree metadata.");
|
|
2927
4100
|
phaseTimer.endPhase();
|
|
2928
4101
|
}
|
|
2929
|
-
async createNewWorktreesWithTiming(
|
|
4102
|
+
async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
|
|
2930
4103
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
2931
4104
|
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);
|
|
4105
|
+
this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
|
|
4106
|
+
await this.createNewWorktrees(syncPlan.create, outcome);
|
|
4107
|
+
phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
|
|
2937
4108
|
phaseTimer.endPhase();
|
|
2938
4109
|
}
|
|
2939
|
-
async createNewWorktrees(
|
|
2940
|
-
|
|
2941
|
-
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
2942
|
-
if (newBranches.length === 0) {
|
|
4110
|
+
async createNewWorktrees(actions, outcome) {
|
|
4111
|
+
if (actions.length === 0) {
|
|
2943
4112
|
this.logger.info("Step 2: No new branches to create worktrees for.");
|
|
2944
4113
|
return;
|
|
2945
4114
|
}
|
|
2946
|
-
const reservedPaths = /* @__PURE__ */ new Map();
|
|
2947
|
-
for (const w of worktrees) {
|
|
2948
|
-
reservedPaths.set(path8.resolve(w.path), w.branch);
|
|
2949
|
-
}
|
|
2950
4115
|
const plan = [];
|
|
2951
|
-
for (const
|
|
2952
|
-
|
|
2953
|
-
const resolved = path8.resolve(worktreePath);
|
|
2954
|
-
const conflict = reservedPaths.get(resolved);
|
|
2955
|
-
if (conflict && conflict !== branchName) {
|
|
4116
|
+
for (const action of actions) {
|
|
4117
|
+
if (action.kind === "skip-create") {
|
|
2956
4118
|
this.logger.error(
|
|
2957
|
-
` \u274C Skipping '${
|
|
4119
|
+
` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
|
|
2958
4120
|
);
|
|
4121
|
+
outcome.recordSkipped("branch", "path_collision", {
|
|
4122
|
+
branch: action.branch,
|
|
4123
|
+
path: action.path,
|
|
4124
|
+
message: `Path collides with existing branch '${action.conflictingBranch}'`
|
|
4125
|
+
});
|
|
2959
4126
|
continue;
|
|
2960
4127
|
}
|
|
2961
|
-
|
|
2962
|
-
plan.push({ branchName, worktreePath });
|
|
4128
|
+
plan.push({ branchName: action.branch, worktreePath: action.path });
|
|
2963
4129
|
}
|
|
2964
4130
|
this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
|
|
2965
4131
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
@@ -2970,8 +4136,14 @@ var WorktreeSyncService = class {
|
|
|
2970
4136
|
try {
|
|
2971
4137
|
await this.gitService.addWorktree(branchName, worktreePath);
|
|
2972
4138
|
this.logger.info(` \u2705 Created worktree for '${branchName}'`);
|
|
4139
|
+
outcome.recordCreated(branchName, worktreePath);
|
|
2973
4140
|
} catch (error) {
|
|
2974
4141
|
this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
|
|
4142
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
4143
|
+
reason: "create_failed",
|
|
4144
|
+
branch: branchName,
|
|
4145
|
+
path: worktreePath
|
|
4146
|
+
});
|
|
2975
4147
|
throw error;
|
|
2976
4148
|
}
|
|
2977
4149
|
})
|
|
@@ -2980,23 +4152,21 @@ var WorktreeSyncService = class {
|
|
|
2980
4152
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
2981
4153
|
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
2982
4154
|
}
|
|
2983
|
-
async pruneOldWorktreesWithTiming(
|
|
4155
|
+
async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
2984
4156
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2985
4157
|
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
2986
|
-
this.
|
|
2987
|
-
await this.pruneOldWorktrees(
|
|
2988
|
-
|
|
2989
|
-
phaseTimer.setPhaseCount("Phase 3: Prune", deletedWorktrees.length);
|
|
4158
|
+
this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
|
|
4159
|
+
await this.pruneOldWorktrees(actions, outcome);
|
|
4160
|
+
phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
|
|
2990
4161
|
phaseTimer.endPhase();
|
|
2991
4162
|
}
|
|
2992
|
-
async pruneOldWorktrees(
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
this.logger.info(`Step 3: Checking ${deletedWorktrees.length} stale worktrees to prune...`);
|
|
4163
|
+
async pruneOldWorktrees(actions, outcome) {
|
|
4164
|
+
if (actions.length > 0) {
|
|
4165
|
+
this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
|
|
2996
4166
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2997
4167
|
const limit = pLimit(maxConcurrent);
|
|
2998
4168
|
const statusResults = await Promise.allSettled(
|
|
2999
|
-
|
|
4169
|
+
actions.map(
|
|
3000
4170
|
({ branch: branchName, path: worktreePath }) => limit(async () => {
|
|
3001
4171
|
const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
|
|
3002
4172
|
return { branchName, worktreePath, status };
|
|
@@ -3019,6 +4189,10 @@ var WorktreeSyncService = class {
|
|
|
3019
4189
|
const branchName = result.reason?.branchName ?? "unknown";
|
|
3020
4190
|
this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
|
|
3021
4191
|
this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
|
|
4192
|
+
outcome.recordSkipped("worktree", "prune_status_check_failed", {
|
|
4193
|
+
branch: branchName,
|
|
4194
|
+
message: getErrorMessage(result.reason)
|
|
4195
|
+
});
|
|
3022
4196
|
}
|
|
3023
4197
|
}
|
|
3024
4198
|
if (toRemove.length > 0) {
|
|
@@ -3034,12 +4208,23 @@ var WorktreeSyncService = class {
|
|
|
3034
4208
|
this.logger.warn(
|
|
3035
4209
|
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
3036
4210
|
);
|
|
4211
|
+
outcome.recordSkipped("worktree", "prune_status_changed", {
|
|
4212
|
+
branch: branchName,
|
|
4213
|
+
path: worktreePath,
|
|
4214
|
+
message: recheck.reasons.join(", ")
|
|
4215
|
+
});
|
|
3037
4216
|
return;
|
|
3038
4217
|
}
|
|
3039
4218
|
await this.gitService.removeWorktree(worktreePath);
|
|
3040
4219
|
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
4220
|
+
outcome.recordRemoved(branchName, worktreePath);
|
|
3041
4221
|
} catch (error) {
|
|
3042
4222
|
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
4223
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
4224
|
+
reason: "remove_failed",
|
|
4225
|
+
branch: branchName,
|
|
4226
|
+
path: worktreePath
|
|
4227
|
+
});
|
|
3043
4228
|
throw error;
|
|
3044
4229
|
}
|
|
3045
4230
|
})
|
|
@@ -3052,6 +4237,11 @@ var WorktreeSyncService = class {
|
|
|
3052
4237
|
this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
|
|
3053
4238
|
}
|
|
3054
4239
|
for (const { branchName, worktreePath, status } of toSkip) {
|
|
4240
|
+
outcome.recordSkipped("worktree", "unsafe_to_remove", {
|
|
4241
|
+
branch: branchName,
|
|
4242
|
+
path: worktreePath,
|
|
4243
|
+
message: status.reasons.join(", ")
|
|
4244
|
+
});
|
|
3055
4245
|
if (status.upstreamGone && status.hasUnpushedCommits) {
|
|
3056
4246
|
this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
|
|
3057
4247
|
this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
|
|
@@ -3144,53 +4334,52 @@ var WorktreeSyncService = class {
|
|
|
3144
4334
|
this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
|
|
3145
4335
|
}
|
|
3146
4336
|
}
|
|
3147
|
-
async updateExistingWorktreesWithTiming(
|
|
4337
|
+
async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
3148
4338
|
const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
3149
4339
|
phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
|
|
3150
|
-
this.
|
|
3151
|
-
await this.updateExistingWorktrees(
|
|
3152
|
-
|
|
3153
|
-
phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
|
|
4340
|
+
this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
|
|
4341
|
+
await this.updateExistingWorktrees(actions, outcome);
|
|
4342
|
+
phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
|
|
3154
4343
|
phaseTimer.endPhase();
|
|
3155
4344
|
}
|
|
3156
|
-
async updateExistingWorktrees(
|
|
4345
|
+
async updateExistingWorktrees(actions, outcome) {
|
|
3157
4346
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
3158
|
-
const divergedDir =
|
|
4347
|
+
const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3159
4348
|
try {
|
|
3160
|
-
const diverged = await
|
|
4349
|
+
const diverged = await fs9.readdir(divergedDir);
|
|
3161
4350
|
if (diverged.length > 0) {
|
|
3162
4351
|
this.logger.info(
|
|
3163
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
4352
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
|
|
3164
4353
|
);
|
|
3165
4354
|
}
|
|
3166
4355
|
} catch {
|
|
3167
4356
|
}
|
|
3168
|
-
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
3169
4357
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
3170
4358
|
const limit = pLimit(maxConcurrent);
|
|
3171
4359
|
const checkResults = await Promise.allSettled(
|
|
3172
|
-
|
|
3173
|
-
(
|
|
4360
|
+
actions.map(
|
|
4361
|
+
(action) => limit(async () => {
|
|
4362
|
+
const worktree = { path: action.path, branch: action.branch };
|
|
3174
4363
|
try {
|
|
3175
|
-
await
|
|
4364
|
+
await fs9.access(worktree.path);
|
|
3176
4365
|
} catch {
|
|
3177
|
-
return
|
|
4366
|
+
return { action: "skip", worktree, reason: "missing_worktree_path" };
|
|
3178
4367
|
}
|
|
3179
4368
|
const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
|
|
3180
|
-
if (hasOp) return
|
|
4369
|
+
if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
|
|
3181
4370
|
const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
|
|
3182
|
-
if (!isClean) return
|
|
4371
|
+
if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
|
|
3183
4372
|
const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
|
|
3184
4373
|
if (!canFastForward) {
|
|
3185
4374
|
const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
|
|
3186
4375
|
if (isAhead) {
|
|
3187
4376
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
|
|
3188
|
-
return
|
|
4377
|
+
return { action: "skip", worktree, reason: "local_ahead" };
|
|
3189
4378
|
}
|
|
3190
4379
|
return { action: "diverged", worktree };
|
|
3191
4380
|
}
|
|
3192
4381
|
const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
|
|
3193
|
-
if (!isBehind) return
|
|
4382
|
+
if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
|
|
3194
4383
|
const sparseCfg = this.config.sparseCheckout;
|
|
3195
4384
|
if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
|
|
3196
4385
|
const sparseService = this.gitService.getSparseCheckoutService();
|
|
@@ -3202,7 +4391,7 @@ var WorktreeSyncService = class {
|
|
|
3202
4391
|
);
|
|
3203
4392
|
if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
|
|
3204
4393
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
|
|
3205
|
-
return
|
|
4394
|
+
return { action: "skip", worktree, reason: "outside_sparse_checkout" };
|
|
3206
4395
|
}
|
|
3207
4396
|
}
|
|
3208
4397
|
}
|
|
@@ -3214,13 +4403,25 @@ var WorktreeSyncService = class {
|
|
|
3214
4403
|
const divergedWorktrees = [];
|
|
3215
4404
|
for (const result of checkResults) {
|
|
3216
4405
|
if (result.status === "fulfilled" && result.value) {
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
4406
|
+
switch (result.value.action) {
|
|
4407
|
+
case "update":
|
|
4408
|
+
worktreesToUpdate.push(result.value.worktree);
|
|
4409
|
+
break;
|
|
4410
|
+
case "diverged":
|
|
4411
|
+
divergedWorktrees.push(result.value.worktree);
|
|
4412
|
+
break;
|
|
4413
|
+
case "noop":
|
|
4414
|
+
outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
|
|
4415
|
+
break;
|
|
4416
|
+
case "skip":
|
|
4417
|
+
outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
|
|
4418
|
+
break;
|
|
3221
4419
|
}
|
|
3222
4420
|
} else if (result.status === "rejected") {
|
|
3223
4421
|
this.logger.error(` - Error checking worktree:`, result.reason);
|
|
4422
|
+
outcome.recordSkipped("worktree", "update_check_failed", {
|
|
4423
|
+
message: getErrorMessage(result.reason)
|
|
4424
|
+
});
|
|
3224
4425
|
}
|
|
3225
4426
|
}
|
|
3226
4427
|
const updateLimit = pLimit(
|
|
@@ -3234,6 +4435,7 @@ var WorktreeSyncService = class {
|
|
|
3234
4435
|
this.logger.info(` - Updating worktree '${worktree.branch}'...`);
|
|
3235
4436
|
await this.gitService.updateWorktree(worktree.path);
|
|
3236
4437
|
this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
|
|
4438
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
|
|
3237
4439
|
} catch (error) {
|
|
3238
4440
|
const errorMessage = getErrorMessage(error);
|
|
3239
4441
|
if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
|
|
@@ -3241,13 +4443,23 @@ var WorktreeSyncService = class {
|
|
|
3241
4443
|
` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
|
|
3242
4444
|
);
|
|
3243
4445
|
try {
|
|
3244
|
-
await this.handleDivergedBranch(worktree);
|
|
4446
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
3245
4447
|
} catch (divergedError) {
|
|
3246
4448
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
|
|
4449
|
+
outcome.recordFailed("worktree", getErrorMessage(divergedError), {
|
|
4450
|
+
reason: "diverged_recovery_failed",
|
|
4451
|
+
branch: worktree.branch,
|
|
4452
|
+
path: worktree.path
|
|
4453
|
+
});
|
|
3247
4454
|
throw divergedError;
|
|
3248
4455
|
}
|
|
3249
4456
|
} else {
|
|
3250
4457
|
this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
|
|
4458
|
+
outcome.recordFailed("worktree", errorMessage, {
|
|
4459
|
+
reason: "update_failed",
|
|
4460
|
+
branch: worktree.branch,
|
|
4461
|
+
path: worktree.path
|
|
4462
|
+
});
|
|
3251
4463
|
throw error;
|
|
3252
4464
|
}
|
|
3253
4465
|
}
|
|
@@ -3259,9 +4471,14 @@ var WorktreeSyncService = class {
|
|
|
3259
4471
|
mutationTasks.push(
|
|
3260
4472
|
updateLimit(async () => {
|
|
3261
4473
|
try {
|
|
3262
|
-
await this.handleDivergedBranch(worktree);
|
|
4474
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
3263
4475
|
} catch (error) {
|
|
3264
4476
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
|
|
4477
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
4478
|
+
reason: "diverged_recovery_failed",
|
|
4479
|
+
branch: worktree.branch,
|
|
4480
|
+
path: worktree.path
|
|
4481
|
+
});
|
|
3265
4482
|
throw error;
|
|
3266
4483
|
}
|
|
3267
4484
|
return { type: "diverged", branch: worktree.branch };
|
|
@@ -3284,13 +4501,13 @@ var WorktreeSyncService = class {
|
|
|
3284
4501
|
}
|
|
3285
4502
|
async cleanupOrphanedDirectories(worktrees) {
|
|
3286
4503
|
try {
|
|
3287
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
3288
|
-
const allDirs = await
|
|
4504
|
+
const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
|
|
4505
|
+
const allDirs = await fs9.readdir(this.config.worktreeDir);
|
|
3289
4506
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
3290
4507
|
const orphanedDirs = [];
|
|
3291
4508
|
for (const dir of regularDirs) {
|
|
3292
4509
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
3293
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
4510
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
|
|
3294
4511
|
});
|
|
3295
4512
|
if (!isPartOfWorktree) {
|
|
3296
4513
|
orphanedDirs.push(dir);
|
|
@@ -3299,11 +4516,11 @@ var WorktreeSyncService = class {
|
|
|
3299
4516
|
if (orphanedDirs.length > 0) {
|
|
3300
4517
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
3301
4518
|
for (const dir of orphanedDirs) {
|
|
3302
|
-
const dirPath =
|
|
4519
|
+
const dirPath = path13.join(this.config.worktreeDir, dir);
|
|
3303
4520
|
try {
|
|
3304
|
-
const stat4 = await
|
|
4521
|
+
const stat4 = await fs9.stat(dirPath);
|
|
3305
4522
|
if (stat4.isDirectory()) {
|
|
3306
|
-
await
|
|
4523
|
+
await fs9.rm(dirPath, { recursive: true, force: true });
|
|
3307
4524
|
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
3308
4525
|
}
|
|
3309
4526
|
} catch (error) {
|
|
@@ -3315,13 +4532,14 @@ var WorktreeSyncService = class {
|
|
|
3315
4532
|
this.logger.error("Error during orphaned directory cleanup:", error);
|
|
3316
4533
|
}
|
|
3317
4534
|
}
|
|
3318
|
-
async handleDivergedBranch(worktree) {
|
|
4535
|
+
async handleDivergedBranch(worktree, outcome) {
|
|
3319
4536
|
this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
|
|
3320
4537
|
const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
|
|
3321
4538
|
if (treesIdentical) {
|
|
3322
4539
|
this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
|
|
3323
4540
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
3324
4541
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
4542
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
|
|
3325
4543
|
} else {
|
|
3326
4544
|
const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
|
|
3327
4545
|
if (!hasLocalChanges) {
|
|
@@ -3330,10 +4548,12 @@ var WorktreeSyncService = class {
|
|
|
3330
4548
|
);
|
|
3331
4549
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
3332
4550
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
4551
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
|
|
3333
4552
|
} else {
|
|
3334
4553
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
3335
4554
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
3336
|
-
const relativePath =
|
|
4555
|
+
const relativePath = path13.relative(process.cwd(), divergedPath);
|
|
4556
|
+
outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
|
|
3337
4557
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
3338
4558
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
3339
4559
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -3357,19 +4577,19 @@ var WorktreeSyncService = class {
|
|
|
3357
4577
|
}
|
|
3358
4578
|
}
|
|
3359
4579
|
async divergeWorktree(worktreePath, branchName) {
|
|
3360
|
-
const divergedBaseDir =
|
|
4580
|
+
const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
3361
4581
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
3362
4582
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
3363
4583
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
3364
4584
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
3365
|
-
const divergedPath =
|
|
3366
|
-
await
|
|
4585
|
+
const divergedPath = path13.join(divergedBaseDir, divergedName);
|
|
4586
|
+
await fs9.mkdir(divergedBaseDir, { recursive: true });
|
|
3367
4587
|
try {
|
|
3368
|
-
await
|
|
4588
|
+
await fs9.rename(worktreePath, divergedPath);
|
|
3369
4589
|
} catch (err) {
|
|
3370
4590
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
3371
|
-
await
|
|
3372
|
-
await
|
|
4591
|
+
await fs9.cp(worktreePath, divergedPath, { recursive: true });
|
|
4592
|
+
await fs9.rm(worktreePath, { recursive: true, force: true });
|
|
3373
4593
|
} else {
|
|
3374
4594
|
throw err;
|
|
3375
4595
|
}
|
|
@@ -3388,14 +4608,197 @@ var WorktreeSyncService = class {
|
|
|
3388
4608
|
|
|
3389
4609
|
Original worktree location: ${worktreePath}`
|
|
3390
4610
|
};
|
|
3391
|
-
await
|
|
3392
|
-
|
|
4611
|
+
await fs9.writeFile(
|
|
4612
|
+
path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
3393
4613
|
JSON.stringify(metadata, null, 2)
|
|
3394
4614
|
);
|
|
3395
4615
|
return divergedPath;
|
|
3396
4616
|
}
|
|
3397
4617
|
};
|
|
3398
4618
|
|
|
4619
|
+
// src/services/worktree-sync.service.ts
|
|
4620
|
+
var WorktreeSyncService = class {
|
|
4621
|
+
constructor(config) {
|
|
4622
|
+
this.config = config;
|
|
4623
|
+
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
4624
|
+
this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
|
|
4625
|
+
this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
|
|
4626
|
+
this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
|
|
4627
|
+
this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
|
|
4628
|
+
config,
|
|
4629
|
+
this.gitService,
|
|
4630
|
+
this.logger,
|
|
4631
|
+
this.progressEmitter
|
|
4632
|
+
);
|
|
4633
|
+
if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
|
|
4634
|
+
this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
|
|
4635
|
+
progressEmitter: (event) => this.emitProgress(event),
|
|
4636
|
+
onSkip: (reason) => {
|
|
4637
|
+
this.skipsAccumulator.push(reason);
|
|
4638
|
+
}
|
|
4639
|
+
});
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
gitService;
|
|
4643
|
+
cloneSyncService = null;
|
|
4644
|
+
logger;
|
|
4645
|
+
syncInProgress = false;
|
|
4646
|
+
progressEmitter = new ProgressEmitter();
|
|
4647
|
+
repoOperationLock;
|
|
4648
|
+
retryPolicy;
|
|
4649
|
+
worktreeModeSyncRunner;
|
|
4650
|
+
skipsAccumulator = [];
|
|
4651
|
+
lastOutcome = null;
|
|
4652
|
+
getRecordedSkips() {
|
|
4653
|
+
return [...this.skipsAccumulator];
|
|
4654
|
+
}
|
|
4655
|
+
clearRecordedSkips() {
|
|
4656
|
+
this.skipsAccumulator = [];
|
|
4657
|
+
}
|
|
4658
|
+
clearPendingInitSkip() {
|
|
4659
|
+
this.cloneSyncService?.clearPendingInitSkip();
|
|
4660
|
+
}
|
|
4661
|
+
getLastOutcome() {
|
|
4662
|
+
return this.lastOutcome;
|
|
4663
|
+
}
|
|
4664
|
+
isCloneMode() {
|
|
4665
|
+
return this.cloneSyncService !== null;
|
|
4666
|
+
}
|
|
4667
|
+
async getWorktrees() {
|
|
4668
|
+
if (this.cloneSyncService) {
|
|
4669
|
+
return this.cloneSyncService.getWorktrees();
|
|
4670
|
+
}
|
|
4671
|
+
return this.gitService.getWorktrees();
|
|
4672
|
+
}
|
|
4673
|
+
async initialize() {
|
|
4674
|
+
if (this.isInitialized()) return;
|
|
4675
|
+
const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
|
|
4676
|
+
if (!result.started) {
|
|
4677
|
+
const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
|
|
4678
|
+
this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4681
|
+
async initializeUnlocked(outcome) {
|
|
4682
|
+
this.emitProgress({ phase: "initialize", message: "Initializing repository" });
|
|
4683
|
+
if (this.cloneSyncService) {
|
|
4684
|
+
await this.cloneSyncService.initialize(outcome);
|
|
4685
|
+
} else {
|
|
4686
|
+
await this.gitService.initialize();
|
|
4687
|
+
}
|
|
4688
|
+
this.emitProgress({ phase: "initialize", message: "Repository initialized" });
|
|
4689
|
+
}
|
|
4690
|
+
isInitialized() {
|
|
4691
|
+
if (this.cloneSyncService) {
|
|
4692
|
+
return this.cloneSyncService.isInitialized();
|
|
4693
|
+
}
|
|
4694
|
+
return this.gitService.isInitialized();
|
|
4695
|
+
}
|
|
4696
|
+
isSyncInProgress() {
|
|
4697
|
+
return this.syncInProgress;
|
|
4698
|
+
}
|
|
4699
|
+
getGitService() {
|
|
4700
|
+
return this.gitService;
|
|
4701
|
+
}
|
|
4702
|
+
updateLogger(logger) {
|
|
4703
|
+
this.logger = logger;
|
|
4704
|
+
this.gitService.updateLogger(logger);
|
|
4705
|
+
this.cloneSyncService?.updateLogger(logger);
|
|
4706
|
+
this.retryPolicy.updateLogger(logger);
|
|
4707
|
+
this.worktreeModeSyncRunner.updateLogger(logger);
|
|
4708
|
+
this.repoOperationLock.updateLogger(logger);
|
|
4709
|
+
}
|
|
4710
|
+
onProgress(listener) {
|
|
4711
|
+
return this.progressEmitter.onProgress(listener);
|
|
4712
|
+
}
|
|
4713
|
+
async runExclusiveRepoOperation(operation) {
|
|
4714
|
+
if (this.syncInProgress) {
|
|
4715
|
+
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
4716
|
+
return { started: false, reason: "in_progress" };
|
|
4717
|
+
}
|
|
4718
|
+
this.syncInProgress = true;
|
|
4719
|
+
let release;
|
|
4720
|
+
try {
|
|
4721
|
+
release = await this.repoOperationLock.acquire();
|
|
4722
|
+
} catch (error) {
|
|
4723
|
+
this.syncInProgress = false;
|
|
4724
|
+
throw error;
|
|
4725
|
+
}
|
|
4726
|
+
if (release === null) {
|
|
4727
|
+
this.syncInProgress = false;
|
|
4728
|
+
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
4729
|
+
return { started: false, reason: "locked" };
|
|
4730
|
+
}
|
|
4731
|
+
try {
|
|
4732
|
+
return { started: true, value: await operation() };
|
|
4733
|
+
} finally {
|
|
4734
|
+
try {
|
|
4735
|
+
await release();
|
|
4736
|
+
} catch (releaseError) {
|
|
4737
|
+
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
4738
|
+
}
|
|
4739
|
+
this.syncInProgress = false;
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
emitProgress(event) {
|
|
4743
|
+
this.progressEmitter.emit(event);
|
|
4744
|
+
}
|
|
4745
|
+
async sync() {
|
|
4746
|
+
const result = await this.runExclusiveRepoOperation(async () => {
|
|
4747
|
+
const totalTimer = new Timer();
|
|
4748
|
+
const phaseTimer = new PhaseTimer();
|
|
4749
|
+
const outcome = new SyncOutcomeAccumulator({
|
|
4750
|
+
mode: this.cloneSyncService ? "clone" : "worktree",
|
|
4751
|
+
repoName: this.config.name
|
|
4752
|
+
});
|
|
4753
|
+
const syncContext = this.retryPolicy.createContext();
|
|
4754
|
+
const retryOptions = this.retryPolicy.createOptions(syncContext);
|
|
4755
|
+
let durationMs;
|
|
4756
|
+
try {
|
|
4757
|
+
if (!this.isInitialized()) {
|
|
4758
|
+
await this.initializeUnlocked(outcome);
|
|
4759
|
+
}
|
|
4760
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
4761
|
+
const retryOutcomeBaseline = outcome.snapshot();
|
|
4762
|
+
const retryOptionsWithOutcomeReset = {
|
|
4763
|
+
...retryOptions,
|
|
4764
|
+
onRetry: (error, attempt, context) => {
|
|
4765
|
+
outcome.restore(retryOutcomeBaseline);
|
|
4766
|
+
retryOptions.onRetry?.(error, attempt, context);
|
|
4767
|
+
}
|
|
4768
|
+
};
|
|
4769
|
+
const cloneSync = this.cloneSyncService;
|
|
4770
|
+
if (cloneSync) {
|
|
4771
|
+
await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
|
|
4772
|
+
} else {
|
|
4773
|
+
await retry(
|
|
4774
|
+
() => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
|
|
4775
|
+
retryOptionsWithOutcomeReset
|
|
4776
|
+
);
|
|
4777
|
+
}
|
|
4778
|
+
} catch (error) {
|
|
4779
|
+
if (outcome.getCounts().failed === 0) {
|
|
4780
|
+
outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
|
|
4781
|
+
}
|
|
4782
|
+
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
4783
|
+
throw error;
|
|
4784
|
+
} finally {
|
|
4785
|
+
this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
|
|
4786
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
4787
|
+
`);
|
|
4788
|
+
durationMs = totalTimer.stop();
|
|
4789
|
+
this.lastOutcome = outcome.toOutcome(durationMs);
|
|
4790
|
+
if (this.config.debug) {
|
|
4791
|
+
const phaseResults = phaseTimer.getResults();
|
|
4792
|
+
const repoName = this.config.name;
|
|
4793
|
+
this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4796
|
+
return this.lastOutcome ?? outcome.toOutcome(durationMs);
|
|
4797
|
+
});
|
|
4798
|
+
return result.started ? { started: true, outcome: result.value } : result;
|
|
4799
|
+
}
|
|
4800
|
+
};
|
|
4801
|
+
|
|
3399
4802
|
// src/mcp/context.ts
|
|
3400
4803
|
var AUTO_DETECT_PREFIX = "__auto_detected__:";
|
|
3401
4804
|
var DISCOVERY_CACHE_TTL_MS = 5e3;
|
|
@@ -3442,11 +4845,18 @@ var RepositoryContext = class {
|
|
|
3442
4845
|
configPath = null;
|
|
3443
4846
|
configLoader = new ConfigLoaderService();
|
|
3444
4847
|
discoveryCache = /* @__PURE__ */ new Map();
|
|
4848
|
+
launchCwd;
|
|
4849
|
+
constructor(options = {}) {
|
|
4850
|
+
this.launchCwd = path14.resolve(options.launchCwd ?? process.cwd());
|
|
4851
|
+
}
|
|
4852
|
+
getLaunchCwd() {
|
|
4853
|
+
return this.launchCwd;
|
|
4854
|
+
}
|
|
3445
4855
|
async loadConfig(configPath, options = {}) {
|
|
3446
4856
|
const setDefaultCurrent = options.setDefaultCurrent ?? true;
|
|
3447
|
-
const absolutePath =
|
|
4857
|
+
const absolutePath = path14.resolve(configPath);
|
|
3448
4858
|
const configFile = await this.configLoader.loadConfigFile(absolutePath);
|
|
3449
|
-
const configDir =
|
|
4859
|
+
const configDir = path14.dirname(absolutePath);
|
|
3450
4860
|
const globalDefaults = configFile.defaults;
|
|
3451
4861
|
const resolvedAll = [];
|
|
3452
4862
|
for (const repo of configFile.repositories) {
|
|
@@ -3483,7 +4893,7 @@ var RepositoryContext = class {
|
|
|
3483
4893
|
return configFile.repositories;
|
|
3484
4894
|
}
|
|
3485
4895
|
async detectFromPath(dirPath) {
|
|
3486
|
-
const absolutePath =
|
|
4896
|
+
const absolutePath = path14.resolve(dirPath);
|
|
3487
4897
|
const cached = this.discoveryCache.get(absolutePath);
|
|
3488
4898
|
if (cached && await this.isCacheFresh(cached)) {
|
|
3489
4899
|
return cached.result;
|
|
@@ -3502,8 +4912,8 @@ var RepositoryContext = class {
|
|
|
3502
4912
|
const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
|
|
3503
4913
|
if (result.isWorktree && result.bareRepoPath && adminDir) {
|
|
3504
4914
|
const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
|
|
3505
|
-
safeMtimeMs(
|
|
3506
|
-
safeMtimeMs(
|
|
4915
|
+
safeMtimeMs(path14.join(adminDir, "HEAD")),
|
|
4916
|
+
safeMtimeMs(path14.join(result.bareRepoPath, "worktrees"))
|
|
3507
4917
|
]);
|
|
3508
4918
|
this.discoveryCache.set(absolutePath, {
|
|
3509
4919
|
result,
|
|
@@ -3534,12 +4944,16 @@ var RepositoryContext = class {
|
|
|
3534
4944
|
__discoveryCacheSizeForTest() {
|
|
3535
4945
|
return this.discoveryCache.size;
|
|
3536
4946
|
}
|
|
4947
|
+
/** @internal Test-only helper — exposes the internal selection state. */
|
|
4948
|
+
__getRepositorySelectionStateForTest() {
|
|
4949
|
+
return this.getRepositorySelectionState();
|
|
4950
|
+
}
|
|
3537
4951
|
async discoverSiblingRepositories(currentBareRepoPath) {
|
|
3538
4952
|
const currentBare = normalizePathForCompare(currentBareRepoPath);
|
|
3539
4953
|
const results = /* @__PURE__ */ new Map();
|
|
3540
4954
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
3541
4955
|
const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
|
|
3542
|
-
const bareRepoPath =
|
|
4956
|
+
const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
|
|
3543
4957
|
return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
|
|
3544
4958
|
}).filter((c) => c.foldedBare !== currentBare);
|
|
3545
4959
|
const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
|
|
@@ -3547,7 +4961,7 @@ var RepositoryContext = class {
|
|
|
3547
4961
|
const sibling = {
|
|
3548
4962
|
name: entry.name,
|
|
3549
4963
|
bareRepoPath,
|
|
3550
|
-
worktreeDir:
|
|
4964
|
+
worktreeDir: path14.resolve(entry.config.worktreeDir),
|
|
3551
4965
|
repoUrl: entry.config.repoUrl,
|
|
3552
4966
|
present: configPresence[i],
|
|
3553
4967
|
configMatched: true
|
|
@@ -3557,24 +4971,24 @@ var RepositoryContext = class {
|
|
|
3557
4971
|
}
|
|
3558
4972
|
results.set(foldedBare, sibling);
|
|
3559
4973
|
});
|
|
3560
|
-
const repoDir =
|
|
3561
|
-
const workspaceRoot =
|
|
4974
|
+
const repoDir = path14.dirname(currentBareRepoPath);
|
|
4975
|
+
const workspaceRoot = path14.dirname(repoDir);
|
|
3562
4976
|
if (workspaceRoot === repoDir) {
|
|
3563
4977
|
return Array.from(results.values()).sort(byName);
|
|
3564
4978
|
}
|
|
3565
4979
|
let entries;
|
|
3566
4980
|
try {
|
|
3567
|
-
entries = await
|
|
4981
|
+
entries = await fs10.readdir(workspaceRoot);
|
|
3568
4982
|
} catch {
|
|
3569
4983
|
return Array.from(results.values()).sort(byName);
|
|
3570
4984
|
}
|
|
3571
4985
|
const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
|
|
3572
4986
|
await Promise.all(
|
|
3573
4987
|
entries.map(async (entry) => {
|
|
3574
|
-
const candidate =
|
|
3575
|
-
const bareCandidate =
|
|
4988
|
+
const candidate = path14.join(workspaceRoot, entry);
|
|
4989
|
+
const bareCandidate = path14.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
|
|
3576
4990
|
if (!await isDirectory(bareCandidate)) return;
|
|
3577
|
-
const resolvedBare =
|
|
4991
|
+
const resolvedBare = path14.resolve(bareCandidate);
|
|
3578
4992
|
const foldedBare = normalizePathForCompare(resolvedBare);
|
|
3579
4993
|
if (foldedBare === currentBare || results.has(foldedBare)) return;
|
|
3580
4994
|
const matchedName = configBares.get(foldedBare);
|
|
@@ -3600,8 +5014,8 @@ var RepositoryContext = class {
|
|
|
3600
5014
|
if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
|
|
3601
5015
|
if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
|
|
3602
5016
|
const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
|
|
3603
|
-
safeMtimeMs(
|
|
3604
|
-
safeMtimeMs(
|
|
5017
|
+
safeMtimeMs(path14.join(cached.worktreeAdminDir, "HEAD")),
|
|
5018
|
+
safeMtimeMs(path14.join(cached.result.bareRepoPath, "worktrees"))
|
|
3605
5019
|
]);
|
|
3606
5020
|
return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
|
|
3607
5021
|
}
|
|
@@ -3634,6 +5048,13 @@ var RepositoryContext = class {
|
|
|
3634
5048
|
return unsupported("No .git file found in path or any parent directory");
|
|
3635
5049
|
}
|
|
3636
5050
|
if (located.kind === "regular-git-dir") {
|
|
5051
|
+
const cloneEntry = this.findConfiguredCloneEntry(worktreeRoot);
|
|
5052
|
+
if (cloneEntry) {
|
|
5053
|
+
return {
|
|
5054
|
+
result: await this.buildCloneModeContext(cloneEntry, worktreeRoot, notes),
|
|
5055
|
+
adminDir: null
|
|
5056
|
+
};
|
|
5057
|
+
}
|
|
3637
5058
|
return unsupported("Directory has .git folder (regular repo, not a sync-worktrees worktree)");
|
|
3638
5059
|
}
|
|
3639
5060
|
const gitFileContent = located.gitFileContent;
|
|
@@ -3642,18 +5063,18 @@ var RepositoryContext = class {
|
|
|
3642
5063
|
return unsupported("Invalid .git file format (missing gitdir line)");
|
|
3643
5064
|
}
|
|
3644
5065
|
const gitdir = gitdirMatch[1].trim();
|
|
3645
|
-
const resolvedGitdir =
|
|
5066
|
+
const resolvedGitdir = path14.isAbsolute(gitdir) ? gitdir : path14.resolve(worktreeRoot, gitdir);
|
|
3646
5067
|
const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
|
|
3647
5068
|
if (!worktreesMatch) {
|
|
3648
5069
|
return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
|
|
3649
5070
|
}
|
|
3650
|
-
const bareRepoPath =
|
|
3651
|
-
const adminDir =
|
|
5071
|
+
const bareRepoPath = path14.resolve(worktreesMatch[1]);
|
|
5072
|
+
const adminDir = path14.resolve(resolvedGitdir);
|
|
3652
5073
|
let repoUrl = null;
|
|
3653
5074
|
let worktrees = [];
|
|
3654
5075
|
let currentBranch = null;
|
|
3655
5076
|
try {
|
|
3656
|
-
const bareGit =
|
|
5077
|
+
const bareGit = simpleGit6(bareRepoPath);
|
|
3657
5078
|
try {
|
|
3658
5079
|
const remoteResult = await bareGit.remote(["get-url", "origin"]);
|
|
3659
5080
|
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
@@ -3689,7 +5110,7 @@ var RepositoryContext = class {
|
|
|
3689
5110
|
adminDir
|
|
3690
5111
|
};
|
|
3691
5112
|
}
|
|
3692
|
-
const worktreeDir =
|
|
5113
|
+
const worktreeDir = path14.dirname(worktreeRoot);
|
|
3693
5114
|
const noUrlReason = "no remote origin URL detected";
|
|
3694
5115
|
const capabilities = {
|
|
3695
5116
|
listWorktrees: { available: true },
|
|
@@ -3725,7 +5146,7 @@ var RepositoryContext = class {
|
|
|
3725
5146
|
cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
3726
5147
|
runOnce: true
|
|
3727
5148
|
};
|
|
3728
|
-
const detectedKey = `${AUTO_DETECT_PREFIX}${
|
|
5149
|
+
const detectedKey = `${AUTO_DETECT_PREFIX}${path14.basename(bareRepoPath)}@${bareRepoPath}`;
|
|
3729
5150
|
if (!this.repos.has(detectedKey)) {
|
|
3730
5151
|
this.repos.set(detectedKey, {
|
|
3731
5152
|
name: detectedKey,
|
|
@@ -3766,13 +5187,19 @@ var RepositoryContext = class {
|
|
|
3766
5187
|
return { result: discovered, adminDir };
|
|
3767
5188
|
}
|
|
3768
5189
|
async getService(repoName) {
|
|
5190
|
+
if (repoName) {
|
|
5191
|
+
const explicit = this.selectExplicitRepository(repoName);
|
|
5192
|
+
if (explicit.kind !== "selected") {
|
|
5193
|
+
throw new Error(this.buildRepoNotFoundError(repoName));
|
|
5194
|
+
}
|
|
5195
|
+
}
|
|
3769
5196
|
const name = repoName ?? this.currentRepo;
|
|
3770
5197
|
if (!name) {
|
|
3771
|
-
throw new Error(
|
|
5198
|
+
throw new Error(this.buildNoRepoSelectedError());
|
|
3772
5199
|
}
|
|
3773
5200
|
const entry = this.repos.get(name);
|
|
3774
5201
|
if (!entry) {
|
|
3775
|
-
throw new Error(
|
|
5202
|
+
throw new Error(this.buildRepoNotFoundError(name));
|
|
3776
5203
|
}
|
|
3777
5204
|
if (!entry.service) {
|
|
3778
5205
|
const logger = createStderrLogger(entry.name);
|
|
@@ -3783,6 +5210,93 @@ var RepositoryContext = class {
|
|
|
3783
5210
|
}
|
|
3784
5211
|
return entry.service;
|
|
3785
5212
|
}
|
|
5213
|
+
getRepositorySelectionState() {
|
|
5214
|
+
const configured = this.getConfiguredRepositoryNames();
|
|
5215
|
+
const detected = this.getDetectedRepositoryNames();
|
|
5216
|
+
return {
|
|
5217
|
+
currentRepo: this.currentRepo,
|
|
5218
|
+
configured,
|
|
5219
|
+
detected,
|
|
5220
|
+
defaultDecision: this.selectDefaultRepository(configured, detected)
|
|
5221
|
+
};
|
|
5222
|
+
}
|
|
5223
|
+
selectExplicitRepository(repoName) {
|
|
5224
|
+
if (this.repos.has(repoName)) {
|
|
5225
|
+
return { kind: "selected", repoName, source: "explicit" };
|
|
5226
|
+
}
|
|
5227
|
+
return {
|
|
5228
|
+
kind: "missing",
|
|
5229
|
+
configured: this.getConfiguredRepositoryNames(),
|
|
5230
|
+
detected: this.getDetectedRepositoryNames(),
|
|
5231
|
+
reason: `Repository '${repoName}' not found`
|
|
5232
|
+
};
|
|
5233
|
+
}
|
|
5234
|
+
selectDefaultRepository(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
|
|
5235
|
+
if (this.currentRepo !== null) {
|
|
5236
|
+
return { kind: "selected", repoName: this.currentRepo, source: "current" };
|
|
5237
|
+
}
|
|
5238
|
+
if (this.canAutoSelectSingleConfig(configured, detected)) {
|
|
5239
|
+
return { kind: "selected", repoName: configured[0], source: "single-config" };
|
|
5240
|
+
}
|
|
5241
|
+
if (configured.length === 0 && detected.length === 0) {
|
|
5242
|
+
return {
|
|
5243
|
+
kind: "missing",
|
|
5244
|
+
configured,
|
|
5245
|
+
detected,
|
|
5246
|
+
reason: "no configured or detected repositories are registered"
|
|
5247
|
+
};
|
|
5248
|
+
}
|
|
5249
|
+
return {
|
|
5250
|
+
kind: "ambiguous",
|
|
5251
|
+
configured,
|
|
5252
|
+
detected,
|
|
5253
|
+
reason: "repository selection is ambiguous without currentRepo or explicit repoName"
|
|
5254
|
+
};
|
|
5255
|
+
}
|
|
5256
|
+
canAutoSelectSingleConfig(configured = this.getConfiguredRepositoryNames(), detected = this.getDetectedRepositoryNames()) {
|
|
5257
|
+
return this.currentRepo === null && configured.length === 1 && detected.length === 0;
|
|
5258
|
+
}
|
|
5259
|
+
getDetectedRepositoryNames() {
|
|
5260
|
+
return Array.from(this.repos.values()).filter((entry) => entry.source === "detected").map((entry) => entry.name);
|
|
5261
|
+
}
|
|
5262
|
+
formatDetectedRepositoryNames() {
|
|
5263
|
+
return Array.from(this.repos.values()).filter((e) => e.source === "detected").map((e) => {
|
|
5264
|
+
const location = e.discovered?.currentWorktreePath ?? e.config.bareRepoDir ?? e.config.worktreeDir;
|
|
5265
|
+
return location ? `${e.name} (${location})` : e.name;
|
|
5266
|
+
});
|
|
5267
|
+
}
|
|
5268
|
+
formatKnownRepositoryNames(names) {
|
|
5269
|
+
return names.length === 0 ? "[]" : `[${names.join(", ")}]`;
|
|
5270
|
+
}
|
|
5271
|
+
buildNoRepoSelectedError() {
|
|
5272
|
+
const selection = this.getRepositorySelectionState();
|
|
5273
|
+
const detected = this.formatDetectedRepositoryNames();
|
|
5274
|
+
const parts = [
|
|
5275
|
+
"No repository specified and no current repository set.",
|
|
5276
|
+
`launchCwd=${this.launchCwd}`,
|
|
5277
|
+
`configPath=${this.configPath ?? "none"}`,
|
|
5278
|
+
`loadedRepos=${this.repos.size} (config: ${selection.configured.length}, detected: ${selection.detected.length})`
|
|
5279
|
+
];
|
|
5280
|
+
if (detected.length > 0) {
|
|
5281
|
+
parts.push(`Detected repos: ${this.formatKnownRepositoryNames(detected)}.`);
|
|
5282
|
+
}
|
|
5283
|
+
if (selection.configured.length > 0) {
|
|
5284
|
+
parts.push(`Configured repos: ${this.formatKnownRepositoryNames(selection.configured)}.`);
|
|
5285
|
+
}
|
|
5286
|
+
if (selection.configured.length > 0 || detected.length > 0) {
|
|
5287
|
+
parts.push("Recovery: call set_current_repository with one of the repo names above or pass repoName explicitly.");
|
|
5288
|
+
} else {
|
|
5289
|
+
parts.push(
|
|
5290
|
+
"Recovery: call detect_context {path: <workspace>}, load_config {configPath: <file>}, set SYNC_WORKTREES_CONFIG env var, or pass repoName explicitly."
|
|
5291
|
+
);
|
|
5292
|
+
}
|
|
5293
|
+
return parts.join(" ");
|
|
5294
|
+
}
|
|
5295
|
+
buildRepoNotFoundError(name) {
|
|
5296
|
+
const known = Array.from(this.repos.keys());
|
|
5297
|
+
const knownStr = this.formatKnownRepositoryNames(known);
|
|
5298
|
+
return `Repository '${name}' not found. Known repos: ${knownStr}. Run load_config or detect_context to register it.`;
|
|
5299
|
+
}
|
|
3786
5300
|
getEntry(repoName) {
|
|
3787
5301
|
const name = repoName ?? this.currentRepo;
|
|
3788
5302
|
if (!name) return null;
|
|
@@ -3812,6 +5326,58 @@ var RepositoryContext = class {
|
|
|
3812
5326
|
getConfiguredRepositoryNames() {
|
|
3813
5327
|
return Array.from(this.repos.values()).filter((entry) => entry.source === "config").map((entry) => entry.name);
|
|
3814
5328
|
}
|
|
5329
|
+
async getConfiguredRepositorySummaries(options = {}) {
|
|
5330
|
+
const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
|
|
5331
|
+
const currentRepo = this.currentRepo;
|
|
5332
|
+
const buildLean = (entry) => {
|
|
5333
|
+
const mode = resolveMode(entry.config);
|
|
5334
|
+
const isCurrent = entry.name === currentRepo;
|
|
5335
|
+
if (mode === REPOSITORY_MODES.CLONE) {
|
|
5336
|
+
return { name: entry.name, mode: "clone", checkoutPath: path14.resolve(entry.config.worktreeDir), isCurrent };
|
|
5337
|
+
}
|
|
5338
|
+
return { name: entry.name, mode: "worktree", worktreeDir: path14.resolve(entry.config.worktreeDir), isCurrent };
|
|
5339
|
+
};
|
|
5340
|
+
if (!options.detailed) {
|
|
5341
|
+
return entries.map(buildLean);
|
|
5342
|
+
}
|
|
5343
|
+
const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
5344
|
+
return Promise.all(
|
|
5345
|
+
entries.map(
|
|
5346
|
+
(entry) => limit(async () => {
|
|
5347
|
+
const summary = buildLean(entry);
|
|
5348
|
+
summary.repoUrl = entry.config.repoUrl;
|
|
5349
|
+
if (entry.config.branch) summary.branch = entry.config.branch;
|
|
5350
|
+
if (entry.config.sparseCheckout) {
|
|
5351
|
+
const sc = entry.config.sparseCheckout;
|
|
5352
|
+
summary.sparseCheckout = {
|
|
5353
|
+
...sc,
|
|
5354
|
+
include: [...sc.include],
|
|
5355
|
+
...sc.exclude ? { exclude: [...sc.exclude] } : {}
|
|
5356
|
+
};
|
|
5357
|
+
}
|
|
5358
|
+
if (summary.mode === "clone") {
|
|
5359
|
+
summary.localReady = await isGitCheckout(summary.checkoutPath);
|
|
5360
|
+
return summary;
|
|
5361
|
+
}
|
|
5362
|
+
if (entry.config.bareRepoDir) {
|
|
5363
|
+
summary.bareRepoDir = path14.resolve(entry.config.bareRepoDir);
|
|
5364
|
+
summary.localReady = await isDirectory(summary.bareRepoDir);
|
|
5365
|
+
} else {
|
|
5366
|
+
summary.localReady = false;
|
|
5367
|
+
}
|
|
5368
|
+
return summary;
|
|
5369
|
+
})
|
|
5370
|
+
)
|
|
5371
|
+
);
|
|
5372
|
+
}
|
|
5373
|
+
autoSelectCurrentRepoIfSingleConfig() {
|
|
5374
|
+
const decision = this.selectDefaultRepository();
|
|
5375
|
+
if (decision.kind !== "selected") return null;
|
|
5376
|
+
if (decision.source === "single-config") {
|
|
5377
|
+
this.currentRepo = decision.repoName;
|
|
5378
|
+
}
|
|
5379
|
+
return this.currentRepo;
|
|
5380
|
+
}
|
|
3815
5381
|
async getAllConfiguredWorktreeDetails(currentWorktreePath = null) {
|
|
3816
5382
|
const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
|
|
3817
5383
|
const results = await Promise.all(
|
|
@@ -3834,22 +5400,93 @@ var RepositoryContext = class {
|
|
|
3834
5400
|
return this.configPath;
|
|
3835
5401
|
}
|
|
3836
5402
|
async readConfiguredWorktrees(entry, currentWorktreePath) {
|
|
5403
|
+
if (entry.source === "config" && resolveMode(entry.config) === REPOSITORY_MODES.CLONE) {
|
|
5404
|
+
return this.readConfiguredCloneWorktree(entry, currentWorktreePath);
|
|
5405
|
+
}
|
|
3837
5406
|
if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
|
|
3838
|
-
const bareRepoPath =
|
|
5407
|
+
const bareRepoPath = path14.resolve(entry.config.bareRepoDir);
|
|
3839
5408
|
if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
|
|
3840
5409
|
try {
|
|
3841
|
-
const output = await
|
|
5410
|
+
const output = await simpleGit6(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
|
|
3842
5411
|
return { worktrees: parseWorktreeList(output, currentWorktreePath) };
|
|
3843
5412
|
} catch (err) {
|
|
3844
5413
|
return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
|
|
3845
5414
|
}
|
|
3846
5415
|
}
|
|
5416
|
+
findConfiguredCloneEntry(worktreeRoot) {
|
|
5417
|
+
const foldedRoot = normalizePathForCompare(path14.resolve(worktreeRoot));
|
|
5418
|
+
for (const entry of this.repos.values()) {
|
|
5419
|
+
if (entry.source !== "config" || resolveMode(entry.config) !== REPOSITORY_MODES.CLONE) continue;
|
|
5420
|
+
if (normalizePathForCompare(path14.resolve(entry.config.worktreeDir)) === foldedRoot) {
|
|
5421
|
+
return entry;
|
|
5422
|
+
}
|
|
5423
|
+
}
|
|
5424
|
+
return null;
|
|
5425
|
+
}
|
|
5426
|
+
async buildCloneModeContext(entry, worktreeRoot, notes) {
|
|
5427
|
+
const resolvedRoot = path14.resolve(worktreeRoot);
|
|
5428
|
+
let currentBranch = null;
|
|
5429
|
+
try {
|
|
5430
|
+
currentBranch = await readCurrentBranch(resolvedRoot);
|
|
5431
|
+
} catch (err) {
|
|
5432
|
+
notes.push(`Could not read clone-mode branch: ${err instanceof Error ? err.message : String(err)}`);
|
|
5433
|
+
}
|
|
5434
|
+
const branch = currentBranch ?? "unknown";
|
|
5435
|
+
const cloneModeReason = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
|
|
5436
|
+
const capabilities = {
|
|
5437
|
+
listWorktrees: { available: true },
|
|
5438
|
+
getStatus: { available: true },
|
|
5439
|
+
createWorktree: { available: false, reason: cloneModeReason },
|
|
5440
|
+
removeWorktree: { available: false, reason: cloneModeReason },
|
|
5441
|
+
updateWorktree: { available: false, reason: cloneModeReason },
|
|
5442
|
+
sync: { available: true },
|
|
5443
|
+
initialize: { available: true }
|
|
5444
|
+
};
|
|
5445
|
+
const discovered = {
|
|
5446
|
+
isWorktree: true,
|
|
5447
|
+
kind: "managed",
|
|
5448
|
+
currentBranch,
|
|
5449
|
+
currentWorktreePath: resolvedRoot,
|
|
5450
|
+
bareRepoPath: null,
|
|
5451
|
+
repoUrl: entry.config.repoUrl,
|
|
5452
|
+
worktreeDir: resolvedRoot,
|
|
5453
|
+
allWorktrees: [{ path: resolvedRoot, branch, isCurrent: true }],
|
|
5454
|
+
siblingRepositories: [],
|
|
5455
|
+
configPath: this.configPath,
|
|
5456
|
+
repoName: entry.name,
|
|
5457
|
+
capabilities,
|
|
5458
|
+
notes
|
|
5459
|
+
};
|
|
5460
|
+
entry.discovered = discovered;
|
|
5461
|
+
this.bootstrapCurrentRepo(entry.name, true);
|
|
5462
|
+
return discovered;
|
|
5463
|
+
}
|
|
5464
|
+
async readConfiguredCloneWorktree(entry, currentWorktreePath) {
|
|
5465
|
+
const worktreePath = path14.resolve(entry.config.worktreeDir);
|
|
5466
|
+
if (!await isDirectory(worktreePath) || !await hasGitMetadata(worktreePath)) {
|
|
5467
|
+
return { worktrees: [] };
|
|
5468
|
+
}
|
|
5469
|
+
try {
|
|
5470
|
+
const branch = await readCurrentBranch(worktreePath);
|
|
5471
|
+
return {
|
|
5472
|
+
worktrees: [
|
|
5473
|
+
{
|
|
5474
|
+
path: worktreePath,
|
|
5475
|
+
branch,
|
|
5476
|
+
isCurrent: currentWorktreePath !== null && normalizePathForCompare(worktreePath) === normalizePathForCompare(currentWorktreePath)
|
|
5477
|
+
}
|
|
5478
|
+
]
|
|
5479
|
+
};
|
|
5480
|
+
} catch (err) {
|
|
5481
|
+
return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
|
|
5482
|
+
}
|
|
5483
|
+
}
|
|
3847
5484
|
};
|
|
3848
5485
|
function parseWorktreeList(output, currentPath) {
|
|
3849
5486
|
const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
|
|
3850
5487
|
const results = [];
|
|
3851
5488
|
for (const wt of parseWorktreeListPorcelain(output)) {
|
|
3852
|
-
const resolved =
|
|
5489
|
+
const resolved = path14.resolve(wt.path);
|
|
3853
5490
|
const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
|
|
3854
5491
|
if (!branch) continue;
|
|
3855
5492
|
results.push({
|
|
@@ -3862,7 +5499,7 @@ function parseWorktreeList(output, currentPath) {
|
|
|
3862
5499
|
}
|
|
3863
5500
|
async function safeMtimeMs(filePath) {
|
|
3864
5501
|
try {
|
|
3865
|
-
const stat4 = await
|
|
5502
|
+
const stat4 = await fs10.stat(filePath);
|
|
3866
5503
|
return stat4.mtimeMs;
|
|
3867
5504
|
} catch {
|
|
3868
5505
|
return null;
|
|
@@ -3870,19 +5507,45 @@ async function safeMtimeMs(filePath) {
|
|
|
3870
5507
|
}
|
|
3871
5508
|
async function isDirectory(filePath) {
|
|
3872
5509
|
try {
|
|
3873
|
-
const stat4 = await
|
|
5510
|
+
const stat4 = await fs10.stat(filePath);
|
|
3874
5511
|
return stat4.isDirectory();
|
|
3875
5512
|
} catch {
|
|
3876
5513
|
return false;
|
|
3877
5514
|
}
|
|
3878
5515
|
}
|
|
5516
|
+
async function hasGitMetadata(worktreePath) {
|
|
5517
|
+
try {
|
|
5518
|
+
await fs10.stat(path14.join(worktreePath, ".git"));
|
|
5519
|
+
return true;
|
|
5520
|
+
} catch {
|
|
5521
|
+
return false;
|
|
5522
|
+
}
|
|
5523
|
+
}
|
|
5524
|
+
async function isGitCheckout(checkoutPath) {
|
|
5525
|
+
if (!await isDirectory(checkoutPath)) return false;
|
|
5526
|
+
try {
|
|
5527
|
+
const inside = (await simpleGit6(checkoutPath).raw(["rev-parse", "--is-inside-work-tree"])).trim();
|
|
5528
|
+
return inside === "true";
|
|
5529
|
+
} catch {
|
|
5530
|
+
return false;
|
|
5531
|
+
}
|
|
5532
|
+
}
|
|
5533
|
+
async function readCurrentBranch(worktreePath) {
|
|
5534
|
+
const git = simpleGit6(worktreePath);
|
|
5535
|
+
const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
5536
|
+
if (branch && branch !== "HEAD") {
|
|
5537
|
+
return branch;
|
|
5538
|
+
}
|
|
5539
|
+
const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
|
|
5540
|
+
return head ? `(detached ${head})` : "(detached)";
|
|
5541
|
+
}
|
|
3879
5542
|
async function findWorktreeRoot(startPath) {
|
|
3880
|
-
let current =
|
|
3881
|
-
const root =
|
|
5543
|
+
let current = path14.resolve(startPath);
|
|
5544
|
+
const root = path14.parse(current).root;
|
|
3882
5545
|
while (true) {
|
|
3883
|
-
const gitPath =
|
|
5546
|
+
const gitPath = path14.join(current, ".git");
|
|
3884
5547
|
try {
|
|
3885
|
-
const content = await
|
|
5548
|
+
const content = await fs10.readFile(gitPath, "utf-8");
|
|
3886
5549
|
return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
|
|
3887
5550
|
} catch (err) {
|
|
3888
5551
|
const code = err.code;
|
|
@@ -3894,7 +5557,7 @@ async function findWorktreeRoot(startPath) {
|
|
|
3894
5557
|
}
|
|
3895
5558
|
}
|
|
3896
5559
|
if (current === root) return null;
|
|
3897
|
-
const parent =
|
|
5560
|
+
const parent = path14.dirname(current);
|
|
3898
5561
|
if (parent === current) return null;
|
|
3899
5562
|
current = parent;
|
|
3900
5563
|
}
|
|
@@ -3905,13 +5568,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3905
5568
|
import { z } from "zod";
|
|
3906
5569
|
|
|
3907
5570
|
// src/mcp/handlers.ts
|
|
3908
|
-
import * as
|
|
3909
|
-
import
|
|
5571
|
+
import * as path15 from "path";
|
|
5572
|
+
import pLimit3 from "p-limit";
|
|
3910
5573
|
|
|
3911
5574
|
// src/utils/disk-space.ts
|
|
3912
5575
|
import fastFolderSize from "fast-folder-size";
|
|
3913
5576
|
async function calculateDirectorySize(dirPath) {
|
|
3914
|
-
return new Promise((
|
|
5577
|
+
return new Promise((resolve11, reject) => {
|
|
3915
5578
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
3916
5579
|
if (err) {
|
|
3917
5580
|
reject(err);
|
|
@@ -3921,7 +5584,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
3921
5584
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
3922
5585
|
return;
|
|
3923
5586
|
}
|
|
3924
|
-
|
|
5587
|
+
resolve11(bytes);
|
|
3925
5588
|
});
|
|
3926
5589
|
});
|
|
3927
5590
|
}
|
|
@@ -3981,7 +5644,7 @@ function formatToolResponse(data) {
|
|
|
3981
5644
|
content: [
|
|
3982
5645
|
{
|
|
3983
5646
|
type: "text",
|
|
3984
|
-
text: JSON.stringify(data
|
|
5647
|
+
text: JSON.stringify(data)
|
|
3985
5648
|
}
|
|
3986
5649
|
]
|
|
3987
5650
|
};
|
|
@@ -4008,7 +5671,7 @@ function formatErrorResponse(error) {
|
|
|
4008
5671
|
content: [
|
|
4009
5672
|
{
|
|
4010
5673
|
type: "text",
|
|
4011
|
-
text: JSON.stringify(body
|
|
5674
|
+
text: JSON.stringify(body)
|
|
4012
5675
|
}
|
|
4013
5676
|
],
|
|
4014
5677
|
isError: true
|
|
@@ -4035,7 +5698,7 @@ function wrapHandler(fn) {
|
|
|
4035
5698
|
}
|
|
4036
5699
|
|
|
4037
5700
|
// src/mcp/worktree-summary.ts
|
|
4038
|
-
import
|
|
5701
|
+
import simpleGit7 from "simple-git";
|
|
4039
5702
|
function deriveLabel(status, isCurrent) {
|
|
4040
5703
|
if (isCurrent) return "current";
|
|
4041
5704
|
if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
|
|
@@ -4056,7 +5719,7 @@ function deriveSafeToRemove(status) {
|
|
|
4056
5719
|
}
|
|
4057
5720
|
async function getDivergence(worktreePath) {
|
|
4058
5721
|
try {
|
|
4059
|
-
const git =
|
|
5722
|
+
const git = simpleGit7(worktreePath);
|
|
4060
5723
|
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
4061
5724
|
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
4062
5725
|
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|
|
@@ -4067,6 +5730,7 @@ async function getDivergence(worktreePath) {
|
|
|
4067
5730
|
|
|
4068
5731
|
// src/mcp/handlers.ts
|
|
4069
5732
|
var pathResolution = new PathResolutionService();
|
|
5733
|
+
var CLONE_MODE_WORKTREE_MUTATION_REASON = "clone-mode repositories have a single checkout; use sync for clone-mode updates";
|
|
4070
5734
|
function ensureCapability(discovered, key, toolName) {
|
|
4071
5735
|
if (!discovered) return;
|
|
4072
5736
|
const cap = discovered.capabilities[key];
|
|
@@ -4076,6 +5740,9 @@ function ensureCapability(discovered, key, toolName) {
|
|
|
4076
5740
|
}
|
|
4077
5741
|
}
|
|
4078
5742
|
async function getReadyService(ctx, repoName, options = {}) {
|
|
5743
|
+
if (!repoName) {
|
|
5744
|
+
ctx.autoSelectCurrentRepoIfSingleConfig();
|
|
5745
|
+
}
|
|
4079
5746
|
const discovered = ctx.getDiscoveredContext(repoName);
|
|
4080
5747
|
if (options.capability && options.toolName) {
|
|
4081
5748
|
ensureCapability(discovered, options.capability, options.toolName);
|
|
@@ -4098,27 +5765,44 @@ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
|
|
|
4098
5765
|
}
|
|
4099
5766
|
return result.value;
|
|
4100
5767
|
}
|
|
4101
|
-
async function ensureRepoWorktreePath(ctx, params, git) {
|
|
4102
|
-
await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
|
|
4103
|
-
return
|
|
5768
|
+
async function ensureRepoWorktreePath(ctx, params, service, git) {
|
|
5769
|
+
await ensurePathBelongsToRepo(ctx, params.path, params.repoName, service, git);
|
|
5770
|
+
return path15.resolve(params.path);
|
|
4104
5771
|
}
|
|
4105
|
-
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
|
|
5772
|
+
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, service, git) {
|
|
4106
5773
|
const discovered = ctx.getDiscoveredContext(repoName);
|
|
4107
5774
|
if (discovered?.allWorktrees.length) {
|
|
4108
5775
|
const match = discovered.allWorktrees.some((w) => pathsEqual(w.path, targetPath));
|
|
4109
5776
|
if (match) return;
|
|
4110
5777
|
}
|
|
4111
5778
|
try {
|
|
4112
|
-
const worktrees = await git
|
|
5779
|
+
const worktrees = await getWorktreesFromService(service, git);
|
|
4113
5780
|
if (worktrees.some((w) => pathsEqual(w.path, targetPath))) return;
|
|
4114
5781
|
} catch {
|
|
4115
5782
|
}
|
|
4116
5783
|
throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
|
|
4117
5784
|
}
|
|
5785
|
+
function isCloneModeService(service) {
|
|
5786
|
+
const candidate = service;
|
|
5787
|
+
return typeof candidate.isCloneMode === "function" && candidate.isCloneMode();
|
|
5788
|
+
}
|
|
5789
|
+
function ensureWorktreeModeService(service, toolName) {
|
|
5790
|
+
if (isCloneModeService(service)) {
|
|
5791
|
+
throw new CapabilityUnavailableError(toolName, [CLONE_MODE_WORKTREE_MUTATION_REASON]);
|
|
5792
|
+
}
|
|
5793
|
+
}
|
|
5794
|
+
async function getWorktreesFromService(service, git) {
|
|
5795
|
+
const candidate = service;
|
|
5796
|
+
if (typeof candidate.getWorktrees === "function") {
|
|
5797
|
+
return candidate.getWorktrees();
|
|
5798
|
+
}
|
|
5799
|
+
return git.getWorktrees();
|
|
5800
|
+
}
|
|
4118
5801
|
async function handleDetectContext(ctx, params, _extra) {
|
|
4119
5802
|
const target = params.path ?? process.cwd();
|
|
4120
5803
|
const discovered = await ctx.detectFromPath(target);
|
|
4121
|
-
|
|
5804
|
+
const configuredRepositories = await ctx.getConfiguredRepositorySummaries({ detailed: params.detailed ?? false });
|
|
5805
|
+
let response = { ...discovered, configuredRepositories };
|
|
4122
5806
|
if (params.includeAllWorktrees) {
|
|
4123
5807
|
const details = await ctx.getAllConfiguredWorktreeDetails(discovered.currentWorktreePath);
|
|
4124
5808
|
const errorsByRepo = Object.keys(details.errorsByRepo).length > 0 ? details.errorsByRepo : void 0;
|
|
@@ -4132,7 +5816,7 @@ async function handleDetectContext(ctx, params, _extra) {
|
|
|
4132
5816
|
return formatToolResponse(response);
|
|
4133
5817
|
}
|
|
4134
5818
|
const statusService = new WorktreeStatusService();
|
|
4135
|
-
const statusLimit =
|
|
5819
|
+
const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
4136
5820
|
const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
|
|
4137
5821
|
let allWorktreesByRepo = response.allWorktreesByRepo;
|
|
4138
5822
|
if (allWorktreesByRepo) {
|
|
@@ -4168,8 +5852,8 @@ async function enrichDetectedWorktrees(worktrees, statusService, limit) {
|
|
|
4168
5852
|
async function handleListWorktrees(ctx, params, _extra) {
|
|
4169
5853
|
const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
|
|
4170
5854
|
if (configuredRepoNames.length > 0) {
|
|
4171
|
-
const limit =
|
|
4172
|
-
const statusLimit =
|
|
5855
|
+
const limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
5856
|
+
const statusLimit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
4173
5857
|
const repositories = await Promise.all(
|
|
4174
5858
|
configuredRepoNames.map(
|
|
4175
5859
|
(repoName) => limit(async () => {
|
|
@@ -4197,14 +5881,14 @@ async function handleListWorktrees(ctx, params, _extra) {
|
|
|
4197
5881
|
const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
|
|
4198
5882
|
return formatToolResponse({ worktrees: results });
|
|
4199
5883
|
}
|
|
4200
|
-
async function listWorktreesForRepo(ctx, repoName, includeSize, limit =
|
|
4201
|
-
const { discovered, git } = await getReadyService(ctx, repoName, {
|
|
5884
|
+
async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit3(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
|
|
5885
|
+
const { discovered, service, git } = await getReadyService(ctx, repoName, {
|
|
4202
5886
|
capability: "listWorktrees",
|
|
4203
5887
|
toolName: "list_worktrees"
|
|
4204
5888
|
});
|
|
4205
5889
|
let worktrees;
|
|
4206
5890
|
try {
|
|
4207
|
-
worktrees = await git
|
|
5891
|
+
worktrees = await getWorktreesFromService(service, git);
|
|
4208
5892
|
} catch {
|
|
4209
5893
|
if (discovered) {
|
|
4210
5894
|
worktrees = discovered.allWorktrees.map((w) => ({ path: w.path, branch: w.branch }));
|
|
@@ -4216,7 +5900,7 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(
|
|
|
4216
5900
|
const results = await Promise.all(
|
|
4217
5901
|
worktrees.map(
|
|
4218
5902
|
(wt) => limit(async () => {
|
|
4219
|
-
const resolvedPath =
|
|
5903
|
+
const resolvedPath = path15.resolve(wt.path);
|
|
4220
5904
|
const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
|
|
4221
5905
|
const [status, divergence, metadata, sizeBytes] = await Promise.all([
|
|
4222
5906
|
git.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
@@ -4241,11 +5925,11 @@ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(
|
|
|
4241
5925
|
return results;
|
|
4242
5926
|
}
|
|
4243
5927
|
async function handleGetWorktreeStatus(ctx, params, _extra) {
|
|
4244
|
-
const { git } = await getReadyService(ctx, params.repoName, {
|
|
5928
|
+
const { service, git } = await getReadyService(ctx, params.repoName, {
|
|
4245
5929
|
capability: "getStatus",
|
|
4246
5930
|
toolName: "get_worktree_status"
|
|
4247
5931
|
});
|
|
4248
|
-
const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
5932
|
+
const resolvedPath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4249
5933
|
const [status, divergence] = await Promise.all([
|
|
4250
5934
|
git.getFullWorktreeStatus(params.path, params.includeDetails ?? false),
|
|
4251
5935
|
getDivergence(params.path)
|
|
@@ -4267,9 +5951,10 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
4267
5951
|
capability: "createWorktree",
|
|
4268
5952
|
toolName: "create_worktree"
|
|
4269
5953
|
});
|
|
5954
|
+
ensureWorktreeModeService(service, "create_worktree");
|
|
4270
5955
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4271
5956
|
if (!service.isInitialized()) {
|
|
4272
|
-
await service.
|
|
5957
|
+
await service.initializeUnlocked();
|
|
4273
5958
|
}
|
|
4274
5959
|
const existence = await git.branchExists(branchName);
|
|
4275
5960
|
let created = false;
|
|
@@ -4299,7 +5984,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
4299
5984
|
return formatToolResponse({
|
|
4300
5985
|
success: true,
|
|
4301
5986
|
branchName,
|
|
4302
|
-
worktreePath:
|
|
5987
|
+
worktreePath: path15.resolve(worktreePath),
|
|
4303
5988
|
created,
|
|
4304
5989
|
pushed
|
|
4305
5990
|
});
|
|
@@ -4310,11 +5995,12 @@ async function handleRemoveWorktree(ctx, params, _extra) {
|
|
|
4310
5995
|
capability: "removeWorktree",
|
|
4311
5996
|
toolName: "remove_worktree"
|
|
4312
5997
|
});
|
|
5998
|
+
ensureWorktreeModeService(service, "remove_worktree");
|
|
4313
5999
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4314
6000
|
if (!service.isInitialized()) {
|
|
4315
|
-
await service.
|
|
6001
|
+
await service.initializeUnlocked();
|
|
4316
6002
|
}
|
|
4317
|
-
const removedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
6003
|
+
const removedPath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4318
6004
|
if (!params.force) {
|
|
4319
6005
|
const status = await git.getFullWorktreeStatus(params.path, false);
|
|
4320
6006
|
if (!status.canRemove) {
|
|
@@ -4337,13 +6023,31 @@ async function handleSync(ctx, params, extra) {
|
|
|
4337
6023
|
const dispose = attachProgressReporter(service, extra);
|
|
4338
6024
|
try {
|
|
4339
6025
|
const start = Date.now();
|
|
6026
|
+
service.clearRecordedSkips();
|
|
4340
6027
|
const result = await service.sync();
|
|
4341
6028
|
if (!result.started) {
|
|
4342
6029
|
throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
|
|
4343
6030
|
}
|
|
4344
6031
|
const duration = Date.now() - start;
|
|
4345
6032
|
ctx.invalidateDiscovered();
|
|
4346
|
-
|
|
6033
|
+
const outcome = result.outcome ?? createEmptySyncOutcome(
|
|
6034
|
+
isCloneModeService(service) ? "clone" : "worktree",
|
|
6035
|
+
ctx.getEntry(params.repoName)?.name ?? params.repoName,
|
|
6036
|
+
duration
|
|
6037
|
+
);
|
|
6038
|
+
const skips = service.getRecordedSkips().map((reason) => ({
|
|
6039
|
+
...reason,
|
|
6040
|
+
message: formatCloneSkipReason(reason)
|
|
6041
|
+
}));
|
|
6042
|
+
return formatToolResponse({
|
|
6043
|
+
success: true,
|
|
6044
|
+
duration,
|
|
6045
|
+
outcome: {
|
|
6046
|
+
...outcome,
|
|
6047
|
+
durationMs: outcome.durationMs ?? duration
|
|
6048
|
+
},
|
|
6049
|
+
skips
|
|
6050
|
+
});
|
|
4347
6051
|
} finally {
|
|
4348
6052
|
dispose();
|
|
4349
6053
|
}
|
|
@@ -4353,11 +6057,12 @@ async function handleUpdateWorktree(ctx, params, _extra) {
|
|
|
4353
6057
|
capability: "updateWorktree",
|
|
4354
6058
|
toolName: "update_worktree"
|
|
4355
6059
|
});
|
|
6060
|
+
ensureWorktreeModeService(service, "update_worktree");
|
|
4356
6061
|
return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4357
6062
|
if (!service.isInitialized()) {
|
|
4358
|
-
await service.
|
|
6063
|
+
await service.initializeUnlocked();
|
|
4359
6064
|
}
|
|
4360
|
-
const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
|
|
6065
|
+
const worktreePath = await ensureRepoWorktreePath(ctx, params, service, git);
|
|
4361
6066
|
await git.updateWorktree(params.path);
|
|
4362
6067
|
ctx.invalidateDiscovered();
|
|
4363
6068
|
return formatToolResponse({
|
|
@@ -4374,7 +6079,8 @@ async function handleInitialize(ctx, params, extra) {
|
|
|
4374
6079
|
const dispose = attachProgressReporter(service, extra);
|
|
4375
6080
|
try {
|
|
4376
6081
|
return await runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
|
|
4377
|
-
await service.
|
|
6082
|
+
await service.initializeUnlocked();
|
|
6083
|
+
service.clearPendingInitSkip();
|
|
4378
6084
|
const git = service.getGitService();
|
|
4379
6085
|
ctx.invalidateDiscovered();
|
|
4380
6086
|
return formatToolResponse({
|
|
@@ -4394,7 +6100,7 @@ async function handleLoadConfig(ctx, params, _extra) {
|
|
|
4394
6100
|
}
|
|
4395
6101
|
await ctx.loadConfig(configPath);
|
|
4396
6102
|
return formatToolResponse({
|
|
4397
|
-
configPath:
|
|
6103
|
+
configPath: path15.resolve(configPath),
|
|
4398
6104
|
currentRepository: ctx.getCurrentRepo(),
|
|
4399
6105
|
repositories: ctx.getRepositoryList()
|
|
4400
6106
|
});
|
|
@@ -4429,20 +6135,23 @@ function attachProgressReporter(service, extra) {
|
|
|
4429
6135
|
}
|
|
4430
6136
|
|
|
4431
6137
|
// src/mcp/server.ts
|
|
4432
|
-
var REPO_NAME_DESCRIBE = "
|
|
4433
|
-
var PATH_DESCRIBE_SUFFIX = "Absolute
|
|
4434
|
-
var SERVER_INSTRUCTIONS = "
|
|
6138
|
+
var REPO_NAME_DESCRIBE = "Repo name from loaded config. Omit to use current (set via set_current_repository) or the only loaded repo.";
|
|
6139
|
+
var PATH_DESCRIBE_SUFFIX = "Absolute preferred; relative resolves from server CWD.";
|
|
6140
|
+
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
6141
|
function buildInstructions(snapshot) {
|
|
4436
6142
|
const d = snapshot?.discovered;
|
|
4437
|
-
if (!d || !d.isWorktree || d.kind !== "managed")
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
if (d.
|
|
4442
|
-
if (d.
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
6143
|
+
if (!d || !d.isWorktree || d.kind !== "managed") {
|
|
6144
|
+
return SERVER_INSTRUCTIONS;
|
|
6145
|
+
}
|
|
6146
|
+
const fields = [];
|
|
6147
|
+
if (d.repoName) fields.push(`workspace=${d.repoName}`);
|
|
6148
|
+
if (d.currentWorktreePath) fields.push(`path=${d.currentWorktreePath}`);
|
|
6149
|
+
if (d.configPath) fields.push(`config=${d.configPath}`);
|
|
6150
|
+
if (typeof snapshot?.configuredRepoCount === "number") {
|
|
6151
|
+
fields.push(`configuredRepos=${snapshot.configuredRepoCount}`);
|
|
6152
|
+
}
|
|
6153
|
+
fields.push(`worktrees=${d.allWorktrees.length}`);
|
|
6154
|
+
return `${SERVER_INSTRUCTIONS} Connect-time: ${fields.join(" ")}.`;
|
|
4446
6155
|
}
|
|
4447
6156
|
function createServer(context, snapshot) {
|
|
4448
6157
|
const server = new McpServer(
|
|
@@ -4459,22 +6168,24 @@ function createServer(context, snapshot) {
|
|
|
4459
6168
|
"sync-worktrees://workspace",
|
|
4460
6169
|
{
|
|
4461
6170
|
title: "Workspace context",
|
|
4462
|
-
description: "
|
|
6171
|
+
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
6172
|
mimeType: "application/json"
|
|
4464
6173
|
},
|
|
4465
6174
|
async (uri) => {
|
|
4466
|
-
let
|
|
6175
|
+
let payload;
|
|
4467
6176
|
try {
|
|
4468
|
-
discovered = await context.detectFromPath(process.cwd());
|
|
6177
|
+
const discovered = await context.detectFromPath(process.cwd());
|
|
6178
|
+
const configuredRepositories = await context.getConfiguredRepositorySummaries();
|
|
6179
|
+
payload = { ...discovered, configuredRepositories };
|
|
4469
6180
|
} catch (err) {
|
|
4470
|
-
|
|
6181
|
+
payload = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
|
|
4471
6182
|
}
|
|
4472
6183
|
return {
|
|
4473
6184
|
contents: [
|
|
4474
6185
|
{
|
|
4475
6186
|
uri: uri.href,
|
|
4476
6187
|
mimeType: "application/json",
|
|
4477
|
-
text: JSON.stringify(
|
|
6188
|
+
text: JSON.stringify(payload)
|
|
4478
6189
|
}
|
|
4479
6190
|
]
|
|
4480
6191
|
};
|
|
@@ -4483,14 +6194,13 @@ function createServer(context, snapshot) {
|
|
|
4483
6194
|
server.registerTool(
|
|
4484
6195
|
"detect_context",
|
|
4485
6196
|
{
|
|
4486
|
-
description: "Detect sync-worktrees structure from
|
|
6197
|
+
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
6198
|
inputSchema: {
|
|
4488
|
-
path: z.string().optional().describe("Directory
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
),
|
|
6199
|
+
path: z.string().optional().describe("Directory to inspect. Default: server CWD."),
|
|
6200
|
+
detailed: z.boolean().optional().default(false).describe("Expand configuredRepositories with repoUrl, branch, sparseCheckout, localReady, bareRepoDir."),
|
|
6201
|
+
includeAllWorktrees: z.boolean().optional().describe("Include allWorktreesByRepo + allWorktreeErrorsByRepo for each configured repo. Default: false."),
|
|
4492
6202
|
includeStatus: z.boolean().optional().describe(
|
|
4493
|
-
"
|
|
6203
|
+
"Enrich entries with label, divergence, staleHint. Adds 1 git status + rev-list per worktree. Default: false."
|
|
4494
6204
|
)
|
|
4495
6205
|
},
|
|
4496
6206
|
annotations: {
|
|
@@ -4505,13 +6215,11 @@ function createServer(context, snapshot) {
|
|
|
4505
6215
|
server.registerTool(
|
|
4506
6216
|
"list_worktrees",
|
|
4507
6217
|
{
|
|
4508
|
-
description: "List worktrees with
|
|
6218
|
+
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
6219
|
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
|
-
),
|
|
6220
|
+
repoName: z.string().optional().describe("Repo name. Omit + config loaded = list all configured repos."),
|
|
4513
6221
|
includeSize: z.boolean().optional().describe(
|
|
4514
|
-
"
|
|
6222
|
+
"Compute on-disk size per worktree (bytes). Slow on large worktrees. Default: false (sizeBytes=null)."
|
|
4515
6223
|
)
|
|
4516
6224
|
},
|
|
4517
6225
|
annotations: {
|
|
@@ -4526,11 +6234,11 @@ function createServer(context, snapshot) {
|
|
|
4526
6234
|
server.registerTool(
|
|
4527
6235
|
"get_worktree_status",
|
|
4528
6236
|
{
|
|
4529
|
-
description: "
|
|
6237
|
+
description: "Detailed status for one worktree: dirty files, unpushed commits, stashes, upstream gone, ops in progress. Returns: status + divergence {ahead,behind} + resolved path.",
|
|
4530
6238
|
inputSchema: {
|
|
4531
6239
|
path: z.string().describe(`Worktree path. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4532
6240
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
|
|
4533
|
-
includeDetails: z.boolean().optional().describe("
|
|
6241
|
+
includeDetails: z.boolean().optional().describe("Include file-level lists (modified, untracked, staged). Default: false (counts only).")
|
|
4534
6242
|
},
|
|
4535
6243
|
annotations: {
|
|
4536
6244
|
title: "Get worktree status",
|
|
@@ -4544,13 +6252,13 @@ function createServer(context, snapshot) {
|
|
|
4544
6252
|
server.registerTool(
|
|
4545
6253
|
"create_worktree",
|
|
4546
6254
|
{
|
|
4547
|
-
description: "Create
|
|
6255
|
+
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
6256
|
inputSchema: {
|
|
4549
|
-
branchName: z.string().describe("Branch name. Slashes
|
|
6257
|
+
branchName: z.string().describe("Branch name. Slashes/special chars sanitized for dir name."),
|
|
4550
6258
|
baseBranch: z.string().optional().describe(
|
|
4551
|
-
"Base
|
|
6259
|
+
"Base for new branch. Required if branchName doesn't exist locally or remotely; ignored otherwise."
|
|
4552
6260
|
),
|
|
4553
|
-
push: z.boolean().optional().describe("Push
|
|
6261
|
+
push: z.boolean().optional().describe("Push new branch to origin. Default: true. Ignored if branch existed."),
|
|
4554
6262
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4555
6263
|
},
|
|
4556
6264
|
annotations: {
|
|
@@ -4566,12 +6274,10 @@ function createServer(context, snapshot) {
|
|
|
4566
6274
|
server.registerTool(
|
|
4567
6275
|
"remove_worktree",
|
|
4568
6276
|
{
|
|
4569
|
-
description: "Remove
|
|
6277
|
+
description: "Remove worktree. Safety checks reject if dirty, unpushed commits, stashes, or op in progress (merge/rebase/cherry-pick/revert/bisect). force=true: `git worktree remove --force` DELETES uncommitted/untracked files in dir; branch ref + stashes + remote preserved. Returns: {success, removedPath}.",
|
|
4570
6278
|
inputSchema: {
|
|
4571
6279
|
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
|
-
),
|
|
6280
|
+
force: z.boolean().optional().describe("Skip safety checks; deletes uncommitted/untracked files. Branch ref preserved. Default: false."),
|
|
4575
6281
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4576
6282
|
},
|
|
4577
6283
|
annotations: {
|
|
@@ -4587,7 +6293,7 @@ function createServer(context, snapshot) {
|
|
|
4587
6293
|
server.registerTool(
|
|
4588
6294
|
"sync",
|
|
4589
6295
|
{
|
|
4590
|
-
description: "
|
|
6296
|
+
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
6297
|
inputSchema: {
|
|
4592
6298
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4593
6299
|
},
|
|
@@ -4604,7 +6310,7 @@ function createServer(context, snapshot) {
|
|
|
4604
6310
|
server.registerTool(
|
|
4605
6311
|
"update_worktree",
|
|
4606
6312
|
{
|
|
4607
|
-
description: "Fast-forward one worktree to
|
|
6313
|
+
description: "Fast-forward one worktree to upstream. No merge, no rebase, aborts if not fast-forwardable. Whole repo? Use sync.",
|
|
4608
6314
|
inputSchema: {
|
|
4609
6315
|
path: z.string().describe(`Worktree path to fast-forward. ${PATH_DESCRIBE_SUFFIX}`),
|
|
4610
6316
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
@@ -4622,7 +6328,7 @@ function createServer(context, snapshot) {
|
|
|
4622
6328
|
server.registerTool(
|
|
4623
6329
|
"initialize",
|
|
4624
6330
|
{
|
|
4625
|
-
description: "Initialize
|
|
6331
|
+
description: "Initialize repo: clone as bare if missing, create main worktree. Idempotent. Emits progress. Preconditions: config loaded. Returns: {success, defaultBranch, worktreeDir}.",
|
|
4626
6332
|
inputSchema: {
|
|
4627
6333
|
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
4628
6334
|
},
|
|
@@ -4639,11 +6345,9 @@ function createServer(context, snapshot) {
|
|
|
4639
6345
|
server.registerTool(
|
|
4640
6346
|
"load_config",
|
|
4641
6347
|
{
|
|
4642
|
-
description: "Load
|
|
6348
|
+
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
6349
|
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
|
-
)
|
|
6350
|
+
configPath: z.string().optional().describe("Config file path. Falls back to SYNC_WORKTREES_CONFIG env var. Errors if neither set.")
|
|
4647
6351
|
},
|
|
4648
6352
|
annotations: {
|
|
4649
6353
|
title: "Load sync-worktrees config",
|
|
@@ -4658,9 +6362,9 @@ function createServer(context, snapshot) {
|
|
|
4658
6362
|
server.registerTool(
|
|
4659
6363
|
"set_current_repository",
|
|
4660
6364
|
{
|
|
4661
|
-
description: "Set
|
|
6365
|
+
description: "Set current repo for tool calls that omit repoName. Session-scoped. Preconditions: load_config called.",
|
|
4662
6366
|
inputSchema: {
|
|
4663
|
-
repoName: z.string().describe("
|
|
6367
|
+
repoName: z.string().describe("Repo name from loaded config repositories[].name.")
|
|
4664
6368
|
},
|
|
4665
6369
|
annotations: {
|
|
4666
6370
|
title: "Set current repository",
|
|
@@ -4702,7 +6406,10 @@ async function main() {
|
|
|
4702
6406
|
process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
|
|
4703
6407
|
`);
|
|
4704
6408
|
}
|
|
4705
|
-
const server = createServer(context, {
|
|
6409
|
+
const server = createServer(context, {
|
|
6410
|
+
discovered,
|
|
6411
|
+
configuredRepoCount: context.getConfiguredRepositoryNames().length
|
|
6412
|
+
});
|
|
4706
6413
|
const transport = new StdioServerTransport();
|
|
4707
6414
|
await server.connect(transport);
|
|
4708
6415
|
}
|