sync-worktrees 3.6.3 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +383 -261
- package/dist/components/App.d.ts +50 -0
- package/dist/components/App.d.ts.map +1 -0
- package/dist/components/BranchCreationWizard.d.ts +26 -0
- package/dist/components/BranchCreationWizard.d.ts.map +1 -0
- package/dist/components/HelpModal.d.ts +7 -0
- package/dist/components/HelpModal.d.ts.map +1 -0
- package/dist/components/LogPanel.d.ts +10 -0
- package/dist/components/LogPanel.d.ts.map +1 -0
- package/dist/components/LogViewer.d.ts +9 -0
- package/dist/components/LogViewer.d.ts.map +1 -0
- package/dist/components/OpenEditorWizard.d.ts +25 -0
- package/dist/components/OpenEditorWizard.d.ts.map +1 -0
- package/dist/components/StatusBar.d.ts +14 -0
- package/dist/components/StatusBar.d.ts.map +1 -0
- package/dist/components/WorktreeStatusView.d.ts +14 -0
- package/dist/components/WorktreeStatusView.d.ts.map +1 -0
- package/dist/constants.d.ts +112 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/errors/index.d.ts +59 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2523 -1106
- package/dist/index.js.map +4 -4
- package/dist/mcp/context.d.ts +143 -0
- package/dist/mcp/context.d.ts.map +1 -0
- package/dist/mcp/handlers.d.ts +46 -0
- package/dist/mcp/handlers.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/utils.d.ts +14 -0
- package/dist/mcp/utils.d.ts.map +1 -0
- package/dist/mcp/worktree-summary.d.ts +14 -0
- package/dist/mcp/worktree-summary.d.ts.map +1 -0
- package/dist/mcp-server.js +2347 -640
- package/dist/mcp-server.js.map +4 -4
- package/dist/services/InteractiveUIService.d.ts +85 -0
- package/dist/services/InteractiveUIService.d.ts.map +1 -0
- package/dist/services/branch-created-actions.service.d.ts +27 -0
- package/dist/services/branch-created-actions.service.d.ts.map +1 -0
- package/dist/services/clone-sync.service.d.ts +93 -0
- package/dist/services/clone-sync.service.d.ts.map +1 -0
- package/dist/services/config-loader.service.d.ts +28 -0
- package/dist/services/config-loader.service.d.ts.map +1 -0
- package/dist/services/file-copy.service.d.ts +19 -0
- package/dist/services/file-copy.service.d.ts.map +1 -0
- package/dist/services/git.service.d.ts +94 -0
- package/dist/services/git.service.d.ts.map +1 -0
- package/dist/services/hook-execution.service.d.ts +20 -0
- package/dist/services/hook-execution.service.d.ts.map +1 -0
- package/dist/services/logger.service.d.ts +24 -0
- package/dist/services/logger.service.d.ts.map +1 -0
- package/dist/services/path-resolution.service.d.ts +10 -0
- package/dist/services/path-resolution.service.d.ts.map +1 -0
- package/dist/services/progress-emitter.d.ts +14 -0
- package/dist/services/progress-emitter.d.ts.map +1 -0
- package/dist/services/repo-operation-lock.d.ts +16 -0
- package/dist/services/repo-operation-lock.d.ts.map +1 -0
- package/dist/services/sparse-checkout.service.d.ts +45 -0
- package/dist/services/sparse-checkout.service.d.ts.map +1 -0
- package/dist/services/sync-outcome.d.ts +47 -0
- package/dist/services/sync-outcome.d.ts.map +1 -0
- package/dist/services/sync-retry-policy.d.ts +18 -0
- package/dist/services/sync-retry-policy.d.ts.map +1 -0
- package/dist/services/worktree-metadata.service.d.ts +25 -0
- package/dist/services/worktree-metadata.service.d.ts.map +1 -0
- package/dist/services/worktree-mode-sync-runner.d.ts +36 -0
- package/dist/services/worktree-mode-sync-runner.d.ts.map +1 -0
- package/dist/services/worktree-status.service.d.ts +60 -0
- package/dist/services/worktree-status.service.d.ts.map +1 -0
- package/dist/services/worktree-sync-planner.d.ts +62 -0
- package/dist/services/worktree-sync-planner.d.ts.map +1 -0
- package/dist/services/worktree-sync.service.d.ts +49 -0
- package/dist/services/worktree-sync.service.d.ts.map +1 -0
- package/dist/types/index.d.ts +303 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/sync-metadata.d.ts +16 -0
- package/dist/types/sync-metadata.d.ts.map +1 -0
- package/dist/utils/app-events.d.ts +31 -0
- package/dist/utils/app-events.d.ts.map +1 -0
- package/dist/utils/branch-filter.d.ts +3 -0
- package/dist/utils/branch-filter.d.ts.map +1 -0
- package/dist/utils/cli.d.ts +21 -0
- package/dist/utils/cli.d.ts.map +1 -0
- package/dist/utils/clone-skip-format.d.ts +3 -0
- package/dist/utils/clone-skip-format.d.ts.map +1 -0
- package/dist/utils/config-generator.d.ts +10 -0
- package/dist/utils/config-generator.d.ts.map +1 -0
- package/dist/utils/date-filter.d.ts +10 -0
- package/dist/utils/date-filter.d.ts.map +1 -0
- package/dist/utils/disk-space.d.ts +23 -0
- package/dist/utils/disk-space.d.ts.map +1 -0
- package/dist/utils/file-exists.d.ts +2 -0
- package/dist/utils/file-exists.d.ts.map +1 -0
- package/dist/utils/git-progress.d.ts +25 -0
- package/dist/utils/git-progress.d.ts.map +1 -0
- package/dist/utils/git-url.d.ts +23 -0
- package/dist/utils/git-url.d.ts.map +1 -0
- package/dist/utils/git-validation.d.ts +5 -0
- package/dist/utils/git-validation.d.ts.map +1 -0
- package/dist/utils/interactive.d.ts +3 -0
- package/dist/utils/interactive.d.ts.map +1 -0
- package/dist/utils/lfs-error.d.ts +35 -0
- package/dist/utils/lfs-error.d.ts.map +1 -0
- package/dist/utils/lock-path.d.ts +9 -0
- package/dist/utils/lock-path.d.ts.map +1 -0
- package/dist/utils/path-compare.d.ts +16 -0
- package/dist/utils/path-compare.d.ts.map +1 -0
- package/dist/utils/repo-mode.d.ts +8 -0
- package/dist/utils/repo-mode.d.ts.map +1 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/sanitize-name.d.ts +2 -0
- package/dist/utils/sanitize-name.d.ts.map +1 -0
- package/dist/utils/shell-escape.d.ts +5 -0
- package/dist/utils/shell-escape.d.ts.map +1 -0
- package/dist/utils/signal-handlers.d.ts +14 -0
- package/dist/utils/signal-handlers.d.ts.map +1 -0
- package/dist/utils/timing.d.ts +24 -0
- package/dist/utils/timing.d.ts.map +1 -0
- package/dist/utils/worktree-list-parser.d.ts +10 -0
- package/dist/utils/worktree-list-parser.d.ts.map +1 -0
- package/package.json +5 -2
package/dist/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";
|
|
@@ -689,7 +826,15 @@ import { Box as Box7, useInput as useInput6, useStdout } from "ink";
|
|
|
689
826
|
import React, { useState, useEffect } from "react";
|
|
690
827
|
import { Box, Text } from "ink";
|
|
691
828
|
import { CronExpressionParser } from "cron-parser";
|
|
692
|
-
var StatusBar = ({
|
|
829
|
+
var StatusBar = ({
|
|
830
|
+
status,
|
|
831
|
+
syncProgressEntries = [],
|
|
832
|
+
maxProgressLines = 2,
|
|
833
|
+
repositoryCount,
|
|
834
|
+
lastSyncTime,
|
|
835
|
+
cronSchedule,
|
|
836
|
+
diskSpaceUsed
|
|
837
|
+
}) => {
|
|
693
838
|
const [nextSyncTime, setNextSyncTime] = useState(null);
|
|
694
839
|
useEffect(() => {
|
|
695
840
|
if (!cronSchedule) {
|
|
@@ -719,7 +864,17 @@ var StatusBar = ({ status, repositoryCount, lastSyncTime, cronSchedule, diskSpac
|
|
|
719
864
|
const getStatusIcon = () => {
|
|
720
865
|
return status === "syncing" ? "\u27F3" : "\u2713";
|
|
721
866
|
};
|
|
722
|
-
|
|
867
|
+
const formatProgress = (syncProgress) => {
|
|
868
|
+
const percent = syncProgress.progress === void 0 || syncProgress.message.includes(`${syncProgress.progress}%`) ? "" : ` ${syncProgress.progress}%`;
|
|
869
|
+
return `[${syncProgress.repo}] ${syncProgress.message}${percent}`;
|
|
870
|
+
};
|
|
871
|
+
const progressLineCount = Math.max(1, maxProgressLines);
|
|
872
|
+
const visibleProgress = syncProgressEntries.slice(-progressLineCount);
|
|
873
|
+
return /* @__PURE__ */ React.createElement(Box, { borderStyle: "single", paddingX: 1 }, /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: "100%" }, /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, getStatusIcon(), " Status:", " ", /* @__PURE__ */ React.createElement(Text, { color: getStatusColor() }, status === "syncing" ? "Syncing..." : "Running")), /* @__PURE__ */ React.createElement(Text, null, "Repositories: ", /* @__PURE__ */ React.createElement(Text, { bold: true, color: "cyan" }, repositoryCount))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Last Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(lastSyncTime))), cronSchedule && /* @__PURE__ */ React.createElement(Text, null, "Next Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(nextSyncTime)))), status === "syncing" && Array.from({ length: progressLineCount }).map((_, index) => {
|
|
874
|
+
const entry = visibleProgress[index];
|
|
875
|
+
const message = entry ? formatProgress(entry) : index === 0 ? "waiting for progress events" : "";
|
|
876
|
+
return /* @__PURE__ */ React.createElement(Box, { key: index }, /* @__PURE__ */ React.createElement(Text, { wrap: "truncate" }, message ? "Progress: " : " ", message && /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, message)));
|
|
877
|
+
}), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "w"), "tree", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
|
|
723
878
|
};
|
|
724
879
|
var StatusBar_default = StatusBar;
|
|
725
880
|
|
|
@@ -1309,6 +1464,14 @@ function isLfsError(errorMessage) {
|
|
|
1309
1464
|
function isLfsErrorFromError(error) {
|
|
1310
1465
|
return isLfsError(getErrorMessage(error));
|
|
1311
1466
|
}
|
|
1467
|
+
var MISSING_REMOTE_REF_PATTERNS = Object.freeze([
|
|
1468
|
+
"couldn't find remote ref",
|
|
1469
|
+
"Couldn't find remote ref",
|
|
1470
|
+
"not our ref"
|
|
1471
|
+
]);
|
|
1472
|
+
function isMissingRemoteRefError(errorMessage) {
|
|
1473
|
+
return MISSING_REMOTE_REF_PATTERNS.some((pattern) => errorMessage.includes(pattern));
|
|
1474
|
+
}
|
|
1312
1475
|
|
|
1313
1476
|
// src/components/WorktreeStatusView.tsx
|
|
1314
1477
|
var getStatusFlags = (status) => {
|
|
@@ -1381,6 +1544,7 @@ var formatDivergedDate = (dateStr) => {
|
|
|
1381
1544
|
var WorktreeStatusView = ({
|
|
1382
1545
|
repositories,
|
|
1383
1546
|
getWorktreeStatusForRepo,
|
|
1547
|
+
getRepositoryDiskUsage,
|
|
1384
1548
|
getDivergedDirectoriesForRepo,
|
|
1385
1549
|
deleteDivergedDirectory,
|
|
1386
1550
|
onClose
|
|
@@ -1395,6 +1559,8 @@ var WorktreeStatusView = ({
|
|
|
1395
1559
|
const [entryFilter, setEntryFilter] = useState4("");
|
|
1396
1560
|
const [expandedEntry, setExpandedEntry] = useState4(null);
|
|
1397
1561
|
const [loading, setLoading] = useState4(false);
|
|
1562
|
+
const [repoDiskUsage, setRepoDiskUsage] = useState4({});
|
|
1563
|
+
const requestedDiskUsageRef = useRef3(/* @__PURE__ */ new Set());
|
|
1398
1564
|
const [confirmDelete, setConfirmDelete] = useState4(null);
|
|
1399
1565
|
const [deleting, setDeleting] = useState4(false);
|
|
1400
1566
|
const [error, setError] = useState4(null);
|
|
@@ -1450,6 +1616,29 @@ var WorktreeStatusView = ({
|
|
|
1450
1616
|
},
|
|
1451
1617
|
[getWorktreeStatusForRepo, getDivergedDirectoriesForRepo]
|
|
1452
1618
|
);
|
|
1619
|
+
useEffect4(() => {
|
|
1620
|
+
if (!getRepositoryDiskUsage) return void 0;
|
|
1621
|
+
let cancelled = false;
|
|
1622
|
+
const indexesToLoad = repositories.map((repo) => repo.index).filter((repoIndex) => !requestedDiskUsageRef.current.has(repoIndex));
|
|
1623
|
+
if (indexesToLoad.length === 0) return void 0;
|
|
1624
|
+
for (const repoIndex of indexesToLoad) {
|
|
1625
|
+
requestedDiskUsageRef.current.add(repoIndex);
|
|
1626
|
+
setRepoDiskUsage((prev) => ({ ...prev, [repoIndex]: { status: "loading" } }));
|
|
1627
|
+
void getRepositoryDiskUsage(repoIndex).then((usage) => {
|
|
1628
|
+
if (cancelled) return;
|
|
1629
|
+
setRepoDiskUsage((prev) => ({ ...prev, [repoIndex]: { status: "ready", usage } }));
|
|
1630
|
+
}).catch(() => {
|
|
1631
|
+
if (cancelled) return;
|
|
1632
|
+
setRepoDiskUsage((prev) => ({
|
|
1633
|
+
...prev,
|
|
1634
|
+
[repoIndex]: { status: "error" }
|
|
1635
|
+
}));
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
return () => {
|
|
1639
|
+
cancelled = true;
|
|
1640
|
+
};
|
|
1641
|
+
}, [repositories, getRepositoryDiskUsage]);
|
|
1453
1642
|
useEffect4(() => {
|
|
1454
1643
|
if (step === "VIEW_STATUS" && entries.length === 0 && !loading && selectedRepoIndexRef.current >= 0) {
|
|
1455
1644
|
loadStatus(selectedRepoIndexRef.current);
|
|
@@ -1523,11 +1712,11 @@ var WorktreeStatusView = ({
|
|
|
1523
1712
|
} else if (key.downArrow) {
|
|
1524
1713
|
setSelectedProjectIndex((prev) => Math.min(filteredProjects.length - 1, prev + 1));
|
|
1525
1714
|
} else if (key.return && filteredProjects.length > 0) {
|
|
1526
|
-
const
|
|
1527
|
-
if (
|
|
1528
|
-
selectedRepoIndexRef.current =
|
|
1715
|
+
const selectedRepo2 = filteredProjects[selectedProjectIndex];
|
|
1716
|
+
if (selectedRepo2) {
|
|
1717
|
+
selectedRepoIndexRef.current = selectedRepo2.index;
|
|
1529
1718
|
setStep("VIEW_STATUS");
|
|
1530
|
-
loadStatus(
|
|
1719
|
+
loadStatus(selectedRepo2.index);
|
|
1531
1720
|
}
|
|
1532
1721
|
} else if (key.backspace || key.delete) {
|
|
1533
1722
|
setProjectFilter((prev) => prev.slice(0, -1));
|
|
@@ -1575,7 +1764,7 @@ var WorktreeStatusView = ({
|
|
|
1575
1764
|
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Select repository:"), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, null, "Filter: "), /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, projectFilter || "_"), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " ", "(", filteredProjects.length, "/", repositories.length, " matches)")), /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column" }, filteredProjects.length === 0 ? /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, "No matches") : /* @__PURE__ */ React5.createElement(React5.Fragment, null, startIdx > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " ..."), visibleProjects.map((repo, idx) => {
|
|
1576
1765
|
const actualIdx = startIdx + idx;
|
|
1577
1766
|
const isSelected = actualIdx === selectedProjectIndex;
|
|
1578
|
-
return /* @__PURE__ */ React5.createElement(Box5, { key: repo.index }, /* @__PURE__ */ React5.createElement(Text5, { color: isSelected ? "cyan" : void 0 }, isSelected ? "> " : " ", repo.name));
|
|
1767
|
+
return /* @__PURE__ */ React5.createElement(Box5, { key: repo.index }, /* @__PURE__ */ React5.createElement(Text5, { color: isSelected ? "cyan" : void 0 }, isSelected ? "> " : " "), /* @__PURE__ */ React5.createElement(Box5, { width: 38 }, /* @__PURE__ */ React5.createElement(Text5, { color: isSelected ? "cyan" : void 0 }, repo.name)), getRepositoryDiskUsage && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " "), renderRepositoryDiskUsage(repo.index));
|
|
1579
1768
|
}), endIdx < filteredProjects.length && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " ..."))));
|
|
1580
1769
|
};
|
|
1581
1770
|
const renderDetailPanel = (entry) => {
|
|
@@ -1586,6 +1775,17 @@ var WorktreeStatusView = ({
|
|
|
1586
1775
|
const renderDivergedDetailPanel = (entry) => {
|
|
1587
1776
|
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginLeft: 4, marginTop: 0, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Path: ", entry.path), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Original branch: ", entry.originalBranch), entry.divergedAt && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Diverged: ", entry.divergedAt), /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " Size: ", entry.sizeFormatted));
|
|
1588
1777
|
};
|
|
1778
|
+
const renderRepositoryDiskUsage = (repoIndex) => {
|
|
1779
|
+
if (!getRepositoryDiskUsage) return null;
|
|
1780
|
+
const state = repoDiskUsage[repoIndex];
|
|
1781
|
+
if (!state || state.status === "loading") {
|
|
1782
|
+
return /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Size: calculating...");
|
|
1783
|
+
}
|
|
1784
|
+
if (state.status === "error") {
|
|
1785
|
+
return /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, "Size: N/A");
|
|
1786
|
+
}
|
|
1787
|
+
return /* @__PURE__ */ React5.createElement(Text5, null, "Size: ", /* @__PURE__ */ React5.createElement(Text5, { color: "magenta" }, state.usage.sizeFormatted));
|
|
1788
|
+
};
|
|
1589
1789
|
const renderStatusList = () => {
|
|
1590
1790
|
if (loading) {
|
|
1591
1791
|
return /* @__PURE__ */ React5.createElement(Text5, { color: "yellow" }, "Loading worktree status...");
|
|
@@ -1639,7 +1839,8 @@ var WorktreeStatusView = ({
|
|
|
1639
1839
|
}
|
|
1640
1840
|
return /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, step === "VIEW_STATUS" ? isDivergedSelected ? "\u2191/\u2193 navigate \u2022 Type to filter \u2022 Enter to expand \u2022 d to delete \u2022 ESC to close" : "\u2191/\u2193 navigate \u2022 Type to filter \u2022 Enter to expand \u2022 ESC to close" : "\u2191/\u2193 navigate \u2022 Type to filter \u2022 Enter to select \u2022 ESC to cancel");
|
|
1641
1841
|
};
|
|
1642
|
-
|
|
1842
|
+
const selectedRepo = selectedRepoIndexRef.current >= 0 ? repositories.find((repo) => repo.index === selectedRepoIndexRef.current) : void 0;
|
|
1843
|
+
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column", marginTop: 1, marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Box5, { borderStyle: "round", borderColor: "green", paddingX: 2, paddingY: 1, flexDirection: "column", width: 70 }, /* @__PURE__ */ React5.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, { bold: true, color: "green" }, "\u{1F4CA} Worktree Status", " ", step !== "ERROR" && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "(Step ", getStepNumber(), "/", getTotalSteps(), ")"))), step === "VIEW_STATUS" && selectedRepo && /* @__PURE__ */ React5.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Repository: ", /* @__PURE__ */ React5.createElement(Text5, { color: "cyan" }, selectedRepo.name)), getRepositoryDiskUsage && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, " "), renderRepositoryDiskUsage(selectedRepo.index)), renderContent(), /* @__PURE__ */ React5.createElement(Box5, { marginTop: 1 }, renderFooter())));
|
|
1643
1844
|
};
|
|
1644
1845
|
var WorktreeStatusView_default = WorktreeStatusView;
|
|
1645
1846
|
|
|
@@ -1748,7 +1949,9 @@ var App = ({
|
|
|
1748
1949
|
onManualSync,
|
|
1749
1950
|
onReload,
|
|
1750
1951
|
onQuit,
|
|
1952
|
+
maxProgressLines = 2,
|
|
1751
1953
|
getRepositoryList,
|
|
1954
|
+
getRepositoryDiskUsage,
|
|
1752
1955
|
getBranchesForRepo,
|
|
1753
1956
|
getDefaultBranchForRepo,
|
|
1754
1957
|
fetchForRepo,
|
|
@@ -1768,11 +1971,12 @@ var App = ({
|
|
|
1768
1971
|
const [showOpenEditorWizard, setShowOpenEditorWizard] = useState6(false);
|
|
1769
1972
|
const [showWorktreeStatus, setShowWorktreeStatus] = useState6(false);
|
|
1770
1973
|
const [status, setStatus] = useState6("idle");
|
|
1974
|
+
const [syncProgressEntries, setSyncProgressEntries] = useState6([]);
|
|
1771
1975
|
const [lastSyncTime, setLastSyncTime] = useState6(null);
|
|
1772
1976
|
const [diskSpaceUsed, setDiskSpaceUsed] = useState6(null);
|
|
1773
1977
|
const [logs, setLogs] = useState6([]);
|
|
1774
1978
|
const [repoCount, setRepoCount] = useState6(repositoryCount);
|
|
1775
|
-
const [
|
|
1979
|
+
const [schedule2, setSchedule] = useState6(cronSchedule);
|
|
1776
1980
|
const { stdout } = useStdout();
|
|
1777
1981
|
const addLog = useCallback4((message, level = "info") => {
|
|
1778
1982
|
setLogs((prev) => {
|
|
@@ -1838,15 +2042,36 @@ var App = ({
|
|
|
1838
2042
|
const updateLastSyncTime = useCallback4(() => {
|
|
1839
2043
|
setLastSyncTime(/* @__PURE__ */ new Date());
|
|
1840
2044
|
setStatus("idle");
|
|
2045
|
+
setSyncProgressEntries([]);
|
|
1841
2046
|
}, []);
|
|
1842
2047
|
useEffect6(() => {
|
|
1843
2048
|
const unsubscribers = [
|
|
1844
2049
|
events.on("updateLastSyncTime", () => {
|
|
1845
2050
|
setLastSyncTime(/* @__PURE__ */ new Date());
|
|
1846
2051
|
setStatus("idle");
|
|
2052
|
+
setSyncProgressEntries([]);
|
|
1847
2053
|
}),
|
|
1848
2054
|
events.on("setStatus", (newStatus) => {
|
|
1849
2055
|
setStatus(newStatus);
|
|
2056
|
+
if (newStatus === "idle") {
|
|
2057
|
+
setSyncProgressEntries([]);
|
|
2058
|
+
}
|
|
2059
|
+
}),
|
|
2060
|
+
events.on("setSyncProgress", (progress) => {
|
|
2061
|
+
if (progress === null) {
|
|
2062
|
+
setSyncProgressEntries([]);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
setSyncProgressEntries((prev) => {
|
|
2066
|
+
if (progress.completed) {
|
|
2067
|
+
return prev.filter((entry) => entry.repo !== progress.repo);
|
|
2068
|
+
}
|
|
2069
|
+
const existingIndex = prev.findIndex((entry) => entry.repo === progress.repo);
|
|
2070
|
+
if (existingIndex === -1) {
|
|
2071
|
+
return [...prev, progress];
|
|
2072
|
+
}
|
|
2073
|
+
return prev.map((entry, index) => index === existingIndex ? progress : entry);
|
|
2074
|
+
});
|
|
1850
2075
|
}),
|
|
1851
2076
|
events.on("setDiskSpace", (diskSpace) => {
|
|
1852
2077
|
setDiskSpaceUsed(diskSpace);
|
|
@@ -1866,7 +2091,8 @@ var App = ({
|
|
|
1866
2091
|
unsubscribers.forEach((unsub) => unsub());
|
|
1867
2092
|
};
|
|
1868
2093
|
}, []);
|
|
1869
|
-
const
|
|
2094
|
+
const progressLineCount = status === "syncing" ? Math.max(1, maxProgressLines) : 0;
|
|
2095
|
+
const statusBarHeight = 5 + progressLineCount;
|
|
1870
2096
|
const terminalRows = stdout.rows ?? 24;
|
|
1871
2097
|
const logPanelHeight = Math.max(5, terminalRows - statusBarHeight);
|
|
1872
2098
|
const showModal = showHelp || showBranchWizard || showOpenEditorWizard || showWorktreeStatus;
|
|
@@ -1931,6 +2157,7 @@ var App = ({
|
|
|
1931
2157
|
{
|
|
1932
2158
|
repositories: getRepositoryList(),
|
|
1933
2159
|
getWorktreeStatusForRepo,
|
|
2160
|
+
getRepositoryDiskUsage,
|
|
1934
2161
|
getDivergedDirectoriesForRepo,
|
|
1935
2162
|
deleteDivergedDirectory,
|
|
1936
2163
|
onClose: () => setShowWorktreeStatus(false)
|
|
@@ -1939,69 +2166,17 @@ var App = ({
|
|
|
1939
2166
|
StatusBar_default,
|
|
1940
2167
|
{
|
|
1941
2168
|
status,
|
|
2169
|
+
syncProgressEntries,
|
|
2170
|
+
maxProgressLines,
|
|
1942
2171
|
repositoryCount: repoCount,
|
|
1943
2172
|
lastSyncTime,
|
|
1944
|
-
cronSchedule:
|
|
2173
|
+
cronSchedule: schedule2,
|
|
1945
2174
|
diskSpaceUsed: diskSpaceUsed ?? void 0
|
|
1946
2175
|
}
|
|
1947
2176
|
));
|
|
1948
2177
|
};
|
|
1949
2178
|
var App_default = App;
|
|
1950
2179
|
|
|
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
2180
|
// src/utils/retry.ts
|
|
2006
2181
|
var DEFAULT_OPTIONS = {
|
|
2007
2182
|
maxAttempts: "unlimited",
|
|
@@ -2072,7 +2247,7 @@ async function retry(fn, options = {}) {
|
|
|
2072
2247
|
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
2073
2248
|
const delay = baseDelay + jitter;
|
|
2074
2249
|
opts.onRetry(error, attempt, lfsContext);
|
|
2075
|
-
await new Promise((
|
|
2250
|
+
await new Promise((resolve12) => setTimeout(resolve12, delay));
|
|
2076
2251
|
attempt++;
|
|
2077
2252
|
}
|
|
2078
2253
|
}
|
|
@@ -2143,7 +2318,7 @@ var PhaseTimer = class {
|
|
|
2143
2318
|
return results;
|
|
2144
2319
|
}
|
|
2145
2320
|
};
|
|
2146
|
-
function
|
|
2321
|
+
function formatDuration(ms) {
|
|
2147
2322
|
if (ms < 1e3) {
|
|
2148
2323
|
return `${ms}ms`;
|
|
2149
2324
|
}
|
|
@@ -2165,7 +2340,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2165
2340
|
}
|
|
2166
2341
|
});
|
|
2167
2342
|
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
2168
|
-
table.push(["Total Sync",
|
|
2343
|
+
table.push(["Total Sync", formatDuration(totalDuration), ""]);
|
|
2169
2344
|
for (let i = 0; i < phaseResults.length; i++) {
|
|
2170
2345
|
const result = phaseResults[i];
|
|
2171
2346
|
const isLast = i === phaseResults.length - 1;
|
|
@@ -2173,15 +2348,869 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2173
2348
|
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
2174
2349
|
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
2175
2350
|
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
2176
|
-
table.push([name,
|
|
2351
|
+
table.push([name, formatDuration(result.duration), efficiency]);
|
|
2177
2352
|
}
|
|
2178
2353
|
return table.toString();
|
|
2179
2354
|
}
|
|
2180
2355
|
|
|
2181
|
-
// src/services/
|
|
2182
|
-
import * as
|
|
2183
|
-
import * as
|
|
2184
|
-
import
|
|
2356
|
+
// src/services/clone-sync.service.ts
|
|
2357
|
+
import * as fs3 from "fs/promises";
|
|
2358
|
+
import * as path4 from "path";
|
|
2359
|
+
import simpleGit from "simple-git";
|
|
2360
|
+
|
|
2361
|
+
// src/utils/git-progress.ts
|
|
2362
|
+
function makeGitProgressHandler(logger, emitProgress) {
|
|
2363
|
+
const lastBucket = /* @__PURE__ */ new Map();
|
|
2364
|
+
return (event) => {
|
|
2365
|
+
if (event.method !== "fetch" && event.method !== "clone" && event.method !== "pull") return;
|
|
2366
|
+
const key = `${event.method}:${event.stage}`;
|
|
2367
|
+
const bucket = Math.floor(event.progress / GIT_CONSTANTS.PROGRESS_BUCKET_PERCENT);
|
|
2368
|
+
let last = lastBucket.get(key) ?? -1;
|
|
2369
|
+
if (bucket < last) last = -1;
|
|
2370
|
+
if (bucket <= last && event.progress < 100) return;
|
|
2371
|
+
lastBucket.set(key, bucket);
|
|
2372
|
+
const total = event.total > 0 ? `${event.processed}/${event.total}` : `${event.processed}`;
|
|
2373
|
+
const message = `${event.method} ${event.stage}: ${event.progress}% (${total})`;
|
|
2374
|
+
logger.debug(` \u21B3 ${message}`);
|
|
2375
|
+
emitProgress?.({
|
|
2376
|
+
phase: event.method,
|
|
2377
|
+
message,
|
|
2378
|
+
progress: event.progress,
|
|
2379
|
+
processed: event.processed,
|
|
2380
|
+
total: event.total
|
|
2381
|
+
});
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// src/services/file-copy.service.ts
|
|
2386
|
+
import * as fs2 from "fs/promises";
|
|
2387
|
+
import * as path3 from "path";
|
|
2388
|
+
import { glob } from "glob";
|
|
2389
|
+
var DEFAULT_IGNORE_PATTERNS = [
|
|
2390
|
+
"**/node_modules/**",
|
|
2391
|
+
"**/.git/**",
|
|
2392
|
+
"**/dist/**",
|
|
2393
|
+
"**/build/**",
|
|
2394
|
+
"**/.next/**",
|
|
2395
|
+
"**/coverage/**"
|
|
2396
|
+
];
|
|
2397
|
+
var FileCopyService = class {
|
|
2398
|
+
/**
|
|
2399
|
+
* Copy files matching patterns from source to destination directory.
|
|
2400
|
+
* Skips files that already exist at destination.
|
|
2401
|
+
* Preserves directory structure relative to source.
|
|
2402
|
+
*/
|
|
2403
|
+
async copyFiles(sourceDir, destDir, patterns) {
|
|
2404
|
+
const result = {
|
|
2405
|
+
copied: [],
|
|
2406
|
+
skipped: [],
|
|
2407
|
+
errors: []
|
|
2408
|
+
};
|
|
2409
|
+
if (!patterns || patterns.length === 0) {
|
|
2410
|
+
return result;
|
|
2411
|
+
}
|
|
2412
|
+
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
2413
|
+
for (const relativePath of filesToCopy) {
|
|
2414
|
+
const sourcePath = path3.join(sourceDir, relativePath);
|
|
2415
|
+
const destPath = path3.join(destDir, relativePath);
|
|
2416
|
+
try {
|
|
2417
|
+
const copied = await this.copyFile(sourcePath, destPath);
|
|
2418
|
+
if (copied) {
|
|
2419
|
+
result.copied.push(relativePath);
|
|
2420
|
+
} else {
|
|
2421
|
+
result.skipped.push(relativePath);
|
|
2422
|
+
}
|
|
2423
|
+
} catch (error) {
|
|
2424
|
+
result.errors.push({
|
|
2425
|
+
file: relativePath,
|
|
2426
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
return result;
|
|
2431
|
+
}
|
|
2432
|
+
async expandPatterns(sourceDir, patterns) {
|
|
2433
|
+
const allFiles = /* @__PURE__ */ new Set();
|
|
2434
|
+
for (const pattern of patterns) {
|
|
2435
|
+
try {
|
|
2436
|
+
const matches = await glob(pattern, {
|
|
2437
|
+
cwd: sourceDir,
|
|
2438
|
+
nodir: true,
|
|
2439
|
+
dot: true,
|
|
2440
|
+
ignore: DEFAULT_IGNORE_PATTERNS
|
|
2441
|
+
});
|
|
2442
|
+
for (const match of matches) {
|
|
2443
|
+
allFiles.add(match);
|
|
2444
|
+
}
|
|
2445
|
+
} catch {
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
return Array.from(allFiles);
|
|
2449
|
+
}
|
|
2450
|
+
async copyFile(sourcePath, destPath) {
|
|
2451
|
+
if (await fileExists(destPath)) {
|
|
2452
|
+
return false;
|
|
2453
|
+
}
|
|
2454
|
+
const destDir = path3.dirname(destPath);
|
|
2455
|
+
await fs2.mkdir(destDir, { recursive: true });
|
|
2456
|
+
await fs2.copyFile(sourcePath, destPath);
|
|
2457
|
+
return true;
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
|
|
2461
|
+
// src/services/branch-created-actions.service.ts
|
|
2462
|
+
var BranchCreatedActionsService = class {
|
|
2463
|
+
fileCopyService;
|
|
2464
|
+
constructor(fileCopyService) {
|
|
2465
|
+
this.fileCopyService = fileCopyService ?? new FileCopyService();
|
|
2466
|
+
}
|
|
2467
|
+
async copyFiles(params) {
|
|
2468
|
+
const { config, sourceDir, worktreePath, branchName, logger } = params;
|
|
2469
|
+
const patterns = config.filesToCopyOnBranchCreate;
|
|
2470
|
+
if (!patterns?.length) return;
|
|
2471
|
+
try {
|
|
2472
|
+
const result = await this.fileCopyService.copyFiles(sourceDir, worktreePath, patterns);
|
|
2473
|
+
if (result.copied.length > 0) {
|
|
2474
|
+
logger.info(`\u{1F4CB} Copied ${result.copied.length} file(s) to '${branchName}': ${result.copied.join(", ")}`);
|
|
2475
|
+
}
|
|
2476
|
+
if (result.errors.length > 0) {
|
|
2477
|
+
logger.warn(`\u26A0\uFE0F Failed to copy ${result.errors.length} file(s) to '${branchName}':`);
|
|
2478
|
+
for (const err of result.errors) {
|
|
2479
|
+
logger.warn(` - ${err.file}: ${err.error}`);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
} catch (error) {
|
|
2483
|
+
logger.error(`Failed to copy files to '${branchName}': ${error}`);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
runHooks(params) {
|
|
2487
|
+
const { config, branchName, worktreePath, repoName, baseBranch, logger, hookExecutionService } = params;
|
|
2488
|
+
if (!config.hooks?.onBranchCreated?.length) return;
|
|
2489
|
+
const context = {
|
|
2490
|
+
branchName,
|
|
2491
|
+
worktreePath,
|
|
2492
|
+
repoName,
|
|
2493
|
+
baseBranch,
|
|
2494
|
+
repoUrl: config.repoUrl
|
|
2495
|
+
};
|
|
2496
|
+
logger.info(`Running ${config.hooks.onBranchCreated.length} hook(s) for branch '${branchName}'...`);
|
|
2497
|
+
hookExecutionService.executeOnBranchCreated(config.hooks, context, {
|
|
2498
|
+
onStdout: (data) => logger.info(`[hook] ${data}`),
|
|
2499
|
+
onStderr: (data) => logger.warn(`[hook] ${data}`),
|
|
2500
|
+
onError: (command, error) => logger.error(`[hook] Failed to execute '${command}': ${error.message}`),
|
|
2501
|
+
onComplete: (command, exitCode) => {
|
|
2502
|
+
if (exitCode === 0) {
|
|
2503
|
+
logger.info(`[hook] Command completed successfully`);
|
|
2504
|
+
} else if (exitCode !== null) {
|
|
2505
|
+
logger.warn(`[hook] Command exited with code ${exitCode}`);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
};
|
|
2511
|
+
|
|
2512
|
+
// src/utils/clone-skip-format.ts
|
|
2513
|
+
function formatCloneSkipReason(reason) {
|
|
2514
|
+
switch (reason.kind) {
|
|
2515
|
+
case "branch_mismatch":
|
|
2516
|
+
return reason.phase === "init" ? `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}' (since process start)` : `clone is on '${reason.currentBranch}', expected '${reason.expectedBranch}'`;
|
|
2517
|
+
case "head_unreadable":
|
|
2518
|
+
return `could not read HEAD: ${reason.error}`;
|
|
2519
|
+
case "dirty_tree":
|
|
2520
|
+
return `working tree has local changes`;
|
|
2521
|
+
case "diverged":
|
|
2522
|
+
return `diverged from origin/${reason.branch}`;
|
|
2523
|
+
case "ahead_unpushed":
|
|
2524
|
+
return `unpushed commits ahead of origin/${reason.branch}`;
|
|
2525
|
+
case "missing_remote_ref":
|
|
2526
|
+
return reason.source === "fetch_error" ? `origin/${reason.branch} missing on remote (fetch error)` : `origin/${reason.branch} pruned after fetch`;
|
|
2527
|
+
case "indeterminate_shallow":
|
|
2528
|
+
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`;
|
|
2529
|
+
case "origin_mismatch":
|
|
2530
|
+
return `clone origin is '${reason.actual}', expected '${reason.expected}'`;
|
|
2531
|
+
default: {
|
|
2532
|
+
const _exhaustive = reason;
|
|
2533
|
+
return _exhaustive;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// src/services/sync-outcome.ts
|
|
2539
|
+
var EMPTY_COUNTS = {
|
|
2540
|
+
created: 0,
|
|
2541
|
+
removed: 0,
|
|
2542
|
+
updated: 0,
|
|
2543
|
+
skipped: 0,
|
|
2544
|
+
preserved: 0,
|
|
2545
|
+
failed: 0,
|
|
2546
|
+
noop: 0
|
|
2547
|
+
};
|
|
2548
|
+
function cloneCounts(counts) {
|
|
2549
|
+
return { ...counts };
|
|
2550
|
+
}
|
|
2551
|
+
function cloneAction(action) {
|
|
2552
|
+
return { ...action };
|
|
2553
|
+
}
|
|
2554
|
+
function countKeyFor(action) {
|
|
2555
|
+
switch (action.kind) {
|
|
2556
|
+
case "created":
|
|
2557
|
+
return "created";
|
|
2558
|
+
case "removed":
|
|
2559
|
+
return "removed";
|
|
2560
|
+
case "updated":
|
|
2561
|
+
return "updated";
|
|
2562
|
+
case "skipped":
|
|
2563
|
+
return "skipped";
|
|
2564
|
+
case "preserved-diverged":
|
|
2565
|
+
return "preserved";
|
|
2566
|
+
case "failed":
|
|
2567
|
+
return "failed";
|
|
2568
|
+
case "noop":
|
|
2569
|
+
return "noop";
|
|
2570
|
+
default: {
|
|
2571
|
+
const _exhaustive = action;
|
|
2572
|
+
return _exhaustive;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
var SyncOutcomeAccumulator = class {
|
|
2577
|
+
constructor(options) {
|
|
2578
|
+
this.options = options;
|
|
2579
|
+
}
|
|
2580
|
+
counts = cloneCounts(EMPTY_COUNTS);
|
|
2581
|
+
actions = [];
|
|
2582
|
+
add(action) {
|
|
2583
|
+
this.actions.push(action);
|
|
2584
|
+
this.counts[countKeyFor(action)]++;
|
|
2585
|
+
}
|
|
2586
|
+
recordCreated(branch, path18) {
|
|
2587
|
+
this.add({ kind: "created", branch, path: path18 });
|
|
2588
|
+
}
|
|
2589
|
+
recordRemoved(branch, path18) {
|
|
2590
|
+
this.add({ kind: "removed", branch, path: path18 });
|
|
2591
|
+
}
|
|
2592
|
+
recordUpdated(branch, path18, reason) {
|
|
2593
|
+
this.add({ kind: "updated", branch, path: path18, reason });
|
|
2594
|
+
}
|
|
2595
|
+
recordNoop(scope, reason, details) {
|
|
2596
|
+
this.add({ kind: "noop", scope, reason, ...details });
|
|
2597
|
+
}
|
|
2598
|
+
recordSkipped(scope, reason, details) {
|
|
2599
|
+
this.add({ kind: "skipped", scope, reason, ...details });
|
|
2600
|
+
}
|
|
2601
|
+
recordPreservedDiverged(branch, path18, preservedPath) {
|
|
2602
|
+
this.add({ kind: "preserved-diverged", branch, path: path18, preservedPath });
|
|
2603
|
+
}
|
|
2604
|
+
recordFailed(scope, error, details = {}) {
|
|
2605
|
+
this.add({ kind: "failed", scope, error, ...details });
|
|
2606
|
+
}
|
|
2607
|
+
getCounts() {
|
|
2608
|
+
return cloneCounts(this.counts);
|
|
2609
|
+
}
|
|
2610
|
+
snapshot() {
|
|
2611
|
+
return {
|
|
2612
|
+
counts: cloneCounts(this.counts),
|
|
2613
|
+
actions: this.actions.map(cloneAction)
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
restore(snapshot) {
|
|
2617
|
+
this.counts = cloneCounts(snapshot.counts);
|
|
2618
|
+
this.actions = snapshot.actions.map(cloneAction);
|
|
2619
|
+
}
|
|
2620
|
+
toOutcome(durationMs) {
|
|
2621
|
+
return {
|
|
2622
|
+
repoName: this.options.repoName,
|
|
2623
|
+
mode: this.options.mode,
|
|
2624
|
+
started: true,
|
|
2625
|
+
counts: cloneCounts(this.counts),
|
|
2626
|
+
actions: this.actions.map(cloneAction),
|
|
2627
|
+
durationMs
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
function cloneSkipToOutcomeAction(reason, details = {}) {
|
|
2632
|
+
const message = formatCloneSkipReason(reason);
|
|
2633
|
+
const branch = "branch" in reason ? reason.branch : reason.kind === "branch_mismatch" ? reason.expectedBranch : details.branch;
|
|
2634
|
+
return {
|
|
2635
|
+
kind: "skipped",
|
|
2636
|
+
scope: "repo",
|
|
2637
|
+
reason: `clone_${reason.kind}`,
|
|
2638
|
+
branch,
|
|
2639
|
+
path: details.path,
|
|
2640
|
+
message
|
|
2641
|
+
};
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// src/services/clone-sync.service.ts
|
|
2645
|
+
var ALL_REMOTE_BRANCHES_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
2646
|
+
var SHALLOW_RELATION_DEEPEN_TARGETS = [50, 200, 1e3];
|
|
2647
|
+
var CloneSyncService = class {
|
|
2648
|
+
constructor(config, gitService, logger, options = {}) {
|
|
2649
|
+
this.config = config;
|
|
2650
|
+
this.gitService = gitService;
|
|
2651
|
+
this.logger = logger;
|
|
2652
|
+
this.branchCreatedActions = options.branchCreatedActions ?? new BranchCreatedActionsService();
|
|
2653
|
+
this.progressEmitter = options.progressEmitter;
|
|
2654
|
+
this.onSkip = options.onSkip;
|
|
2655
|
+
}
|
|
2656
|
+
initialized = false;
|
|
2657
|
+
resolvedBranch = null;
|
|
2658
|
+
branchCreatedActions;
|
|
2659
|
+
progressEmitter;
|
|
2660
|
+
onSkip;
|
|
2661
|
+
outcomeAccumulator;
|
|
2662
|
+
// One-shot suppression token. When init records a wrong-branch / unreadable-HEAD
|
|
2663
|
+
// skip for an existing clone, it sets this so the immediately following
|
|
2664
|
+
// runSyncAttempt (same sync operation) does not record the identical skip again.
|
|
2665
|
+
pendingInitSkip = null;
|
|
2666
|
+
updateLogger(logger) {
|
|
2667
|
+
this.logger = logger;
|
|
2668
|
+
}
|
|
2669
|
+
isInitialized() {
|
|
2670
|
+
return this.initialized;
|
|
2671
|
+
}
|
|
2672
|
+
clearPendingInitSkip() {
|
|
2673
|
+
this.pendingInitSkip = null;
|
|
2674
|
+
}
|
|
2675
|
+
async getWorktrees() {
|
|
2676
|
+
const worktreeDir = path4.resolve(this.config.worktreeDir);
|
|
2677
|
+
if (!await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR))) {
|
|
2678
|
+
return [];
|
|
2679
|
+
}
|
|
2680
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2681
|
+
let branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
2682
|
+
if (!branch || branch === "HEAD") {
|
|
2683
|
+
const head = (await git.raw(["rev-parse", "--short", "HEAD"])).trim();
|
|
2684
|
+
branch = head ? `(detached ${head})` : "(detached)";
|
|
2685
|
+
}
|
|
2686
|
+
return [{ path: worktreeDir, branch }];
|
|
2687
|
+
}
|
|
2688
|
+
get repoName() {
|
|
2689
|
+
return this.config.name ?? this.config.repoUrl;
|
|
2690
|
+
}
|
|
2691
|
+
getCloneTimeoutMs() {
|
|
2692
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
2693
|
+
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
2694
|
+
}
|
|
2695
|
+
getFetchTimeoutMs() {
|
|
2696
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
2697
|
+
return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
|
|
2698
|
+
}
|
|
2699
|
+
isLfsSkipEnabled() {
|
|
2700
|
+
return this.config.skipLfs === true;
|
|
2701
|
+
}
|
|
2702
|
+
buildGitOptions(blockMs) {
|
|
2703
|
+
const options = {
|
|
2704
|
+
progress: makeGitProgressHandler(this.logger, (event) => this.emitProgress(event))
|
|
2705
|
+
};
|
|
2706
|
+
if (blockMs > 0) options.timeout = { block: blockMs };
|
|
2707
|
+
return options;
|
|
2708
|
+
}
|
|
2709
|
+
emitProgress(event) {
|
|
2710
|
+
try {
|
|
2711
|
+
this.progressEmitter?.(event);
|
|
2712
|
+
} catch {
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
async withOutcome(outcome, operation) {
|
|
2716
|
+
const previousOutcome = this.outcomeAccumulator;
|
|
2717
|
+
if (outcome) {
|
|
2718
|
+
this.outcomeAccumulator = outcome;
|
|
2719
|
+
}
|
|
2720
|
+
try {
|
|
2721
|
+
return await operation();
|
|
2722
|
+
} finally {
|
|
2723
|
+
if (outcome) {
|
|
2724
|
+
this.outcomeAccumulator = previousOutcome;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
recordSkip(reason, logMessage, progressMessage, logLevel = "warn") {
|
|
2729
|
+
if (logLevel === "warn") {
|
|
2730
|
+
this.logger.warn(logMessage);
|
|
2731
|
+
} else {
|
|
2732
|
+
this.logger.info(logMessage);
|
|
2733
|
+
}
|
|
2734
|
+
this.emitProgress({ phase: "skip", message: progressMessage ?? logMessage });
|
|
2735
|
+
try {
|
|
2736
|
+
this.onSkip?.(reason);
|
|
2737
|
+
} catch {
|
|
2738
|
+
}
|
|
2739
|
+
this.outcomeAccumulator?.add(
|
|
2740
|
+
cloneSkipToOutcomeAction(reason, {
|
|
2741
|
+
branch: this.resolvedBranch ?? this.config.branch,
|
|
2742
|
+
path: this.config.worktreeDir
|
|
2743
|
+
})
|
|
2744
|
+
);
|
|
2745
|
+
}
|
|
2746
|
+
clientFor(dir, blockMs) {
|
|
2747
|
+
return simpleGit(dir, this.buildGitOptions(blockMs)).env(this.buildGitEnv());
|
|
2748
|
+
}
|
|
2749
|
+
// Force a stable C locale so git's stderr is deterministic English. The
|
|
2750
|
+
// missing-remote-ref and LFS error classification matches on those strings
|
|
2751
|
+
// and would otherwise misfire under a non-English LANG/LC_ALL. simple-git's
|
|
2752
|
+
// .env() merges this object with process.env (PATH etc. preserved).
|
|
2753
|
+
buildGitEnv(opts = {}) {
|
|
2754
|
+
const env = { LC_ALL: "C", LANG: "C" };
|
|
2755
|
+
if (opts.forceLfsSkip || this.isLfsSkipEnabled()) {
|
|
2756
|
+
env[ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE] = "1";
|
|
2757
|
+
}
|
|
2758
|
+
return env;
|
|
2759
|
+
}
|
|
2760
|
+
buildCloneArgs(branch) {
|
|
2761
|
+
const args = ["--branch", branch, "--progress"];
|
|
2762
|
+
if (this.config.depth !== void 0) {
|
|
2763
|
+
args.push("--depth", String(this.config.depth), "--no-single-branch");
|
|
2764
|
+
}
|
|
2765
|
+
return args;
|
|
2766
|
+
}
|
|
2767
|
+
async buildFetchArgs(git) {
|
|
2768
|
+
const args = ["origin", "--prune", "--progress"];
|
|
2769
|
+
if (this.config.depth !== void 0 && await this.isShallowRepository(git)) {
|
|
2770
|
+
args.push("--depth", String(this.config.depth));
|
|
2771
|
+
}
|
|
2772
|
+
return args;
|
|
2773
|
+
}
|
|
2774
|
+
async ensureAllRemoteBranchesRefspec(git) {
|
|
2775
|
+
let fetchRefspecs = [];
|
|
2776
|
+
try {
|
|
2777
|
+
const output = await git.raw(["config", "--get-all", "remote.origin.fetch"]);
|
|
2778
|
+
fetchRefspecs = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2779
|
+
} catch {
|
|
2780
|
+
fetchRefspecs = [];
|
|
2781
|
+
}
|
|
2782
|
+
if (fetchRefspecs.includes(ALL_REMOTE_BRANCHES_REFSPEC)) return;
|
|
2783
|
+
const customRefspecs = fetchRefspecs.filter((refspec) => !this.isOriginRemoteBranchTrackingRefspec(refspec));
|
|
2784
|
+
this.logger.info(`Configuring '${this.repoName}' to fetch all remote branches from origin.`);
|
|
2785
|
+
await git.raw(["remote", "set-branches", "origin", "*"]);
|
|
2786
|
+
for (const refspec of customRefspecs) {
|
|
2787
|
+
await git.raw(["config", "--add", "remote.origin.fetch", refspec]);
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
isOriginRemoteBranchTrackingRefspec(refspec) {
|
|
2791
|
+
const withoutForce = refspec.startsWith("+") ? refspec.slice(1) : refspec;
|
|
2792
|
+
if (withoutForce.startsWith("^")) return false;
|
|
2793
|
+
const [source, destination] = withoutForce.split(":");
|
|
2794
|
+
return source.startsWith("refs/heads/") && destination?.startsWith("refs/remotes/origin/") === true;
|
|
2795
|
+
}
|
|
2796
|
+
recordMissingRemoteRefSkip(branch) {
|
|
2797
|
+
this.recordSkip(
|
|
2798
|
+
{ kind: "missing_remote_ref", branch, source: "fetch_error" },
|
|
2799
|
+
`Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
|
|
2800
|
+
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
async fetchWithRecovery(git, fetchArgs, worktreeDir, branch) {
|
|
2804
|
+
try {
|
|
2805
|
+
await git.fetch(fetchArgs);
|
|
2806
|
+
return { skipped: false };
|
|
2807
|
+
} catch (fetchError) {
|
|
2808
|
+
const message = getErrorMessage(fetchError);
|
|
2809
|
+
if (isLfsError(message)) {
|
|
2810
|
+
this.logger.info(`\u26A0\uFE0F LFS error during fetch for '${this.repoName}'; retrying with LFS disabled.`);
|
|
2811
|
+
this.emitProgress({ phase: "fetch", message: `Retrying fetch for '${this.repoName}' with LFS disabled` });
|
|
2812
|
+
const lfsSkipGit = simpleGit(worktreeDir, this.buildGitOptions(this.getFetchTimeoutMs())).env(
|
|
2813
|
+
this.buildGitEnv({ forceLfsSkip: true })
|
|
2814
|
+
);
|
|
2815
|
+
try {
|
|
2816
|
+
await lfsSkipGit.fetch(fetchArgs);
|
|
2817
|
+
return { skipped: false };
|
|
2818
|
+
} catch (retryError) {
|
|
2819
|
+
if (isMissingRemoteRefError(getErrorMessage(retryError))) {
|
|
2820
|
+
this.recordMissingRemoteRefSkip(branch);
|
|
2821
|
+
return { skipped: true };
|
|
2822
|
+
}
|
|
2823
|
+
throw retryError;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
if (isMissingRemoteRefError(message)) {
|
|
2827
|
+
this.recordMissingRemoteRefSkip(branch);
|
|
2828
|
+
return { skipped: true };
|
|
2829
|
+
}
|
|
2830
|
+
throw fetchError;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
async hasRemoteBranch(git, branch) {
|
|
2834
|
+
try {
|
|
2835
|
+
await git.raw(["show-ref", "--verify", `refs/remotes/origin/${branch}`]);
|
|
2836
|
+
return true;
|
|
2837
|
+
} catch {
|
|
2838
|
+
return false;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
async isShallowRepository(git) {
|
|
2842
|
+
try {
|
|
2843
|
+
const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
|
|
2844
|
+
return output.trim() === "true";
|
|
2845
|
+
} catch {
|
|
2846
|
+
return false;
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
async unshallowIfDepthRemoved(git) {
|
|
2850
|
+
if (this.config.depth !== void 0) return;
|
|
2851
|
+
if (!await this.isShallowRepository(git)) return;
|
|
2852
|
+
this.logger.info(
|
|
2853
|
+
`[deepen] Existing shallow clone for '${this.repoName}' has no configured depth; fetching full history...`
|
|
2854
|
+
);
|
|
2855
|
+
await git.fetch(["--unshallow"]);
|
|
2856
|
+
}
|
|
2857
|
+
getDeepenTargets() {
|
|
2858
|
+
const configuredDepth = this.config.depth;
|
|
2859
|
+
if (configuredDepth === void 0) return [];
|
|
2860
|
+
return SHALLOW_RELATION_DEEPEN_TARGETS.filter((target) => target > configuredDepth);
|
|
2861
|
+
}
|
|
2862
|
+
async deepenShallowHistoryToDepth(git, branch, targetDepth) {
|
|
2863
|
+
this.logger.info(
|
|
2864
|
+
`[deepen] Shallow clone for '${this.repoName}' lacks enough history to classify origin/${branch}; refetching to depth ${targetDepth} before deciding.`
|
|
2865
|
+
);
|
|
2866
|
+
this.emitProgress({
|
|
2867
|
+
phase: "fetch",
|
|
2868
|
+
message: `Deepening '${this.repoName}' to depth ${targetDepth} before classifying origin/${branch}`
|
|
2869
|
+
});
|
|
2870
|
+
await git.fetch([
|
|
2871
|
+
"origin",
|
|
2872
|
+
"--depth",
|
|
2873
|
+
String(targetDepth),
|
|
2874
|
+
"--prune",
|
|
2875
|
+
"--progress",
|
|
2876
|
+
`+refs/heads/${branch}:refs/remotes/origin/${branch}`
|
|
2877
|
+
]);
|
|
2878
|
+
}
|
|
2879
|
+
async resolveBranch() {
|
|
2880
|
+
if (this.resolvedBranch) return this.resolvedBranch;
|
|
2881
|
+
if (this.config.branch) {
|
|
2882
|
+
this.resolvedBranch = this.config.branch;
|
|
2883
|
+
this.emitProgress({ phase: "branch", message: `Using configured branch '${this.resolvedBranch}'` });
|
|
2884
|
+
return this.resolvedBranch;
|
|
2885
|
+
}
|
|
2886
|
+
this.logger.info(`No branch configured for '${this.repoName}', detecting remote default branch...`);
|
|
2887
|
+
this.emitProgress({ phase: "branch", message: `Resolving remote default branch for '${this.repoName}'` });
|
|
2888
|
+
this.resolvedBranch = await this.gitService.getRemoteDefaultBranch(this.config.repoUrl);
|
|
2889
|
+
this.logger.info(` \u21B3 resolved default branch: ${this.resolvedBranch}`);
|
|
2890
|
+
this.emitProgress({ phase: "branch", message: `Resolved default branch '${this.resolvedBranch}'` });
|
|
2891
|
+
return this.resolvedBranch;
|
|
2892
|
+
}
|
|
2893
|
+
async initialize(outcome) {
|
|
2894
|
+
return this.withOutcome(outcome, () => this.initializeInternal());
|
|
2895
|
+
}
|
|
2896
|
+
async initializeInternal() {
|
|
2897
|
+
this.pendingInitSkip = null;
|
|
2898
|
+
const branch = await this.resolveBranch();
|
|
2899
|
+
const worktreeDir = this.config.worktreeDir;
|
|
2900
|
+
let entries = null;
|
|
2901
|
+
try {
|
|
2902
|
+
entries = await fs3.readdir(worktreeDir);
|
|
2903
|
+
} catch {
|
|
2904
|
+
entries = null;
|
|
2905
|
+
}
|
|
2906
|
+
if (entries?.includes(PATH_CONSTANTS.GIT_DIR)) {
|
|
2907
|
+
this.emitProgress({ phase: "clone", message: `Validating existing clone for '${this.repoName}'` });
|
|
2908
|
+
const result = await this.validateExistingClone(branch);
|
|
2909
|
+
if (!result.valid) {
|
|
2910
|
+
this.recordSkip(result.skip, result.warnMessage, `Skipping '${this.repoName}': ${result.progressDetail}`);
|
|
2911
|
+
this.pendingInitSkip = result.skip;
|
|
2912
|
+
this.initialized = true;
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2916
|
+
await this.ensureAllRemoteBranchesRefspec(git);
|
|
2917
|
+
this.initialized = true;
|
|
2918
|
+
this.emitProgress({ phase: "clone", message: `Existing clone validated for '${this.repoName}'` });
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
if (entries && entries.length > 0) {
|
|
2922
|
+
throw new ConfigError(
|
|
2923
|
+
`Cannot clone into '${worktreeDir}': directory exists and is not empty. Remove existing contents or point worktreeDir at an empty path.`,
|
|
2924
|
+
"CLONE_DESTINATION_NOT_EMPTY"
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
const cloneCreatedDir = entries === null;
|
|
2928
|
+
await fs3.mkdir(worktreeDir, { recursive: true });
|
|
2929
|
+
this.logger.info(`Cloning '${this.config.repoUrl}' (${branch}) into '${worktreeDir}'...`);
|
|
2930
|
+
this.emitProgress({ phase: "clone", message: `Cloning '${this.repoName}' (${branch})` });
|
|
2931
|
+
const cloneClient = simpleGit(this.buildGitOptions(this.getCloneTimeoutMs())).env(this.buildGitEnv());
|
|
2932
|
+
try {
|
|
2933
|
+
await cloneClient.clone(this.config.repoUrl, worktreeDir, this.buildCloneArgs(branch));
|
|
2934
|
+
} catch (error) {
|
|
2935
|
+
await this.maybeCleanupPartialClone(worktreeDir, cloneCreatedDir);
|
|
2936
|
+
this.outcomeAccumulator?.recordFailed("repo", getErrorMessage(error), {
|
|
2937
|
+
reason: "clone_failed",
|
|
2938
|
+
branch,
|
|
2939
|
+
path: worktreeDir
|
|
2940
|
+
});
|
|
2941
|
+
throw error;
|
|
2942
|
+
}
|
|
2943
|
+
const worktreeGit = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2944
|
+
await this.ensureAllRemoteBranchesRefspec(worktreeGit);
|
|
2945
|
+
this.logger.info(`\u2705 Clone successful.`);
|
|
2946
|
+
this.emitProgress({ phase: "clone", message: `Clone successful for '${this.repoName}'` });
|
|
2947
|
+
if (this.config.sparseCheckout) {
|
|
2948
|
+
this.logger.info(`Applying sparse-checkout patterns to '${worktreeDir}'...`);
|
|
2949
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Applying sparse-checkout for '${this.repoName}'` });
|
|
2950
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
2951
|
+
await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
|
|
2952
|
+
await worktreeGit.raw(["checkout", "HEAD"]);
|
|
2953
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout applied for '${this.repoName}'` });
|
|
2954
|
+
}
|
|
2955
|
+
this.emitProgress({ phase: "lfs", message: `Verifying LFS for '${this.repoName}'` });
|
|
2956
|
+
await this.gitService.verifyLfs(worktreeDir, branch);
|
|
2957
|
+
this.emitProgress({ phase: "lfs", message: `LFS verified for '${this.repoName}'` });
|
|
2958
|
+
await this.runInitialFileCopy(worktreeDir, branch);
|
|
2959
|
+
this.outcomeAccumulator?.recordCreated(branch, worktreeDir);
|
|
2960
|
+
this.initialized = true;
|
|
2961
|
+
}
|
|
2962
|
+
// Detects an on-disk clone whose `origin` no longer matches the configured
|
|
2963
|
+
// repoUrl (e.g. repoUrl was repointed in config). Returns a skip descriptor so
|
|
2964
|
+
// we never fetch/ff-merge from the wrong remote; null when origin matches or
|
|
2965
|
+
// can't be read. Comparison is normalized so https/.git/trailing-slash
|
|
2966
|
+
// variants don't false-positive; the raw URLs are kept in the message.
|
|
2967
|
+
async evaluateOriginMatch(git, worktreeDir) {
|
|
2968
|
+
let originUrl;
|
|
2969
|
+
try {
|
|
2970
|
+
originUrl = (await git.raw(["remote", "get-url", "origin"])).trim();
|
|
2971
|
+
} catch {
|
|
2972
|
+
this.logger.warn(`Could not read 'origin' remote URL from existing clone at '${worktreeDir}'.`);
|
|
2973
|
+
return null;
|
|
2974
|
+
}
|
|
2975
|
+
if (!originUrl || normalizeRepoUrlForComparison(originUrl) === normalizeRepoUrlForComparison(this.config.repoUrl)) {
|
|
2976
|
+
return null;
|
|
2977
|
+
}
|
|
2978
|
+
return {
|
|
2979
|
+
skip: { kind: "origin_mismatch", actual: originUrl, expected: this.config.repoUrl },
|
|
2980
|
+
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.`,
|
|
2981
|
+
progressDetail: `origin '${originUrl}' is not '${this.config.repoUrl}'`
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
async validateExistingClone(expectedBranch) {
|
|
2985
|
+
const worktreeDir = this.config.worktreeDir;
|
|
2986
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
2987
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
2988
|
+
if (originMismatch) {
|
|
2989
|
+
return { valid: false, ...originMismatch };
|
|
2990
|
+
}
|
|
2991
|
+
let currentBranch;
|
|
2992
|
+
try {
|
|
2993
|
+
currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
2994
|
+
} catch (error) {
|
|
2995
|
+
const errorMessage = getErrorMessage(error);
|
|
2996
|
+
return {
|
|
2997
|
+
valid: false,
|
|
2998
|
+
skip: { kind: "head_unreadable", phase: "init", error: errorMessage },
|
|
2999
|
+
warnMessage: `Existing clone at '${worktreeDir}' has a .git folder but reading HEAD failed: ${errorMessage}`,
|
|
3000
|
+
progressDetail: `could not read HEAD (${errorMessage})`
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
if (currentBranch !== expectedBranch) {
|
|
3004
|
+
return {
|
|
3005
|
+
valid: false,
|
|
3006
|
+
skip: {
|
|
3007
|
+
kind: "branch_mismatch",
|
|
3008
|
+
phase: "init",
|
|
3009
|
+
currentBranch,
|
|
3010
|
+
expectedBranch
|
|
3011
|
+
},
|
|
3012
|
+
warnMessage: `Existing clone at '${worktreeDir}' is on branch '${currentBranch}', expected '${expectedBranch}'. Switch the working tree to '${expectedBranch}' or update the config.`,
|
|
3013
|
+
progressDetail: `current branch '${currentBranch}' is not '${expectedBranch}'`
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
return { valid: true };
|
|
3017
|
+
}
|
|
3018
|
+
async maybeCleanupPartialClone(worktreeDir, cloneCreatedDir) {
|
|
3019
|
+
if (!cloneCreatedDir) {
|
|
3020
|
+
this.logger.warn(
|
|
3021
|
+
`Clone failed; leaving '${worktreeDir}' for manual inspection (directory existed before clone attempt).`
|
|
3022
|
+
);
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
let entries;
|
|
3026
|
+
try {
|
|
3027
|
+
entries = await fs3.readdir(worktreeDir);
|
|
3028
|
+
} catch {
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
const looksIncomplete = entries.every((e) => e.startsWith("."));
|
|
3032
|
+
const hasUsableGit = entries.includes(PATH_CONSTANTS.GIT_DIR) && await fileExists(path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, "HEAD"));
|
|
3033
|
+
if (looksIncomplete && !hasUsableGit) {
|
|
3034
|
+
try {
|
|
3035
|
+
await fs3.rm(worktreeDir, { recursive: true, force: true });
|
|
3036
|
+
this.logger.info(`Cleaned up incomplete clone at '${worktreeDir}'.`);
|
|
3037
|
+
} catch (rmError) {
|
|
3038
|
+
this.logger.warn(`Failed to clean up incomplete clone at '${worktreeDir}': ${getErrorMessage(rmError)}`);
|
|
3039
|
+
}
|
|
3040
|
+
} else {
|
|
3041
|
+
this.logger.warn(
|
|
3042
|
+
`Clone failed; leaving '${worktreeDir}' for manual inspection (post-failure contents do not look like an empty incomplete clone).`
|
|
3043
|
+
);
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
getInitMarkerPath(worktreeDir) {
|
|
3047
|
+
return path4.join(worktreeDir, PATH_CONSTANTS.GIT_DIR, PATH_CONSTANTS.CLONE_INIT_MARKER);
|
|
3048
|
+
}
|
|
3049
|
+
async runInitialFileCopy(worktreeDir, branch) {
|
|
3050
|
+
const marker = this.getInitMarkerPath(worktreeDir);
|
|
3051
|
+
if (await fileExists(marker)) {
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
const sourceDir = this.config.__configFileDir ?? worktreeDir;
|
|
3055
|
+
await this.branchCreatedActions.copyFiles({
|
|
3056
|
+
config: this.config,
|
|
3057
|
+
branchName: branch,
|
|
3058
|
+
worktreePath: worktreeDir,
|
|
3059
|
+
sourceDir,
|
|
3060
|
+
logger: this.logger
|
|
3061
|
+
});
|
|
3062
|
+
try {
|
|
3063
|
+
await fs3.writeFile(marker, (/* @__PURE__ */ new Date()).toISOString());
|
|
3064
|
+
} catch (error) {
|
|
3065
|
+
this.logger.warn(`Could not write clone-init marker: ${getErrorMessage(error)}`);
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
async runSyncAttempt(outcome) {
|
|
3069
|
+
return this.withOutcome(outcome, () => this.runSyncAttemptInternal());
|
|
3070
|
+
}
|
|
3071
|
+
async runSyncAttemptInternal() {
|
|
3072
|
+
if (!this.initialized) {
|
|
3073
|
+
await this.initialize();
|
|
3074
|
+
this.pendingInitSkip = null;
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
if (this.pendingInitSkip) {
|
|
3078
|
+
this.pendingInitSkip = null;
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
const branch = await this.resolveBranch();
|
|
3082
|
+
const worktreeDir = this.config.worktreeDir;
|
|
3083
|
+
const git = this.clientFor(worktreeDir, this.getFetchTimeoutMs());
|
|
3084
|
+
let currentBranch;
|
|
3085
|
+
try {
|
|
3086
|
+
currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
const errorMessage = getErrorMessage(error);
|
|
3089
|
+
this.recordSkip(
|
|
3090
|
+
{ kind: "head_unreadable", phase: "sync", error: errorMessage },
|
|
3091
|
+
`Could not read current branch from '${worktreeDir}': ${errorMessage}`,
|
|
3092
|
+
`Skipping '${this.repoName}': could not read current branch`
|
|
3093
|
+
);
|
|
3094
|
+
return;
|
|
3095
|
+
}
|
|
3096
|
+
if (currentBranch !== branch) {
|
|
3097
|
+
this.recordSkip(
|
|
3098
|
+
{ kind: "branch_mismatch", phase: "sync", currentBranch, expectedBranch: branch },
|
|
3099
|
+
`Clone at '${worktreeDir}' is on '${currentBranch}', expected '${branch}'. Skipping fetch+merge.`,
|
|
3100
|
+
`Skipping '${this.repoName}': current branch '${currentBranch}' is not '${branch}'`
|
|
3101
|
+
);
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
const originMismatch = await this.evaluateOriginMatch(git, worktreeDir);
|
|
3105
|
+
if (originMismatch) {
|
|
3106
|
+
this.recordSkip(
|
|
3107
|
+
originMismatch.skip,
|
|
3108
|
+
originMismatch.warnMessage,
|
|
3109
|
+
`Skipping '${this.repoName}': ${originMismatch.progressDetail}`
|
|
3110
|
+
);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
await this.unshallowIfDepthRemoved(git);
|
|
3114
|
+
await this.ensureAllRemoteBranchesRefspec(git);
|
|
3115
|
+
const fetchArgs = await this.buildFetchArgs(git);
|
|
3116
|
+
this.emitProgress({ phase: "fetch", message: `Fetching origin branches for '${this.repoName}'` });
|
|
3117
|
+
if ((await this.fetchWithRecovery(git, fetchArgs, worktreeDir, branch)).skipped) {
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
3120
|
+
this.emitProgress({ phase: "fetch", message: `Fetched origin branches for '${this.repoName}'` });
|
|
3121
|
+
if (!await this.hasRemoteBranch(git, branch)) {
|
|
3122
|
+
this.recordSkip(
|
|
3123
|
+
{ kind: "missing_remote_ref", branch, source: "post_fetch_verify" },
|
|
3124
|
+
`Tracked branch '${branch}' is missing on remote for '${this.repoName}'. Skipping sync.`,
|
|
3125
|
+
`Skipping '${this.repoName}': origin/${branch} is missing`
|
|
3126
|
+
);
|
|
3127
|
+
return;
|
|
3128
|
+
}
|
|
3129
|
+
if (this.config.sparseCheckout) {
|
|
3130
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
3131
|
+
try {
|
|
3132
|
+
if (await sparseService.needsUpdate(worktreeDir, this.config.sparseCheckout)) {
|
|
3133
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Updating sparse-checkout for '${this.repoName}'` });
|
|
3134
|
+
await sparseService.applyToWorktree(worktreeDir, this.config.sparseCheckout);
|
|
3135
|
+
this.emitProgress({ phase: "sparse_checkout", message: `Sparse-checkout updated for '${this.repoName}'` });
|
|
3136
|
+
}
|
|
3137
|
+
} catch (error) {
|
|
3138
|
+
this.logger.warn(`Failed to reapply sparse-checkout for '${this.repoName}': ${getErrorMessage(error)}`);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktreeDir);
|
|
3142
|
+
if (!isClean) {
|
|
3143
|
+
this.recordSkip(
|
|
3144
|
+
{ kind: "dirty_tree" },
|
|
3145
|
+
`\u23ED\uFE0F Skipping ff-merge for '${this.repoName}' \u2014 working tree has local changes.`,
|
|
3146
|
+
`Skipping merge for '${this.repoName}': working tree has local changes`,
|
|
3147
|
+
"info"
|
|
3148
|
+
);
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
let relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
|
|
3152
|
+
let lastDeepenedTo = null;
|
|
3153
|
+
if (relationship === "indeterminate_shallow") {
|
|
3154
|
+
for (const target of this.getDeepenTargets()) {
|
|
3155
|
+
await this.deepenShallowHistoryToDepth(git, branch, target);
|
|
3156
|
+
lastDeepenedTo = target;
|
|
3157
|
+
relationship = await this.gitService.classifyRemoteRelationship(worktreeDir, branch);
|
|
3158
|
+
if (relationship !== "indeterminate_shallow") break;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
if (relationship === "up_to_date") {
|
|
3162
|
+
this.logger.info(`'${this.repoName}' already up to date with origin/${branch}.`);
|
|
3163
|
+
this.emitProgress({
|
|
3164
|
+
phase: "skip",
|
|
3165
|
+
message: `'${this.repoName}' already up to date with origin/${branch}`
|
|
3166
|
+
});
|
|
3167
|
+
this.outcomeAccumulator?.recordNoop("repo", "already_up_to_date", {
|
|
3168
|
+
branch,
|
|
3169
|
+
path: worktreeDir,
|
|
3170
|
+
message: `Already up to date with origin/${branch}`
|
|
3171
|
+
});
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
if (relationship !== "fast_forward") {
|
|
3175
|
+
if (relationship === "local_ahead") {
|
|
3176
|
+
this.recordSkip(
|
|
3177
|
+
{ kind: "ahead_unpushed", branch },
|
|
3178
|
+
`\u23ED\uFE0F '${this.repoName}' has unpushed commits ahead of origin/${branch}. Skipping merge.`,
|
|
3179
|
+
`Skipping merge for '${this.repoName}': unpushed commits ahead of origin/${branch}`,
|
|
3180
|
+
"info"
|
|
3181
|
+
);
|
|
3182
|
+
} else if (relationship === "indeterminate_shallow") {
|
|
3183
|
+
const detail = lastDeepenedTo === null ? `no deepening attempted (configured depth already at or above all deepen targets)` : `deepening to ${lastDeepenedTo} commits`;
|
|
3184
|
+
const progressDetail = lastDeepenedTo === null ? `no deepening attempted (configured depth at/above limits)` : `shallow depth budget exhausted at ${lastDeepenedTo}`;
|
|
3185
|
+
this.recordSkip(
|
|
3186
|
+
{ kind: "indeterminate_shallow", branch, deepenedTo: lastDeepenedTo },
|
|
3187
|
+
`\u23ED\uFE0F '${this.repoName}' could not classify origin/${branch} after ${detail}. Skipping merge \u2014 consider removing or raising 'depth' to unshallow.`,
|
|
3188
|
+
`Skipping merge for '${this.repoName}': ${progressDetail}`,
|
|
3189
|
+
"info"
|
|
3190
|
+
);
|
|
3191
|
+
} else {
|
|
3192
|
+
this.recordSkip(
|
|
3193
|
+
{ kind: "diverged", branch },
|
|
3194
|
+
`\u23ED\uFE0F '${this.repoName}' has diverged from origin/${branch}. Skipping merge (no auto-reset).`,
|
|
3195
|
+
`Skipping merge for '${this.repoName}': diverged from origin/${branch}`,
|
|
3196
|
+
"info"
|
|
3197
|
+
);
|
|
3198
|
+
}
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
this.logger.info(`Fast-forwarding '${this.repoName}' to origin/${branch}...`);
|
|
3202
|
+
this.emitProgress({ phase: "merge", message: `Fast-forwarding '${this.repoName}' to origin/${branch}` });
|
|
3203
|
+
await git.merge([`origin/${branch}`, "--ff-only"]);
|
|
3204
|
+
this.logger.info(`\u2705 Updated '${this.repoName}' to origin/${branch}.`);
|
|
3205
|
+
this.emitProgress({ phase: "merge", message: `Updated '${this.repoName}' to origin/${branch}` });
|
|
3206
|
+
this.outcomeAccumulator?.recordUpdated(branch, worktreeDir, "fast_forward");
|
|
3207
|
+
}
|
|
3208
|
+
};
|
|
3209
|
+
|
|
3210
|
+
// src/services/git.service.ts
|
|
3211
|
+
import * as fs6 from "fs/promises";
|
|
3212
|
+
import * as path8 from "path";
|
|
3213
|
+
import simpleGit5 from "simple-git";
|
|
2185
3214
|
|
|
2186
3215
|
// src/utils/worktree-list-parser.ts
|
|
2187
3216
|
function parseWorktreeListPorcelain(output) {
|
|
@@ -2324,8 +3353,8 @@ function defaultConsoleOutput(msg, level) {
|
|
|
2324
3353
|
}
|
|
2325
3354
|
|
|
2326
3355
|
// src/services/sparse-checkout.service.ts
|
|
2327
|
-
import * as
|
|
2328
|
-
import
|
|
3356
|
+
import * as path5 from "path";
|
|
3357
|
+
import simpleGit2 from "simple-git";
|
|
2329
3358
|
var SparseCheckoutService = class {
|
|
2330
3359
|
logger;
|
|
2331
3360
|
gitFactory;
|
|
@@ -2333,7 +3362,7 @@ var SparseCheckoutService = class {
|
|
|
2333
3362
|
matcherCache = /* @__PURE__ */ new WeakMap();
|
|
2334
3363
|
constructor(logger, gitFactory) {
|
|
2335
3364
|
this.logger = logger ?? Logger.createDefault();
|
|
2336
|
-
this.gitFactory = gitFactory ?? ((p) =>
|
|
3365
|
+
this.gitFactory = gitFactory ?? ((p) => simpleGit2(p));
|
|
2337
3366
|
}
|
|
2338
3367
|
updateLogger(logger) {
|
|
2339
3368
|
this.logger = logger;
|
|
@@ -2384,11 +3413,25 @@ var SparseCheckoutService = class {
|
|
|
2384
3413
|
return null;
|
|
2385
3414
|
}
|
|
2386
3415
|
}
|
|
3416
|
+
async readCurrentMode(worktreePath) {
|
|
3417
|
+
const git = this.gitFactory(worktreePath);
|
|
3418
|
+
try {
|
|
3419
|
+
const out = await git.raw(["config", "--bool", "--get", "core.sparseCheckoutCone"]);
|
|
3420
|
+
const value = out.trim().toLowerCase();
|
|
3421
|
+
if (value === "true") return "cone";
|
|
3422
|
+
if (value === "false") return "no-cone";
|
|
3423
|
+
return null;
|
|
3424
|
+
} catch {
|
|
3425
|
+
return null;
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
2387
3428
|
async needsUpdate(worktreePath, cfg) {
|
|
3429
|
+
const desiredMode = this.resolveMode(cfg);
|
|
3430
|
+
const currentMode = await this.readCurrentMode(worktreePath);
|
|
3431
|
+
if (currentMode !== desiredMode) return true;
|
|
2388
3432
|
const current = await this.readCurrent(worktreePath);
|
|
2389
|
-
const desired = this.buildPatterns(cfg);
|
|
2390
3433
|
if (current === null) return true;
|
|
2391
|
-
return !this.patternsEqual(current,
|
|
3434
|
+
return !this.patternsEqual(current, this.buildPatternsForMode(cfg, desiredMode));
|
|
2392
3435
|
}
|
|
2393
3436
|
isNarrowing(currentPatterns, nextPatterns) {
|
|
2394
3437
|
if (!currentPatterns || currentPatterns.length === 0) return false;
|
|
@@ -2445,7 +3488,7 @@ var SparseCheckoutService = class {
|
|
|
2445
3488
|
for (const pat of matcher.patterns) {
|
|
2446
3489
|
if (p === pat || p.startsWith(pat + "/")) return true;
|
|
2447
3490
|
}
|
|
2448
|
-
return matcher.ancestorDirs.has(
|
|
3491
|
+
return matcher.ancestorDirs.has(path5.posix.dirname(p));
|
|
2449
3492
|
});
|
|
2450
3493
|
}
|
|
2451
3494
|
getMatcher(cfg) {
|
|
@@ -2472,9 +3515,9 @@ var SparseCheckoutService = class {
|
|
|
2472
3515
|
};
|
|
2473
3516
|
|
|
2474
3517
|
// src/services/worktree-metadata.service.ts
|
|
2475
|
-
import * as
|
|
2476
|
-
import * as
|
|
2477
|
-
import
|
|
3518
|
+
import * as fs4 from "fs/promises";
|
|
3519
|
+
import * as path6 from "path";
|
|
3520
|
+
import simpleGit3 from "simple-git";
|
|
2478
3521
|
var WorktreeMetadataService = class {
|
|
2479
3522
|
logger;
|
|
2480
3523
|
constructor(logger) {
|
|
@@ -2486,7 +3529,7 @@ var WorktreeMetadataService = class {
|
|
|
2486
3529
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
2487
3530
|
*/
|
|
2488
3531
|
getWorktreeDirectoryName(worktreePath) {
|
|
2489
|
-
return
|
|
3532
|
+
return path6.basename(worktreePath);
|
|
2490
3533
|
}
|
|
2491
3534
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
2492
3535
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -2494,7 +3537,7 @@ var WorktreeMetadataService = class {
|
|
|
2494
3537
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
2495
3538
|
);
|
|
2496
3539
|
}
|
|
2497
|
-
return
|
|
3540
|
+
return path6.join(
|
|
2498
3541
|
bareRepoPath,
|
|
2499
3542
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
2500
3543
|
worktreeName,
|
|
@@ -2507,31 +3550,31 @@ var WorktreeMetadataService = class {
|
|
|
2507
3550
|
}
|
|
2508
3551
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
2509
3552
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2510
|
-
await
|
|
3553
|
+
await fs4.mkdir(path6.dirname(metadataPath), { recursive: true });
|
|
2511
3554
|
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2512
3555
|
let renamed = false;
|
|
2513
3556
|
try {
|
|
2514
|
-
await
|
|
3557
|
+
await fs4.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
2515
3558
|
try {
|
|
2516
|
-
await
|
|
3559
|
+
await fs4.rename(tmpPath, metadataPath);
|
|
2517
3560
|
renamed = true;
|
|
2518
3561
|
} catch (err) {
|
|
2519
3562
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
2520
|
-
await
|
|
3563
|
+
await fs4.copyFile(tmpPath, metadataPath);
|
|
2521
3564
|
} else {
|
|
2522
3565
|
throw err;
|
|
2523
3566
|
}
|
|
2524
3567
|
}
|
|
2525
3568
|
} finally {
|
|
2526
3569
|
if (!renamed) {
|
|
2527
|
-
await
|
|
3570
|
+
await fs4.unlink(tmpPath).catch(() => void 0);
|
|
2528
3571
|
}
|
|
2529
3572
|
}
|
|
2530
3573
|
}
|
|
2531
3574
|
async loadMetadata(bareRepoPath, worktreeName) {
|
|
2532
3575
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2533
3576
|
try {
|
|
2534
|
-
const content = await
|
|
3577
|
+
const content = await fs4.readFile(metadataPath, "utf-8");
|
|
2535
3578
|
return JSON.parse(content);
|
|
2536
3579
|
} catch {
|
|
2537
3580
|
return null;
|
|
@@ -2540,7 +3583,7 @@ var WorktreeMetadataService = class {
|
|
|
2540
3583
|
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
2541
3584
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
2542
3585
|
try {
|
|
2543
|
-
const content = await
|
|
3586
|
+
const content = await fs4.readFile(metadataPath, "utf-8");
|
|
2544
3587
|
const metadata = JSON.parse(content);
|
|
2545
3588
|
if (!await this.validateMetadata(metadata)) {
|
|
2546
3589
|
this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
@@ -2554,7 +3597,7 @@ var WorktreeMetadataService = class {
|
|
|
2554
3597
|
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
2555
3598
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2556
3599
|
try {
|
|
2557
|
-
await
|
|
3600
|
+
await fs4.unlink(metadataPath);
|
|
2558
3601
|
} catch (error) {
|
|
2559
3602
|
if (error.code !== "ENOENT") {
|
|
2560
3603
|
throw error;
|
|
@@ -2564,7 +3607,7 @@ var WorktreeMetadataService = class {
|
|
|
2564
3607
|
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
2565
3608
|
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
2566
3609
|
try {
|
|
2567
|
-
await
|
|
3610
|
+
await fs4.unlink(metadataPath);
|
|
2568
3611
|
} catch (error) {
|
|
2569
3612
|
if (error.code !== "ENOENT") {
|
|
2570
3613
|
throw error;
|
|
@@ -2598,7 +3641,7 @@ var WorktreeMetadataService = class {
|
|
|
2598
3641
|
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
2599
3642
|
this.logger.info(` Attempting to create initial metadata...`);
|
|
2600
3643
|
try {
|
|
2601
|
-
const worktreeGit =
|
|
3644
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
2602
3645
|
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
2603
3646
|
const branchSummary = await worktreeGit.branch();
|
|
2604
3647
|
const actualBranchName = branchSummary.current;
|
|
@@ -2699,9 +3742,9 @@ var WorktreeMetadataService = class {
|
|
|
2699
3742
|
};
|
|
2700
3743
|
|
|
2701
3744
|
// src/services/worktree-status.service.ts
|
|
2702
|
-
import * as
|
|
2703
|
-
import * as
|
|
2704
|
-
import
|
|
3745
|
+
import * as fs5 from "fs/promises";
|
|
3746
|
+
import * as path7 from "path";
|
|
3747
|
+
import simpleGit4 from "simple-git";
|
|
2705
3748
|
var OPERATION_FILES = [
|
|
2706
3749
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
2707
3750
|
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
@@ -2732,9 +3775,7 @@ var WorktreeStatusService = class {
|
|
|
2732
3775
|
return true;
|
|
2733
3776
|
}
|
|
2734
3777
|
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
2735
|
-
|
|
2736
|
-
await fs3.access(worktreePath);
|
|
2737
|
-
} catch {
|
|
3778
|
+
if (!await fileExists(worktreePath)) {
|
|
2738
3779
|
return {
|
|
2739
3780
|
isClean: true,
|
|
2740
3781
|
hasUnpushedCommits: false,
|
|
@@ -2904,7 +3945,7 @@ var WorktreeStatusService = class {
|
|
|
2904
3945
|
async detectOperationFile(gitDir) {
|
|
2905
3946
|
const results = await Promise.all(
|
|
2906
3947
|
OPERATION_FILES.map(
|
|
2907
|
-
({ file }) =>
|
|
3948
|
+
({ file }) => fs5.access(path7.join(gitDir, file)).then(
|
|
2908
3949
|
() => true,
|
|
2909
3950
|
() => false
|
|
2910
3951
|
)
|
|
@@ -3025,14 +4066,14 @@ var WorktreeStatusService = class {
|
|
|
3025
4066
|
}
|
|
3026
4067
|
}
|
|
3027
4068
|
async resolveGitDir(worktreePath) {
|
|
3028
|
-
const gitPath =
|
|
4069
|
+
const gitPath = path7.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
3029
4070
|
try {
|
|
3030
|
-
const stat3 = await
|
|
4071
|
+
const stat3 = await fs5.stat(gitPath);
|
|
3031
4072
|
if (stat3.isFile()) {
|
|
3032
|
-
const content = await
|
|
4073
|
+
const content = await fs5.readFile(gitPath, "utf-8");
|
|
3033
4074
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
3034
4075
|
if (gitdirMatch) {
|
|
3035
|
-
return
|
|
4076
|
+
return path7.resolve(worktreePath, gitdirMatch[1].trim());
|
|
3036
4077
|
}
|
|
3037
4078
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
3038
4079
|
}
|
|
@@ -3046,10 +4087,10 @@ var WorktreeStatusService = class {
|
|
|
3046
4087
|
}
|
|
3047
4088
|
}
|
|
3048
4089
|
createGitInstance(worktreePath) {
|
|
3049
|
-
const key = `${
|
|
4090
|
+
const key = `${path7.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
3050
4091
|
let git = this.gitInstances.get(key);
|
|
3051
4092
|
if (!git) {
|
|
3052
|
-
git = this.config.skipLfs ?
|
|
4093
|
+
git = this.config.skipLfs ? simpleGit4(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit4(worktreePath);
|
|
3053
4094
|
this.gitInstances.set(key, git);
|
|
3054
4095
|
}
|
|
3055
4096
|
return git;
|
|
@@ -3065,11 +4106,12 @@ function sanitizeGitEnv(env) {
|
|
|
3065
4106
|
return sanitized;
|
|
3066
4107
|
}
|
|
3067
4108
|
var GitService = class {
|
|
3068
|
-
constructor(config, logger) {
|
|
4109
|
+
constructor(config, logger, progressEmitter) {
|
|
3069
4110
|
this.config = config;
|
|
4111
|
+
this.progressEmitter = progressEmitter;
|
|
3070
4112
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
3071
4113
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
3072
|
-
this.mainWorktreePath =
|
|
4114
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
3073
4115
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
3074
4116
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
3075
4117
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
@@ -3097,36 +4139,22 @@ var GitService = class {
|
|
|
3097
4139
|
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
3098
4140
|
}
|
|
3099
4141
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
3100
|
-
const key = `${
|
|
4142
|
+
const key = `${path8.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
3101
4143
|
let git = this.gitInstances.get(key);
|
|
3102
4144
|
if (!git) {
|
|
3103
|
-
const base =
|
|
4145
|
+
const base = simpleGit5(dirPath, this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
3104
4146
|
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
3105
4147
|
this.gitInstances.set(key, git);
|
|
3106
4148
|
}
|
|
3107
4149
|
return git;
|
|
3108
4150
|
}
|
|
3109
4151
|
buildSimpleGitOptions(blockMs) {
|
|
3110
|
-
const options = {
|
|
4152
|
+
const options = {
|
|
4153
|
+
progress: makeGitProgressHandler(this.logger, (event) => this.progressEmitter?.(event))
|
|
4154
|
+
};
|
|
3111
4155
|
if (blockMs > 0) options.timeout = { block: blockMs };
|
|
3112
4156
|
return options;
|
|
3113
4157
|
}
|
|
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
4158
|
updateLogger(logger) {
|
|
3131
4159
|
this.logger = logger;
|
|
3132
4160
|
this.sparseCheckoutService.updateLogger(logger);
|
|
@@ -3134,11 +4162,11 @@ var GitService = class {
|
|
|
3134
4162
|
async initialize() {
|
|
3135
4163
|
const { repoUrl } = this.config;
|
|
3136
4164
|
try {
|
|
3137
|
-
await
|
|
4165
|
+
await fs6.access(path8.join(this.bareRepoPath, "HEAD"));
|
|
3138
4166
|
} catch {
|
|
3139
4167
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
3140
|
-
await
|
|
3141
|
-
const cloneBase =
|
|
4168
|
+
await fs6.mkdir(path8.dirname(this.bareRepoPath), { recursive: true });
|
|
4169
|
+
const cloneBase = simpleGit5(this.buildSimpleGitOptions(this.getCloneTimeoutMs()));
|
|
3142
4170
|
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
3143
4171
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare", "--progress"]);
|
|
3144
4172
|
this.logger.info("\u2705 Clone successful.");
|
|
@@ -3156,17 +4184,17 @@ var GitService = class {
|
|
|
3156
4184
|
this.logger.info("Fetching remote branches...");
|
|
3157
4185
|
await bareGit.fetch(["--all", "--progress"]);
|
|
3158
4186
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
3159
|
-
this.mainWorktreePath =
|
|
4187
|
+
this.mainWorktreePath = path8.join(this.config.worktreeDir, this.defaultBranch);
|
|
3160
4188
|
let needsMainWorktree = true;
|
|
3161
4189
|
try {
|
|
3162
4190
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3163
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
4191
|
+
needsMainWorktree = !worktrees.some((w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath));
|
|
3164
4192
|
} catch {
|
|
3165
4193
|
}
|
|
3166
4194
|
if (needsMainWorktree) {
|
|
3167
4195
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
3168
|
-
await
|
|
3169
|
-
const absoluteWorktreePath =
|
|
4196
|
+
await fs6.mkdir(this.config.worktreeDir, { recursive: true });
|
|
4197
|
+
const absoluteWorktreePath = path8.resolve(this.mainWorktreePath);
|
|
3170
4198
|
const branches = await bareGit.branch();
|
|
3171
4199
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
3172
4200
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -3202,7 +4230,7 @@ var GitService = class {
|
|
|
3202
4230
|
}
|
|
3203
4231
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
3204
4232
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
3205
|
-
(w) =>
|
|
4233
|
+
(w) => path8.resolve(w.path) === path8.resolve(this.mainWorktreePath)
|
|
3206
4234
|
);
|
|
3207
4235
|
if (!mainWorktreeRegistered) {
|
|
3208
4236
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -3228,6 +4256,45 @@ var GitService = class {
|
|
|
3228
4256
|
getBareRepoPath() {
|
|
3229
4257
|
return this.bareRepoPath;
|
|
3230
4258
|
}
|
|
4259
|
+
async getRemoteDefaultBranch(repoUrl) {
|
|
4260
|
+
const git = simpleGit5(this.buildSimpleGitOptions(this.getFetchTimeoutMs()));
|
|
4261
|
+
try {
|
|
4262
|
+
const out = await git.raw(["ls-remote", "--symref", repoUrl, "HEAD"]);
|
|
4263
|
+
const match = out.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m);
|
|
4264
|
+
if (match && match[1]) {
|
|
4265
|
+
return match[1];
|
|
4266
|
+
}
|
|
4267
|
+
} catch {
|
|
4268
|
+
}
|
|
4269
|
+
const existing = [];
|
|
4270
|
+
for (const candidate of GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES) {
|
|
4271
|
+
try {
|
|
4272
|
+
const out = await git.raw(["ls-remote", "--exit-code", repoUrl, `refs/heads/${candidate}`]);
|
|
4273
|
+
if (out.trim().length > 0) {
|
|
4274
|
+
existing.push(candidate);
|
|
4275
|
+
}
|
|
4276
|
+
} catch {
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
if (existing.length === 1) {
|
|
4280
|
+
this.logger.warn(
|
|
4281
|
+
`Could not read symref HEAD for '${repoUrl}'; using the only common branch found ('${existing[0]}') as the default.`
|
|
4282
|
+
);
|
|
4283
|
+
return existing[0];
|
|
4284
|
+
}
|
|
4285
|
+
if (existing.length > 1) {
|
|
4286
|
+
throw new Error(
|
|
4287
|
+
`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.`
|
|
4288
|
+
);
|
|
4289
|
+
}
|
|
4290
|
+
throw new Error(
|
|
4291
|
+
`Unable to detect default branch for '${repoUrl}'. Set 'branch' explicitly in the repository config or ensure the remote is reachable.`
|
|
4292
|
+
);
|
|
4293
|
+
}
|
|
4294
|
+
async verifyLfs(worktreePath, label) {
|
|
4295
|
+
if (this.isLfsSkipEnabled()) return;
|
|
4296
|
+
await this.verifyLfsFilesDownloaded(worktreePath, label);
|
|
4297
|
+
}
|
|
3231
4298
|
async fetchAll() {
|
|
3232
4299
|
this.assertInitialized();
|
|
3233
4300
|
this.logger.info("Fetching latest data from remote...");
|
|
@@ -3274,7 +4341,7 @@ var GitService = class {
|
|
|
3274
4341
|
return branches;
|
|
3275
4342
|
}
|
|
3276
4343
|
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
3277
|
-
const worktreeGit = this.config.sparseCheckout ?
|
|
4344
|
+
const worktreeGit = this.config.sparseCheckout ? simpleGit5(worktreePath).env({ ...sanitizeGitEnv(process.env), [ENV_CONSTANTS.GIT_ATTR_SOURCE]: "HEAD" }) : this.getCachedGit(worktreePath);
|
|
3278
4345
|
try {
|
|
3279
4346
|
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
3280
4347
|
let lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
@@ -3285,7 +4352,7 @@ var GitService = class {
|
|
|
3285
4352
|
const existence = await Promise.all(
|
|
3286
4353
|
lfsFileList.map(async (f) => {
|
|
3287
4354
|
try {
|
|
3288
|
-
await
|
|
4355
|
+
await fs6.access(path8.join(worktreePath, f));
|
|
3289
4356
|
return f;
|
|
3290
4357
|
} catch {
|
|
3291
4358
|
return null;
|
|
@@ -3313,9 +4380,9 @@ var GitService = class {
|
|
|
3313
4380
|
let allDownloaded = true;
|
|
3314
4381
|
const notDownloaded = [];
|
|
3315
4382
|
for (const file of samplesToCheck) {
|
|
3316
|
-
const filePath =
|
|
4383
|
+
const filePath = path8.join(worktreePath, file);
|
|
3317
4384
|
try {
|
|
3318
|
-
const handle = await
|
|
4385
|
+
const handle = await fs6.open(filePath, "r");
|
|
3319
4386
|
try {
|
|
3320
4387
|
const buffer = Buffer.alloc(200);
|
|
3321
4388
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
@@ -3340,7 +4407,7 @@ var GitService = class {
|
|
|
3340
4407
|
}
|
|
3341
4408
|
retries++;
|
|
3342
4409
|
if (retries < maxRetries) {
|
|
3343
|
-
await new Promise((
|
|
4410
|
+
await new Promise((resolve12) => setTimeout(resolve12, retryDelay));
|
|
3344
4411
|
}
|
|
3345
4412
|
}
|
|
3346
4413
|
this.logger.warn(
|
|
@@ -3402,18 +4469,18 @@ var GitService = class {
|
|
|
3402
4469
|
}
|
|
3403
4470
|
async addWorktree(branchName, worktreePath) {
|
|
3404
4471
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
3405
|
-
const absoluteWorktreePath =
|
|
3406
|
-
await
|
|
4472
|
+
const absoluteWorktreePath = path8.resolve(worktreePath);
|
|
4473
|
+
await fs6.mkdir(path8.dirname(absoluteWorktreePath), { recursive: true });
|
|
3407
4474
|
try {
|
|
3408
|
-
await
|
|
4475
|
+
await fs6.access(absoluteWorktreePath);
|
|
3409
4476
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3410
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
4477
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3411
4478
|
if (isValidWorktree) {
|
|
3412
4479
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3413
4480
|
return;
|
|
3414
4481
|
} else {
|
|
3415
4482
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
3416
|
-
await
|
|
4483
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3417
4484
|
}
|
|
3418
4485
|
} catch {
|
|
3419
4486
|
}
|
|
@@ -3452,7 +4519,7 @@ var GitService = class {
|
|
|
3452
4519
|
}
|
|
3453
4520
|
if (errorMessage.includes("already registered worktree")) {
|
|
3454
4521
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3455
|
-
const existingWorktree = worktrees.find((w) =>
|
|
4522
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3456
4523
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3457
4524
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
3458
4525
|
return;
|
|
@@ -3460,7 +4527,7 @@ var GitService = class {
|
|
|
3460
4527
|
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
3461
4528
|
await bareGit.raw(["worktree", "prune"]);
|
|
3462
4529
|
try {
|
|
3463
|
-
await
|
|
4530
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3464
4531
|
} catch {
|
|
3465
4532
|
}
|
|
3466
4533
|
let retryCreatedNewBranch = false;
|
|
@@ -3496,15 +4563,15 @@ var GitService = class {
|
|
|
3496
4563
|
}
|
|
3497
4564
|
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
3498
4565
|
try {
|
|
3499
|
-
await
|
|
4566
|
+
await fs6.access(absoluteWorktreePath);
|
|
3500
4567
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3501
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
4568
|
+
const isValidWorktree = worktrees.some((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3502
4569
|
if (isValidWorktree) {
|
|
3503
4570
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3504
4571
|
return;
|
|
3505
4572
|
} else {
|
|
3506
4573
|
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
3507
|
-
await
|
|
4574
|
+
await fs6.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
3508
4575
|
}
|
|
3509
4576
|
} catch {
|
|
3510
4577
|
}
|
|
@@ -3528,7 +4595,7 @@ var GitService = class {
|
|
|
3528
4595
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
3529
4596
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
3530
4597
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3531
|
-
const existingWorktree = worktrees.find((w) =>
|
|
4598
|
+
const existingWorktree = worktrees.find((w) => path8.resolve(w.path) === absoluteWorktreePath);
|
|
3532
4599
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3533
4600
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
3534
4601
|
return;
|
|
@@ -3751,6 +4818,40 @@ var GitService = class {
|
|
|
3751
4818
|
return false;
|
|
3752
4819
|
}
|
|
3753
4820
|
}
|
|
4821
|
+
async classifyRemoteRelationship(worktreePath, branch) {
|
|
4822
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
4823
|
+
let headSha;
|
|
4824
|
+
let remoteSha;
|
|
4825
|
+
try {
|
|
4826
|
+
headSha = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
4827
|
+
remoteSha = (await worktreeGit.revparse([`refs/remotes/origin/${branch}`])).trim();
|
|
4828
|
+
} catch {
|
|
4829
|
+
return "diverged";
|
|
4830
|
+
}
|
|
4831
|
+
if (headSha === remoteSha) return "up_to_date";
|
|
4832
|
+
let mergeBase = "";
|
|
4833
|
+
let mergeBaseFailed = false;
|
|
4834
|
+
try {
|
|
4835
|
+
mergeBase = (await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`])).trim();
|
|
4836
|
+
} catch {
|
|
4837
|
+
mergeBaseFailed = true;
|
|
4838
|
+
}
|
|
4839
|
+
if (mergeBaseFailed || !mergeBase) {
|
|
4840
|
+
if (await this.isShallowRepository(worktreeGit)) return "indeterminate_shallow";
|
|
4841
|
+
return "diverged";
|
|
4842
|
+
}
|
|
4843
|
+
if (mergeBase === headSha) return "fast_forward";
|
|
4844
|
+
if (mergeBase === remoteSha) return "local_ahead";
|
|
4845
|
+
return "diverged";
|
|
4846
|
+
}
|
|
4847
|
+
async isShallowRepository(git) {
|
|
4848
|
+
try {
|
|
4849
|
+
const output = await git.raw(["rev-parse", "--is-shallow-repository"]);
|
|
4850
|
+
return output.trim() === "true";
|
|
4851
|
+
} catch {
|
|
4852
|
+
return false;
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
3754
4855
|
async getChangedPathsInRange(worktreePath, fromRef, toRef) {
|
|
3755
4856
|
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3756
4857
|
try {
|
|
@@ -3861,232 +4962,371 @@ var GitService = class {
|
|
|
3861
4962
|
}
|
|
3862
4963
|
};
|
|
3863
4964
|
|
|
3864
|
-
// src/services/
|
|
4965
|
+
// src/services/progress-emitter.ts
|
|
4966
|
+
var ProgressEmitter = class {
|
|
4967
|
+
listeners = /* @__PURE__ */ new Set();
|
|
4968
|
+
onProgress(listener) {
|
|
4969
|
+
this.listeners.add(listener);
|
|
4970
|
+
return () => this.listeners.delete(listener);
|
|
4971
|
+
}
|
|
4972
|
+
emit(event) {
|
|
4973
|
+
for (const listener of [...this.listeners]) {
|
|
4974
|
+
try {
|
|
4975
|
+
listener(event);
|
|
4976
|
+
} catch {
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
}
|
|
4980
|
+
};
|
|
4981
|
+
|
|
4982
|
+
// src/services/repo-operation-lock.ts
|
|
4983
|
+
import * as fs7 from "fs/promises";
|
|
4984
|
+
import * as path10 from "path";
|
|
4985
|
+
import * as lockfile from "proper-lockfile";
|
|
4986
|
+
|
|
4987
|
+
// src/utils/lock-path.ts
|
|
3865
4988
|
import { createHash } from "crypto";
|
|
3866
|
-
import * as
|
|
3867
|
-
import * as
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
4989
|
+
import * as os from "os";
|
|
4990
|
+
import * as path9 from "path";
|
|
4991
|
+
function getCloneModeLockTarget(config) {
|
|
4992
|
+
const name = config.name;
|
|
4993
|
+
const configDir = config.__configFileDir;
|
|
4994
|
+
const hash = createHash("sha256").update(path9.resolve(config.worktreeDir)).digest("hex").slice(0, 16);
|
|
4995
|
+
if (configDir) {
|
|
4996
|
+
return {
|
|
4997
|
+
dir: path9.join(configDir, ".sync-worktrees-state"),
|
|
4998
|
+
file: `${sanitizeNameForPath(name ?? "repo", "clone-mode lock name")}-${hash}.lock`
|
|
4999
|
+
};
|
|
3875
5000
|
}
|
|
3876
|
-
|
|
3877
|
-
|
|
5001
|
+
const stateBase = process.env.XDG_STATE_HOME && process.env.XDG_STATE_HOME.length > 0 ? process.env.XDG_STATE_HOME : path9.join(os.homedir(), ".cache");
|
|
5002
|
+
const dir = path9.join(stateBase, "sync-worktrees", "locks");
|
|
5003
|
+
return { dir, file: `${hash}.lock` };
|
|
5004
|
+
}
|
|
5005
|
+
|
|
5006
|
+
// src/services/repo-operation-lock.ts
|
|
5007
|
+
var RepoOperationLock = class {
|
|
5008
|
+
constructor(config, gitService, logger = Logger.createDefault()) {
|
|
5009
|
+
this.config = config;
|
|
5010
|
+
this.gitService = gitService;
|
|
5011
|
+
this.logger = logger;
|
|
3878
5012
|
}
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
return absolute;
|
|
3887
|
-
}
|
|
3888
|
-
missing.unshift(path7.basename(current));
|
|
3889
|
-
current = parent;
|
|
5013
|
+
updateLogger(logger) {
|
|
5014
|
+
this.logger = logger;
|
|
5015
|
+
}
|
|
5016
|
+
async acquire() {
|
|
5017
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
5018
|
+
return async () => {
|
|
5019
|
+
};
|
|
3890
5020
|
}
|
|
5021
|
+
if (resolveMode(this.config) === REPOSITORY_MODES.CLONE) {
|
|
5022
|
+
return this.acquireCloneModeLock();
|
|
5023
|
+
}
|
|
5024
|
+
return this.acquireWorktreeModeLock();
|
|
5025
|
+
}
|
|
5026
|
+
async acquireCloneModeLock() {
|
|
5027
|
+
const target = getCloneModeLockTarget(this.config);
|
|
5028
|
+
const lockTarget = path10.join(target.dir, target.file);
|
|
3891
5029
|
try {
|
|
3892
|
-
|
|
5030
|
+
await fs7.mkdir(target.dir, { recursive: true });
|
|
5031
|
+
await fs7.writeFile(lockTarget, "", { flag: "a" });
|
|
3893
5032
|
} catch {
|
|
3894
|
-
return
|
|
5033
|
+
return null;
|
|
3895
5034
|
}
|
|
5035
|
+
return this.lockPath(lockTarget);
|
|
3896
5036
|
}
|
|
3897
|
-
|
|
3898
|
-
const
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
}
|
|
3904
|
-
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3905
|
-
const resolved = this.resolveRealPath(worktreePath);
|
|
3906
|
-
const resolvedBase = this.resolveRealPath(worktreeBaseDir);
|
|
3907
|
-
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3908
|
-
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
5037
|
+
async acquireWorktreeModeLock() {
|
|
5038
|
+
const barePath = this.gitService.getBareRepoPath();
|
|
5039
|
+
try {
|
|
5040
|
+
await fs7.mkdir(barePath, { recursive: true });
|
|
5041
|
+
} catch {
|
|
5042
|
+
return null;
|
|
3909
5043
|
}
|
|
3910
|
-
return
|
|
3911
|
-
}
|
|
3912
|
-
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3913
|
-
const resolved = this.resolveRealPath(targetPath);
|
|
3914
|
-
const resolvedBase = this.resolveRealPath(baseDir);
|
|
3915
|
-
return this.isResolvedPathInsideBase(resolved, resolvedBase);
|
|
5044
|
+
return this.lockPath(barePath);
|
|
3916
5045
|
}
|
|
3917
|
-
|
|
3918
|
-
|
|
5046
|
+
async lockPath(lockTarget) {
|
|
5047
|
+
try {
|
|
5048
|
+
return await lockfile.lock(lockTarget, {
|
|
5049
|
+
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
5050
|
+
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
5051
|
+
retries: 0,
|
|
5052
|
+
realpath: false
|
|
5053
|
+
});
|
|
5054
|
+
} catch (error) {
|
|
5055
|
+
const code = error.code;
|
|
5056
|
+
if (code === "ELOCKED") {
|
|
5057
|
+
return null;
|
|
5058
|
+
}
|
|
5059
|
+
this.logger.warn(
|
|
5060
|
+
`Could not acquire repo lock at '${lockTarget}' (${code ?? "unknown"}: ${getErrorMessage(error)}); skipping.`
|
|
5061
|
+
);
|
|
5062
|
+
return null;
|
|
5063
|
+
}
|
|
3919
5064
|
}
|
|
3920
5065
|
};
|
|
3921
5066
|
|
|
3922
|
-
// src/services/
|
|
3923
|
-
var
|
|
3924
|
-
constructor(config) {
|
|
5067
|
+
// src/services/sync-retry-policy.ts
|
|
5068
|
+
var SyncRetryPolicy = class {
|
|
5069
|
+
constructor(config, gitService, logger) {
|
|
3925
5070
|
this.config = config;
|
|
3926
|
-
this.
|
|
3927
|
-
this.
|
|
5071
|
+
this.gitService = gitService;
|
|
5072
|
+
this.logger = logger;
|
|
3928
5073
|
}
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
syncInProgress = false;
|
|
3932
|
-
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" });
|
|
5074
|
+
updateLogger(logger) {
|
|
5075
|
+
this.logger = logger;
|
|
3938
5076
|
}
|
|
3939
|
-
|
|
3940
|
-
return
|
|
5077
|
+
createContext() {
|
|
5078
|
+
return { lfsSkipEnabled: false };
|
|
3941
5079
|
}
|
|
3942
|
-
|
|
3943
|
-
return
|
|
5080
|
+
createOptions(syncContext) {
|
|
5081
|
+
return {
|
|
5082
|
+
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
5083
|
+
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
5084
|
+
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
5085
|
+
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
5086
|
+
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
5087
|
+
jitterMs: this.config.retry?.jitterMs ?? 0,
|
|
5088
|
+
onRetry: (error, attempt, context) => {
|
|
5089
|
+
const errorMessage = getErrorMessage(error);
|
|
5090
|
+
this.logger.info(`
|
|
5091
|
+
\u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
|
|
5092
|
+
if (context?.isLfsError && !this.config.skipLfs) {
|
|
5093
|
+
this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
|
|
5094
|
+
} else {
|
|
5095
|
+
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
5096
|
+
`);
|
|
5097
|
+
}
|
|
5098
|
+
},
|
|
5099
|
+
lfsRetryHandler: () => {
|
|
5100
|
+
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
5101
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
5102
|
+
this.gitService.setLfsSkipEnabled(true);
|
|
5103
|
+
syncContext.lfsSkipEnabled = true;
|
|
5104
|
+
}
|
|
5105
|
+
}
|
|
5106
|
+
};
|
|
3944
5107
|
}
|
|
3945
|
-
|
|
3946
|
-
|
|
5108
|
+
resetLfsSkipIfNeeded(syncContext) {
|
|
5109
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
5110
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
5111
|
+
}
|
|
3947
5112
|
}
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
5113
|
+
};
|
|
5114
|
+
|
|
5115
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
5116
|
+
import * as fs9 from "fs/promises";
|
|
5117
|
+
import * as path13 from "path";
|
|
5118
|
+
import pLimit from "p-limit";
|
|
5119
|
+
|
|
5120
|
+
// src/utils/date-filter.ts
|
|
5121
|
+
function parseDuration(durationStr) {
|
|
5122
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5123
|
+
if (!match) {
|
|
5124
|
+
return null;
|
|
3951
5125
|
}
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
5126
|
+
const value = parseInt(match[1], 10);
|
|
5127
|
+
const unit = match[2];
|
|
5128
|
+
const multipliers = {
|
|
5129
|
+
h: 60 * 60 * 1e3,
|
|
5130
|
+
// hours
|
|
5131
|
+
d: 24 * 60 * 60 * 1e3,
|
|
5132
|
+
// days
|
|
5133
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
5134
|
+
// weeks
|
|
5135
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
5136
|
+
// months (approximate)
|
|
5137
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
5138
|
+
// years (approximate)
|
|
5139
|
+
};
|
|
5140
|
+
return value * multipliers[unit];
|
|
5141
|
+
}
|
|
5142
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
5143
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
5144
|
+
if (maxAgeMs === null) {
|
|
5145
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
5146
|
+
return branches;
|
|
3955
5147
|
}
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
5148
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
5149
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
5150
|
+
}
|
|
5151
|
+
function formatDuration2(durationStr) {
|
|
5152
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
5153
|
+
if (!match) {
|
|
5154
|
+
return durationStr;
|
|
5155
|
+
}
|
|
5156
|
+
const value = parseInt(match[1], 10);
|
|
5157
|
+
const unit = match[2];
|
|
5158
|
+
const unitNames = {
|
|
5159
|
+
h: value === 1 ? "hour" : "hours",
|
|
5160
|
+
d: value === 1 ? "day" : "days",
|
|
5161
|
+
w: value === 1 ? "week" : "weeks",
|
|
5162
|
+
m: value === 1 ? "month" : "months",
|
|
5163
|
+
y: value === 1 ? "year" : "years"
|
|
5164
|
+
};
|
|
5165
|
+
return `${value} ${unitNames[unit]}`;
|
|
5166
|
+
}
|
|
5167
|
+
|
|
5168
|
+
// src/services/path-resolution.service.ts
|
|
5169
|
+
import { createHash as createHash2 } from "crypto";
|
|
5170
|
+
import * as fs8 from "fs";
|
|
5171
|
+
import * as path11 from "path";
|
|
5172
|
+
var BRANCH_STEM_MAX = 80;
|
|
5173
|
+
var BRANCH_HASH_LEN = 8;
|
|
5174
|
+
var PathResolutionService = class {
|
|
5175
|
+
sanitizeBranchName(branchName) {
|
|
5176
|
+
const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
|
|
5177
|
+
const hash = createHash2("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
|
|
5178
|
+
return `${stem}-${hash}`;
|
|
5179
|
+
}
|
|
5180
|
+
getBranchWorktreePath(worktreeDir, branchName) {
|
|
5181
|
+
return path11.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
5182
|
+
}
|
|
5183
|
+
resolveRealPath(inputPath) {
|
|
5184
|
+
const absolute = path11.resolve(inputPath);
|
|
5185
|
+
const missing = [];
|
|
5186
|
+
let current = absolute;
|
|
5187
|
+
while (!fs8.existsSync(current)) {
|
|
5188
|
+
const parent = path11.dirname(current);
|
|
5189
|
+
if (parent === current) {
|
|
5190
|
+
return absolute;
|
|
3975
5191
|
}
|
|
5192
|
+
missing.unshift(path11.basename(current));
|
|
5193
|
+
current = parent;
|
|
3976
5194
|
}
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
listener(event);
|
|
3982
|
-
} catch {
|
|
3983
|
-
}
|
|
5195
|
+
try {
|
|
5196
|
+
return path11.join(fs8.realpathSync(current), ...missing);
|
|
5197
|
+
} catch {
|
|
5198
|
+
return absolute;
|
|
3984
5199
|
}
|
|
3985
5200
|
}
|
|
3986
|
-
|
|
3987
|
-
const
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
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;
|
|
5201
|
+
isResolvedPathInsideBase(resolved, resolvedBase) {
|
|
5202
|
+
const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
5203
|
+
const a = fold(resolved);
|
|
5204
|
+
const b = fold(resolvedBase);
|
|
5205
|
+
if (a === b) return true;
|
|
5206
|
+
return a.length > b.length && a.charAt(b.length) === path11.sep && a.startsWith(b);
|
|
4016
5207
|
}
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
if (typeof this.gitService.getBareRepoPath !== "function") {
|
|
4023
|
-
return async () => {
|
|
4024
|
-
};
|
|
5208
|
+
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
5209
|
+
const resolved = this.resolveRealPath(worktreePath);
|
|
5210
|
+
const resolvedBase = this.resolveRealPath(worktreeBaseDir);
|
|
5211
|
+
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
5212
|
+
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
4025
5213
|
}
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
5214
|
+
return path11.relative(resolvedBase, resolved);
|
|
5215
|
+
}
|
|
5216
|
+
isPathInsideBaseDir(targetPath, baseDir) {
|
|
5217
|
+
const resolved = this.resolveRealPath(targetPath);
|
|
5218
|
+
const resolvedBase = this.resolveRealPath(baseDir);
|
|
5219
|
+
return this.isResolvedPathInsideBase(resolved, resolvedBase);
|
|
5220
|
+
}
|
|
5221
|
+
extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
|
|
5222
|
+
return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
|
|
5223
|
+
}
|
|
5224
|
+
};
|
|
5225
|
+
|
|
5226
|
+
// src/services/worktree-sync-planner.ts
|
|
5227
|
+
import * as path12 from "path";
|
|
5228
|
+
function createWorktreeSyncPlan(inventory, options = {}) {
|
|
5229
|
+
return {
|
|
5230
|
+
create: planCreateActions(inventory, options),
|
|
5231
|
+
prune: planPruneActions(inventory),
|
|
5232
|
+
update: options.updateExistingWorktrees === false ? [] : planUpdateActions(inventory),
|
|
5233
|
+
sparse: planSparseActions(inventory, options.sparseCheckout),
|
|
5234
|
+
warnings: []
|
|
5235
|
+
};
|
|
5236
|
+
}
|
|
5237
|
+
function planCreateActions(inventory, options = {}) {
|
|
5238
|
+
const pathResolution = options.pathResolution ?? new PathResolutionService();
|
|
5239
|
+
const existingBranches = new Set(inventory.existingWorktrees.map((w) => w.branch));
|
|
5240
|
+
const newBranches = inventory.remoteBranches.filter(
|
|
5241
|
+
(branch) => !existingBranches.has(branch) && branch !== inventory.defaultBranch
|
|
5242
|
+
);
|
|
5243
|
+
const reservedPaths = /* @__PURE__ */ new Map();
|
|
5244
|
+
for (const worktree of inventory.existingWorktrees) {
|
|
5245
|
+
reservedPaths.set(path12.resolve(worktree.path), worktree.branch);
|
|
5246
|
+
}
|
|
5247
|
+
const actions = [];
|
|
5248
|
+
for (const branch of newBranches) {
|
|
5249
|
+
const worktreePath = pathResolution.getBranchWorktreePath(inventory.worktreeDir, branch);
|
|
5250
|
+
const resolved = path12.resolve(worktreePath);
|
|
5251
|
+
const conflictingBranch = reservedPaths.get(resolved);
|
|
5252
|
+
if (conflictingBranch && conflictingBranch !== branch) {
|
|
5253
|
+
actions.push({
|
|
5254
|
+
kind: "skip-create",
|
|
5255
|
+
branch,
|
|
5256
|
+
path: worktreePath,
|
|
5257
|
+
reason: "path-collision",
|
|
5258
|
+
conflictingBranch
|
|
4034
5259
|
});
|
|
4035
|
-
|
|
4036
|
-
} catch (error) {
|
|
4037
|
-
const code = error.code;
|
|
4038
|
-
if (code === "ELOCKED") {
|
|
4039
|
-
return null;
|
|
4040
|
-
}
|
|
4041
|
-
throw error;
|
|
5260
|
+
continue;
|
|
4042
5261
|
}
|
|
5262
|
+
reservedPaths.set(resolved, branch);
|
|
5263
|
+
actions.push({ kind: "create", branch, path: worktreePath });
|
|
4043
5264
|
}
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
5265
|
+
return actions;
|
|
5266
|
+
}
|
|
5267
|
+
function planPruneActions(inventory) {
|
|
5268
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
5269
|
+
return inventory.existingWorktrees.filter((worktree) => !remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "check-prune", branch: worktree.branch, path: worktree.path }));
|
|
5270
|
+
}
|
|
5271
|
+
function planUpdateActions(inventory) {
|
|
5272
|
+
const remoteBranches = new Set(inventory.remoteBranches);
|
|
5273
|
+
return inventory.existingWorktrees.filter((worktree) => remoteBranches.has(worktree.branch)).map((worktree) => ({ kind: "update-candidate", branch: worktree.branch, path: worktree.path }));
|
|
5274
|
+
}
|
|
5275
|
+
function planSparseActions(inventory, sparseCheckout) {
|
|
5276
|
+
if (!sparseCheckout) {
|
|
5277
|
+
return [];
|
|
5278
|
+
}
|
|
5279
|
+
return inventory.existingWorktrees.map((worktree) => ({
|
|
5280
|
+
kind: "check-sparse",
|
|
5281
|
+
branch: worktree.branch,
|
|
5282
|
+
path: worktree.path
|
|
5283
|
+
}));
|
|
5284
|
+
}
|
|
5285
|
+
|
|
5286
|
+
// src/services/worktree-mode-sync-runner.ts
|
|
5287
|
+
var WorktreeModeSyncRunner = class {
|
|
5288
|
+
constructor(config, gitService, logger, progressEmitter) {
|
|
5289
|
+
this.config = config;
|
|
5290
|
+
this.gitService = gitService;
|
|
5291
|
+
this.logger = logger;
|
|
5292
|
+
this.progressEmitter = progressEmitter;
|
|
5293
|
+
}
|
|
5294
|
+
pathResolution = new PathResolutionService();
|
|
5295
|
+
updateLogger(logger) {
|
|
5296
|
+
this.logger = logger;
|
|
4070
5297
|
}
|
|
4071
|
-
async runSyncAttempt(phaseTimer, syncContext) {
|
|
5298
|
+
async runSyncAttempt(phaseTimer, syncContext, outcome) {
|
|
4072
5299
|
await this.gitService.pruneWorktrees();
|
|
4073
5300
|
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
4074
5301
|
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
4075
|
-
await
|
|
5302
|
+
await fs9.mkdir(this.config.worktreeDir, { recursive: true });
|
|
4076
5303
|
const worktrees = await this.gitService.getWorktrees();
|
|
4077
5304
|
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
4078
5305
|
await this.cleanupOrphanedDirectories(worktrees);
|
|
4079
|
-
|
|
4080
|
-
|
|
5306
|
+
const syncPlan = createWorktreeSyncPlan(
|
|
5307
|
+
{
|
|
5308
|
+
remoteBranches,
|
|
5309
|
+
defaultBranch,
|
|
5310
|
+
existingWorktrees: worktrees,
|
|
5311
|
+
worktreeDir: this.config.worktreeDir
|
|
5312
|
+
},
|
|
5313
|
+
{
|
|
5314
|
+
pathResolution: this.pathResolution,
|
|
5315
|
+
updateExistingWorktrees: this.config.updateExistingWorktrees !== false,
|
|
5316
|
+
sparseCheckout: this.config.sparseCheckout
|
|
5317
|
+
}
|
|
5318
|
+
);
|
|
5319
|
+
await this.createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome);
|
|
5320
|
+
await this.pruneOldWorktreesWithTiming(syncPlan.prune, phaseTimer, outcome);
|
|
4081
5321
|
if (this.config.updateExistingWorktrees !== false) {
|
|
4082
|
-
await this.updateExistingWorktreesWithTiming(
|
|
5322
|
+
await this.updateExistingWorktreesWithTiming(syncPlan.update, phaseTimer, outcome);
|
|
4083
5323
|
}
|
|
4084
5324
|
if (this.config.sparseCheckout) {
|
|
4085
|
-
await this.reapplySparseCheckout(
|
|
5325
|
+
await this.reapplySparseCheckout(syncPlan.sparse, outcome);
|
|
4086
5326
|
}
|
|
4087
5327
|
await this.finalizeSyncAttempt(phaseTimer);
|
|
4088
5328
|
}
|
|
4089
|
-
async reapplySparseCheckout(
|
|
5329
|
+
async reapplySparseCheckout(actions, outcome) {
|
|
4090
5330
|
const sparseConfig = this.config.sparseCheckout;
|
|
4091
5331
|
if (!sparseConfig) return;
|
|
4092
5332
|
this.logger.info("Step 5: Reconciling sparse-checkout patterns on existing worktrees...");
|
|
@@ -4094,32 +5334,44 @@ var WorktreeSyncService = class {
|
|
|
4094
5334
|
const desired = sparseService.buildPatterns(sparseConfig);
|
|
4095
5335
|
const limit = pLimit(this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
4096
5336
|
await Promise.all(
|
|
4097
|
-
|
|
4098
|
-
(
|
|
5337
|
+
actions.map(
|
|
5338
|
+
(action) => limit(async () => {
|
|
5339
|
+
if (action.kind !== "check-sparse") return;
|
|
4099
5340
|
try {
|
|
4100
5341
|
try {
|
|
4101
|
-
await
|
|
5342
|
+
await fs9.access(action.path);
|
|
4102
5343
|
} catch {
|
|
4103
5344
|
return;
|
|
4104
5345
|
}
|
|
4105
|
-
const current = await sparseService.readCurrent(
|
|
5346
|
+
const current = await sparseService.readCurrent(action.path);
|
|
4106
5347
|
if (current !== null && sparseService.patternsEqual(current, desired)) return;
|
|
4107
5348
|
if (sparseService.isNarrowing(current, desired)) {
|
|
4108
|
-
const status = await this.gitService.getFullWorktreeStatus(
|
|
5349
|
+
const status = await this.gitService.getFullWorktreeStatus(action.path, false);
|
|
4109
5350
|
if (!status.canRemove) {
|
|
4110
5351
|
this.logger.warn(
|
|
4111
|
-
` - Skipping sparse-checkout narrowing for '${
|
|
5352
|
+
` - Skipping sparse-checkout narrowing for '${action.branch}': ${status.reasons.join(", ")}.`
|
|
4112
5353
|
);
|
|
5354
|
+
outcome.recordSkipped("sparse-checkout", "sparse_narrowing_unsafe", {
|
|
5355
|
+
branch: action.branch,
|
|
5356
|
+
path: action.path,
|
|
5357
|
+
message: status.reasons.join(", ")
|
|
5358
|
+
});
|
|
4113
5359
|
return;
|
|
4114
5360
|
}
|
|
4115
5361
|
}
|
|
4116
|
-
await sparseService.applyToWorktree(
|
|
4117
|
-
await this.gitService.checkoutHead(
|
|
4118
|
-
this.logger.info(` - \u2705 Sparse-checkout updated for '${
|
|
5362
|
+
await sparseService.applyToWorktree(action.path, sparseConfig);
|
|
5363
|
+
await this.gitService.checkoutHead(action.path);
|
|
5364
|
+
this.logger.info(` - \u2705 Sparse-checkout updated for '${action.branch}'`);
|
|
5365
|
+
outcome.recordUpdated(action.branch, action.path, "sparse_checkout");
|
|
4119
5366
|
} catch (error) {
|
|
4120
5367
|
this.logger.warn(
|
|
4121
|
-
` - \u26A0\uFE0F Failed to update sparse-checkout for '${
|
|
5368
|
+
` - \u26A0\uFE0F Failed to update sparse-checkout for '${action.branch}': ${getErrorMessage(error)}`
|
|
4122
5369
|
);
|
|
5370
|
+
outcome.recordFailed("sparse-checkout", getErrorMessage(error), {
|
|
5371
|
+
reason: "sparse_checkout_failed",
|
|
5372
|
+
branch: action.branch,
|
|
5373
|
+
path: action.path
|
|
5374
|
+
});
|
|
4123
5375
|
}
|
|
4124
5376
|
})
|
|
4125
5377
|
)
|
|
@@ -4128,7 +5380,7 @@ var WorktreeSyncService = class {
|
|
|
4128
5380
|
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
4129
5381
|
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
4130
5382
|
phaseTimer.startPhase("Phase 1: Fetch");
|
|
4131
|
-
this.
|
|
5383
|
+
this.progressEmitter.emit({ phase: "fetch", message: "Fetching latest data from remote" });
|
|
4132
5384
|
try {
|
|
4133
5385
|
await this.gitService.fetchAll();
|
|
4134
5386
|
} catch (fetchError) {
|
|
@@ -4173,7 +5425,7 @@ var WorktreeSyncService = class {
|
|
|
4173
5425
|
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
4174
5426
|
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
4175
5427
|
this.logger.info(
|
|
4176
|
-
`After filtering by age (${
|
|
5428
|
+
`After filtering by age (${formatDuration2(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
4177
5429
|
);
|
|
4178
5430
|
if (filteredByName.length > remoteBranches.length) {
|
|
4179
5431
|
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
@@ -4192,45 +5444,38 @@ var WorktreeSyncService = class {
|
|
|
4192
5444
|
}
|
|
4193
5445
|
async finalizeSyncAttempt(phaseTimer) {
|
|
4194
5446
|
phaseTimer.startPhase("Phase 5: Cleanup");
|
|
4195
|
-
this.
|
|
5447
|
+
this.progressEmitter.emit({ phase: "cleanup", message: "Pruning worktree metadata" });
|
|
4196
5448
|
await this.gitService.pruneWorktrees();
|
|
4197
5449
|
this.logger.info("Step 5: Pruned worktree metadata.");
|
|
4198
5450
|
phaseTimer.endPhase();
|
|
4199
5451
|
}
|
|
4200
|
-
async createNewWorktreesWithTiming(
|
|
5452
|
+
async createNewWorktreesWithTiming(syncPlan, phaseTimer, outcome) {
|
|
4201
5453
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
4202
5454
|
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);
|
|
5455
|
+
this.progressEmitter.emit({ phase: "create", message: "Creating worktrees for new branches" });
|
|
5456
|
+
await this.createNewWorktrees(syncPlan.create, outcome);
|
|
5457
|
+
phaseTimer.setPhaseCount("Phase 2: Create", syncPlan.create.length);
|
|
4208
5458
|
phaseTimer.endPhase();
|
|
4209
5459
|
}
|
|
4210
|
-
async createNewWorktrees(
|
|
4211
|
-
|
|
4212
|
-
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
4213
|
-
if (newBranches.length === 0) {
|
|
5460
|
+
async createNewWorktrees(actions, outcome) {
|
|
5461
|
+
if (actions.length === 0) {
|
|
4214
5462
|
this.logger.info("Step 2: No new branches to create worktrees for.");
|
|
4215
5463
|
return;
|
|
4216
5464
|
}
|
|
4217
|
-
const reservedPaths = /* @__PURE__ */ new Map();
|
|
4218
|
-
for (const w of worktrees) {
|
|
4219
|
-
reservedPaths.set(path8.resolve(w.path), w.branch);
|
|
4220
|
-
}
|
|
4221
5465
|
const plan = [];
|
|
4222
|
-
for (const
|
|
4223
|
-
|
|
4224
|
-
const resolved = path8.resolve(worktreePath);
|
|
4225
|
-
const conflict = reservedPaths.get(resolved);
|
|
4226
|
-
if (conflict && conflict !== branchName) {
|
|
5466
|
+
for (const action of actions) {
|
|
5467
|
+
if (action.kind === "skip-create") {
|
|
4227
5468
|
this.logger.error(
|
|
4228
|
-
` \u274C Skipping '${
|
|
5469
|
+
` \u274C Skipping '${action.branch}': sanitized worktree path '${action.path}' collides with existing branch '${action.conflictingBranch}'.`
|
|
4229
5470
|
);
|
|
5471
|
+
outcome.recordSkipped("branch", "path_collision", {
|
|
5472
|
+
branch: action.branch,
|
|
5473
|
+
path: action.path,
|
|
5474
|
+
message: `Path collides with existing branch '${action.conflictingBranch}'`
|
|
5475
|
+
});
|
|
4230
5476
|
continue;
|
|
4231
5477
|
}
|
|
4232
|
-
|
|
4233
|
-
plan.push({ branchName, worktreePath });
|
|
5478
|
+
plan.push({ branchName: action.branch, worktreePath: action.path });
|
|
4234
5479
|
}
|
|
4235
5480
|
this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
|
|
4236
5481
|
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
@@ -4241,8 +5486,14 @@ var WorktreeSyncService = class {
|
|
|
4241
5486
|
try {
|
|
4242
5487
|
await this.gitService.addWorktree(branchName, worktreePath);
|
|
4243
5488
|
this.logger.info(` \u2705 Created worktree for '${branchName}'`);
|
|
5489
|
+
outcome.recordCreated(branchName, worktreePath);
|
|
4244
5490
|
} catch (error) {
|
|
4245
5491
|
this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
|
|
5492
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5493
|
+
reason: "create_failed",
|
|
5494
|
+
branch: branchName,
|
|
5495
|
+
path: worktreePath
|
|
5496
|
+
});
|
|
4246
5497
|
throw error;
|
|
4247
5498
|
}
|
|
4248
5499
|
})
|
|
@@ -4251,23 +5502,21 @@ var WorktreeSyncService = class {
|
|
|
4251
5502
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
4252
5503
|
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
4253
5504
|
}
|
|
4254
|
-
async pruneOldWorktreesWithTiming(
|
|
5505
|
+
async pruneOldWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
4255
5506
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
4256
5507
|
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
4257
|
-
this.
|
|
4258
|
-
await this.pruneOldWorktrees(
|
|
4259
|
-
|
|
4260
|
-
phaseTimer.setPhaseCount("Phase 3: Prune", deletedWorktrees.length);
|
|
5508
|
+
this.progressEmitter.emit({ phase: "prune", message: "Pruning stale worktrees" });
|
|
5509
|
+
await this.pruneOldWorktrees(actions, outcome);
|
|
5510
|
+
phaseTimer.setPhaseCount("Phase 3: Prune", actions.length);
|
|
4261
5511
|
phaseTimer.endPhase();
|
|
4262
5512
|
}
|
|
4263
|
-
async pruneOldWorktrees(
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
this.logger.info(`Step 3: Checking ${deletedWorktrees.length} stale worktrees to prune...`);
|
|
5513
|
+
async pruneOldWorktrees(actions, outcome) {
|
|
5514
|
+
if (actions.length > 0) {
|
|
5515
|
+
this.logger.info(`Step 3: Checking ${actions.length} stale worktrees to prune...`);
|
|
4267
5516
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
4268
5517
|
const limit = pLimit(maxConcurrent);
|
|
4269
5518
|
const statusResults = await Promise.allSettled(
|
|
4270
|
-
|
|
5519
|
+
actions.map(
|
|
4271
5520
|
({ branch: branchName, path: worktreePath }) => limit(async () => {
|
|
4272
5521
|
const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
|
|
4273
5522
|
return { branchName, worktreePath, status };
|
|
@@ -4290,6 +5539,10 @@ var WorktreeSyncService = class {
|
|
|
4290
5539
|
const branchName = result.reason?.branchName ?? "unknown";
|
|
4291
5540
|
this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
|
|
4292
5541
|
this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
|
|
5542
|
+
outcome.recordSkipped("worktree", "prune_status_check_failed", {
|
|
5543
|
+
branch: branchName,
|
|
5544
|
+
message: getErrorMessage(result.reason)
|
|
5545
|
+
});
|
|
4293
5546
|
}
|
|
4294
5547
|
}
|
|
4295
5548
|
if (toRemove.length > 0) {
|
|
@@ -4305,12 +5558,23 @@ var WorktreeSyncService = class {
|
|
|
4305
5558
|
this.logger.warn(
|
|
4306
5559
|
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
4307
5560
|
);
|
|
5561
|
+
outcome.recordSkipped("worktree", "prune_status_changed", {
|
|
5562
|
+
branch: branchName,
|
|
5563
|
+
path: worktreePath,
|
|
5564
|
+
message: recheck.reasons.join(", ")
|
|
5565
|
+
});
|
|
4308
5566
|
return;
|
|
4309
5567
|
}
|
|
4310
5568
|
await this.gitService.removeWorktree(worktreePath);
|
|
4311
5569
|
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
5570
|
+
outcome.recordRemoved(branchName, worktreePath);
|
|
4312
5571
|
} catch (error) {
|
|
4313
5572
|
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
5573
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5574
|
+
reason: "remove_failed",
|
|
5575
|
+
branch: branchName,
|
|
5576
|
+
path: worktreePath
|
|
5577
|
+
});
|
|
4314
5578
|
throw error;
|
|
4315
5579
|
}
|
|
4316
5580
|
})
|
|
@@ -4323,6 +5587,11 @@ var WorktreeSyncService = class {
|
|
|
4323
5587
|
this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
|
|
4324
5588
|
}
|
|
4325
5589
|
for (const { branchName, worktreePath, status } of toSkip) {
|
|
5590
|
+
outcome.recordSkipped("worktree", "unsafe_to_remove", {
|
|
5591
|
+
branch: branchName,
|
|
5592
|
+
path: worktreePath,
|
|
5593
|
+
message: status.reasons.join(", ")
|
|
5594
|
+
});
|
|
4326
5595
|
if (status.upstreamGone && status.hasUnpushedCommits) {
|
|
4327
5596
|
this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
|
|
4328
5597
|
this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
|
|
@@ -4415,53 +5684,52 @@ var WorktreeSyncService = class {
|
|
|
4415
5684
|
this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
|
|
4416
5685
|
}
|
|
4417
5686
|
}
|
|
4418
|
-
async updateExistingWorktreesWithTiming(
|
|
5687
|
+
async updateExistingWorktreesWithTiming(actions, phaseTimer, outcome) {
|
|
4419
5688
|
const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
4420
5689
|
phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
|
|
4421
|
-
this.
|
|
4422
|
-
await this.updateExistingWorktrees(
|
|
4423
|
-
|
|
4424
|
-
phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
|
|
5690
|
+
this.progressEmitter.emit({ phase: "update", message: "Updating existing worktrees" });
|
|
5691
|
+
await this.updateExistingWorktrees(actions, outcome);
|
|
5692
|
+
phaseTimer.setPhaseCount("Phase 4: Update", actions.length);
|
|
4425
5693
|
phaseTimer.endPhase();
|
|
4426
5694
|
}
|
|
4427
|
-
async updateExistingWorktrees(
|
|
5695
|
+
async updateExistingWorktrees(actions, outcome) {
|
|
4428
5696
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
4429
|
-
const divergedDir =
|
|
5697
|
+
const divergedDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4430
5698
|
try {
|
|
4431
|
-
const diverged = await
|
|
5699
|
+
const diverged = await fs9.readdir(divergedDir);
|
|
4432
5700
|
if (diverged.length > 0) {
|
|
4433
5701
|
this.logger.info(
|
|
4434
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
5702
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path13.relative(process.cwd(), divergedDir)}`
|
|
4435
5703
|
);
|
|
4436
5704
|
}
|
|
4437
5705
|
} catch {
|
|
4438
5706
|
}
|
|
4439
|
-
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
4440
5707
|
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
4441
5708
|
const limit = pLimit(maxConcurrent);
|
|
4442
5709
|
const checkResults = await Promise.allSettled(
|
|
4443
|
-
|
|
4444
|
-
(
|
|
5710
|
+
actions.map(
|
|
5711
|
+
(action) => limit(async () => {
|
|
5712
|
+
const worktree = { path: action.path, branch: action.branch };
|
|
4445
5713
|
try {
|
|
4446
|
-
await
|
|
5714
|
+
await fs9.access(worktree.path);
|
|
4447
5715
|
} catch {
|
|
4448
|
-
return
|
|
5716
|
+
return { action: "skip", worktree, reason: "missing_worktree_path" };
|
|
4449
5717
|
}
|
|
4450
5718
|
const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
|
|
4451
|
-
if (hasOp) return
|
|
5719
|
+
if (hasOp) return { action: "skip", worktree, reason: "operation_in_progress" };
|
|
4452
5720
|
const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
|
|
4453
|
-
if (!isClean) return
|
|
5721
|
+
if (!isClean) return { action: "skip", worktree, reason: "dirty_worktree" };
|
|
4454
5722
|
const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
|
|
4455
5723
|
if (!canFastForward) {
|
|
4456
5724
|
const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
|
|
4457
5725
|
if (isAhead) {
|
|
4458
5726
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
|
|
4459
|
-
return
|
|
5727
|
+
return { action: "skip", worktree, reason: "local_ahead" };
|
|
4460
5728
|
}
|
|
4461
5729
|
return { action: "diverged", worktree };
|
|
4462
5730
|
}
|
|
4463
5731
|
const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
|
|
4464
|
-
if (!isBehind) return
|
|
5732
|
+
if (!isBehind) return { action: "noop", worktree, reason: "already_up_to_date" };
|
|
4465
5733
|
const sparseCfg = this.config.sparseCheckout;
|
|
4466
5734
|
if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
|
|
4467
5735
|
const sparseService = this.gitService.getSparseCheckoutService();
|
|
@@ -4473,7 +5741,7 @@ var WorktreeSyncService = class {
|
|
|
4473
5741
|
);
|
|
4474
5742
|
if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
|
|
4475
5743
|
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
|
|
4476
|
-
return
|
|
5744
|
+
return { action: "skip", worktree, reason: "outside_sparse_checkout" };
|
|
4477
5745
|
}
|
|
4478
5746
|
}
|
|
4479
5747
|
}
|
|
@@ -4485,13 +5753,25 @@ var WorktreeSyncService = class {
|
|
|
4485
5753
|
const divergedWorktrees = [];
|
|
4486
5754
|
for (const result of checkResults) {
|
|
4487
5755
|
if (result.status === "fulfilled" && result.value) {
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
5756
|
+
switch (result.value.action) {
|
|
5757
|
+
case "update":
|
|
5758
|
+
worktreesToUpdate.push(result.value.worktree);
|
|
5759
|
+
break;
|
|
5760
|
+
case "diverged":
|
|
5761
|
+
divergedWorktrees.push(result.value.worktree);
|
|
5762
|
+
break;
|
|
5763
|
+
case "noop":
|
|
5764
|
+
outcome.recordNoop("worktree", result.value.reason, result.value.worktree);
|
|
5765
|
+
break;
|
|
5766
|
+
case "skip":
|
|
5767
|
+
outcome.recordSkipped("worktree", result.value.reason, result.value.worktree);
|
|
5768
|
+
break;
|
|
4492
5769
|
}
|
|
4493
5770
|
} else if (result.status === "rejected") {
|
|
4494
5771
|
this.logger.error(` - Error checking worktree:`, result.reason);
|
|
5772
|
+
outcome.recordSkipped("worktree", "update_check_failed", {
|
|
5773
|
+
message: getErrorMessage(result.reason)
|
|
5774
|
+
});
|
|
4495
5775
|
}
|
|
4496
5776
|
}
|
|
4497
5777
|
const updateLimit = pLimit(
|
|
@@ -4505,6 +5785,7 @@ var WorktreeSyncService = class {
|
|
|
4505
5785
|
this.logger.info(` - Updating worktree '${worktree.branch}'...`);
|
|
4506
5786
|
await this.gitService.updateWorktree(worktree.path);
|
|
4507
5787
|
this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
|
|
5788
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "fast_forward");
|
|
4508
5789
|
} catch (error) {
|
|
4509
5790
|
const errorMessage = getErrorMessage(error);
|
|
4510
5791
|
if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
|
|
@@ -4512,13 +5793,23 @@ var WorktreeSyncService = class {
|
|
|
4512
5793
|
` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
|
|
4513
5794
|
);
|
|
4514
5795
|
try {
|
|
4515
|
-
await this.handleDivergedBranch(worktree);
|
|
5796
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
4516
5797
|
} catch (divergedError) {
|
|
4517
5798
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
|
|
5799
|
+
outcome.recordFailed("worktree", getErrorMessage(divergedError), {
|
|
5800
|
+
reason: "diverged_recovery_failed",
|
|
5801
|
+
branch: worktree.branch,
|
|
5802
|
+
path: worktree.path
|
|
5803
|
+
});
|
|
4518
5804
|
throw divergedError;
|
|
4519
5805
|
}
|
|
4520
5806
|
} else {
|
|
4521
5807
|
this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
|
|
5808
|
+
outcome.recordFailed("worktree", errorMessage, {
|
|
5809
|
+
reason: "update_failed",
|
|
5810
|
+
branch: worktree.branch,
|
|
5811
|
+
path: worktree.path
|
|
5812
|
+
});
|
|
4522
5813
|
throw error;
|
|
4523
5814
|
}
|
|
4524
5815
|
}
|
|
@@ -4530,9 +5821,14 @@ var WorktreeSyncService = class {
|
|
|
4530
5821
|
mutationTasks.push(
|
|
4531
5822
|
updateLimit(async () => {
|
|
4532
5823
|
try {
|
|
4533
|
-
await this.handleDivergedBranch(worktree);
|
|
5824
|
+
await this.handleDivergedBranch(worktree, outcome);
|
|
4534
5825
|
} catch (error) {
|
|
4535
5826
|
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
|
|
5827
|
+
outcome.recordFailed("worktree", getErrorMessage(error), {
|
|
5828
|
+
reason: "diverged_recovery_failed",
|
|
5829
|
+
branch: worktree.branch,
|
|
5830
|
+
path: worktree.path
|
|
5831
|
+
});
|
|
4536
5832
|
throw error;
|
|
4537
5833
|
}
|
|
4538
5834
|
return { type: "diverged", branch: worktree.branch };
|
|
@@ -4555,13 +5851,13 @@ var WorktreeSyncService = class {
|
|
|
4555
5851
|
}
|
|
4556
5852
|
async cleanupOrphanedDirectories(worktrees) {
|
|
4557
5853
|
try {
|
|
4558
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
4559
|
-
const allDirs = await
|
|
5854
|
+
const worktreeRelativePaths = worktrees.map((w) => path13.relative(this.config.worktreeDir, w.path));
|
|
5855
|
+
const allDirs = await fs9.readdir(this.config.worktreeDir);
|
|
4560
5856
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
4561
5857
|
const orphanedDirs = [];
|
|
4562
5858
|
for (const dir of regularDirs) {
|
|
4563
5859
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
4564
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
5860
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path13.sep);
|
|
4565
5861
|
});
|
|
4566
5862
|
if (!isPartOfWorktree) {
|
|
4567
5863
|
orphanedDirs.push(dir);
|
|
@@ -4570,11 +5866,11 @@ var WorktreeSyncService = class {
|
|
|
4570
5866
|
if (orphanedDirs.length > 0) {
|
|
4571
5867
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
4572
5868
|
for (const dir of orphanedDirs) {
|
|
4573
|
-
const dirPath =
|
|
5869
|
+
const dirPath = path13.join(this.config.worktreeDir, dir);
|
|
4574
5870
|
try {
|
|
4575
|
-
const stat3 = await
|
|
5871
|
+
const stat3 = await fs9.stat(dirPath);
|
|
4576
5872
|
if (stat3.isDirectory()) {
|
|
4577
|
-
await
|
|
5873
|
+
await fs9.rm(dirPath, { recursive: true, force: true });
|
|
4578
5874
|
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
4579
5875
|
}
|
|
4580
5876
|
} catch (error) {
|
|
@@ -4586,13 +5882,14 @@ var WorktreeSyncService = class {
|
|
|
4586
5882
|
this.logger.error("Error during orphaned directory cleanup:", error);
|
|
4587
5883
|
}
|
|
4588
5884
|
}
|
|
4589
|
-
async handleDivergedBranch(worktree) {
|
|
5885
|
+
async handleDivergedBranch(worktree, outcome) {
|
|
4590
5886
|
this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
|
|
4591
5887
|
const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
|
|
4592
5888
|
if (treesIdentical) {
|
|
4593
5889
|
this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
|
|
4594
5890
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
4595
5891
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
5892
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_identical_tree");
|
|
4596
5893
|
} else {
|
|
4597
5894
|
const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
|
|
4598
5895
|
if (!hasLocalChanges) {
|
|
@@ -4601,10 +5898,12 @@ var WorktreeSyncService = class {
|
|
|
4601
5898
|
);
|
|
4602
5899
|
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
4603
5900
|
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
5901
|
+
outcome.recordUpdated(worktree.branch, worktree.path, "reset_no_local_changes");
|
|
4604
5902
|
} else {
|
|
4605
5903
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
4606
5904
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
4607
|
-
const relativePath =
|
|
5905
|
+
const relativePath = path13.relative(process.cwd(), divergedPath);
|
|
5906
|
+
outcome.recordPreservedDiverged(worktree.branch, worktree.path, divergedPath);
|
|
4608
5907
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
4609
5908
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
4610
5909
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -4628,19 +5927,19 @@ var WorktreeSyncService = class {
|
|
|
4628
5927
|
}
|
|
4629
5928
|
}
|
|
4630
5929
|
async divergeWorktree(worktreePath, branchName) {
|
|
4631
|
-
const divergedBaseDir =
|
|
5930
|
+
const divergedBaseDir = path13.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4632
5931
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4633
5932
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4634
5933
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4635
5934
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4636
|
-
const divergedPath =
|
|
4637
|
-
await
|
|
5935
|
+
const divergedPath = path13.join(divergedBaseDir, divergedName);
|
|
5936
|
+
await fs9.mkdir(divergedBaseDir, { recursive: true });
|
|
4638
5937
|
try {
|
|
4639
|
-
await
|
|
5938
|
+
await fs9.rename(worktreePath, divergedPath);
|
|
4640
5939
|
} catch (err) {
|
|
4641
5940
|
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
4642
|
-
await
|
|
4643
|
-
await
|
|
5941
|
+
await fs9.cp(worktreePath, divergedPath, { recursive: true });
|
|
5942
|
+
await fs9.rm(worktreePath, { recursive: true, force: true });
|
|
4644
5943
|
} else {
|
|
4645
5944
|
throw err;
|
|
4646
5945
|
}
|
|
@@ -4659,89 +5958,194 @@ var WorktreeSyncService = class {
|
|
|
4659
5958
|
|
|
4660
5959
|
Original worktree location: ${worktreePath}`
|
|
4661
5960
|
};
|
|
4662
|
-
await
|
|
4663
|
-
|
|
5961
|
+
await fs9.writeFile(
|
|
5962
|
+
path13.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4664
5963
|
JSON.stringify(metadata, null, 2)
|
|
4665
5964
|
);
|
|
4666
5965
|
return divergedPath;
|
|
4667
5966
|
}
|
|
4668
5967
|
};
|
|
4669
5968
|
|
|
4670
|
-
// src/services/
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
skipped: [],
|
|
4692
|
-
errors: []
|
|
4693
|
-
};
|
|
4694
|
-
if (!patterns || patterns.length === 0) {
|
|
4695
|
-
return result;
|
|
5969
|
+
// src/services/worktree-sync.service.ts
|
|
5970
|
+
var WorktreeSyncService = class {
|
|
5971
|
+
constructor(config) {
|
|
5972
|
+
this.config = config;
|
|
5973
|
+
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
5974
|
+
this.gitService = new GitService(config, this.logger, (event) => this.emitProgress(event));
|
|
5975
|
+
this.repoOperationLock = new RepoOperationLock(config, this.gitService, this.logger);
|
|
5976
|
+
this.retryPolicy = new SyncRetryPolicy(config, this.gitService, this.logger);
|
|
5977
|
+
this.worktreeModeSyncRunner = new WorktreeModeSyncRunner(
|
|
5978
|
+
config,
|
|
5979
|
+
this.gitService,
|
|
5980
|
+
this.logger,
|
|
5981
|
+
this.progressEmitter
|
|
5982
|
+
);
|
|
5983
|
+
if (resolveMode(config) === REPOSITORY_MODES.CLONE) {
|
|
5984
|
+
this.cloneSyncService = new CloneSyncService(config, this.gitService, this.logger, {
|
|
5985
|
+
progressEmitter: (event) => this.emitProgress(event),
|
|
5986
|
+
onSkip: (reason) => {
|
|
5987
|
+
this.skipsAccumulator.push(reason);
|
|
5988
|
+
}
|
|
5989
|
+
});
|
|
4696
5990
|
}
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
5991
|
+
}
|
|
5992
|
+
gitService;
|
|
5993
|
+
cloneSyncService = null;
|
|
5994
|
+
logger;
|
|
5995
|
+
syncInProgress = false;
|
|
5996
|
+
progressEmitter = new ProgressEmitter();
|
|
5997
|
+
repoOperationLock;
|
|
5998
|
+
retryPolicy;
|
|
5999
|
+
worktreeModeSyncRunner;
|
|
6000
|
+
skipsAccumulator = [];
|
|
6001
|
+
lastOutcome = null;
|
|
6002
|
+
getRecordedSkips() {
|
|
6003
|
+
return [...this.skipsAccumulator];
|
|
6004
|
+
}
|
|
6005
|
+
clearRecordedSkips() {
|
|
6006
|
+
this.skipsAccumulator = [];
|
|
6007
|
+
}
|
|
6008
|
+
clearPendingInitSkip() {
|
|
6009
|
+
this.cloneSyncService?.clearPendingInitSkip();
|
|
6010
|
+
}
|
|
6011
|
+
getLastOutcome() {
|
|
6012
|
+
return this.lastOutcome;
|
|
6013
|
+
}
|
|
6014
|
+
isCloneMode() {
|
|
6015
|
+
return this.cloneSyncService !== null;
|
|
6016
|
+
}
|
|
6017
|
+
async getWorktrees() {
|
|
6018
|
+
if (this.cloneSyncService) {
|
|
6019
|
+
return this.cloneSyncService.getWorktrees();
|
|
6020
|
+
}
|
|
6021
|
+
return this.gitService.getWorktrees();
|
|
6022
|
+
}
|
|
6023
|
+
async initialize() {
|
|
6024
|
+
if (this.isInitialized()) return;
|
|
6025
|
+
const result = await this.runExclusiveRepoOperation(() => this.initializeUnlocked());
|
|
6026
|
+
if (!result.started) {
|
|
6027
|
+
const reason = result.reason === "in_progress" ? "operation in progress" : "another process holds the lock";
|
|
6028
|
+
this.logger.warn(`\u26A0\uFE0F Initialize skipped: ${reason}`);
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
async initializeUnlocked(outcome) {
|
|
6032
|
+
this.emitProgress({ phase: "initialize", message: "Initializing repository" });
|
|
6033
|
+
if (this.cloneSyncService) {
|
|
6034
|
+
await this.cloneSyncService.initialize(outcome);
|
|
6035
|
+
} else {
|
|
6036
|
+
await this.gitService.initialize();
|
|
6037
|
+
}
|
|
6038
|
+
this.emitProgress({ phase: "initialize", message: "Repository initialized" });
|
|
6039
|
+
}
|
|
6040
|
+
isInitialized() {
|
|
6041
|
+
if (this.cloneSyncService) {
|
|
6042
|
+
return this.cloneSyncService.isInitialized();
|
|
6043
|
+
}
|
|
6044
|
+
return this.gitService.isInitialized();
|
|
6045
|
+
}
|
|
6046
|
+
isSyncInProgress() {
|
|
6047
|
+
return this.syncInProgress;
|
|
6048
|
+
}
|
|
6049
|
+
getGitService() {
|
|
6050
|
+
return this.gitService;
|
|
6051
|
+
}
|
|
6052
|
+
updateLogger(logger) {
|
|
6053
|
+
this.logger = logger;
|
|
6054
|
+
this.gitService.updateLogger(logger);
|
|
6055
|
+
this.cloneSyncService?.updateLogger(logger);
|
|
6056
|
+
this.retryPolicy.updateLogger(logger);
|
|
6057
|
+
this.worktreeModeSyncRunner.updateLogger(logger);
|
|
6058
|
+
this.repoOperationLock.updateLogger(logger);
|
|
6059
|
+
}
|
|
6060
|
+
onProgress(listener) {
|
|
6061
|
+
return this.progressEmitter.onProgress(listener);
|
|
6062
|
+
}
|
|
6063
|
+
async runExclusiveRepoOperation(operation) {
|
|
6064
|
+
if (this.syncInProgress) {
|
|
6065
|
+
this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
|
|
6066
|
+
return { started: false, reason: "in_progress" };
|
|
6067
|
+
}
|
|
6068
|
+
this.syncInProgress = true;
|
|
6069
|
+
let release;
|
|
6070
|
+
try {
|
|
6071
|
+
release = await this.repoOperationLock.acquire();
|
|
6072
|
+
} catch (error) {
|
|
6073
|
+
this.syncInProgress = false;
|
|
6074
|
+
throw error;
|
|
6075
|
+
}
|
|
6076
|
+
if (release === null) {
|
|
6077
|
+
this.syncInProgress = false;
|
|
6078
|
+
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
6079
|
+
return { started: false, reason: "locked" };
|
|
6080
|
+
}
|
|
6081
|
+
try {
|
|
6082
|
+
return { started: true, value: await operation() };
|
|
6083
|
+
} finally {
|
|
4701
6084
|
try {
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
} else {
|
|
4706
|
-
result.skipped.push(relativePath);
|
|
4707
|
-
}
|
|
4708
|
-
} catch (error) {
|
|
4709
|
-
result.errors.push({
|
|
4710
|
-
file: relativePath,
|
|
4711
|
-
error: error instanceof Error ? error.message : String(error)
|
|
4712
|
-
});
|
|
6085
|
+
await release();
|
|
6086
|
+
} catch (releaseError) {
|
|
6087
|
+
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
4713
6088
|
}
|
|
6089
|
+
this.syncInProgress = false;
|
|
4714
6090
|
}
|
|
4715
|
-
return result;
|
|
4716
6091
|
}
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
6092
|
+
emitProgress(event) {
|
|
6093
|
+
this.progressEmitter.emit(event);
|
|
6094
|
+
}
|
|
6095
|
+
async sync() {
|
|
6096
|
+
const result = await this.runExclusiveRepoOperation(async () => {
|
|
6097
|
+
const totalTimer = new Timer();
|
|
6098
|
+
const phaseTimer = new PhaseTimer();
|
|
6099
|
+
const outcome = new SyncOutcomeAccumulator({
|
|
6100
|
+
mode: this.cloneSyncService ? "clone" : "worktree",
|
|
6101
|
+
repoName: this.config.name
|
|
6102
|
+
});
|
|
6103
|
+
const syncContext = this.retryPolicy.createContext();
|
|
6104
|
+
const retryOptions = this.retryPolicy.createOptions(syncContext);
|
|
6105
|
+
let durationMs;
|
|
4720
6106
|
try {
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
6107
|
+
if (!this.isInitialized()) {
|
|
6108
|
+
await this.initializeUnlocked(outcome);
|
|
6109
|
+
}
|
|
6110
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
6111
|
+
const retryOutcomeBaseline = outcome.snapshot();
|
|
6112
|
+
const retryOptionsWithOutcomeReset = {
|
|
6113
|
+
...retryOptions,
|
|
6114
|
+
onRetry: (error, attempt, context) => {
|
|
6115
|
+
outcome.restore(retryOutcomeBaseline);
|
|
6116
|
+
retryOptions.onRetry?.(error, attempt, context);
|
|
6117
|
+
}
|
|
6118
|
+
};
|
|
6119
|
+
const cloneSync = this.cloneSyncService;
|
|
6120
|
+
if (cloneSync) {
|
|
6121
|
+
await retry(() => cloneSync.runSyncAttempt(outcome), retryOptionsWithOutcomeReset);
|
|
6122
|
+
} else {
|
|
6123
|
+
await retry(
|
|
6124
|
+
() => this.worktreeModeSyncRunner.runSyncAttempt(phaseTimer, syncContext, outcome),
|
|
6125
|
+
retryOptionsWithOutcomeReset
|
|
6126
|
+
);
|
|
6127
|
+
}
|
|
6128
|
+
} catch (error) {
|
|
6129
|
+
if (outcome.getCounts().failed === 0) {
|
|
6130
|
+
outcome.recordFailed("repo", getErrorMessage(error), { reason: "sync_failed" });
|
|
6131
|
+
}
|
|
6132
|
+
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
6133
|
+
throw error;
|
|
6134
|
+
} finally {
|
|
6135
|
+
this.retryPolicy.resetLfsSkipIfNeeded(syncContext);
|
|
6136
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
6137
|
+
`);
|
|
6138
|
+
durationMs = totalTimer.stop();
|
|
6139
|
+
this.lastOutcome = outcome.toOutcome(durationMs);
|
|
6140
|
+
if (this.config.debug) {
|
|
6141
|
+
const phaseResults = phaseTimer.getResults();
|
|
6142
|
+
const repoName = this.config.name;
|
|
6143
|
+
this.logger.table(formatTimingTable(durationMs, phaseResults, repoName));
|
|
4729
6144
|
}
|
|
4730
|
-
} catch {
|
|
4731
6145
|
}
|
|
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;
|
|
6146
|
+
return this.lastOutcome ?? outcome.toOutcome(durationMs);
|
|
6147
|
+
});
|
|
6148
|
+
return result.started ? { started: true, outcome: result.value } : result;
|
|
4745
6149
|
}
|
|
4746
6150
|
};
|
|
4747
6151
|
|
|
@@ -4874,7 +6278,7 @@ var HookExecutionService = class {
|
|
|
4874
6278
|
// src/utils/disk-space.ts
|
|
4875
6279
|
import fastFolderSize from "fast-folder-size";
|
|
4876
6280
|
async function calculateDirectorySize(dirPath) {
|
|
4877
|
-
return new Promise((
|
|
6281
|
+
return new Promise((resolve12, reject) => {
|
|
4878
6282
|
fastFolderSize(dirPath, (err, bytes) => {
|
|
4879
6283
|
if (err) {
|
|
4880
6284
|
reject(err);
|
|
@@ -4884,7 +6288,7 @@ async function calculateDirectorySize(dirPath) {
|
|
|
4884
6288
|
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
4885
6289
|
return;
|
|
4886
6290
|
}
|
|
4887
|
-
|
|
6291
|
+
resolve12(bytes);
|
|
4888
6292
|
});
|
|
4889
6293
|
});
|
|
4890
6294
|
}
|
|
@@ -4955,7 +6359,7 @@ var AppEventEmitter = class {
|
|
|
4955
6359
|
};
|
|
4956
6360
|
|
|
4957
6361
|
// src/services/InteractiveUIService.tsx
|
|
4958
|
-
import * as
|
|
6362
|
+
import * as fs10 from "fs/promises";
|
|
4959
6363
|
var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
|
|
4960
6364
|
var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
|
|
4961
6365
|
var InteractiveUIService = class {
|
|
@@ -4968,15 +6372,17 @@ var InteractiveUIService = class {
|
|
|
4968
6372
|
logBuffer = [];
|
|
4969
6373
|
uiReady = false;
|
|
4970
6374
|
hookExecutionService = new HookExecutionService();
|
|
6375
|
+
branchCreatedActions = new BranchCreatedActionsService();
|
|
4971
6376
|
pathResolution = new PathResolutionService();
|
|
4972
6377
|
limit;
|
|
6378
|
+
maxProgressLines;
|
|
4973
6379
|
reloadInProgress = false;
|
|
4974
6380
|
isDestroyed = false;
|
|
4975
|
-
reloadOptions;
|
|
4976
6381
|
events;
|
|
4977
6382
|
ownsEvents;
|
|
4978
6383
|
unsubscribeCallbacks = [];
|
|
4979
|
-
|
|
6384
|
+
progressUnsubscribers = [];
|
|
6385
|
+
constructor(syncServices, configPath, cronSchedule, maxParallel, events) {
|
|
4980
6386
|
this.ownsEvents = events === void 0;
|
|
4981
6387
|
this.events = events ?? new AppEventEmitter();
|
|
4982
6388
|
if (syncServices.length === 0) {
|
|
@@ -4986,10 +6392,11 @@ var InteractiveUIService = class {
|
|
|
4986
6392
|
this.configPath = configPath;
|
|
4987
6393
|
this.cronSchedule = cronSchedule;
|
|
4988
6394
|
this.repositoryCount = syncServices.length;
|
|
4989
|
-
this.
|
|
4990
|
-
this.
|
|
6395
|
+
this.maxProgressLines = Math.max(1, maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
6396
|
+
this.limit = pLimit2(this.maxProgressLines);
|
|
4991
6397
|
this.startBufferFlushCheck();
|
|
4992
6398
|
this.renderUI();
|
|
6399
|
+
this.subscribeToServiceProgress();
|
|
4993
6400
|
this.injectLoggersIntoServices();
|
|
4994
6401
|
setTimeout(() => {
|
|
4995
6402
|
this.addLog("\u{1F680} sync-worktrees UI initialized", "info");
|
|
@@ -5027,6 +6434,26 @@ var InteractiveUIService = class {
|
|
|
5027
6434
|
);
|
|
5028
6435
|
}
|
|
5029
6436
|
}
|
|
6437
|
+
subscribeToServiceProgress() {
|
|
6438
|
+
for (const unsubscribe of this.progressUnsubscribers) {
|
|
6439
|
+
unsubscribe();
|
|
6440
|
+
}
|
|
6441
|
+
this.progressUnsubscribers = this.syncServices.map((service, index) => {
|
|
6442
|
+
const repoName = this.getRepoName(index);
|
|
6443
|
+
if (!service.onProgress) return () => void 0;
|
|
6444
|
+
return service.onProgress((event) => {
|
|
6445
|
+
if (this.isDestroyed) return;
|
|
6446
|
+
this.events.emit("setSyncProgress", {
|
|
6447
|
+
repo: repoName,
|
|
6448
|
+
phase: event.phase,
|
|
6449
|
+
message: event.message,
|
|
6450
|
+
progress: event.progress,
|
|
6451
|
+
processed: event.processed,
|
|
6452
|
+
total: event.total
|
|
6453
|
+
});
|
|
6454
|
+
});
|
|
6455
|
+
});
|
|
6456
|
+
}
|
|
5030
6457
|
addLog(message, level = "info") {
|
|
5031
6458
|
if (this.isDestroyed) return;
|
|
5032
6459
|
if (this.uiReady) {
|
|
@@ -5045,15 +6472,15 @@ var InteractiveUIService = class {
|
|
|
5045
6472
|
const scheduleGroups = /* @__PURE__ */ new Map();
|
|
5046
6473
|
for (const service of this.syncServices) {
|
|
5047
6474
|
if (service.config.runOnce) continue;
|
|
5048
|
-
const
|
|
5049
|
-
if (!
|
|
5050
|
-
if (!scheduleGroups.has(
|
|
5051
|
-
scheduleGroups.set(
|
|
6475
|
+
const schedule2 = service.config.cronSchedule || this.cronSchedule;
|
|
6476
|
+
if (!schedule2) continue;
|
|
6477
|
+
if (!scheduleGroups.has(schedule2)) {
|
|
6478
|
+
scheduleGroups.set(schedule2, []);
|
|
5052
6479
|
}
|
|
5053
|
-
scheduleGroups.get(
|
|
6480
|
+
scheduleGroups.get(schedule2).push(service);
|
|
5054
6481
|
}
|
|
5055
|
-
for (const [
|
|
5056
|
-
const task = cron2.schedule(
|
|
6482
|
+
for (const [schedule2, services] of scheduleGroups) {
|
|
6483
|
+
const task = cron2.schedule(schedule2, async () => {
|
|
5057
6484
|
await this.runSyncCycle(services, { logErrors: false });
|
|
5058
6485
|
});
|
|
5059
6486
|
this.cronJobs.push(task);
|
|
@@ -5079,6 +6506,7 @@ var InteractiveUIService = class {
|
|
|
5079
6506
|
events: this.events,
|
|
5080
6507
|
repositoryCount: this.repositoryCount,
|
|
5081
6508
|
cronSchedule: this.cronSchedule,
|
|
6509
|
+
maxProgressLines: this.maxProgressLines,
|
|
5082
6510
|
onManualSync: () => this.handleManualSync(),
|
|
5083
6511
|
onReload: () => this.handleReload(),
|
|
5084
6512
|
onQuit: () => this.handleQuit(),
|
|
@@ -5089,10 +6517,11 @@ var InteractiveUIService = class {
|
|
|
5089
6517
|
createAndPushBranch: (repoIndex, baseBranch, branchName) => this.createAndPushBranch(repoIndex, baseBranch, branchName),
|
|
5090
6518
|
getWorktreesForRepo: (index) => this.getWorktreesForRepo(index),
|
|
5091
6519
|
getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
|
|
6520
|
+
getRepositoryDiskUsage: (index) => this.getRepositoryDiskUsage(index),
|
|
5092
6521
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
5093
6522
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
5094
|
-
openEditorInWorktree: (
|
|
5095
|
-
openTerminalInWorktree: (repoIndex,
|
|
6523
|
+
openEditorInWorktree: (path18) => this.openEditorInWorktree(path18),
|
|
6524
|
+
openTerminalInWorktree: (repoIndex, path18, branchName) => this.openTerminalInWorktree(repoIndex, path18, branchName),
|
|
5096
6525
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
5097
6526
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
5098
6527
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
@@ -5121,24 +6550,28 @@ var InteractiveUIService = class {
|
|
|
5121
6550
|
this.addLog("Reloading configuration...");
|
|
5122
6551
|
this.setStatus("syncing");
|
|
5123
6552
|
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
|
-
});
|
|
6553
|
+
const { repositories } = await configLoader.buildRepositories(this.configPath);
|
|
5129
6554
|
const initResults = await Promise.allSettled(
|
|
5130
6555
|
repositories.map(
|
|
5131
6556
|
(repoConfig) => this.limit(async () => {
|
|
5132
6557
|
const service = new WorktreeSyncService(repoConfig);
|
|
5133
6558
|
await service.initialize();
|
|
5134
|
-
return
|
|
6559
|
+
return {
|
|
6560
|
+
service,
|
|
6561
|
+
clonePhaseSkips: service.getRecordedSkips().map((reason) => ({
|
|
6562
|
+
repo: repoConfig.name || repoConfig.repoUrl,
|
|
6563
|
+
reason: formatCloneSkipReason(reason)
|
|
6564
|
+
}))
|
|
6565
|
+
};
|
|
5135
6566
|
})
|
|
5136
6567
|
)
|
|
5137
6568
|
);
|
|
5138
6569
|
const newServices = [];
|
|
6570
|
+
const initClonePhaseSkips = [];
|
|
5139
6571
|
for (const result of initResults) {
|
|
5140
6572
|
if (result.status === "fulfilled") {
|
|
5141
|
-
newServices.push(result.value);
|
|
6573
|
+
newServices.push(result.value.service);
|
|
6574
|
+
initClonePhaseSkips.push(...result.value.clonePhaseSkips);
|
|
5142
6575
|
} else {
|
|
5143
6576
|
this.addLog(`Failed to initialize repository: ${result.reason}`, "error");
|
|
5144
6577
|
}
|
|
@@ -5150,18 +6583,31 @@ var InteractiveUIService = class {
|
|
|
5150
6583
|
cronJobsCancelled = true;
|
|
5151
6584
|
this.syncServices = newServices;
|
|
5152
6585
|
this.repositoryCount = this.syncServices.length;
|
|
6586
|
+
this.subscribeToServiceProgress();
|
|
5153
6587
|
this.injectLoggersIntoServices();
|
|
5154
6588
|
const uniqueSchedules = [...new Set(this.syncServices.map((s) => s.config.cronSchedule))];
|
|
5155
6589
|
this.cronSchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
|
|
5156
6590
|
this.setupCronJobs();
|
|
5157
6591
|
this.events.emit("updateRepositoryCount", this.repositoryCount);
|
|
5158
6592
|
this.events.emit("updateCronSchedule", this.cronSchedule);
|
|
5159
|
-
const {
|
|
6593
|
+
const {
|
|
6594
|
+
failures,
|
|
6595
|
+
skipped,
|
|
6596
|
+
clonePhaseSkips: syncClonePhaseSkips,
|
|
6597
|
+
attempted
|
|
6598
|
+
} = await this.runSyncServices(this.syncServices);
|
|
6599
|
+
const clonePhaseSkips = [...initClonePhaseSkips, ...syncClonePhaseSkips];
|
|
5160
6600
|
await this.recordSyncOutcome({ failures, skipped, attempted });
|
|
5161
6601
|
this.setStatus("idle");
|
|
5162
6602
|
for (const skip of skipped) {
|
|
5163
6603
|
this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
|
|
5164
6604
|
}
|
|
6605
|
+
for (const skip of clonePhaseSkips) {
|
|
6606
|
+
this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
|
|
6607
|
+
}
|
|
6608
|
+
if (clonePhaseSkips.length > 0) {
|
|
6609
|
+
this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) during reload`, "warn");
|
|
6610
|
+
}
|
|
5165
6611
|
if (failures.length > 0) {
|
|
5166
6612
|
for (const failure of failures) {
|
|
5167
6613
|
this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
|
|
@@ -5195,14 +6641,14 @@ var InteractiveUIService = class {
|
|
|
5195
6641
|
if (Date.now() - startTime > timeoutMs) {
|
|
5196
6642
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
5197
6643
|
}
|
|
5198
|
-
await new Promise((
|
|
6644
|
+
await new Promise((resolve12) => setTimeout(resolve12, checkInterval));
|
|
5199
6645
|
}
|
|
5200
6646
|
});
|
|
5201
6647
|
try {
|
|
5202
6648
|
await Promise.all(syncChecks);
|
|
5203
6649
|
} catch {
|
|
5204
6650
|
this.addLog(
|
|
5205
|
-
`Warning: Timeout waiting for sync operations to complete after ${
|
|
6651
|
+
`Warning: Timeout waiting for sync operations to complete after ${formatDuration(timeoutMs)}. Proceeding with potential data loss risk.`,
|
|
5206
6652
|
"warn"
|
|
5207
6653
|
);
|
|
5208
6654
|
}
|
|
@@ -5214,6 +6660,9 @@ var InteractiveUIService = class {
|
|
|
5214
6660
|
setStatus(status) {
|
|
5215
6661
|
if (this.isDestroyed) return;
|
|
5216
6662
|
this.events.emit("setStatus", status);
|
|
6663
|
+
if (status === "idle") {
|
|
6664
|
+
this.events.emit("setSyncProgress", null);
|
|
6665
|
+
}
|
|
5217
6666
|
}
|
|
5218
6667
|
setDiskSpace(diskSpace) {
|
|
5219
6668
|
if (this.isDestroyed) return;
|
|
@@ -5243,6 +6692,45 @@ var InteractiveUIService = class {
|
|
|
5243
6692
|
const service = this.syncServices[index];
|
|
5244
6693
|
return service.config.name || `repo-${index}`;
|
|
5245
6694
|
}
|
|
6695
|
+
async getRepositoryDiskUsage(repoIndex) {
|
|
6696
|
+
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
6697
|
+
throw new Error(`Invalid repository index: ${repoIndex}`);
|
|
6698
|
+
}
|
|
6699
|
+
const service = this.syncServices[repoIndex];
|
|
6700
|
+
const config = service.config;
|
|
6701
|
+
const repoName = this.getRepoName(repoIndex);
|
|
6702
|
+
const mode = resolveMode(config);
|
|
6703
|
+
const sizeTargets = [
|
|
6704
|
+
...mode === "worktree" ? [{ kind: "bare", path: config.bareRepoDir || getDefaultBareRepoDir(config.repoUrl) }] : [],
|
|
6705
|
+
{ kind: "worktree", path: config.worktreeDir }
|
|
6706
|
+
];
|
|
6707
|
+
let bareSizeBytes = 0;
|
|
6708
|
+
let worktreeSizeBytes = 0;
|
|
6709
|
+
const errors = [];
|
|
6710
|
+
for (const target of sizeTargets) {
|
|
6711
|
+
try {
|
|
6712
|
+
const size = await calculateDirectorySize(target.path);
|
|
6713
|
+
if (target.kind === "bare") {
|
|
6714
|
+
bareSizeBytes = size;
|
|
6715
|
+
} else {
|
|
6716
|
+
worktreeSizeBytes = size;
|
|
6717
|
+
}
|
|
6718
|
+
} catch (error) {
|
|
6719
|
+
errors.push(`${target.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
6720
|
+
}
|
|
6721
|
+
}
|
|
6722
|
+
const sizeBytes = bareSizeBytes + worktreeSizeBytes;
|
|
6723
|
+
const failedAllPaths = errors.length === sizeTargets.length;
|
|
6724
|
+
return {
|
|
6725
|
+
repoIndex,
|
|
6726
|
+
repoName,
|
|
6727
|
+
sizeBytes: failedAllPaths ? null : sizeBytes,
|
|
6728
|
+
sizeFormatted: failedAllPaths ? "N/A" : formatBytes(sizeBytes),
|
|
6729
|
+
bareSizeBytes,
|
|
6730
|
+
worktreeSizeBytes,
|
|
6731
|
+
error: errors.length > 0 ? errors.join("; ") : void 0
|
|
6732
|
+
};
|
|
6733
|
+
}
|
|
5246
6734
|
async getBranchesForRepo(repoIndex) {
|
|
5247
6735
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
5248
6736
|
throw new Error(`Invalid repository index: ${repoIndex}`);
|
|
@@ -5304,8 +6792,7 @@ var InteractiveUIService = class {
|
|
|
5304
6792
|
throw new Error(`Invalid repository index: ${repoIndex}`);
|
|
5305
6793
|
}
|
|
5306
6794
|
const service = this.syncServices[repoIndex];
|
|
5307
|
-
|
|
5308
|
-
return gitService.getWorktrees();
|
|
6795
|
+
return this.getWorktreesFromService(service);
|
|
5309
6796
|
}
|
|
5310
6797
|
async getWorktreeStatusForRepo(repoIndex) {
|
|
5311
6798
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -5313,7 +6800,7 @@ var InteractiveUIService = class {
|
|
|
5313
6800
|
}
|
|
5314
6801
|
const service = this.syncServices[repoIndex];
|
|
5315
6802
|
const gitService = service.getGitService();
|
|
5316
|
-
const worktrees = await
|
|
6803
|
+
const worktrees = await this.getWorktreesFromService(service);
|
|
5317
6804
|
const results = await Promise.allSettled(
|
|
5318
6805
|
worktrees.map(async (wt) => {
|
|
5319
6806
|
const status = await gitService.getFullWorktreeStatus(wt.path, true);
|
|
@@ -5322,28 +6809,35 @@ var InteractiveUIService = class {
|
|
|
5322
6809
|
);
|
|
5323
6810
|
return results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
5324
6811
|
}
|
|
6812
|
+
async getWorktreesFromService(service) {
|
|
6813
|
+
const worktreeProvider = service;
|
|
6814
|
+
if (typeof worktreeProvider.getWorktrees === "function") {
|
|
6815
|
+
return worktreeProvider.getWorktrees();
|
|
6816
|
+
}
|
|
6817
|
+
return service.getGitService().getWorktrees();
|
|
6818
|
+
}
|
|
5325
6819
|
async getDivergedDirectoriesForRepo(repoIndex) {
|
|
5326
6820
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
5327
6821
|
return [];
|
|
5328
6822
|
}
|
|
5329
6823
|
const service = this.syncServices[repoIndex];
|
|
5330
6824
|
const worktreeDir = service.config.worktreeDir;
|
|
5331
|
-
const divergedDir =
|
|
6825
|
+
const divergedDir = path14.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5332
6826
|
let dirEntries;
|
|
5333
6827
|
try {
|
|
5334
|
-
dirEntries = await
|
|
6828
|
+
dirEntries = await fs10.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
5335
6829
|
} catch {
|
|
5336
6830
|
return [];
|
|
5337
6831
|
}
|
|
5338
6832
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
5339
6833
|
const results = await Promise.allSettled(
|
|
5340
6834
|
subdirs.map(async (entry) => {
|
|
5341
|
-
const fullPath =
|
|
5342
|
-
const infoFilePath =
|
|
6835
|
+
const fullPath = path14.join(divergedDir, entry.name);
|
|
6836
|
+
const infoFilePath = path14.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
5343
6837
|
let originalBranch = entry.name;
|
|
5344
6838
|
let divergedAt = "";
|
|
5345
6839
|
try {
|
|
5346
|
-
const infoContent = await
|
|
6840
|
+
const infoContent = await fs10.readFile(infoFilePath, "utf-8");
|
|
5347
6841
|
const info = JSON.parse(infoContent);
|
|
5348
6842
|
if (typeof info.originalBranch === "string") originalBranch = info.originalBranch;
|
|
5349
6843
|
if (typeof info.divergedAt === "string") divergedAt = info.divergedAt;
|
|
@@ -5374,15 +6868,15 @@ var InteractiveUIService = class {
|
|
|
5374
6868
|
}
|
|
5375
6869
|
const service = this.syncServices[repoIndex];
|
|
5376
6870
|
const worktreeDir = service.config.worktreeDir;
|
|
5377
|
-
const divergedBase =
|
|
6871
|
+
const divergedBase = path14.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5378
6872
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
5379
6873
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
5380
6874
|
}
|
|
5381
|
-
const targetPath =
|
|
6875
|
+
const targetPath = path14.join(divergedBase, name);
|
|
5382
6876
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
5383
6877
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
5384
6878
|
}
|
|
5385
|
-
await
|
|
6879
|
+
await fs10.rm(targetPath, { recursive: true, force: true });
|
|
5386
6880
|
this.addLog(`\u{1F5D1}\uFE0F Deleted diverged directory: ${name}`, "info");
|
|
5387
6881
|
}
|
|
5388
6882
|
async createWorktreeForBranch(repoIndex, branchName) {
|
|
@@ -5500,7 +6994,7 @@ var InteractiveUIService = class {
|
|
|
5500
6994
|
async runSyncCycle(services, options) {
|
|
5501
6995
|
this.setStatus("syncing");
|
|
5502
6996
|
try {
|
|
5503
|
-
const { failures, skipped, attempted } = await this.runSyncServices(services);
|
|
6997
|
+
const { failures, skipped, partialSkips, clonePhaseSkips, attempted } = await this.runSyncServices(services);
|
|
5504
6998
|
if (options.logErrors) {
|
|
5505
6999
|
for (const failure of failures) {
|
|
5506
7000
|
this.addLog(`Failed to sync repository '${failure.repo}': ${failure.error}`, "error");
|
|
@@ -5509,6 +7003,15 @@ var InteractiveUIService = class {
|
|
|
5509
7003
|
for (const skip of skipped) {
|
|
5510
7004
|
this.addLog(`Sync skipped for '${skip.repo}': ${skip.reason}`, "warn");
|
|
5511
7005
|
}
|
|
7006
|
+
for (const skip of clonePhaseSkips) {
|
|
7007
|
+
this.addLog(`Clone-mode skip for '${skip.repo}': ${skip.reason}`, "warn");
|
|
7008
|
+
}
|
|
7009
|
+
if (clonePhaseSkips.length > 0) {
|
|
7010
|
+
this.addLog(`\u26A0\uFE0F ${clonePhaseSkips.length} clone-mode skip(s) this cycle`, "warn");
|
|
7011
|
+
}
|
|
7012
|
+
for (const partial of partialSkips) {
|
|
7013
|
+
this.addLog(`${partial.repo}: ${partial.reason}`, "info");
|
|
7014
|
+
}
|
|
5512
7015
|
await this.recordSyncOutcome({ failures, skipped, attempted });
|
|
5513
7016
|
return failures;
|
|
5514
7017
|
} finally {
|
|
@@ -5523,33 +7026,64 @@ var InteractiveUIService = class {
|
|
|
5523
7026
|
}
|
|
5524
7027
|
async runSyncServices(services) {
|
|
5525
7028
|
const syncResults = await Promise.allSettled(
|
|
5526
|
-
services.map(
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
7029
|
+
services.map((service) => {
|
|
7030
|
+
const repoName = service.config.name || service.config.repoUrl;
|
|
7031
|
+
return this.limit(async () => {
|
|
7032
|
+
service.clearRecordedSkips();
|
|
7033
|
+
try {
|
|
7034
|
+
if (!service.isInitialized()) {
|
|
7035
|
+
await service.initialize();
|
|
7036
|
+
}
|
|
7037
|
+
const result = await service.sync();
|
|
7038
|
+
return { service, result };
|
|
7039
|
+
} finally {
|
|
7040
|
+
this.events.emit("setSyncProgress", {
|
|
7041
|
+
repo: repoName,
|
|
7042
|
+
phase: "complete",
|
|
7043
|
+
message: "Finished",
|
|
7044
|
+
completed: true
|
|
7045
|
+
});
|
|
5530
7046
|
}
|
|
5531
|
-
const result = await service.sync();
|
|
5532
|
-
return { service, result };
|
|
5533
7047
|
}).catch((error) => {
|
|
5534
|
-
const repoName = service.config.name || service.config.repoUrl;
|
|
5535
7048
|
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { repoName });
|
|
5536
|
-
})
|
|
5537
|
-
)
|
|
7049
|
+
});
|
|
7050
|
+
})
|
|
5538
7051
|
);
|
|
5539
7052
|
const failures = [];
|
|
5540
7053
|
const skipped = [];
|
|
7054
|
+
const partialSkips = [];
|
|
7055
|
+
const clonePhaseSkips = [];
|
|
5541
7056
|
for (let i = 0; i < syncResults.length; i++) {
|
|
5542
7057
|
const result = syncResults[i];
|
|
7058
|
+
const repoName = services[i].config.name || services[i].config.repoUrl;
|
|
5543
7059
|
if (result.status === "rejected") {
|
|
5544
|
-
const
|
|
7060
|
+
const fallbackName = result.reason?.repoName ?? repoName;
|
|
5545
7061
|
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
5546
|
-
failures.push({ repo:
|
|
7062
|
+
failures.push({ repo: fallbackName, error: errorMessage });
|
|
5547
7063
|
} else if (result.value.result && result.value.result.started === false) {
|
|
5548
|
-
const repoName = services[i].config.name || services[i].config.repoUrl;
|
|
5549
7064
|
skipped.push({ repo: repoName, reason: `sync skipped: ${result.value.result.reason}` });
|
|
7065
|
+
} else if (result.status === "fulfilled" && result.value.result?.started === true) {
|
|
7066
|
+
const outcome = result.value.result.outcome;
|
|
7067
|
+
if (outcome?.counts.failed) {
|
|
7068
|
+
failures.push({ repo: repoName, error: `${outcome.counts.failed} sync action(s) failed` });
|
|
7069
|
+
}
|
|
7070
|
+
if (outcome?.mode === "worktree" && outcome.counts.skipped > 0) {
|
|
7071
|
+
partialSkips.push({ repo: repoName, reason: `${outcome.counts.skipped} sync action(s) skipped` });
|
|
7072
|
+
}
|
|
7073
|
+
}
|
|
7074
|
+
for (const reason of services[i].getRecordedSkips()) {
|
|
7075
|
+
clonePhaseSkips.push({ repo: repoName, reason: formatCloneSkipReason(reason) });
|
|
5550
7076
|
}
|
|
5551
7077
|
}
|
|
5552
|
-
return { failures, skipped, attempted: services.length };
|
|
7078
|
+
return { failures, skipped, partialSkips, clonePhaseSkips, attempted: services.length };
|
|
7079
|
+
}
|
|
7080
|
+
buildUiLogger() {
|
|
7081
|
+
return new Logger({
|
|
7082
|
+
outputFn: (msg, level) => {
|
|
7083
|
+
const uiLevel = level === "warn" ? "warn" : level === "error" ? "error" : "info";
|
|
7084
|
+
this.addLog(msg, uiLevel);
|
|
7085
|
+
}
|
|
7086
|
+
});
|
|
5553
7087
|
}
|
|
5554
7088
|
executeOnBranchCreatedHooks(repoIndex, context) {
|
|
5555
7089
|
if (repoIndex < 0 || repoIndex >= this.syncServices.length) {
|
|
@@ -5557,27 +7091,15 @@ var InteractiveUIService = class {
|
|
|
5557
7091
|
}
|
|
5558
7092
|
const service = this.syncServices[repoIndex];
|
|
5559
7093
|
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
|
-
}
|
|
7094
|
+
const repoName = config.name || config.repoUrl;
|
|
7095
|
+
this.branchCreatedActions.runHooks({
|
|
7096
|
+
config,
|
|
7097
|
+
repoName,
|
|
7098
|
+
branchName: context.branchName,
|
|
7099
|
+
worktreePath: context.worktreePath,
|
|
7100
|
+
baseBranch: context.baseBranch,
|
|
7101
|
+
logger: this.buildUiLogger(),
|
|
7102
|
+
hookExecutionService: this.hookExecutionService
|
|
5581
7103
|
});
|
|
5582
7104
|
}
|
|
5583
7105
|
async copyBranchFiles(repoIndex, baseBranch, targetBranch) {
|
|
@@ -5589,33 +7111,20 @@ var InteractiveUIService = class {
|
|
|
5589
7111
|
if (!config.filesToCopyOnBranchCreate?.length) {
|
|
5590
7112
|
return;
|
|
5591
7113
|
}
|
|
5592
|
-
const
|
|
5593
|
-
const worktrees = await gitService.getWorktrees();
|
|
7114
|
+
const worktrees = await this.getWorktreesFromService(service);
|
|
5594
7115
|
const sourceWorktree = worktrees.find((w) => w.branch === baseBranch);
|
|
5595
7116
|
const targetWorktree = worktrees.find((w) => w.branch === targetBranch);
|
|
5596
7117
|
if (!sourceWorktree || !targetWorktree) {
|
|
5597
7118
|
this.addLog(`Could not find worktrees for file copy: source=${baseBranch}, target=${targetBranch}`, "warn");
|
|
5598
7119
|
return;
|
|
5599
7120
|
}
|
|
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
|
-
}
|
|
7121
|
+
await this.branchCreatedActions.copyFiles({
|
|
7122
|
+
config,
|
|
7123
|
+
branchName: targetBranch,
|
|
7124
|
+
worktreePath: targetWorktree.path,
|
|
7125
|
+
sourceDir: sourceWorktree.path,
|
|
7126
|
+
logger: this.buildUiLogger()
|
|
7127
|
+
});
|
|
5619
7128
|
}
|
|
5620
7129
|
async destroy(fast = false) {
|
|
5621
7130
|
this.isDestroyed = true;
|
|
@@ -5633,6 +7142,10 @@ var InteractiveUIService = class {
|
|
|
5633
7142
|
unsubscribe();
|
|
5634
7143
|
}
|
|
5635
7144
|
this.unsubscribeCallbacks = [];
|
|
7145
|
+
for (const unsubscribe of this.progressUnsubscribers) {
|
|
7146
|
+
unsubscribe();
|
|
7147
|
+
}
|
|
7148
|
+
this.progressUnsubscribers = [];
|
|
5636
7149
|
if (this.ownsEvents) {
|
|
5637
7150
|
this.events.removeAllListeners();
|
|
5638
7151
|
}
|
|
@@ -5644,135 +7157,85 @@ var InteractiveUIService = class {
|
|
|
5644
7157
|
// src/utils/cli.ts
|
|
5645
7158
|
import yargs from "yargs";
|
|
5646
7159
|
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");
|
|
7160
|
+
var CLI_COMMANDS = {
|
|
7161
|
+
RUN: "run",
|
|
7162
|
+
INIT: "init",
|
|
7163
|
+
LIST: "list"
|
|
7164
|
+
};
|
|
7165
|
+
function parseArguments(argv = hideBin(process.argv)) {
|
|
7166
|
+
let parsed;
|
|
7167
|
+
yargs(argv).scriptName("sync-worktrees").parserConfiguration({ "camel-case-expansion": false }).strict().command(
|
|
7168
|
+
"$0",
|
|
7169
|
+
"Sync git worktrees against a config file",
|
|
7170
|
+
(y) => y.option("config", {
|
|
7171
|
+
alias: "c",
|
|
7172
|
+
type: "string",
|
|
7173
|
+
description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
|
|
7174
|
+
}).option("runOnce", {
|
|
7175
|
+
type: "boolean",
|
|
7176
|
+
description: "Run a sync once and exit, overriding config runOnce settings for this invocation.",
|
|
7177
|
+
default: false
|
|
7178
|
+
}),
|
|
7179
|
+
(args) => {
|
|
7180
|
+
parsed = {
|
|
7181
|
+
command: CLI_COMMANDS.RUN,
|
|
7182
|
+
config: args.config,
|
|
7183
|
+
runOnce: args.runOnce
|
|
7184
|
+
};
|
|
7185
|
+
}
|
|
7186
|
+
).command(
|
|
7187
|
+
CLI_COMMANDS.INIT,
|
|
7188
|
+
"Create a new config file interactively",
|
|
7189
|
+
(y) => y.option("config", {
|
|
7190
|
+
alias: "c",
|
|
7191
|
+
type: "string",
|
|
7192
|
+
description: "Target path for the generated config file (default: ./sync-worktrees.config.js)."
|
|
7193
|
+
}).option("force", {
|
|
7194
|
+
type: "boolean",
|
|
7195
|
+
description: "Overwrite the target file if it already exists.",
|
|
7196
|
+
default: false
|
|
7197
|
+
}),
|
|
7198
|
+
(args) => {
|
|
7199
|
+
parsed = {
|
|
7200
|
+
command: CLI_COMMANDS.INIT,
|
|
7201
|
+
config: args.config,
|
|
7202
|
+
force: args.force
|
|
7203
|
+
};
|
|
7204
|
+
}
|
|
7205
|
+
).command(
|
|
7206
|
+
CLI_COMMANDS.LIST,
|
|
7207
|
+
"List repositories configured in a config file and exit",
|
|
7208
|
+
(y) => y.option("config", {
|
|
7209
|
+
alias: "c",
|
|
7210
|
+
type: "string",
|
|
7211
|
+
description: "Path to JavaScript config file (auto-detected in CWD when omitted)."
|
|
7212
|
+
}).option("filter", {
|
|
7213
|
+
alias: "f",
|
|
7214
|
+
type: "string",
|
|
7215
|
+
description: "Filter repositories by name (wildcards, comma-separated)."
|
|
7216
|
+
}),
|
|
7217
|
+
(args) => {
|
|
7218
|
+
parsed = {
|
|
7219
|
+
command: CLI_COMMANDS.LIST,
|
|
7220
|
+
config: args.config,
|
|
7221
|
+
filter: args.filter
|
|
7222
|
+
};
|
|
7223
|
+
}
|
|
7224
|
+
).demandCommand(0, 0).help().alias("help", "h").version().parseSync();
|
|
7225
|
+
if (!parsed) {
|
|
7226
|
+
throw new Error("Failed to parse CLI arguments");
|
|
5764
7227
|
}
|
|
5765
|
-
return
|
|
7228
|
+
return parsed;
|
|
5766
7229
|
}
|
|
5767
7230
|
|
|
5768
7231
|
// src/utils/config-generator.ts
|
|
5769
|
-
import * as
|
|
5770
|
-
import * as
|
|
7232
|
+
import * as fs11 from "fs/promises";
|
|
7233
|
+
import * as path15 from "path";
|
|
5771
7234
|
function serializeToESM(obj, indent = 0) {
|
|
5772
7235
|
const spaces = " ".repeat(indent);
|
|
5773
7236
|
const innerSpaces = " ".repeat(indent + 2);
|
|
5774
7237
|
if (typeof obj === "string") {
|
|
5775
|
-
return
|
|
7238
|
+
return JSON.stringify(obj);
|
|
5776
7239
|
}
|
|
5777
7240
|
if (typeof obj === "number" || typeof obj === "boolean") {
|
|
5778
7241
|
return String(obj);
|
|
@@ -5796,99 +7259,105 @@ ${spaces}}`;
|
|
|
5796
7259
|
}
|
|
5797
7260
|
return String(obj);
|
|
5798
7261
|
}
|
|
5799
|
-
async function generateConfigFile(
|
|
5800
|
-
const configDir =
|
|
5801
|
-
await
|
|
5802
|
-
const worktreeDirRelative =
|
|
7262
|
+
async function generateConfigFile(input2, configPath, options = {}) {
|
|
7263
|
+
const configDir = path15.dirname(configPath);
|
|
7264
|
+
await fs11.mkdir(configDir, { recursive: true });
|
|
7265
|
+
const worktreeDirRelative = path15.relative(configDir, input2.worktreeDir);
|
|
5803
7266
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
5804
|
-
const repoName = extractRepoNameFromUrl(
|
|
7267
|
+
const repoName = extractRepoNameFromUrl(input2.repoUrl);
|
|
5805
7268
|
const repository = {
|
|
5806
7269
|
name: repoName,
|
|
5807
|
-
repoUrl:
|
|
5808
|
-
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` :
|
|
7270
|
+
repoUrl: input2.repoUrl,
|
|
7271
|
+
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : input2.worktreeDir
|
|
5809
7272
|
};
|
|
5810
|
-
if (
|
|
5811
|
-
const bareRepoDirRelative =
|
|
7273
|
+
if (input2.bareRepoDir) {
|
|
7274
|
+
const bareRepoDirRelative = path15.relative(configDir, input2.bareRepoDir);
|
|
5812
7275
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
5813
|
-
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` :
|
|
7276
|
+
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : input2.bareRepoDir;
|
|
7277
|
+
}
|
|
7278
|
+
const defaults = {
|
|
7279
|
+
cronSchedule: input2.cronSchedule
|
|
7280
|
+
};
|
|
7281
|
+
if (input2.runOnce) {
|
|
7282
|
+
defaults.runOnce = input2.runOnce;
|
|
5814
7283
|
}
|
|
5815
7284
|
const configObject = {
|
|
5816
|
-
defaults
|
|
5817
|
-
cronSchedule: config.cronSchedule,
|
|
5818
|
-
runOnce: config.runOnce
|
|
5819
|
-
},
|
|
7285
|
+
defaults,
|
|
5820
7286
|
repositories: [repository]
|
|
5821
7287
|
};
|
|
5822
|
-
const configContent =
|
|
7288
|
+
const configContent = `// @ts-check
|
|
7289
|
+
|
|
7290
|
+
/**
|
|
5823
7291
|
* Sync-worktrees configuration file
|
|
5824
7292
|
* Generated on ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
5825
7293
|
*/
|
|
5826
7294
|
|
|
5827
|
-
|
|
7295
|
+
/** @satisfies {import("sync-worktrees").SyncWorktreesConfig} */
|
|
7296
|
+
const config = ${serializeToESM(configObject)};
|
|
7297
|
+
|
|
7298
|
+
export default config;
|
|
5828
7299
|
`;
|
|
5829
|
-
|
|
7300
|
+
try {
|
|
7301
|
+
await fs11.writeFile(configPath, configContent, {
|
|
7302
|
+
encoding: "utf-8",
|
|
7303
|
+
flag: options.overwrite ? "w" : "wx"
|
|
7304
|
+
});
|
|
7305
|
+
} catch (error) {
|
|
7306
|
+
if (error.code === "EEXIST") {
|
|
7307
|
+
throw new ConfigFileExistsError(configPath);
|
|
7308
|
+
}
|
|
7309
|
+
throw error;
|
|
7310
|
+
}
|
|
5830
7311
|
}
|
|
5831
7312
|
function getDefaultConfigPath() {
|
|
5832
|
-
return
|
|
7313
|
+
return path15.join(process.cwd(), "sync-worktrees.config.js");
|
|
5833
7314
|
}
|
|
5834
7315
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
5835
7316
|
for (const name of CONFIG_FILE_NAMES) {
|
|
5836
|
-
const full =
|
|
5837
|
-
|
|
5838
|
-
await fs9.access(full);
|
|
7317
|
+
const full = path15.join(cwd, name);
|
|
7318
|
+
if (await fileExists(full)) {
|
|
5839
7319
|
return full;
|
|
5840
|
-
} catch {
|
|
5841
7320
|
}
|
|
5842
7321
|
}
|
|
5843
7322
|
return null;
|
|
5844
7323
|
}
|
|
5845
7324
|
|
|
5846
7325
|
// src/utils/interactive.ts
|
|
5847
|
-
import * as
|
|
7326
|
+
import * as path16 from "path";
|
|
5848
7327
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
5849
|
-
async function
|
|
7328
|
+
async function promptForInitConfig() {
|
|
5850
7329
|
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
|
-
}
|
|
7330
|
+
const repoUrl = await input({
|
|
7331
|
+
message: "Enter the Git repository URL (e.g., https://github.com/user/repo.git):",
|
|
7332
|
+
validate: (value) => {
|
|
7333
|
+
if (!value.trim()) {
|
|
7334
|
+
return "Repository URL is required";
|
|
5867
7335
|
}
|
|
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;
|
|
7336
|
+
if (!value.match(/^(https?:\/\/|ssh:\/\/|git@|file:\/\/).*$/)) {
|
|
7337
|
+
return "Please enter a valid Git URL (https://, ssh://, git@, or file://)";
|
|
5882
7338
|
}
|
|
5883
|
-
|
|
5884
|
-
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
5885
|
-
worktreeDir = defaultWorktreeDir;
|
|
7339
|
+
return true;
|
|
5886
7340
|
}
|
|
5887
|
-
|
|
5888
|
-
|
|
7341
|
+
});
|
|
7342
|
+
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
7343
|
+
const defaultWorktreeDir = repoName ? `./${repoName}` : "";
|
|
7344
|
+
let worktreeDir = await input({
|
|
7345
|
+
message: "Enter the directory for storing worktrees:",
|
|
7346
|
+
default: defaultWorktreeDir,
|
|
7347
|
+
validate: (value) => {
|
|
7348
|
+
if (!value.trim() && !defaultWorktreeDir) {
|
|
7349
|
+
return "Worktree directory is required";
|
|
7350
|
+
}
|
|
7351
|
+
return true;
|
|
5889
7352
|
}
|
|
7353
|
+
});
|
|
7354
|
+
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
7355
|
+
worktreeDir = defaultWorktreeDir;
|
|
5890
7356
|
}
|
|
5891
|
-
|
|
7357
|
+
if (!path16.isAbsolute(worktreeDir)) {
|
|
7358
|
+
worktreeDir = path16.resolve(worktreeDir);
|
|
7359
|
+
}
|
|
7360
|
+
let bareRepoDir;
|
|
5892
7361
|
const askForBareDir = await confirm({
|
|
5893
7362
|
message: "Would you like to specify a custom location for the bare repository?",
|
|
5894
7363
|
default: false
|
|
@@ -5904,96 +7373,42 @@ async function promptForConfig(partialConfig) {
|
|
|
5904
7373
|
return true;
|
|
5905
7374
|
}
|
|
5906
7375
|
});
|
|
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
|
-
});
|
|
7376
|
+
if (!path16.isAbsolute(bareRepoDir)) {
|
|
7377
|
+
bareRepoDir = path16.resolve(bareRepoDir);
|
|
5937
7378
|
}
|
|
5938
7379
|
}
|
|
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
|
|
7380
|
+
const runMode = await select({
|
|
7381
|
+
message: "How would you like to run the sync?",
|
|
7382
|
+
choices: [
|
|
7383
|
+
{ name: "Run once", value: "once" },
|
|
7384
|
+
{ name: "Schedule with cron", value: "scheduled" }
|
|
7385
|
+
]
|
|
5963
7386
|
});
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
message: "Enter the
|
|
5969
|
-
default:
|
|
7387
|
+
const runOnce = runMode === "once";
|
|
7388
|
+
let cronSchedule = "0 * * * *";
|
|
7389
|
+
if (!runOnce) {
|
|
7390
|
+
cronSchedule = await input({
|
|
7391
|
+
message: "Enter the cron schedule (or press enter for default):",
|
|
7392
|
+
default: "0 * * * *",
|
|
5970
7393
|
validate: (value) => {
|
|
5971
7394
|
if (!value.trim()) {
|
|
5972
|
-
return "
|
|
7395
|
+
return "Cron schedule is required";
|
|
5973
7396
|
}
|
|
5974
|
-
|
|
5975
|
-
|
|
7397
|
+
const parts = value.trim().split(" ");
|
|
7398
|
+
if (parts.length < 5) {
|
|
7399
|
+
return "Invalid cron pattern. Expected format: '* * * * *'";
|
|
5976
7400
|
}
|
|
5977
7401
|
return true;
|
|
5978
7402
|
}
|
|
5979
7403
|
});
|
|
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
7404
|
}
|
|
5996
|
-
return {
|
|
7405
|
+
return {
|
|
7406
|
+
repoUrl,
|
|
7407
|
+
worktreeDir,
|
|
7408
|
+
bareRepoDir,
|
|
7409
|
+
cronSchedule,
|
|
7410
|
+
runOnce
|
|
7411
|
+
};
|
|
5997
7412
|
}
|
|
5998
7413
|
|
|
5999
7414
|
// src/utils/signal-handlers.ts
|
|
@@ -6045,48 +7460,12 @@ Shutdown took longer than ${forceExitMs}ms, forcing exit.`);
|
|
|
6045
7460
|
|
|
6046
7461
|
// src/index.ts
|
|
6047
7462
|
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) {
|
|
7463
|
+
async function runMultipleRepositories(configFile, repositories, configPath) {
|
|
6087
7464
|
const services = /* @__PURE__ */ new Map();
|
|
6088
7465
|
const globalLogger = Logger.createDefault();
|
|
6089
|
-
const
|
|
7466
|
+
const runOnce = configFile.defaults?.runOnce ?? false;
|
|
7467
|
+
const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
|
|
7468
|
+
const limit = pLimit3(maxParallel);
|
|
6090
7469
|
if (runOnce) {
|
|
6091
7470
|
globalLogger.info(`
|
|
6092
7471
|
\u{1F504} Syncing ${repositories.length} repositories...`);
|
|
@@ -6123,7 +7502,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6123
7502
|
servicesToSync.map(
|
|
6124
7503
|
({ name, service }) => limit(async () => {
|
|
6125
7504
|
try {
|
|
6126
|
-
await service.sync();
|
|
7505
|
+
return await service.sync();
|
|
6127
7506
|
} catch (error) {
|
|
6128
7507
|
globalLogger.error(`\u274C Error syncing repository '${name}':`, error);
|
|
6129
7508
|
throw error;
|
|
@@ -6131,9 +7510,66 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6131
7510
|
})
|
|
6132
7511
|
)
|
|
6133
7512
|
);
|
|
6134
|
-
const
|
|
6135
|
-
|
|
6136
|
-
|
|
7513
|
+
const skipsByRepo = [];
|
|
7514
|
+
const skippedNames = /* @__PURE__ */ new Set();
|
|
7515
|
+
const outcomeFailedNames = /* @__PURE__ */ new Set();
|
|
7516
|
+
const partialSkipNames = /* @__PURE__ */ new Set();
|
|
7517
|
+
for (let i = 0; i < servicesToSync.length; i++) {
|
|
7518
|
+
const { name, service } = servicesToSync[i];
|
|
7519
|
+
const reasons = service.getRecordedSkips();
|
|
7520
|
+
if (reasons.length > 0) {
|
|
7521
|
+
skipsByRepo.push({ repo: name, reasons });
|
|
7522
|
+
skippedNames.add(name);
|
|
7523
|
+
}
|
|
7524
|
+
const result = syncResults[i];
|
|
7525
|
+
if (result.status === "fulfilled") {
|
|
7526
|
+
if (!result.value.started) {
|
|
7527
|
+
skippedNames.add(name);
|
|
7528
|
+
continue;
|
|
7529
|
+
}
|
|
7530
|
+
const counts = result.value.outcome?.counts;
|
|
7531
|
+
if (counts) {
|
|
7532
|
+
if (counts.failed > 0) {
|
|
7533
|
+
outcomeFailedNames.add(name);
|
|
7534
|
+
}
|
|
7535
|
+
if (counts.skipped > 0 && !skippedNames.has(name) && !outcomeFailedNames.has(name)) {
|
|
7536
|
+
partialSkipNames.add(name);
|
|
7537
|
+
}
|
|
7538
|
+
}
|
|
7539
|
+
}
|
|
7540
|
+
}
|
|
7541
|
+
if (skipsByRepo.length > 0) {
|
|
7542
|
+
const skipsRepoWord = skipsByRepo.length === 1 ? "repo" : "repos";
|
|
7543
|
+
globalLogger.warn(`
|
|
7544
|
+
\u26A0\uFE0F Clone-mode skips (${skipsByRepo.length} ${skipsRepoWord}):`);
|
|
7545
|
+
for (const { repo, reasons } of skipsByRepo) {
|
|
7546
|
+
for (const reason of reasons) {
|
|
7547
|
+
globalLogger.warn(` \u2022 ${repo} \u2014 ${formatCloneSkipReason(reason)}`);
|
|
7548
|
+
}
|
|
7549
|
+
}
|
|
7550
|
+
}
|
|
7551
|
+
const initFailures = initResults.filter(
|
|
7552
|
+
(result, index) => result.status === "rejected" && !skippedNames.has(repositories[index].name)
|
|
7553
|
+
).length;
|
|
7554
|
+
const syncFailures = syncResults.filter(
|
|
7555
|
+
(result, index) => result.status === "rejected" && !skippedNames.has(servicesToSync[index].name)
|
|
7556
|
+
).length;
|
|
7557
|
+
const failedCount = initFailures + syncFailures + outcomeFailedNames.size;
|
|
7558
|
+
const skippedCount = skippedNames.size;
|
|
7559
|
+
const successCount = syncResults.filter((result, index) => {
|
|
7560
|
+
const repoName = servicesToSync[index].name;
|
|
7561
|
+
return result.status === "fulfilled" && result.value.started && !skippedNames.has(repoName) && !outcomeFailedNames.has(repoName);
|
|
7562
|
+
}).length;
|
|
7563
|
+
const processedRepoWord = repositories.length === 1 ? "repo" : "repos";
|
|
7564
|
+
const skipSummaryLabel = skippedNames.size === skipsByRepo.length ? "with clone-mode skips" : "skipped";
|
|
7565
|
+
const partialSuffix = partialSkipNames.size > 0 ? ` (${partialSkipNames.size} with partial skips)` : "";
|
|
7566
|
+
globalLogger.info(
|
|
7567
|
+
`
|
|
7568
|
+
\u{1F4CA} Processed ${repositories.length} ${processedRepoWord}: ${successCount} synced${partialSuffix}, ${skippedCount} ${skipSummaryLabel}, ${failedCount} failed`
|
|
7569
|
+
);
|
|
7570
|
+
if (failedCount > 0) {
|
|
7571
|
+
process.exitCode = 1;
|
|
7572
|
+
}
|
|
6137
7573
|
} else {
|
|
6138
7574
|
for (const repoConfig of repositories) {
|
|
6139
7575
|
const syncService = new WorktreeSyncService(repoConfig);
|
|
@@ -6142,7 +7578,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6142
7578
|
const uniqueSchedules = [...new Set(repositories.map((r) => r.cronSchedule))];
|
|
6143
7579
|
const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
|
|
6144
7580
|
const allServices = Array.from(services.values());
|
|
6145
|
-
const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel
|
|
7581
|
+
const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel);
|
|
6146
7582
|
signalHandle.register((fast) => uiService.destroy(fast));
|
|
6147
7583
|
void uiService.calculateAndUpdateDiskSpace();
|
|
6148
7584
|
uiService.setupCronJobs();
|
|
@@ -6151,15 +7587,12 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
6151
7587
|
for (const repo of repositories) {
|
|
6152
7588
|
cronSchedules.set(repo.cronSchedule, (cronSchedules.get(repo.cronSchedule) || 0) + 1);
|
|
6153
7589
|
}
|
|
6154
|
-
for (const [
|
|
6155
|
-
uiService.addLog(`\u23F0 ${
|
|
6156
|
-
}
|
|
6157
|
-
if (syncOnStart) {
|
|
6158
|
-
await uiService.triggerInitialSync();
|
|
7590
|
+
for (const [schedule2, count] of cronSchedules) {
|
|
7591
|
+
uiService.addLog(`\u23F0 ${schedule2}: ${count} repository(ies)`);
|
|
6159
7592
|
}
|
|
6160
7593
|
}
|
|
6161
7594
|
}
|
|
6162
|
-
async function
|
|
7595
|
+
async function runList(configPath, filter) {
|
|
6163
7596
|
const configLoader = new ConfigLoaderService();
|
|
6164
7597
|
try {
|
|
6165
7598
|
const { repositories } = await configLoader.buildRepositories(configPath, { filter });
|
|
@@ -6187,114 +7620,98 @@ async function listRepositories(configPath, filter) {
|
|
|
6187
7620
|
process.exit(1);
|
|
6188
7621
|
}
|
|
6189
7622
|
}
|
|
6190
|
-
async function runFromConfigFile(configPath,
|
|
7623
|
+
async function runFromConfigFile(configPath, runOnceOverride = false) {
|
|
6191
7624
|
const configLoader = new ConfigLoaderService();
|
|
6192
|
-
const { repositories, configFile } = await configLoader.buildRepositories(configPath
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
7625
|
+
const { repositories, configFile } = await configLoader.buildRepositories(configPath);
|
|
7626
|
+
const effectiveConfigFile = runOnceOverride ? { ...configFile, defaults: { ...configFile.defaults ?? {}, runOnce: true } } : configFile;
|
|
7627
|
+
await runMultipleRepositories(effectiveConfigFile, repositories, configPath);
|
|
7628
|
+
}
|
|
7629
|
+
async function resolveConfigOrExit(cliPath) {
|
|
7630
|
+
const resolved = cliPath ? path17.resolve(cliPath) : await findConfigInCwd();
|
|
7631
|
+
if (!resolved) {
|
|
7632
|
+
console.error(
|
|
7633
|
+
"\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."
|
|
7634
|
+
);
|
|
6199
7635
|
process.exit(1);
|
|
6200
7636
|
}
|
|
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
|
-
);
|
|
7637
|
+
return resolved;
|
|
6216
7638
|
}
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
return;
|
|
7639
|
+
function exitConfigExists(targetPath) {
|
|
7640
|
+
console.error(`
|
|
7641
|
+
\u274C Config file already exists: ${targetPath}`);
|
|
7642
|
+
console.error(`\u{1F4A1} Re-run with --force to overwrite.`);
|
|
7643
|
+
process.exit(1);
|
|
7644
|
+
}
|
|
7645
|
+
async function runInit(configPath, force) {
|
|
7646
|
+
const targetPath = configPath ? path17.resolve(configPath) : getDefaultConfigPath();
|
|
7647
|
+
if (!force && await fileExists(targetPath)) {
|
|
7648
|
+
exitConfigExists(targetPath);
|
|
6228
7649
|
}
|
|
6229
|
-
const
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
}
|
|
6233
|
-
|
|
7650
|
+
const input2 = await promptForInitConfig();
|
|
7651
|
+
try {
|
|
7652
|
+
await generateConfigFile(input2, targetPath, { overwrite: force });
|
|
7653
|
+
} catch (error) {
|
|
7654
|
+
if (error instanceof ConfigFileExistsError) {
|
|
7655
|
+
exitConfigExists(error.configPath);
|
|
7656
|
+
}
|
|
7657
|
+
throw error;
|
|
6234
7658
|
}
|
|
6235
|
-
|
|
6236
|
-
|
|
7659
|
+
const displayPath = path17.relative(process.cwd(), targetPath) || targetPath;
|
|
7660
|
+
console.log(`
|
|
7661
|
+
\u2705 Configuration saved to: ${targetPath}`);
|
|
7662
|
+
console.log(`
|
|
7663
|
+
\u{1F4A1} Next: sync-worktrees --config ${displayPath}`);
|
|
7664
|
+
}
|
|
7665
|
+
async function runSync(options) {
|
|
7666
|
+
const configPath = await resolveConfigOrExit(options.config);
|
|
7667
|
+
const displayPath = path17.relative(process.cwd(), configPath) || configPath;
|
|
7668
|
+
console.log(`\u{1F4C4} Using config: ${displayPath}`);
|
|
7669
|
+
try {
|
|
7670
|
+
await runFromConfigFile(configPath, options.runOnce);
|
|
7671
|
+
} catch (error) {
|
|
7672
|
+
if (error instanceof ConfigFileNotFoundError) {
|
|
7673
|
+
console.error(`
|
|
7674
|
+
\u274C Config file not found: ${error.configPath}`);
|
|
7675
|
+
console.error(`\u{1F4A1} Run 'sync-worktrees init --config ${displayPath}' to create one.`);
|
|
7676
|
+
process.exit(1);
|
|
7677
|
+
}
|
|
7678
|
+
console.error("\u274C Error loading config file:", error.message);
|
|
7679
|
+
process.exit(1);
|
|
6237
7680
|
}
|
|
6238
|
-
await runSingleRepository(config);
|
|
6239
7681
|
}
|
|
6240
7682
|
async function main() {
|
|
6241
7683
|
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
|
-
}
|
|
7684
|
+
switch (options.command) {
|
|
7685
|
+
case CLI_COMMANDS.INIT:
|
|
7686
|
+
return runInit(options.config, options.force);
|
|
7687
|
+
case CLI_COMMANDS.LIST: {
|
|
7688
|
+
const configPath = await resolveConfigOrExit(options.config);
|
|
7689
|
+
return runList(configPath, options.filter);
|
|
6280
7690
|
}
|
|
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;
|
|
7691
|
+
case CLI_COMMANDS.RUN:
|
|
7692
|
+
return runSync(options);
|
|
7693
|
+
default: {
|
|
7694
|
+
const _exhaustive = options;
|
|
7695
|
+
throw new Error(`Unhandled command: ${JSON.stringify(_exhaustive)}`);
|
|
6292
7696
|
}
|
|
6293
|
-
await runSingleRepository(config);
|
|
6294
7697
|
}
|
|
6295
7698
|
}
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
7699
|
+
function isMainEntrypoint() {
|
|
7700
|
+
const entry = process.argv[1];
|
|
7701
|
+
if (!entry) return false;
|
|
7702
|
+
try {
|
|
7703
|
+
return realpathSync2(entry) === fileURLToPath(import.meta.url);
|
|
7704
|
+
} catch {
|
|
7705
|
+
return false;
|
|
7706
|
+
}
|
|
7707
|
+
}
|
|
7708
|
+
if (isMainEntrypoint()) {
|
|
7709
|
+
main().catch((error) => {
|
|
7710
|
+
console.error("\u274C Unhandled error:", error);
|
|
7711
|
+
process.exit(1);
|
|
7712
|
+
});
|
|
7713
|
+
}
|
|
7714
|
+
export {
|
|
7715
|
+
runMultipleRepositories
|
|
7716
|
+
};
|
|
6300
7717
|
//# sourceMappingURL=index.js.map
|