sync-worktrees 3.3.1 → 3.5.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/dist/index.js +293 -107
- package/dist/index.js.map +4 -4
- package/dist/mcp-server.js +234 -81
- package/dist/mcp-server.js.map +3 -3
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import * as
|
|
4
|
+
import * as path13 from "path";
|
|
5
5
|
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
6
6
|
import * as cron3 from "node-cron";
|
|
7
7
|
import pLimit3 from "p-limit";
|
|
@@ -55,7 +55,11 @@ var DEFAULT_CONFIG = {
|
|
|
55
55
|
MAX_SAFE_TOTAL_CONCURRENT_OPS: 100
|
|
56
56
|
},
|
|
57
57
|
UPDATE_EXISTING_WORKTREES: true,
|
|
58
|
-
HOOK_TIMEOUT_MS: 6e4
|
|
58
|
+
HOOK_TIMEOUT_MS: 6e4,
|
|
59
|
+
FETCH_TIMEOUT_MS: 3e5,
|
|
60
|
+
CLONE_TIMEOUT_MS: 9e5,
|
|
61
|
+
LOCK_STALE_MS: 6e5,
|
|
62
|
+
LOCK_UPDATE_MS: 3e4
|
|
59
63
|
};
|
|
60
64
|
var ERROR_MESSAGES = {
|
|
61
65
|
GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
|
|
@@ -215,9 +219,9 @@ var WorktreeError = class extends SyncWorktreesError {
|
|
|
215
219
|
}
|
|
216
220
|
};
|
|
217
221
|
var WorktreeNotCleanError = class extends WorktreeError {
|
|
218
|
-
constructor(
|
|
219
|
-
super(`Worktree at '${
|
|
220
|
-
this.path =
|
|
222
|
+
constructor(path14, reasons) {
|
|
223
|
+
super(`Worktree at '${path14}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
224
|
+
this.path = path14;
|
|
221
225
|
this.reasons = reasons;
|
|
222
226
|
}
|
|
223
227
|
};
|
|
@@ -669,7 +673,7 @@ var ConfigLoaderService = class {
|
|
|
669
673
|
|
|
670
674
|
// src/services/InteractiveUIService.tsx
|
|
671
675
|
import React8 from "react";
|
|
672
|
-
import * as
|
|
676
|
+
import * as path10 from "path";
|
|
673
677
|
import { render } from "ink";
|
|
674
678
|
import * as cron2 from "node-cron";
|
|
675
679
|
import pLimit2 from "p-limit";
|
|
@@ -1945,8 +1949,9 @@ var App_default = App;
|
|
|
1945
1949
|
|
|
1946
1950
|
// src/services/worktree-sync.service.ts
|
|
1947
1951
|
import * as fs6 from "fs/promises";
|
|
1948
|
-
import * as
|
|
1952
|
+
import * as path8 from "path";
|
|
1949
1953
|
import pLimit from "p-limit";
|
|
1954
|
+
import * as lockfile from "proper-lockfile";
|
|
1950
1955
|
|
|
1951
1956
|
// src/utils/date-filter.ts
|
|
1952
1957
|
function parseDuration(durationStr) {
|
|
@@ -2174,7 +2179,7 @@ function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
|
2174
2179
|
|
|
2175
2180
|
// src/services/git.service.ts
|
|
2176
2181
|
import * as fs4 from "fs/promises";
|
|
2177
|
-
import * as
|
|
2182
|
+
import * as path6 from "path";
|
|
2178
2183
|
import simpleGit4 from "simple-git";
|
|
2179
2184
|
|
|
2180
2185
|
// src/utils/worktree-list-parser.ts
|
|
@@ -2318,11 +2323,13 @@ function defaultConsoleOutput(msg, level) {
|
|
|
2318
2323
|
}
|
|
2319
2324
|
|
|
2320
2325
|
// src/services/sparse-checkout.service.ts
|
|
2326
|
+
import * as path3 from "path";
|
|
2321
2327
|
import simpleGit from "simple-git";
|
|
2322
2328
|
var SparseCheckoutService = class {
|
|
2323
2329
|
logger;
|
|
2324
2330
|
gitFactory;
|
|
2325
2331
|
warnedConfigs = /* @__PURE__ */ new WeakSet();
|
|
2332
|
+
matcherCache = /* @__PURE__ */ new WeakMap();
|
|
2326
2333
|
constructor(logger, gitFactory) {
|
|
2327
2334
|
this.logger = logger ?? Logger.createDefault();
|
|
2328
2335
|
this.gitFactory = gitFactory ?? ((p) => simpleGit(p));
|
|
@@ -2406,11 +2413,66 @@ var SparseCheckoutService = class {
|
|
|
2406
2413
|
const bt = b.map((x) => x.trim());
|
|
2407
2414
|
return at.every((v, i) => v === bt[i]);
|
|
2408
2415
|
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Decide whether a list of changed file paths intersects the sparse-checkout
|
|
2418
|
+
* set defined by `cfg`. Used to skip fast-forward updates when upstream
|
|
2419
|
+
* commits only touch files outside the materialized worktree.
|
|
2420
|
+
*
|
|
2421
|
+
* Cone mode materializes:
|
|
2422
|
+
* - all files at the repository root,
|
|
2423
|
+
* - all files directly inside every ancestor of an included directory
|
|
2424
|
+
* (e.g. include `tools/build` keeps `tools/foo.txt` checked out too),
|
|
2425
|
+
* - everything inside an included directory.
|
|
2426
|
+
* We mirror those rules here. Missing the ancestor-files case would let
|
|
2427
|
+
* stale files linger when only those parent files change upstream.
|
|
2428
|
+
*
|
|
2429
|
+
* No-cone mode: gitignore-style matching with negation is non-trivial and
|
|
2430
|
+
* not implemented here yet. We return `true` so the caller falls back to
|
|
2431
|
+
* the safe behavior of always running the update.
|
|
2432
|
+
*
|
|
2433
|
+
* The matcher derived from `cfg` is cached on the cfg object identity
|
|
2434
|
+
* (WeakMap), so callers should reuse the same `cfg` reference across
|
|
2435
|
+
* invocations to benefit from the cache.
|
|
2436
|
+
*/
|
|
2437
|
+
pathsTouchSparse(changedPaths, cfg) {
|
|
2438
|
+
if (changedPaths.length === 0) return false;
|
|
2439
|
+
const matcher = this.getMatcher(cfg);
|
|
2440
|
+
if (matcher.mode === "no-cone") return true;
|
|
2441
|
+
if (matcher.patterns.length === 0) return true;
|
|
2442
|
+
return changedPaths.some((p) => {
|
|
2443
|
+
if (!p.includes("/")) return true;
|
|
2444
|
+
for (const pat of matcher.patterns) {
|
|
2445
|
+
if (p === pat || p.startsWith(pat + "/")) return true;
|
|
2446
|
+
}
|
|
2447
|
+
return matcher.ancestorDirs.has(path3.posix.dirname(p));
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
getMatcher(cfg) {
|
|
2451
|
+
const cached = this.matcherCache.get(cfg);
|
|
2452
|
+
if (cached) return cached;
|
|
2453
|
+
const mode = this.resolveMode(cfg);
|
|
2454
|
+
if (mode === "no-cone") {
|
|
2455
|
+
const matcher2 = { mode, patterns: [], ancestorDirs: /* @__PURE__ */ new Set() };
|
|
2456
|
+
this.matcherCache.set(cfg, matcher2);
|
|
2457
|
+
return matcher2;
|
|
2458
|
+
}
|
|
2459
|
+
const patterns = cfg.include.map((p) => p.trim()).filter((p) => p.length > 0).map((p) => p.endsWith("/") ? p.slice(0, -1) : p);
|
|
2460
|
+
const ancestorDirs = /* @__PURE__ */ new Set();
|
|
2461
|
+
for (const pat of patterns) {
|
|
2462
|
+
const parts = pat.split("/");
|
|
2463
|
+
for (let i = 1; i < parts.length; i++) {
|
|
2464
|
+
ancestorDirs.add(parts.slice(0, i).join("/"));
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
const matcher = { mode, patterns, ancestorDirs };
|
|
2468
|
+
this.matcherCache.set(cfg, matcher);
|
|
2469
|
+
return matcher;
|
|
2470
|
+
}
|
|
2409
2471
|
};
|
|
2410
2472
|
|
|
2411
2473
|
// src/services/worktree-metadata.service.ts
|
|
2412
2474
|
import * as fs2 from "fs/promises";
|
|
2413
|
-
import * as
|
|
2475
|
+
import * as path4 from "path";
|
|
2414
2476
|
import simpleGit2 from "simple-git";
|
|
2415
2477
|
var WorktreeMetadataService = class {
|
|
2416
2478
|
logger;
|
|
@@ -2423,7 +2485,7 @@ var WorktreeMetadataService = class {
|
|
|
2423
2485
|
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
2424
2486
|
*/
|
|
2425
2487
|
getWorktreeDirectoryName(worktreePath) {
|
|
2426
|
-
return
|
|
2488
|
+
return path4.basename(worktreePath);
|
|
2427
2489
|
}
|
|
2428
2490
|
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
2429
2491
|
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
@@ -2431,7 +2493,7 @@ var WorktreeMetadataService = class {
|
|
|
2431
2493
|
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
2432
2494
|
);
|
|
2433
2495
|
}
|
|
2434
|
-
return
|
|
2496
|
+
return path4.join(
|
|
2435
2497
|
bareRepoPath,
|
|
2436
2498
|
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
2437
2499
|
worktreeName,
|
|
@@ -2444,7 +2506,7 @@ var WorktreeMetadataService = class {
|
|
|
2444
2506
|
}
|
|
2445
2507
|
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
2446
2508
|
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
2447
|
-
await fs2.mkdir(
|
|
2509
|
+
await fs2.mkdir(path4.dirname(metadataPath), { recursive: true });
|
|
2448
2510
|
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2449
2511
|
let renamed = false;
|
|
2450
2512
|
try {
|
|
@@ -2637,7 +2699,7 @@ var WorktreeMetadataService = class {
|
|
|
2637
2699
|
|
|
2638
2700
|
// src/services/worktree-status.service.ts
|
|
2639
2701
|
import * as fs3 from "fs/promises";
|
|
2640
|
-
import * as
|
|
2702
|
+
import * as path5 from "path";
|
|
2641
2703
|
import simpleGit3 from "simple-git";
|
|
2642
2704
|
var OPERATION_FILES = [
|
|
2643
2705
|
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
@@ -2840,7 +2902,7 @@ var WorktreeStatusService = class {
|
|
|
2840
2902
|
async detectOperationFile(gitDir) {
|
|
2841
2903
|
const results = await Promise.all(
|
|
2842
2904
|
OPERATION_FILES.map(
|
|
2843
|
-
({ file }) => fs3.access(
|
|
2905
|
+
({ file }) => fs3.access(path5.join(gitDir, file)).then(
|
|
2844
2906
|
() => true,
|
|
2845
2907
|
() => false
|
|
2846
2908
|
)
|
|
@@ -2961,14 +3023,14 @@ var WorktreeStatusService = class {
|
|
|
2961
3023
|
}
|
|
2962
3024
|
}
|
|
2963
3025
|
async resolveGitDir(worktreePath) {
|
|
2964
|
-
const gitPath =
|
|
3026
|
+
const gitPath = path5.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
2965
3027
|
try {
|
|
2966
3028
|
const stat3 = await fs3.stat(gitPath);
|
|
2967
3029
|
if (stat3.isFile()) {
|
|
2968
3030
|
const content = await fs3.readFile(gitPath, "utf-8");
|
|
2969
3031
|
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
2970
3032
|
if (gitdirMatch) {
|
|
2971
|
-
return
|
|
3033
|
+
return path5.resolve(worktreePath, gitdirMatch[1].trim());
|
|
2972
3034
|
}
|
|
2973
3035
|
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
2974
3036
|
}
|
|
@@ -2982,7 +3044,7 @@ var WorktreeStatusService = class {
|
|
|
2982
3044
|
}
|
|
2983
3045
|
}
|
|
2984
3046
|
createGitInstance(worktreePath) {
|
|
2985
|
-
const key = `${
|
|
3047
|
+
const key = `${path5.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
2986
3048
|
let git = this.gitInstances.get(key);
|
|
2987
3049
|
if (!git) {
|
|
2988
3050
|
git = this.config.skipLfs ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
|
|
@@ -3005,7 +3067,7 @@ var GitService = class {
|
|
|
3005
3067
|
this.config = config;
|
|
3006
3068
|
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
3007
3069
|
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
3008
|
-
this.mainWorktreePath =
|
|
3070
|
+
this.mainWorktreePath = path6.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
3009
3071
|
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
3010
3072
|
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
3011
3073
|
this.sparseCheckoutService = new SparseCheckoutService(this.logger);
|
|
@@ -3024,11 +3086,21 @@ var GitService = class {
|
|
|
3024
3086
|
getSparseCheckoutService() {
|
|
3025
3087
|
return this.sparseCheckoutService;
|
|
3026
3088
|
}
|
|
3089
|
+
getFetchTimeoutMs() {
|
|
3090
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
3091
|
+
return this.config.fetchTimeoutMs ?? DEFAULT_CONFIG.FETCH_TIMEOUT_MS;
|
|
3092
|
+
}
|
|
3093
|
+
getCloneTimeoutMs() {
|
|
3094
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) return 0;
|
|
3095
|
+
return this.config.cloneTimeoutMs ?? DEFAULT_CONFIG.CLONE_TIMEOUT_MS;
|
|
3096
|
+
}
|
|
3027
3097
|
getCachedGit(dirPath, useLfsSkip = false) {
|
|
3028
|
-
const key = `${
|
|
3098
|
+
const key = `${path6.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
3029
3099
|
let git = this.gitInstances.get(key);
|
|
3030
3100
|
if (!git) {
|
|
3031
|
-
|
|
3101
|
+
const block = this.getFetchTimeoutMs();
|
|
3102
|
+
const base = block > 0 ? simpleGit4(dirPath, { timeout: { block } }) : simpleGit4(dirPath);
|
|
3103
|
+
git = useLfsSkip ? base.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : base;
|
|
3032
3104
|
this.gitInstances.set(key, git);
|
|
3033
3105
|
}
|
|
3034
3106
|
return git;
|
|
@@ -3040,11 +3112,13 @@ var GitService = class {
|
|
|
3040
3112
|
async initialize() {
|
|
3041
3113
|
const { repoUrl } = this.config;
|
|
3042
3114
|
try {
|
|
3043
|
-
await fs4.access(
|
|
3115
|
+
await fs4.access(path6.join(this.bareRepoPath, "HEAD"));
|
|
3044
3116
|
} catch {
|
|
3045
3117
|
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
3046
|
-
await fs4.mkdir(
|
|
3047
|
-
const
|
|
3118
|
+
await fs4.mkdir(path6.dirname(this.bareRepoPath), { recursive: true });
|
|
3119
|
+
const cloneBlock = this.getCloneTimeoutMs();
|
|
3120
|
+
const cloneBase = cloneBlock > 0 ? simpleGit4({ timeout: { block: cloneBlock } }) : simpleGit4();
|
|
3121
|
+
const cloneGit = this.isLfsSkipEnabled() ? cloneBase.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : cloneBase;
|
|
3048
3122
|
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
3049
3123
|
this.logger.info("\u2705 Clone successful.");
|
|
3050
3124
|
}
|
|
@@ -3061,17 +3135,17 @@ var GitService = class {
|
|
|
3061
3135
|
this.logger.info("Fetching remote branches...");
|
|
3062
3136
|
await bareGit.fetch(["--all"]);
|
|
3063
3137
|
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
3064
|
-
this.mainWorktreePath =
|
|
3138
|
+
this.mainWorktreePath = path6.join(this.config.worktreeDir, this.defaultBranch);
|
|
3065
3139
|
let needsMainWorktree = true;
|
|
3066
3140
|
try {
|
|
3067
3141
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3068
|
-
needsMainWorktree = !worktrees.some((w) =>
|
|
3142
|
+
needsMainWorktree = !worktrees.some((w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath));
|
|
3069
3143
|
} catch {
|
|
3070
3144
|
}
|
|
3071
3145
|
if (needsMainWorktree) {
|
|
3072
3146
|
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
3073
3147
|
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
3074
|
-
const absoluteWorktreePath =
|
|
3148
|
+
const absoluteWorktreePath = path6.resolve(this.mainWorktreePath);
|
|
3075
3149
|
const branches = await bareGit.branch();
|
|
3076
3150
|
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
3077
3151
|
const useNoCheckoutMain = !!this.config.sparseCheckout;
|
|
@@ -3107,7 +3181,7 @@ var GitService = class {
|
|
|
3107
3181
|
}
|
|
3108
3182
|
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
3109
3183
|
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
3110
|
-
(w) =>
|
|
3184
|
+
(w) => path6.resolve(w.path) === path6.resolve(this.mainWorktreePath)
|
|
3111
3185
|
);
|
|
3112
3186
|
if (!mainWorktreeRegistered) {
|
|
3113
3187
|
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
@@ -3130,6 +3204,9 @@ var GitService = class {
|
|
|
3130
3204
|
getDefaultBranch() {
|
|
3131
3205
|
return this.defaultBranch;
|
|
3132
3206
|
}
|
|
3207
|
+
getBareRepoPath() {
|
|
3208
|
+
return this.bareRepoPath;
|
|
3209
|
+
}
|
|
3133
3210
|
async fetchAll() {
|
|
3134
3211
|
this.assertInitialized();
|
|
3135
3212
|
this.logger.info("Fetching latest data from remote...");
|
|
@@ -3187,7 +3264,7 @@ var GitService = class {
|
|
|
3187
3264
|
const existence = await Promise.all(
|
|
3188
3265
|
lfsFileList.map(async (f) => {
|
|
3189
3266
|
try {
|
|
3190
|
-
await fs4.access(
|
|
3267
|
+
await fs4.access(path6.join(worktreePath, f));
|
|
3191
3268
|
return f;
|
|
3192
3269
|
} catch {
|
|
3193
3270
|
return null;
|
|
@@ -3215,7 +3292,7 @@ var GitService = class {
|
|
|
3215
3292
|
let allDownloaded = true;
|
|
3216
3293
|
const notDownloaded = [];
|
|
3217
3294
|
for (const file of samplesToCheck) {
|
|
3218
|
-
const filePath =
|
|
3295
|
+
const filePath = path6.join(worktreePath, file);
|
|
3219
3296
|
try {
|
|
3220
3297
|
const handle = await fs4.open(filePath, "r");
|
|
3221
3298
|
try {
|
|
@@ -3304,12 +3381,12 @@ var GitService = class {
|
|
|
3304
3381
|
}
|
|
3305
3382
|
async addWorktree(branchName, worktreePath) {
|
|
3306
3383
|
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
3307
|
-
const absoluteWorktreePath =
|
|
3308
|
-
await fs4.mkdir(
|
|
3384
|
+
const absoluteWorktreePath = path6.resolve(worktreePath);
|
|
3385
|
+
await fs4.mkdir(path6.dirname(absoluteWorktreePath), { recursive: true });
|
|
3309
3386
|
try {
|
|
3310
3387
|
await fs4.access(absoluteWorktreePath);
|
|
3311
3388
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3312
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3389
|
+
const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
|
|
3313
3390
|
if (isValidWorktree) {
|
|
3314
3391
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3315
3392
|
return;
|
|
@@ -3354,7 +3431,7 @@ var GitService = class {
|
|
|
3354
3431
|
}
|
|
3355
3432
|
if (errorMessage.includes("already registered worktree")) {
|
|
3356
3433
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3357
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3434
|
+
const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
|
|
3358
3435
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3359
3436
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
3360
3437
|
return;
|
|
@@ -3400,7 +3477,7 @@ var GitService = class {
|
|
|
3400
3477
|
try {
|
|
3401
3478
|
await fs4.access(absoluteWorktreePath);
|
|
3402
3479
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3403
|
-
const isValidWorktree = worktrees.some((w) =>
|
|
3480
|
+
const isValidWorktree = worktrees.some((w) => path6.resolve(w.path) === absoluteWorktreePath);
|
|
3404
3481
|
if (isValidWorktree) {
|
|
3405
3482
|
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
3406
3483
|
return;
|
|
@@ -3430,7 +3507,7 @@ var GitService = class {
|
|
|
3430
3507
|
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
3431
3508
|
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
3432
3509
|
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
3433
|
-
const existingWorktree = worktrees.find((w) =>
|
|
3510
|
+
const existingWorktree = worktrees.find((w) => path6.resolve(w.path) === absoluteWorktreePath);
|
|
3434
3511
|
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
3435
3512
|
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
3436
3513
|
return;
|
|
@@ -3653,6 +3730,23 @@ var GitService = class {
|
|
|
3653
3730
|
return false;
|
|
3654
3731
|
}
|
|
3655
3732
|
}
|
|
3733
|
+
async getChangedPathsInRange(worktreePath, fromRef, toRef) {
|
|
3734
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3735
|
+
try {
|
|
3736
|
+
const out = await worktreeGit.raw([
|
|
3737
|
+
"-c",
|
|
3738
|
+
"core.quotePath=false",
|
|
3739
|
+
"diff",
|
|
3740
|
+
"--name-only",
|
|
3741
|
+
"--no-renames",
|
|
3742
|
+
`${fromRef}..${toRef}`
|
|
3743
|
+
]);
|
|
3744
|
+
return out.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.length > 0);
|
|
3745
|
+
} catch (error) {
|
|
3746
|
+
this.logger.warn(`Failed to compute diff ${fromRef}..${toRef} in ${worktreePath}: ${getErrorMessage(error)}`);
|
|
3747
|
+
return null;
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3656
3750
|
async compareTreeContent(worktreePath, branch) {
|
|
3657
3751
|
const worktreeGit = this.getCachedGit(worktreePath);
|
|
3658
3752
|
try {
|
|
@@ -3737,7 +3831,7 @@ var GitService = class {
|
|
|
3737
3831
|
// src/services/path-resolution.service.ts
|
|
3738
3832
|
import { createHash } from "crypto";
|
|
3739
3833
|
import * as fs5 from "fs";
|
|
3740
|
-
import * as
|
|
3834
|
+
import * as path7 from "path";
|
|
3741
3835
|
var BRANCH_STEM_MAX = 80;
|
|
3742
3836
|
var BRANCH_HASH_LEN = 8;
|
|
3743
3837
|
var PathResolutionService = class {
|
|
@@ -3747,22 +3841,22 @@ var PathResolutionService = class {
|
|
|
3747
3841
|
return `${stem}-${hash}`;
|
|
3748
3842
|
}
|
|
3749
3843
|
getBranchWorktreePath(worktreeDir, branchName) {
|
|
3750
|
-
return
|
|
3844
|
+
return path7.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
3751
3845
|
}
|
|
3752
3846
|
resolveRealPath(inputPath) {
|
|
3753
|
-
const absolute =
|
|
3847
|
+
const absolute = path7.resolve(inputPath);
|
|
3754
3848
|
const missing = [];
|
|
3755
3849
|
let current = absolute;
|
|
3756
3850
|
while (!fs5.existsSync(current)) {
|
|
3757
|
-
const parent =
|
|
3851
|
+
const parent = path7.dirname(current);
|
|
3758
3852
|
if (parent === current) {
|
|
3759
3853
|
return absolute;
|
|
3760
3854
|
}
|
|
3761
|
-
missing.unshift(
|
|
3855
|
+
missing.unshift(path7.basename(current));
|
|
3762
3856
|
current = parent;
|
|
3763
3857
|
}
|
|
3764
3858
|
try {
|
|
3765
|
-
return
|
|
3859
|
+
return path7.join(fs5.realpathSync(current), ...missing);
|
|
3766
3860
|
} catch {
|
|
3767
3861
|
return absolute;
|
|
3768
3862
|
}
|
|
@@ -3772,7 +3866,7 @@ var PathResolutionService = class {
|
|
|
3772
3866
|
const a = fold(resolved);
|
|
3773
3867
|
const b = fold(resolvedBase);
|
|
3774
3868
|
if (a === b) return true;
|
|
3775
|
-
return a.length > b.length && a.charAt(b.length) ===
|
|
3869
|
+
return a.length > b.length && a.charAt(b.length) === path7.sep && a.startsWith(b);
|
|
3776
3870
|
}
|
|
3777
3871
|
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
3778
3872
|
const resolved = this.resolveRealPath(worktreePath);
|
|
@@ -3780,7 +3874,7 @@ var PathResolutionService = class {
|
|
|
3780
3874
|
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
3781
3875
|
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
3782
3876
|
}
|
|
3783
|
-
return
|
|
3877
|
+
return path7.relative(resolvedBase, resolved);
|
|
3784
3878
|
}
|
|
3785
3879
|
isPathInsideBaseDir(targetPath, baseDir) {
|
|
3786
3880
|
const resolved = this.resolveRealPath(targetPath);
|
|
@@ -3839,6 +3933,11 @@ var WorktreeSyncService = class {
|
|
|
3839
3933
|
this.logger.warn("\u26A0\uFE0F Sync already in progress, skipping...");
|
|
3840
3934
|
return { started: false, reason: "in_progress" };
|
|
3841
3935
|
}
|
|
3936
|
+
const release = await this.acquireBareLock();
|
|
3937
|
+
if (release === null) {
|
|
3938
|
+
this.logger.warn("\u26A0\uFE0F Another process holds the sync lock for this repo, skipping...");
|
|
3939
|
+
return { started: false, reason: "locked" };
|
|
3940
|
+
}
|
|
3842
3941
|
this.syncInProgress = true;
|
|
3843
3942
|
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
3844
3943
|
const totalTimer = new Timer();
|
|
@@ -3855,6 +3954,11 @@ var WorktreeSyncService = class {
|
|
|
3855
3954
|
this.gitService.setLfsSkipEnabled(false);
|
|
3856
3955
|
}
|
|
3857
3956
|
this.syncInProgress = false;
|
|
3957
|
+
try {
|
|
3958
|
+
await release();
|
|
3959
|
+
} catch (releaseError) {
|
|
3960
|
+
this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
|
|
3961
|
+
}
|
|
3858
3962
|
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
3859
3963
|
`);
|
|
3860
3964
|
if (this.config.debug) {
|
|
@@ -3866,6 +3970,39 @@ var WorktreeSyncService = class {
|
|
|
3866
3970
|
}
|
|
3867
3971
|
return { started: true };
|
|
3868
3972
|
}
|
|
3973
|
+
async acquireBareLock() {
|
|
3974
|
+
if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
3975
|
+
return async () => {
|
|
3976
|
+
};
|
|
3977
|
+
}
|
|
3978
|
+
if (typeof this.gitService.getBareRepoPath !== "function") {
|
|
3979
|
+
return async () => {
|
|
3980
|
+
};
|
|
3981
|
+
}
|
|
3982
|
+
const barePath = this.gitService.getBareRepoPath();
|
|
3983
|
+
const lockTarget = path8.join(barePath, "HEAD");
|
|
3984
|
+
try {
|
|
3985
|
+
await fs6.access(lockTarget);
|
|
3986
|
+
} catch {
|
|
3987
|
+
return async () => {
|
|
3988
|
+
};
|
|
3989
|
+
}
|
|
3990
|
+
try {
|
|
3991
|
+
const release = await lockfile.lock(lockTarget, {
|
|
3992
|
+
stale: DEFAULT_CONFIG.LOCK_STALE_MS,
|
|
3993
|
+
update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
|
|
3994
|
+
retries: 0,
|
|
3995
|
+
realpath: false
|
|
3996
|
+
});
|
|
3997
|
+
return release;
|
|
3998
|
+
} catch (error) {
|
|
3999
|
+
const code = error.code;
|
|
4000
|
+
if (code === "ELOCKED") {
|
|
4001
|
+
return null;
|
|
4002
|
+
}
|
|
4003
|
+
throw error;
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
3869
4006
|
createRetryOptions(syncContext) {
|
|
3870
4007
|
return {
|
|
3871
4008
|
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
@@ -4041,12 +4178,12 @@ var WorktreeSyncService = class {
|
|
|
4041
4178
|
}
|
|
4042
4179
|
const reservedPaths = /* @__PURE__ */ new Map();
|
|
4043
4180
|
for (const w of worktrees) {
|
|
4044
|
-
reservedPaths.set(
|
|
4181
|
+
reservedPaths.set(path8.resolve(w.path), w.branch);
|
|
4045
4182
|
}
|
|
4046
4183
|
const plan = [];
|
|
4047
4184
|
for (const branchName of newBranches) {
|
|
4048
4185
|
const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
|
|
4049
|
-
const resolved =
|
|
4186
|
+
const resolved = path8.resolve(worktreePath);
|
|
4050
4187
|
const conflict = reservedPaths.get(resolved);
|
|
4051
4188
|
if (conflict && conflict !== branchName) {
|
|
4052
4189
|
this.logger.error(
|
|
@@ -4251,12 +4388,12 @@ var WorktreeSyncService = class {
|
|
|
4251
4388
|
}
|
|
4252
4389
|
async updateExistingWorktrees(worktrees, remoteBranches) {
|
|
4253
4390
|
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
4254
|
-
const divergedDir =
|
|
4391
|
+
const divergedDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4255
4392
|
try {
|
|
4256
4393
|
const diverged = await fs6.readdir(divergedDir);
|
|
4257
4394
|
if (diverged.length > 0) {
|
|
4258
4395
|
this.logger.info(
|
|
4259
|
-
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${
|
|
4396
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path8.relative(process.cwd(), divergedDir)}`
|
|
4260
4397
|
);
|
|
4261
4398
|
}
|
|
4262
4399
|
} catch {
|
|
@@ -4286,7 +4423,23 @@ var WorktreeSyncService = class {
|
|
|
4286
4423
|
return { action: "diverged", worktree };
|
|
4287
4424
|
}
|
|
4288
4425
|
const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
|
|
4289
|
-
|
|
4426
|
+
if (!isBehind) return null;
|
|
4427
|
+
const sparseCfg = this.config.sparseCheckout;
|
|
4428
|
+
if (sparseCfg && sparseCfg.skipUpdateWhenOutsideSparse !== false) {
|
|
4429
|
+
const sparseService = this.gitService.getSparseCheckoutService();
|
|
4430
|
+
if (sparseService.resolveMode(sparseCfg) === "cone") {
|
|
4431
|
+
const diff = await this.gitService.getChangedPathsInRange(
|
|
4432
|
+
worktree.path,
|
|
4433
|
+
"HEAD",
|
|
4434
|
+
`origin/${worktree.branch}`
|
|
4435
|
+
);
|
|
4436
|
+
if (diff !== null && !sparseService.pathsTouchSparse(diff, sparseCfg)) {
|
|
4437
|
+
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - upstream changes outside sparse paths`);
|
|
4438
|
+
return null;
|
|
4439
|
+
}
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
return { action: "update", worktree };
|
|
4290
4443
|
})
|
|
4291
4444
|
)
|
|
4292
4445
|
);
|
|
@@ -4364,13 +4517,13 @@ var WorktreeSyncService = class {
|
|
|
4364
4517
|
}
|
|
4365
4518
|
async cleanupOrphanedDirectories(worktrees) {
|
|
4366
4519
|
try {
|
|
4367
|
-
const worktreeRelativePaths = worktrees.map((w) =>
|
|
4520
|
+
const worktreeRelativePaths = worktrees.map((w) => path8.relative(this.config.worktreeDir, w.path));
|
|
4368
4521
|
const allDirs = await fs6.readdir(this.config.worktreeDir);
|
|
4369
4522
|
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
4370
4523
|
const orphanedDirs = [];
|
|
4371
4524
|
for (const dir of regularDirs) {
|
|
4372
4525
|
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
4373
|
-
return worktreePath === dir || worktreePath.startsWith(dir +
|
|
4526
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path8.sep);
|
|
4374
4527
|
});
|
|
4375
4528
|
if (!isPartOfWorktree) {
|
|
4376
4529
|
orphanedDirs.push(dir);
|
|
@@ -4379,7 +4532,7 @@ var WorktreeSyncService = class {
|
|
|
4379
4532
|
if (orphanedDirs.length > 0) {
|
|
4380
4533
|
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
4381
4534
|
for (const dir of orphanedDirs) {
|
|
4382
|
-
const dirPath =
|
|
4535
|
+
const dirPath = path8.join(this.config.worktreeDir, dir);
|
|
4383
4536
|
try {
|
|
4384
4537
|
const stat3 = await fs6.stat(dirPath);
|
|
4385
4538
|
if (stat3.isDirectory()) {
|
|
@@ -4413,7 +4566,7 @@ var WorktreeSyncService = class {
|
|
|
4413
4566
|
} else {
|
|
4414
4567
|
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
4415
4568
|
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
4416
|
-
const relativePath =
|
|
4569
|
+
const relativePath = path8.relative(process.cwd(), divergedPath);
|
|
4417
4570
|
this.logger.info(` Moved to: ${relativePath}`);
|
|
4418
4571
|
this.logger.info(` Your local changes are preserved. To review:`);
|
|
4419
4572
|
this.logger.info(` cd ${relativePath}`);
|
|
@@ -4437,12 +4590,12 @@ var WorktreeSyncService = class {
|
|
|
4437
4590
|
}
|
|
4438
4591
|
}
|
|
4439
4592
|
async divergeWorktree(worktreePath, branchName) {
|
|
4440
|
-
const divergedBaseDir =
|
|
4593
|
+
const divergedBaseDir = path8.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
4441
4594
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4442
4595
|
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
4443
4596
|
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
4444
4597
|
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
4445
|
-
const divergedPath =
|
|
4598
|
+
const divergedPath = path8.join(divergedBaseDir, divergedName);
|
|
4446
4599
|
await fs6.mkdir(divergedBaseDir, { recursive: true });
|
|
4447
4600
|
try {
|
|
4448
4601
|
await fs6.rename(worktreePath, divergedPath);
|
|
@@ -4469,7 +4622,7 @@ var WorktreeSyncService = class {
|
|
|
4469
4622
|
Original worktree location: ${worktreePath}`
|
|
4470
4623
|
};
|
|
4471
4624
|
await fs6.writeFile(
|
|
4472
|
-
|
|
4625
|
+
path8.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
4473
4626
|
JSON.stringify(metadata, null, 2)
|
|
4474
4627
|
);
|
|
4475
4628
|
return divergedPath;
|
|
@@ -4478,7 +4631,7 @@ var WorktreeSyncService = class {
|
|
|
4478
4631
|
|
|
4479
4632
|
// src/services/file-copy.service.ts
|
|
4480
4633
|
import * as fs7 from "fs/promises";
|
|
4481
|
-
import * as
|
|
4634
|
+
import * as path9 from "path";
|
|
4482
4635
|
import { glob } from "glob";
|
|
4483
4636
|
var DEFAULT_IGNORE_PATTERNS = [
|
|
4484
4637
|
"**/node_modules/**",
|
|
@@ -4505,8 +4658,8 @@ var FileCopyService = class {
|
|
|
4505
4658
|
}
|
|
4506
4659
|
const filesToCopy = await this.expandPatterns(sourceDir, patterns);
|
|
4507
4660
|
for (const relativePath of filesToCopy) {
|
|
4508
|
-
const sourcePath =
|
|
4509
|
-
const destPath =
|
|
4661
|
+
const sourcePath = path9.join(sourceDir, relativePath);
|
|
4662
|
+
const destPath = path9.join(destDir, relativePath);
|
|
4510
4663
|
try {
|
|
4511
4664
|
const copied = await this.copyFile(sourcePath, destPath);
|
|
4512
4665
|
if (copied) {
|
|
@@ -4547,7 +4700,7 @@ var FileCopyService = class {
|
|
|
4547
4700
|
return false;
|
|
4548
4701
|
} catch {
|
|
4549
4702
|
}
|
|
4550
|
-
const destDir =
|
|
4703
|
+
const destDir = path9.dirname(destPath);
|
|
4551
4704
|
await fs7.mkdir(destDir, { recursive: true });
|
|
4552
4705
|
await fs7.copyFile(sourcePath, destPath);
|
|
4553
4706
|
return true;
|
|
@@ -4765,6 +4918,8 @@ var AppEventEmitter = class {
|
|
|
4765
4918
|
|
|
4766
4919
|
// src/services/InteractiveUIService.tsx
|
|
4767
4920
|
import * as fs8 from "fs/promises";
|
|
4921
|
+
var WAIT_SYNC_FAST_TIMEOUT_MS = 2e3;
|
|
4922
|
+
var WAIT_SYNC_DEFAULT_TIMEOUT_MS = 3e4;
|
|
4768
4923
|
var InteractiveUIService = class {
|
|
4769
4924
|
app = null;
|
|
4770
4925
|
syncServices;
|
|
@@ -4872,6 +5027,9 @@ var InteractiveUIService = class {
|
|
|
4872
5027
|
}
|
|
4873
5028
|
this.cronJobs = [];
|
|
4874
5029
|
}
|
|
5030
|
+
registerCronJob(job) {
|
|
5031
|
+
this.cronJobs.push(job);
|
|
5032
|
+
}
|
|
4875
5033
|
renderUI() {
|
|
4876
5034
|
if (this.app) {
|
|
4877
5035
|
this.app.unmount();
|
|
@@ -4895,8 +5053,8 @@ var InteractiveUIService = class {
|
|
|
4895
5053
|
getWorktreeStatusForRepo: (index) => this.getWorktreeStatusForRepo(index),
|
|
4896
5054
|
getDivergedDirectoriesForRepo: (index) => this.getDivergedDirectoriesForRepo(index),
|
|
4897
5055
|
deleteDivergedDirectory: (repoIndex, name) => this.deleteDivergedDirectory(repoIndex, name),
|
|
4898
|
-
openEditorInWorktree: (
|
|
4899
|
-
openTerminalInWorktree: (repoIndex,
|
|
5056
|
+
openEditorInWorktree: (path14) => this.openEditorInWorktree(path14),
|
|
5057
|
+
openTerminalInWorktree: (repoIndex, path14, branchName) => this.openTerminalInWorktree(repoIndex, path14, branchName),
|
|
4900
5058
|
copyBranchFiles: (repoIndex, baseBranch, targetBranch) => this.copyBranchFiles(repoIndex, baseBranch, targetBranch),
|
|
4901
5059
|
createWorktreeForBranch: (repoIndex, branchName) => this.createWorktreeForBranch(repoIndex, branchName),
|
|
4902
5060
|
executeOnBranchCreatedHooks: (repoIndex, context) => this.executeOnBranchCreatedHooks(repoIndex, context)
|
|
@@ -4986,18 +5144,17 @@ var InteractiveUIService = class {
|
|
|
4986
5144
|
await this.destroy();
|
|
4987
5145
|
process.exit(0);
|
|
4988
5146
|
}
|
|
4989
|
-
async waitForInProgressSyncs() {
|
|
5147
|
+
async waitForInProgressSyncs(timeoutMs = WAIT_SYNC_DEFAULT_TIMEOUT_MS) {
|
|
4990
5148
|
const inProgressServices = this.syncServices.filter((s) => s.isSyncInProgress());
|
|
4991
5149
|
if (inProgressServices.length === 0) {
|
|
4992
5150
|
return;
|
|
4993
5151
|
}
|
|
4994
5152
|
this.addLog(`Waiting for ${inProgressServices.length} in-progress sync(s) to finish...`, "info");
|
|
4995
5153
|
const syncChecks = inProgressServices.map(async (service) => {
|
|
4996
|
-
const timeout = 3e4;
|
|
4997
5154
|
const checkInterval = 500;
|
|
4998
5155
|
const startTime = Date.now();
|
|
4999
5156
|
while (service.isSyncInProgress()) {
|
|
5000
|
-
if (Date.now() - startTime >
|
|
5157
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
5001
5158
|
throw new Error("Timeout waiting for sync operations to complete");
|
|
5002
5159
|
}
|
|
5003
5160
|
await new Promise((resolve9) => setTimeout(resolve9, checkInterval));
|
|
@@ -5007,7 +5164,7 @@ var InteractiveUIService = class {
|
|
|
5007
5164
|
await Promise.all(syncChecks);
|
|
5008
5165
|
} catch {
|
|
5009
5166
|
this.addLog(
|
|
5010
|
-
|
|
5167
|
+
`Warning: Timeout waiting for sync operations to complete after ${formatDuration2(timeoutMs)}. Proceeding with potential data loss risk.`,
|
|
5011
5168
|
"warn"
|
|
5012
5169
|
);
|
|
5013
5170
|
}
|
|
@@ -5133,7 +5290,7 @@ var InteractiveUIService = class {
|
|
|
5133
5290
|
}
|
|
5134
5291
|
const service = this.syncServices[repoIndex];
|
|
5135
5292
|
const worktreeDir = service.config.worktreeDir;
|
|
5136
|
-
const divergedDir =
|
|
5293
|
+
const divergedDir = path10.join(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5137
5294
|
let dirEntries;
|
|
5138
5295
|
try {
|
|
5139
5296
|
dirEntries = await fs8.readdir(divergedDir, { withFileTypes: true, encoding: "utf-8" });
|
|
@@ -5143,8 +5300,8 @@ var InteractiveUIService = class {
|
|
|
5143
5300
|
const subdirs = dirEntries.filter((e) => e.isDirectory());
|
|
5144
5301
|
const results = await Promise.allSettled(
|
|
5145
5302
|
subdirs.map(async (entry) => {
|
|
5146
|
-
const fullPath =
|
|
5147
|
-
const infoFilePath =
|
|
5303
|
+
const fullPath = path10.join(divergedDir, entry.name);
|
|
5304
|
+
const infoFilePath = path10.join(fullPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE);
|
|
5148
5305
|
let originalBranch = entry.name;
|
|
5149
5306
|
let divergedAt = "";
|
|
5150
5307
|
try {
|
|
@@ -5179,11 +5336,11 @@ var InteractiveUIService = class {
|
|
|
5179
5336
|
}
|
|
5180
5337
|
const service = this.syncServices[repoIndex];
|
|
5181
5338
|
const worktreeDir = service.config.worktreeDir;
|
|
5182
|
-
const divergedBase =
|
|
5339
|
+
const divergedBase = path10.resolve(worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
5183
5340
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
5184
5341
|
throw new Error(`Invalid diverged directory name: "${name}"`);
|
|
5185
5342
|
}
|
|
5186
|
-
const targetPath =
|
|
5343
|
+
const targetPath = path10.join(divergedBase, name);
|
|
5187
5344
|
if (!this.pathResolution.isPathInsideBaseDir(targetPath, divergedBase)) {
|
|
5188
5345
|
throw new Error(`Path traversal rejected: "${name}" resolves outside the diverged directory`);
|
|
5189
5346
|
}
|
|
@@ -5422,11 +5579,11 @@ var InteractiveUIService = class {
|
|
|
5422
5579
|
this.addLog(`Failed to copy files to new branch: ${error}`, "error");
|
|
5423
5580
|
}
|
|
5424
5581
|
}
|
|
5425
|
-
async destroy() {
|
|
5582
|
+
async destroy(fast = false) {
|
|
5426
5583
|
this.isDestroyed = true;
|
|
5427
5584
|
this.cancelCronJobs();
|
|
5428
5585
|
try {
|
|
5429
|
-
await this.waitForInProgressSyncs();
|
|
5586
|
+
await this.waitForInProgressSyncs(fast ? WAIT_SYNC_FAST_TIMEOUT_MS : WAIT_SYNC_DEFAULT_TIMEOUT_MS);
|
|
5430
5587
|
} catch {
|
|
5431
5588
|
}
|
|
5432
5589
|
this.hookExecutionService.cleanup();
|
|
@@ -5572,7 +5729,7 @@ function reconstructCliCommand(config) {
|
|
|
5572
5729
|
|
|
5573
5730
|
// src/utils/config-generator.ts
|
|
5574
5731
|
import * as fs9 from "fs/promises";
|
|
5575
|
-
import * as
|
|
5732
|
+
import * as path11 from "path";
|
|
5576
5733
|
function serializeToESM(obj, indent = 0) {
|
|
5577
5734
|
const spaces = " ".repeat(indent);
|
|
5578
5735
|
const innerSpaces = " ".repeat(indent + 2);
|
|
@@ -5602,9 +5759,9 @@ ${spaces}}`;
|
|
|
5602
5759
|
return String(obj);
|
|
5603
5760
|
}
|
|
5604
5761
|
async function generateConfigFile(config, configPath) {
|
|
5605
|
-
const configDir =
|
|
5762
|
+
const configDir = path11.dirname(configPath);
|
|
5606
5763
|
await fs9.mkdir(configDir, { recursive: true });
|
|
5607
|
-
const worktreeDirRelative =
|
|
5764
|
+
const worktreeDirRelative = path11.relative(configDir, config.worktreeDir);
|
|
5608
5765
|
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
5609
5766
|
const repoName = extractRepoNameFromUrl(config.repoUrl);
|
|
5610
5767
|
const repository = {
|
|
@@ -5613,7 +5770,7 @@ async function generateConfigFile(config, configPath) {
|
|
|
5613
5770
|
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
|
|
5614
5771
|
};
|
|
5615
5772
|
if (config.bareRepoDir) {
|
|
5616
|
-
const bareRepoDirRelative =
|
|
5773
|
+
const bareRepoDirRelative = path11.relative(configDir, config.bareRepoDir);
|
|
5617
5774
|
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
5618
5775
|
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
|
|
5619
5776
|
}
|
|
@@ -5634,11 +5791,11 @@ export default ${serializeToESM(configObject)};
|
|
|
5634
5791
|
await fs9.writeFile(configPath, configContent, "utf-8");
|
|
5635
5792
|
}
|
|
5636
5793
|
function getDefaultConfigPath() {
|
|
5637
|
-
return
|
|
5794
|
+
return path11.join(process.cwd(), "sync-worktrees.config.js");
|
|
5638
5795
|
}
|
|
5639
5796
|
async function findConfigInCwd(cwd = process.cwd()) {
|
|
5640
5797
|
for (const name of CONFIG_FILE_NAMES) {
|
|
5641
|
-
const full =
|
|
5798
|
+
const full = path11.join(cwd, name);
|
|
5642
5799
|
try {
|
|
5643
5800
|
await fs9.access(full);
|
|
5644
5801
|
return full;
|
|
@@ -5649,7 +5806,7 @@ async function findConfigInCwd(cwd = process.cwd()) {
|
|
|
5649
5806
|
}
|
|
5650
5807
|
|
|
5651
5808
|
// src/utils/interactive.ts
|
|
5652
|
-
import * as
|
|
5809
|
+
import * as path12 from "path";
|
|
5653
5810
|
import { confirm, input, select } from "@inquirer/prompts";
|
|
5654
5811
|
async function promptForConfig(partialConfig) {
|
|
5655
5812
|
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
@@ -5689,8 +5846,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5689
5846
|
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
5690
5847
|
worktreeDir = defaultWorktreeDir;
|
|
5691
5848
|
}
|
|
5692
|
-
if (!
|
|
5693
|
-
worktreeDir =
|
|
5849
|
+
if (!path12.isAbsolute(worktreeDir)) {
|
|
5850
|
+
worktreeDir = path12.resolve(worktreeDir);
|
|
5694
5851
|
}
|
|
5695
5852
|
}
|
|
5696
5853
|
let bareRepoDir = partialConfig.bareRepoDir;
|
|
@@ -5709,8 +5866,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5709
5866
|
return true;
|
|
5710
5867
|
}
|
|
5711
5868
|
});
|
|
5712
|
-
if (!
|
|
5713
|
-
bareRepoDir =
|
|
5869
|
+
if (!path12.isAbsolute(bareRepoDir)) {
|
|
5870
|
+
bareRepoDir = path12.resolve(bareRepoDir);
|
|
5714
5871
|
}
|
|
5715
5872
|
}
|
|
5716
5873
|
let runOnce = partialConfig.runOnce;
|
|
@@ -5782,8 +5939,8 @@ async function promptForConfig(partialConfig) {
|
|
|
5782
5939
|
return true;
|
|
5783
5940
|
}
|
|
5784
5941
|
});
|
|
5785
|
-
if (!
|
|
5786
|
-
configPath =
|
|
5942
|
+
if (!path12.isAbsolute(configPath)) {
|
|
5943
|
+
configPath = path12.resolve(configPath);
|
|
5787
5944
|
}
|
|
5788
5945
|
try {
|
|
5789
5946
|
await generateConfigFile(finalConfig, configPath);
|
|
@@ -5801,26 +5958,55 @@ async function promptForConfig(partialConfig) {
|
|
|
5801
5958
|
return { config: finalConfig, savedConfigPath };
|
|
5802
5959
|
}
|
|
5803
5960
|
|
|
5804
|
-
// src/
|
|
5805
|
-
var
|
|
5806
|
-
function setupSignalHandlers() {
|
|
5807
|
-
|
|
5808
|
-
const
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5961
|
+
// src/utils/signal-handlers.ts
|
|
5962
|
+
var DEFAULT_FORCE_EXIT_MS = 3e3;
|
|
5963
|
+
function setupSignalHandlers(options = {}) {
|
|
5964
|
+
const forceExitMs = options.forceExitMs ?? DEFAULT_FORCE_EXIT_MS;
|
|
5965
|
+
const log = options.log ?? ((msg) => console.log(msg));
|
|
5966
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
5967
|
+
const target = options.process ?? process;
|
|
5968
|
+
const cleanupFns = [];
|
|
5969
|
+
let signalCount = 0;
|
|
5970
|
+
const handler = (signal) => {
|
|
5971
|
+
signalCount += 1;
|
|
5972
|
+
if (signalCount >= 2) {
|
|
5973
|
+
log(`
|
|
5974
|
+
Received second ${signal}, forcing exit.`);
|
|
5975
|
+
exit(130);
|
|
5976
|
+
return;
|
|
5977
|
+
}
|
|
5978
|
+
log(`
|
|
5979
|
+
Received ${signal}, shutting down (Ctrl+C again to force exit)...`);
|
|
5980
|
+
const watchdog = setTimeout(() => {
|
|
5981
|
+
log(`
|
|
5982
|
+
Shutdown took longer than ${forceExitMs}ms, forcing exit.`);
|
|
5983
|
+
exit(130);
|
|
5984
|
+
}, forceExitMs);
|
|
5985
|
+
if (typeof watchdog.unref === "function") {
|
|
5986
|
+
watchdog.unref();
|
|
5987
|
+
}
|
|
5988
|
+
void Promise.allSettled(cleanupFns.map((fn) => Promise.resolve().then(() => fn(true)))).then(() => {
|
|
5989
|
+
clearTimeout(watchdog);
|
|
5990
|
+
exit(0);
|
|
5991
|
+
});
|
|
5992
|
+
};
|
|
5993
|
+
const sigintListener = () => handler("SIGINT");
|
|
5994
|
+
const sigtermListener = () => handler("SIGTERM");
|
|
5995
|
+
target.on("SIGINT", sigintListener);
|
|
5996
|
+
target.on("SIGTERM", sigtermListener);
|
|
5997
|
+
return {
|
|
5998
|
+
register: (fn) => {
|
|
5999
|
+
cleanupFns.push(fn);
|
|
6000
|
+
},
|
|
6001
|
+
dispose: () => {
|
|
6002
|
+
target.removeListener("SIGINT", sigintListener);
|
|
6003
|
+
target.removeListener("SIGTERM", sigtermListener);
|
|
5818
6004
|
}
|
|
5819
|
-
process.exit(0);
|
|
5820
6005
|
};
|
|
5821
|
-
process.on("SIGINT", () => void handler("SIGINT"));
|
|
5822
|
-
process.on("SIGTERM", () => void handler("SIGTERM"));
|
|
5823
6006
|
}
|
|
6007
|
+
|
|
6008
|
+
// src/index.ts
|
|
6009
|
+
var signalHandle = setupSignalHandlers();
|
|
5824
6010
|
async function runSingleRepository(config) {
|
|
5825
6011
|
const logger = Logger.createDefault(void 0, config.debug);
|
|
5826
6012
|
logger.info("\n\u{1F4CB} CLI Command (for future reference):");
|
|
@@ -5837,11 +6023,11 @@ async function runSingleRepository(config) {
|
|
|
5837
6023
|
await syncService.sync();
|
|
5838
6024
|
} else {
|
|
5839
6025
|
const uiService = new InteractiveUIService([syncService], void 0, config.cronSchedule);
|
|
5840
|
-
|
|
6026
|
+
signalHandle.register((fast) => uiService.destroy(fast));
|
|
5841
6027
|
await syncService.sync();
|
|
5842
6028
|
uiService.updateLastSyncTime();
|
|
5843
6029
|
void uiService.calculateAndUpdateDiskSpace();
|
|
5844
|
-
cron3.schedule(config.cronSchedule, async () => {
|
|
6030
|
+
const job = cron3.schedule(config.cronSchedule, async () => {
|
|
5845
6031
|
try {
|
|
5846
6032
|
uiService.setStatus("syncing");
|
|
5847
6033
|
await syncService.sync();
|
|
@@ -5852,6 +6038,7 @@ async function runSingleRepository(config) {
|
|
|
5852
6038
|
uiService.setStatus("idle");
|
|
5853
6039
|
}
|
|
5854
6040
|
});
|
|
6041
|
+
uiService.registerCronJob(job);
|
|
5855
6042
|
}
|
|
5856
6043
|
} catch (error) {
|
|
5857
6044
|
logger.error("\u274C Fatal Error during initialization:", error);
|
|
@@ -5918,7 +6105,7 @@ async function runMultipleRepositories(repositories, runOnce, configPath, maxPar
|
|
|
5918
6105
|
const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
|
|
5919
6106
|
const allServices = Array.from(services.values());
|
|
5920
6107
|
const uiService = new InteractiveUIService(allServices, configPath, displaySchedule, maxParallel, reloadOptions);
|
|
5921
|
-
|
|
6108
|
+
signalHandle.register((fast) => uiService.destroy(fast));
|
|
5922
6109
|
void uiService.calculateAndUpdateDiskSpace();
|
|
5923
6110
|
uiService.setupCronJobs();
|
|
5924
6111
|
uiService.addLog(`\u{1F4CB} ${repositories.length} repositories configured`);
|
|
@@ -6013,13 +6200,12 @@ async function runInteractive(partial, options) {
|
|
|
6013
6200
|
await runSingleRepository(config);
|
|
6014
6201
|
}
|
|
6015
6202
|
async function main() {
|
|
6016
|
-
setupSignalHandlers();
|
|
6017
6203
|
const options = parseArguments();
|
|
6018
6204
|
if (!options.config && !options.repoUrl && !options.worktreeDir) {
|
|
6019
6205
|
const discovered = await findConfigInCwd();
|
|
6020
6206
|
if (discovered) {
|
|
6021
6207
|
options.config = discovered;
|
|
6022
|
-
console.log(`\u{1F4C4} Using config: ${
|
|
6208
|
+
console.log(`\u{1F4C4} Using config: ${path13.relative(process.cwd(), discovered)}`);
|
|
6023
6209
|
}
|
|
6024
6210
|
}
|
|
6025
6211
|
if (options.config) {
|