sync-worktrees 3.6.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +383 -261
- package/dist/components/App.d.ts +52 -0
- package/dist/components/App.d.ts.map +1 -0
- package/dist/components/BranchCreationWizard.d.ts +26 -0
- package/dist/components/BranchCreationWizard.d.ts.map +1 -0
- package/dist/components/HelpModal.d.ts +7 -0
- package/dist/components/HelpModal.d.ts.map +1 -0
- package/dist/components/LogPanel.d.ts +10 -0
- package/dist/components/LogPanel.d.ts.map +1 -0
- package/dist/components/LogViewer.d.ts +9 -0
- package/dist/components/LogViewer.d.ts.map +1 -0
- package/dist/components/OpenEditorWizard.d.ts +25 -0
- package/dist/components/OpenEditorWizard.d.ts.map +1 -0
- package/dist/components/StatusBar.d.ts +11 -0
- package/dist/components/StatusBar.d.ts.map +1 -0
- package/dist/components/WorktreeStatusView.d.ts +17 -0
- package/dist/components/WorktreeStatusView.d.ts.map +1 -0
- package/dist/constants.d.ts +112 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/errors/index.d.ts +59 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2326 -1081
- package/dist/index.js.map +4 -4
- package/dist/mcp/context.d.ts +143 -0
- package/dist/mcp/context.d.ts.map +1 -0
- package/dist/mcp/handlers.d.ts +46 -0
- package/dist/mcp/handlers.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/utils.d.ts +14 -0
- package/dist/mcp/utils.d.ts.map +1 -0
- package/dist/mcp/worktree-summary.d.ts +14 -0
- package/dist/mcp/worktree-summary.d.ts.map +1 -0
- package/dist/mcp-server.js +2341 -637
- package/dist/mcp-server.js.map +4 -4
- package/dist/services/InteractiveUIService.d.ts +85 -0
- package/dist/services/InteractiveUIService.d.ts.map +1 -0
- package/dist/services/branch-created-actions.service.d.ts +27 -0
- package/dist/services/branch-created-actions.service.d.ts.map +1 -0
- package/dist/services/clone-sync.service.d.ts +93 -0
- package/dist/services/clone-sync.service.d.ts.map +1 -0
- package/dist/services/config-loader.service.d.ts +28 -0
- package/dist/services/config-loader.service.d.ts.map +1 -0
- package/dist/services/file-copy.service.d.ts +19 -0
- package/dist/services/file-copy.service.d.ts.map +1 -0
- package/dist/services/git.service.d.ts +92 -0
- package/dist/services/git.service.d.ts.map +1 -0
- package/dist/services/hook-execution.service.d.ts +20 -0
- package/dist/services/hook-execution.service.d.ts.map +1 -0
- package/dist/services/logger.service.d.ts +24 -0
- package/dist/services/logger.service.d.ts.map +1 -0
- package/dist/services/path-resolution.service.d.ts +10 -0
- package/dist/services/path-resolution.service.d.ts.map +1 -0
- package/dist/services/progress-emitter.d.ts +14 -0
- package/dist/services/progress-emitter.d.ts.map +1 -0
- package/dist/services/repo-operation-lock.d.ts +16 -0
- package/dist/services/repo-operation-lock.d.ts.map +1 -0
- package/dist/services/sparse-checkout.service.d.ts +45 -0
- package/dist/services/sparse-checkout.service.d.ts.map +1 -0
- package/dist/services/sync-outcome.d.ts +47 -0
- package/dist/services/sync-outcome.d.ts.map +1 -0
- package/dist/services/sync-retry-policy.d.ts +18 -0
- package/dist/services/sync-retry-policy.d.ts.map +1 -0
- package/dist/services/worktree-metadata.service.d.ts +25 -0
- package/dist/services/worktree-metadata.service.d.ts.map +1 -0
- package/dist/services/worktree-mode-sync-runner.d.ts +36 -0
- package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -0
- package/dist/services/worktree-status.service.d.ts +60 -0
- package/dist/services/worktree-status.service.d.ts.map +1 -0
- package/dist/services/worktree-sync-planner.d.ts +62 -0
- package/dist/services/worktree-sync-planner.d.ts.map +1 -0
- package/dist/services/worktree-sync.service.d.ts +49 -0
- package/dist/services/worktree-sync.service.d.ts.map +1 -0
- package/dist/types/index.d.ts +289 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/sync-metadata.d.ts +16 -0
- package/dist/types/sync-metadata.d.ts.map +1 -0
- package/dist/utils/app-events.d.ts +21 -0
- package/dist/utils/app-events.d.ts.map +1 -0
- package/dist/utils/branch-filter.d.ts +3 -0
- package/dist/utils/branch-filter.d.ts.map +1 -0
- package/dist/utils/cli.d.ts +21 -0
- package/dist/utils/cli.d.ts.map +1 -0
- package/dist/utils/clone-skip-format.d.ts +3 -0
- package/dist/utils/clone-skip-format.d.ts.map +1 -0
- package/dist/utils/config-generator.d.ts +10 -0
- package/dist/utils/config-generator.d.ts.map +1 -0
- package/dist/utils/date-filter.d.ts +10 -0
- package/dist/utils/date-filter.d.ts.map +1 -0
- package/dist/utils/disk-space.d.ts +23 -0
- package/dist/utils/disk-space.d.ts.map +1 -0
- package/dist/utils/file-exists.d.ts +2 -0
- package/dist/utils/file-exists.d.ts.map +1 -0
- package/dist/utils/git-progress.d.ts +25 -0
- package/dist/utils/git-progress.d.ts.map +1 -0
- package/dist/utils/git-url.d.ts +23 -0
- package/dist/utils/git-url.d.ts.map +1 -0
- package/dist/utils/git-validation.d.ts +5 -0
- package/dist/utils/git-validation.d.ts.map +1 -0
- package/dist/utils/interactive.d.ts +3 -0
- package/dist/utils/interactive.d.ts.map +1 -0
- package/dist/utils/lfs-error.d.ts +35 -0
- package/dist/utils/lfs-error.d.ts.map +1 -0
- package/dist/utils/lock-path.d.ts +9 -0
- package/dist/utils/lock-path.d.ts.map +1 -0
- package/dist/utils/path-compare.d.ts +16 -0
- package/dist/utils/path-compare.d.ts.map +1 -0
- package/dist/utils/repo-mode.d.ts +8 -0
- package/dist/utils/repo-mode.d.ts.map +1 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/sanitize-name.d.ts +2 -0
- package/dist/utils/sanitize-name.d.ts.map +1 -0
- package/dist/utils/shell-escape.d.ts +5 -0
- package/dist/utils/shell-escape.d.ts.map +1 -0
- package/dist/utils/signal-handlers.d.ts +14 -0
- package/dist/utils/signal-handlers.d.ts.map +1 -0
- package/dist/utils/timing.d.ts +24 -0
- package/dist/utils/timing.d.ts.map +1 -0
- package/dist/utils/worktree-list-parser.d.ts +10 -0
- package/dist/utils/worktree-list-parser.d.ts.map +1 -0
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
4
|
+
import { realpathSync as realpathSync2 } from "fs";
|
|
5
|
+
import * as path17 from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
7
|
import pLimit3 from "p-limit";
|
|
8
8
|
|
|
9
9
|
// src/constants.ts
|
|
@@ -87,7 +87,8 @@ var ENV_CONSTANTS = {
|
|
|
87
87
|
};
|
|
88
88
|
var PATH_CONSTANTS = {
|
|
89
89
|
GIT_DIR: ".git",
|
|
90
|
-
README: "README"
|
|
90
|
+
README: "README",
|
|
91
|
+
CLONE_INIT_MARKER: ".sync-worktrees-clone-init"
|
|
91
92
|
};
|
|
92
93
|
var CONFIG_FILE_NAMES = [
|
|
93
94
|
"sync-worktrees.config.js",
|
|
@@ -127,8 +128,68 @@ var HOOK_CONSTANTS = {
|
|
|
127
128
|
}
|
|
128
129
|
};
|
|
129
130
|
|
|
131
|
+
// src/errors/index.ts
|
|
132
|
+
var SyncWorktreesError = class extends Error {
|
|
133
|
+
constructor(message, code, cause) {
|
|
134
|
+
super(message);
|
|
135
|
+
this.code = code;
|
|
136
|
+
this.cause = cause;
|
|
137
|
+
this.name = this.constructor.name;
|
|
138
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
139
|
+
if (cause && cause.stack) {
|
|
140
|
+
this.stack = `${this.stack}
|
|
141
|
+
Caused by: ${cause.stack}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var GitError = class extends SyncWorktreesError {
|
|
146
|
+
constructor(message, code, cause) {
|
|
147
|
+
super(message, `GIT_${code}`, cause);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var GitOperationError = class extends GitError {
|
|
151
|
+
constructor(operation, details, cause) {
|
|
152
|
+
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
var WorktreeError = class extends SyncWorktreesError {
|
|
156
|
+
constructor(message, code, cause) {
|
|
157
|
+
super(message, `WORKTREE_${code}`, cause);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
var WorktreeNotCleanError = class extends WorktreeError {
|
|
161
|
+
constructor(path18, reasons) {
|
|
162
|
+
super(`Worktree at '${path18}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
163
|
+
this.path = path18;
|
|
164
|
+
this.reasons = reasons;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
var ConfigError = class extends SyncWorktreesError {
|
|
168
|
+
constructor(message, code, cause) {
|
|
169
|
+
super(message, `CONFIG_${code}`, cause);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var ConfigValidationError = class extends ConfigError {
|
|
173
|
+
constructor(field, reason) {
|
|
174
|
+
super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
|
|
175
|
+
this.field = field;
|
|
176
|
+
this.reason = reason;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
var ConfigFileNotFoundError = class extends ConfigError {
|
|
180
|
+
constructor(configPath) {
|
|
181
|
+
super(`Config file not found: ${configPath}`, "FILE_NOT_FOUND");
|
|
182
|
+
this.configPath = configPath;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
var ConfigFileExistsError = class extends ConfigError {
|
|
186
|
+
constructor(configPath) {
|
|
187
|
+
super(`Config file already exists: ${configPath}`, "FILE_EXISTS");
|
|
188
|
+
this.configPath = configPath;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
130
192
|
// src/services/config-loader.service.ts
|
|
131
|
-
import * as fs from "fs/promises";
|
|
132
193
|
import * as path2 from "path";
|
|
133
194
|
import { pathToFileURL } from "url";
|
|
134
195
|
import * as cron from "node-cron";
|
|
@@ -153,6 +214,17 @@ function filterBranchesByName(branches, include, exclude) {
|
|
|
153
214
|
return result;
|
|
154
215
|
}
|
|
155
216
|
|
|
217
|
+
// src/utils/file-exists.ts
|
|
218
|
+
import * as fs from "fs/promises";
|
|
219
|
+
async function fileExists(path18) {
|
|
220
|
+
try {
|
|
221
|
+
await fs.access(path18);
|
|
222
|
+
return true;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
156
228
|
// src/utils/git-url.ts
|
|
157
229
|
function extractRepoNameFromUrl(gitUrl) {
|
|
158
230
|
const url = gitUrl.trim();
|
|
@@ -178,6 +250,16 @@ function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
|
178
250
|
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
179
251
|
return `${baseDir}/${repoName}`;
|
|
180
252
|
}
|
|
253
|
+
function normalizeRepoUrlForComparison(url) {
|
|
254
|
+
let normalized = url.trim();
|
|
255
|
+
const isForgeUrl = /^(https?|ssh|git):\/\//i.test(normalized) || /^[\w.-]+@[^/]+:/.test(normalized);
|
|
256
|
+
normalized = normalized.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+/, (prefix) => prefix.toLowerCase());
|
|
257
|
+
normalized = normalized.replace(/\/+$/, "");
|
|
258
|
+
if (isForgeUrl) {
|
|
259
|
+
normalized = normalized.replace(/\.git$/, "");
|
|
260
|
+
}
|
|
261
|
+
return normalized;
|
|
262
|
+
}
|
|
181
263
|
|
|
182
264
|
// src/utils/path-compare.ts
|
|
183
265
|
import * as path from "path";
|
|
@@ -190,54 +272,17 @@ function normalizePathForCompare(p, platform = process.platform) {
|
|
|
190
272
|
return isCaseInsensitiveFs(platform) ? resolved.toLowerCase() : resolved;
|
|
191
273
|
}
|
|
192
274
|
|
|
193
|
-
// src/
|
|
194
|
-
var
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this.code = code;
|
|
198
|
-
this.cause = cause;
|
|
199
|
-
this.name = this.constructor.name;
|
|
200
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
201
|
-
if (cause && cause.stack) {
|
|
202
|
-
this.stack = `${this.stack}
|
|
203
|
-
Caused by: ${cause.stack}`;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
var GitError = class extends SyncWorktreesError {
|
|
208
|
-
constructor(message, code, cause) {
|
|
209
|
-
super(message, `GIT_${code}`, cause);
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
var GitOperationError = class extends GitError {
|
|
213
|
-
constructor(operation, details, cause) {
|
|
214
|
-
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
var WorktreeError = class extends SyncWorktreesError {
|
|
218
|
-
constructor(message, code, cause) {
|
|
219
|
-
super(message, `WORKTREE_${code}`, cause);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
var WorktreeNotCleanError = class extends WorktreeError {
|
|
223
|
-
constructor(path14, reasons) {
|
|
224
|
-
super(`Worktree at '${path14}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
225
|
-
this.path = path14;
|
|
226
|
-
this.reasons = reasons;
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
var ConfigError = class extends SyncWorktreesError {
|
|
230
|
-
constructor(message, code, cause) {
|
|
231
|
-
super(message, `CONFIG_${code}`, cause);
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
var ConfigValidationError = class extends ConfigError {
|
|
235
|
-
constructor(field, reason) {
|
|
236
|
-
super(`Invalid configuration for '${field}': ${reason}`, "VALIDATION_FAILED");
|
|
237
|
-
this.field = field;
|
|
238
|
-
this.reason = reason;
|
|
239
|
-
}
|
|
275
|
+
// src/utils/repo-mode.ts
|
|
276
|
+
var REPOSITORY_MODES = {
|
|
277
|
+
CLONE: "clone",
|
|
278
|
+
WORKTREE: "worktree"
|
|
240
279
|
};
|
|
280
|
+
function isRepositoryMode(value) {
|
|
281
|
+
return value === REPOSITORY_MODES.CLONE || value === REPOSITORY_MODES.WORKTREE;
|
|
282
|
+
}
|
|
283
|
+
function resolveMode(cfg) {
|
|
284
|
+
return cfg.mode ?? REPOSITORY_MODES.WORKTREE;
|
|
285
|
+
}
|
|
241
286
|
|
|
242
287
|
// src/utils/sanitize-name.ts
|
|
243
288
|
var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
@@ -261,6 +306,13 @@ function sanitizeNameForPath(name, fieldContext = "name") {
|
|
|
261
306
|
}
|
|
262
307
|
|
|
263
308
|
// src/services/config-loader.service.ts
|
|
309
|
+
var CLONE_MODE_CONFLICTING_FIELDS = [
|
|
310
|
+
"branchInclude",
|
|
311
|
+
"branchExclude",
|
|
312
|
+
"branchMaxAge",
|
|
313
|
+
"updateExistingWorktrees",
|
|
314
|
+
"bareRepoDir"
|
|
315
|
+
];
|
|
264
316
|
var ConfigLoaderService = class {
|
|
265
317
|
async findConfigUpward(startDir) {
|
|
266
318
|
let current = path2.resolve(startDir);
|
|
@@ -268,10 +320,8 @@ var ConfigLoaderService = class {
|
|
|
268
320
|
while (true) {
|
|
269
321
|
for (const name of CONFIG_FILE_NAMES) {
|
|
270
322
|
const candidate = path2.join(current, name);
|
|
271
|
-
|
|
272
|
-
await fs.access(candidate);
|
|
323
|
+
if (await fileExists(candidate)) {
|
|
273
324
|
return candidate;
|
|
274
|
-
} catch {
|
|
275
325
|
}
|
|
276
326
|
}
|
|
277
327
|
if (current === root) return null;
|
|
@@ -282,10 +332,8 @@ var ConfigLoaderService = class {
|
|
|
282
332
|
}
|
|
283
333
|
async loadConfigFile(configPath) {
|
|
284
334
|
const absolutePath = path2.resolve(configPath);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
} catch {
|
|
288
|
-
throw new Error(`Config file not found: ${absolutePath}`);
|
|
335
|
+
if (!await fileExists(absolutePath)) {
|
|
336
|
+
throw new ConfigFileNotFoundError(absolutePath);
|
|
289
337
|
}
|
|
290
338
|
try {
|
|
291
339
|
const fileUrl = pathToFileURL(absolutePath);
|
|
@@ -298,7 +346,7 @@ var ConfigLoaderService = class {
|
|
|
298
346
|
this.validateConfigFile(config);
|
|
299
347
|
return config;
|
|
300
348
|
} catch (error) {
|
|
301
|
-
if (error instanceof
|
|
349
|
+
if (error instanceof SyncWorktreesError) {
|
|
302
350
|
throw error;
|
|
303
351
|
}
|
|
304
352
|
throw new Error(`Failed to load config file: ${error.message}`);
|
|
@@ -351,6 +399,12 @@ var ConfigLoaderService = class {
|
|
|
351
399
|
if (repoObj.runOnce !== void 0 && typeof repoObj.runOnce !== "boolean") {
|
|
352
400
|
throw new Error(`Repository '${repoObj.name}' has invalid 'runOnce' property`);
|
|
353
401
|
}
|
|
402
|
+
if (repoObj.debug !== void 0 && typeof repoObj.debug !== "boolean") {
|
|
403
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'debug' property`);
|
|
404
|
+
}
|
|
405
|
+
if (repoObj.retry !== void 0) {
|
|
406
|
+
this.validateRetryConfig(repoObj.retry, `Repository '${repoObj.name}' retry config`);
|
|
407
|
+
}
|
|
354
408
|
if (repoObj.filesToCopyOnBranchCreate !== void 0) {
|
|
355
409
|
this.validateFilesToCopyConfig(repoObj.filesToCopyOnBranchCreate, `Repository '${repoObj.name}'`);
|
|
356
410
|
}
|
|
@@ -360,6 +414,8 @@ var ConfigLoaderService = class {
|
|
|
360
414
|
if (repoObj.sparseCheckout !== void 0) {
|
|
361
415
|
this.validateSparseCheckoutConfig(repoObj.sparseCheckout, `Repository '${repoObj.name}'`);
|
|
362
416
|
}
|
|
417
|
+
this.validateDepth(repoObj.depth, `Repository '${repoObj.name}' depth`);
|
|
418
|
+
this.validateRepositoryMode(repoObj, configObj.defaults);
|
|
363
419
|
});
|
|
364
420
|
this.warnOnDuplicateRepoUrls(configObj.repositories);
|
|
365
421
|
if (configObj.defaults) {
|
|
@@ -376,9 +432,15 @@ var ConfigLoaderService = class {
|
|
|
376
432
|
if (defaults.runOnce !== void 0 && typeof defaults.runOnce !== "boolean") {
|
|
377
433
|
throw new Error("Invalid 'runOnce' in defaults");
|
|
378
434
|
}
|
|
435
|
+
if (defaults.debug !== void 0 && typeof defaults.debug !== "boolean") {
|
|
436
|
+
throw new Error("Invalid 'debug' in defaults");
|
|
437
|
+
}
|
|
379
438
|
if (defaults.retry !== void 0 && typeof defaults.retry !== "object") {
|
|
380
439
|
throw new Error("Invalid 'retry' in defaults");
|
|
381
440
|
}
|
|
441
|
+
if (defaults.retry !== void 0) {
|
|
442
|
+
this.validateRetryConfig(defaults.retry, "defaults retry config");
|
|
443
|
+
}
|
|
382
444
|
if (defaults.filesToCopyOnBranchCreate !== void 0) {
|
|
383
445
|
this.validateFilesToCopyConfig(defaults.filesToCopyOnBranchCreate, "defaults");
|
|
384
446
|
}
|
|
@@ -388,39 +450,17 @@ var ConfigLoaderService = class {
|
|
|
388
450
|
if (defaults.sparseCheckout !== void 0) {
|
|
389
451
|
this.validateSparseCheckoutConfig(defaults.sparseCheckout, "defaults");
|
|
390
452
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
throw new Error("'retry' must be an object");
|
|
395
|
-
}
|
|
396
|
-
const retry2 = configObj.retry;
|
|
397
|
-
if (retry2.maxAttempts !== void 0) {
|
|
398
|
-
if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
|
|
399
|
-
throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
if (retry2.maxLfsRetries !== void 0) {
|
|
403
|
-
if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
|
|
404
|
-
throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
|
|
408
|
-
throw new Error("Invalid 'initialDelayMs' in retry config");
|
|
409
|
-
}
|
|
410
|
-
if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
|
|
411
|
-
throw new Error("Invalid 'maxDelayMs' in retry config");
|
|
453
|
+
this.validateDepth(defaults.depth, "defaults.depth");
|
|
454
|
+
if (defaults.mode !== void 0 && !isRepositoryMode(defaults.mode)) {
|
|
455
|
+
throw new ConfigValidationError("defaults.mode", "must be 'clone' or 'worktree'");
|
|
412
456
|
}
|
|
413
|
-
if (
|
|
414
|
-
throw new
|
|
415
|
-
}
|
|
416
|
-
const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
|
|
417
|
-
const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
|
|
418
|
-
if (initialDelay > maxDelay) {
|
|
419
|
-
throw new Error(
|
|
420
|
-
`Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
|
|
421
|
-
);
|
|
457
|
+
if (defaults.branch !== void 0 && (typeof defaults.branch !== "string" || defaults.branch.trim() === "")) {
|
|
458
|
+
throw new ConfigValidationError("defaults.branch", "must be a non-empty string");
|
|
422
459
|
}
|
|
423
460
|
}
|
|
461
|
+
if (configObj.retry !== void 0) {
|
|
462
|
+
this.validateRetryConfig(configObj.retry, "retry config");
|
|
463
|
+
}
|
|
424
464
|
if (configObj.parallelism !== void 0) {
|
|
425
465
|
this.validateParallelismConfig(configObj.parallelism, "global");
|
|
426
466
|
}
|
|
@@ -431,6 +471,47 @@ var ConfigLoaderService = class {
|
|
|
431
471
|
}
|
|
432
472
|
}
|
|
433
473
|
}
|
|
474
|
+
validateDepth(value, field) {
|
|
475
|
+
if (value === void 0) return;
|
|
476
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
477
|
+
throw new ConfigValidationError(field, "must be a positive safe integer");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
validateRetryConfig(value, context) {
|
|
481
|
+
if (typeof value !== "object" || value === null) {
|
|
482
|
+
throw new Error(context === "retry config" ? "'retry' must be an object" : `Invalid 'retry' in ${context}`);
|
|
483
|
+
}
|
|
484
|
+
const retry2 = value;
|
|
485
|
+
if (retry2.maxAttempts !== void 0) {
|
|
486
|
+
if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
|
|
487
|
+
throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (retry2.maxLfsRetries !== void 0) {
|
|
491
|
+
if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
|
|
492
|
+
throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
|
|
496
|
+
throw new Error("Invalid 'initialDelayMs' in retry config");
|
|
497
|
+
}
|
|
498
|
+
if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
|
|
499
|
+
throw new Error("Invalid 'maxDelayMs' in retry config");
|
|
500
|
+
}
|
|
501
|
+
if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
|
|
502
|
+
throw new Error("Invalid 'backoffMultiplier' in retry config");
|
|
503
|
+
}
|
|
504
|
+
if (retry2.jitterMs !== void 0 && (typeof retry2.jitterMs !== "number" || retry2.jitterMs < 0)) {
|
|
505
|
+
throw new Error("Invalid 'jitterMs' in retry config");
|
|
506
|
+
}
|
|
507
|
+
const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
|
|
508
|
+
const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
|
|
509
|
+
if (initialDelay > maxDelay) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
434
515
|
validateParallelismConfig(parallelism, context) {
|
|
435
516
|
if (typeof parallelism !== "object" || parallelism === null) {
|
|
436
517
|
throw new Error(`'parallelism' in ${context} must be an object`);
|
|
@@ -531,6 +612,50 @@ var ConfigLoaderService = class {
|
|
|
531
612
|
}
|
|
532
613
|
}
|
|
533
614
|
}
|
|
615
|
+
validateRepositoryMode(repoObj, defaults) {
|
|
616
|
+
const repoName = repoObj.name;
|
|
617
|
+
const repoMode = repoObj.mode;
|
|
618
|
+
if (repoMode !== void 0 && !isRepositoryMode(repoMode)) {
|
|
619
|
+
throw new ConfigValidationError(`Repository '${repoName}' mode`, "must be 'clone' or 'worktree'");
|
|
620
|
+
}
|
|
621
|
+
if (repoObj.branch !== void 0 && (typeof repoObj.branch !== "string" || repoObj.branch.trim() === "")) {
|
|
622
|
+
throw new ConfigValidationError(`Repository '${repoName}' branch`, "must be a non-empty string");
|
|
623
|
+
}
|
|
624
|
+
const effectiveMode = repoMode ?? defaults?.mode;
|
|
625
|
+
if (effectiveMode !== REPOSITORY_MODES.CLONE) {
|
|
626
|
+
const depthFromRepo = repoObj.depth;
|
|
627
|
+
const depthFromDefaults = defaults?.depth;
|
|
628
|
+
if (depthFromRepo !== void 0 || depthFromDefaults !== void 0) {
|
|
629
|
+
const source = depthFromRepo !== void 0 ? "repository" : "defaults";
|
|
630
|
+
throw new ConfigValidationError(
|
|
631
|
+
`Repository '${repoName}' depth`,
|
|
632
|
+
`only supported when mode is 'clone' (set on ${source})`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
const branchFromRepo = repoObj.branch;
|
|
636
|
+
const branchFromDefaults = defaults?.branch;
|
|
637
|
+
if (branchFromRepo !== void 0 || branchFromDefaults !== void 0) {
|
|
638
|
+
const source = branchFromRepo !== void 0 ? "repository" : "defaults";
|
|
639
|
+
throw new ConfigValidationError(
|
|
640
|
+
`Repository '${repoName}' branch`,
|
|
641
|
+
`only supported when mode is 'clone' (set on ${source})`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
for (const field of CLONE_MODE_CONFLICTING_FIELDS) {
|
|
647
|
+
const fromRepo = repoObj[field];
|
|
648
|
+
const fromDefaults = defaults?.[field];
|
|
649
|
+
const present = fromRepo !== void 0 || fromDefaults !== void 0;
|
|
650
|
+
if (present) {
|
|
651
|
+
const source = fromRepo !== void 0 ? "repository" : "defaults";
|
|
652
|
+
throw new ConfigValidationError(
|
|
653
|
+
`Repository '${repoName}' ${field}`,
|
|
654
|
+
`not supported when mode is 'clone' (set on ${source})`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
534
659
|
validateHooksConfig(hooks, context) {
|
|
535
660
|
if (typeof hooks !== "object" || hooks === null) {
|
|
536
661
|
throw new Error(`'hooks' in ${context} must be an object`);
|
|
@@ -551,29 +676,47 @@ var ConfigLoaderService = class {
|
|
|
551
676
|
}
|
|
552
677
|
}
|
|
553
678
|
resolveRepositoryConfig(repo, defaults, configDir, globalRetry, allRepositories) {
|
|
679
|
+
const mode = repo.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
|
|
554
680
|
const resolved = {
|
|
555
681
|
name: repo.name,
|
|
556
682
|
repoUrl: repo.repoUrl,
|
|
557
683
|
worktreeDir: this.resolvePath(repo.worktreeDir, configDir),
|
|
558
684
|
cronSchedule: repo.cronSchedule ?? defaults?.cronSchedule ?? DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
559
|
-
runOnce: repo.runOnce ?? defaults?.runOnce ?? false
|
|
685
|
+
runOnce: repo.runOnce ?? defaults?.runOnce ?? false,
|
|
686
|
+
debug: repo.debug ?? defaults?.debug,
|
|
687
|
+
mode
|
|
560
688
|
};
|
|
561
|
-
if (
|
|
562
|
-
resolved.
|
|
563
|
-
} else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories)) {
|
|
564
|
-
const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
|
|
565
|
-
resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
|
|
566
|
-
} else {
|
|
567
|
-
resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
|
|
689
|
+
if (configDir) {
|
|
690
|
+
resolved.__configFileDir = configDir;
|
|
568
691
|
}
|
|
569
|
-
if (
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
692
|
+
if (mode === REPOSITORY_MODES.CLONE) {
|
|
693
|
+
if (repo.branch ?? defaults?.branch) {
|
|
694
|
+
resolved.branch = repo.branch ?? defaults?.branch;
|
|
695
|
+
}
|
|
696
|
+
if (repo.depth !== void 0 || defaults?.depth !== void 0) {
|
|
697
|
+
resolved.depth = repo.depth ?? defaults?.depth;
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
if (repo.bareRepoDir) {
|
|
701
|
+
resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
|
|
702
|
+
} else if (allRepositories && this.isDuplicateRepoUrl(repo, allRepositories, defaults)) {
|
|
703
|
+
const sanitized = sanitizeNameForPath(repo.name, `Repository '${repo.name}' name`);
|
|
704
|
+
resolved.bareRepoDir = this.resolvePath(`.bare/${sanitized}`, configDir);
|
|
705
|
+
} else {
|
|
706
|
+
resolved.bareRepoDir = this.resolvePath(getDefaultBareRepoDir(repo.repoUrl), configDir);
|
|
707
|
+
}
|
|
708
|
+
if (repo.branchMaxAge || defaults?.branchMaxAge) {
|
|
709
|
+
resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
|
|
710
|
+
}
|
|
711
|
+
if (repo.branchInclude || defaults?.branchInclude) {
|
|
712
|
+
resolved.branchInclude = repo.branchInclude ?? defaults?.branchInclude;
|
|
713
|
+
}
|
|
714
|
+
if (repo.branchExclude || defaults?.branchExclude) {
|
|
715
|
+
resolved.branchExclude = repo.branchExclude ?? defaults?.branchExclude;
|
|
716
|
+
}
|
|
717
|
+
if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
|
|
718
|
+
resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
|
|
719
|
+
}
|
|
577
720
|
}
|
|
578
721
|
if (repo.skipLfs !== void 0 || defaults?.skipLfs !== void 0) {
|
|
579
722
|
resolved.skipLfs = repo.skipLfs ?? defaults?.skipLfs ?? false;
|
|
@@ -591,9 +734,6 @@ var ConfigLoaderService = class {
|
|
|
591
734
|
...repo.parallelism || {}
|
|
592
735
|
};
|
|
593
736
|
}
|
|
594
|
-
if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
|
|
595
|
-
resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
|
|
596
|
-
}
|
|
597
737
|
if (repo.filesToCopyOnBranchCreate || defaults?.filesToCopyOnBranchCreate) {
|
|
598
738
|
const files = repo.filesToCopyOnBranchCreate ?? defaults?.filesToCopyOnBranchCreate;
|
|
599
739
|
resolved.filesToCopyOnBranchCreate = files?.map((f) => this.resolvePath(f, configDir));
|
|
@@ -610,8 +750,11 @@ var ConfigLoaderService = class {
|
|
|
610
750
|
}
|
|
611
751
|
return resolved;
|
|
612
752
|
}
|
|
613
|
-
isDuplicateRepoUrl(repo, all) {
|
|
614
|
-
const firstIndex = all.findIndex((r) =>
|
|
753
|
+
isDuplicateRepoUrl(repo, all, defaults) {
|
|
754
|
+
const firstIndex = all.findIndex((r) => {
|
|
755
|
+
const mode = r.mode ?? defaults?.mode ?? REPOSITORY_MODES.WORKTREE;
|
|
756
|
+
return r.repoUrl === repo.repoUrl && mode === REPOSITORY_MODES.WORKTREE;
|
|
757
|
+
});
|
|
615
758
|
const myIndex = all.indexOf(repo);
|
|
616
759
|
return firstIndex !== -1 && myIndex !== -1 && myIndex !== firstIndex;
|
|
617
760
|
}
|
|
@@ -662,19 +805,13 @@ var ConfigLoaderService = class {
|
|
|
662
805
|
if (overrides?.filter) {
|
|
663
806
|
repositories = this.filterRepositories(repositories, overrides.filter);
|
|
664
807
|
}
|
|
665
|
-
if (overrides?.noUpdateExisting) {
|
|
666
|
-
repositories = repositories.map((repo) => ({ ...repo, updateExistingWorktrees: false }));
|
|
667
|
-
}
|
|
668
|
-
if (overrides?.debug) {
|
|
669
|
-
repositories = repositories.map((repo) => ({ ...repo, debug: true }));
|
|
670
|
-
}
|
|
671
808
|
return { repositories, configFile, configDir };
|
|
672
809
|
}
|
|
673
810
|
};
|
|
674
811
|
|
|
675
812
|
// src/services/InteractiveUIService.tsx
|
|
676
813
|
import React8 from "react";
|
|
677
|
-
import * as
|
|
814
|
+
import * as path14 from "path";
|
|
678
815
|
import { render } from "ink";
|
|
679
816
|
import * as cron2 from "node-cron";
|
|
680
817
|
import pLimit2 from "p-limit";
|
|
@@ -1309,6 +1446,14 @@ function isLfsError(errorMessage) {
|
|
|
1309
1446
|
function isLfsErrorFromError(error) {
|
|
1310
1447
|
return isLfsError(getErrorMessage(error));
|
|
1311
1448
|
}
|
|
1449
|
+
var MISSING_REMOTE_REF_PATTERNS = Object.freeze([
|
|
1450
|
+
"couldn't find remote ref",
|
|
1451
|
+
"Couldn't find remote ref",
|
|
1452
|
+
"not our ref"
|
|
1453
|
+
]);
|
|
1454
|
+
function isMissingRemoteRefError(errorMessage) {
|
|
1455
|
+
return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
|
|
1456
|
+
}
|
|
1312
1457
|
|
|
1313
1458
|
// src/components/WorktreeStatusView.tsx
|
|
1314
1459
|
var getStatusFlags = (status) => {
|
|
@@ -1772,7 +1917,7 @@ var App = ({
|
|
|
1772
1917
|
const [diskSpaceUsed, setDiskSpaceUsed] = useState6(null);
|
|
1773
1918
|
const [logs, setLogs] = useState6([]);
|
|
1774
1919
|
const [repoCount, setRepoCount] = useState6(repositoryCount);
|
|
1775
|
-
const [
|
|
1920
|
+
const [schedule2, setSchedule] = useState6(cronSchedule);
|
|
1776
1921
|
const { stdout } = useStdout();
|
|
1777
1922
|
const addLog = useCallback4((message, level = "info") => {
|
|
1778
1923
|
setLogs((prev) => {
|
|
@@ -1941,67 +2086,13 @@ var App = ({
|
|
|
1941
2086
|
status,
|
|
1942
2087
|
repositoryCount: repoCount,
|
|
1943
2088
|
lastSyncTime,
|
|
1944
|
-
cronSchedule:
|
|
2089
|
+
cronSchedule: schedule2,
|
|
1945
2090
|
diskSpaceUsed: diskSpaceUsed ?? void 0
|
|
1946
2091
|
}
|
|
1947
2092
|
));
|
|
1948
2093
|
};
|
|
1949
2094
|
var App_default = App;
|
|
1950
2095
|
|
|
1951
|
-
// src/services/worktree-sync.service.ts
|
|
1952
|
-
import * as fs6 from "fs/promises";
|
|
1953
|
-
import * as path8 from "path";
|
|
1954
|
-
import pLimit from "p-limit";
|
|
1955
|
-
import * as lockfile from "proper-lockfile";
|
|
1956
|
-
|
|
1957
|
-
// src/utils/date-filter.ts
|
|
1958
|
-
function parseDuration(durationStr) {
|
|
1959
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
1960
|
-
if (!match) {
|
|
1961
|
-
return null;
|
|
1962
|
-
}
|
|
1963
|
-
const value = parseInt(match[1], 10);
|
|
1964
|
-
const unit = match[2];
|
|
1965
|
-
const multipliers = {
|
|
1966
|
-
h: 60 * 60 * 1e3,
|
|
1967
|
-
// hours
|
|
1968
|
-
d: 24 * 60 * 60 * 1e3,
|
|
1969
|
-
// days
|
|
1970
|
-
w: 7 * 24 * 60 * 60 * 1e3,
|
|
1971
|
-
// weeks
|
|
1972
|
-
m: 30 * 24 * 60 * 60 * 1e3,
|
|
1973
|
-
// months (approximate)
|
|
1974
|
-
y: 365 * 24 * 60 * 60 * 1e3
|
|
1975
|
-
// years (approximate)
|
|
1976
|
-
};
|
|
1977
|
-
return value * multipliers[unit];
|
|
1978
|
-
}
|
|
1979
|
-
function filterBranchesByAge(branches, maxAge) {
|
|
1980
|
-
const maxAgeMs = parseDuration(maxAge);
|
|
1981
|
-
if (maxAgeMs === null) {
|
|
1982
|
-
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
1983
|
-
return branches;
|
|
1984
|
-
}
|
|
1985
|
-
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
1986
|
-
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
1987
|
-
}
|
|
1988
|
-
function formatDuration(durationStr) {
|
|
1989
|
-
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
1990
|
-
if (!match) {
|
|
1991
|
-
return durationStr;
|
|
1992
|
-
}
|
|
1993
|
-
const value = parseInt(match[1], 10);
|
|
1994
|
-
const unit = match[2];
|
|
1995
|
-
const unitNames = {
|
|
1996
|
-
h: value === 1 ? "hour" : "hours",
|
|
1997
|
-
d: value === 1 ? "day" : "days",
|
|
1998
|
-
w: value === 1 ? "week" : "weeks",
|
|
1999
|
-
m: value === 1 ? "month" : "months",
|
|
2000
|
-
y: value === 1 ? "year" : "years"
|
|
2001
|
-
};
|
|
2002
|
-
return `${value} ${unitNames[unit]}`;
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
2096
|
// src/utils/retry.ts
|
|
2006
2097
|
var DEFAULT_OPTIONS = {
|
|
2007
2098
|
maxAttempts: "unlimited",
|
|
@@ -2072,7 +2163,7 @@ async function retry(fn, options = {}) {
|
|
|
2072
2163
|
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
2073
2164
|
const delay = baseDelay + jitter;
|
|
2074
2165
|
opts.onRetry(error, attempt, lfsContext);
|
|
2075
|
-
await new Promise((
|
|
2166
|
+
await new Promise((resolve12) => setTimeout(resolve12, delay));
|
|
2076
2167
|
attempt++;
|
|
2077
2168
|
}
|
|
2078
2169
|
}
|
|
@@ -2143,7 +2234,7 @@ var PhaseTimer = class {
|
|
|
2143
2234
|
return results;
|
|
2144
2235
|
}
|
|
2145
2236
|
};
|
|
2146
|
-
function
|
|
2237
|
+
function formatDuration(ms) {
|
|
2147
2238
|
if (ms < 1e3) {
|
|
2148
2239
|
return `${ms}ms`;
|
|
2149
2240
|
}
|
|
@@ -2165,7 +2256,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2165
2256
|
}
|
|
2166
2257
|
});
|
|
2167
2258
|
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
2168
|
-
table.push(["Total Sync",
|
|
2259
|
+
table.push(["Total Sync", formatDuration(totalDuration), ""]);
|
|
2169
2260
|
for (let i = 0; i < phaseResults.length; i++) {
|
|
2170
2261
|
const result = phaseResults[i];
|
|
2171
2262
|
const isLast = i === phaseResults.length - 1;
|
|
@@ -2173,34 +2264,888 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2173
2264
|
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
2174
2265
|
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
2175
2266
|
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
2176
|
-
table.push([name,
|
|
2267
|
+
table.push([name, formatDuration(result.duration), efficiency]);
|
|
2177
2268
|
}
|
|
2178
2269
|
return table.toString();
|
|
2179
2270
|
}
|
|
2180
2271
|
|
|
2181
|
-
// src/services/
|
|
2182
|
-
import * as
|
|
2183
|
-
import * as
|
|
2184
|
-
import
|
|
2272
|
+
// src/services/clone-sync.service.ts
|
|
2273
|
+
import * as fs3 from "fs/promises";
|
|
2274
|
+
import * as path4 from "path";
|
|
2275
|
+
import simpleGit from "simple-git";
|
|
2185
2276
|
|
|
2186
|
-
// src/utils/
|
|
2187
|
-
function
|
|
2188
|
-
const
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2277
|
+
// src/utils/git-progress.ts
|
|
2278
|
+
function makeGitProgressHandler(logger, emitProgress) {
|
|
2279
|
+
const lastBucket = /* @__PURE__ */ new Map();
|
|
2280
|
+
return (event) => {
|
|
2281
|
+
if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
|
|
2282
|
+
const key = `${event.method}:${event.stage}`;
|
|
2283
|
+
const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
|
|
2284
|
+
let last = lastBucket.get(key) ?? -1;
|
|
2285
|
+
if (bucket < last) last = -1;
|
|
2286
|
+
if (bucket <= last && event.progress < 100) return;
|
|
2287
|
+
lastBucket.set(key, bucket);
|
|
2288
|
+
const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
|
|
2289
|
+
const message = `${event.method} ${event.stage}: ${event.progress}% (${total})`;
|
|
2290
|
+
logger.info(` \u21B3 ${message}`);
|
|
2291
|
+
emitProgress?.({
|
|
2292
|
+
phase: event.method,
|
|
2293
|
+
message,
|
|
2294
|
+
progress: event.progress,
|
|
2295
|
+
processed: event.processed,
|
|
2296
|
+
total: event.total
|
|
2202
2297
|
});
|
|
2203
|
-
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// src/services/file-copy.service.ts
|
|
2302
|
+
import * as fs2 from "fs/promises";
|
|
2303
|
+
import * as path3 from "path";
|
|
2304
|
+
import { glob } from "glob";
|
|
2305
|
+
var DEFAULT_IGNORE_PATTERNS = [
|
|
2306
|
+
"**/node_modules/**",
|
|
2307
|
+
"**/.git/**",
|
|
2308
|
+
"**/dist/**",
|
|
2309
|
+
"**/build/**",
|
|
2310
|
+
"**/.next/**",
|
|
2311
|
+
"**/coverage/**"
|
|
2312
|
+
];
|
|
2313
|
+
var FileCopyService = class {
|
|
2314
|
+
/**
|
|
2315
|
+
* Copy files matching patterns from source to destination directory.
|
|
2316
|
+
* Skips files that already exist at destination.
|
|
2317
|
+
* Preserves directory structure relative to source.
|
|
2318
|
+
*/
|
|
2319
|
+
async copyFiles(sourceDir, destDir, patterns) {
|
|
2320
|
+
const result = {
|
|
2321
|
+
copied: [],
|
|
2322
|
+
skipped: [],
|
|
2323
|
+
errors: []
|
|
2324
|
+
};
|
|
2325
|
+
if (!patterns || patterns.length === 0) {
|
|
2326
|
+
return result;
|
|
2327
|
+
}
|
|
2328
|
+
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
2329
|
+
for (const relativePath of filesToCopy) {
|
|
2330
|
+
const sourcePath = path3.join(sourceDir, relativePath);
|
|
2331
|
+
const destPath = path3.join(destDir, relativePath);
|
|
2332
|
+
try {
|
|
2333
|
+
const copied = await this.copyFile(sourcePath, destPath);
|
|
2334
|
+
if (copied) {
|
|
2335
|
+
result.copied.push(relativePath);
|
|
2336
|
+
} else {
|
|
2337
|
+
result.skipped.push(relativePath);
|
|
2338
|
+
}
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
result.errors.push({
|
|
2341
|
+
file: relativePath,
|
|
2342
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
return result;
|
|
2347
|
+
}
|
|
2348
|
+
async expandPatterns(sourceDir, patterns) {
|
|
2349
|
+
const allFiles = /* @__PURE__ */ new Set();
|
|
2350
|
+
for (const pattern of patterns) {
|
|
2351
|
+
try {
|
|
2352
|
+
const matches = await glob(pattern, {
|
|
2353
|
+
cwd: sourceDir,
|
|
2354
|
+
nodir: true,
|
|
2355
|
+
dot: true,
|
|
2356
|
+
ignore: DEFAULT_IGNORE_PATTERNS
|
|
2357
|
+
});
|
|
2358
|
+
for (const match of matches) {
|
|
2359
|
+
allFiles.add(match);
|
|
2360
|
+
}
|
|
2361
|
+
} catch {
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
return Array.from(allFiles);
|
|
2365
|
+
}
|
|
2366
|
+
async copyFile(sourcePath, destPath) {
|
|
2367
|
+
if (await fileExists(destPath)) {
|
|
2368
|
+
return false;
|
|
2369
|
+
}
|
|
2370
|
+
const destDir = path3.dirname(destPath);
|
|
2371
|
+
await fs2.mkdir(destDir, { recursive: true });
|
|
2372
|
+
await fs2.copyFile(sourcePath, destPath);
|
|
2373
|
+
return true;
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
|
|
2377
|
+
// src/services/branch-created-actions.service.ts
|
|
2378
|
+
var BranchCreatedActionsService = class {
|
|
2379
|
+
fileCopyService;
|
|
2380
|
+
constructor(fileCopyService) {
|
|
2381
|
+
this.fileCopyService = fileCopyService ?? new FileCopyService();
|
|
2382
|
+
}
|
|
2383
|
+
async copyFiles(params) {
|
|
2384
|
+
const { config, sourceDir, worktreePath, branchName, logger } = params;
|
|
2385
|
+
const patterns = config.filesToCopyOnBranchCreate;
|
|
2386
|
+
if (!patterns?.length) return;
|
|
2387
|
+
try {
|
|
2388
|
+
const result = await this.fileCopyService.copyFiles(sourceDir, worktreePath, patterns);
|
|
2389
|
+
if (result.copied.length > 0) {
|
|
2390
|
+
logger.info(`\u{1F4CB} Copied ${result.copied.length} file(s) to '${branchName}': ${result.copied.join(", ")}`);
|
|
2391
|
+
}
|
|
2392
|
+
if (result.errors.length > 0) {
|
|
2393
|
+
logger.warn(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s) to '${branchName}':`);
|
|
2394
|
+
for (const err of result.errors) {
|
|
2395
|
+
logger.warn(` - ${err.file}: ${err.error}`);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
} catch (error) {
|
|
2399
|
+
logger.error(`Failed to copy files to '${branchName}': ${error}`);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
runHooks(params) {
|
|
2403
|
+
const { config, branchName, worktreePath, repoName, baseBranch, logger, hookExecutionService } = params;
|
|
2404
|
+
if (!config.hooks?.onBranchCreated?.length) return;
|
|
2405
|
+
const context = {
|
|
2406
|
+
branchName,
|
|
2407
|
+
worktreePath,
|
|
2408
|
+
repoName,
|
|
2409
|
+
baseBranch,
|
|
2410
|
+
repoUrl: config.repoUrl
|
|
2411
|
+
};
|
|
2412
|
+
logger.info(`Running ${config.hooks.onBranchCreated.length} hook(s) for branch '${branchName}'...`);
|
|
2413
|
+
hookExecutionService.executeOnBranchCreated(config.hooks, context, {
|
|
2414
|
+
onStdout: (data) => logger.info(`[hook] ${data}`),
|
|
2415
|
+
onStderr: (data) => logger.warn(`[hook] ${data}`),
|
|
2416
|
+
onError: (command, error) => logger.error(`[hook] Failed to execute '${command}': ${error.message}`),
|
|
2417
|
+
onComplete: (command, exitCode) => {
|
|
2418
|
+
if (exitCode === 0) {
|
|
2419
|
+
logger.info(`[hook] Command completed successfully`);
|
|
2420
|
+
} else if (exitCode !== null) {
|
|
2421
|
+
logger.warn(`[hook] Command exited with code ${exitCode}`);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
};
|
|
2427
|
+
|
|
2428
|
+
// src/utils/clone-skip-format.ts
|
|
2429
|
+
function formatCloneSkipReason(reason) {
|
|
2430
|
+
switch (reason.kind) {
|
|
2431
|
+
case "branch_mismatch":
|
|
2432
|
+
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
|
|
2433
|
+
case "head_unreadable":
|
|
2434
|
+
return `could not read HEAD: ${reason.error}`;
|
|
2435
|
+
case "dirty_tree":
|
|
2436
|
+
return `working tree has local changes`;
|
|
2437
|
+
case "diverged":
|
|
2438
|
+
return `diverged from origin/${reason.branch}`;
|
|
2439
|
+
case "ahead_unpushed":
|
|
2440
|
+
return `unpushed commits ahead of origin/${reason.branch}`;
|
|
2441
|
+
case "missing_remote_ref":
|
|
2442
|
+
return reason.source === "fetch_error" ? `origin/${reason.branch} missing on remote (fetch error)` : `origin/${reason.branch} pruned after fetch`;
|
|
2443
|
+
case "indeterminate_shallow":
|
|
2444
|
+
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`;
|
|
2445
|
+
case "origin_mismatch":
|
|
2446
|
+
return `clone origin is '${reason.actual}', expected '${reason.expected}'`;
|
|
2447
|
+
default: {
|
|
2448
|
+
const _exhaustive = reason;
|
|
2449
|
+
return _exhaustive;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// src/services/sync-outcome.ts
|
|
2455
|
+
var EMPTY_COUNTS = {
|
|
2456
|
+
created: 0,
|
|
2457
|
+
removed: 0,
|
|
2458
|
+
updated: 0,
|
|
2459
|
+
skipped: 0,
|
|
2460
|
+
preserved: 0,
|
|
2461
|
+
failed: 0,
|
|
2462
|
+
noop: 0
|
|
2463
|
+
};
|
|
2464
|
+
function cloneCounts(counts) {
|
|
2465
|
+
return { ...counts };
|
|
2466
|
+
}
|
|
2467
|
+
function cloneAction(action) {
|
|
2468
|
+
return { ...action };
|
|
2469
|
+
}
|
|
2470
|
+
function countKeyFor(action) {
|
|
2471
|
+
switch (action.kind) {
|
|
2472
|
+
case "created":
|
|
2473
|
+
return "created";
|
|
2474
|
+
case "removed":
|
|
2475
|
+
return "removed";
|
|
2476
|
+
case "updated":
|
|
2477
|
+
return "updated";
|
|
2478
|
+
case "skipped":
|
|
2479
|
+
return "skipped";
|
|
2480
|
+
case "preserved-diverged":
|
|
2481
|
+
return "preserved";
|
|
2482
|
+
case "failed":
|
|
2483
|
+
return "failed";
|
|
2484
|
+
case "noop":
|
|
2485
|
+
return "noop";
|
|
2486
|
+
default: {
|
|
2487
|
+
const _exhaustive = action;
|
|
2488
|
+
return _exhaustive;
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
var SyncOutcomeAccumulator = class {
|
|
2493
|
+
constructor(options) {
|
|
2494
|
+
this.options = options;
|
|
2495
|
+
}
|
|
2496
|
+
counts = cloneCounts(EMPTY_COUNTS);
|
|
2497
|
+
actions = [];
|
|
2498
|
+
add(action) {
|
|
2499
|
+
this.actions.push(action);
|
|
2500
|
+
this.counts[countKeyFor(action)]++;
|
|
2501
|
+
}
|
|
2502
|
+
recordCreated(branch, path18) {
|
|
2503
|
+
this.add({ kind: "created", branch, path: path18 });
|
|
2504
|
+
}
|
|
2505
|
+
recordRemoved(branch, path18) {
|
|
2506
|
+
this.add({ kind: "removed", branch, path: path18 });
|
|
2507
|
+
}
|
|
2508
|
+
recordUpdated(branch, path18, reason) {
|
|
2509
|
+
this.add({ kind: "updated", branch, path: path18, reason });
|
|
2510
|
+
}
|
|
2511
|
+
recordNoop(scope, reason, details) {
|
|
2512
|
+
this.add({ kind: "noop", scope, reason, ...details });
|
|
2513
|
+
}
|
|
2514
|
+
recordSkipped(scope, reason, details) {
|
|
2515
|
+
this.add({ kind: "skipped", scope, reason, ...details });
|
|
2516
|
+
}
|
|
2517
|
+
recordPreservedDiverged(branch, path18, preservedPath) {
|
|
2518
|
+
this.add({ kind: "preserved-diverged", branch, path: path18, preservedPath });
|
|
2519
|
+
}
|
|
2520
|
+
recordFailed(scope, error, details = {}) {
|
|
2521
|
+
this.add({ kind: "failed", scope, error, ...details });
|
|
2522
|
+
}
|
|
2523
|
+
getCounts() {
|
|
2524
|
+
return cloneCounts(this.counts);
|
|
2525
|
+
}
|
|
2526
|
+
snapshot() {
|
|
2527
|
+
return {
|
|
2528
|
+
counts: cloneCounts(this.counts),
|
|
2529
|
+
actions: this.actions.map(cloneAction)
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
restore(snapshot) {
|
|
2533
|
+
this.counts = cloneCounts(snapshot.counts);
|
|
2534
|
+
this.actions = snapshot.actions.map(cloneAction);
|
|
2535
|
+
}
|
|
2536
|
+
toOutcome(durationMs) {
|
|
2537
|
+
return {
|
|
2538
|
+
repoName: this.options.repoName,
|
|
2539
|
+
mode: this.options.mode,
|
|
2540
|
+
started: true,
|
|
2541
|
+
counts: cloneCounts(this.counts),
|
|
2542
|
+
actions: this.actions.map(cloneAction),
|
|
2543
|
+
durationMs
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
function cloneSkipToOutcomeAction(reason, details = {}) {
|
|
2548
|
+
const message = formatCloneSkipReason(reason);
|
|
2549
|
+
const branch = "branch" in reason ? reason.branch : reason.kind === "branch_mismatch" ? reason.expectedBranch : details.branch;
|
|
2550
|
+
return {
|
|
2551
|
+
kind: "skipped",
|
|
2552
|
+
scope: "repo",
|
|
2553
|
+
reason: `clone_${reason.kind}`,
|
|
2554
|
+
branch,
|
|
2555
|
+
path: details.path,
|
|
2556
|
+
message
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// src/services/clone-sync.service.ts
|
|
2561
|
+
var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
2562
|
+
var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
|
|
2563
|
+
var CloneSyncService = class {
|
|
2564
|
+
constructor(config, gitService, logger, options = {}) {
|
|
2565
|
+
this.config = config;
|
|
2566
|
+
this.gitService = gitService;
|
|
2567
|
+
this.logger = logger;
|
|
2568
|
+
this.branchCreatedActions = options.branchCreatedActions ?? new BranchCreatedActionsService();
|
|
2569
|
+
this.progressEmitter = options.progressEmitter;
|
|
2570
|
+
this.onSkip = options.onSkip;
|
|
2571
|
+
}
|
|
2572
|
+
initialized = false;
|
|
2573
|
+
resolvedBranch = null;
|
|
2574
|
+
branchCreatedActions;
|
|
2575
|
+
progressEmitter;
|
|
2576
|
+
onSkip;
|
|
2577
|
+
outcomeAccumulator;
|
|
2578
|
+
// One-shot suppression token. When init records a wrong-branch / unreadable-HEAD
|
|
2579
|
+
// skip for an existing clone, it sets this so the immediately following
|
|
2580
|
+
// runSyncAttempt (same sync operation) does not record the identical skip again.
|
|
2581
|
+
pendingInitSkip = null;
|
|
2582
|
+
updateLogger(logger) {
|
|
2583
|
+
this.logger = logger;
|
|
2584
|
+
}
|
|
2585
|
+
isInitialized() {
|
|
2586
|
+
return this.initialized;
|
|
2587
|
+
}
|
|
2588
|
+
clearPendingInitSkip() {
|
|
2589
|
+
this.pendingInitSkip = null;
|
|
2590
|
+
}
|
|
2591
|
+
async getWorktrees() {
|
|
2592
|
+
const worktreeDir = path4.resolve(this.config.worktreeDir);
|
|
2593
|
+
if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
|
|
2594
|
+
return [];
|
|
2595
|
+
}
|
|
2596
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2597
|
+
let branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
2598
|
+
if (!branch || branch === "HEAD") {
|
|
2599
|
+
const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
|
|
2600
|
+
branch = head ? `(detached ${head})` : "(detached)";
|
|
2601
|
+
}
|
|
2602
|
+
return [{ path: worktreeDir, branch }];
|
|
2603
|
+
}
|
|
2604
|
+
get repoName() {
|
|
2605
|
+
return this.config.name ?? this.config.repoUrl;
|
|
2606
|
+
}
|
|
2607
|
+
getCloneTimeoutMs() {
|
|
2608
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
2609
|
+
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
2610
|
+
}
|
|
2611
|
+
getFetchTimeoutMs() {
|
|
2612
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
2613
|
+
return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
|
|
2614
|
+
}
|
|
2615
|
+
isLfsSkipEnabled() {
|
|
2616
|
+
return this.config.skipLfs === true;
|
|
2617
|
+
}
|
|
2618
|
+
buildGitOptions(blockMs) {
|
|
2619
|
+
const options = {
|
|
2620
|
+
progress: makeGitProgressHandler(this.logger, (event) => this.emitProgress(event))
|
|
2621
|
+
};
|
|
2622
|
+
if (blockMs > 0) options.timeout = { block: blockMs };
|
|
2623
|
+
return options;
|
|
2624
|
+
}
|
|
2625
|
+
emitProgress(event) {
|
|
2626
|
+
try {
|
|
2627
|
+
this.progressEmitter?.(event);
|
|
2628
|
+
} catch {
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
async withOutcome(outcome, operation) {
|
|
2632
|
+
const previousOutcome = this.outcomeAccumulator;
|
|
2633
|
+
if (outcome) {
|
|
2634
|
+
this.outcomeAccumulator = outcome;
|
|
2635
|
+
}
|
|
2636
|
+
try {
|
|
2637
|
+
return await operation();
|
|
2638
|
+
} finally {
|
|
2639
|
+
if (outcome) {
|
|
2640
|
+
this.outcomeAccumulator = previousOutcome;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
recordSkip(reason, logMessage, progressMessage, logLevel = "warn") {
|
|
2645
|
+
if (logLevel === "warn") {
|
|
2646
|
+
this.logger.warn(logMessage);
|
|
2647
|
+
} else {
|
|
2648
|
+
this.logger.info(logMessage);
|
|
2649
|
+
}
|
|
2650
|
+
this.emitProgress({ phase: "skip", message: progressMessage ?? logMessage });
|
|
2651
|
+
try {
|
|
2652
|
+
this.onSkip?.(reason);
|
|
2653
|
+
} catch {
|
|
2654
|
+
}
|
|
2655
|
+
this.outcomeAccumulator?.add(
|
|
2656
|
+
cloneSkipToOutcomeAction(reason, {
|
|
2657
|
+
branch: this.resolvedBranch ?? this.config.branch,
|
|
2658
|
+
path: this.config.worktreeDir
|
|
2659
|
+
})
|
|
2660
|
+
);
|
|
2661
|
+
}
|
|
2662
|
+
clientFor(dir, blockMs) {
|
|
2663
|
+
return simpleGit(dir, this.buildGitOptions(blockMs)).env(this.buildGitEnv());
|
|
2664
|
+
}
|
|
2665
|
+
// Force a stable C locale so git's stderr is deterministic English. The
|
|
2666
|
+
// missing-remote-ref and LFS error classification matches on those strings
|
|
2667
|
+
// and would otherwise misfire under a non-English LANG/LC_ALL. simple-git's
|
|
2668
|
+
// .env() merges this object with process.env (PATH etc. preserved).
|
|
2669
|
+
buildGitEnv(opts = {}) {
|
|
2670
|
+
const env = { LC_ALL: "C", LANG: "C" };
|
|
2671
|
+
if (opts.forceLfsSkip || this.isLfsSkipEnabled()) {
|
|
2672
|
+
env[ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE] = "1";
|
|
2673
|
+
}
|
|
2674
|
+
return env;
|
|
2675
|
+
}
|
|
2676
|
+
buildCloneArgs(branch) {
|
|
2677
|
+
const args = ["--branch", branch, "--progress"];
|
|
2678
|
+
if (this.config.depth !== void 0) {
|
|
2679
|
+
args.push("--depth", String(this.config.depth), "--no-single-branch");
|
|
2680
|
+
}
|
|
2681
|
+
return args;
|
|
2682
|
+
}
|
|
2683
|
+
async buildFetchArgs(git) {
|
|
2684
|
+
const args = ["origin", "--prune", "--progress"];
|
|
2685
|
+
if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
|
|
2686
|
+
args.push("--depth", String(this.config.depth));
|
|
2687
|
+
}
|
|
2688
|
+
return args;
|
|
2689
|
+
}
|
|
2690
|
+
async ensureAllRemoteBranchesRefspec(git) {
|
|
2691
|
+
let fetchRefspecs = [];
|
|
2692
|
+
try {
|
|
2693
|
+
const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
|
|
2694
|
+
fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2695
|
+
} catch {
|
|
2696
|
+
fetchRefspecs = [];
|
|
2697
|
+
}
|
|
2698
|
+
if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
|
|
2699
|
+
const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
|
|
2700
|
+
this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
|
|
2701
|
+
await git.raw(["remote", "set-branches", "origin", "*"]);
|
|
2702
|
+
for (const refspec of customRefspecs) {
|
|
2703
|
+
await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
isOriginRemoteBranchTrackingRefspec(refspec) {
|
|
2707
|
+
const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
|
|
2708
|
+
if (withoutForce.startsWith("^")) return false;
|
|
2709
|
+
const [source, destination] = withoutForce.split(":");
|
|
2710
|
+
return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
|
|
2711
|
+
}
|
|
2712
|
+
recordMissingRemoteRefSkip(branch) {
|
|
2713
|
+
this.recordSkip(
|
|
2714
|
+
{ kind: "missing_remote_ref", branch, source: "fetch_error" },
|
|
2715
|
+
`Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
|
|
2716
|
+
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
|
|
2720
|
+
try {
|
|
2721
|
+
await git.fetch(fetchArgs);
|
|
2722
|
+
return { skipped: false };
|
|
2723
|
+
} catch (fetchError) {
|
|
2724
|
+
const message = getErrorMessage(fetchError);
|
|
2725
|
+
if (isLfsError(message)) {
|
|
2726
|
+
this.logger.info(`\u26A0\uFE0F LFS error during fetch for '${this.repoName}'; retrying with LFS disabled.`);
|
|
2727
|
+
this.emitProgress({ phase: "fetch", message: `Retrying fetch for '${this.repoName}' with LFS disabled` });
|
|
2728
|
+
const lfsSkipGit = simpleGit(worktreeDir, this.buildGitOptions(this.getFetchTimeoutMs())).env(
|
|
2729
|
+
this.buildGitEnv({ forceLfsSkip: true })
|
|
2730
|
+
);
|
|
2731
|
+
try {
|
|
2732
|
+
await lfsSkipGit.fetch(fetchArgs);
|
|
2733
|
+
return { skipped: false };
|
|
2734
|
+
} catch (retryError) {
|
|
2735
|
+
if (isMissingRemoteRefError(getErrorMessage(retryError))) {
|
|
2736
|
+
this.recordMissingRemoteRefSkip(branch);
|
|
2737
|
+
return { skipped: true };
|
|
2738
|
+
}
|
|
2739
|
+
throw retryError;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (isMissingRemoteRefError(message)) {
|
|
2743
|
+
this.recordMissingRemoteRefSkip(branch);
|
|
2744
|
+
return { skipped: true };
|
|
2745
|
+
}
|
|
2746
|
+
throw fetchError;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
async hasRemoteBranch(git, branch) {
|
|
2750
|
+
try {
|
|
2751
|
+
await git.raw(["show-ref", "--verify", `refs/remotes/origin/${branch}`]);
|
|
2752
|
+
return true;
|
|
2753
|
+
} catch {
|
|
2754
|
+
return false;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
async isShallowRepository(git) {
|
|
2758
|
+
try {
|
|
2759
|
+
const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
|
|
2760
|
+
return output.trim() === "true";
|
|
2761
|
+
} catch {
|
|
2762
|
+
return false;
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
async unshallowIfDepthRemoved(git) {
|
|
2766
|
+
if (this.config.depth !== void 0) return;
|
|
2767
|
+
if (!await this.isShallowRepository(git)) return;
|
|
2768
|
+
this.logger.info(
|
|
2769
|
+
`[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
|
|
2770
|
+
);
|
|
2771
|
+
await git.fetch(["--unshallow"]);
|
|
2772
|
+
}
|
|
2773
|
+
getDeepenTargets() {
|
|
2774
|
+
const configuredDepth = this.config.depth;
|
|
2775
|
+
if (configuredDepth === void 0) return [];
|
|
2776
|
+
return SHALLOW_RELATION_DEEPEN_TARGETS.filter((target) => target > configuredDepth);
|
|
2777
|
+
}
|
|
2778
|
+
async deepenShallowHistoryToDepth(git, branch, targetDepth) {
|
|
2779
|
+
this.logger.info(
|
|
2780
|
+
`[deepen] Shallow clone for '${this.repoName}' lacks enough history to classify origin/${branch}; refetching to depth ${targetDepth} before deciding.`
|
|
2781
|
+
);
|
|
2782
|
+
this.emitProgress({
|
|
2783
|
+
phase: "fetch",
|
|
2784
|
+
message: `Deepening '${this.repoName}' to depth ${targetDepth} before classifying origin/${branch}`
|
|
2785
|
+
});
|
|
2786
|
+
await git.fetch([
|
|
2787
|
+
"origin",
|
|
2788
|
+
"--depth",
|
|
2789
|
+
String(targetDepth),
|
|
2790
|
+
"--prune",
|
|
2791
|
+
"--progress",
|
|
2792
|
+
`+refs/heads/${branch}:refs/remotes/origin/${branch}`
|
|
2793
|
+
]);
|
|
2794
|
+
}
|
|
2795
|
+
async resolveBranch() {
|
|
2796
|
+
if (this.resolvedBranch) return this.resolvedBranch;
|
|
2797
|
+
if (this.config.branch) {
|
|
2798
|
+
this.resolvedBranch = this.config.branch;
|
|
2799
|
+
this.emitProgress({ phase: "branch", message: `Using configured branch '${this.resolvedBranch}'` });
|
|
2800
|
+
return this.resolvedBranch;
|
|
2801
|
+
}
|
|
2802
|
+
this.logger.info(`No branch configured for '${this.repoName}', detecting remote default branch...`);
|
|
2803
|
+
this.emitProgress({ phase: "branch", message: `Resolving remote default branch for '${this.repoName}'` });
|
|
2804
|
+
this.resolvedBranch = await this.gitService.getRemoteDefaultBranch(this.config.repoUrl);
|
|
2805
|
+
this.logger.info(` \u21B3 resolved default branch: ${this.resolvedBranch}`);
|
|
2806
|
+
this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
|
|
2807
|
+
return this.resolvedBranch;
|
|
2808
|
+
}
|
|
2809
|
+
async initialize(outcome) {
|
|
2810
|
+
return this.withOutcome(outcome, () => this.initializeInternal());
|
|
2811
|
+
}
|
|
2812
|
+
async initializeInternal() {
|
|
2813
|
+
this.pendingInitSkip = null;
|
|
2814
|
+
const branch = await this.resolveBranch();
|
|
2815
|
+
const worktreeDir = this.config.worktreeDir;
|
|
2816
|
+
let entries = null;
|
|
2817
|
+
try {
|
|
2818
|
+
entries = await fs3.readdir(worktreeDir);
|
|
2819
|
+
} catch {
|
|
2820
|
+
entries = null;
|
|
2821
|
+
}
|
|
2822
|
+
if (entries?.includes(PATH_CONSTANTS.GIT_DIR)) {
|
|
2823
|
+
this.emitProgress({ phase: "clone", message: `Validating existing clone for '${this.repoName}'` });
|
|
2824
|
+
const result = await this.validateExistingClone(branch);
|
|
2825
|
+
if (!result.valid) {
|
|
2826
|
+
this.recordSkip(result.skip, result.warnMessage, `Skipping '${this.repoName}': ${result.progressDetail}`);
|
|
2827
|
+
this.pendingInitSkip = result.skip;
|
|
2828
|
+
this.initialized = true;
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2832
|
+
await this.ensureAllRemoteBranchesRefspec(git);
|
|
2833
|
+
this.initialized = true;
|
|
2834
|
+
this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
if (entries && entries.length > 0) {
|
|
2838
|
+
throw new ConfigError(
|
|
2839
|
+
`Cannot clone into '${worktreeDir}': directory exists and is not empty. Remove existing contents or point worktreeDir at an empty path.`,
|
|
2840
|
+
"CLONE_DESTINATION_NOT_EMPTY"
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
const cloneCreatedDir = entries === null;
|
|
2844
|
+
await fs3.mkdir(worktreeDir, { recursive: true });
|
|
2845
|
+
this.logger.info(`Cloning '${this.config.repoUrl}' (${branch}) into '${worktreeDir}'...`);
|
|
2846
|
+
this.emitProgress({ phase: "clone", message: `Cloning '${this.repoName}' (${branch})` });
|
|
2847
|
+
const cloneClient = simpleGit(this.buildGitOptions(this.getCloneTimeoutMs())).env(this.buildGitEnv());
|
|
2848
|
+
try {
|
|
2849
|
+
await cloneClient.clone(this.config.repoUrl, worktreeDir, this.buildCloneArgs(branch));
|
|
2850
|
+
} catch (error) {
|
|
2851
|
+
await this.maybeCleanupPartialClone(worktreeDir, cloneCreatedDir);
|
|
2852
|
+
this.outcomeAccumulator?.recordFailed("repo", getErrorMessage(error), {
|
|
2853
|
+
reason: "clone_failed",
|
|
2854
|
+
branch,
|
|
2855
|
+
path: worktreeDir
|
|
2856
|
+
});
|
|
2857
|
+
throw error;
|
|
2858
|
+
}
|
|
2859
|
+
const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2860
|
+
await this.ensureAllRemoteBranchesRefspec(worktreeGit);
|
|
2861
|
+
this.logger.info(`\u2705 Clone successful.`);
|
|
2862
|
+
this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
|
|
2863
|
+
if (this.config.sparseCheckout) {
|
|
2864
|
+
this.logger.info(`Applying sparse-checkout patterns to '${worktreeDir}'...`);
|
|
2865
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Applying sparse-checkout for '${this.repoName}'` });
|
|
2866
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
2867
|
+
await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
|
|
2868
|
+
await worktreeGit.raw(["checkout", "HEAD"]);
|
|
2869
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout applied for '${this.repoName}'` });
|
|
2870
|
+
}
|
|
2871
|
+
this.emitProgress({ phase: "lfs", message: `Verifying LFS for '${this.repoName}'` });
|
|
2872
|
+
await this.gitService.verifyLfs(worktreeDir, branch);
|
|
2873
|
+
this.emitProgress({ phase: "lfs", message: `LFS verified for '${this.repoName}'` });
|
|
2874
|
+
await this.runInitialFileCopy(worktreeDir, branch);
|
|
2875
|
+
this.outcomeAccumulator?.recordCreated(branch, worktreeDir);
|
|
2876
|
+
this.initialized = true;
|
|
2877
|
+
}
|
|
2878
|
+
// Detects an on-disk clone whose `origin` no longer matches the configured
|
|
2879
|
+
// repoUrl (e.g. repoUrl was repointed in config). Returns a skip descriptor so
|
|
2880
|
+
// we never fetch/ff-merge from the wrong remote; null when origin matches or
|
|
2881
|
+
// can't be read. Comparison is normalized so https/.git/trailing-slash
|
|
2882
|
+
// variants don't false-positive; the raw URLs are kept in the message.
|
|
2883
|
+
async evaluateOriginMatch(git, worktreeDir) {
|
|
2884
|
+
let originUrl;
|
|
2885
|
+
try {
|
|
2886
|
+
originUrl = (await git.raw(["remote", "get-url", "origin"])).trim();
|
|
2887
|
+
} catch {
|
|
2888
|
+
this.logger.warn(`Could not read 'origin' remote URL from existing clone at '${worktreeDir}'.`);
|
|
2889
|
+
return null;
|
|
2890
|
+
}
|
|
2891
|
+
if (!originUrl || normalizeRepoUrlForComparison(originUrl) === normalizeRepoUrlForComparison(this.config.repoUrl)) {
|
|
2892
|
+
return null;
|
|
2893
|
+
}
|
|
2894
|
+
return {
|
|
2895
|
+
skip: { kind: "origin_mismatch", actual: originUrl, expected: this.config.repoUrl },
|
|
2896
|
+
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.`,
|
|
2897
|
+
progressDetail: `origin '${originUrl}' is not '${this.config.repoUrl}'`
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
async validateExistingClone(expectedBranch) {
|
|
2901
|
+
const worktreeDir = this.config.worktreeDir;
|
|
2902
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2903
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
2904
|
+
if (originMismatch) {
|
|
2905
|
+
return { valid: false, ...originMismatch };
|
|
2906
|
+
}
|
|
2907
|
+
let currentBranch;
|
|
2908
|
+
try {
|
|
2909
|
+
currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
2910
|
+
} catch (error) {
|
|
2911
|
+
const errorMessage = getErrorMessage(error);
|
|
2912
|
+
return {
|
|
2913
|
+
valid: false,
|
|
2914
|
+
skip: { kind: "head_unreadable", phase: "init", error: errorMessage },
|
|
2915
|
+
warnMessage: `Existing clone at '${worktreeDir}' has a .git folder but reading HEAD failed: ${errorMessage}`,
|
|
2916
|
+
progressDetail: `could not read HEAD (${errorMessage})`
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
if (currentBranch !== expectedBranch) {
|
|
2920
|
+
return {
|
|
2921
|
+
valid: false,
|
|
2922
|
+
skip: {
|
|
2923
|
+
kind: "branch_mismatch",
|
|
2924
|
+
phase: "init",
|
|
2925
|
+
currentBranch,
|
|
2926
|
+
expectedBranch
|
|
2927
|
+
},
|
|
2928
|
+
warnMessage: `Existing clone at '${worktreeDir}' is on branch '${currentBranch}', expected '${expectedBranch}'. Switch the working tree to '${expectedBranch}' or update the config.`,
|
|
2929
|
+
progressDetail: `current branch '${currentBranch}' is not '${expectedBranch}'`
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
return { valid: true };
|
|
2933
|
+
}
|
|
2934
|
+
async maybeCleanupPartialClone(worktreeDir, cloneCreatedDir) {
|
|
2935
|
+
if (!cloneCreatedDir) {
|
|
2936
|
+
this.logger.warn(
|
|
2937
|
+
`Clone failed; leaving '${worktreeDir}' for manual inspection (directory existed before clone attempt).`
|
|
2938
|
+
);
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
let entries;
|
|
2942
|
+
try {
|
|
2943
|
+
entries = await fs3.readdir(worktreeDir);
|
|
2944
|
+
} catch {
|
|
2945
|
+
return;
|
|
2946
|
+
}
|
|
2947
|
+
const looksIncomplete = entries.every((e) => e.startsWith("."));
|
|
2948
|
+
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
|
|
2949
|
+
if (looksIncomplete && !hasUsableGit) {
|
|
2950
|
+
try {
|
|
2951
|
+
await fs3.rm(worktreeDir, { recursive: true, force: true });
|
|
2952
|
+
this.logger.info(`Cleaned up incomplete clone at '${worktreeDir}'.`);
|
|
2953
|
+
} catch (rmError) {
|
|
2954
|
+
this.logger.warn(`Failed to clean up incomplete clone at '${worktreeDir}': ${getErrorMessage(rmError)}`);
|
|
2955
|
+
}
|
|
2956
|
+
} else {
|
|
2957
|
+
this.logger.warn(
|
|
2958
|
+
`Clone failed; leaving '${worktreeDir}' for manual inspection (post-failure contents do not look like an empty incomplete clone).`
|
|
2959
|
+
);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
getInitMarkerPath(worktreeDir) {
|
|
2963
|
+
return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
|
|
2964
|
+
}
|
|
2965
|
+
async runInitialFileCopy(worktreeDir, branch) {
|
|
2966
|
+
const marker = this.getInitMarkerPath(worktreeDir);
|
|
2967
|
+
if (await fileExists(marker)) {
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
const sourceDir = this.config.__configFileDir ?? worktreeDir;
|
|
2971
|
+
await this.branchCreatedActions.copyFiles({
|
|
2972
|
+
config: this.config,
|
|
2973
|
+
branchName: branch,
|
|
2974
|
+
worktreePath: worktreeDir,
|
|
2975
|
+
sourceDir,
|
|
2976
|
+
logger: this.logger
|
|
2977
|
+
});
|
|
2978
|
+
try {
|
|
2979
|
+
await fs3.writeFile(marker, (/* @__PURE__ */ new Date()).toISOString());
|
|
2980
|
+
} catch (error) {
|
|
2981
|
+
this.logger.warn(`Could not write clone-init marker: ${getErrorMessage(error)}`);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
async runSyncAttempt(outcome) {
|
|
2985
|
+
return this.withOutcome(outcome, () => this.runSyncAttemptInternal());
|
|
2986
|
+
}
|
|
2987
|
+
async runSyncAttemptInternal() {
|
|
2988
|
+
if (!this.initialized) {
|
|
2989
|
+
await this.initialize();
|
|
2990
|
+
this.pendingInitSkip = null;
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
if (this.pendingInitSkip) {
|
|
2994
|
+
this.pendingInitSkip = null;
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
const branch = await this.resolveBranch();
|
|
2998
|
+
const worktreeDir = this.config.worktreeDir;
|
|
2999
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
3000
|
+
let currentBranch;
|
|
3001
|
+
try {
|
|
3002
|
+
currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
3003
|
+
} catch (error) {
|
|
3004
|
+
const errorMessage = getErrorMessage(error);
|
|
3005
|
+
this.recordSkip(
|
|
3006
|
+
{ kind: "head_unreadable", phase: "sync", error: errorMessage },
|
|
3007
|
+
`Could not read current branch from '${worktreeDir}': ${errorMessage}`,
|
|
3008
|
+
`Skipping '${this.repoName}': could not read current branch`
|
|
3009
|
+
);
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
if (currentBranch !== branch) {
|
|
3013
|
+
this.recordSkip(
|
|
3014
|
+
{ kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
|
|
3015
|
+
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
|
|
3016
|
+
`Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
|
|
3017
|
+
);
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
3021
|
+
if (originMismatch) {
|
|
3022
|
+
this.recordSkip(
|
|
3023
|
+
originMismatch.skip,
|
|
3024
|
+
originMismatch.warnMessage,
|
|
3025
|
+
`Skipping '${this.repoName}': ${originMismatch.progressDetail}`
|
|
3026
|
+
);
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
await this.unshallowIfDepthRemoved(git);
|
|
3030
|
+
await this.ensureAllRemoteBranchesRefspec(git);
|
|
3031
|
+
const fetchArgs = await this.buildFetchArgs(git);
|
|
3032
|
+
this.emitProgress({ phase: "fetch", message: `Fetching origin branches for '${this.repoName}'` });
|
|
3033
|
+
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
|
|
3037
|
+
if (!await this.hasRemoteBranch(git, branch)) {
|
|
3038
|
+
this.recordSkip(
|
|
3039
|
+
{ kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
|
|
3040
|
+
`Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
|
|
3041
|
+
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
3042
|
+
);
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
if (this.config.sparseCheckout) {
|
|
3046
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
3047
|
+
try {
|
|
3048
|
+
if (await sparseService.needsUpdate(worktreeDir, this.config.sparseCheckout)) {
|
|
3049
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Updating sparse-checkout for '${this.repoName}'` });
|
|
3050
|
+
await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
|
|
3051
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout updated for '${this.repoName}'` });
|
|
3052
|
+
}
|
|
3053
|
+
} catch (error) {
|
|
3054
|
+
this.logger.warn(`Failed to reapply sparse-checkout for '${this.repoName}': ${getErrorMessage(error)}`);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
|
|
3058
|
+
if (!isClean) {
|
|
3059
|
+
this.recordSkip(
|
|
3060
|
+
{ kind: "dirty_tree" },
|
|
3061
|
+
`\u23ED\uFE0F Skipping ff-merge for '${this.repoName}' \u2014 working tree has local changes.`,
|
|
3062
|
+
`Skipping merge for '${this.repoName}': working tree has local changes`,
|
|
3063
|
+
"info"
|
|
3064
|
+
);
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
let relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
|
|
3068
|
+
let lastDeepenedTo = null;
|
|
3069
|
+
if (relationship === "indeterminate_shallow") {
|
|
3070
|
+
for (const target of this.getDeepenTargets()) {
|
|
3071
|
+
await this.deepenShallowHistoryToDepth(git, branch, target);
|
|
3072
|
+
lastDeepenedTo = target;
|
|
3073
|
+
relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
|
|
3074
|
+
if (relationship !== "indeterminate_shallow") break;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
if (relationship === "up_to_date") {
|
|
3078
|
+
this.logger.info(`'${this.repoName}' already up to date with origin/${branch}.`);
|
|
3079
|
+
this.emitProgress({
|
|
3080
|
+
phase: "skip",
|
|
3081
|
+
message: `'${this.repoName}' already up to date with origin/${branch}`
|
|
3082
|
+
});
|
|
3083
|
+
this.outcomeAccumulator?.recordNoop("repo", "already_up_to_date", {
|
|
3084
|
+
branch,
|
|
3085
|
+
path: worktreeDir,
|
|
3086
|
+
message: `Already up to date with origin/${branch}`
|
|
3087
|
+
});
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
if (relationship !== "fast_forward") {
|
|
3091
|
+
if (relationship === "local_ahead") {
|
|
3092
|
+
this.recordSkip(
|
|
3093
|
+
{ kind: "ahead_unpushed", branch },
|
|
3094
|
+
`\u23ED\uFE0F '${this.repoName}' has unpushed commits ahead of origin/${branch}. Skipping merge.`,
|
|
3095
|
+
`Skipping merge for '${this.repoName}': unpushed commits ahead of origin/${branch}`,
|
|
3096
|
+
"info"
|
|
3097
|
+
);
|
|
3098
|
+
} else if (relationship === "indeterminate_shallow") {
|
|
3099
|
+
const detail = lastDeepenedTo === null ? `no deepening attempted (configured depth already at or above all deepen targets)` : `deepening to ${lastDeepenedTo} commits`;
|
|
3100
|
+
const progressDetail = lastDeepenedTo === null ? `no deepening attempted (configured depth at/above limits)` : `shallow depth budget exhausted at ${lastDeepenedTo}`;
|
|
3101
|
+
this.recordSkip(
|
|
3102
|
+
{ kind: "indeterminate_shallow", branch, deepenedTo: lastDeepenedTo },
|
|
3103
|
+
`\u23ED\uFE0F '${this.repoName}' could not classify origin/${branch} after ${detail}. Skipping merge \u2014 consider removing or raising 'depth' to unshallow.`,
|
|
3104
|
+
`Skipping merge for '${this.repoName}': ${progressDetail}`,
|
|
3105
|
+
"info"
|
|
3106
|
+
);
|
|
3107
|
+
} else {
|
|
3108
|
+
this.recordSkip(
|
|
3109
|
+
{ kind: "diverged", branch },
|
|
3110
|
+
`\u23ED\uFE0F '${this.repoName}' has diverged from origin/${branch}. Skipping merge (no auto-reset).`,
|
|
3111
|
+
`Skipping merge for '${this.repoName}': diverged from origin/${branch}`,
|
|
3112
|
+
"info"
|
|
3113
|
+
);
|
|
3114
|
+
}
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
this.logger.info(`Fast-forwarding '${this.repoName}' to origin/${branch}...`);
|
|
3118
|
+
this.emitProgress({ phase: "merge", message: `Fast-forwarding '${this.repoName}' to origin/${branch}` });
|
|
3119
|
+
await git.merge([`origin/${branch}`, "--ff-only"]);
|
|
3120
|
+
this.logger.info(`\u2705 Updated '${this.repoName}' to origin/${branch}.`);
|
|
3121
|
+
this.emitProgress({ phase: "merge", message: `Updated '${this.repoName}' to origin/${branch}` });
|
|
3122
|
+
this.outcomeAccumulator?.recordUpdated(branch, worktreeDir, "fast_forward");
|
|
3123
|
+
}
|
|
3124
|
+
};
|
|
3125
|
+
|
|
3126
|
+
// src/services/git.service.ts
|
|
3127
|
+
import * as fs6 from "fs/promises";
|
|
3128
|
+
import * as path8 from "path";
|
|
3129
|
+
import simpleGit5 from "simple-git";
|
|
3130
|
+
|
|
3131
|
+
// src/utils/worktree-list-parser.ts
|
|
3132
|
+
function parseWorktreeListPorcelain(output) {
|
|
3133
|
+
const worktrees = [];
|
|
3134
|
+
let current = {};
|
|
3135
|
+
const flush = () => {
|
|
3136
|
+
if (!current.path) {
|
|
3137
|
+
current = {};
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
worktrees.push({
|
|
3141
|
+
path: current.path,
|
|
3142
|
+
branch: current.branch ?? null,
|
|
3143
|
+
head: current.head ?? null,
|
|
3144
|
+
detached: current.detached ?? false,
|
|
3145
|
+
prunable: current.prunable ?? false,
|
|
3146
|
+
locked: current.locked ?? false
|
|
3147
|
+
});
|
|
3148
|
+
current = {};
|
|
2204
3149
|
};
|
|
2205
3150
|
for (const line of output.split("\n")) {
|
|
2206
3151
|
if (line.startsWith("worktree ")) {
|
|
@@ -2324,8 +3269,8 @@ function defaultConsoleOutput(msg, level) {
|
|
|
2324
3269
|
}
|
|
2325
3270
|
|
|
2326
3271
|
// src/services/sparse-checkout.service.ts
|
|
2327
|
-
import * as
|
|
2328
|
-
import
|
|
3272
|
+
import * as path5 from "path";
|
|
3273
|
+
import simpleGit2 from "simple-git";
|
|
2329
3274
|
var SparseCheckoutService = class {
|
|
2330
3275
|
logger;
|
|
2331
3276
|
gitFactory;
|
|
@@ -2333,7 +3278,7 @@ var SparseCheckoutService = class {
|
|
|
2333
3278
|
matcherCache = /* @__PURE__ */ new WeakMap();
|
|
2334
3279
|
constructor(logger, gitFactory) {
|
|
2335
3280
|
this.logger = logger ?? Logger.createDefault();
|
|
2336
|
-
this.gitFactory = gitFactory ?? ((p) =>
|
|
3281
|
+
this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
|
|
2337
3282
|
}
|
|
2338
3283
|
updateLogger(logger) {
|
|
2339
3284
|
this.logger = logger;
|
|
@@ -2384,11 +3329,25 @@ var SparseCheckoutService = class {
|
|
|
2384
3329
|
return null;
|
|
2385
3330
|
}
|
|
2386
3331
|
}
|
|
3332
|
+
async readCurrentMode(worktreePath) {
|
|
3333
|
+
const git = this.gitFactory(worktreePath);
|
|
3334
|
+
try {
|
|
3335
|
+
const out = await git.raw(["config", "--bool", "--get", "core.sparseCheckoutCone"]);
|
|
3336
|
+
const value = out.trim().toLowerCase();
|
|
3337
|
+
if (value === "true") return "cone";
|
|
3338
|
+
if (value === "false") return "no-cone";
|
|
3339
|
+
return null;
|
|
3340
|
+
} catch {
|
|
3341
|
+
return null;
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
2387
3344
|
async needsUpdate(worktreePath, cfg) {
|
|
3345
|
+
const desiredMode = this.resolveMode(cfg);
|
|
3346
|
+
const currentMode = await this.readCurrentMode(worktreePath);
|
|
3347
|
+
if (currentMode !== desiredMode) return true;
|
|
2388
3348
|
const current = await this.readCurrent(worktreePath);
|
|
2389
|
-
const desired = this.buildPatterns(cfg);
|
|
2390
3349
|
if (current === null) return true;
|
|
2391
|
-
return !this.patternsEqual(current,
|
|
3350
|
+
return !this.patternsEqual(current, this.buildPatternsForMode(cfg, desiredMode));
|
|
2392
3351
|
}
|
|
2393
3352
|
isNarrowing(currentPatterns, nextPatterns) {
|
|
2394
3353
|
if (!currentPatterns || currentPatterns.length === 0) return false;
|
|
@@ -2445,7 +3404,7 @@ var SparseCheckoutService = class {
|
|
|
2445
3404
|
for (const pat of matcher.patterns) {
|
|
2446
3405
|
if (p === pat || p.startsWith(pat + "/")) return true;
|
|
2447
3406
|
}
|
|
2448
|
-
return matcher.ancestorDirs.has(
|
|
3407
|
+
return matcher.ancestorDirs.has(path5.posix.dirname(p));
|
|
2449
3408
|
});
|
|
2450
3409
|
}
|
|
2451
3410
|
getMatcher(cfg) {
|
|
@@ -2472,9 +3431,9 @@ var SparseCheckoutService = class {
|
|
|
2472
3431
|
};
|
|
2473
3432
|
|
|
2474
3433
|
// src/services/worktree-metadata.service.ts
|
|
2475
|
-
import * as
|
|
2476
|
-
import * as
|
|
2477
|
-
import
|
|
3434
|
+
import * as fs4 from "fs/promises";
|
|
3435
|
+
import * as path6 from "path";
|
|
3436
|
+
import simpleGit3 from "simple-git";
|
|
2478
3437
|
var WorktreeMetadataService = class {
|
|
2479
3438
|
logger;
|
|
2480
3439
|
constructor(logger) {
|
|
@@ -2486,7 +3445,7 @@ var WorktreeMetadataService = class {
|
|
|
2486
3445
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
2487
3446
|
*/
|
|
2488
3447
|
getWorktreeDirectoryName(worktreePath) {
|
|
2489
|
-
return
|
|
3448
|
+
return path6.basename(worktreePath);
|
|
2490
3449
|
}
|
|
2491
3450
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
2492
3451
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -2494,7 +3453,7 @@ var WorktreeMetadataService = class {
|
|
|
2494
3453
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
2495
3454
|
);
|
|
2496
3455
|
}
|
|
2497
|
-
return
|
|
3456
|
+
return path6.join(
|
|
2498
3457
|
bareRepoPath,
|
|
2499
3458
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
2500
3459
|
worktreeName,
|
|
@@ -2507,31 +3466,31 @@ var WorktreeMetadataService = class {
|
|
|
2507
3466
|
}
|
|
2508
3467
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
2509
3468
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2510
|
-
await
|
|
3469
|
+
await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
|
|
2511
3470
|
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2512
3471
|
let renamed = false;
|
|
2513
3472
|
try {
|
|
2514
|
-
await
|
|
3473
|
+
await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
2515
3474
|
try {
|
|
2516
|
-
await
|
|
3475
|
+
await fs4.rename(tmpPath, metadataPath);
|
|
2517
3476
|
renamed = true;
|
|
2518
3477
|
} catch (err) {
|
|
2519
3478
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
2520
|
-
await
|
|
3479
|
+
await fs4.copyFile(tmpPath, metadataPath);
|
|
2521
3480
|
} else {
|
|
2522
3481
|
throw err;
|
|
2523
3482
|
}
|
|
2524
3483
|
}
|
|
2525
3484
|
} finally {
|
|
2526
3485
|
if (!renamed) {
|
|
2527
|
-
await
|
|
3486
|
+
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
2528
3487
|
}
|
|
2529
3488
|
}
|
|
2530
3489
|
}
|
|
2531
3490
|
async loadMetadata(bareRepoPath, worktreeName) {
|
|
2532
3491
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2533
3492
|
try {
|
|
2534
|
-
const content = await
|
|
3493
|
+
const content = await fs4.readFile(metadataPath, "utf-8");
|
|
2535
3494
|
return JSON.parse(content);
|
|
2536
3495
|
} catch {
|
|
2537
3496
|
return null;
|
|
@@ -2540,7 +3499,7 @@ var WorktreeMetadataService = class {
|
|
|
2540
3499
|
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
2541
3500
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
2542
3501
|
try {
|
|
2543
|
-
const content = await
|
|
3502
|
+
const content = await fs4.readFile(metadataPath, "utf-8");
|
|
2544
3503
|
const metadata = JSON.parse(content);
|
|
2545
3504
|
if (!await this.validateMetadata(metadata)) {
|
|
2546
3505
|
this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
@@ -2554,7 +3513,7 @@ var WorktreeMetadataService = class {
|
|
|
2554
3513
|
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
2555
3514
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2556
3515
|
try {
|
|
2557
|
-
await
|
|
3516
|
+
await fs4.unlink(metadataPath);
|
|
2558
3517
|
} catch (error) {
|
|
2559
3518
|
if (error.code !== "ENOENT") {
|
|
2560
3519
|
throw error;
|
|
@@ -2564,7 +3523,7 @@ var WorktreeMetadataService = class {
|
|
|
2564
3523
|
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
2565
3524
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
2566
3525
|
try {
|
|
2567
|
-
await
|
|
3526
|
+
await fs4.unlink(metadataPath);
|
|
2568
3527
|
} catch (error) {
|
|
2569
3528
|
if (error.code !== "ENOENT") {
|
|
2570
3529
|
throw error;
|
|
@@ -2598,7 +3557,7 @@ var WorktreeMetadataService = class {
|
|
|
2598
3557
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
2599
3558
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
2600
3559
|
try {
|
|
2601
|
-
const worktreeGit =
|
|
3560
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
2602
3561
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
2603
3562
|
const branchSummary = await worktreeGit.branch();
|
|
2604
3563
|
const actualBranchName = branchSummary.current;
|
|
@@ -2699,9 +3658,9 @@ var WorktreeMetadataService = class {
|
|
|
2699
3658
|
};
|
|
2700
3659
|
|
|
2701
3660
|
// src/services/worktree-status.service.ts
|
|
2702
|
-
import * as
|
|
2703
|
-
import * as
|
|
2704
|
-
import
|
|
3661
|
+
import * as fs5 from "fs/promises";
|
|
3662
|
+
import * as path7 from "path";
|
|
3663
|
+
import simpleGit4 from "simple-git";
|
|
2705
3664
|
var OPERATION_FILES = [
|
|
2706
3665
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
2707
3666
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -2732,9 +3691,7 @@ var WorktreeStatusService = class {
|
|
|
2732
3691
|
return true;
|
|
2733
3692
|
}
|
|
2734
3693
|
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
2735
|
-
|
|
2736
|
-
await fs3.access(worktreePath);
|
|
2737
|
-
} catch {
|
|
3694
|
+
if (!await fileExists(worktreePath)) {
|
|
2738
3695
|
return {
|
|
2739
3696
|
isClean: true,
|
|
2740
3697
|
hasUnpushedCommits: false,
|
|
@@ -2904,7 +3861,7 @@ var WorktreeStatusService = class {
|
|
|
2904
3861
|
async detectOperationFile(gitDir) {
|
|
2905
3862
|
const results = await Promise.all(
|
|
2906
3863
|
OPERATION_FILES.map(
|
|
2907
|
-
({ file }) =>
|
|
3864
|
+
({ file }) => fs5.access(path7.join(gitDir, file)).then(
|
|
2908
3865
|
() => true,
|
|
2909
3866
|
() => false
|
|
2910
3867
|
)
|
|
@@ -3025,14 +3982,14 @@ var WorktreeStatusService = class {
|
|
|
3025
3982
|
}
|
|
3026
3983
|
}
|
|
3027
3984
|
async resolveGitDir(worktreePath) {
|
|
3028
|
-
const gitPath =
|
|
3985
|
+
const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
3029
3986
|
try {
|
|
3030
|
-
const stat3 = await
|
|
3987
|
+
const stat3 = await fs5.stat(gitPath);
|
|
3031
3988
|
if (stat3.isFile()) {
|
|
3032
|
-
const content = await
|
|
3989
|
+
const content = await fs5.readFile(gitPath, "utf-8");
|
|
3033
3990
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
3034
3991
|
if (gitdirMatch) {
|
|
3035
|
-
return
|
|
3992
|
+
return path7.resolve(worktreePath, gitdirMatch[1].trim());
|
|
3036
3993
|
}
|
|
3037
3994
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
3038
3995
|
}
|
|
@@ -3046,10 +4003,10 @@ var WorktreeStatusService = class {
|
|
|
3046
4003
|
}
|
|
3047
4004
|
}
|
|
3048
4005
|
createGitInstance(worktreePath) {
|
|
3049
|
-
const key = `${
|
|
4006
|
+
const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
3050
4007
|
let git = this.gitInstances.get(key);
|
|
3051
4008
|
if (!git) {
|
|
3052
|
-
git = this.config.skipLfs ?
|
|
4009
|
+
git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
|
|
3053
4010
|
this.gitInstances.set(key, git);
|
|
3054
4011
|
}
|
|
3055
4012
|
return git;
|
|
@@ -3069,7 +4026,7 @@ var GitService = class {
|
|
|
3069
4026
|
this.config = config;
|
|
3070
4027
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
3071
4028
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
3072
|
-
this.mainWorktreePath =
|
|
4029
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
3073
4030
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
3074
4031
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
3075
4032
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
@@ -3097,36 +4054,20 @@ var GitService = class {
|
|
|
3097
4054
|
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
3098
4055
|
}
|
|
3099
4056
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
3100
|
-
const key = `${
|
|
4057
|
+
const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
3101
4058
|
let git = this.gitInstances.get(key);
|
|
3102
4059
|
if (!git) {
|
|
3103
|
-
const base =
|
|
4060
|
+
const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
3104
4061
|
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
3105
4062
|
this.gitInstances.set(key, git);
|
|
3106
4063
|
}
|
|
3107
4064
|
return git;
|
|
3108
4065
|
}
|
|
3109
4066
|
buildSimpleGitOptions(blockMs) {
|
|
3110
|
-
const options = { progress: this.
|
|
4067
|
+
const options = { progress: makeGitProgressHandler(this.logger) };
|
|
3111
4068
|
if (blockMs > 0) options.timeout = { block: blockMs };
|
|
3112
4069
|
return options;
|
|
3113
4070
|
}
|
|
3114
|
-
makeProgressHandler() {
|
|
3115
|
-
const lastBucket = /* @__PURE__ */ new Map();
|
|
3116
|
-
return (event) => {
|
|
3117
|
-
if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
|
|
3118
|
-
const key = `${event.method}:${event.stage}`;
|
|
3119
|
-
const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
|
|
3120
|
-
let last = lastBucket.get(key) ?? -1;
|
|
3121
|
-
if (bucket < last) {
|
|
3122
|
-
last = -1;
|
|
3123
|
-
}
|
|
3124
|
-
if (bucket <= last && event.progress < 100) return;
|
|
3125
|
-
lastBucket.set(key, bucket);
|
|
3126
|
-
const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
|
|
3127
|
-
this.logger.info(` \u21B3 ${event.method} ${event.stage}: ${event.progress}% (${total})`);
|
|
3128
|
-
};
|
|
3129
|
-
}
|
|
3130
4071
|
updateLogger(logger) {
|
|
3131
4072
|
this.logger = logger;
|
|
3132
4073
|
this.sparseCheckoutService.updateLogger(logger);
|
|
@@ -3134,11 +4075,11 @@ var GitService = class {
|
|
|
3134
4075
|
async initialize() {
|
|
3135
4076
|
const { repoUrl } = this.config;
|
|
3136
4077
|
try {
|
|
3137
|
-
await
|
|
4078
|
+
await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
|
|
3138
4079
|
} catch {
|
|
3139
4080
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
3140
|
-
await
|
|
3141
|
-
const cloneBase =
|
|
4081
|
+
await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
|
|
4082
|
+
const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
|
|
3142
4083
|
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
3143
4084
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
|
|
3144
4085
|
this.logger.info("\u2705 Clone successful.");
|
|
@@ -3156,17 +4097,17 @@ var GitService = class {
|
|
|
3156
4097
|
this.logger.info("Fetching remote branches...");
|
|
3157
4098
|
await bareGit.fetch(["--all", "--progress"]);
|
|
3158
4099
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
3159
|
-
this.mainWorktreePath =
|
|
4100
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
|
|
3160
4101
|
let needsMainWorktree = true;
|
|
3161
4102
|
try {
|
|
3162
4103
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3163
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
4104
|
+
needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
|
|
3164
4105
|
} catch {
|
|
3165
4106
|
}
|
|
3166
4107
|
if (needsMainWorktree) {
|
|
3167
4108
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
3168
|
-
await
|
|
3169
|
-
const absoluteWorktreePath =
|
|
4109
|
+
await fs6.mkdir(this.config.worktreeDir, { recursive: true });
|
|
4110
|
+
const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
|
|
3170
4111
|
const branches = await bareGit.branch();
|
|
3171
4112
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
3172
4113
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -3202,7 +4143,7 @@ var GitService = class {
|
|
|
3202
4143
|
}
|
|
3203
4144
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
3204
4145
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
3205
|
-
(w) =>
|
|
4146
|
+
(w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
|
|
3206
4147
|
);
|
|
3207
4148
|
if (!mainWorktreeRegistered) {
|
|
3208
4149
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -3228,6 +4169,45 @@ var GitService = class {
|
|
|
3228
4169
|
getBareRepoPath() {
|
|
3229
4170
|
return this.bareRepoPath;
|
|
3230
4171
|
}
|
|
4172
|
+
async getRemoteDefaultBranch(repoUrl) {
|
|
4173
|
+
const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
4174
|
+
try {
|
|
4175
|
+
const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
|
|
4176
|
+
const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
|
|
4177
|
+
if (match && match[1]) {
|
|
4178
|
+
return match[1];
|
|
4179
|
+
}
|
|
4180
|
+
} catch {
|
|
4181
|
+
}
|
|
4182
|
+
const existing = [];
|
|
4183
|
+
for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
|
|
4184
|
+
try {
|
|
4185
|
+
const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
|
|
4186
|
+
if (out.trim().length > 0) {
|
|
4187
|
+
existing.push(candidate);
|
|
4188
|
+
}
|
|
4189
|
+
} catch {
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
if (existing.length === 1) {
|
|
4193
|
+
this.logger.warn(
|
|
4194
|
+
`Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
|
|
4195
|
+
);
|
|
4196
|
+
return existing[0];
|
|
4197
|
+
}
|
|
4198
|
+
if (existing.length > 1) {
|
|
4199
|
+
throw new Error(
|
|
4200
|
+
`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.`
|
|
4201
|
+
);
|
|
4202
|
+
}
|
|
4203
|
+
throw new Error(
|
|
4204
|
+
`Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
|
|
4205
|
+
);
|
|
4206
|
+
}
|
|
4207
|
+
async verifyLfs(worktreePath, label) {
|
|
4208
|
+
if (this.isLfsSkipEnabled()) return;
|
|
4209
|
+
await this.verifyLfsFilesDownloaded(worktreePath, label);
|
|
4210
|
+
}
|
|
3231
4211
|
async fetchAll() {
|
|
3232
4212
|
this.assertInitialized();
|
|
3233
4213
|
this.logger.info("Fetching latest data from remote...");
|
|
@@ -3274,7 +4254,7 @@ var GitService = class {
|
|
|
3274
4254
|
return branches;
|
|
3275
4255
|
}
|
|
3276
4256
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
3277
|
-
const worktreeGit = this.config.sparseCheckout ?
|
|
4257
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
3278
4258
|
try {
|
|
3279
4259
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
3280
4260
|
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
@@ -3285,7 +4265,7 @@ var GitService = class {
|
|
|
3285
4265
|
const existence = await Promise.all(
|
|
3286
4266
|
lfsFileList.map(async (f) => {
|
|
3287
4267
|
try {
|
|
3288
|
-
await
|
|
4268
|
+
await fs6.access(path8.join(worktreePath, f));
|
|
3289
4269
|
return f;
|
|
3290
4270
|
} catch {
|
|
3291
4271
|
return null;
|
|
@@ -3313,9 +4293,9 @@ var GitService = class {
|
|
|
3313
4293
|
let allDownloaded = true;
|
|
3314
4294
|
const notDownloaded = [];
|
|
3315
4295
|
for (const file of samplesToCheck) {
|
|
3316
|
-
const filePath =
|
|
4296
|
+
const filePath = path8.join(worktreePath, file);
|
|
3317
4297
|
try {
|
|
3318
|
-
const handle = await
|
|
4298
|
+
const handle = await fs6.open(filePath, "r");
|
|
3319
4299
|
try {
|
|
3320
4300
|
const buffer = Buffer.alloc(200);
|
|
3321
4301
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
@@ -3340,7 +4320,7 @@ var GitService = class {
|
|
|
3340
4320
|
}
|
|
3341
4321
|
retries++;
|
|
3342
4322
|
if (retries < maxRetries) {
|
|
3343
|
-
await new Promise((
|
|
4323
|
+
await new Promise((resolve12) => setTimeout(resolve12, retryDelay));
|
|
3344
4324
|
}
|
|
3345
4325
|
}
|
|
3346
4326
|
this.logger.warn(
|
|
@@ -3402,18 +4382,18 @@ var GitService = class {
|
|
|
3402
4382
|
}
|
|
3403
4383
|
async addWorktree(branchName, worktreePath) {
|
|
3404
4384
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
3405
|
-
const absoluteWorktreePath =
|
|
3406
|
-
await
|
|
4385
|
+
const absoluteWorktreePath = path8.resolve(worktreePath);
|
|
4386
|
+
await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
|
|
3407
4387
|
try {
|
|
3408
|
-
await
|
|
4388
|
+
await fs6.access(absoluteWorktreePath);
|
|
3409
4389
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3410
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
4390
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3411
4391
|
if (isValidWorktree) {
|
|
3412
4392
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3413
4393
|
return;
|
|
3414
4394
|
} else {
|
|
3415
4395
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
3416
|
-
await
|
|
4396
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3417
4397
|
}
|
|
3418
4398
|
} catch {
|
|
3419
4399
|
}
|
|
@@ -3452,7 +4432,7 @@ var GitService = class {
|
|
|
3452
4432
|
}
|
|
3453
4433
|
if (errorMessage.includes("already registered worktree")) {
|
|
3454
4434
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3455
|
-
const existingWorktree = worktrees.find((w) =>
|
|
4435
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3456
4436
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3457
4437
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
3458
4438
|
return;
|
|
@@ -3460,7 +4440,7 @@ var GitService = class {
|
|
|
3460
4440
|
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
3461
4441
|
await bareGit.raw(["worktree", "prune"]);
|
|
3462
4442
|
try {
|
|
3463
|
-
await
|
|
4443
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3464
4444
|
} catch {
|
|
3465
4445
|
}
|
|
3466
4446
|
let retryCreatedNewBranch = false;
|
|
@@ -3496,15 +4476,15 @@ var GitService = class {
|
|
|
3496
4476
|
}
|
|
3497
4477
|
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
3498
4478
|
try {
|
|
3499
|
-
await
|
|
4479
|
+
await fs6.access(absoluteWorktreePath);
|
|
3500
4480
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3501
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
4481
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3502
4482
|
if (isValidWorktree) {
|
|
3503
4483
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3504
4484
|
return;
|
|
3505
4485
|
} else {
|
|
3506
4486
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
3507
|
-
await
|
|
4487
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3508
4488
|
}
|
|
3509
4489
|
} catch {
|
|
3510
4490
|
}
|
|
@@ -3528,7 +4508,7 @@ var GitService = class {
|
|
|
3528
4508
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
3529
4509
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
3530
4510
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3531
|
-
const existingWorktree = worktrees.find((w) =>
|
|
4511
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3532
4512
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3533
4513
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
3534
4514
|
return;
|
|
@@ -3751,6 +4731,40 @@ var GitService = class {
|
|
|
3751
4731
|
return false;
|
|
3752
4732
|
}
|
|
3753
4733
|
}
|
|
4734
|
+
async classifyRemoteRelationship(worktreePath, branch) {
|
|
4735
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
4736
|
+
let headSha;
|
|
4737
|
+
let remoteSha;
|
|
4738
|
+
try {
|
|
4739
|
+
headSha = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
4740
|
+
remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
|
|
4741
|
+
} catch {
|
|
4742
|
+
return "diverged";
|
|
4743
|
+
}
|
|
4744
|
+
if (headSha === remoteSha) return "up_to_date";
|
|
4745
|
+
let mergeBase = "";
|
|
4746
|
+
let mergeBaseFailed = false;
|
|
4747
|
+
try {
|
|
4748
|
+
mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
|
|
4749
|
+
} catch {
|
|
4750
|
+
mergeBaseFailed = true;
|
|
4751
|
+
}
|
|
4752
|
+
if (mergeBaseFailed || !mergeBase) {
|
|
4753
|
+
if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
|
|
4754
|
+
return "diverged";
|
|
4755
|
+
}
|
|
4756
|
+
if (mergeBase === headSha) return "fast_forward";
|
|
4757
|
+
if (mergeBase === remoteSha) return "local_ahead";
|
|
4758
|
+
return "diverged";
|
|
4759
|
+
}
|
|
4760
|
+
async isShallowRepository(git) {
|
|
4761
|
+
try {
|
|
4762
|
+
const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
|
|
4763
|
+
return output.trim() === "true";
|
|
4764
|
+
} catch {
|
|
4765
|
+
return false;
|
|
4766
|
+
}
|
|
4767
|
+
}
|
|
3754
4768
|
async getChangedPathsInRange(worktreePath, fromRef, toRef) {
|
|
3755
4769
|
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3756
4770
|
try {
|
|
@@ -3861,35 +4875,238 @@ var GitService = class {
|
|
|
3861
4875
|
}
|
|
3862
4876
|
};
|
|
3863
4877
|
|
|
4878
|
+
// src/services/progress-emitter.ts
|
|
4879
|
+
var ProgressEmitter = class {
|
|
4880
|
+
listeners = /* @__PURE__ */ new Set();
|
|
4881
|
+
onProgress(listener) {
|
|
4882
|
+
this.listeners.add(listener);
|
|
4883
|
+
return () => this.listeners.delete(listener);
|
|
4884
|
+
}
|
|
4885
|
+
emit(event) {
|
|
4886
|
+
for (const listener of [...this.listeners]) {
|
|
4887
|
+
try {
|
|
4888
|
+
listener(event);
|
|
4889
|
+
} catch {
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
};
|
|
4894
|
+
|
|
4895
|
+
// src/services/repo-operation-lock.ts
|
|
4896
|
+
import * as fs7 from "fs/promises";
|
|
4897
|
+
import * as path10 from "path";
|
|
4898
|
+
import * as lockfile from "proper-lockfile";
|
|
4899
|
+
|
|
4900
|
+
// src/utils/lock-path.ts
|
|
4901
|
+
import { createHash } from "crypto";
|
|
4902
|
+
import * as os from "os";
|
|
4903
|
+
import * as path9 from "path";
|
|
4904
|
+
function getCloneModeLockTarget(config) {
|
|
4905
|
+
const name = config.name;
|
|
4906
|
+
const configDir = config.__configFileDir;
|
|
4907
|
+
const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
4908
|
+
if (configDir) {
|
|
4909
|
+
return {
|
|
4910
|
+
dir: path9.join(configDir, ".sync-worktrees-state"),
|
|
4911
|
+
file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
|
|
4912
|
+
};
|
|
4913
|
+
}
|
|
4914
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
|
|
4915
|
+
const dir = path9.join(stateBase, "sync-worktrees", "locks");
|
|
4916
|
+
return { dir, file: `${hash}.lock` };
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
// src/services/repo-operation-lock.ts
|
|
4920
|
+
var RepoOperationLock = class {
|
|
4921
|
+
constructor(config, gitService, logger = Logger.createDefault()) {
|
|
4922
|
+
this.config = config;
|
|
4923
|
+
this.gitService = gitService;
|
|
4924
|
+
this.logger = logger;
|
|
4925
|
+
}
|
|
4926
|
+
updateLogger(logger) {
|
|
4927
|
+
this.logger = logger;
|
|
4928
|
+
}
|
|
4929
|
+
async acquire() {
|
|
4930
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
4931
|
+
return async () => {
|
|
4932
|
+
};
|
|
4933
|
+
}
|
|
4934
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
4935
|
+
return this.acquireCloneModeLock();
|
|
4936
|
+
}
|
|
4937
|
+
return this.acquireWorktreeModeLock();
|
|
4938
|
+
}
|
|
4939
|
+
async acquireCloneModeLock() {
|
|
4940
|
+
const target = getCloneModeLockTarget(this.config);
|
|
4941
|
+
const lockTarget = path10.join(target.dir, target.file);
|
|
4942
|
+
try {
|
|
4943
|
+
await fs7.mkdir(target.dir, { recursive: true });
|
|
4944
|
+
await fs7.writeFile(lockTarget, "", { flag: "a" });
|
|
4945
|
+
} catch {
|
|
4946
|
+
return null;
|
|
4947
|
+
}
|
|
4948
|
+
return this.lockPath(lockTarget);
|
|
4949
|
+
}
|
|
4950
|
+
async acquireWorktreeModeLock() {
|
|
4951
|
+
const barePath = this.gitService.getBareRepoPath();
|
|
4952
|
+
try {
|
|
4953
|
+
await fs7.mkdir(barePath, { recursive: true });
|
|
4954
|
+
} catch {
|
|
4955
|
+
return null;
|
|
4956
|
+
}
|
|
4957
|
+
return this.lockPath(barePath);
|
|
4958
|
+
}
|
|
4959
|
+
async lockPath(lockTarget) {
|
|
4960
|
+
try {
|
|
4961
|
+
return await lockfile.lock(lockTarget, {
|
|
4962
|
+
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
4963
|
+
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
4964
|
+
retries: 0,
|
|
4965
|
+
realpath: false
|
|
4966
|
+
});
|
|
4967
|
+
} catch (error) {
|
|
4968
|
+
const code = error.code;
|
|
4969
|
+
if (code === "ELOCKED") {
|
|
4970
|
+
return null;
|
|
4971
|
+
}
|
|
4972
|
+
this.logger.warn(
|
|
4973
|
+
`Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
|
|
4974
|
+
);
|
|
4975
|
+
return null;
|
|
4976
|
+
}
|
|
4977
|
+
}
|
|
4978
|
+
};
|
|
4979
|
+
|
|
4980
|
+
// src/services/sync-retry-policy.ts
|
|
4981
|
+
var SyncRetryPolicy = class {
|
|
4982
|
+
constructor(config, gitService, logger) {
|
|
4983
|
+
this.config = config;
|
|
4984
|
+
this.gitService = gitService;
|
|
4985
|
+
this.logger = logger;
|
|
4986
|
+
}
|
|
4987
|
+
updateLogger(logger) {
|
|
4988
|
+
this.logger = logger;
|
|
4989
|
+
}
|
|
4990
|
+
createContext() {
|
|
4991
|
+
return { lfsSkipEnabled: false };
|
|
4992
|
+
}
|
|
4993
|
+
createOptions(syncContext) {
|
|
4994
|
+
return {
|
|
4995
|
+
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
4996
|
+
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
4997
|
+
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
4998
|
+
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
4999
|
+
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
5000
|
+
jitterMs: this.config.retry?.jitterMs ?? 0,
|
|
5001
|
+
onRetry: (error, attempt, context) => {
|
|
5002
|
+
const errorMessage = getErrorMessage(error);
|
|
5003
|
+
this.logger.info(`
|
|
5004
|
+
\u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
|
|
5005
|
+
if (context?.isLfsError && !this.config.skipLfs) {
|
|
5006
|
+
this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
|
|
5007
|
+
} else {
|
|
5008
|
+
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
5009
|
+
`);
|
|
5010
|
+
}
|
|
5011
|
+
},
|
|
5012
|
+
lfsRetryHandler: () => {
|
|
5013
|
+
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
5014
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
5015
|
+
this.gitService.setLfsSkipEnabled(true);
|
|
5016
|
+
syncContext.lfsSkipEnabled = true;
|
|
5017
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
};
|
|
5020
|
+
}
|
|
5021
|
+
resetLfsSkipIfNeeded(syncContext) {
|
|
5022
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
5023
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
5024
|
+
}
|
|
5025
|
+
}
|
|
5026
|
+
};
|
|
5027
|
+
|
|
5028
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
5029
|
+
import * as fs9 from "fs/promises";
|
|
5030
|
+
import * as path13 from "path";
|
|
5031
|
+
import pLimit from "p-limit";
|
|
5032
|
+
|
|
5033
|
+
// src/utils/date-filter.ts
|
|
5034
|
+
function parseDuration(durationStr) {
|
|
5035
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5036
|
+
if (!match) {
|
|
5037
|
+
return null;
|
|
5038
|
+
}
|
|
5039
|
+
const value = parseInt(match[1], 10);
|
|
5040
|
+
const unit = match[2];
|
|
5041
|
+
const multipliers = {
|
|
5042
|
+
h: 60 * 60 * 1e3,
|
|
5043
|
+
// hours
|
|
5044
|
+
d: 24 * 60 * 60 * 1e3,
|
|
5045
|
+
// days
|
|
5046
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
5047
|
+
// weeks
|
|
5048
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
5049
|
+
// months (approximate)
|
|
5050
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
5051
|
+
// years (approximate)
|
|
5052
|
+
};
|
|
5053
|
+
return value * multipliers[unit];
|
|
5054
|
+
}
|
|
5055
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
5056
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
5057
|
+
if (maxAgeMs === null) {
|
|
5058
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
5059
|
+
return branches;
|
|
5060
|
+
}
|
|
5061
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
5062
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
5063
|
+
}
|
|
5064
|
+
function formatDuration2(durationStr) {
|
|
5065
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5066
|
+
if (!match) {
|
|
5067
|
+
return durationStr;
|
|
5068
|
+
}
|
|
5069
|
+
const value = parseInt(match[1], 10);
|
|
5070
|
+
const unit = match[2];
|
|
5071
|
+
const unitNames = {
|
|
5072
|
+
h: value === 1 ? "hour" : "hours",
|
|
5073
|
+
d: value === 1 ? "day" : "days",
|
|
5074
|
+
w: value === 1 ? "week" : "weeks",
|
|
5075
|
+
m: value === 1 ? "month" : "months",
|
|
5076
|
+
y: value === 1 ? "year" : "years"
|
|
5077
|
+
};
|
|
5078
|
+
return `${value} ${unitNames[unit]}`;
|
|
5079
|
+
}
|
|
5080
|
+
|
|
3864
5081
|
// src/services/path-resolution.service.ts
|
|
3865
|
-
import { createHash } from "crypto";
|
|
3866
|
-
import * as
|
|
3867
|
-
import * as
|
|
5082
|
+
import { createHash as createHash2 } from "crypto";
|
|
5083
|
+
import * as fs8 from "fs";
|
|
5084
|
+
import * as path11 from "path";
|
|
3868
5085
|
var BRANCH_STEM_MAX = 80;
|
|
3869
5086
|
var BRANCH_HASH_LEN = 8;
|
|
3870
5087
|
var PathResolutionService = class {
|
|
3871
5088
|
sanitizeBranchName(branchName) {
|
|
3872
5089
|
const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
|
|
3873
|
-
const hash =
|
|
5090
|
+
const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
|
|
3874
5091
|
return `${stem}-${hash}`;
|
|
3875
5092
|
}
|
|
3876
5093
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3877
|
-
return
|
|
5094
|
+
return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3878
5095
|
}
|
|
3879
5096
|
resolveRealPath(inputPath) {
|
|
3880
|
-
const absolute =
|
|
5097
|
+
const absolute = path11.resolve(inputPath);
|
|
3881
5098
|
const missing = [];
|
|
3882
5099
|
let current = absolute;
|
|
3883
|
-
while (!
|
|
3884
|
-
const parent =
|
|
5100
|
+
while (!fs8.existsSync(current)) {
|
|
5101
|
+
const parent = path11.dirname(current);
|
|
3885
5102
|
if (parent === current) {
|
|
3886
5103
|
return absolute;
|
|
3887
5104
|
}
|
|
3888
|
-
missing.unshift(
|
|
5105
|
+
missing.unshift(path11.basename(current));
|
|
3889
5106
|
current = parent;
|
|
3890
5107
|
}
|
|
3891
5108
|
try {
|
|
3892
|
-
return
|
|
5109
|
+
return path11.join(fs8.realpathSync(current), ...missing);
|
|
3893
5110
|
} catch {
|
|
3894
5111
|
return absolute;
|
|
3895
5112
|
}
|
|
@@ -3899,7 +5116,7 @@ var PathResolutionService = class {
|
|
|
3899
5116
|
const a = fold(resolved);
|
|
3900
5117
|
const b = fold(resolvedBase);
|
|
3901
5118
|
if (a === b) return true;
|
|
3902
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
5119
|
+
return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
|
|
3903
5120
|
}
|
|
3904
5121
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3905
5122
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -3907,7 +5124,7 @@ var PathResolutionService = class {
|
|
|
3907
5124
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3908
5125
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3909
5126
|
}
|
|
3910
|
-
return
|
|
5127
|
+
return path11.relative(resolvedBase, resolved);
|
|
3911
5128
|
}
|
|
3912
5129
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3913
5130
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -3919,174 +5136,110 @@ var PathResolutionService = class {
|
|
|
3919
5136
|
}
|
|
3920
5137
|
};
|
|
3921
5138
|
|
|
3922
|
-
// src/services/worktree-sync.
|
|
3923
|
-
|
|
3924
|
-
|
|
5139
|
+
// src/services/worktree-sync-planner.ts
|
|
5140
|
+
import * as path12 from "path";
|
|
5141
|
+
function createWorktreeSyncPlan(inventory, options = {}) {
|
|
5142
|
+
return {
|
|
5143
|
+
create: planCreateActions(inventory, options),
|
|
5144
|
+
prune: planPruneActions(inventory),
|
|
5145
|
+
update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
|
|
5146
|
+
sparse: planSparseActions(inventory, options.sparseCheckout),
|
|
5147
|
+
warnings: []
|
|
5148
|
+
};
|
|
5149
|
+
}
|
|
5150
|
+
function planCreateActions(inventory, options = {}) {
|
|
5151
|
+
const pathResolution = options.pathResolution ?? new PathResolutionService();
|
|
5152
|
+
const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
|
|
5153
|
+
const newBranches = inventory.remoteBranches.filter(
|
|
5154
|
+
(branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
|
|
5155
|
+
);
|
|
5156
|
+
const reservedPaths = /* @__PURE__ */ new Map();
|
|
5157
|
+
for (const worktree of inventory.existingWorktrees) {
|
|
5158
|
+
reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
|
|
5159
|
+
}
|
|
5160
|
+
const actions = [];
|
|
5161
|
+
for (const branch of newBranches) {
|
|
5162
|
+
const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
|
|
5163
|
+
const resolved = path12.resolve(worktreePath);
|
|
5164
|
+
const conflictingBranch = reservedPaths.get(resolved);
|
|
5165
|
+
if (conflictingBranch && conflictingBranch !== branch) {
|
|
5166
|
+
actions.push({
|
|
5167
|
+
kind: "skip-create",
|
|
5168
|
+
branch,
|
|
5169
|
+
path: worktreePath,
|
|
5170
|
+
reason: "path-collision",
|
|
5171
|
+
conflictingBranch
|
|
5172
|
+
});
|
|
5173
|
+
continue;
|
|
5174
|
+
}
|
|
5175
|
+
reservedPaths.set(resolved, branch);
|
|
5176
|
+
actions.push({ kind: "create", branch, path: worktreePath });
|
|
5177
|
+
}
|
|
5178
|
+
return actions;
|
|
5179
|
+
}
|
|
5180
|
+
function planPruneActions(inventory) {
|
|
5181
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
5182
|
+
return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
|
|
5183
|
+
}
|
|
5184
|
+
function planUpdateActions(inventory) {
|
|
5185
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
5186
|
+
return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
|
|
5187
|
+
}
|
|
5188
|
+
function planSparseActions(inventory, sparseCheckout) {
|
|
5189
|
+
if (!sparseCheckout) {
|
|
5190
|
+
return [];
|
|
5191
|
+
}
|
|
5192
|
+
return inventory.existingWorktrees.map((worktree) => ({
|
|
5193
|
+
kind: "check-sparse",
|
|
5194
|
+
branch: worktree.branch,
|
|
5195
|
+
path: worktree.path
|
|
5196
|
+
}));
|
|
5197
|
+
}
|
|
5198
|
+
|
|
5199
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
5200
|
+
var WorktreeModeSyncRunner = class {
|
|
5201
|
+
constructor(config, gitService, logger, progressEmitter) {
|
|
3925
5202
|
this.config = config;
|
|
3926
|
-
this.
|
|
3927
|
-
this.
|
|
5203
|
+
this.gitService = gitService;
|
|
5204
|
+
this.logger = logger;
|
|
5205
|
+
this.progressEmitter = progressEmitter;
|
|
3928
5206
|
}
|
|
3929
|
-
gitService;
|
|
3930
|
-
logger;
|
|
3931
|
-
syncInProgress = false;
|
|
3932
5207
|
pathResolution = new PathResolutionService();
|
|
3933
|
-
progressListeners = /* @__PURE__ */ new Set();
|
|
3934
|
-
async initialize() {
|
|
3935
|
-
this.emitProgress({ phase: "initialize", message: "Initializing repository" });
|
|
3936
|
-
await this.gitService.initialize();
|
|
3937
|
-
this.emitProgress({ phase: "initialize", message: "Repository initialized" });
|
|
3938
|
-
}
|
|
3939
|
-
isInitialized() {
|
|
3940
|
-
return this.gitService.isInitialized();
|
|
3941
|
-
}
|
|
3942
|
-
isSyncInProgress() {
|
|
3943
|
-
return this.syncInProgress;
|
|
3944
|
-
}
|
|
3945
|
-
getGitService() {
|
|
3946
|
-
return this.gitService;
|
|
3947
|
-
}
|
|
3948
5208
|
updateLogger(logger) {
|
|
3949
5209
|
this.logger = logger;
|
|
3950
|
-
this.gitService.updateLogger(logger);
|
|
3951
|
-
}
|
|
3952
|
-
onProgress(listener) {
|
|
3953
|
-
this.progressListeners.add(listener);
|
|
3954
|
-
return () => this.progressListeners.delete(listener);
|
|
3955
|
-
}
|
|
3956
|
-
async runExclusiveRepoOperation(operation) {
|
|
3957
|
-
if (this.syncInProgress) {
|
|
3958
|
-
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
3959
|
-
return { started: false, reason: "in_progress" };
|
|
3960
|
-
}
|
|
3961
|
-
const release = await this.acquireBareLock();
|
|
3962
|
-
if (release === null) {
|
|
3963
|
-
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
3964
|
-
return { started: false, reason: "locked" };
|
|
3965
|
-
}
|
|
3966
|
-
this.syncInProgress = true;
|
|
3967
|
-
try {
|
|
3968
|
-
return { started: true, value: await operation() };
|
|
3969
|
-
} finally {
|
|
3970
|
-
this.syncInProgress = false;
|
|
3971
|
-
try {
|
|
3972
|
-
await release();
|
|
3973
|
-
} catch (releaseError) {
|
|
3974
|
-
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
3975
|
-
}
|
|
3976
|
-
}
|
|
3977
|
-
}
|
|
3978
|
-
emitProgress(event) {
|
|
3979
|
-
for (const listener of this.progressListeners) {
|
|
3980
|
-
try {
|
|
3981
|
-
listener(event);
|
|
3982
|
-
} catch {
|
|
3983
|
-
}
|
|
3984
|
-
}
|
|
3985
|
-
}
|
|
3986
|
-
async sync() {
|
|
3987
|
-
const result = await this.runExclusiveRepoOperation(async () => {
|
|
3988
|
-
if (!this.isInitialized()) {
|
|
3989
|
-
await this.initialize();
|
|
3990
|
-
}
|
|
3991
|
-
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
3992
|
-
const totalTimer = new Timer();
|
|
3993
|
-
const phaseTimer = new PhaseTimer();
|
|
3994
|
-
const syncContext = { lfsSkipEnabled: false };
|
|
3995
|
-
const retryOptions = this.createRetryOptions(syncContext);
|
|
3996
|
-
try {
|
|
3997
|
-
await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
|
|
3998
|
-
} catch (error) {
|
|
3999
|
-
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
4000
|
-
throw error;
|
|
4001
|
-
} finally {
|
|
4002
|
-
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
4003
|
-
this.gitService.setLfsSkipEnabled(false);
|
|
4004
|
-
}
|
|
4005
|
-
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
4006
|
-
`);
|
|
4007
|
-
if (this.config.debug) {
|
|
4008
|
-
const totalDuration = totalTimer.stop();
|
|
4009
|
-
const phaseResults = phaseTimer.getResults();
|
|
4010
|
-
const repoName = this.config.name;
|
|
4011
|
-
this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
|
|
4012
|
-
}
|
|
4013
|
-
}
|
|
4014
|
-
});
|
|
4015
|
-
return result.started ? { started: true } : result;
|
|
4016
|
-
}
|
|
4017
|
-
async acquireBareLock() {
|
|
4018
|
-
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
4019
|
-
return async () => {
|
|
4020
|
-
};
|
|
4021
|
-
}
|
|
4022
|
-
if (typeof this.gitService.getBareRepoPath !== "function") {
|
|
4023
|
-
return async () => {
|
|
4024
|
-
};
|
|
4025
|
-
}
|
|
4026
|
-
const barePath = this.gitService.getBareRepoPath();
|
|
4027
|
-
await fs6.mkdir(barePath, { recursive: true });
|
|
4028
|
-
try {
|
|
4029
|
-
const release = await lockfile.lock(barePath, {
|
|
4030
|
-
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
4031
|
-
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
4032
|
-
retries: 0,
|
|
4033
|
-
realpath: false
|
|
4034
|
-
});
|
|
4035
|
-
return release;
|
|
4036
|
-
} catch (error) {
|
|
4037
|
-
const code = error.code;
|
|
4038
|
-
if (code === "ELOCKED") {
|
|
4039
|
-
return null;
|
|
4040
|
-
}
|
|
4041
|
-
throw error;
|
|
4042
|
-
}
|
|
4043
|
-
}
|
|
4044
|
-
createRetryOptions(syncContext) {
|
|
4045
|
-
return {
|
|
4046
|
-
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
4047
|
-
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
4048
|
-
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
4049
|
-
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
4050
|
-
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
4051
|
-
onRetry: (error, attempt, context) => {
|
|
4052
|
-
const errorMessage = getErrorMessage(error);
|
|
4053
|
-
this.logger.info(`
|
|
4054
|
-
\u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
|
|
4055
|
-
if (context?.isLfsError && !this.config.skipLfs) {
|
|
4056
|
-
this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
|
|
4057
|
-
} else {
|
|
4058
|
-
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
4059
|
-
`);
|
|
4060
|
-
}
|
|
4061
|
-
},
|
|
4062
|
-
lfsRetryHandler: () => {
|
|
4063
|
-
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
4064
|
-
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
4065
|
-
this.gitService.setLfsSkipEnabled(true);
|
|
4066
|
-
syncContext.lfsSkipEnabled = true;
|
|
4067
|
-
}
|
|
4068
|
-
}
|
|
4069
|
-
};
|
|
4070
5210
|
}
|
|
4071
|
-
async runSyncAttempt(phaseTimer, syncContext) {
|
|
5211
|
+
async runSyncAttempt(phaseTimer, syncContext, outcome) {
|
|
4072
5212
|
await this.gitService.pruneWorktrees();
|
|
4073
5213
|
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
4074
5214
|
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
4075
|
-
await
|
|
5215
|
+
await fs9.mkdir(this.config.worktreeDir, { recursive: true });
|
|
4076
5216
|
const worktrees = await this.gitService.getWorktrees();
|
|
4077
5217
|
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
4078
5218
|
await this.cleanupOrphanedDirectories(worktrees);
|
|
4079
|
-
|
|
4080
|
-
|
|
5219
|
+
const syncPlan = createWorktreeSyncPlan(
|
|
5220
|
+
{
|
|
5221
|
+
remoteBranches,
|
|
5222
|
+
defaultBranch,
|
|
5223
|
+
existingWorktrees: worktrees,
|
|
5224
|
+
worktreeDir: this.config.worktreeDir
|
|
5225
|
+
},
|
|
5226
|
+
{
|
|
5227
|
+
pathResolution: this.pathResolution,
|
|
5228
|
+
updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
|
|
5229
|
+
sparseCheckout: this.config.sparseCheckout
|
|
5230
|
+
}
|
|
5231
|
+
);
|
|
5232
|
+
await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
|
|
5233
|
+
await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
|
|
4081
5234
|
if (this.config.updateExistingWorktrees !== false) {
|
|
4082
|
-
await this.updateExistingWorktreesWithTiming(
|
|
5235
|
+
await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
|
|
4083
5236
|
}
|
|
4084
5237
|
if (this.config.sparseCheckout) {
|
|
4085
|
-
await this.reapplySparseCheckout(
|
|
5238
|
+
await this.reapplySparseCheckout(syncPlan.sparse, outcome);
|
|
4086
5239
|
}
|
|
4087
5240
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
4088
5241
|
}
|
|
4089
|
-
async reapplySparseCheckout(
|
|
5242
|
+
async reapplySparseCheckout(actions, outcome) {
|
|
4090
5243
|
const sparseConfig = this.config.sparseCheckout;
|
|
4091
5244
|
if (!sparseConfig) return;
|
|
4092
5245
|
this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
|
|
@@ -4094,32 +5247,44 @@ var WorktreeSyncService = class {
|
|
|
4094
5247
|
const desired = sparseService.buildPatterns(sparseConfig);
|
|
4095
5248
|
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
4096
5249
|
await Promise.all(
|
|
4097
|
-
|
|
4098
|
-
(
|
|
5250
|
+
actions.map(
|
|
5251
|
+
(action) => limit(async () => {
|
|
5252
|
+
if (action.kind !== "check-sparse") return;
|
|
4099
5253
|
try {
|
|
4100
5254
|
try {
|
|
4101
|
-
await
|
|
5255
|
+
await fs9.access(action.path);
|
|
4102
5256
|
} catch {
|
|
4103
5257
|
return;
|
|
4104
5258
|
}
|
|
4105
|
-
const current = await sparseService.readCurrent(
|
|
5259
|
+
const current = await sparseService.readCurrent(action.path);
|
|
4106
5260
|
if (current !== null && sparseService.patternsEqual(current, desired)) return;
|
|
4107
5261
|
if (sparseService.isNarrowing(current, desired)) {
|
|
4108
|
-
const status = await this.gitService.getFullWorktreeStatus(
|
|
5262
|
+
const status = await this.gitService.getFullWorktreeStatus(action.path, false);
|
|
4109
5263
|
if (!status.canRemove) {
|
|
4110
5264
|
this.logger.warn(
|
|
4111
|
-
` - Skipping sparse-checkout narrowing for '${
|
|
5265
|
+
` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
|
|
4112
5266
|
);
|
|
5267
|
+
outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
|
|
5268
|
+
branch: action.branch,
|
|
5269
|
+
path: action.path,
|
|
5270
|
+
message: status.reasons.join(", ")
|
|
5271
|
+
});
|
|
4113
5272
|
return;
|
|
4114
5273
|
}
|
|
4115
5274
|
}
|
|
4116
|
-
await sparseService.applyToWorktree(
|
|
4117
|
-
await this.gitService.checkoutHead(
|
|
4118
|
-
this.logger.info(` - \u2705 Sparse-checkout updated for '${
|
|
5275
|
+
await sparseService.applyToWorktree(action.path, sparseConfig);
|
|
5276
|
+
await this.gitService.checkoutHead(action.path);
|
|
5277
|
+
this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
|
|
5278
|
+
outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
|
|
4119
5279
|
} catch (error) {
|
|
4120
5280
|
this.logger.warn(
|
|
4121
|
-
` - \u26A0\uFE0F Failed to update sparse-checkout for '${
|
|
5281
|
+
` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
|
|
4122
5282
|
);
|
|
5283
|
+
outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
|
|
5284
|
+
reason: "sparse_checkout_failed",
|
|
5285
|
+
branch: action.branch,
|
|
5286
|
+
path: action.path
|
|
5287
|
+
});
|
|
4123
5288
|
}
|
|
4124
5289
|
})
|
|
4125
5290
|
)
|
|
@@ -4128,7 +5293,7 @@ var WorktreeSyncService = class {
|
|
|
4128
5293
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
4129
5294
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
4130
5295
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
4131
|
-
this.
|
|
5296
|
+
this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
|
|
4132
5297
|
try {
|
|
4133
5298
|
await this.gitService.fetchAll();
|
|
4134
5299
|
} catch (fetchError) {
|
|
@@ -4173,7 +5338,7 @@ var WorktreeSyncService = class {
|
|
|
4173
5338
|
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
4174
5339
|
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
4175
5340
|
this.logger.info(
|
|
4176
|
-
`After filtering by age (${
|
|
5341
|
+
`After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
4177
5342
|
);
|
|
4178
5343
|
if (filteredByName.length > remoteBranches.length) {
|
|
4179
5344
|
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
@@ -4192,45 +5357,38 @@ var WorktreeSyncService = class {
|
|
|
4192
5357
|
}
|
|
4193
5358
|
async finalizeSyncAttempt(phaseTimer) {
|
|
4194
5359
|
phaseTimer.startPhase("Phase 5: Cleanup");
|
|
4195
|
-
this.
|
|
5360
|
+
this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
|
|
4196
5361
|
await this.gitService.pruneWorktrees();
|
|
4197
5362
|
this.logger.info("Step 5: Pruned worktree metadata.");
|
|
4198
5363
|
phaseTimer.endPhase();
|
|
4199
5364
|
}
|
|
4200
|
-
async createNewWorktreesWithTiming(
|
|
5365
|
+
async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
|
|
4201
5366
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
4202
5367
|
phaseTimer.startPhase("Phase 2: Create", maxConcurrent);
|
|
4203
|
-
this.
|
|
4204
|
-
await this.createNewWorktrees(
|
|
4205
|
-
|
|
4206
|
-
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
4207
|
-
phaseTimer.setPhaseCount("Phase 2: Create", newBranches.length);
|
|
5368
|
+
this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
|
|
5369
|
+
await this.createNewWorktrees(syncPlan.create, outcome);
|
|
5370
|
+
phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
|
|
4208
5371
|
phaseTimer.endPhase();
|
|
4209
5372
|
}
|
|
4210
|
-
async createNewWorktrees(
|
|
4211
|
-
|
|
4212
|
-
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
4213
|
-
if (newBranches.length === 0) {
|
|
5373
|
+
async createNewWorktrees(actions, outcome) {
|
|
5374
|
+
if (actions.length === 0) {
|
|
4214
5375
|
this.logger.info("Step 2: No new branches to create worktrees for.");
|
|
4215
5376
|
return;
|
|
4216
5377
|
}
|
|
4217
|
-
const reservedPaths = /* @__PURE__ */ new Map();
|
|
4218
|
-
for (const w of worktrees) {
|
|
4219
|
-
reservedPaths.set(path8.resolve(w.path), w.branch);
|
|
4220
|
-
}
|
|
4221
5378
|
const plan = [];
|
|
4222
|
-
for (const
|
|
4223
|
-
|
|
4224
|
-
const resolved = path8.resolve(worktreePath);
|
|
4225
|
-
const conflict = reservedPaths.get(resolved);
|
|
4226
|
-
if (conflict && conflict !== branchName) {
|
|
5379
|
+
for (const action of actions) {
|
|
5380
|
+
if (action.kind === "skip-create") {
|
|
4227
5381
|
this.logger.error(
|
|
4228
|
-
` \u274C Skipping '${
|
|
5382
|
+
` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
|
|
4229
5383
|
);
|
|
5384
|
+
outcome.recordSkipped("branch", "path_collision", {
|
|
5385
|
+
branch: action.branch,
|
|
5386
|
+
path: action.path,
|
|
5387
|
+
message: `Path collides with existing branch '${action.conflictingBranch}'`
|
|
5388
|
+
});
|
|
4230
5389
|
continue;
|
|
4231
5390
|
}
|
|
4232
|
-
|
|
4233
|
-
plan.push({ branchName, worktreePath });
|
|
5391
|
+
plan.push({ branchName: action.branch, worktreePath: action.path });
|
|
4234
5392
|
}
|
|
4235
5393
|
this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
|
|
4236
5394
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
@@ -4241,8 +5399,14 @@ var WorktreeSyncService = class {
|
|
|
4241
5399
|
try {
|
|
4242
5400
|
await this.gitService.addWorktree(branchName, worktreePath);
|
|
4243
5401
|
this.logger.info(` \u2705 Created worktree for '${branchName}'`);
|
|
5402
|
+
outcome.recordCreated(branchName, worktreePath);
|
|
4244
5403
|
} catch (error) {
|
|
4245
5404
|
this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
|
|
5405
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5406
|
+
reason: "create_failed",
|
|
5407
|
+
branch: branchName,
|
|
5408
|
+
path: worktreePath
|
|
5409
|
+
});
|
|
4246
5410
|
throw error;
|
|
4247
5411
|
}
|
|
4248
5412
|
})
|
|
@@ -4251,23 +5415,21 @@ var WorktreeSyncService = class {
|
|
|
4251
5415
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
4252
5416
|
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
4253
5417
|
}
|
|
4254
|
-
async pruneOldWorktreesWithTiming(
|
|
5418
|
+
async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
4255
5419
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
4256
5420
|
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
4257
|
-
this.
|
|
4258
|
-
await this.pruneOldWorktrees(
|
|
4259
|
-
|
|
4260
|
-
phaseTimer.setPhaseCount("Phase 3: Prune", deletedWorktrees.length);
|
|
5421
|
+
this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
|
|
5422
|
+
await this.pruneOldWorktrees(actions, outcome);
|
|
5423
|
+
phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
|
|
4261
5424
|
phaseTimer.endPhase();
|
|
4262
5425
|
}
|
|
4263
|
-
async pruneOldWorktrees(
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
this.logger.info(`Step 3: Checking ${deletedWorktrees.length} stale worktrees to prune...`);
|
|
5426
|
+
async pruneOldWorktrees(actions, outcome) {
|
|
5427
|
+
if (actions.length > 0) {
|
|
5428
|
+
this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
|
|
4267
5429
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
4268
5430
|
const limit = pLimit(maxConcurrent);
|
|
4269
5431
|
const statusResults = await Promise.allSettled(
|
|
4270
|
-
|
|
5432
|
+
actions.map(
|
|
4271
5433
|
({ branch: branchName, path: worktreePath }) => limit(async () => {
|
|
4272
5434
|
const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
|
|
4273
5435
|
return { branchName, worktreePath, status };
|
|
@@ -4290,6 +5452,10 @@ var WorktreeSyncService = class {
|
|
|
4290
5452
|
const branchName = result.reason?.branchName ?? "unknown";
|
|
4291
5453
|
this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
|
|
4292
5454
|
this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
|
|
5455
|
+
outcome.recordSkipped("worktree", "prune_status_check_failed", {
|
|
5456
|
+
branch: branchName,
|
|
5457
|
+
message: getErrorMessage(result.reason)
|
|
5458
|
+
});
|
|
4293
5459
|
}
|
|
4294
5460
|
}
|
|
4295
5461
|
if (toRemove.length > 0) {
|
|
@@ -4305,12 +5471,23 @@ var WorktreeSyncService = class {
|
|
|
4305
5471
|
this.logger.warn(
|
|
4306
5472
|
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
4307
5473
|
);
|
|
5474
|
+
outcome.recordSkipped("worktree", "prune_status_changed", {
|
|
5475
|
+
branch: branchName,
|
|
5476
|
+
path: worktreePath,
|
|
5477
|
+
message: recheck.reasons.join(", ")
|
|
5478
|
+
});
|
|
4308
5479
|
return;
|
|
4309
5480
|
}
|
|
4310
5481
|
await this.gitService.removeWorktree(worktreePath);
|
|
4311
5482
|
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
5483
|
+
outcome.recordRemoved(branchName, worktreePath);
|
|
4312
5484
|
} catch (error) {
|
|
4313
5485
|
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
5486
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5487
|
+
reason: "remove_failed",
|
|
5488
|
+
branch: branchName,
|
|
5489
|
+
path: worktreePath
|
|
5490
|
+
});
|
|
4314
5491
|
throw error;
|
|
4315
5492
|
}
|
|
4316
5493
|
})
|
|
@@ -4323,6 +5500,11 @@ var WorktreeSyncService = class {
|
|
|
4323
5500
|
this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
|
|
4324
5501
|
}
|
|
4325
5502
|
for (const { branchName, worktreePath, status } of toSkip) {
|
|
5503
|
+
outcome.recordSkipped("worktree", "unsafe_to_remove", {
|
|
5504
|
+
branch: branchName,
|
|
5505
|
+
path: worktreePath,
|
|
5506
|
+
message: status.reasons.join(", ")
|
|
5507
|
+
});
|
|
4326
5508
|
if (status.upstreamGone && status.hasUnpushedCommits) {
|
|
4327
5509
|
this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
|
|
4328
5510
|
this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
|
|
@@ -4415,53 +5597,52 @@ var WorktreeSyncService = class {
|
|
|
4415
5597
|
this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
|
|
4416
5598
|
}
|
|
4417
5599
|
}
|
|
4418
|
-
async updateExistingWorktreesWithTiming(
|
|
5600
|
+
async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
4419
5601
|
const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
4420
5602
|
phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
|
|
4421
|
-
this.
|
|
4422
|
-
await this.updateExistingWorktrees(
|
|
4423
|
-
|
|
4424
|
-
phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
|
|
5603
|
+
this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
|
|
5604
|
+
await this.updateExistingWorktrees(actions, outcome);
|
|
5605
|
+
phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
|
|
4425
5606
|
phaseTimer.endPhase();
|
|
4426
5607
|
}
|
|
4427
|
-
async updateExistingWorktrees(
|
|
5608
|
+
async updateExistingWorktrees(actions, outcome) {
|
|
4428
5609
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
4429
|
-
const divergedDir =
|
|
5610
|
+
const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4430
5611
|
try {
|
|
4431
|
-
const diverged = await
|
|
5612
|
+
const diverged = await fs9.readdir(divergedDir);
|
|
4432
5613
|
if (diverged.length > 0) {
|
|
4433
5614
|
this.logger.info(
|
|
4434
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
5615
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
|
|
4435
5616
|
);
|
|
4436
5617
|
}
|
|
4437
5618
|
} catch {
|
|
4438
5619
|
}
|
|
4439
|
-
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
4440
5620
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
4441
5621
|
const limit = pLimit(maxConcurrent);
|
|
4442
5622
|
const checkResults = await Promise.allSettled(
|
|
4443
|
-
|
|
4444
|
-
(
|
|
5623
|
+
actions.map(
|
|
5624
|
+
(action) => limit(async () => {
|
|
5625
|
+
const worktree = { path: action.path, branch: action.branch };
|
|
4445
5626
|
try {
|
|
4446
|
-
await
|
|
5627
|
+
await fs9.access(worktree.path);
|
|
4447
5628
|
} catch {
|
|
4448
|
-
return
|
|
5629
|
+
return { action: "skip", worktree, reason: "missing_worktree_path" };
|
|
4449
5630
|
}
|
|
4450
5631
|
const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
|
|
4451
|
-
if (hasOp) return
|
|
5632
|
+
if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
|
|
4452
5633
|
const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
|
|
4453
|
-
if (!isClean) return
|
|
5634
|
+
if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
|
|
4454
5635
|
const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
|
|
4455
5636
|
if (!canFastForward) {
|
|
4456
5637
|
const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
|
|
4457
5638
|
if (isAhead) {
|
|
4458
5639
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
|
|
4459
|
-
return
|
|
5640
|
+
return { action: "skip", worktree, reason: "local_ahead" };
|
|
4460
5641
|
}
|
|
4461
5642
|
return { action: "diverged", worktree };
|
|
4462
5643
|
}
|
|
4463
5644
|
const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
|
|
4464
|
-
if (!isBehind) return
|
|
5645
|
+
if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
|
|
4465
5646
|
const sparseCfg = this.config.sparseCheckout;
|
|
4466
5647
|
if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
|
|
4467
5648
|
const sparseService = this.gitService.getSparseCheckoutService();
|
|
@@ -4473,7 +5654,7 @@ var WorktreeSyncService = class {
|
|
|
4473
5654
|
);
|
|
4474
5655
|
if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
|
|
4475
5656
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
|
|
4476
|
-
return
|
|
5657
|
+
return { action: "skip", worktree, reason: "outside_sparse_checkout" };
|
|
4477
5658
|
}
|
|
4478
5659
|
}
|
|
4479
5660
|
}
|
|
@@ -4485,13 +5666,25 @@ var WorktreeSyncService = class {
|
|
|
4485
5666
|
const divergedWorktrees = [];
|
|
4486
5667
|
for (const result of checkResults) {
|
|
4487
5668
|
if (result.status === "fulfilled" && result.value) {
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
5669
|
+
switch (result.value.action) {
|
|
5670
|
+
case "update":
|
|
5671
|
+
worktreesToUpdate.push(result.value.worktree);
|
|
5672
|
+
break;
|
|
5673
|
+
case "diverged":
|
|
5674
|
+
divergedWorktrees.push(result.value.worktree);
|
|
5675
|
+
break;
|
|
5676
|
+
case "noop":
|
|
5677
|
+
outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
|
|
5678
|
+
break;
|
|
5679
|
+
case "skip":
|
|
5680
|
+
outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
|
|
5681
|
+
break;
|
|
4492
5682
|
}
|
|
4493
5683
|
} else if (result.status === "rejected") {
|
|
4494
5684
|
this.logger.error(` - Error checking worktree:`, result.reason);
|
|
5685
|
+
outcome.recordSkipped("worktree", "update_check_failed", {
|
|
5686
|
+
message: getErrorMessage(result.reason)
|
|
5687
|
+
});
|
|
4495
5688
|
}
|
|
4496
5689
|
}
|
|
4497
5690
|
const updateLimit = pLimit(
|
|
@@ -4505,6 +5698,7 @@ var WorktreeSyncService = class {
|
|
|
4505
5698
|
this.logger.info(` - Updating worktree '${worktree.branch}'...`);
|
|
4506
5699
|
await this.gitService.updateWorktree(worktree.path);
|
|
4507
5700
|
this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
|
|
5701
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
|
|
4508
5702
|
} catch (error) {
|
|
4509
5703
|
const errorMessage = getErrorMessage(error);
|
|
4510
5704
|
if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
|
|
@@ -4512,13 +5706,23 @@ var WorktreeSyncService = class {
|
|
|
4512
5706
|
` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
|
|
4513
5707
|
);
|
|
4514
5708
|
try {
|
|
4515
|
-
await this.handleDivergedBranch(worktree);
|
|
5709
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
4516
5710
|
} catch (divergedError) {
|
|
4517
5711
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
|
|
5712
|
+
outcome.recordFailed("worktree", getErrorMessage(divergedError), {
|
|
5713
|
+
reason: "diverged_recovery_failed",
|
|
5714
|
+
branch: worktree.branch,
|
|
5715
|
+
path: worktree.path
|
|
5716
|
+
});
|
|
4518
5717
|
throw divergedError;
|
|
4519
5718
|
}
|
|
4520
5719
|
} else {
|
|
4521
5720
|
this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
|
|
5721
|
+
outcome.recordFailed("worktree", errorMessage, {
|
|
5722
|
+
reason: "update_failed",
|
|
5723
|
+
branch: worktree.branch,
|
|
5724
|
+
path: worktree.path
|
|
5725
|
+
});
|
|
4522
5726
|
throw error;
|
|
4523
5727
|
}
|
|
4524
5728
|
}
|
|
@@ -4530,9 +5734,14 @@ var WorktreeSyncService = class {
|
|
|
4530
5734
|
mutationTasks.push(
|
|
4531
5735
|
updateLimit(async () => {
|
|
4532
5736
|
try {
|
|
4533
|
-
await this.handleDivergedBranch(worktree);
|
|
5737
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
4534
5738
|
} catch (error) {
|
|
4535
5739
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
|
|
5740
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5741
|
+
reason: "diverged_recovery_failed",
|
|
5742
|
+
branch: worktree.branch,
|
|
5743
|
+
path: worktree.path
|
|
5744
|
+
});
|
|
4536
5745
|
throw error;
|
|
4537
5746
|
}
|
|
4538
5747
|
return { type: "diverged", branch: worktree.branch };
|
|
@@ -4555,13 +5764,13 @@ var WorktreeSyncService = class {
|
|
|
4555
5764
|
}
|
|
4556
5765
|
async cleanupOrphanedDirectories(worktrees) {
|
|
4557
5766
|
try {
|
|
4558
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
4559
|
-
const allDirs = await
|
|
5767
|
+
const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
|
|
5768
|
+
const allDirs = await fs9.readdir(this.config.worktreeDir);
|
|
4560
5769
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
4561
5770
|
const orphanedDirs = [];
|
|
4562
5771
|
for (const dir of regularDirs) {
|
|
4563
5772
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
4564
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
5773
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
|
|
4565
5774
|
});
|
|
4566
5775
|
if (!isPartOfWorktree) {
|
|
4567
5776
|
orphanedDirs.push(dir);
|
|
@@ -4570,11 +5779,11 @@ var WorktreeSyncService = class {
|
|
|
4570
5779
|
if (orphanedDirs.length > 0) {
|
|
4571
5780
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
4572
5781
|
for (const dir of orphanedDirs) {
|
|
4573
|
-
const dirPath =
|
|
5782
|
+
const dirPath = path13.join(this.config.worktreeDir, dir);
|
|
4574
5783
|
try {
|
|
4575
|
-
const stat3 = await
|
|
5784
|
+
const stat3 = await fs9.stat(dirPath);
|
|
4576
5785
|
if (stat3.isDirectory()) {
|
|
4577
|
-
await
|
|
5786
|
+
await fs9.rm(dirPath, { recursive: true, force: true });
|
|
4578
5787
|
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
4579
5788
|
}
|
|
4580
5789
|
} catch (error) {
|
|
@@ -4586,13 +5795,14 @@ var WorktreeSyncService = class {
|
|
|
4586
5795
|
this.logger.error("Error during orphaned directory cleanup:", error);
|
|
4587
5796
|
}
|
|
4588
5797
|
}
|
|
4589
|
-
async handleDivergedBranch(worktree) {
|
|
5798
|
+
async handleDivergedBranch(worktree, outcome) {
|
|
4590
5799
|
this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
|
|
4591
5800
|
const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
|
|
4592
5801
|
if (treesIdentical) {
|
|
4593
5802
|
this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
|
|
4594
5803
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
4595
5804
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
5805
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
|
|
4596
5806
|
} else {
|
|
4597
5807
|
const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
|
|
4598
5808
|
if (!hasLocalChanges) {
|
|
@@ -4601,10 +5811,12 @@ var WorktreeSyncService = class {
|
|
|
4601
5811
|
);
|
|
4602
5812
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
4603
5813
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
5814
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
|
|
4604
5815
|
} else {
|
|
4605
5816
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
4606
5817
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
4607
|
-
const relativePath =
|
|
5818
|
+
const relativePath = path13.relative(process.cwd(), divergedPath);
|
|
5819
|
+
outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
|
|
4608
5820
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
4609
5821
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
4610
5822
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -4628,19 +5840,19 @@ var WorktreeSyncService = class {
|
|
|
4628
5840
|
}
|
|
4629
5841
|
}
|
|
4630
5842
|
async divergeWorktree(worktreePath, branchName) {
|
|
4631
|
-
const divergedBaseDir =
|
|
5843
|
+
const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4632
5844
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4633
5845
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4634
5846
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4635
5847
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4636
|
-
const divergedPath =
|
|
4637
|
-
await
|
|
5848
|
+
const divergedPath = path13.join(divergedBaseDir, divergedName);
|
|
5849
|
+
await fs9.mkdir(divergedBaseDir, { recursive: true });
|
|
4638
5850
|
try {
|
|
4639
|
-
await
|
|
5851
|
+
await fs9.rename(worktreePath, divergedPath);
|
|
4640
5852
|
} catch (err) {
|
|
4641
5853
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
4642
|
-
await
|
|
4643
|
-
await
|
|
5854
|
+
await fs9.cp(worktreePath, divergedPath, { recursive: true });
|
|
5855
|
+
await fs9.rm(worktreePath, { recursive: true, force: true });
|
|
4644
5856
|
} else {
|
|
4645
5857
|
throw err;
|
|
4646
5858
|
}
|
|
@@ -4659,89 +5871,194 @@ var WorktreeSyncService = class {
|
|
|
4659
5871
|
|
|
4660
5872
|
Original worktree location: ${worktreePath}`
|
|
4661
5873
|
};
|
|
4662
|
-
await
|
|
4663
|
-
|
|
5874
|
+
await fs9.writeFile(
|
|
5875
|
+
path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4664
5876
|
JSON.stringify(metadata, null, 2)
|
|
4665
5877
|
);
|
|
4666
5878
|
return divergedPath;
|
|
4667
5879
|
}
|
|
4668
5880
|
};
|
|
4669
5881
|
|
|
4670
|
-
// src/services/
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
5882
|
+
// src/services/worktree-sync.service.ts
|
|
5883
|
+
var WorktreeSyncService = class {
|
|
5884
|
+
constructor(config) {
|
|
5885
|
+
this.config = config;
|
|
5886
|
+
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
5887
|
+
this.gitService = new GitService(config, this.logger);
|
|
5888
|
+
this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
|
|
5889
|
+
this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
|
|
5890
|
+
this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
|
|
5891
|
+
config,
|
|
5892
|
+
this.gitService,
|
|
5893
|
+
this.logger,
|
|
5894
|
+
this.progressEmitter
|
|
5895
|
+
);
|
|
5896
|
+
if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
|
|
5897
|
+
this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
|
|
5898
|
+
progressEmitter: (event) => this.emitProgress(event),
|
|
5899
|
+
onSkip: (reason) => {
|
|
5900
|
+
this.skipsAccumulator.push(reason);
|
|
5901
|
+
}
|
|
5902
|
+
});
|
|
5903
|
+
}
|
|
5904
|
+
}
|
|
5905
|
+
gitService;
|
|
5906
|
+
cloneSyncService = null;
|
|
5907
|
+
logger;
|
|
5908
|
+
syncInProgress = false;
|
|
5909
|
+
progressEmitter = new ProgressEmitter();
|
|
5910
|
+
repoOperationLock;
|
|
5911
|
+
retryPolicy;
|
|
5912
|
+
worktreeModeSyncRunner;
|
|
5913
|
+
skipsAccumulator = [];
|
|
5914
|
+
lastOutcome = null;
|
|
5915
|
+
getRecordedSkips() {
|
|
5916
|
+
return [...this.skipsAccumulator];
|
|
5917
|
+
}
|
|
5918
|
+
clearRecordedSkips() {
|
|
5919
|
+
this.skipsAccumulator = [];
|
|
5920
|
+
}
|
|
5921
|
+
clearPendingInitSkip() {
|
|
5922
|
+
this.cloneSyncService?.clearPendingInitSkip();
|
|
5923
|
+
}
|
|
5924
|
+
getLastOutcome() {
|
|
5925
|
+
return this.lastOutcome;
|
|
5926
|
+
}
|
|
5927
|
+
isCloneMode() {
|
|
5928
|
+
return this.cloneSyncService !== null;
|
|
5929
|
+
}
|
|
5930
|
+
async getWorktrees() {
|
|
5931
|
+
if (this.cloneSyncService) {
|
|
5932
|
+
return this.cloneSyncService.getWorktrees();
|
|
5933
|
+
}
|
|
5934
|
+
return this.gitService.getWorktrees();
|
|
5935
|
+
}
|
|
5936
|
+
async initialize() {
|
|
5937
|
+
if (this.isInitialized()) return;
|
|
5938
|
+
const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
|
|
5939
|
+
if (!result.started) {
|
|
5940
|
+
const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
|
|
5941
|
+
this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
|
|
5942
|
+
}
|
|
5943
|
+
}
|
|
5944
|
+
async initializeUnlocked(outcome) {
|
|
5945
|
+
this.emitProgress({ phase: "initialize", message: "Initializing repository" });
|
|
5946
|
+
if (this.cloneSyncService) {
|
|
5947
|
+
await this.cloneSyncService.initialize(outcome);
|
|
5948
|
+
} else {
|
|
5949
|
+
await this.gitService.initialize();
|
|
5950
|
+
}
|
|
5951
|
+
this.emitProgress({ phase: "initialize", message: "Repository initialized" });
|
|
5952
|
+
}
|
|
5953
|
+
isInitialized() {
|
|
5954
|
+
if (this.cloneSyncService) {
|
|
5955
|
+
return this.cloneSyncService.isInitialized();
|
|
5956
|
+
}
|
|
5957
|
+
return this.gitService.isInitialized();
|
|
5958
|
+
}
|
|
5959
|
+
isSyncInProgress() {
|
|
5960
|
+
return this.syncInProgress;
|
|
5961
|
+
}
|
|
5962
|
+
getGitService() {
|
|
5963
|
+
return this.gitService;
|
|
5964
|
+
}
|
|
5965
|
+
updateLogger(logger) {
|
|
5966
|
+
this.logger = logger;
|
|
5967
|
+
this.gitService.updateLogger(logger);
|
|
5968
|
+
this.cloneSyncService?.updateLogger(logger);
|
|
5969
|
+
this.retryPolicy.updateLogger(logger);
|
|
5970
|
+
this.worktreeModeSyncRunner.updateLogger(logger);
|
|
5971
|
+
this.repoOperationLock.updateLogger(logger);
|
|
5972
|
+
}
|
|
5973
|
+
onProgress(listener) {
|
|
5974
|
+
return this.progressEmitter.onProgress(listener);
|
|
5975
|
+
}
|
|
5976
|
+
async runExclusiveRepoOperation(operation) {
|
|
5977
|
+
if (this.syncInProgress) {
|
|
5978
|
+
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
5979
|
+
return { started: false, reason: "in_progress" };
|
|
5980
|
+
}
|
|
5981
|
+
this.syncInProgress = true;
|
|
5982
|
+
let release;
|
|
5983
|
+
try {
|
|
5984
|
+
release = await this.repoOperationLock.acquire();
|
|
5985
|
+
} catch (error) {
|
|
5986
|
+
this.syncInProgress = false;
|
|
5987
|
+
throw error;
|
|
5988
|
+
}
|
|
5989
|
+
if (release === null) {
|
|
5990
|
+
this.syncInProgress = false;
|
|
5991
|
+
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
5992
|
+
return { started: false, reason: "locked" };
|
|
5993
|
+
}
|
|
5994
|
+
try {
|
|
5995
|
+
return { started: true, value: await operation() };
|
|
5996
|
+
} finally {
|
|
5997
|
+
try {
|
|
5998
|
+
await release();
|
|
5999
|
+
} catch (releaseError) {
|
|
6000
|
+
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
6001
|
+
}
|
|
6002
|
+
this.syncInProgress = false;
|
|
4696
6003
|
}
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
6004
|
+
}
|
|
6005
|
+
emitProgress(event) {
|
|
6006
|
+
this.progressEmitter.emit(event);
|
|
6007
|
+
}
|
|
6008
|
+
async sync() {
|
|
6009
|
+
const result = await this.runExclusiveRepoOperation(async () => {
|
|
6010
|
+
const totalTimer = new Timer();
|
|
6011
|
+
const phaseTimer = new PhaseTimer();
|
|
6012
|
+
const outcome = new SyncOutcomeAccumulator({
|
|
6013
|
+
mode: this.cloneSyncService ? "clone" : "worktree",
|
|
6014
|
+
repoName: this.config.name
|
|
6015
|
+
});
|
|
6016
|
+
const syncContext = this.retryPolicy.createContext();
|
|
6017
|
+
const retryOptions = this.retryPolicy.createOptions(syncContext);
|
|
6018
|
+
let durationMs;
|
|
4701
6019
|
try {
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
6020
|
+
if (!this.isInitialized()) {
|
|
6021
|
+
await this.initializeUnlocked(outcome);
|
|
6022
|
+
}
|
|
6023
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
6024
|
+
const retryOutcomeBaseline = outcome.snapshot();
|
|
6025
|
+
const retryOptionsWithOutcomeReset = {
|
|
6026
|
+
...retryOptions,
|
|
6027
|
+
onRetry: (error, attempt, context) => {
|
|
6028
|
+
outcome.restore(retryOutcomeBaseline);
|
|
6029
|
+
retryOptions.onRetry?.(error, attempt, context);
|
|
6030
|
+
}
|
|
6031
|
+
};
|
|
6032
|
+
const cloneSync = this.cloneSyncService;
|
|
6033
|
+
if (cloneSync) {
|
|
6034
|
+
await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
|
|
4705
6035
|
} else {
|
|
4706
|
-
|
|
6036
|
+
await retry(
|
|
6037
|
+
() => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
|
|
6038
|
+
retryOptionsWithOutcomeReset
|
|
6039
|
+
);
|
|
4707
6040
|
}
|
|
4708
6041
|
} catch (error) {
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
dot: true,
|
|
4725
|
-
ignore: DEFAULT_IGNORE_PATTERNS
|
|
4726
|
-
});
|
|
4727
|
-
for (const match of matches) {
|
|
4728
|
-
allFiles.add(match);
|
|
6042
|
+
if (outcome.getCounts().failed === 0) {
|
|
6043
|
+
outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
|
|
6044
|
+
}
|
|
6045
|
+
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
6046
|
+
throw error;
|
|
6047
|
+
} finally {
|
|
6048
|
+
this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
|
|
6049
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
6050
|
+
`);
|
|
6051
|
+
durationMs = totalTimer.stop();
|
|
6052
|
+
this.lastOutcome = outcome.toOutcome(durationMs);
|
|
6053
|
+
if (this.config.debug) {
|
|
6054
|
+
const phaseResults = phaseTimer.getResults();
|
|
6055
|
+
const repoName = this.config.name;
|
|
6056
|
+
this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
|
|
4729
6057
|
}
|
|
4730
|
-
} catch {
|
|
4731
6058
|
}
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
async copyFile(sourcePath, destPath) {
|
|
4736
|
-
try {
|
|
4737
|
-
await fs7.access(destPath);
|
|
4738
|
-
return false;
|
|
4739
|
-
} catch {
|
|
4740
|
-
}
|
|
4741
|
-
const destDir = path9.dirname(destPath);
|
|
4742
|
-
await fs7.mkdir(destDir, { recursive: true });
|
|
4743
|
-
await fs7.copyFile(sourcePath, destPath);
|
|
4744
|
-
return true;
|
|
6059
|
+
return this.lastOutcome ?? outcome.toOutcome(durationMs);
|
|
6060
|
+
});
|
|
6061
|
+
return result.started ? { started: true, outcome: result.value } : result;
|
|
4745
6062
|
}
|
|
4746
6063
|
};
|
|
4747
6064
|
|
|
@@ -4874,7 +6191,7 @@ var HookExecutionService = class {
|
|
|
4874
6191
|
// src/utils/disk-space.ts
|
|
4875
6192
|
import fastFolderSize from "fast-folder-size";
|
|
4876
6193
|
async function calculateDirectorySize(dirPath) {
|
|
4877
|
-
return new Promise((
|
|
6194
|
+
return new Promise((resolve12, reject) => {
|
|
4878
6195
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
4879
6196
|
if (err) {
|
|
4880
6197
|
reject(err);
|
|
@@ -4884,7 +6201,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
4884
6201
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
4885
6202
|
return;
|
|
4886
6203
|
}
|
|
4887
|
-
|
|
6204
|
+
resolve12(bytes);
|
|
4888
6205
|
});
|
|
4889
6206
|
});
|
|
4890
6207
|
}
|
|
@@ -4955,7 +6272,7 @@ var AppEventEmitter = class {
|
|
|
4955
6272
|
};
|
|
4956
6273
|
|
|
4957
6274
|
// src/services/InteractiveUIService.tsx
|
|
4958
|
-
import * as
|
|
6275
|
+
import * as fs10 from "fs/promises";
|
|
4959
6276
|
var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
|
|
4960
6277
|
var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
|
|
4961
6278
|
var InteractiveUIService = class {
|
|
@@ -4968,15 +6285,15 @@ var InteractiveUIService = class {
|
|
|
4968
6285
|
logBuffer = [];
|
|
4969
6286
|
uiReady = false;
|
|
4970
6287
|
hookExecutionService = new HookExecutionService();
|
|
6288
|
+
branchCreatedActions = new BranchCreatedActionsService();
|
|
4971
6289
|
pathResolution = new PathResolutionService();
|
|
4972
6290
|
limit;
|
|
4973
6291
|
reloadInProgress = false;
|
|
4974
6292
|
isDestroyed = false;
|
|
4975
|
-
reloadOptions;
|
|
4976
6293
|
events;
|
|
4977
6294
|
ownsEvents;
|
|
4978
6295
|
unsubscribeCallbacks = [];
|
|
4979
|
-
constructor(syncServices, configPath, cronSchedule, maxParallel,
|
|
6296
|
+
constructor(syncServices, configPath, cronSchedule, maxParallel, events) {
|
|
4980
6297
|
this.ownsEvents = events === void 0;
|
|
4981
6298
|
this.events = events ?? new AppEventEmitter();
|
|
4982
6299
|
if (syncServices.length === 0) {
|
|
@@ -4987,7 +6304,6 @@ var InteractiveUIService = class {
|
|
|
4987
6304
|
this.cronSchedule = cronSchedule;
|
|
4988
6305
|
this.repositoryCount = syncServices.length;
|
|
4989
6306
|
this.limit = pLimit2(maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
4990
|
-
this.reloadOptions = reloadOptions ?? {};
|
|
4991
6307
|
this.startBufferFlushCheck();
|
|
4992
6308
|
this.renderUI();
|
|
4993
6309
|
this.injectLoggersIntoServices();
|
|
@@ -5045,15 +6361,15 @@ var InteractiveUIService = class {
|
|
|
5045
6361
|
const scheduleGroups = /* @__PURE__ */ new Map();
|
|
5046
6362
|
for (const service of this.syncServices) {
|
|
5047
6363
|
if (service.config.runOnce) continue;
|
|
5048
|
-
const
|
|
5049
|
-
if (!
|
|
5050
|
-
if (!scheduleGroups.has(
|
|
5051
|
-
scheduleGroups.set(
|
|
6364
|
+
const schedule2 = service.config.cronSchedule || this.cronSchedule;
|
|
6365
|
+
if (!schedule2) continue;
|
|
6366
|
+
if (!scheduleGroups.has(schedule2)) {
|
|
6367
|
+
scheduleGroups.set(schedule2, []);
|
|
5052
6368
|
}
|
|
5053
|
-
scheduleGroups.get(
|
|
6369
|
+
scheduleGroups.get(schedule2).push(service);
|
|
5054
6370
|
}
|
|
5055
|
-
for (const [
|
|
5056
|
-
const task = cron2.schedule(
|
|
6371
|
+
for (const [schedule2, services] of scheduleGroups) {
|
|
6372
|
+
const task = cron2.schedule(schedule2, async () => {
|
|
5057
6373
|
await this.runSyncCycle(services, { logErrors: false });
|
|
5058
6374
|
});
|
|
5059
6375
|
this.cronJobs.push(task);
|
|
@@ -5091,8 +6407,8 @@ var InteractiveUIService = class {
|
|
|
5091
6407
|
getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
|
|
5092
6408
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
5093
6409
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
5094
|
-
openEditorInWorktree: (
|
|
5095
|
-
openTerminalInWorktree: (repoIndex,
|
|
6410
|
+
openEditorInWorktree: (path18) => this.openEditorInWorktree(path18),
|
|
6411
|
+
openTerminalInWorktree: (repoIndex, path18, branchName) => this.openTerminalInWorktree(repoIndex, path18, branchName),
|
|
5096
6412
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
5097
6413
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
5098
6414
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
@@ -5121,24 +6437,28 @@ var InteractiveUIService = class {
|
|
|
5121
6437
|
this.addLog("Reloading configuration...");
|
|
5122
6438
|
this.setStatus("syncing");
|
|
5123
6439
|
const configLoader = new ConfigLoaderService();
|
|
5124
|
-
const { repositories } = await configLoader.buildRepositories(this.configPath
|
|
5125
|
-
filter: this.reloadOptions.filter,
|
|
5126
|
-
noUpdateExisting: this.reloadOptions.noUpdateExisting,
|
|
5127
|
-
debug: this.reloadOptions.debug
|
|
5128
|
-
});
|
|
6440
|
+
const { repositories } = await configLoader.buildRepositories(this.configPath);
|
|
5129
6441
|
const initResults = await Promise.allSettled(
|
|
5130
6442
|
repositories.map(
|
|
5131
6443
|
(repoConfig) => this.limit(async () => {
|
|
5132
6444
|
const service = new WorktreeSyncService(repoConfig);
|
|
5133
6445
|
await service.initialize();
|
|
5134
|
-
return
|
|
6446
|
+
return {
|
|
6447
|
+
service,
|
|
6448
|
+
clonePhaseSkips: service.getRecordedSkips().map((reason) => ({
|
|
6449
|
+
repo: repoConfig.name || repoConfig.repoUrl,
|
|
6450
|
+
reason: formatCloneSkipReason(reason)
|
|
6451
|
+
}))
|
|
6452
|
+
};
|
|
5135
6453
|
})
|
|
5136
6454
|
)
|
|
5137
6455
|
);
|
|
5138
6456
|
const newServices = [];
|
|
6457
|
+
const initClonePhaseSkips = [];
|
|
5139
6458
|
for (const result of initResults) {
|
|
5140
6459
|
if (result.status === "fulfilled") {
|
|
5141
|
-
newServices.push(result.value);
|
|
6460
|
+
newServices.push(result.value.service);
|
|
6461
|
+
initClonePhaseSkips.push(...result.value.clonePhaseSkips);
|
|
5142
6462
|
} else {
|
|
5143
6463
|
this.addLog(`Failed to initialize repository: ${result.reason}`, "error");
|
|
5144
6464
|
}
|
|
@@ -5156,12 +6476,24 @@ var InteractiveUIService = class {
|
|
|
5156
6476
|
this.setupCronJobs();
|
|
5157
6477
|
this.events.emit("updateRepositoryCount", this.repositoryCount);
|
|
5158
6478
|
this.events.emit("updateCronSchedule", this.cronSchedule);
|
|
5159
|
-
const {
|
|
6479
|
+
const {
|
|
6480
|
+
failures,
|
|
6481
|
+
skipped,
|
|
6482
|
+
clonePhaseSkips: syncClonePhaseSkips,
|
|
6483
|
+
attempted
|
|
6484
|
+
} = await this.runSyncServices(this.syncServices);
|
|
6485
|
+
const clonePhaseSkips = [...initClonePhaseSkips, ...syncClonePhaseSkips];
|
|
5160
6486
|
await this.recordSyncOutcome({ failures, skipped, attempted });
|
|
5161
6487
|
this.setStatus("idle");
|
|
5162
6488
|
for (const skip of skipped) {
|
|
5163
6489
|
this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
|
|
5164
6490
|
}
|
|
6491
|
+
for (const skip of clonePhaseSkips) {
|
|
6492
|
+
this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
|
|
6493
|
+
}
|
|
6494
|
+
if (clonePhaseSkips.length > 0) {
|
|
6495
|
+
this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) during reload`, "warn");
|
|
6496
|
+
}
|
|
5165
6497
|
if (failures.length > 0) {
|
|
5166
6498
|
for (const failure of failures) {
|
|
5167
6499
|
this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
|
|
@@ -5195,14 +6527,14 @@ var InteractiveUIService = class {
|
|
|
5195
6527
|
if (Date.now() - startTime > timeoutMs) {
|
|
5196
6528
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
5197
6529
|
}
|
|
5198
|
-
await new Promise((
|
|
6530
|
+
await new Promise((resolve12) => setTimeout(resolve12, checkInterval));
|
|
5199
6531
|
}
|
|
5200
6532
|
});
|
|
5201
6533
|
try {
|
|
5202
6534
|
await Promise.all(syncChecks);
|
|
5203
6535
|
} catch {
|
|
5204
6536
|
this.addLog(
|
|
5205
|
-
`Warning: Timeout waiting for sync operations to complete after ${
|
|
6537
|
+
`Warning: Timeout waiting for sync operations to complete after ${formatDuration(timeoutMs)}. Proceeding with potential data loss risk.`,
|
|
5206
6538
|
"warn"
|
|
5207
6539
|
);
|
|
5208
6540
|
}
|
|
@@ -5304,8 +6636,7 @@ var InteractiveUIService = class {
|
|
|
5304
6636
|
throw new Error(`Invalid repository index: ${repoIndex}`);
|
|
5305
6637
|
}
|
|
5306
6638
|
const service = this.syncServices[repoIndex];
|
|
5307
|
-
|
|
5308
|
-
return gitService.getWorktrees();
|
|
6639
|
+
return this.getWorktreesFromService(service);
|
|
5309
6640
|
}
|
|
5310
6641
|
async getWorktreeStatusForRepo(repoIndex) {
|
|
5311
6642
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -5313,7 +6644,7 @@ var InteractiveUIService = class {
|
|
|
5313
6644
|
}
|
|
5314
6645
|
const service = this.syncServices[repoIndex];
|
|
5315
6646
|
const gitService = service.getGitService();
|
|
5316
|
-
const worktrees = await
|
|
6647
|
+
const worktrees = await this.getWorktreesFromService(service);
|
|
5317
6648
|
const results = await Promise.allSettled(
|
|
5318
6649
|
worktrees.map(async (wt) => {
|
|
5319
6650
|
const status = await gitService.getFullWorktreeStatus(wt.path, true);
|
|
@@ -5322,28 +6653,35 @@ var InteractiveUIService = class {
|
|
|
5322
6653
|
);
|
|
5323
6654
|
return results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
5324
6655
|
}
|
|
6656
|
+
async getWorktreesFromService(service) {
|
|
6657
|
+
const worktreeProvider = service;
|
|
6658
|
+
if (typeof worktreeProvider.getWorktrees === "function") {
|
|
6659
|
+
return worktreeProvider.getWorktrees();
|
|
6660
|
+
}
|
|
6661
|
+
return service.getGitService().getWorktrees();
|
|
6662
|
+
}
|
|
5325
6663
|
async getDivergedDirectoriesForRepo(repoIndex) {
|
|
5326
6664
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
5327
6665
|
return [];
|
|
5328
6666
|
}
|
|
5329
6667
|
const service = this.syncServices[repoIndex];
|
|
5330
6668
|
const worktreeDir = service.config.worktreeDir;
|
|
5331
|
-
const divergedDir =
|
|
6669
|
+
const divergedDir = path14.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5332
6670
|
let dirEntries;
|
|
5333
6671
|
try {
|
|
5334
|
-
dirEntries = await
|
|
6672
|
+
dirEntries = await fs10.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
5335
6673
|
} catch {
|
|
5336
6674
|
return [];
|
|
5337
6675
|
}
|
|
5338
6676
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
5339
6677
|
const results = await Promise.allSettled(
|
|
5340
6678
|
subdirs.map(async (entry) => {
|
|
5341
|
-
const fullPath =
|
|
5342
|
-
const infoFilePath =
|
|
6679
|
+
const fullPath = path14.join(divergedDir, entry.name);
|
|
6680
|
+
const infoFilePath = path14.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
5343
6681
|
let originalBranch = entry.name;
|
|
5344
6682
|
let divergedAt = "";
|
|
5345
6683
|
try {
|
|
5346
|
-
const infoContent = await
|
|
6684
|
+
const infoContent = await fs10.readFile(infoFilePath, "utf-8");
|
|
5347
6685
|
const info = JSON.parse(infoContent);
|
|
5348
6686
|
if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
|
|
5349
6687
|
if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
|
|
@@ -5374,15 +6712,15 @@ var InteractiveUIService = class {
|
|
|
5374
6712
|
}
|
|
5375
6713
|
const service = this.syncServices[repoIndex];
|
|
5376
6714
|
const worktreeDir = service.config.worktreeDir;
|
|
5377
|
-
const divergedBase =
|
|
6715
|
+
const divergedBase = path14.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5378
6716
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
5379
6717
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
5380
6718
|
}
|
|
5381
|
-
const targetPath =
|
|
6719
|
+
const targetPath = path14.join(divergedBase, name);
|
|
5382
6720
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
5383
6721
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
5384
6722
|
}
|
|
5385
|
-
await
|
|
6723
|
+
await fs10.rm(targetPath, { recursive: true, force: true });
|
|
5386
6724
|
this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
|
|
5387
6725
|
}
|
|
5388
6726
|
async createWorktreeForBranch(repoIndex, branchName) {
|
|
@@ -5500,7 +6838,7 @@ var InteractiveUIService = class {
|
|
|
5500
6838
|
async runSyncCycle(services, options) {
|
|
5501
6839
|
this.setStatus("syncing");
|
|
5502
6840
|
try {
|
|
5503
|
-
const { failures, skipped, attempted } = await this.runSyncServices(services);
|
|
6841
|
+
const { failures, skipped, partialSkips, clonePhaseSkips, attempted } = await this.runSyncServices(services);
|
|
5504
6842
|
if (options.logErrors) {
|
|
5505
6843
|
for (const failure of failures) {
|
|
5506
6844
|
this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
|
|
@@ -5509,6 +6847,15 @@ var InteractiveUIService = class {
|
|
|
5509
6847
|
for (const skip of skipped) {
|
|
5510
6848
|
this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
|
|
5511
6849
|
}
|
|
6850
|
+
for (const skip of clonePhaseSkips) {
|
|
6851
|
+
this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
|
|
6852
|
+
}
|
|
6853
|
+
if (clonePhaseSkips.length > 0) {
|
|
6854
|
+
this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) this cycle`, "warn");
|
|
6855
|
+
}
|
|
6856
|
+
for (const partial of partialSkips) {
|
|
6857
|
+
this.addLog(`${partial.repo}: ${partial.reason}`, "info");
|
|
6858
|
+
}
|
|
5512
6859
|
await this.recordSyncOutcome({ failures, skipped, attempted });
|
|
5513
6860
|
return failures;
|
|
5514
6861
|
} finally {
|
|
@@ -5525,6 +6872,7 @@ var InteractiveUIService = class {
|
|
|
5525
6872
|
const syncResults = await Promise.allSettled(
|
|
5526
6873
|
services.map(
|
|
5527
6874
|
(service) => this.limit(async () => {
|
|
6875
|
+
service.clearRecordedSkips();
|
|
5528
6876
|
if (!service.isInitialized()) {
|
|
5529
6877
|
await service.initialize();
|
|
5530
6878
|
}
|
|
@@ -5538,18 +6886,39 @@ var InteractiveUIService = class {
|
|
|
5538
6886
|
);
|
|
5539
6887
|
const failures = [];
|
|
5540
6888
|
const skipped = [];
|
|
6889
|
+
const partialSkips = [];
|
|
6890
|
+
const clonePhaseSkips = [];
|
|
5541
6891
|
for (let i = 0; i < syncResults.length; i++) {
|
|
5542
6892
|
const result = syncResults[i];
|
|
6893
|
+
const repoName = services[i].config.name || services[i].config.repoUrl;
|
|
5543
6894
|
if (result.status === "rejected") {
|
|
5544
|
-
const
|
|
6895
|
+
const fallbackName = result.reason?.repoName ?? repoName;
|
|
5545
6896
|
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
5546
|
-
failures.push({ repo:
|
|
6897
|
+
failures.push({ repo: fallbackName, error: errorMessage });
|
|
5547
6898
|
} else if (result.value.result && result.value.result.started === false) {
|
|
5548
|
-
const repoName = services[i].config.name || services[i].config.repoUrl;
|
|
5549
6899
|
skipped.push({ repo: repoName, reason: `sync skipped: ${result.value.result.reason}` });
|
|
6900
|
+
} else if (result.status === "fulfilled" && result.value.result?.started === true) {
|
|
6901
|
+
const outcome = result.value.result.outcome;
|
|
6902
|
+
if (outcome?.counts.failed) {
|
|
6903
|
+
failures.push({ repo: repoName, error: `${outcome.counts.failed} sync action(s) failed` });
|
|
6904
|
+
}
|
|
6905
|
+
if (outcome?.mode === "worktree" && outcome.counts.skipped > 0) {
|
|
6906
|
+
partialSkips.push({ repo: repoName, reason: `${outcome.counts.skipped} sync action(s) skipped` });
|
|
6907
|
+
}
|
|
6908
|
+
}
|
|
6909
|
+
for (const reason of services[i].getRecordedSkips()) {
|
|
6910
|
+
clonePhaseSkips.push({ repo: repoName, reason: formatCloneSkipReason(reason) });
|
|
5550
6911
|
}
|
|
5551
6912
|
}
|
|
5552
|
-
return { failures, skipped, attempted: services.length };
|
|
6913
|
+
return { failures, skipped, partialSkips, clonePhaseSkips, attempted: services.length };
|
|
6914
|
+
}
|
|
6915
|
+
buildUiLogger() {
|
|
6916
|
+
return new Logger({
|
|
6917
|
+
outputFn: (msg, level) => {
|
|
6918
|
+
const uiLevel = level === "warn" ? "warn" : level === "error" ? "error" : "info";
|
|
6919
|
+
this.addLog(msg, uiLevel);
|
|
6920
|
+
}
|
|
6921
|
+
});
|
|
5553
6922
|
}
|
|
5554
6923
|
executeOnBranchCreatedHooks(repoIndex, context) {
|
|
5555
6924
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -5557,27 +6926,15 @@ var InteractiveUIService = class {
|
|
|
5557
6926
|
}
|
|
5558
6927
|
const service = this.syncServices[repoIndex];
|
|
5559
6928
|
const config = service.config;
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
this.addLog(`[hook] ${data}`, "warn");
|
|
5570
|
-
},
|
|
5571
|
-
onError: (command, error) => {
|
|
5572
|
-
this.addLog(`[hook] Failed to execute '${command}': ${error.message}`, "error");
|
|
5573
|
-
},
|
|
5574
|
-
onComplete: (command, exitCode) => {
|
|
5575
|
-
if (exitCode === 0) {
|
|
5576
|
-
this.addLog(`[hook] Command completed successfully`, "info");
|
|
5577
|
-
} else if (exitCode !== null) {
|
|
5578
|
-
this.addLog(`[hook] Command exited with code ${exitCode}`, "warn");
|
|
5579
|
-
}
|
|
5580
|
-
}
|
|
6929
|
+
const repoName = config.name || config.repoUrl;
|
|
6930
|
+
this.branchCreatedActions.runHooks({
|
|
6931
|
+
config,
|
|
6932
|
+
repoName,
|
|
6933
|
+
branchName: context.branchName,
|
|
6934
|
+
worktreePath: context.worktreePath,
|
|
6935
|
+
baseBranch: context.baseBranch,
|
|
6936
|
+
logger: this.buildUiLogger(),
|
|
6937
|
+
hookExecutionService: this.hookExecutionService
|
|
5581
6938
|
});
|
|
5582
6939
|
}
|
|
5583
6940
|
async copyBranchFiles(repoIndex, baseBranch, targetBranch) {
|
|
@@ -5589,33 +6946,20 @@ var InteractiveUIService = class {
|
|
|
5589
6946
|
if (!config.filesToCopyOnBranchCreate?.length) {
|
|
5590
6947
|
return;
|
|
5591
6948
|
}
|
|
5592
|
-
const
|
|
5593
|
-
const worktrees = await gitService.getWorktrees();
|
|
6949
|
+
const worktrees = await this.getWorktreesFromService(service);
|
|
5594
6950
|
const sourceWorktree = worktrees.find((w) => w.branch === baseBranch);
|
|
5595
6951
|
const targetWorktree = worktrees.find((w) => w.branch === targetBranch);
|
|
5596
6952
|
if (!sourceWorktree || !targetWorktree) {
|
|
5597
6953
|
this.addLog(`Could not find worktrees for file copy: source=${baseBranch}, target=${targetBranch}`, "warn");
|
|
5598
6954
|
return;
|
|
5599
6955
|
}
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
if (result.copied.length > 0) {
|
|
5608
|
-
this.addLog(`\u{1F4CB} Copied ${result.copied.length} file(s) to new branch: ${result.copied.join(", ")}`, "info");
|
|
5609
|
-
}
|
|
5610
|
-
if (result.errors.length > 0) {
|
|
5611
|
-
this.addLog(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s):`, "warn");
|
|
5612
|
-
for (const err of result.errors) {
|
|
5613
|
-
this.addLog(` - ${err.file}: ${err.error}`, "warn");
|
|
5614
|
-
}
|
|
5615
|
-
}
|
|
5616
|
-
} catch (error) {
|
|
5617
|
-
this.addLog(`Failed to copy files to new branch: ${error}`, "error");
|
|
5618
|
-
}
|
|
6956
|
+
await this.branchCreatedActions.copyFiles({
|
|
6957
|
+
config,
|
|
6958
|
+
branchName: targetBranch,
|
|
6959
|
+
worktreePath: targetWorktree.path,
|
|
6960
|
+
sourceDir: sourceWorktree.path,
|
|
6961
|
+
logger: this.buildUiLogger()
|
|
6962
|
+
});
|
|
5619
6963
|
}
|
|
5620
6964
|
async destroy(fast = false) {
|
|
5621
6965
|
this.isDestroyed = true;
|
|
@@ -5644,135 +6988,85 @@ var InteractiveUIService = class {
|
|
|
5644
6988
|
// src/utils/cli.ts
|
|
5645
6989
|
import yargs from "yargs";
|
|
5646
6990
|
import { hideBin } from "yargs/helpers";
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
repoUrl: argv.repoUrl,
|
|
5715
|
-
worktreeDir: argv.worktreeDir,
|
|
5716
|
-
cronSchedule: argv.cronSchedule,
|
|
5717
|
-
runOnce: argv.runOnce,
|
|
5718
|
-
bareRepoDir: argv.bareRepoDir,
|
|
5719
|
-
branchMaxAge: argv.branchMaxAge,
|
|
5720
|
-
branchInclude: argv.branchInclude ? argv.branchInclude.split(",").map((p) => p.trim()) : void 0,
|
|
5721
|
-
branchExclude: argv.branchExclude ? argv.branchExclude.split(",").map((p) => p.trim()) : void 0,
|
|
5722
|
-
skipLfs: argv.skipLfs,
|
|
5723
|
-
noUpdateExisting: argv["no-update-existing"],
|
|
5724
|
-
debug: argv.debug,
|
|
5725
|
-
syncOnStart: argv["sync-on-start"]
|
|
5726
|
-
};
|
|
5727
|
-
}
|
|
5728
|
-
function isInteractiveMode(config) {
|
|
5729
|
-
return !config.repoUrl || !config.worktreeDir;
|
|
5730
|
-
}
|
|
5731
|
-
function reconstructCliCommand(config) {
|
|
5732
|
-
const executable = process.argv[1].includes("ts-node") ? "ts-node src/index.ts" : "sync-worktrees";
|
|
5733
|
-
const args = [];
|
|
5734
|
-
args.push(`--repoUrl "${config.repoUrl}"`);
|
|
5735
|
-
if (config.worktreeDir) {
|
|
5736
|
-
args.push(`--worktreeDir "${config.worktreeDir}"`);
|
|
5737
|
-
}
|
|
5738
|
-
if (config.bareRepoDir) {
|
|
5739
|
-
args.push(`--bareRepoDir "${config.bareRepoDir}"`);
|
|
5740
|
-
}
|
|
5741
|
-
if (config.cronSchedule && config.cronSchedule !== "0 * * * *") {
|
|
5742
|
-
args.push(`--cronSchedule "${config.cronSchedule}"`);
|
|
5743
|
-
}
|
|
5744
|
-
if (config.runOnce) {
|
|
5745
|
-
args.push("--runOnce");
|
|
5746
|
-
}
|
|
5747
|
-
if (config.branchMaxAge) {
|
|
5748
|
-
args.push(`--branchMaxAge "${config.branchMaxAge}"`);
|
|
5749
|
-
}
|
|
5750
|
-
if (config.branchInclude?.length) {
|
|
5751
|
-
args.push(`--branchInclude "${config.branchInclude.join(",")}"`);
|
|
5752
|
-
}
|
|
5753
|
-
if (config.branchExclude?.length) {
|
|
5754
|
-
args.push(`--branchExclude "${config.branchExclude.join(",")}"`);
|
|
5755
|
-
}
|
|
5756
|
-
if (config.skipLfs) {
|
|
5757
|
-
args.push("--skip-lfs");
|
|
5758
|
-
}
|
|
5759
|
-
if (config.updateExistingWorktrees === false) {
|
|
5760
|
-
args.push("--no-update-existing");
|
|
5761
|
-
}
|
|
5762
|
-
if (config.debug) {
|
|
5763
|
-
args.push("--debug");
|
|
6991
|
+
var CLI_COMMANDS = {
|
|
6992
|
+
RUN: "run",
|
|
6993
|
+
INIT: "init",
|
|
6994
|
+
LIST: "list"
|
|
6995
|
+
};
|
|
6996
|
+
function parseArguments(argv = hideBin(process.argv)) {
|
|
6997
|
+
let parsed;
|
|
6998
|
+
yargs(argv).scriptName("sync-worktrees").parserConfiguration({ "camel-case-expansion": false }).strict().command(
|
|
6999
|
+
"$0",
|
|
7000
|
+
"Sync git worktrees against a config file",
|
|
7001
|
+
(y) => y.option("config", {
|
|
7002
|
+
alias: "c",
|
|
7003
|
+
type: "string",
|
|
7004
|
+
description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
|
|
7005
|
+
}).option("runOnce", {
|
|
7006
|
+
type: "boolean",
|
|
7007
|
+
description: "Run a sync once and exit, overriding config runOnce settings for this invocation.",
|
|
7008
|
+
default: false
|
|
7009
|
+
}),
|
|
7010
|
+
(args) => {
|
|
7011
|
+
parsed = {
|
|
7012
|
+
command: CLI_COMMANDS.RUN,
|
|
7013
|
+
config: args.config,
|
|
7014
|
+
runOnce: args.runOnce
|
|
7015
|
+
};
|
|
7016
|
+
}
|
|
7017
|
+
).command(
|
|
7018
|
+
CLI_COMMANDS.INIT,
|
|
7019
|
+
"Create a new config file interactively",
|
|
7020
|
+
(y) => y.option("config", {
|
|
7021
|
+
alias: "c",
|
|
7022
|
+
type: "string",
|
|
7023
|
+
description: "Target path for the generated config file (default: ./sync-worktrees.config.js)."
|
|
7024
|
+
}).option("force", {
|
|
7025
|
+
type: "boolean",
|
|
7026
|
+
description: "Overwrite the target file if it already exists.",
|
|
7027
|
+
default: false
|
|
7028
|
+
}),
|
|
7029
|
+
(args) => {
|
|
7030
|
+
parsed = {
|
|
7031
|
+
command: CLI_COMMANDS.INIT,
|
|
7032
|
+
config: args.config,
|
|
7033
|
+
force: args.force
|
|
7034
|
+
};
|
|
7035
|
+
}
|
|
7036
|
+
).command(
|
|
7037
|
+
CLI_COMMANDS.LIST,
|
|
7038
|
+
"List repositories configured in a config file and exit",
|
|
7039
|
+
(y) => y.option("config", {
|
|
7040
|
+
alias: "c",
|
|
7041
|
+
type: "string",
|
|
7042
|
+
description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
|
|
7043
|
+
}).option("filter", {
|
|
7044
|
+
alias: "f",
|
|
7045
|
+
type: "string",
|
|
7046
|
+
description: "Filter repositories by name (wildcards, comma-separated)."
|
|
7047
|
+
}),
|
|
7048
|
+
(args) => {
|
|
7049
|
+
parsed = {
|
|
7050
|
+
command: CLI_COMMANDS.LIST,
|
|
7051
|
+
config: args.config,
|
|
7052
|
+
filter: args.filter
|
|
7053
|
+
};
|
|
7054
|
+
}
|
|
7055
|
+
).demandCommand(0, 0).help().alias("help", "h").version().parseSync();
|
|
7056
|
+
if (!parsed) {
|
|
7057
|
+
throw new Error("Failed to parse CLI arguments");
|
|
5764
7058
|
}
|
|
5765
|
-
return
|
|
7059
|
+
return parsed;
|
|
5766
7060
|
}
|
|
5767
7061
|
|
|
5768
7062
|
// src/utils/config-generator.ts
|
|
5769
|
-
import * as
|
|
5770
|
-
import * as
|
|
7063
|
+
import * as fs11 from "fs/promises";
|
|
7064
|
+
import * as path15 from "path";
|
|
5771
7065
|
function serializeToESM(obj, indent = 0) {
|
|
5772
7066
|
const spaces = " ".repeat(indent);
|
|
5773
7067
|
const innerSpaces = " ".repeat(indent + 2);
|
|
5774
7068
|
if (typeof obj === "string") {
|
|
5775
|
-
return
|
|
7069
|
+
return JSON.stringify(obj);
|
|
5776
7070
|
}
|
|
5777
7071
|
if (typeof obj === "number" || typeof obj === "boolean") {
|
|
5778
7072
|
return String(obj);
|
|
@@ -5796,99 +7090,102 @@ ${spaces}}`;
|
|
|
5796
7090
|
}
|
|
5797
7091
|
return String(obj);
|
|
5798
7092
|
}
|
|
5799
|
-
async function generateConfigFile(
|
|
5800
|
-
const configDir =
|
|
5801
|
-
await
|
|
5802
|
-
const worktreeDirRelative =
|
|
7093
|
+
async function generateConfigFile(input2, configPath, options = {}) {
|
|
7094
|
+
const configDir = path15.dirname(configPath);
|
|
7095
|
+
await fs11.mkdir(configDir, { recursive: true });
|
|
7096
|
+
const worktreeDirRelative = path15.relative(configDir, input2.worktreeDir);
|
|
5803
7097
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
5804
|
-
const repoName = extractRepoNameFromUrl(
|
|
7098
|
+
const repoName = extractRepoNameFromUrl(input2.repoUrl);
|
|
5805
7099
|
const repository = {
|
|
5806
7100
|
name: repoName,
|
|
5807
|
-
repoUrl:
|
|
5808
|
-
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` :
|
|
7101
|
+
repoUrl: input2.repoUrl,
|
|
7102
|
+
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
|
|
5809
7103
|
};
|
|
5810
|
-
if (
|
|
5811
|
-
const bareRepoDirRelative =
|
|
7104
|
+
if (input2.bareRepoDir) {
|
|
7105
|
+
const bareRepoDirRelative = path15.relative(configDir, input2.bareRepoDir);
|
|
5812
7106
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
5813
|
-
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` :
|
|
7107
|
+
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
|
|
5814
7108
|
}
|
|
5815
7109
|
const configObject = {
|
|
5816
7110
|
defaults: {
|
|
5817
|
-
cronSchedule:
|
|
5818
|
-
runOnce:
|
|
7111
|
+
cronSchedule: input2.cronSchedule,
|
|
7112
|
+
runOnce: input2.runOnce
|
|
5819
7113
|
},
|
|
5820
7114
|
repositories: [repository]
|
|
5821
7115
|
};
|
|
5822
|
-
const configContent =
|
|
7116
|
+
const configContent = `// @ts-check
|
|
7117
|
+
|
|
7118
|
+
/**
|
|
5823
7119
|
* Sync-worktrees configuration file
|
|
5824
7120
|
* Generated on ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
5825
7121
|
*/
|
|
5826
7122
|
|
|
5827
|
-
|
|
7123
|
+
/** @satisfies {import("sync-worktrees").SyncWorktreesConfig} */
|
|
7124
|
+
const config = ${serializeToESM(configObject)};
|
|
7125
|
+
|
|
7126
|
+
export default config;
|
|
5828
7127
|
`;
|
|
5829
|
-
|
|
7128
|
+
try {
|
|
7129
|
+
await fs11.writeFile(configPath, configContent, {
|
|
7130
|
+
encoding: "utf-8",
|
|
7131
|
+
flag: options.overwrite ? "w" : "wx"
|
|
7132
|
+
});
|
|
7133
|
+
} catch (error) {
|
|
7134
|
+
if (error.code === "EEXIST") {
|
|
7135
|
+
throw new ConfigFileExistsError(configPath);
|
|
7136
|
+
}
|
|
7137
|
+
throw error;
|
|
7138
|
+
}
|
|
5830
7139
|
}
|
|
5831
7140
|
function getDefaultConfigPath() {
|
|
5832
|
-
return
|
|
7141
|
+
return path15.join(process.cwd(), "sync-worktrees.config.js");
|
|
5833
7142
|
}
|
|
5834
7143
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
5835
7144
|
for (const name of CONFIG_FILE_NAMES) {
|
|
5836
|
-
const full =
|
|
5837
|
-
|
|
5838
|
-
await fs9.access(full);
|
|
7145
|
+
const full = path15.join(cwd, name);
|
|
7146
|
+
if (await fileExists(full)) {
|
|
5839
7147
|
return full;
|
|
5840
|
-
} catch {
|
|
5841
7148
|
}
|
|
5842
7149
|
}
|
|
5843
7150
|
return null;
|
|
5844
7151
|
}
|
|
5845
7152
|
|
|
5846
7153
|
// src/utils/interactive.ts
|
|
5847
|
-
import * as
|
|
7154
|
+
import * as path16 from "path";
|
|
5848
7155
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
5849
|
-
async function
|
|
7156
|
+
async function promptForInitConfig() {
|
|
5850
7157
|
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
if (!value.trim()) {
|
|
5857
|
-
return "Repository URL is required";
|
|
5858
|
-
}
|
|
5859
|
-
try {
|
|
5860
|
-
if (!value.match(/^(https?:\/\/|ssh:\/\/|git@|file:\/\/).*$/)) {
|
|
5861
|
-
return "Please enter a valid Git URL (https://, ssh://, git@, or file://)";
|
|
5862
|
-
}
|
|
5863
|
-
return true;
|
|
5864
|
-
} catch {
|
|
5865
|
-
return "Please enter a valid URL";
|
|
5866
|
-
}
|
|
7158
|
+
const repoUrl = await input({
|
|
7159
|
+
message: "Enter the Git repository URL (e.g., https://github.com/user/repo.git):",
|
|
7160
|
+
validate: (value) => {
|
|
7161
|
+
if (!value.trim()) {
|
|
7162
|
+
return "Repository URL is required";
|
|
5867
7163
|
}
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
let worktreeDir = partialConfig.worktreeDir;
|
|
5871
|
-
if (!worktreeDir) {
|
|
5872
|
-
const repoName = repoUrl ? extractRepoNameFromUrl(repoUrl) : "";
|
|
5873
|
-
const defaultWorktreeDir = repoName ? `./${repoName}` : "";
|
|
5874
|
-
worktreeDir = await input({
|
|
5875
|
-
message: "Enter the directory for storing worktrees:",
|
|
5876
|
-
default: defaultWorktreeDir,
|
|
5877
|
-
validate: (value) => {
|
|
5878
|
-
if (!value.trim() && !defaultWorktreeDir) {
|
|
5879
|
-
return "Worktree directory is required";
|
|
5880
|
-
}
|
|
5881
|
-
return true;
|
|
7164
|
+
if (!value.match(/^(https?:\/\/|ssh:\/\/|git@|file:\/\/).*$/)) {
|
|
7165
|
+
return "Please enter a valid Git URL (https://, ssh://, git@, or file://)";
|
|
5882
7166
|
}
|
|
5883
|
-
|
|
5884
|
-
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
5885
|
-
worktreeDir = defaultWorktreeDir;
|
|
7167
|
+
return true;
|
|
5886
7168
|
}
|
|
5887
|
-
|
|
5888
|
-
|
|
7169
|
+
});
|
|
7170
|
+
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
7171
|
+
const defaultWorktreeDir = repoName ? `./${repoName}` : "";
|
|
7172
|
+
let worktreeDir = await input({
|
|
7173
|
+
message: "Enter the directory for storing worktrees:",
|
|
7174
|
+
default: defaultWorktreeDir,
|
|
7175
|
+
validate: (value) => {
|
|
7176
|
+
if (!value.trim() && !defaultWorktreeDir) {
|
|
7177
|
+
return "Worktree directory is required";
|
|
7178
|
+
}
|
|
7179
|
+
return true;
|
|
5889
7180
|
}
|
|
7181
|
+
});
|
|
7182
|
+
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
7183
|
+
worktreeDir = defaultWorktreeDir;
|
|
5890
7184
|
}
|
|
5891
|
-
|
|
7185
|
+
if (!path16.isAbsolute(worktreeDir)) {
|
|
7186
|
+
worktreeDir = path16.resolve(worktreeDir);
|
|
7187
|
+
}
|
|
7188
|
+
let bareRepoDir;
|
|
5892
7189
|
const askForBareDir = await confirm({
|
|
5893
7190
|
message: "Would you like to specify a custom location for the bare repository?",
|
|
5894
7191
|
default: false
|
|
@@ -5904,96 +7201,42 @@ async function promptForConfig(partialConfig) {
|
|
|
5904
7201
|
return true;
|
|
5905
7202
|
}
|
|
5906
7203
|
});
|
|
5907
|
-
if (!
|
|
5908
|
-
bareRepoDir =
|
|
5909
|
-
}
|
|
5910
|
-
}
|
|
5911
|
-
let runOnce = partialConfig.runOnce;
|
|
5912
|
-
let cronSchedule = partialConfig.cronSchedule || "0 * * * *";
|
|
5913
|
-
if (runOnce === void 0) {
|
|
5914
|
-
const runMode = await select({
|
|
5915
|
-
message: "How would you like to run the sync?",
|
|
5916
|
-
choices: [
|
|
5917
|
-
{ name: "Run once", value: "once" },
|
|
5918
|
-
{ name: "Schedule with cron", value: "scheduled" }
|
|
5919
|
-
]
|
|
5920
|
-
});
|
|
5921
|
-
runOnce = runMode === "once";
|
|
5922
|
-
if (!runOnce && !partialConfig.cronSchedule) {
|
|
5923
|
-
cronSchedule = await input({
|
|
5924
|
-
message: "Enter the cron schedule (or press enter for default):",
|
|
5925
|
-
default: "0 * * * *",
|
|
5926
|
-
validate: (value) => {
|
|
5927
|
-
if (!value.trim()) {
|
|
5928
|
-
return "Cron schedule is required";
|
|
5929
|
-
}
|
|
5930
|
-
const parts = value.trim().split(" ");
|
|
5931
|
-
if (parts.length < 5) {
|
|
5932
|
-
return "Invalid cron pattern. Expected format: '* * * * *'";
|
|
5933
|
-
}
|
|
5934
|
-
return true;
|
|
5935
|
-
}
|
|
5936
|
-
});
|
|
7204
|
+
if (!path16.isAbsolute(bareRepoDir)) {
|
|
7205
|
+
bareRepoDir = path16.resolve(bareRepoDir);
|
|
5937
7206
|
}
|
|
5938
7207
|
}
|
|
5939
|
-
const
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
};
|
|
5946
|
-
console.log("\n\u{1F4CB} Configuration summary:");
|
|
5947
|
-
console.log(` Repository URL: ${finalConfig.repoUrl}`);
|
|
5948
|
-
console.log(` Worktrees: ${finalConfig.worktreeDir}`);
|
|
5949
|
-
if (finalConfig.bareRepoDir) {
|
|
5950
|
-
console.log(` Bare repo: ${finalConfig.bareRepoDir}`);
|
|
5951
|
-
} else {
|
|
5952
|
-
console.log(` Bare repo: .bare/<repo-name> (default)`);
|
|
5953
|
-
}
|
|
5954
|
-
if (finalConfig.runOnce) {
|
|
5955
|
-
console.log(` Mode: Run once`);
|
|
5956
|
-
} else {
|
|
5957
|
-
console.log(` Mode: Scheduled (${finalConfig.cronSchedule})`);
|
|
5958
|
-
}
|
|
5959
|
-
console.log("");
|
|
5960
|
-
const saveConfig = await confirm({
|
|
5961
|
-
message: "Would you like to save this configuration to a file for future use?",
|
|
5962
|
-
default: true
|
|
7208
|
+
const runMode = await select({
|
|
7209
|
+
message: "How would you like to run the sync?",
|
|
7210
|
+
choices: [
|
|
7211
|
+
{ name: "Run once", value: "once" },
|
|
7212
|
+
{ name: "Schedule with cron", value: "scheduled" }
|
|
7213
|
+
]
|
|
5963
7214
|
});
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
message: "Enter the
|
|
5969
|
-
default:
|
|
7215
|
+
const runOnce = runMode === "once";
|
|
7216
|
+
let cronSchedule = "0 * * * *";
|
|
7217
|
+
if (!runOnce) {
|
|
7218
|
+
cronSchedule = await input({
|
|
7219
|
+
message: "Enter the cron schedule (or press enter for default):",
|
|
7220
|
+
default: "0 * * * *",
|
|
5970
7221
|
validate: (value) => {
|
|
5971
7222
|
if (!value.trim()) {
|
|
5972
|
-
return "
|
|
7223
|
+
return "Cron schedule is required";
|
|
5973
7224
|
}
|
|
5974
|
-
|
|
5975
|
-
|
|
7225
|
+
const parts = value.trim().split(" ");
|
|
7226
|
+
if (parts.length < 5) {
|
|
7227
|
+
return "Invalid cron pattern. Expected format: '* * * * *'";
|
|
5976
7228
|
}
|
|
5977
7229
|
return true;
|
|
5978
7230
|
}
|
|
5979
7231
|
});
|
|
5980
|
-
if (!path12.isAbsolute(configPath)) {
|
|
5981
|
-
configPath = path12.resolve(configPath);
|
|
5982
|
-
}
|
|
5983
|
-
try {
|
|
5984
|
-
await generateConfigFile(finalConfig, configPath);
|
|
5985
|
-
savedConfigPath = configPath;
|
|
5986
|
-
console.log(`
|
|
5987
|
-
\u2705 Configuration saved to: ${configPath}`);
|
|
5988
|
-
console.log(`
|
|
5989
|
-
\u{1F4A1} Next time run \`sync-worktrees\` from this directory \u2014 the config will be auto-loaded.`);
|
|
5990
|
-
console.log("");
|
|
5991
|
-
} catch (error) {
|
|
5992
|
-
console.error(`
|
|
5993
|
-
\u274C Failed to save config file: ${error.message}`);
|
|
5994
|
-
}
|
|
5995
7232
|
}
|
|
5996
|
-
return {
|
|
7233
|
+
return {
|
|
7234
|
+
repoUrl,
|
|
7235
|
+
worktreeDir,
|
|
7236
|
+
bareRepoDir,
|
|
7237
|
+
cronSchedule,
|
|
7238
|
+
runOnce
|
|
7239
|
+
};
|
|
5997
7240
|
}
|
|
5998
7241
|
|
|
5999
7242
|
// src/utils/signal-handlers.ts
|
|
@@ -6045,48 +7288,12 @@ Shutdown took longer than ${forceExitMs}ms, forcing exit.`);
|
|
|
6045
7288
|
|
|
6046
7289
|
// src/index.ts
|
|
6047
7290
|
var signalHandle = setupSignalHandlers();
|
|
6048
|
-
async function
|
|
6049
|
-
const logger = Logger.createDefault(void 0, config.debug);
|
|
6050
|
-
logger.info("\n\u{1F4CB} CLI Command (for future reference):");
|
|
6051
|
-
logger.info(` ${reconstructCliCommand(config)}`);
|
|
6052
|
-
logger.info("");
|
|
6053
|
-
if (!config.logger) {
|
|
6054
|
-
config.logger = logger;
|
|
6055
|
-
}
|
|
6056
|
-
const syncService = new WorktreeSyncService(config);
|
|
6057
|
-
try {
|
|
6058
|
-
await syncService.initialize();
|
|
6059
|
-
if (config.runOnce) {
|
|
6060
|
-
logger.info("Running the sync process once as requested by --runOnce flag.");
|
|
6061
|
-
await syncService.sync();
|
|
6062
|
-
} else {
|
|
6063
|
-
const uiService = new InteractiveUIService([syncService], void 0, config.cronSchedule);
|
|
6064
|
-
signalHandle.register((fast) => uiService.destroy(fast));
|
|
6065
|
-
await syncService.sync();
|
|
6066
|
-
uiService.updateLastSyncTime();
|
|
6067
|
-
void uiService.calculateAndUpdateDiskSpace();
|
|
6068
|
-
const job = cron3.schedule(config.cronSchedule, async () => {
|
|
6069
|
-
try {
|
|
6070
|
-
uiService.setStatus("syncing");
|
|
6071
|
-
await syncService.sync();
|
|
6072
|
-
uiService.updateLastSyncTime();
|
|
6073
|
-
void uiService.calculateAndUpdateDiskSpace();
|
|
6074
|
-
} catch (error) {
|
|
6075
|
-
logger.error(`Error during scheduled sync: ${error.message}`, error);
|
|
6076
|
-
uiService.setStatus("idle");
|
|
6077
|
-
}
|
|
6078
|
-
});
|
|
6079
|
-
uiService.registerCronJob(job);
|
|
6080
|
-
}
|
|
6081
|
-
} catch (error) {
|
|
6082
|
-
logger.error("\u274C Fatal Error during initialization:", error);
|
|
6083
|
-
process.exit(1);
|
|
6084
|
-
}
|
|
6085
|
-
}
|
|
6086
|
-
async function runMultipleRepositories(repositories, runOnce, configPath, maxParallel, syncOnStart, reloadOptions) {
|
|
7291
|
+
async function runMultipleRepositories(configFile, repositories, configPath) {
|
|
6087
7292
|
const services = /* @__PURE__ */ new Map();
|
|
6088
7293
|
const globalLogger = Logger.createDefault();
|
|
6089
|
-
const
|
|
7294
|
+
const runOnce = configFile.defaults?.runOnce ?? false;
|
|
7295
|
+
const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
|
|
7296
|
+
const limit = pLimit3(maxParallel);
|
|
6090
7297
|
if (runOnce) {
|
|
6091
7298
|
globalLogger.info(`
|
|
6092
7299
|
\u{1F504} Syncing ${repositories.length} repositories...`);
|
|
@@ -6123,7 +7330,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6123
7330
|
servicesToSync.map(
|
|
6124
7331
|
({ name, service }) => limit(async () => {
|
|
6125
7332
|
try {
|
|
6126
|
-
await service.sync();
|
|
7333
|
+
return await service.sync();
|
|
6127
7334
|
} catch (error) {
|
|
6128
7335
|
globalLogger.error(`\u274C Error syncing repository '${name}':`, error);
|
|
6129
7336
|
throw error;
|
|
@@ -6131,9 +7338,66 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6131
7338
|
})
|
|
6132
7339
|
)
|
|
6133
7340
|
);
|
|
6134
|
-
const
|
|
6135
|
-
|
|
6136
|
-
|
|
7341
|
+
const skipsByRepo = [];
|
|
7342
|
+
const skippedNames = /* @__PURE__ */ new Set();
|
|
7343
|
+
const outcomeFailedNames = /* @__PURE__ */ new Set();
|
|
7344
|
+
const partialSkipNames = /* @__PURE__ */ new Set();
|
|
7345
|
+
for (let i = 0; i < servicesToSync.length; i++) {
|
|
7346
|
+
const { name, service } = servicesToSync[i];
|
|
7347
|
+
const reasons = service.getRecordedSkips();
|
|
7348
|
+
if (reasons.length > 0) {
|
|
7349
|
+
skipsByRepo.push({ repo: name, reasons });
|
|
7350
|
+
skippedNames.add(name);
|
|
7351
|
+
}
|
|
7352
|
+
const result = syncResults[i];
|
|
7353
|
+
if (result.status === "fulfilled") {
|
|
7354
|
+
if (!result.value.started) {
|
|
7355
|
+
skippedNames.add(name);
|
|
7356
|
+
continue;
|
|
7357
|
+
}
|
|
7358
|
+
const counts = result.value.outcome?.counts;
|
|
7359
|
+
if (counts) {
|
|
7360
|
+
if (counts.failed > 0) {
|
|
7361
|
+
outcomeFailedNames.add(name);
|
|
7362
|
+
}
|
|
7363
|
+
if (counts.skipped > 0 && !skippedNames.has(name) && !outcomeFailedNames.has(name)) {
|
|
7364
|
+
partialSkipNames.add(name);
|
|
7365
|
+
}
|
|
7366
|
+
}
|
|
7367
|
+
}
|
|
7368
|
+
}
|
|
7369
|
+
if (skipsByRepo.length > 0) {
|
|
7370
|
+
const skipsRepoWord = skipsByRepo.length === 1 ? "repo" : "repos";
|
|
7371
|
+
globalLogger.warn(`
|
|
7372
|
+
\u26A0\uFE0F Clone-mode skips (${skipsByRepo.length} ${skipsRepoWord}):`);
|
|
7373
|
+
for (const { repo, reasons } of skipsByRepo) {
|
|
7374
|
+
for (const reason of reasons) {
|
|
7375
|
+
globalLogger.warn(` \u2022 ${repo} \u2014 ${formatCloneSkipReason(reason)}`);
|
|
7376
|
+
}
|
|
7377
|
+
}
|
|
7378
|
+
}
|
|
7379
|
+
const initFailures = initResults.filter(
|
|
7380
|
+
(result, index) => result.status === "rejected" && !skippedNames.has(repositories[index].name)
|
|
7381
|
+
).length;
|
|
7382
|
+
const syncFailures = syncResults.filter(
|
|
7383
|
+
(result, index) => result.status === "rejected" && !skippedNames.has(servicesToSync[index].name)
|
|
7384
|
+
).length;
|
|
7385
|
+
const failedCount = initFailures + syncFailures + outcomeFailedNames.size;
|
|
7386
|
+
const skippedCount = skippedNames.size;
|
|
7387
|
+
const successCount = syncResults.filter((result, index) => {
|
|
7388
|
+
const repoName = servicesToSync[index].name;
|
|
7389
|
+
return result.status === "fulfilled" && result.value.started && !skippedNames.has(repoName) && !outcomeFailedNames.has(repoName);
|
|
7390
|
+
}).length;
|
|
7391
|
+
const processedRepoWord = repositories.length === 1 ? "repo" : "repos";
|
|
7392
|
+
const skipSummaryLabel = skippedNames.size === skipsByRepo.length ? "with clone-mode skips" : "skipped";
|
|
7393
|
+
const partialSuffix = partialSkipNames.size > 0 ? ` (${partialSkipNames.size} with partial skips)` : "";
|
|
7394
|
+
globalLogger.info(
|
|
7395
|
+
`
|
|
7396
|
+
\u{1F4CA} Processed ${repositories.length} ${processedRepoWord}: ${successCount} synced${partialSuffix}, ${skippedCount} ${skipSummaryLabel}, ${failedCount} failed`
|
|
7397
|
+
);
|
|
7398
|
+
if (failedCount > 0) {
|
|
7399
|
+
process.exitCode = 1;
|
|
7400
|
+
}
|
|
6137
7401
|
} else {
|
|
6138
7402
|
for (const repoConfig of repositories) {
|
|
6139
7403
|
const syncService = new WorktreeSyncService(repoConfig);
|
|
@@ -6142,7 +7406,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6142
7406
|
const uniqueSchedules = [...new Set(repositories.map((r) => r.cronSchedule))];
|
|
6143
7407
|
const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
|
|
6144
7408
|
const allServices = Array.from(services.values());
|
|
6145
|
-
const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel
|
|
7409
|
+
const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel);
|
|
6146
7410
|
signalHandle.register((fast) => uiService.destroy(fast));
|
|
6147
7411
|
void uiService.calculateAndUpdateDiskSpace();
|
|
6148
7412
|
uiService.setupCronJobs();
|
|
@@ -6151,15 +7415,12 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6151
7415
|
for (const repo of repositories) {
|
|
6152
7416
|
cronSchedules.set(repo.cronSchedule, (cronSchedules.get(repo.cronSchedule) || 0) + 1);
|
|
6153
7417
|
}
|
|
6154
|
-
for (const [
|
|
6155
|
-
uiService.addLog(`\u23F0 ${
|
|
6156
|
-
}
|
|
6157
|
-
if (syncOnStart) {
|
|
6158
|
-
await uiService.triggerInitialSync();
|
|
7418
|
+
for (const [schedule2, count] of cronSchedules) {
|
|
7419
|
+
uiService.addLog(`\u23F0 ${schedule2}: ${count} repository(ies)`);
|
|
6159
7420
|
}
|
|
6160
7421
|
}
|
|
6161
7422
|
}
|
|
6162
|
-
async function
|
|
7423
|
+
async function runList(configPath, filter) {
|
|
6163
7424
|
const configLoader = new ConfigLoaderService();
|
|
6164
7425
|
try {
|
|
6165
7426
|
const { repositories } = await configLoader.buildRepositories(configPath, { filter });
|
|
@@ -6187,114 +7448,98 @@ async function listRepositories(configPath, filter) {
|
|
|
6187
7448
|
process.exit(1);
|
|
6188
7449
|
}
|
|
6189
7450
|
}
|
|
6190
|
-
async function runFromConfigFile(configPath,
|
|
7451
|
+
async function runFromConfigFile(configPath, runOnceOverride = false) {
|
|
6191
7452
|
const configLoader = new ConfigLoaderService();
|
|
6192
|
-
const { repositories, configFile } = await configLoader.buildRepositories(configPath
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
7453
|
+
const { repositories, configFile } = await configLoader.buildRepositories(configPath);
|
|
7454
|
+
const effectiveConfigFile = runOnceOverride ? { ...configFile, defaults: { ...configFile.defaults ?? {}, runOnce: true } } : configFile;
|
|
7455
|
+
await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
|
|
7456
|
+
}
|
|
7457
|
+
async function resolveConfigOrExit(cliPath) {
|
|
7458
|
+
const resolved = cliPath ? path17.resolve(cliPath) : await findConfigInCwd();
|
|
7459
|
+
if (!resolved) {
|
|
7460
|
+
console.error(
|
|
7461
|
+
"\u274C No config file found. Pass --config <path>, run `sync-worktrees init` to create one, or place a sync-worktrees.config.{js,mjs,cjs} in this directory."
|
|
7462
|
+
);
|
|
6199
7463
|
process.exit(1);
|
|
6200
7464
|
}
|
|
6201
|
-
|
|
6202
|
-
const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
|
|
6203
|
-
const reloadOptions = {
|
|
6204
|
-
filter: options.filter,
|
|
6205
|
-
noUpdateExisting: options.noUpdateExisting,
|
|
6206
|
-
debug: options.debug
|
|
6207
|
-
};
|
|
6208
|
-
await runMultipleRepositories(
|
|
6209
|
-
repositories,
|
|
6210
|
-
globalRunOnce,
|
|
6211
|
-
configPath,
|
|
6212
|
-
maxParallel,
|
|
6213
|
-
options.syncOnStart,
|
|
6214
|
-
reloadOptions
|
|
6215
|
-
);
|
|
7465
|
+
return resolved;
|
|
6216
7466
|
}
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
return;
|
|
7467
|
+
function exitConfigExists(targetPath) {
|
|
7468
|
+
console.error(`
|
|
7469
|
+
\u274C Config file already exists: ${targetPath}`);
|
|
7470
|
+
console.error(`\u{1F4A1} Re-run with --force to overwrite.`);
|
|
7471
|
+
process.exit(1);
|
|
7472
|
+
}
|
|
7473
|
+
async function runInit(configPath, force) {
|
|
7474
|
+
const targetPath = configPath ? path17.resolve(configPath) : getDefaultConfigPath();
|
|
7475
|
+
if (!force && await fileExists(targetPath)) {
|
|
7476
|
+
exitConfigExists(targetPath);
|
|
6228
7477
|
}
|
|
6229
|
-
const
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
}
|
|
6233
|
-
|
|
7478
|
+
const input2 = await promptForInitConfig();
|
|
7479
|
+
try {
|
|
7480
|
+
await generateConfigFile(input2, targetPath, { overwrite: force });
|
|
7481
|
+
} catch (error) {
|
|
7482
|
+
if (error instanceof ConfigFileExistsError) {
|
|
7483
|
+
exitConfigExists(error.configPath);
|
|
7484
|
+
}
|
|
7485
|
+
throw error;
|
|
6234
7486
|
}
|
|
6235
|
-
|
|
6236
|
-
|
|
7487
|
+
const displayPath = path17.relative(process.cwd(), targetPath) || targetPath;
|
|
7488
|
+
console.log(`
|
|
7489
|
+
\u2705 Configuration saved to: ${targetPath}`);
|
|
7490
|
+
console.log(`
|
|
7491
|
+
\u{1F4A1} Next: sync-worktrees --config ${displayPath}`);
|
|
7492
|
+
}
|
|
7493
|
+
async function runSync(options) {
|
|
7494
|
+
const configPath = await resolveConfigOrExit(options.config);
|
|
7495
|
+
const displayPath = path17.relative(process.cwd(), configPath) || configPath;
|
|
7496
|
+
console.log(`\u{1F4C4} Using config: ${displayPath}`);
|
|
7497
|
+
try {
|
|
7498
|
+
await runFromConfigFile(configPath, options.runOnce);
|
|
7499
|
+
} catch (error) {
|
|
7500
|
+
if (error instanceof ConfigFileNotFoundError) {
|
|
7501
|
+
console.error(`
|
|
7502
|
+
\u274C Config file not found: ${error.configPath}`);
|
|
7503
|
+
console.error(`\u{1F4A1} Run 'sync-worktrees init --config ${displayPath}' to create one.`);
|
|
7504
|
+
process.exit(1);
|
|
7505
|
+
}
|
|
7506
|
+
console.error("\u274C Error loading config file:", error.message);
|
|
7507
|
+
process.exit(1);
|
|
6237
7508
|
}
|
|
6238
|
-
await runSingleRepository(config);
|
|
6239
7509
|
}
|
|
6240
7510
|
async function main() {
|
|
6241
7511
|
const options = parseArguments();
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
}
|
|
6249
|
-
if (options.config) {
|
|
6250
|
-
if (options.list) {
|
|
6251
|
-
await listRepositories(options.config, options.filter);
|
|
6252
|
-
return;
|
|
6253
|
-
}
|
|
6254
|
-
try {
|
|
6255
|
-
await runFromConfigFile(options.config, {
|
|
6256
|
-
filter: options.filter,
|
|
6257
|
-
noUpdateExisting: options.noUpdateExisting,
|
|
6258
|
-
debug: options.debug,
|
|
6259
|
-
runOnce: options.runOnce,
|
|
6260
|
-
syncOnStart: options.syncOnStart
|
|
6261
|
-
});
|
|
6262
|
-
} catch (error) {
|
|
6263
|
-
if (error instanceof Error && error.message.includes("Config file not found")) {
|
|
6264
|
-
console.error(`
|
|
6265
|
-
\u274C Config file not found: ${options.config}`);
|
|
6266
|
-
const createConfig = await confirm2({
|
|
6267
|
-
message: "Would you like to run interactive setup to create a config file?",
|
|
6268
|
-
default: true
|
|
6269
|
-
});
|
|
6270
|
-
if (createConfig) {
|
|
6271
|
-
await runInteractive({}, options);
|
|
6272
|
-
} else {
|
|
6273
|
-
console.log("\n\u{1F4A1} You can create a config file manually or run without --config for interactive setup.");
|
|
6274
|
-
process.exit(1);
|
|
6275
|
-
}
|
|
6276
|
-
} else {
|
|
6277
|
-
console.error("\u274C Error loading config file:", error.message);
|
|
6278
|
-
process.exit(1);
|
|
6279
|
-
}
|
|
7512
|
+
switch (options.command) {
|
|
7513
|
+
case CLI_COMMANDS.INIT:
|
|
7514
|
+
return runInit(options.config, options.force);
|
|
7515
|
+
case CLI_COMMANDS.LIST: {
|
|
7516
|
+
const configPath = await resolveConfigOrExit(options.config);
|
|
7517
|
+
return runList(configPath, options.filter);
|
|
6280
7518
|
}
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
config.updateExistingWorktrees = false;
|
|
6287
|
-
} else if (config.updateExistingWorktrees === void 0) {
|
|
6288
|
-
config.updateExistingWorktrees = true;
|
|
6289
|
-
}
|
|
6290
|
-
if (options.debug !== void 0) {
|
|
6291
|
-
config.debug = options.debug;
|
|
7519
|
+
case CLI_COMMANDS.RUN:
|
|
7520
|
+
return runSync(options);
|
|
7521
|
+
default: {
|
|
7522
|
+
const _exhaustive = options;
|
|
7523
|
+
throw new Error(`Unhandled command: ${JSON.stringify(_exhaustive)}`);
|
|
6292
7524
|
}
|
|
6293
|
-
await runSingleRepository(config);
|
|
6294
7525
|
}
|
|
6295
7526
|
}
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
7527
|
+
function isMainEntrypoint() {
|
|
7528
|
+
const entry = process.argv[1];
|
|
7529
|
+
if (!entry) return false;
|
|
7530
|
+
try {
|
|
7531
|
+
return realpathSync2(entry) === fileURLToPath(import.meta.url);
|
|
7532
|
+
} catch {
|
|
7533
|
+
return false;
|
|
7534
|
+
}
|
|
7535
|
+
}
|
|
7536
|
+
if (isMainEntrypoint()) {
|
|
7537
|
+
main().catch((error) => {
|
|
7538
|
+
console.error("\u274C Unhandled error:", error);
|
|
7539
|
+
process.exit(1);
|
|
7540
|
+
});
|
|
7541
|
+
}
|
|
7542
|
+
export {
|
|
7543
|
+
runMultipleRepositories
|
|
7544
|
+
};
|
|
6300
7545
|
//# sourceMappingURL=index.js.map
|