sync-worktrees 1.8.0 → 2.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 +4 -4
- package/dist/index.js +3324 -197
- package/dist/index.js.map +7 -1
- package/package.json +21 -11
- package/dist/constants.d.ts +0 -54
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -66
- package/dist/constants.js.map +0 -1
- package/dist/errors/index.d.ts +0 -51
- package/dist/errors/index.d.ts.map +0 -1
- package/dist/errors/index.js +0 -119
- package/dist/errors/index.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/services/config-loader.service.d.ts +0 -9
- package/dist/services/config-loader.service.d.ts.map +0 -1
- package/dist/services/config-loader.service.js +0 -193
- package/dist/services/config-loader.service.js.map +0 -1
- package/dist/services/git.service.d.ts +0 -51
- package/dist/services/git.service.d.ts.map +0 -1
- package/dist/services/git.service.js +0 -749
- package/dist/services/git.service.js.map +0 -1
- package/dist/services/path-resolution.service.d.ts +0 -7
- package/dist/services/path-resolution.service.d.ts.map +0 -1
- package/dist/services/path-resolution.service.js +0 -58
- package/dist/services/path-resolution.service.js.map +0 -1
- package/dist/services/worktree-metadata.service.d.ts +0 -22
- package/dist/services/worktree-metadata.service.d.ts.map +0 -1
- package/dist/services/worktree-metadata.service.js +0 -276
- package/dist/services/worktree-metadata.service.js.map +0 -1
- package/dist/services/worktree-status.service.d.ts +0 -28
- package/dist/services/worktree-status.service.d.ts.map +0 -1
- package/dist/services/worktree-status.service.js +0 -229
- package/dist/services/worktree-status.service.js.map +0 -1
- package/dist/services/worktree-sync.service.d.ts +0 -17
- package/dist/services/worktree-sync.service.d.ts.map +0 -1
- package/dist/services/worktree-sync.service.js +0 -454
- package/dist/services/worktree-sync.service.js.map +0 -1
- package/dist/types/index.d.ts +0 -32
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -3
- package/dist/types/index.js.map +0 -1
- package/dist/types/sync-metadata.d.ts +0 -16
- package/dist/types/sync-metadata.d.ts.map +0 -1
- package/dist/types/sync-metadata.js +0 -3
- package/dist/types/sync-metadata.js.map +0 -1
- package/dist/utils/cli.d.ts +0 -14
- package/dist/utils/cli.d.ts.map +0 -1
- package/dist/utils/cli.js +0 -117
- package/dist/utils/cli.js.map +0 -1
- package/dist/utils/config-generator.d.ts +0 -4
- package/dist/utils/config-generator.d.ts.map +0 -1
- package/dist/utils/config-generator.js +0 -112
- package/dist/utils/config-generator.js.map +0 -1
- package/dist/utils/date-filter.d.ts +0 -10
- package/dist/utils/date-filter.d.ts.map +0 -1
- package/dist/utils/date-filter.js +0 -47
- package/dist/utils/date-filter.js.map +0 -1
- package/dist/utils/git-url.d.ts +0 -15
- package/dist/utils/git-url.d.ts.map +0 -1
- package/dist/utils/git-url.js +0 -46
- package/dist/utils/git-url.js.map +0 -1
- package/dist/utils/interactive.d.ts +0 -3
- package/dist/utils/interactive.d.ts.map +0 -1
- package/dist/utils/interactive.js +0 -195
- package/dist/utils/interactive.js.map +0 -1
- package/dist/utils/lfs-error.d.ts +0 -23
- package/dist/utils/lfs-error.d.ts.map +0 -1
- package/dist/utils/lfs-error.js +0 -45
- package/dist/utils/lfs-error.js.map +0 -1
- package/dist/utils/retry.d.ts +0 -15
- package/dist/utils/retry.d.ts.map +0 -1
- package/dist/utils/retry.js +0 -78
- package/dist/utils/retry.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,231 +1,3358 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import * as path8 from "path";
|
|
5
|
+
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
6
|
+
import * as cron2 from "node-cron";
|
|
7
|
+
import pLimit2 from "p-limit";
|
|
8
|
+
|
|
9
|
+
// src/constants.ts
|
|
10
|
+
var GIT_CONSTANTS = {
|
|
11
|
+
REMOTE_PREFIX: "origin/",
|
|
12
|
+
REMOTE_NAME: "origin",
|
|
13
|
+
HEAD_REF: "/HEAD",
|
|
14
|
+
DEFAULT_BRANCH: "main",
|
|
15
|
+
COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
|
|
16
|
+
BARE_DIR_NAME: ".bare",
|
|
17
|
+
DIVERGED_DIR_NAME: ".diverged",
|
|
18
|
+
LFS_HEADER: "version https://git-lfs.github.com/spec/",
|
|
19
|
+
SUBMODULE_STATUS_ADDED: "+",
|
|
20
|
+
SUBMODULE_STATUS_REMOVED: "-",
|
|
21
|
+
GITDIR_PREFIX: "gitdir:",
|
|
22
|
+
GIT_CHECK_IGNORE_NO_MATCH: "exit code: 1",
|
|
23
|
+
REFS: {
|
|
24
|
+
HEADS: "refs/heads/",
|
|
25
|
+
REMOTES: "refs/remotes/origin",
|
|
26
|
+
REMOTES_ORIGIN: "refs/remotes/origin/*"
|
|
27
|
+
},
|
|
28
|
+
FETCH_CONFIG: "+refs/heads/*:refs/remotes/origin/*"
|
|
29
|
+
};
|
|
30
|
+
var GIT_OPERATIONS = {
|
|
31
|
+
MERGE_HEAD: "MERGE_HEAD",
|
|
32
|
+
CHERRY_PICK_HEAD: "CHERRY_PICK_HEAD",
|
|
33
|
+
REVERT_HEAD: "REVERT_HEAD",
|
|
34
|
+
BISECT_LOG: "BISECT_LOG",
|
|
35
|
+
REBASE_MERGE: "rebase-merge",
|
|
36
|
+
REBASE_APPLY: "rebase-apply"
|
|
37
|
+
};
|
|
38
|
+
var DEFAULT_CONFIG = {
|
|
39
|
+
CRON_SCHEDULE: "0 * * * *",
|
|
40
|
+
RETRY: {
|
|
41
|
+
MAX_ATTEMPTS: 3,
|
|
42
|
+
MAX_LFS_RETRIES: 2,
|
|
43
|
+
INITIAL_DELAY_MS: 1e3,
|
|
44
|
+
MAX_DELAY_MS: 3e4,
|
|
45
|
+
BACKOFF_MULTIPLIER: 2,
|
|
46
|
+
JITTER_MS: 500
|
|
47
|
+
},
|
|
48
|
+
PARALLELISM: {
|
|
49
|
+
MAX_REPOSITORIES: 2,
|
|
50
|
+
MAX_WORKTREE_CREATION: 1,
|
|
51
|
+
MAX_WORKTREE_UPDATES: 3,
|
|
52
|
+
MAX_WORKTREE_REMOVAL: 3,
|
|
53
|
+
MAX_STATUS_CHECKS: 20,
|
|
54
|
+
MAX_SAFE_TOTAL_CONCURRENT_OPS: 100
|
|
55
|
+
},
|
|
56
|
+
UPDATE_EXISTING_WORKTREES: true
|
|
57
|
+
};
|
|
58
|
+
var ERROR_MESSAGES = {
|
|
59
|
+
GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
|
|
60
|
+
ALREADY_EXISTS: "already exists",
|
|
61
|
+
ALREADY_REGISTERED: "already registered worktree",
|
|
62
|
+
FAST_FORWARD_FAILED: [
|
|
63
|
+
"Not possible to fast-forward",
|
|
64
|
+
"fatal: Not possible to fast-forward, aborting",
|
|
65
|
+
"cannot fast-forward"
|
|
66
|
+
],
|
|
67
|
+
NO_UPSTREAM: [
|
|
68
|
+
"fatal: no upstream configured",
|
|
69
|
+
"no upstream configured for branch",
|
|
70
|
+
"fatal: ambiguous argument",
|
|
71
|
+
"unknown revision or path"
|
|
72
|
+
],
|
|
73
|
+
LFS_ERROR: ["smudge filter lfs failed", "git-lfs", "LFS"],
|
|
74
|
+
EXDEV: "EXDEV"
|
|
75
|
+
};
|
|
76
|
+
var ENV_CONSTANTS = {
|
|
77
|
+
GIT_LFS_SKIP_SMUDGE: "GIT_LFS_SKIP_SMUDGE",
|
|
78
|
+
NODE_ENV_TEST: "test"
|
|
79
|
+
};
|
|
80
|
+
var PATH_CONSTANTS = {
|
|
81
|
+
GIT_DIR: ".git",
|
|
82
|
+
README: "README"
|
|
83
|
+
};
|
|
84
|
+
var CONFIG_CONSTANTS = {
|
|
85
|
+
WILDCARD_PATTERN: ".*"
|
|
86
|
+
};
|
|
87
|
+
var METADATA_CONSTANTS = {
|
|
88
|
+
MAX_HISTORY_ENTRIES: 10,
|
|
89
|
+
METADATA_FILENAME: "sync-metadata.json",
|
|
90
|
+
WORKTREE_METADATA_PATH: ".git/worktrees",
|
|
91
|
+
DIVERGED_INFO_FILE: ".diverged-info.json",
|
|
92
|
+
DIVERGED_REASON: "diverged-history-with-changes",
|
|
93
|
+
ACTION_CREATED: "created",
|
|
94
|
+
ACTION_UPDATED: "updated",
|
|
95
|
+
ACTION_FETCHED: "fetched"
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/services/config-loader.service.ts
|
|
99
|
+
import * as fs from "fs/promises";
|
|
100
|
+
import * as path from "path";
|
|
101
|
+
import { pathToFileURL } from "url";
|
|
102
|
+
var ConfigLoaderService = class {
|
|
103
|
+
async loadConfigFile(configPath) {
|
|
104
|
+
const absolutePath = path.resolve(configPath);
|
|
105
|
+
try {
|
|
106
|
+
await fs.access(absolutePath);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`Config file not found: ${absolutePath}`);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const fileUrl = pathToFileURL(absolutePath);
|
|
112
|
+
fileUrl.searchParams.set("t", Date.now().toString());
|
|
113
|
+
const configModule = await import(fileUrl.href);
|
|
114
|
+
const config = configModule.default;
|
|
115
|
+
if (!config) {
|
|
116
|
+
throw new Error("Config file must use 'export default' syntax");
|
|
117
|
+
}
|
|
118
|
+
this.validateConfigFile(config);
|
|
119
|
+
return config;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error instanceof Error && error.message.includes("Config file not found")) {
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Failed to load config file: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
validateConfigFile(config) {
|
|
128
|
+
if (!config || typeof config !== "object") {
|
|
129
|
+
throw new Error("Config file must export an object");
|
|
130
|
+
}
|
|
131
|
+
const configObj = config;
|
|
132
|
+
if (!Array.isArray(configObj.repositories)) {
|
|
133
|
+
throw new Error("Config file must have a 'repositories' array");
|
|
134
|
+
}
|
|
135
|
+
if (configObj.repositories.length === 0) {
|
|
136
|
+
throw new Error("Config file must have at least one repository");
|
|
137
|
+
}
|
|
138
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
139
|
+
configObj.repositories.forEach((repo, index) => {
|
|
140
|
+
if (!repo || typeof repo !== "object") {
|
|
141
|
+
throw new Error(`Repository at index ${index} must be an object`);
|
|
142
|
+
}
|
|
143
|
+
const repoObj = repo;
|
|
144
|
+
if (!repoObj.name || typeof repoObj.name !== "string") {
|
|
145
|
+
throw new Error(`Repository at index ${index} must have a 'name' property`);
|
|
146
|
+
}
|
|
147
|
+
if (seenNames.has(repoObj.name)) {
|
|
148
|
+
throw new Error(`Duplicate repository name: ${repoObj.name}`);
|
|
149
|
+
}
|
|
150
|
+
seenNames.add(repoObj.name);
|
|
151
|
+
if (!repoObj.repoUrl || typeof repoObj.repoUrl !== "string") {
|
|
152
|
+
throw new Error(`Repository '${repoObj.name}' must have a 'repoUrl' property`);
|
|
153
|
+
}
|
|
154
|
+
if (!repoObj.worktreeDir || typeof repoObj.worktreeDir !== "string") {
|
|
155
|
+
throw new Error(`Repository '${repoObj.name}' must have a 'worktreeDir' property`);
|
|
156
|
+
}
|
|
157
|
+
if (repoObj.bareRepoDir !== void 0 && typeof repoObj.bareRepoDir !== "string") {
|
|
158
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'bareRepoDir' property`);
|
|
159
|
+
}
|
|
160
|
+
if (repoObj.cronSchedule !== void 0 && typeof repoObj.cronSchedule !== "string") {
|
|
161
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'cronSchedule' property`);
|
|
162
|
+
}
|
|
163
|
+
if (repoObj.runOnce !== void 0 && typeof repoObj.runOnce !== "boolean") {
|
|
164
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'runOnce' property`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
if (configObj.defaults) {
|
|
168
|
+
if (typeof configObj.defaults !== "object") {
|
|
169
|
+
throw new Error("'defaults' must be an object");
|
|
170
|
+
}
|
|
171
|
+
const defaults = configObj.defaults;
|
|
172
|
+
if (defaults.cronSchedule !== void 0 && typeof defaults.cronSchedule !== "string") {
|
|
173
|
+
throw new Error("Invalid 'cronSchedule' in defaults");
|
|
174
|
+
}
|
|
175
|
+
if (defaults.runOnce !== void 0 && typeof defaults.runOnce !== "boolean") {
|
|
176
|
+
throw new Error("Invalid 'runOnce' in defaults");
|
|
177
|
+
}
|
|
178
|
+
if (defaults.retry !== void 0 && typeof defaults.retry !== "object") {
|
|
179
|
+
throw new Error("Invalid 'retry' in defaults");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (configObj.retry !== void 0) {
|
|
183
|
+
if (typeof configObj.retry !== "object") {
|
|
184
|
+
throw new Error("'retry' must be an object");
|
|
185
|
+
}
|
|
186
|
+
const retry2 = configObj.retry;
|
|
187
|
+
if (retry2.maxAttempts !== void 0) {
|
|
188
|
+
if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
|
|
189
|
+
throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (retry2.maxLfsRetries !== void 0) {
|
|
193
|
+
if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
|
|
194
|
+
throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
|
|
198
|
+
throw new Error("Invalid 'initialDelayMs' in retry config");
|
|
199
|
+
}
|
|
200
|
+
if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
|
|
201
|
+
throw new Error("Invalid 'maxDelayMs' in retry config");
|
|
202
|
+
}
|
|
203
|
+
if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
|
|
204
|
+
throw new Error("Invalid 'backoffMultiplier' in retry config");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (configObj.parallelism !== void 0) {
|
|
208
|
+
this.validateParallelismConfig(configObj.parallelism, "global");
|
|
209
|
+
}
|
|
210
|
+
if (configObj.defaults && typeof configObj.defaults === "object") {
|
|
211
|
+
const defaults = configObj.defaults;
|
|
212
|
+
if (defaults.parallelism !== void 0) {
|
|
213
|
+
this.validateParallelismConfig(defaults.parallelism, "defaults");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
validateParallelismConfig(parallelism, context) {
|
|
218
|
+
if (typeof parallelism !== "object" || parallelism === null) {
|
|
219
|
+
throw new Error(`'parallelism' in ${context} must be an object`);
|
|
220
|
+
}
|
|
221
|
+
const config = parallelism;
|
|
222
|
+
if (config.maxRepositories !== void 0) {
|
|
223
|
+
if (typeof config.maxRepositories !== "number" || config.maxRepositories < 1) {
|
|
224
|
+
throw new Error(`Invalid 'maxRepositories' in ${context} parallelism config. Must be a positive number`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (config.maxWorktreeCreation !== void 0) {
|
|
228
|
+
if (typeof config.maxWorktreeCreation !== "number" || config.maxWorktreeCreation < 1) {
|
|
229
|
+
throw new Error(`Invalid 'maxWorktreeCreation' in ${context} parallelism config. Must be a positive number`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (config.maxWorktreeUpdates !== void 0) {
|
|
233
|
+
if (typeof config.maxWorktreeUpdates !== "number" || config.maxWorktreeUpdates < 1) {
|
|
234
|
+
throw new Error(`Invalid 'maxWorktreeUpdates' in ${context} parallelism config. Must be a positive number`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (config.maxWorktreeRemoval !== void 0) {
|
|
238
|
+
if (typeof config.maxWorktreeRemoval !== "number" || config.maxWorktreeRemoval < 1) {
|
|
239
|
+
throw new Error(`Invalid 'maxWorktreeRemoval' in ${context} parallelism config. Must be a positive number`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (config.maxStatusChecks !== void 0) {
|
|
243
|
+
if (typeof config.maxStatusChecks !== "number" || config.maxStatusChecks < 1) {
|
|
244
|
+
throw new Error(`Invalid 'maxStatusChecks' in ${context} parallelism config. Must be a positive number`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const maxRepos = config.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
|
|
248
|
+
const maxCreation = config.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
249
|
+
const maxUpdates = config.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
250
|
+
const maxRemoval = config.maxWorktreeRemoval ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_REMOVAL;
|
|
251
|
+
const maxStatus = config.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
252
|
+
const maxPerRepoOps = maxCreation + maxUpdates + maxRemoval + maxStatus;
|
|
253
|
+
const totalMaxConcurrent = maxRepos * maxPerRepoOps;
|
|
254
|
+
if (totalMaxConcurrent > DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS) {
|
|
255
|
+
const safeMaxRepos = Math.floor(DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS / maxPerRepoOps);
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Total concurrent operations (${totalMaxConcurrent}) exceeds safe limit (${DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS}). With current per-repository limits (creation: ${maxCreation}, updates: ${maxUpdates}, removal: ${maxRemoval}, status: ${maxStatus}), maximum safe maxRepositories is ${safeMaxRepos}. Consider reducing maxRepositories or lowering per-operation limits.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
resolveRepositoryConfig(repo, defaults, configDir, globalRetry) {
|
|
262
|
+
const resolved = {
|
|
263
|
+
name: repo.name,
|
|
264
|
+
repoUrl: repo.repoUrl,
|
|
265
|
+
worktreeDir: this.resolvePath(repo.worktreeDir, configDir),
|
|
266
|
+
cronSchedule: repo.cronSchedule ?? defaults?.cronSchedule ?? DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
267
|
+
runOnce: repo.runOnce ?? defaults?.runOnce ?? false
|
|
268
|
+
};
|
|
269
|
+
if (repo.bareRepoDir) {
|
|
270
|
+
resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
|
|
271
|
+
}
|
|
272
|
+
if (repo.branchMaxAge || defaults?.branchMaxAge) {
|
|
273
|
+
resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
|
|
274
|
+
}
|
|
275
|
+
if (repo.skipLfs !== void 0 || defaults?.skipLfs !== void 0) {
|
|
276
|
+
resolved.skipLfs = repo.skipLfs ?? defaults?.skipLfs ?? false;
|
|
277
|
+
}
|
|
278
|
+
if (repo.retry || defaults?.retry || globalRetry) {
|
|
279
|
+
resolved.retry = {
|
|
280
|
+
...globalRetry || {},
|
|
281
|
+
...defaults?.retry || {},
|
|
282
|
+
...repo.retry || {}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (repo.parallelism || defaults?.parallelism) {
|
|
286
|
+
resolved.parallelism = {
|
|
287
|
+
...defaults?.parallelism || {},
|
|
288
|
+
...repo.parallelism || {}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
|
|
292
|
+
resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
|
|
293
|
+
}
|
|
294
|
+
return resolved;
|
|
295
|
+
}
|
|
296
|
+
resolvePath(inputPath, baseDir) {
|
|
297
|
+
if (path.isAbsolute(inputPath)) {
|
|
298
|
+
return inputPath;
|
|
299
|
+
}
|
|
300
|
+
return path.resolve(baseDir || process.cwd(), inputPath);
|
|
301
|
+
}
|
|
302
|
+
filterRepositories(repositories, filter) {
|
|
303
|
+
if (!filter) {
|
|
304
|
+
return repositories;
|
|
305
|
+
}
|
|
306
|
+
const patterns = filter.split(",").map((p) => p.trim());
|
|
307
|
+
return repositories.filter((repo) => {
|
|
308
|
+
return patterns.some((pattern) => {
|
|
309
|
+
if (pattern.includes("*")) {
|
|
310
|
+
const regex = new RegExp("^" + pattern.replace(/\*/g, CONFIG_CONSTANTS.WILDCARD_PATTERN) + "$");
|
|
311
|
+
return regex.test(repo.name);
|
|
312
|
+
}
|
|
313
|
+
return repo.name === pattern;
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// src/services/InteractiveUIService.tsx
|
|
320
|
+
import React4 from "react";
|
|
321
|
+
import { render } from "ink";
|
|
322
|
+
import * as cron from "node-cron";
|
|
323
|
+
|
|
324
|
+
// src/components/App.tsx
|
|
325
|
+
import React3, { useState as useState2, useEffect as useEffect2, useCallback } from "react";
|
|
326
|
+
import { Box as Box3, useInput as useInput2 } from "ink";
|
|
327
|
+
|
|
328
|
+
// src/components/StatusBar.tsx
|
|
329
|
+
import React, { useState, useEffect } from "react";
|
|
330
|
+
import { Box, Text } from "ink";
|
|
331
|
+
import { CronExpressionParser } from "cron-parser";
|
|
332
|
+
var StatusBar = ({ status, repositoryCount, lastSyncTime, cronSchedule, diskSpaceUsed }) => {
|
|
333
|
+
const [nextSyncTime, setNextSyncTime] = useState(null);
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
if (!cronSchedule) {
|
|
336
|
+
setNextSyncTime(null);
|
|
337
|
+
return void 0;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const interval = CronExpressionParser.parse(cronSchedule);
|
|
341
|
+
setNextSyncTime(interval.next().toDate());
|
|
342
|
+
const timer = setInterval(() => {
|
|
343
|
+
const nextInterval = CronExpressionParser.parse(cronSchedule);
|
|
344
|
+
setNextSyncTime(nextInterval.next().toDate());
|
|
345
|
+
}, 6e4);
|
|
346
|
+
return () => clearInterval(timer);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
setNextSyncTime(null);
|
|
349
|
+
return void 0;
|
|
350
|
+
}
|
|
351
|
+
}, [cronSchedule]);
|
|
352
|
+
const formatTime = (date) => {
|
|
353
|
+
if (!date) return "N/A";
|
|
354
|
+
return date.toLocaleTimeString();
|
|
355
|
+
};
|
|
356
|
+
const getStatusColor = () => {
|
|
357
|
+
return status === "syncing" ? "yellow" : "green";
|
|
358
|
+
};
|
|
359
|
+
const getStatusIcon = () => {
|
|
360
|
+
return status === "syncing" ? "\u27F3" : "\u2713";
|
|
361
|
+
};
|
|
362
|
+
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)))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")))));
|
|
363
|
+
};
|
|
364
|
+
var StatusBar_default = StatusBar;
|
|
365
|
+
|
|
366
|
+
// src/components/HelpModal.tsx
|
|
367
|
+
import React2 from "react";
|
|
368
|
+
import { Box as Box2, Text as Text2, useInput } from "ink";
|
|
369
|
+
var HelpModal = ({ onClose }) => {
|
|
370
|
+
useInput(() => {
|
|
371
|
+
onClose();
|
|
372
|
+
});
|
|
373
|
+
return /* @__PURE__ */ React2.createElement(Box2, { justifyContent: "center", alignItems: "center", flexDirection: "column", marginTop: 2, marginBottom: 2 }, /* @__PURE__ */ React2.createElement(Box2, { borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60 }, /* @__PURE__ */ React2.createElement(Box2, { justifyContent: "center", marginBottom: 1 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "cyan" }, "\u{1F333} sync-worktrees - Keyboard Shortcuts")), /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", gap: 0 }, /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "?"), /* @__PURE__ */ React2.createElement(Text2, null, " or "), /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "h")), /* @__PURE__ */ React2.createElement(Text2, null, "Toggle this help screen")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "s")), /* @__PURE__ */ React2.createElement(Text2, null, "Manually trigger sync for all repositories")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "r")), /* @__PURE__ */ React2.createElement(Text2, null, "Reload configuration and re-sync all repos")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "q"), /* @__PURE__ */ React2.createElement(Text2, null, " or "), /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "Esc")), /* @__PURE__ */ React2.createElement(Text2, null, "Gracefully quit"))), /* @__PURE__ */ React2.createElement(Box2, { justifyContent: "center", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "Press any key to close"))));
|
|
374
|
+
};
|
|
375
|
+
var HelpModal_default = HelpModal;
|
|
376
|
+
|
|
377
|
+
// src/components/App.tsx
|
|
378
|
+
var App = ({ repositoryCount, cronSchedule, onManualSync, onReload, onQuit }) => {
|
|
379
|
+
const [showHelp, setShowHelp] = useState2(false);
|
|
380
|
+
const [status, setStatus] = useState2("idle");
|
|
381
|
+
const [lastSyncTime, setLastSyncTime] = useState2(null);
|
|
382
|
+
const [diskSpaceUsed, setDiskSpaceUsed] = useState2(null);
|
|
383
|
+
useInput2((input2, key) => {
|
|
384
|
+
if (showHelp) {
|
|
385
|
+
if (input2 === "?" || input2 === "h") {
|
|
386
|
+
setShowHelp(false);
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (key.escape || input2 === "q") {
|
|
391
|
+
void onQuit();
|
|
392
|
+
} else if (input2 === "?" || input2 === "h") {
|
|
393
|
+
setShowHelp(true);
|
|
394
|
+
} else if (input2 === "s" && status !== "syncing") {
|
|
395
|
+
setStatus("syncing");
|
|
396
|
+
void (async () => {
|
|
397
|
+
try {
|
|
398
|
+
await onManualSync();
|
|
399
|
+
} catch (error) {
|
|
400
|
+
console.error("Manual sync failed:", error);
|
|
401
|
+
setStatus("idle");
|
|
402
|
+
}
|
|
403
|
+
})();
|
|
404
|
+
} else if (input2 === "r" && status !== "syncing") {
|
|
405
|
+
setStatus("syncing");
|
|
406
|
+
void (async () => {
|
|
407
|
+
try {
|
|
408
|
+
await onReload();
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.error("Reload failed:", error);
|
|
411
|
+
setStatus("idle");
|
|
412
|
+
}
|
|
413
|
+
})();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
const updateLastSyncTime = useCallback(() => {
|
|
417
|
+
setLastSyncTime(/* @__PURE__ */ new Date());
|
|
418
|
+
setStatus("idle");
|
|
419
|
+
}, []);
|
|
420
|
+
useEffect2(() => {
|
|
421
|
+
globalThis.__inkAppMethods = {
|
|
422
|
+
updateLastSyncTime,
|
|
423
|
+
setStatus,
|
|
424
|
+
setDiskSpace: setDiskSpaceUsed
|
|
27
425
|
};
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
var result = {};
|
|
31
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
-
__setModuleDefault(result, mod);
|
|
33
|
-
return result;
|
|
426
|
+
return () => {
|
|
427
|
+
delete globalThis.__inkAppMethods;
|
|
34
428
|
};
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
429
|
+
}, [updateLastSyncTime, setStatus]);
|
|
430
|
+
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(
|
|
431
|
+
StatusBar_default,
|
|
432
|
+
{
|
|
433
|
+
status,
|
|
434
|
+
repositoryCount,
|
|
435
|
+
lastSyncTime,
|
|
436
|
+
cronSchedule,
|
|
437
|
+
diskSpaceUsed: diskSpaceUsed ?? void 0
|
|
438
|
+
}
|
|
439
|
+
), showHelp && /* @__PURE__ */ React3.createElement(HelpModal_default, { onClose: () => setShowHelp(false) }));
|
|
440
|
+
};
|
|
441
|
+
var App_default = App;
|
|
442
|
+
|
|
443
|
+
// src/services/worktree-sync.service.ts
|
|
444
|
+
import * as fs5 from "fs/promises";
|
|
445
|
+
import * as path5 from "path";
|
|
446
|
+
import pLimit from "p-limit";
|
|
447
|
+
|
|
448
|
+
// src/utils/date-filter.ts
|
|
449
|
+
function parseDuration(durationStr) {
|
|
450
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
451
|
+
if (!match) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
const value = parseInt(match[1], 10);
|
|
455
|
+
const unit = match[2];
|
|
456
|
+
const multipliers = {
|
|
457
|
+
h: 60 * 60 * 1e3,
|
|
458
|
+
// hours
|
|
459
|
+
d: 24 * 60 * 60 * 1e3,
|
|
460
|
+
// days
|
|
461
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
462
|
+
// weeks
|
|
463
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
464
|
+
// months (approximate)
|
|
465
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
466
|
+
// years (approximate)
|
|
467
|
+
};
|
|
468
|
+
return value * multipliers[unit];
|
|
469
|
+
}
|
|
470
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
471
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
472
|
+
if (maxAgeMs === null) {
|
|
473
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
474
|
+
return branches;
|
|
475
|
+
}
|
|
476
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
477
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
478
|
+
}
|
|
479
|
+
function formatDuration(durationStr) {
|
|
480
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
481
|
+
if (!match) {
|
|
482
|
+
return durationStr;
|
|
483
|
+
}
|
|
484
|
+
const value = parseInt(match[1], 10);
|
|
485
|
+
const unit = match[2];
|
|
486
|
+
const unitNames = {
|
|
487
|
+
h: value === 1 ? "hour" : "hours",
|
|
488
|
+
d: value === 1 ? "day" : "days",
|
|
489
|
+
w: value === 1 ? "week" : "weeks",
|
|
490
|
+
m: value === 1 ? "month" : "months",
|
|
491
|
+
y: value === 1 ? "year" : "years"
|
|
492
|
+
};
|
|
493
|
+
return `${value} ${unitNames[unit]}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/utils/lfs-error.ts
|
|
497
|
+
function getErrorMessage(error) {
|
|
498
|
+
if (error instanceof Error) {
|
|
499
|
+
return error.message;
|
|
500
|
+
}
|
|
501
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
502
|
+
return String(error.message);
|
|
503
|
+
}
|
|
504
|
+
return String(error);
|
|
505
|
+
}
|
|
506
|
+
var LFS_ERROR_PATTERNS = Object.freeze([
|
|
507
|
+
"smudge filter lfs failed",
|
|
508
|
+
"Object does not exist on the server",
|
|
509
|
+
"external filter 'git-lfs filter-process' failed"
|
|
510
|
+
]);
|
|
511
|
+
function isLfsError(errorMessage) {
|
|
512
|
+
return LFS_ERROR_PATTERNS.some((pattern) => errorMessage.includes(pattern));
|
|
513
|
+
}
|
|
514
|
+
function isLfsErrorFromError(error) {
|
|
515
|
+
return isLfsError(getErrorMessage(error));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/utils/retry.ts
|
|
519
|
+
var DEFAULT_OPTIONS = {
|
|
520
|
+
maxAttempts: "unlimited",
|
|
521
|
+
maxLfsRetries: 2,
|
|
522
|
+
initialDelayMs: 1e3,
|
|
523
|
+
maxDelayMs: 6e5,
|
|
524
|
+
// 10 minutes
|
|
525
|
+
backoffMultiplier: 2,
|
|
526
|
+
jitterMs: 0,
|
|
527
|
+
shouldRetry: (error, context) => {
|
|
528
|
+
const err = error;
|
|
529
|
+
if (isLfsErrorFromError(error)) {
|
|
530
|
+
if (context) {
|
|
531
|
+
context.isLfsError = true;
|
|
532
|
+
}
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
if (err.code === "ENOTFOUND" || err.code === "ECONNREFUSED" || err.code === "ETIMEDOUT") {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
if (err.code === "EBUSY" || err.code === "ENOENT" || err.code === "EACCES") {
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
if (err.message?.includes("Could not read from remote repository")) {
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
if (err.message?.includes("fatal: unable to access")) {
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
},
|
|
549
|
+
onRetry: () => {
|
|
550
|
+
},
|
|
551
|
+
lfsRetryHandler: (_context) => {
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
async function retry(fn, options = {}) {
|
|
555
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
556
|
+
let attempt = 1;
|
|
557
|
+
let lfsAttempt = 0;
|
|
558
|
+
const lfsContext = { isLfsError: false };
|
|
559
|
+
while (true) {
|
|
49
560
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await syncService.sync();
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
console.error("Error during scheduled sync:", error);
|
|
68
|
-
}
|
|
69
|
-
});
|
|
561
|
+
return await fn();
|
|
562
|
+
} catch (error) {
|
|
563
|
+
lfsContext.isLfsError = false;
|
|
564
|
+
if (!opts.shouldRetry(error, lfsContext)) {
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
if (lfsContext.isLfsError) {
|
|
568
|
+
lfsAttempt++;
|
|
569
|
+
if (lfsAttempt > opts.maxLfsRetries) {
|
|
570
|
+
const err = error;
|
|
571
|
+
throw new Error(
|
|
572
|
+
`LFS error retry limit exceeded (${opts.maxLfsRetries} attempts). Consider using --skip-lfs option to bypass LFS downloads.`,
|
|
573
|
+
{ cause: err }
|
|
574
|
+
);
|
|
70
575
|
}
|
|
576
|
+
}
|
|
577
|
+
const isLastAttempt = opts.maxAttempts !== "unlimited" && attempt >= opts.maxAttempts;
|
|
578
|
+
if (isLastAttempt) {
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
if (lfsContext.isLfsError && opts.lfsRetryHandler) {
|
|
582
|
+
opts.lfsRetryHandler(lfsContext);
|
|
583
|
+
}
|
|
584
|
+
const baseDelay = Math.min(opts.initialDelayMs * Math.pow(opts.backoffMultiplier, attempt - 1), opts.maxDelayMs);
|
|
585
|
+
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
586
|
+
const delay = baseDelay + jitter;
|
|
587
|
+
opts.onRetry(error, attempt, lfsContext);
|
|
588
|
+
await new Promise((resolve6) => setTimeout(resolve6, delay));
|
|
589
|
+
attempt++;
|
|
71
590
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/utils/timing.ts
|
|
595
|
+
import Table from "cli-table3";
|
|
596
|
+
var Timer = class {
|
|
597
|
+
startTime;
|
|
598
|
+
endTime;
|
|
599
|
+
constructor() {
|
|
600
|
+
this.startTime = Date.now();
|
|
601
|
+
}
|
|
602
|
+
stop() {
|
|
603
|
+
this.endTime = Date.now();
|
|
604
|
+
return this.getDuration();
|
|
605
|
+
}
|
|
606
|
+
getDuration() {
|
|
607
|
+
const end = this.endTime ?? Date.now();
|
|
608
|
+
return end - this.startTime;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
var PhaseTimer = class {
|
|
612
|
+
phases = /* @__PURE__ */ new Map();
|
|
613
|
+
currentPhase;
|
|
614
|
+
startPhase(name, parallelism) {
|
|
615
|
+
if (this.currentPhase) {
|
|
616
|
+
this.endPhase();
|
|
617
|
+
}
|
|
618
|
+
this.currentPhase = name;
|
|
619
|
+
this.phases.set(name, { timer: new Timer(), parallelism });
|
|
620
|
+
}
|
|
621
|
+
endPhase() {
|
|
622
|
+
if (this.currentPhase) {
|
|
623
|
+
const phase = this.phases.get(this.currentPhase);
|
|
624
|
+
if (phase) {
|
|
625
|
+
phase.timer.stop();
|
|
626
|
+
}
|
|
627
|
+
this.currentPhase = void 0;
|
|
75
628
|
}
|
|
629
|
+
}
|
|
630
|
+
setPhaseCount(name, count) {
|
|
631
|
+
const phase = this.phases.get(name);
|
|
632
|
+
if (phase) {
|
|
633
|
+
phase.count = count;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
getResults() {
|
|
637
|
+
if (this.currentPhase) {
|
|
638
|
+
this.endPhase();
|
|
639
|
+
}
|
|
640
|
+
const results = [];
|
|
641
|
+
for (const [name, { timer, count, parallelism }] of this.phases.entries()) {
|
|
642
|
+
const duration = timer.getDuration();
|
|
643
|
+
const result = {
|
|
644
|
+
name,
|
|
645
|
+
duration,
|
|
646
|
+
count
|
|
647
|
+
};
|
|
648
|
+
if (count && count > 0 && parallelism && parallelism > 1) {
|
|
649
|
+
const batches = Math.ceil(count / parallelism);
|
|
650
|
+
const avgTimePerBatch = duration / batches;
|
|
651
|
+
const theoreticalSequentialTime = count * avgTimePerBatch;
|
|
652
|
+
result.efficiency = theoreticalSequentialTime > 0 ? Math.round(theoreticalSequentialTime / duration * 100) : 100;
|
|
653
|
+
}
|
|
654
|
+
results.push(result);
|
|
655
|
+
}
|
|
656
|
+
return results;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
function formatDuration2(ms) {
|
|
660
|
+
if (ms < 1e3) {
|
|
661
|
+
return `${ms}ms`;
|
|
662
|
+
}
|
|
663
|
+
if (ms < 6e4) {
|
|
664
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
665
|
+
}
|
|
666
|
+
const minutes = Math.floor(ms / 6e4);
|
|
667
|
+
const seconds = Math.floor(ms % 6e4 / 1e3);
|
|
668
|
+
return `${minutes}m ${seconds}s`;
|
|
76
669
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
670
|
+
function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
671
|
+
const header = repoName ? `Performance Summary - [${repoName}]` : "Performance Summary";
|
|
672
|
+
const table = new Table({
|
|
673
|
+
head: ["Operation", "Duration", "Efficiency"],
|
|
674
|
+
colWidths: [35, 12, 12],
|
|
675
|
+
style: {
|
|
676
|
+
head: ["cyan", "bold"],
|
|
677
|
+
border: ["gray"]
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
681
|
+
table.push(["Total Sync", formatDuration2(totalDuration), ""]);
|
|
682
|
+
for (let i = 0; i < phaseResults.length; i++) {
|
|
683
|
+
const result = phaseResults[i];
|
|
684
|
+
const isLast = i === phaseResults.length - 1;
|
|
685
|
+
const countStr = result.count ? ` (${result.count})` : "";
|
|
686
|
+
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
687
|
+
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
688
|
+
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
689
|
+
table.push([name, formatDuration2(result.duration), efficiency]);
|
|
690
|
+
}
|
|
691
|
+
return table.toString();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/services/git.service.ts
|
|
695
|
+
import * as fs4 from "fs/promises";
|
|
696
|
+
import * as path4 from "path";
|
|
697
|
+
import simpleGit3 from "simple-git";
|
|
698
|
+
|
|
699
|
+
// src/utils/git-url.ts
|
|
700
|
+
function extractRepoNameFromUrl(gitUrl) {
|
|
701
|
+
const url = gitUrl.trim();
|
|
702
|
+
const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
703
|
+
if (sshMatch) {
|
|
704
|
+
return sshMatch[1];
|
|
705
|
+
}
|
|
706
|
+
const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
707
|
+
if (sshUrlMatch) {
|
|
708
|
+
return sshUrlMatch[1];
|
|
709
|
+
}
|
|
710
|
+
const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
711
|
+
if (httpsMatch) {
|
|
712
|
+
return httpsMatch[1];
|
|
713
|
+
}
|
|
714
|
+
const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
715
|
+
if (fileMatch) {
|
|
716
|
+
return fileMatch[1];
|
|
717
|
+
}
|
|
718
|
+
throw new Error(`Invalid Git URL format: ${gitUrl}`);
|
|
719
|
+
}
|
|
720
|
+
function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
721
|
+
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
722
|
+
return `${baseDir}/${repoName}`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// src/services/logger.service.ts
|
|
726
|
+
var Logger = class _Logger {
|
|
727
|
+
repoName;
|
|
728
|
+
debugEnabled;
|
|
729
|
+
constructor(options = {}) {
|
|
730
|
+
this.repoName = options.repoName;
|
|
731
|
+
this.debugEnabled = options.debug ?? false;
|
|
732
|
+
}
|
|
733
|
+
prefix() {
|
|
734
|
+
return this.repoName ? `[${this.repoName}] ` : "";
|
|
735
|
+
}
|
|
736
|
+
debug(message, ...args) {
|
|
737
|
+
if (!this.debugEnabled) return;
|
|
738
|
+
console.log(this.prefix() + this.formatMessage(message, args));
|
|
739
|
+
}
|
|
740
|
+
info(message, ...args) {
|
|
741
|
+
console.log(this.prefix() + this.formatMessage(message, args));
|
|
742
|
+
}
|
|
743
|
+
warn(message, ...args) {
|
|
744
|
+
console.warn(this.prefix() + this.formatMessage(message, args));
|
|
745
|
+
}
|
|
746
|
+
error(message, error) {
|
|
747
|
+
if (error instanceof Error) {
|
|
748
|
+
console.error(this.prefix() + message, error);
|
|
749
|
+
} else if (error) {
|
|
750
|
+
console.error(this.prefix() + message, error);
|
|
751
|
+
} else {
|
|
752
|
+
console.error(this.prefix() + message);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
table(content) {
|
|
756
|
+
console.log("\n" + content + "\n");
|
|
757
|
+
}
|
|
758
|
+
formatMessage(message, args) {
|
|
759
|
+
if (args.length === 0) {
|
|
760
|
+
return message;
|
|
761
|
+
}
|
|
762
|
+
return args.reduce((msg, arg) => msg.replace("%s", String(arg)), message);
|
|
763
|
+
}
|
|
764
|
+
static createDefault(repoName, debug) {
|
|
765
|
+
return new _Logger({ repoName, debug });
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// src/services/worktree-metadata.service.ts
|
|
770
|
+
import * as fs2 from "fs/promises";
|
|
771
|
+
import * as path2 from "path";
|
|
772
|
+
import simpleGit from "simple-git";
|
|
773
|
+
var WorktreeMetadataService = class {
|
|
774
|
+
/**
|
|
775
|
+
* Gets the internal worktree directory name from a worktree path.
|
|
776
|
+
* Git uses the basename of the worktree path as the internal directory name.
|
|
777
|
+
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
778
|
+
*/
|
|
779
|
+
getWorktreeDirectoryName(worktreePath) {
|
|
780
|
+
return path2.basename(worktreePath);
|
|
781
|
+
}
|
|
782
|
+
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
783
|
+
return path2.join(
|
|
784
|
+
bareRepoPath,
|
|
785
|
+
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
786
|
+
worktreeName,
|
|
787
|
+
METADATA_CONSTANTS.METADATA_FILENAME
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
async getMetadataPathFromWorktreePath(bareRepoPath, worktreePath) {
|
|
791
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
792
|
+
return this.getMetadataPath(bareRepoPath, worktreeDirName);
|
|
793
|
+
}
|
|
794
|
+
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
795
|
+
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
796
|
+
await fs2.mkdir(path2.dirname(metadataPath), { recursive: true });
|
|
797
|
+
await fs2.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
798
|
+
}
|
|
799
|
+
async loadMetadata(bareRepoPath, worktreeName) {
|
|
800
|
+
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
801
|
+
try {
|
|
802
|
+
const content = await fs2.readFile(metadataPath, "utf-8");
|
|
803
|
+
return JSON.parse(content);
|
|
804
|
+
} catch {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
809
|
+
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
810
|
+
try {
|
|
811
|
+
const content = await fs2.readFile(metadataPath, "utf-8");
|
|
812
|
+
const metadata = JSON.parse(content);
|
|
813
|
+
if (!await this.validateMetadata(metadata)) {
|
|
814
|
+
console.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
return metadata;
|
|
818
|
+
} catch {
|
|
819
|
+
try {
|
|
820
|
+
const branchName = path2.basename(worktreePath);
|
|
821
|
+
const parentDir = path2.dirname(worktreePath);
|
|
822
|
+
const possibleBranchWithSlash = path2.join(path2.basename(parentDir), branchName);
|
|
823
|
+
const oldPath = path2.join(
|
|
824
|
+
bareRepoPath,
|
|
825
|
+
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
826
|
+
possibleBranchWithSlash,
|
|
827
|
+
METADATA_CONSTANTS.METADATA_FILENAME
|
|
828
|
+
);
|
|
829
|
+
const content = await fs2.readFile(oldPath, "utf-8");
|
|
830
|
+
const metadata = JSON.parse(content);
|
|
831
|
+
if (!await this.validateMetadata(metadata)) {
|
|
832
|
+
console.warn(`Corrupted metadata at old path ${oldPath}, treating as missing`);
|
|
833
|
+
return null;
|
|
122
834
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
835
|
+
await this.saveMetadata(bareRepoPath, this.getWorktreeDirectoryName(worktreePath), metadata);
|
|
836
|
+
try {
|
|
837
|
+
await fs2.unlink(oldPath);
|
|
838
|
+
await fs2.rm(path2.dirname(oldPath), { recursive: false, force: true });
|
|
839
|
+
} catch {
|
|
127
840
|
}
|
|
841
|
+
return metadata;
|
|
842
|
+
} catch {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
128
845
|
}
|
|
129
|
-
}
|
|
130
|
-
async
|
|
131
|
-
const
|
|
846
|
+
}
|
|
847
|
+
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
848
|
+
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
849
|
+
try {
|
|
850
|
+
await fs2.unlink(metadataPath);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (error.code !== "ENOENT") {
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
858
|
+
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
132
859
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
860
|
+
await fs2.unlink(metadataPath);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
if (error.code !== "ENOENT") {
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async updateLastSync(bareRepoPath, worktreeName, commit, action = "updated") {
|
|
868
|
+
const existing = await this.loadMetadata(bareRepoPath, worktreeName);
|
|
869
|
+
if (!existing) {
|
|
870
|
+
console.warn(`No metadata found for worktree ${worktreeName}, skipping update`);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
existing.lastSyncCommit = commit;
|
|
874
|
+
existing.lastSyncDate = (/* @__PURE__ */ new Date()).toISOString();
|
|
875
|
+
existing.syncHistory.push({
|
|
876
|
+
date: existing.lastSyncDate,
|
|
877
|
+
commit,
|
|
878
|
+
action
|
|
879
|
+
});
|
|
880
|
+
if (existing.syncHistory.length > METADATA_CONSTANTS.MAX_HISTORY_ENTRIES) {
|
|
881
|
+
existing.syncHistory = existing.syncHistory.slice(-METADATA_CONSTANTS.MAX_HISTORY_ENTRIES);
|
|
882
|
+
}
|
|
883
|
+
await this.saveMetadata(bareRepoPath, worktreeName, existing);
|
|
884
|
+
}
|
|
885
|
+
async updateLastSyncFromPath(bareRepoPath, worktreePath, commit, action = "updated", defaultBranch) {
|
|
886
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
887
|
+
const existing = await this.loadMetadataFromPath(bareRepoPath, worktreePath);
|
|
888
|
+
if (!existing) {
|
|
889
|
+
console.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
890
|
+
console.log(` Attempting to create initial metadata...`);
|
|
891
|
+
try {
|
|
892
|
+
const worktreeGit = simpleGit(worktreePath);
|
|
893
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
894
|
+
const branchSummary = await worktreeGit.branch();
|
|
895
|
+
const actualBranchName = branchSummary.current;
|
|
896
|
+
if (!actualBranchName) {
|
|
897
|
+
throw new Error("Could not determine current branch name");
|
|
898
|
+
}
|
|
899
|
+
let upstreamBranch = `origin/${actualBranchName}`;
|
|
900
|
+
try {
|
|
901
|
+
const configuredUpstream = await worktreeGit.raw([
|
|
902
|
+
"rev-parse",
|
|
903
|
+
"--abbrev-ref",
|
|
904
|
+
`${actualBranchName}@{upstream}`
|
|
905
|
+
]);
|
|
906
|
+
if (configuredUpstream.trim()) {
|
|
907
|
+
upstreamBranch = configuredUpstream.trim();
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
const parentBranch = defaultBranch || GIT_CONSTANTS.DEFAULT_BRANCH;
|
|
912
|
+
await this.createInitialMetadataFromPath(
|
|
913
|
+
bareRepoPath,
|
|
914
|
+
worktreePath,
|
|
915
|
+
currentCommit.trim(),
|
|
916
|
+
upstreamBranch,
|
|
917
|
+
parentBranch,
|
|
918
|
+
currentCommit.trim()
|
|
919
|
+
);
|
|
920
|
+
console.log(` \u2705 Created metadata for ${worktreeDirName}`);
|
|
921
|
+
return;
|
|
922
|
+
} catch (error) {
|
|
923
|
+
console.error(` \u274C Failed to create metadata: ${error}`);
|
|
924
|
+
throw error;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
existing.lastSyncCommit = commit;
|
|
928
|
+
existing.lastSyncDate = (/* @__PURE__ */ new Date()).toISOString();
|
|
929
|
+
existing.syncHistory.push({
|
|
930
|
+
date: existing.lastSyncDate,
|
|
931
|
+
commit,
|
|
932
|
+
action
|
|
933
|
+
});
|
|
934
|
+
if (existing.syncHistory.length > METADATA_CONSTANTS.MAX_HISTORY_ENTRIES) {
|
|
935
|
+
existing.syncHistory = existing.syncHistory.slice(-METADATA_CONSTANTS.MAX_HISTORY_ENTRIES);
|
|
936
|
+
}
|
|
937
|
+
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
938
|
+
}
|
|
939
|
+
async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
|
|
940
|
+
const metadata = {
|
|
941
|
+
lastSyncCommit: commit,
|
|
942
|
+
lastSyncDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
943
|
+
upstreamBranch,
|
|
944
|
+
createdFrom: {
|
|
945
|
+
branch: parentBranch,
|
|
946
|
+
commit: parentCommit
|
|
947
|
+
},
|
|
948
|
+
syncHistory: [
|
|
949
|
+
{
|
|
950
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
951
|
+
commit,
|
|
952
|
+
action: "created"
|
|
953
|
+
}
|
|
954
|
+
]
|
|
955
|
+
};
|
|
956
|
+
await this.saveMetadata(bareRepoPath, worktreeName, metadata);
|
|
957
|
+
}
|
|
958
|
+
async createInitialMetadataFromPath(bareRepoPath, worktreePath, commit, upstreamBranch, parentBranch, parentCommit) {
|
|
959
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
960
|
+
const metadata = {
|
|
961
|
+
lastSyncCommit: commit,
|
|
962
|
+
lastSyncDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
963
|
+
upstreamBranch,
|
|
964
|
+
createdFrom: {
|
|
965
|
+
branch: parentBranch,
|
|
966
|
+
commit: parentCommit
|
|
967
|
+
},
|
|
968
|
+
syncHistory: [
|
|
969
|
+
{
|
|
970
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
971
|
+
commit,
|
|
972
|
+
action: "created"
|
|
973
|
+
}
|
|
974
|
+
]
|
|
975
|
+
};
|
|
976
|
+
await this.saveMetadata(bareRepoPath, worktreeDirName, metadata);
|
|
977
|
+
}
|
|
978
|
+
async validateMetadata(metadata) {
|
|
979
|
+
if (!metadata.lastSyncCommit || !metadata.lastSyncDate || !metadata.upstreamBranch) {
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
if (!/^[0-9a-f]+$/i.test(metadata.lastSyncCommit)) {
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
if (Number.isNaN(new Date(metadata.lastSyncDate).getTime())) {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// src/services/worktree-status.service.ts
|
|
993
|
+
import * as fs3 from "fs/promises";
|
|
994
|
+
import * as path3 from "path";
|
|
995
|
+
import simpleGit2 from "simple-git";
|
|
996
|
+
|
|
997
|
+
// src/errors/index.ts
|
|
998
|
+
var SyncWorktreesError = class extends Error {
|
|
999
|
+
constructor(message, code, cause) {
|
|
1000
|
+
super(message);
|
|
1001
|
+
this.code = code;
|
|
1002
|
+
this.cause = cause;
|
|
1003
|
+
this.name = this.constructor.name;
|
|
1004
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
1005
|
+
if (cause && cause.stack) {
|
|
1006
|
+
this.stack = `${this.stack}
|
|
1007
|
+
Caused by: ${cause.stack}`;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
var GitError = class extends SyncWorktreesError {
|
|
1012
|
+
constructor(message, code, cause) {
|
|
1013
|
+
super(message, `GIT_${code}`, cause);
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
var GitOperationError = class extends GitError {
|
|
1017
|
+
constructor(operation, details, cause) {
|
|
1018
|
+
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
var WorktreeError = class extends SyncWorktreesError {
|
|
1022
|
+
constructor(message, code, cause) {
|
|
1023
|
+
super(message, `WORKTREE_${code}`, cause);
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
var WorktreeNotCleanError = class extends WorktreeError {
|
|
1027
|
+
constructor(path9, reasons) {
|
|
1028
|
+
super(`Worktree at '${path9}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
1029
|
+
this.path = path9;
|
|
1030
|
+
this.reasons = reasons;
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// src/services/worktree-status.service.ts
|
|
1035
|
+
var WorktreeStatusService = class {
|
|
1036
|
+
constructor(config = {}) {
|
|
1037
|
+
this.config = config;
|
|
1038
|
+
}
|
|
1039
|
+
async checkWorktreeStatus(worktreePath) {
|
|
1040
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1041
|
+
const status = await worktreeGit.status();
|
|
1042
|
+
const hasTrackedChanges = status.modified.length > 0 || status.deleted.length > 0 || status.renamed.length > 0 || status.created.length > 0 || status.conflicted.length > 0;
|
|
1043
|
+
if (hasTrackedChanges) {
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
if (status.not_added.length > 0) {
|
|
1047
|
+
const untrackedFiles = status.not_added;
|
|
1048
|
+
const notIgnoredFiles = await this.filterUntrackedFiles(worktreePath, untrackedFiles);
|
|
1049
|
+
return notIgnoredFiles.length === 0;
|
|
1050
|
+
}
|
|
1051
|
+
return true;
|
|
1052
|
+
}
|
|
1053
|
+
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
1054
|
+
const isClean = await this.checkWorktreeStatus(worktreePath);
|
|
1055
|
+
const hasUnpushedCommits = await this.hasUnpushedCommits(worktreePath, lastSyncCommit);
|
|
1056
|
+
const hasStashedChanges = await this.hasStashedChanges(worktreePath);
|
|
1057
|
+
const hasOperationInProgress = await this.hasOperationInProgress(worktreePath);
|
|
1058
|
+
const hasModifiedSubmodules = await this.hasModifiedSubmodules(worktreePath);
|
|
1059
|
+
const upstreamGone = await this.hasUpstreamGone(worktreePath);
|
|
1060
|
+
const reasons = [];
|
|
1061
|
+
if (!isClean) reasons.push("uncommitted changes");
|
|
1062
|
+
if (hasUnpushedCommits) reasons.push("unpushed commits");
|
|
1063
|
+
if (hasStashedChanges) reasons.push("stashed changes");
|
|
1064
|
+
if (hasOperationInProgress) reasons.push("operation in progress");
|
|
1065
|
+
if (hasModifiedSubmodules) reasons.push("modified submodules");
|
|
1066
|
+
const canRemove = isClean && !hasUnpushedCommits && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules;
|
|
1067
|
+
let details;
|
|
1068
|
+
if (includeDetails) {
|
|
1069
|
+
details = await this.getStatusDetails(worktreePath);
|
|
1070
|
+
}
|
|
1071
|
+
return {
|
|
1072
|
+
isClean,
|
|
1073
|
+
hasUnpushedCommits,
|
|
1074
|
+
hasStashedChanges,
|
|
1075
|
+
hasOperationInProgress,
|
|
1076
|
+
hasModifiedSubmodules,
|
|
1077
|
+
upstreamGone,
|
|
1078
|
+
canRemove,
|
|
1079
|
+
reasons,
|
|
1080
|
+
details
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
async hasUnpushedCommits(worktreePath, lastSyncCommit) {
|
|
1084
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1085
|
+
try {
|
|
1086
|
+
if (await this.isDetachedHead(worktreeGit)) {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
const branchSummary = await worktreeGit.branch();
|
|
1090
|
+
const currentBranch = branchSummary.current;
|
|
1091
|
+
if (lastSyncCommit) {
|
|
1092
|
+
try {
|
|
1093
|
+
const newCommitsResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
|
|
1094
|
+
const newCommitsCount = parseInt(newCommitsResult.trim(), 10);
|
|
1095
|
+
return newCommitsCount > 0;
|
|
1096
|
+
} catch {
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
|
|
1100
|
+
const unpushedCount = parseInt(result.trim(), 10);
|
|
1101
|
+
return unpushedCount > 0;
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
console.error(`Error checking unpushed commits: ${error}`);
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
async hasUpstreamGone(worktreePath) {
|
|
1108
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1109
|
+
try {
|
|
1110
|
+
if (await this.isDetachedHead(worktreeGit)) {
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
const branchSummary = await worktreeGit.branch();
|
|
1114
|
+
const currentBranch = branchSummary.current;
|
|
1115
|
+
const upstream = await worktreeGit.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]);
|
|
1116
|
+
const remoteBranches = await worktreeGit.branch(["-r"]);
|
|
1117
|
+
return !remoteBranches.all.includes(upstream.trim());
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
const errorMessage = getErrorMessage(error);
|
|
1120
|
+
if (errorMessage.includes("fatal: no upstream configured") || errorMessage.includes("no upstream configured for branch") || errorMessage.includes("fatal: ambiguous argument") || errorMessage.includes("unknown revision or path")) {
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
console.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
async hasStashedChanges(worktreePath) {
|
|
1128
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1129
|
+
try {
|
|
1130
|
+
const stashList = await worktreeGit.stashList();
|
|
1131
|
+
return stashList.total > 0;
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
console.error(`Error checking stash: ${error}`);
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
async hasModifiedSubmodules(worktreePath) {
|
|
1138
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1139
|
+
try {
|
|
1140
|
+
const result = await worktreeGit.raw(["submodule", "status"]);
|
|
1141
|
+
const lines = result.split("\n").filter((line) => line.trim());
|
|
1142
|
+
for (const line of lines) {
|
|
1143
|
+
const firstChar = line.charAt(0);
|
|
1144
|
+
if (firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_ADDED || firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_REMOVED) {
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return false;
|
|
1149
|
+
} catch {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async hasOperationInProgress(worktreePath) {
|
|
1154
|
+
try {
|
|
1155
|
+
const gitDir = await this.resolveGitDir(worktreePath);
|
|
1156
|
+
const operationFiles = [
|
|
1157
|
+
GIT_OPERATIONS.MERGE_HEAD,
|
|
1158
|
+
GIT_OPERATIONS.CHERRY_PICK_HEAD,
|
|
1159
|
+
GIT_OPERATIONS.REVERT_HEAD,
|
|
1160
|
+
GIT_OPERATIONS.BISECT_LOG,
|
|
1161
|
+
GIT_OPERATIONS.REBASE_MERGE,
|
|
1162
|
+
GIT_OPERATIONS.REBASE_APPLY
|
|
1163
|
+
];
|
|
1164
|
+
for (const file of operationFiles) {
|
|
1165
|
+
try {
|
|
1166
|
+
await fs3.access(path3.join(gitDir, file));
|
|
1167
|
+
return true;
|
|
1168
|
+
} catch {
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return false;
|
|
1173
|
+
} catch {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
|
|
1178
|
+
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
|
|
1179
|
+
if (!status.canRemove) {
|
|
1180
|
+
throw new WorktreeNotCleanError(worktreePath, status.reasons);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
async getStatusDetails(worktreePath) {
|
|
1184
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1185
|
+
const status = await worktreeGit.status();
|
|
1186
|
+
const details = {
|
|
1187
|
+
modifiedFiles: status.modified.length,
|
|
1188
|
+
deletedFiles: status.deleted.length,
|
|
1189
|
+
renamedFiles: status.renamed.length,
|
|
1190
|
+
createdFiles: status.created.length,
|
|
1191
|
+
conflictedFiles: status.conflicted.length,
|
|
1192
|
+
untrackedFiles: 0
|
|
1193
|
+
};
|
|
1194
|
+
if (status.modified.length > 0) {
|
|
1195
|
+
details.modifiedFilesList = status.modified;
|
|
1196
|
+
}
|
|
1197
|
+
if (status.deleted.length > 0) {
|
|
1198
|
+
details.deletedFilesList = status.deleted;
|
|
1199
|
+
}
|
|
1200
|
+
if (status.renamed.length > 0) {
|
|
1201
|
+
details.renamedFilesList = status.renamed.map((r) => ({ from: r.from, to: r.to }));
|
|
1202
|
+
}
|
|
1203
|
+
if (status.created.length > 0) {
|
|
1204
|
+
details.createdFilesList = status.created;
|
|
1205
|
+
}
|
|
1206
|
+
if (status.conflicted.length > 0) {
|
|
1207
|
+
details.conflictedFilesList = status.conflicted;
|
|
1208
|
+
}
|
|
1209
|
+
if (status.not_added.length > 0) {
|
|
1210
|
+
const notIgnoredFiles = await this.filterUntrackedFiles(worktreePath, status.not_added);
|
|
1211
|
+
details.untrackedFiles = notIgnoredFiles.length;
|
|
1212
|
+
if (notIgnoredFiles.length > 0) {
|
|
1213
|
+
details.untrackedFilesList = notIgnoredFiles;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
if (!await this.isDetachedHead(worktreeGit)) {
|
|
1218
|
+
const branchSummary = await worktreeGit.branch();
|
|
1219
|
+
const currentBranch = branchSummary.current;
|
|
1220
|
+
const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
|
|
1221
|
+
details.unpushedCommitCount = parseInt(result.trim(), 10);
|
|
1222
|
+
}
|
|
1223
|
+
} catch {
|
|
1224
|
+
details.unpushedCommitCount = void 0;
|
|
1225
|
+
}
|
|
1226
|
+
try {
|
|
1227
|
+
const stashList = await worktreeGit.stashList();
|
|
1228
|
+
details.stashCount = stashList.total;
|
|
1229
|
+
} catch {
|
|
1230
|
+
details.stashCount = void 0;
|
|
1231
|
+
}
|
|
1232
|
+
const operationType = await this.getOperationType(worktreePath);
|
|
1233
|
+
if (operationType) {
|
|
1234
|
+
details.operationType = operationType;
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
const result = await worktreeGit.raw(["submodule", "status"]);
|
|
1238
|
+
const lines = result.split("\n").filter((line) => line.trim());
|
|
1239
|
+
const modifiedSubmodules = [];
|
|
1240
|
+
for (const line of lines) {
|
|
1241
|
+
const firstChar = line.charAt(0);
|
|
1242
|
+
if (firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_ADDED || firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_REMOVED) {
|
|
1243
|
+
const match = line.match(/^[+-]\s*(\S+)/);
|
|
1244
|
+
if (match) {
|
|
1245
|
+
modifiedSubmodules.push(match[1]);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (modifiedSubmodules.length > 0) {
|
|
1250
|
+
details.modifiedSubmodules = modifiedSubmodules;
|
|
1251
|
+
}
|
|
1252
|
+
} catch {
|
|
1253
|
+
}
|
|
1254
|
+
return details;
|
|
1255
|
+
}
|
|
1256
|
+
async getOperationType(worktreePath) {
|
|
1257
|
+
try {
|
|
1258
|
+
const gitDir = await this.resolveGitDir(worktreePath);
|
|
1259
|
+
const operations = [
|
|
1260
|
+
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
1261
|
+
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
1262
|
+
{ file: GIT_OPERATIONS.REVERT_HEAD, type: "revert" },
|
|
1263
|
+
{ file: GIT_OPERATIONS.BISECT_LOG, type: "bisect" },
|
|
1264
|
+
{ file: GIT_OPERATIONS.REBASE_MERGE, type: "rebase" },
|
|
1265
|
+
{ file: GIT_OPERATIONS.REBASE_APPLY, type: "rebase (apply)" }
|
|
1266
|
+
];
|
|
1267
|
+
for (const op of operations) {
|
|
1268
|
+
try {
|
|
1269
|
+
await fs3.access(path3.join(gitDir, op.file));
|
|
1270
|
+
return op.type;
|
|
1271
|
+
} catch {
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return void 0;
|
|
1276
|
+
} catch {
|
|
1277
|
+
return void 0;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async filterUntrackedFiles(worktreePath, files) {
|
|
1281
|
+
if (files.length === 0) return [];
|
|
1282
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1283
|
+
try {
|
|
1284
|
+
const result = await worktreeGit.raw(["check-ignore", "--", ...files]);
|
|
1285
|
+
const ignoredFiles = new Set(
|
|
1286
|
+
result.trim().split("\n").filter((f) => f)
|
|
1287
|
+
);
|
|
1288
|
+
return files.filter((f) => !ignoredFiles.has(f));
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
const errorMessage = getErrorMessage(error);
|
|
1291
|
+
if (errorMessage.includes(GIT_CONSTANTS.GIT_CHECK_IGNORE_NO_MATCH)) {
|
|
1292
|
+
return files;
|
|
1293
|
+
}
|
|
1294
|
+
console.warn(`Warning: Could not check gitignore status for files in ${worktreePath}: ${errorMessage}`);
|
|
1295
|
+
return files;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
async isDetachedHead(worktreeGit) {
|
|
1299
|
+
try {
|
|
1300
|
+
const branchSummary = await worktreeGit.branch();
|
|
1301
|
+
return !branchSummary.current || branchSummary.detached;
|
|
1302
|
+
} catch {
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
async resolveGitDir(worktreePath) {
|
|
1307
|
+
const gitPath = path3.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
1308
|
+
try {
|
|
1309
|
+
const stat4 = await fs3.stat(gitPath);
|
|
1310
|
+
if (stat4.isFile()) {
|
|
1311
|
+
const content = await fs3.readFile(gitPath, "utf-8");
|
|
1312
|
+
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
1313
|
+
if (gitdirMatch) {
|
|
1314
|
+
return path3.resolve(worktreePath, gitdirMatch[1].trim());
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return gitPath;
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
throw new GitOperationError(
|
|
1320
|
+
"resolve-git-dir",
|
|
1321
|
+
`Failed to resolve .git directory for ${worktreePath}`,
|
|
1322
|
+
error instanceof Error ? error : void 0
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
createGitInstance(worktreePath) {
|
|
1327
|
+
const git = simpleGit2(worktreePath);
|
|
1328
|
+
return this.config.skipLfs ? git.env({ GIT_LFS_SKIP_SMUDGE: "1" }) : git;
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
// src/services/git.service.ts
|
|
1333
|
+
var GitService = class {
|
|
1334
|
+
constructor(config, logger) {
|
|
1335
|
+
this.config = config;
|
|
1336
|
+
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
1337
|
+
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
1338
|
+
this.mainWorktreePath = path4.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
1339
|
+
this.metadataService = new WorktreeMetadataService();
|
|
1340
|
+
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs });
|
|
1341
|
+
}
|
|
1342
|
+
git = null;
|
|
1343
|
+
bareRepoPath;
|
|
1344
|
+
mainWorktreePath;
|
|
1345
|
+
defaultBranch = GIT_CONSTANTS.DEFAULT_BRANCH;
|
|
1346
|
+
// Will be updated after detection
|
|
1347
|
+
metadataService;
|
|
1348
|
+
statusService;
|
|
1349
|
+
logger;
|
|
1350
|
+
async initialize() {
|
|
1351
|
+
const { repoUrl } = this.config;
|
|
1352
|
+
try {
|
|
1353
|
+
await fs4.access(path4.join(this.bareRepoPath, "HEAD"));
|
|
1354
|
+
this.logger.info(`Bare repository at "${this.bareRepoPath}" already exists. Using it.`);
|
|
1355
|
+
} catch {
|
|
1356
|
+
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
1357
|
+
await fs4.mkdir(path4.dirname(this.bareRepoPath), { recursive: true });
|
|
1358
|
+
const cloneGit = this.isLfsSkipEnabled() ? simpleGit3().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3();
|
|
1359
|
+
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
1360
|
+
this.logger.info("\u2705 Clone successful.");
|
|
1361
|
+
}
|
|
1362
|
+
const bareGit = simpleGit3(this.bareRepoPath);
|
|
1363
|
+
try {
|
|
1364
|
+
const existingConfig = await bareGit.raw(["config", "--get-all", "remote.origin.fetch"]);
|
|
1365
|
+
const targetConfig = "+refs/heads/*:refs/remotes/origin/*";
|
|
1366
|
+
if (!existingConfig.includes(targetConfig)) {
|
|
1367
|
+
await bareGit.addConfig("remote.origin.fetch", targetConfig);
|
|
1368
|
+
}
|
|
1369
|
+
} catch {
|
|
1370
|
+
await bareGit.addConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*");
|
|
1371
|
+
}
|
|
1372
|
+
this.logger.info("Fetching remote branches...");
|
|
1373
|
+
await bareGit.fetch(["--all"]);
|
|
1374
|
+
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
1375
|
+
this.mainWorktreePath = path4.join(this.config.worktreeDir, this.defaultBranch);
|
|
1376
|
+
this.logger.info(`Detected default branch: ${this.defaultBranch}`);
|
|
1377
|
+
let needsMainWorktree = true;
|
|
1378
|
+
try {
|
|
1379
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1380
|
+
needsMainWorktree = !worktrees.some((w) => path4.resolve(w.path) === path4.resolve(this.mainWorktreePath));
|
|
1381
|
+
} catch {
|
|
1382
|
+
}
|
|
1383
|
+
if (needsMainWorktree) {
|
|
1384
|
+
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
1385
|
+
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
1386
|
+
const absoluteWorktreePath = path4.resolve(this.mainWorktreePath);
|
|
1387
|
+
try {
|
|
1388
|
+
const branches = await bareGit.branch();
|
|
1389
|
+
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
1390
|
+
if (defaultBranchExists) {
|
|
1391
|
+
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
1392
|
+
const worktreeGit = this.isLfsSkipEnabled() ? simpleGit3(absoluteWorktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(absoluteWorktreePath);
|
|
1393
|
+
await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
|
|
1394
|
+
} else {
|
|
1395
|
+
await bareGit.raw([
|
|
1396
|
+
"worktree",
|
|
1397
|
+
"add",
|
|
1398
|
+
"--track",
|
|
1399
|
+
"-b",
|
|
1400
|
+
this.defaultBranch,
|
|
1401
|
+
absoluteWorktreePath,
|
|
1402
|
+
`origin/${this.defaultBranch}`
|
|
1403
|
+
]);
|
|
1404
|
+
}
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
const errorMessage = getErrorMessage(error);
|
|
1407
|
+
if (errorMessage.includes("already exists")) {
|
|
1408
|
+
this.logger.info(
|
|
1409
|
+
`${this.defaultBranch} worktree directory already exists at '${absoluteWorktreePath}', skipping creation.`
|
|
1410
|
+
);
|
|
1411
|
+
} else {
|
|
1412
|
+
this.logger.warn(`Failed to create ${this.defaultBranch} worktree with tracking, using simple add: ${error}`);
|
|
1413
|
+
try {
|
|
1414
|
+
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
1415
|
+
} catch (fallbackError) {
|
|
1416
|
+
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
1417
|
+
if (fallbackErrorMessage.includes("already exists")) {
|
|
1418
|
+
this.logger.info(
|
|
1419
|
+
`${this.defaultBranch} worktree directory already exists at '${absoluteWorktreePath}', skipping creation.`
|
|
1420
|
+
);
|
|
1421
|
+
} else {
|
|
1422
|
+
throw fallbackError;
|
|
145
1423
|
}
|
|
146
|
-
|
|
147
|
-
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
1428
|
+
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
1429
|
+
(w) => path4.resolve(w.path) === path4.resolve(this.mainWorktreePath)
|
|
1430
|
+
);
|
|
1431
|
+
if (!mainWorktreeRegistered) {
|
|
1432
|
+
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
1433
|
+
this.logger.warn(`Main worktree was created but not found in worktree list. This may cause issues.`);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
this.git = simpleGit3(this.mainWorktreePath);
|
|
1438
|
+
return this.git;
|
|
1439
|
+
}
|
|
1440
|
+
getGit() {
|
|
1441
|
+
if (!this.git) {
|
|
1442
|
+
throw new Error("Git service not initialized. Call initialize() first.");
|
|
1443
|
+
}
|
|
1444
|
+
return this.git;
|
|
1445
|
+
}
|
|
1446
|
+
getDefaultBranch() {
|
|
1447
|
+
return this.defaultBranch;
|
|
1448
|
+
}
|
|
1449
|
+
async fetchAll() {
|
|
1450
|
+
const git = this.getGit();
|
|
1451
|
+
this.logger.info("Fetching latest data from remote...");
|
|
1452
|
+
if (this.isLfsSkipEnabled()) {
|
|
1453
|
+
await git.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }).fetch(["--all", "--prune"]);
|
|
1454
|
+
} else {
|
|
1455
|
+
await git.fetch(["--all", "--prune"]);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
async fetchBranch(branchName) {
|
|
1459
|
+
const git = this.getGit();
|
|
1460
|
+
if (this.isLfsSkipEnabled()) {
|
|
1461
|
+
await git.env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }).fetch(["origin", branchName, "--prune"]);
|
|
1462
|
+
} else {
|
|
1463
|
+
await git.fetch(["origin", branchName, "--prune"]);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
async getRemoteBranches() {
|
|
1467
|
+
const git = this.getGit();
|
|
1468
|
+
const branches = await git.branch(["-r"]);
|
|
1469
|
+
return branches.all.filter((b) => b.startsWith("origin/") && !b.endsWith("/HEAD")).map((b) => b.replace("origin/", "")).filter((b) => b !== "origin" && b.length > 0);
|
|
1470
|
+
}
|
|
1471
|
+
async getRemoteBranchesWithActivity() {
|
|
1472
|
+
const git = this.getGit();
|
|
1473
|
+
const result = await git.raw([
|
|
1474
|
+
"for-each-ref",
|
|
1475
|
+
"--format=%(refname:short)|%(committerdate:iso8601)",
|
|
1476
|
+
"refs/remotes/origin"
|
|
1477
|
+
]);
|
|
1478
|
+
const branches = [];
|
|
1479
|
+
const lines = result.trim().split("\n").filter((line) => line);
|
|
1480
|
+
for (const line of lines) {
|
|
1481
|
+
const [ref, dateStr] = line.split("|", 2);
|
|
1482
|
+
if (ref && dateStr && !ref.endsWith("/HEAD")) {
|
|
1483
|
+
const branch = ref.replace("origin/", "");
|
|
1484
|
+
if (branch === "origin" || branch.length === 0) {
|
|
1485
|
+
continue;
|
|
1486
|
+
}
|
|
1487
|
+
const lastActivity = new Date(dateStr);
|
|
1488
|
+
if (!isNaN(lastActivity.getTime())) {
|
|
1489
|
+
branches.push({ branch, lastActivity });
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return branches;
|
|
1494
|
+
}
|
|
1495
|
+
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
1496
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1497
|
+
try {
|
|
1498
|
+
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
1499
|
+
const lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
1500
|
+
if (lfsFileList.length === 0) {
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (this.config.debug) {
|
|
1504
|
+
this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
|
|
1505
|
+
}
|
|
1506
|
+
const sampleSize = Math.min(5, lfsFileList.length);
|
|
1507
|
+
const samplesToCheck = [];
|
|
1508
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
1509
|
+
const randomIndex = Math.floor(Math.random() * lfsFileList.length);
|
|
1510
|
+
samplesToCheck.push(lfsFileList[randomIndex]);
|
|
1511
|
+
}
|
|
1512
|
+
let retries = 0;
|
|
1513
|
+
const maxRetries = 30;
|
|
1514
|
+
const retryDelay = 1e3;
|
|
1515
|
+
while (retries < maxRetries) {
|
|
1516
|
+
let allDownloaded = true;
|
|
1517
|
+
const notDownloaded = [];
|
|
1518
|
+
for (const file of samplesToCheck) {
|
|
1519
|
+
const filePath = path4.join(worktreePath, file);
|
|
1520
|
+
try {
|
|
1521
|
+
const handle = await fs4.open(filePath, "r");
|
|
1522
|
+
try {
|
|
1523
|
+
const buffer = Buffer.alloc(200);
|
|
1524
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
1525
|
+
const header = buffer.subarray(0, bytesRead).toString("utf8");
|
|
1526
|
+
if (header.startsWith(GIT_CONSTANTS.LFS_HEADER)) {
|
|
1527
|
+
allDownloaded = false;
|
|
1528
|
+
notDownloaded.push(file);
|
|
1529
|
+
}
|
|
1530
|
+
} finally {
|
|
1531
|
+
await handle.close();
|
|
148
1532
|
}
|
|
149
|
-
|
|
150
|
-
|
|
1533
|
+
} catch {
|
|
1534
|
+
allDownloaded = false;
|
|
1535
|
+
notDownloaded.push(file);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
if (allDownloaded) {
|
|
1539
|
+
if (this.config.debug) {
|
|
1540
|
+
this.logger.info(` - \u2705 LFS files verified (${samplesToCheck.length} samples checked)`);
|
|
1541
|
+
}
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
retries++;
|
|
1545
|
+
if (retries < maxRetries) {
|
|
1546
|
+
await new Promise((resolve6) => setTimeout(resolve6, retryDelay));
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
this.logger.warn(
|
|
1550
|
+
` - \u26A0\uFE0F Warning: Some LFS files may not be fully downloaded after ${maxRetries} seconds. This might cause issues if tools access the worktree immediately.`
|
|
1551
|
+
);
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
|
|
151
1554
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1555
|
+
}
|
|
1556
|
+
async createWorktreeMetadata(bareGit, worktreePath, branchName) {
|
|
1557
|
+
try {
|
|
1558
|
+
const worktreeGit = this.isLfsSkipEnabled() ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
|
|
1559
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
1560
|
+
const parentCommit = await bareGit.revparse([this.defaultBranch]);
|
|
1561
|
+
await this.metadataService.createInitialMetadataFromPath(
|
|
1562
|
+
this.bareRepoPath,
|
|
1563
|
+
worktreePath,
|
|
1564
|
+
currentCommit.trim(),
|
|
1565
|
+
`origin/${branchName}`,
|
|
1566
|
+
this.defaultBranch,
|
|
1567
|
+
parentCommit.trim()
|
|
1568
|
+
);
|
|
1569
|
+
} catch (metadataError) {
|
|
1570
|
+
this.logger.error(` - \u274C Failed to create metadata for '${branchName}': ${metadataError}`);
|
|
1571
|
+
throw new Error(`Metadata creation failed for ${branchName}. This worktree cannot be auto-managed.`);
|
|
155
1572
|
}
|
|
156
|
-
}
|
|
157
|
-
async
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
1573
|
+
}
|
|
1574
|
+
async addWorktree(branchName, worktreePath) {
|
|
1575
|
+
const bareGit = this.isLfsSkipEnabled() ? simpleGit3(this.bareRepoPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(this.bareRepoPath);
|
|
1576
|
+
const absoluteWorktreePath = path4.resolve(worktreePath);
|
|
1577
|
+
await fs4.mkdir(path4.dirname(absoluteWorktreePath), { recursive: true });
|
|
1578
|
+
try {
|
|
1579
|
+
await fs4.access(absoluteWorktreePath);
|
|
1580
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1581
|
+
const isValidWorktree = worktrees.some((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1582
|
+
if (isValidWorktree) {
|
|
1583
|
+
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1584
|
+
return;
|
|
1585
|
+
} else {
|
|
1586
|
+
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
1587
|
+
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1588
|
+
}
|
|
1589
|
+
} catch {
|
|
1590
|
+
}
|
|
1591
|
+
try {
|
|
1592
|
+
const branches = await bareGit.branch();
|
|
1593
|
+
const localBranchExists = branches.all.includes(branchName);
|
|
1594
|
+
if (localBranchExists || branchName.includes("/")) {
|
|
1595
|
+
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
1596
|
+
const worktreeGit = this.isLfsSkipEnabled() ? simpleGit3(absoluteWorktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(absoluteWorktreePath);
|
|
1597
|
+
await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
|
|
1598
|
+
} else {
|
|
1599
|
+
await bareGit.raw([
|
|
1600
|
+
"worktree",
|
|
1601
|
+
"add",
|
|
1602
|
+
"--track",
|
|
1603
|
+
"-b",
|
|
1604
|
+
branchName,
|
|
1605
|
+
absoluteWorktreePath,
|
|
1606
|
+
`origin/${branchName}`
|
|
1607
|
+
]);
|
|
1608
|
+
}
|
|
1609
|
+
this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
|
|
1610
|
+
if (!this.isLfsSkipEnabled()) {
|
|
1611
|
+
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
1612
|
+
}
|
|
1613
|
+
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
const errorMessage = getErrorMessage(error);
|
|
1616
|
+
if (errorMessage.includes("Metadata creation failed")) {
|
|
1617
|
+
throw error;
|
|
1618
|
+
}
|
|
1619
|
+
if (errorMessage.includes("already registered worktree")) {
|
|
1620
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1621
|
+
const existingWorktree = worktrees.find((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1622
|
+
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1623
|
+
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
1627
|
+
await bareGit.raw(["worktree", "prune"]);
|
|
1628
|
+
try {
|
|
1629
|
+
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1630
|
+
} catch {
|
|
1631
|
+
}
|
|
1632
|
+
try {
|
|
1633
|
+
await bareGit.raw([
|
|
1634
|
+
"worktree",
|
|
1635
|
+
"add",
|
|
1636
|
+
"--track",
|
|
1637
|
+
"-b",
|
|
1638
|
+
branchName,
|
|
1639
|
+
absoluteWorktreePath,
|
|
1640
|
+
`origin/${branchName}`
|
|
1641
|
+
]);
|
|
1642
|
+
this.logger.info(` - Created worktree for '${branchName}' after pruning`);
|
|
1643
|
+
if (!this.isLfsSkipEnabled()) {
|
|
1644
|
+
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
1645
|
+
}
|
|
1646
|
+
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1647
|
+
return;
|
|
1648
|
+
} catch (retryError) {
|
|
1649
|
+
this.logger.error(` - Failed to create worktree after pruning: ${retryError}`);
|
|
1650
|
+
throw retryError;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
1654
|
+
try {
|
|
1655
|
+
await fs4.access(absoluteWorktreePath);
|
|
1656
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1657
|
+
const isValidWorktree = worktrees.some((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1658
|
+
if (isValidWorktree) {
|
|
1659
|
+
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1660
|
+
return;
|
|
1661
|
+
} else {
|
|
1662
|
+
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
1663
|
+
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1664
|
+
}
|
|
1665
|
+
} catch {
|
|
1666
|
+
}
|
|
1667
|
+
try {
|
|
1668
|
+
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
1669
|
+
this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
|
|
1670
|
+
if (!this.isLfsSkipEnabled()) {
|
|
1671
|
+
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
1672
|
+
}
|
|
1673
|
+
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1674
|
+
} catch (fallbackError) {
|
|
1675
|
+
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
1676
|
+
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
1677
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1678
|
+
const existingWorktree = worktrees.find((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1679
|
+
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1680
|
+
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
163
1681
|
return;
|
|
1682
|
+
}
|
|
164
1683
|
}
|
|
1684
|
+
throw fallbackError;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
async removeWorktree(worktreePath) {
|
|
1689
|
+
const bareGit = simpleGit3(this.bareRepoPath);
|
|
1690
|
+
await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
1691
|
+
this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
|
|
1692
|
+
try {
|
|
1693
|
+
await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
1694
|
+
} catch (metadataError) {
|
|
1695
|
+
this.logger.warn(`Failed to delete metadata for worktree: ${metadataError}`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
async pruneWorktrees() {
|
|
1699
|
+
const bareGit = simpleGit3(this.bareRepoPath);
|
|
1700
|
+
await bareGit.raw(["worktree", "prune"]);
|
|
1701
|
+
this.logger.info("Pruned worktree metadata.");
|
|
1702
|
+
}
|
|
1703
|
+
async checkWorktreeStatus(worktreePath) {
|
|
1704
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1705
|
+
const status = await worktreeGit.status();
|
|
1706
|
+
return status.isClean();
|
|
1707
|
+
}
|
|
1708
|
+
async isDetachedHead(worktreeGit) {
|
|
1709
|
+
try {
|
|
1710
|
+
const branchSummary = await worktreeGit.branch();
|
|
1711
|
+
return !branchSummary.current || branchSummary.detached;
|
|
1712
|
+
} catch {
|
|
1713
|
+
return true;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
async hasUnpushedCommits(worktreePath) {
|
|
1717
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1718
|
+
try {
|
|
1719
|
+
if (await this.isDetachedHead(worktreeGit)) {
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1722
|
+
const branchSummary = await worktreeGit.branch();
|
|
1723
|
+
const currentBranch = branchSummary.current;
|
|
1724
|
+
const upstreamGone = await this.hasUpstreamGone(worktreePath);
|
|
1725
|
+
if (upstreamGone) {
|
|
1726
|
+
const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
1727
|
+
if (metadata?.lastSyncCommit) {
|
|
1728
|
+
try {
|
|
1729
|
+
const newCommitsResult = await worktreeGit.raw(["rev-list", "--count", `${metadata.lastSyncCommit}..HEAD`]);
|
|
1730
|
+
const newCommitsCount = parseInt(newCommitsResult.trim(), 10);
|
|
1731
|
+
return newCommitsCount > 0;
|
|
1732
|
+
} catch {
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
|
|
1737
|
+
const unpushedCount = parseInt(result.trim(), 10);
|
|
1738
|
+
return unpushedCount > 0;
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
this.logger.error(`Error checking unpushed commits: ${error}`);
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
async hasUpstreamGone(worktreePath) {
|
|
1745
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1746
|
+
try {
|
|
1747
|
+
if (await this.isDetachedHead(worktreeGit)) {
|
|
1748
|
+
return false;
|
|
1749
|
+
}
|
|
1750
|
+
const branchSummary = await worktreeGit.branch();
|
|
1751
|
+
const currentBranch = branchSummary.current;
|
|
1752
|
+
const upstream = await worktreeGit.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]);
|
|
1753
|
+
const remoteBranches = await worktreeGit.branch(["-r"]);
|
|
1754
|
+
return !remoteBranches.all.includes(upstream.trim());
|
|
1755
|
+
} catch (error) {
|
|
1756
|
+
const errorMessage = getErrorMessage(error);
|
|
1757
|
+
if (errorMessage.includes("fatal: no upstream configured") || errorMessage.includes("no upstream configured for branch")) {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
if (errorMessage.includes("fatal: ambiguous argument") || errorMessage.includes("unknown revision or path")) {
|
|
165
1761
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1762
|
+
const branchSummary = await worktreeGit.branch();
|
|
1763
|
+
const currentBranch = branchSummary.current;
|
|
1764
|
+
const remoteResult = await worktreeGit.raw(["config", "--get", `branch.${currentBranch}.remote`]).catch(() => "");
|
|
1765
|
+
const mergeResult = await worktreeGit.raw(["config", "--get", `branch.${currentBranch}.merge`]).catch(() => "");
|
|
1766
|
+
const remote = remoteResult.trim();
|
|
1767
|
+
const merge = mergeResult.trim();
|
|
1768
|
+
if (remote && merge) {
|
|
1769
|
+
const remoteBranchName = merge.replace("refs/heads/", "");
|
|
1770
|
+
const expectedUpstream = `${remote}/${remoteBranchName}`;
|
|
1771
|
+
const remoteBranches = await worktreeGit.branch(["-r"]);
|
|
1772
|
+
return !remoteBranches.all.includes(expectedUpstream);
|
|
1773
|
+
}
|
|
1774
|
+
} catch {
|
|
1775
|
+
}
|
|
1776
|
+
return false;
|
|
1777
|
+
}
|
|
1778
|
+
this.logger.error(
|
|
1779
|
+
`Unexpected error checking upstream status for ${worktreePath}. This might indicate a real issue rather than a missing upstream. Error: ${errorMessage}`
|
|
1780
|
+
);
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
async hasStashedChanges(worktreePath) {
|
|
1785
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1786
|
+
try {
|
|
1787
|
+
const stashList = await worktreeGit.stashList();
|
|
1788
|
+
return stashList.total > 0;
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
this.logger.error(`Error checking stash: ${error}`);
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
async getFullWorktreeStatus(worktreePath, includeDetails = false) {
|
|
1795
|
+
const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
1796
|
+
return this.statusService.getFullWorktreeStatus(worktreePath, includeDetails, metadata?.lastSyncCommit);
|
|
1797
|
+
}
|
|
1798
|
+
async hasModifiedSubmodules(worktreePath) {
|
|
1799
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1800
|
+
try {
|
|
1801
|
+
const result = await worktreeGit.raw(["submodule", "status"]);
|
|
1802
|
+
return /^[+-]/m.test(result);
|
|
1803
|
+
} catch {
|
|
1804
|
+
return false;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
async hasOperationInProgress(worktreePath) {
|
|
1808
|
+
let resolvedGitDir = path4.join(worktreePath, ".git");
|
|
1809
|
+
try {
|
|
1810
|
+
const stat4 = await fs4.stat(resolvedGitDir);
|
|
1811
|
+
if (stat4.isFile()) {
|
|
1812
|
+
const content = await fs4.readFile(resolvedGitDir, "utf-8");
|
|
1813
|
+
const match = content.match(/gitdir:\s*(.*)/i);
|
|
1814
|
+
if (match && match[1]) {
|
|
1815
|
+
resolvedGitDir = match[1].trim();
|
|
1816
|
+
if (!path4.isAbsolute(resolvedGitDir)) {
|
|
1817
|
+
resolvedGitDir = path4.resolve(worktreePath, resolvedGitDir);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
} catch {
|
|
1822
|
+
}
|
|
1823
|
+
const checkFiles = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply"];
|
|
1824
|
+
for (const file of checkFiles) {
|
|
1825
|
+
try {
|
|
1826
|
+
await fs4.access(path4.join(resolvedGitDir, file));
|
|
1827
|
+
return true;
|
|
1828
|
+
} catch {
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
async getCurrentBranch() {
|
|
1834
|
+
const git = this.getGit();
|
|
1835
|
+
const branchSummary = await git.branch();
|
|
1836
|
+
return branchSummary.current;
|
|
1837
|
+
}
|
|
1838
|
+
async detectDefaultBranch(bareGit) {
|
|
1839
|
+
try {
|
|
1840
|
+
const headRef = await bareGit.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
1841
|
+
const branch = headRef.trim().split("/").pop();
|
|
1842
|
+
if (branch) {
|
|
1843
|
+
return branch;
|
|
1844
|
+
}
|
|
1845
|
+
} catch {
|
|
1846
|
+
try {
|
|
1847
|
+
await bareGit.raw(["remote", "set-head", "origin", "-a"]);
|
|
1848
|
+
const headRef = await bareGit.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
1849
|
+
const branch = headRef.trim().split("/").pop();
|
|
1850
|
+
if (branch) {
|
|
1851
|
+
return branch;
|
|
1852
|
+
}
|
|
1853
|
+
} catch {
|
|
1854
|
+
try {
|
|
1855
|
+
const remoteBranches = await bareGit.branch(["-r"]);
|
|
1856
|
+
const commonDefaults = GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES;
|
|
1857
|
+
for (const defaultName of commonDefaults) {
|
|
1858
|
+
if (remoteBranches.all.some((branch) => branch === `origin/${defaultName}`)) {
|
|
1859
|
+
return defaultName;
|
|
175
1860
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
1861
|
+
}
|
|
1862
|
+
} catch {
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
return GIT_CONSTANTS.DEFAULT_BRANCH;
|
|
1867
|
+
}
|
|
1868
|
+
isLfsSkipEnabled() {
|
|
1869
|
+
return this.config.skipLfs || process.env[ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE] === "1";
|
|
1870
|
+
}
|
|
1871
|
+
async getWorktrees() {
|
|
1872
|
+
const bareGit = simpleGit3(this.bareRepoPath);
|
|
1873
|
+
return this.getWorktreesFromBare(bareGit);
|
|
1874
|
+
}
|
|
1875
|
+
async isWorktreeBehind(worktreePath) {
|
|
1876
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1877
|
+
try {
|
|
1878
|
+
const branchSummary = await worktreeGit.branch();
|
|
1879
|
+
const currentBranch = branchSummary.current;
|
|
1880
|
+
const upstreamInfo = await worktreeGit.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]);
|
|
1881
|
+
if (!upstreamInfo.trim()) {
|
|
1882
|
+
return false;
|
|
1883
|
+
}
|
|
1884
|
+
const behindCount = await worktreeGit.raw(["rev-list", "--count", `HEAD..${upstreamInfo.trim()}`]);
|
|
1885
|
+
return parseInt(behindCount.trim(), 10) > 0;
|
|
1886
|
+
} catch {
|
|
1887
|
+
return false;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
async updateWorktree(worktreePath) {
|
|
1891
|
+
const worktreeGit = this.isLfsSkipEnabled() ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
|
|
1892
|
+
const branchSummary = await worktreeGit.branch();
|
|
1893
|
+
const currentBranch = branchSummary.current;
|
|
1894
|
+
await worktreeGit.merge([`origin/${currentBranch}`, "--ff-only"]);
|
|
1895
|
+
const isMainWorktree = path4.resolve(worktreePath) === path4.resolve(this.mainWorktreePath);
|
|
1896
|
+
if (isMainWorktree) {
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
try {
|
|
1900
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
1901
|
+
await this.metadataService.updateLastSyncFromPath(
|
|
1902
|
+
this.bareRepoPath,
|
|
1903
|
+
worktreePath,
|
|
1904
|
+
currentCommit.trim(),
|
|
1905
|
+
"updated",
|
|
1906
|
+
this.defaultBranch
|
|
1907
|
+
);
|
|
1908
|
+
} catch (metadataError) {
|
|
1909
|
+
this.logger.warn(`Failed to update metadata for worktree: ${metadataError}`);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
async hasDivergedHistory(worktreePath, expectedBranch) {
|
|
1913
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1914
|
+
const branchInfo = await worktreeGit.branch();
|
|
1915
|
+
if (branchInfo.current !== expectedBranch) {
|
|
1916
|
+
this.logger.warn(`Branch mismatch in hasDivergedHistory: expected ${expectedBranch}, got ${branchInfo.current}`);
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
try {
|
|
1920
|
+
await worktreeGit.raw(["merge-base", "--is-ancestor", "HEAD", `origin/${expectedBranch}`]);
|
|
1921
|
+
return false;
|
|
1922
|
+
} catch {
|
|
1923
|
+
return true;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
async canFastForward(worktreePath, branch) {
|
|
1927
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1928
|
+
try {
|
|
1929
|
+
const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
|
|
1930
|
+
const mergeBaseSha = mergeBase.trim();
|
|
1931
|
+
const headSha = await worktreeGit.revparse(["HEAD"]);
|
|
1932
|
+
const headShaTrimmed = headSha.trim();
|
|
1933
|
+
return mergeBaseSha === headShaTrimmed;
|
|
1934
|
+
} catch {
|
|
1935
|
+
return false;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
async compareTreeContent(worktreePath, branch) {
|
|
1939
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1940
|
+
try {
|
|
1941
|
+
const localTree = await worktreeGit.raw(["rev-parse", "HEAD^{tree}"]);
|
|
1942
|
+
const remoteTree = await worktreeGit.raw(["rev-parse", `origin/${branch}^{tree}`]);
|
|
1943
|
+
return localTree.trim() === remoteTree.trim();
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
this.logger.error(`Error comparing tree content: ${error}`);
|
|
1946
|
+
return false;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
async resetToUpstream(worktreePath, branch) {
|
|
1950
|
+
const worktreeGit = this.isLfsSkipEnabled() ? simpleGit3(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(worktreePath);
|
|
1951
|
+
await worktreeGit.reset(["--hard", `origin/${branch}`]);
|
|
1952
|
+
try {
|
|
1953
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
1954
|
+
await this.metadataService.updateLastSyncFromPath(
|
|
1955
|
+
this.bareRepoPath,
|
|
1956
|
+
worktreePath,
|
|
1957
|
+
currentCommit.trim(),
|
|
1958
|
+
"updated",
|
|
1959
|
+
this.defaultBranch
|
|
1960
|
+
);
|
|
1961
|
+
} catch (metadataError) {
|
|
1962
|
+
this.logger.warn(`Failed to update metadata after reset: ${metadataError}`);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
async getCurrentCommit(worktreePath) {
|
|
1966
|
+
const worktreeGit = simpleGit3(worktreePath);
|
|
1967
|
+
const commit = await worktreeGit.revparse(["HEAD"]);
|
|
1968
|
+
return commit.trim();
|
|
1969
|
+
}
|
|
1970
|
+
async getRemoteCommit(ref) {
|
|
1971
|
+
const git = simpleGit3(this.bareRepoPath);
|
|
1972
|
+
const commit = await git.revparse([ref]);
|
|
1973
|
+
return commit.trim();
|
|
1974
|
+
}
|
|
1975
|
+
async getWorktreeMetadata(worktreePath) {
|
|
1976
|
+
return this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
1977
|
+
}
|
|
1978
|
+
async getWorktreesFromBare(bareGit) {
|
|
1979
|
+
const result = await bareGit.raw(["worktree", "list", "--porcelain"]);
|
|
1980
|
+
const worktrees = [];
|
|
1981
|
+
const lines = result.trim().split("\n");
|
|
1982
|
+
let currentWorktree = {};
|
|
1983
|
+
for (const line of lines) {
|
|
1984
|
+
if (line.startsWith("worktree ")) {
|
|
1985
|
+
currentWorktree.path = line.substring(9);
|
|
1986
|
+
} else if (line.startsWith("branch ")) {
|
|
1987
|
+
currentWorktree.branch = line.substring(7).replace("refs/heads/", "");
|
|
1988
|
+
} else if (line === "detached") {
|
|
1989
|
+
currentWorktree.detached = true;
|
|
1990
|
+
} else if (line === "prunable") {
|
|
1991
|
+
currentWorktree.prunable = true;
|
|
1992
|
+
} else if (line.trim() === "") {
|
|
1993
|
+
if (currentWorktree.path) {
|
|
1994
|
+
if (currentWorktree.branch && !currentWorktree.detached) {
|
|
1995
|
+
worktrees.push({
|
|
1996
|
+
path: currentWorktree.path,
|
|
1997
|
+
branch: currentWorktree.branch,
|
|
1998
|
+
isPrunable: currentWorktree.prunable || false
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
currentWorktree = {};
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
if (currentWorktree.path && currentWorktree.branch && !currentWorktree.detached) {
|
|
2006
|
+
worktrees.push({
|
|
2007
|
+
path: currentWorktree.path,
|
|
2008
|
+
branch: currentWorktree.branch,
|
|
2009
|
+
isPrunable: currentWorktree.prunable || false
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
return worktrees;
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// src/services/worktree-sync.service.ts
|
|
2017
|
+
var WorktreeSyncService = class {
|
|
2018
|
+
constructor(config) {
|
|
2019
|
+
this.config = config;
|
|
2020
|
+
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
2021
|
+
this.gitService = new GitService(config, this.logger);
|
|
2022
|
+
}
|
|
2023
|
+
gitService;
|
|
2024
|
+
logger;
|
|
2025
|
+
syncInProgress = false;
|
|
2026
|
+
async initialize() {
|
|
2027
|
+
await this.gitService.initialize();
|
|
2028
|
+
}
|
|
2029
|
+
isSyncInProgress() {
|
|
2030
|
+
return this.syncInProgress;
|
|
2031
|
+
}
|
|
2032
|
+
async sync() {
|
|
2033
|
+
if (this.syncInProgress) {
|
|
2034
|
+
this.logger.warn("\u26A0\uFE0F Sync already in progress, skipping...");
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
this.syncInProgress = true;
|
|
2038
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
2039
|
+
const totalTimer = new Timer();
|
|
2040
|
+
const phaseTimer = new PhaseTimer();
|
|
2041
|
+
let lfsSkipEnabled = false;
|
|
2042
|
+
const retryOptions = {
|
|
2043
|
+
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
2044
|
+
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
2045
|
+
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
2046
|
+
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
2047
|
+
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
2048
|
+
onRetry: (error, attempt, context) => {
|
|
2049
|
+
const errorMessage = getErrorMessage(error);
|
|
2050
|
+
this.logger.info(`
|
|
2051
|
+
\u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
|
|
2052
|
+
if (context?.isLfsError && !this.config.skipLfs) {
|
|
2053
|
+
this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
|
|
2054
|
+
} else {
|
|
2055
|
+
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
2056
|
+
`);
|
|
2057
|
+
}
|
|
2058
|
+
},
|
|
2059
|
+
lfsRetryHandler: () => {
|
|
2060
|
+
if (!this.config.skipLfs && !lfsSkipEnabled) {
|
|
2061
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
2062
|
+
process.env.GIT_LFS_SKIP_SMUDGE = "1";
|
|
2063
|
+
lfsSkipEnabled = true;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
try {
|
|
2068
|
+
await retry(async () => {
|
|
2069
|
+
await this.gitService.pruneWorktrees();
|
|
2070
|
+
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
2071
|
+
phaseTimer.startPhase("Phase 1: Fetch");
|
|
2072
|
+
try {
|
|
2073
|
+
await this.gitService.fetchAll();
|
|
2074
|
+
} catch (fetchError) {
|
|
2075
|
+
const errorMessage = getErrorMessage(fetchError);
|
|
2076
|
+
if (isLfsError(errorMessage) && !lfsSkipEnabled && !this.config.skipLfs) {
|
|
2077
|
+
this.logger.info("\u26A0\uFE0F Fetch all failed due to LFS error. Attempting branch-by-branch fetch...");
|
|
2078
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for branch-by-branch fetch...");
|
|
2079
|
+
process.env.GIT_LFS_SKIP_SMUDGE = "1";
|
|
2080
|
+
lfsSkipEnabled = true;
|
|
2081
|
+
await this.fetchBranchByBranch();
|
|
2082
|
+
} else {
|
|
2083
|
+
throw fetchError;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
phaseTimer.endPhase();
|
|
2087
|
+
let remoteBranches;
|
|
2088
|
+
if (this.config.branchMaxAge) {
|
|
2089
|
+
const branchesWithActivity = await this.gitService.getRemoteBranchesWithActivity();
|
|
2090
|
+
const filteredBranches = filterBranchesByAge(branchesWithActivity, this.config.branchMaxAge);
|
|
2091
|
+
remoteBranches = filteredBranches.map((b) => b.branch);
|
|
2092
|
+
this.logger.info(`Found ${branchesWithActivity.length} remote branches.`);
|
|
2093
|
+
this.logger.info(
|
|
2094
|
+
`After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
2095
|
+
);
|
|
2096
|
+
if (branchesWithActivity.length > remoteBranches.length) {
|
|
2097
|
+
const excludedCount = branchesWithActivity.length - remoteBranches.length;
|
|
2098
|
+
this.logger.info(` - Excluded ${excludedCount} stale branches.`);
|
|
2099
|
+
}
|
|
2100
|
+
} else {
|
|
2101
|
+
remoteBranches = await this.gitService.getRemoteBranches();
|
|
2102
|
+
this.logger.info(`Found ${remoteBranches.length} remote branches.`);
|
|
2103
|
+
}
|
|
2104
|
+
const defaultBranch = this.gitService.getDefaultBranch();
|
|
2105
|
+
if (!remoteBranches.includes(defaultBranch)) {
|
|
2106
|
+
remoteBranches.push(defaultBranch);
|
|
2107
|
+
this.logger.info(`Ensuring default branch '${defaultBranch}' is retained.`);
|
|
2108
|
+
}
|
|
2109
|
+
await fs5.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2110
|
+
const worktrees = await this.gitService.getWorktrees();
|
|
2111
|
+
const worktreeBranches = worktrees.map((w) => w.branch);
|
|
2112
|
+
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
2113
|
+
await this.cleanupOrphanedDirectories(worktrees);
|
|
2114
|
+
await this.createNewWorktreesWithTiming(remoteBranches, worktreeBranches, defaultBranch, phaseTimer);
|
|
2115
|
+
await this.pruneOldWorktreesWithTiming(remoteBranches, worktreeBranches, phaseTimer);
|
|
2116
|
+
if (this.config.updateExistingWorktrees !== false) {
|
|
2117
|
+
await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
|
|
2118
|
+
}
|
|
2119
|
+
phaseTimer.startPhase("Phase 5: Cleanup");
|
|
2120
|
+
await this.gitService.pruneWorktrees();
|
|
2121
|
+
this.logger.info("Step 5: Pruned worktree metadata.");
|
|
2122
|
+
phaseTimer.endPhase();
|
|
2123
|
+
}, retryOptions);
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
2126
|
+
throw error;
|
|
2127
|
+
} finally {
|
|
2128
|
+
if (lfsSkipEnabled && !this.config.skipLfs) {
|
|
2129
|
+
delete process.env.GIT_LFS_SKIP_SMUDGE;
|
|
2130
|
+
}
|
|
2131
|
+
this.syncInProgress = false;
|
|
2132
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
2133
|
+
`);
|
|
2134
|
+
if (this.config.debug) {
|
|
2135
|
+
const totalDuration = totalTimer.stop();
|
|
2136
|
+
const phaseResults = phaseTimer.getResults();
|
|
2137
|
+
const repoName = this.config.name;
|
|
2138
|
+
this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
async createNewWorktreesWithTiming(remoteBranches, existingWorktreeBranches, defaultBranch, phaseTimer) {
|
|
2143
|
+
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
2144
|
+
phaseTimer.startPhase("Phase 2: Create", maxConcurrent);
|
|
2145
|
+
await this.createNewWorktrees(remoteBranches, existingWorktreeBranches, defaultBranch);
|
|
2146
|
+
const newBranches = remoteBranches.filter((b) => !existingWorktreeBranches.includes(b)).filter((b) => b !== defaultBranch);
|
|
2147
|
+
phaseTimer.setPhaseCount("Phase 2: Create", newBranches.length);
|
|
2148
|
+
phaseTimer.endPhase();
|
|
2149
|
+
}
|
|
2150
|
+
async createNewWorktrees(remoteBranches, existingWorktreeBranches, defaultBranch) {
|
|
2151
|
+
const newBranches = remoteBranches.filter((b) => !existingWorktreeBranches.includes(b)).filter((b) => b !== defaultBranch);
|
|
2152
|
+
if (newBranches.length > 0) {
|
|
2153
|
+
this.logger.info(`Step 2: Creating ${newBranches.length} new worktrees...`);
|
|
2154
|
+
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
2155
|
+
const limit = pLimit(maxConcurrent);
|
|
2156
|
+
const results = await Promise.allSettled(
|
|
2157
|
+
newBranches.map(
|
|
2158
|
+
(branchName) => limit(async () => {
|
|
2159
|
+
const worktreePath = path5.join(this.config.worktreeDir, branchName);
|
|
2160
|
+
try {
|
|
2161
|
+
await this.gitService.addWorktree(branchName, worktreePath);
|
|
2162
|
+
this.logger.info(` \u2705 Created worktree for '${branchName}'`);
|
|
2163
|
+
} catch (error) {
|
|
2164
|
+
this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
|
|
2165
|
+
throw error;
|
|
183
2166
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
2167
|
+
})
|
|
2168
|
+
)
|
|
2169
|
+
);
|
|
2170
|
+
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
2171
|
+
this.logger.info(` Created ${successCount}/${newBranches.length} worktrees successfully`);
|
|
2172
|
+
} else {
|
|
2173
|
+
this.logger.info("Step 2: No new branches to create worktrees for.");
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
async pruneOldWorktreesWithTiming(remoteBranches, existingWorktreeBranches, phaseTimer) {
|
|
2177
|
+
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2178
|
+
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
2179
|
+
await this.pruneOldWorktrees(remoteBranches, existingWorktreeBranches);
|
|
2180
|
+
const deletedBranches = existingWorktreeBranches.filter((branch) => !remoteBranches.includes(branch));
|
|
2181
|
+
phaseTimer.setPhaseCount("Phase 3: Prune", deletedBranches.length);
|
|
2182
|
+
phaseTimer.endPhase();
|
|
2183
|
+
}
|
|
2184
|
+
async pruneOldWorktrees(remoteBranches, existingWorktreeBranches) {
|
|
2185
|
+
const deletedBranches = existingWorktreeBranches.filter((branch) => !remoteBranches.includes(branch));
|
|
2186
|
+
if (deletedBranches.length > 0) {
|
|
2187
|
+
this.logger.info(`Step 3: Checking ${deletedBranches.length} stale worktrees to prune...`);
|
|
2188
|
+
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2189
|
+
const limit = pLimit(maxConcurrent);
|
|
2190
|
+
const statusResults = await Promise.allSettled(
|
|
2191
|
+
deletedBranches.map(
|
|
2192
|
+
(branchName) => limit(async () => {
|
|
2193
|
+
const worktreePath = path5.join(this.config.worktreeDir, branchName);
|
|
2194
|
+
const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
|
|
2195
|
+
return { branchName, worktreePath, status };
|
|
2196
|
+
})
|
|
2197
|
+
)
|
|
2198
|
+
);
|
|
2199
|
+
const toRemove = [];
|
|
2200
|
+
const toSkip = [];
|
|
2201
|
+
for (const result of statusResults) {
|
|
2202
|
+
if (result.status === "fulfilled") {
|
|
2203
|
+
const { branchName, worktreePath, status } = result.value;
|
|
2204
|
+
if (status.canRemove) {
|
|
2205
|
+
toRemove.push({ branchName, worktreePath });
|
|
2206
|
+
} else {
|
|
2207
|
+
toSkip.push({ branchName, status });
|
|
2208
|
+
}
|
|
2209
|
+
} else {
|
|
2210
|
+
this.logger.error(` - Error checking worktree:`, result.reason);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
if (toRemove.length > 0) {
|
|
2214
|
+
const removeLimit = pLimit(
|
|
2215
|
+
this.config.parallelism?.maxWorktreeRemoval ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_REMOVAL
|
|
2216
|
+
);
|
|
2217
|
+
const removeResults = await Promise.allSettled(
|
|
2218
|
+
toRemove.map(
|
|
2219
|
+
({ branchName, worktreePath }) => removeLimit(async () => {
|
|
2220
|
+
try {
|
|
2221
|
+
await this.gitService.removeWorktree(worktreePath);
|
|
2222
|
+
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
2225
|
+
throw error;
|
|
2226
|
+
}
|
|
2227
|
+
})
|
|
2228
|
+
)
|
|
2229
|
+
);
|
|
2230
|
+
const removedCount = removeResults.filter((r) => r.status === "fulfilled").length;
|
|
2231
|
+
this.logger.info(` Removed ${removedCount}/${toRemove.length} worktrees successfully`);
|
|
2232
|
+
}
|
|
2233
|
+
if (toSkip.length > 0) {
|
|
2234
|
+
this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
|
|
2235
|
+
}
|
|
2236
|
+
for (const { branchName, status } of toSkip) {
|
|
2237
|
+
if (status.upstreamGone && status.hasUnpushedCommits) {
|
|
2238
|
+
const worktreePath = path5.join(this.config.worktreeDir, branchName);
|
|
2239
|
+
this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
|
|
2240
|
+
this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
|
|
2241
|
+
this.logger.info(
|
|
2242
|
+
` If changes were squash-merged, you can safely remove with: git worktree remove ${worktreePath}`
|
|
2243
|
+
);
|
|
2244
|
+
} else {
|
|
2245
|
+
this.logger.info(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to: ${status.reasons.join(", ")}.`);
|
|
2246
|
+
}
|
|
2247
|
+
if (this.config.debug && status.details) {
|
|
2248
|
+
this.logDebugDetails(branchName, status.details);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
} else {
|
|
2252
|
+
this.logger.info("Step 3: No stale worktrees to prune.");
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
logDebugDetails(branchName, details) {
|
|
2256
|
+
this.logger.info(`
|
|
2257
|
+
\u{1F50D} Debug details for '${branchName}':`);
|
|
2258
|
+
if (details.modifiedFiles > 0 && details.modifiedFilesList) {
|
|
2259
|
+
this.logger.info(` - Modified files (${details.modifiedFiles}):`);
|
|
2260
|
+
details.modifiedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2261
|
+
}
|
|
2262
|
+
if (details.deletedFiles > 0 && details.deletedFilesList) {
|
|
2263
|
+
this.logger.info(` - Deleted files (${details.deletedFiles}):`);
|
|
2264
|
+
details.deletedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2265
|
+
}
|
|
2266
|
+
if (details.renamedFiles > 0 && details.renamedFilesList) {
|
|
2267
|
+
this.logger.info(` - Renamed files (${details.renamedFiles}):`);
|
|
2268
|
+
details.renamedFilesList.forEach((file) => this.logger.info(` \u2022 ${file.from} \u2192 ${file.to}`));
|
|
2269
|
+
}
|
|
2270
|
+
if (details.createdFiles > 0 && details.createdFilesList) {
|
|
2271
|
+
this.logger.info(` - Created files (${details.createdFiles}):`);
|
|
2272
|
+
details.createdFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2273
|
+
}
|
|
2274
|
+
if (details.conflictedFiles > 0 && details.conflictedFilesList) {
|
|
2275
|
+
this.logger.info(` - Conflicted files (${details.conflictedFiles}):`);
|
|
2276
|
+
details.conflictedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2277
|
+
}
|
|
2278
|
+
if (details.untrackedFiles > 0 && details.untrackedFilesList) {
|
|
2279
|
+
this.logger.info(` - Untracked files (not ignored) (${details.untrackedFiles}):`);
|
|
2280
|
+
details.untrackedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2281
|
+
}
|
|
2282
|
+
if (details.unpushedCommitCount !== void 0 && details.unpushedCommitCount > 0) {
|
|
2283
|
+
this.logger.info(` - Unpushed commits: ${details.unpushedCommitCount}`);
|
|
2284
|
+
}
|
|
2285
|
+
if (details.stashCount !== void 0 && details.stashCount > 0) {
|
|
2286
|
+
this.logger.info(` - Stashed changes: ${details.stashCount}`);
|
|
2287
|
+
}
|
|
2288
|
+
if (details.operationType) {
|
|
2289
|
+
this.logger.info(` - Operation in progress: ${details.operationType}`);
|
|
2290
|
+
}
|
|
2291
|
+
if (details.modifiedSubmodules && details.modifiedSubmodules.length > 0) {
|
|
2292
|
+
this.logger.info(` - Modified submodules (${details.modifiedSubmodules.length}):`);
|
|
2293
|
+
details.modifiedSubmodules.forEach((submodule) => this.logger.info(` \u2022 ${submodule}`));
|
|
2294
|
+
}
|
|
2295
|
+
this.logger.info("");
|
|
2296
|
+
}
|
|
2297
|
+
async fetchBranchByBranch() {
|
|
2298
|
+
this.logger.info("Fetching branches individually to isolate LFS errors...");
|
|
2299
|
+
const remoteBranches = await this.gitService.getRemoteBranches();
|
|
2300
|
+
this.logger.info(`Found ${remoteBranches.length} remote branches to fetch.`);
|
|
2301
|
+
const failedBranches = [];
|
|
2302
|
+
let successCount = 0;
|
|
2303
|
+
for (const branch of remoteBranches) {
|
|
2304
|
+
try {
|
|
2305
|
+
await this.gitService.fetchBranch(branch);
|
|
2306
|
+
successCount++;
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
const errorMessage = getErrorMessage(error);
|
|
2309
|
+
this.logger.info(` \u26A0\uFE0F Failed to fetch branch '${branch}': ${errorMessage}`);
|
|
2310
|
+
failedBranches.push(branch);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
this.logger.info(`Branch-by-branch fetch completed: ${successCount}/${remoteBranches.length} successful`);
|
|
2314
|
+
if (failedBranches.length > 0) {
|
|
2315
|
+
this.logger.info(`\u26A0\uFE0F Failed to fetch ${failedBranches.length} branches due to errors.`);
|
|
2316
|
+
this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
async updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer) {
|
|
2320
|
+
const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
2321
|
+
phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
|
|
2322
|
+
await this.updateExistingWorktrees(worktrees, remoteBranches);
|
|
2323
|
+
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
2324
|
+
phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
|
|
2325
|
+
phaseTimer.endPhase();
|
|
2326
|
+
}
|
|
2327
|
+
async updateExistingWorktrees(worktrees, remoteBranches) {
|
|
2328
|
+
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
2329
|
+
const divergedDir = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
2330
|
+
try {
|
|
2331
|
+
const diverged = await fs5.readdir(divergedDir);
|
|
2332
|
+
if (diverged.length > 0) {
|
|
2333
|
+
this.logger.info(
|
|
2334
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path5.relative(process.cwd(), divergedDir)}`
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
} catch {
|
|
2338
|
+
}
|
|
2339
|
+
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
2340
|
+
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2341
|
+
const limit = pLimit(maxConcurrent);
|
|
2342
|
+
const checkResults = await Promise.allSettled(
|
|
2343
|
+
activeWorktrees.map(
|
|
2344
|
+
(worktree) => limit(async () => {
|
|
2345
|
+
try {
|
|
2346
|
+
await fs5.access(worktree.path);
|
|
2347
|
+
} catch {
|
|
2348
|
+
return null;
|
|
2349
|
+
}
|
|
2350
|
+
const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
|
|
2351
|
+
if (hasOp) return null;
|
|
2352
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
|
|
2353
|
+
if (!isClean) return null;
|
|
2354
|
+
const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
|
|
2355
|
+
if (!canFastForward) {
|
|
2356
|
+
await this.handleDivergedBranch(worktree);
|
|
2357
|
+
return null;
|
|
2358
|
+
}
|
|
2359
|
+
const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
|
|
2360
|
+
return isBehind ? worktree : null;
|
|
2361
|
+
})
|
|
2362
|
+
)
|
|
2363
|
+
);
|
|
2364
|
+
const worktreesToUpdate = [];
|
|
2365
|
+
for (const result of checkResults) {
|
|
2366
|
+
if (result.status === "fulfilled" && result.value) {
|
|
2367
|
+
worktreesToUpdate.push(result.value);
|
|
2368
|
+
} else if (result.status === "rejected") {
|
|
2369
|
+
this.logger.error(` - Error checking worktree:`, result.reason);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (worktreesToUpdate.length > 0) {
|
|
2373
|
+
this.logger.info(` - Found ${worktreesToUpdate.length} worktrees behind their upstream branches.`);
|
|
2374
|
+
const updateLimit = pLimit(
|
|
2375
|
+
this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES
|
|
2376
|
+
);
|
|
2377
|
+
const updateResults = await Promise.allSettled(
|
|
2378
|
+
worktreesToUpdate.map(
|
|
2379
|
+
(worktree) => updateLimit(async () => {
|
|
2380
|
+
try {
|
|
2381
|
+
this.logger.info(` - Updating worktree '${worktree.branch}'...`);
|
|
2382
|
+
await this.gitService.updateWorktree(worktree.path);
|
|
2383
|
+
this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
const errorMessage = getErrorMessage(error);
|
|
2386
|
+
if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
|
|
2387
|
+
this.logger.info(
|
|
2388
|
+
` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
|
|
2389
|
+
);
|
|
2390
|
+
try {
|
|
2391
|
+
await this.handleDivergedBranch(worktree);
|
|
2392
|
+
} catch (divergedError) {
|
|
2393
|
+
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
|
|
201
2394
|
}
|
|
2395
|
+
} else {
|
|
2396
|
+
this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
|
|
2397
|
+
}
|
|
2398
|
+
throw error;
|
|
202
2399
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
2400
|
+
})
|
|
2401
|
+
)
|
|
2402
|
+
);
|
|
2403
|
+
const successCount = updateResults.filter((r) => r.status === "fulfilled").length;
|
|
2404
|
+
this.logger.info(` Updated ${successCount}/${worktreesToUpdate.length} worktrees successfully`);
|
|
2405
|
+
} else {
|
|
2406
|
+
this.logger.info(" - All worktrees are up to date.");
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
async cleanupOrphanedDirectories(worktrees) {
|
|
2410
|
+
try {
|
|
2411
|
+
const worktreeRelativePaths = worktrees.map((w) => path5.relative(this.config.worktreeDir, w.path));
|
|
2412
|
+
const allDirs = await fs5.readdir(this.config.worktreeDir);
|
|
2413
|
+
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
2414
|
+
const orphanedDirs = [];
|
|
2415
|
+
for (const dir of regularDirs) {
|
|
2416
|
+
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
2417
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path5.sep);
|
|
2418
|
+
});
|
|
2419
|
+
if (!isPartOfWorktree) {
|
|
2420
|
+
orphanedDirs.push(dir);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
if (orphanedDirs.length > 0) {
|
|
2424
|
+
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
2425
|
+
for (const dir of orphanedDirs) {
|
|
2426
|
+
const dirPath = path5.join(this.config.worktreeDir, dir);
|
|
2427
|
+
try {
|
|
2428
|
+
const stat4 = await fs5.stat(dirPath);
|
|
2429
|
+
if (stat4.isDirectory()) {
|
|
2430
|
+
await fs5.rm(dirPath, { recursive: true, force: true });
|
|
2431
|
+
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
206
2432
|
}
|
|
2433
|
+
} catch (error) {
|
|
2434
|
+
this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
|
|
2435
|
+
}
|
|
207
2436
|
}
|
|
2437
|
+
}
|
|
2438
|
+
} catch (error) {
|
|
2439
|
+
this.logger.error("Error during orphaned directory cleanup:", error);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
async handleDivergedBranch(worktree) {
|
|
2443
|
+
this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
|
|
2444
|
+
const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
|
|
2445
|
+
if (treesIdentical) {
|
|
2446
|
+
this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
|
|
2447
|
+
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
2448
|
+
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
2449
|
+
} else {
|
|
2450
|
+
const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
|
|
2451
|
+
if (!hasLocalChanges) {
|
|
2452
|
+
this.logger.info(
|
|
2453
|
+
`\u2705 Branch '${worktree.branch}' has diverged but you made no local changes. Resetting to upstream...`
|
|
2454
|
+
);
|
|
2455
|
+
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
2456
|
+
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
2457
|
+
} else {
|
|
2458
|
+
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
2459
|
+
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
2460
|
+
const relativePath = path5.relative(process.cwd(), divergedPath);
|
|
2461
|
+
this.logger.info(` Moved to: ${relativePath}`);
|
|
2462
|
+
this.logger.info(` Your local changes are preserved. To review:`);
|
|
2463
|
+
this.logger.info(` cd ${relativePath}`);
|
|
2464
|
+
this.logger.info(` git diff origin/${worktree.branch}`);
|
|
2465
|
+
await this.gitService.removeWorktree(worktree.path);
|
|
2466
|
+
await this.gitService.addWorktree(worktree.branch, worktree.path);
|
|
2467
|
+
this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
|
|
2468
|
+
}
|
|
208
2469
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
2470
|
+
}
|
|
2471
|
+
async hasLocalChangesSinceLastSync(worktreePath) {
|
|
2472
|
+
try {
|
|
2473
|
+
const metadata = await this.gitService.getWorktreeMetadata(worktreePath);
|
|
2474
|
+
if (!metadata || !metadata.lastSyncCommit) {
|
|
2475
|
+
return true;
|
|
2476
|
+
}
|
|
2477
|
+
const currentCommit = await this.gitService.getCurrentCommit(worktreePath);
|
|
2478
|
+
return currentCommit !== metadata.lastSyncCommit;
|
|
2479
|
+
} catch {
|
|
2480
|
+
return true;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
async divergeWorktree(worktreePath, branchName) {
|
|
2484
|
+
const divergedBaseDir = path5.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
2485
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
2486
|
+
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
2487
|
+
const safeBranchName = branchName.replace(/\//g, "-");
|
|
2488
|
+
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
2489
|
+
const divergedPath = path5.join(divergedBaseDir, divergedName);
|
|
2490
|
+
await fs5.mkdir(divergedBaseDir, { recursive: true });
|
|
2491
|
+
try {
|
|
2492
|
+
await fs5.rename(worktreePath, divergedPath);
|
|
2493
|
+
} catch (err) {
|
|
2494
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2495
|
+
if (msg.includes("EXDEV")) {
|
|
2496
|
+
await fs5.cp(worktreePath, divergedPath, { recursive: true });
|
|
2497
|
+
await fs5.rm(worktreePath, { recursive: true, force: true });
|
|
2498
|
+
} else {
|
|
2499
|
+
throw err;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
const metadata = {
|
|
2503
|
+
originalBranch: branchName,
|
|
2504
|
+
divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2505
|
+
reason: METADATA_CONSTANTS.DIVERGED_REASON,
|
|
2506
|
+
originalPath: worktreePath,
|
|
2507
|
+
localCommit: await this.gitService.getCurrentCommit(divergedPath),
|
|
2508
|
+
remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
|
|
2509
|
+
instruction: `To preserve your changes:
|
|
2510
|
+
1. Review: git diff origin/${branchName}
|
|
2511
|
+
2. Keep changes: git push --force-with-lease origin ${branchName}
|
|
2512
|
+
3. Discard changes: rm -rf this directory
|
|
2513
|
+
|
|
2514
|
+
Original worktree location: ${worktreePath}`
|
|
2515
|
+
};
|
|
2516
|
+
await fs5.writeFile(
|
|
2517
|
+
path5.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
2518
|
+
JSON.stringify(metadata, null, 2)
|
|
2519
|
+
);
|
|
2520
|
+
return divergedPath;
|
|
2521
|
+
}
|
|
2522
|
+
};
|
|
2523
|
+
|
|
2524
|
+
// src/utils/disk-space.ts
|
|
2525
|
+
import fastFolderSize from "fast-folder-size";
|
|
2526
|
+
async function calculateDirectorySize(dirPath) {
|
|
2527
|
+
return new Promise((resolve6) => {
|
|
2528
|
+
fastFolderSize(dirPath, (err, bytes) => {
|
|
2529
|
+
if (err || bytes === void 0) {
|
|
2530
|
+
resolve6(0);
|
|
2531
|
+
} else {
|
|
2532
|
+
resolve6(bytes);
|
|
2533
|
+
}
|
|
2534
|
+
});
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
function formatBytes(bytes) {
|
|
2538
|
+
if (bytes === 0) return "0 B";
|
|
2539
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
2540
|
+
const k = 1024;
|
|
2541
|
+
const decimals = 2;
|
|
2542
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
2543
|
+
const value = bytes / Math.pow(k, i);
|
|
2544
|
+
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
2545
|
+
}
|
|
2546
|
+
async function calculateSyncDiskSpace(repoPaths, worktreeDirs) {
|
|
2547
|
+
try {
|
|
2548
|
+
let totalBytes = 0;
|
|
2549
|
+
for (const repoPath of repoPaths) {
|
|
2550
|
+
const bareSize = await calculateDirectorySize(repoPath);
|
|
2551
|
+
totalBytes += bareSize;
|
|
2552
|
+
}
|
|
2553
|
+
for (const worktreeDir of worktreeDirs) {
|
|
2554
|
+
const worktreeSize = await calculateDirectorySize(worktreeDir);
|
|
2555
|
+
totalBytes += worktreeSize;
|
|
2556
|
+
}
|
|
2557
|
+
return formatBytes(totalBytes);
|
|
2558
|
+
} catch (error) {
|
|
2559
|
+
console.error("Failed to calculate disk space:", error);
|
|
2560
|
+
return "N/A";
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// src/services/InteractiveUIService.tsx
|
|
2565
|
+
var InteractiveUIService = class {
|
|
2566
|
+
app = null;
|
|
2567
|
+
syncServices;
|
|
2568
|
+
configPath;
|
|
2569
|
+
cronSchedule;
|
|
2570
|
+
cronJobs = [];
|
|
2571
|
+
repositoryCount;
|
|
2572
|
+
originalConsoleLog;
|
|
2573
|
+
originalConsoleWarn;
|
|
2574
|
+
originalConsoleError;
|
|
2575
|
+
constructor(syncServices, configPath, cronSchedule) {
|
|
2576
|
+
if (syncServices.length === 0) {
|
|
2577
|
+
throw new Error("InteractiveUIService requires at least one WorktreeSyncService");
|
|
2578
|
+
}
|
|
2579
|
+
this.syncServices = syncServices;
|
|
2580
|
+
this.configPath = configPath;
|
|
2581
|
+
this.cronSchedule = cronSchedule;
|
|
2582
|
+
this.repositoryCount = syncServices.length;
|
|
2583
|
+
this.originalConsoleLog = console.log.bind(console);
|
|
2584
|
+
this.originalConsoleWarn = console.warn.bind(console);
|
|
2585
|
+
this.originalConsoleError = console.error.bind(console);
|
|
2586
|
+
this.redirectConsole();
|
|
2587
|
+
this.setupCronJobs();
|
|
2588
|
+
this.renderUI();
|
|
2589
|
+
}
|
|
2590
|
+
redirectConsole() {
|
|
2591
|
+
console.log = (...args) => {
|
|
2592
|
+
const message = args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg, null, 2)).join(" ");
|
|
2593
|
+
this.originalConsoleLog(message);
|
|
2594
|
+
};
|
|
2595
|
+
console.warn = (...args) => {
|
|
2596
|
+
const message = args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg, null, 2)).join(" ");
|
|
2597
|
+
this.originalConsoleWarn(message);
|
|
2598
|
+
};
|
|
2599
|
+
console.error = (...args) => {
|
|
2600
|
+
const message = args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg, null, 2)).join(" ");
|
|
2601
|
+
this.originalConsoleError(message);
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
restoreConsole() {
|
|
2605
|
+
console.log = this.originalConsoleLog;
|
|
2606
|
+
console.warn = this.originalConsoleWarn;
|
|
2607
|
+
console.error = this.originalConsoleError;
|
|
2608
|
+
}
|
|
2609
|
+
setupCronJobs() {
|
|
2610
|
+
if (!this.cronSchedule) {
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
for (const service of this.syncServices) {
|
|
2614
|
+
if (service.config.runOnce) {
|
|
2615
|
+
continue;
|
|
2616
|
+
}
|
|
2617
|
+
const schedule3 = service.config.cronSchedule || this.cronSchedule;
|
|
2618
|
+
const task = cron.schedule(schedule3, async () => {
|
|
2619
|
+
this.setStatus("syncing");
|
|
2620
|
+
try {
|
|
2621
|
+
await service.sync();
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
console.error(`Error syncing: ${error.message}`);
|
|
2624
|
+
} finally {
|
|
2625
|
+
this.setStatus("idle");
|
|
213
2626
|
}
|
|
214
|
-
|
|
215
|
-
|
|
2627
|
+
this.updateLastSyncTime();
|
|
2628
|
+
await this.calculateAndUpdateDiskSpace();
|
|
2629
|
+
});
|
|
2630
|
+
this.cronJobs.push(task);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
cancelCronJobs() {
|
|
2634
|
+
for (const job of this.cronJobs) {
|
|
2635
|
+
job.stop();
|
|
2636
|
+
}
|
|
2637
|
+
this.cronJobs = [];
|
|
2638
|
+
}
|
|
2639
|
+
renderUI() {
|
|
2640
|
+
if (this.app) {
|
|
2641
|
+
this.app.unmount();
|
|
2642
|
+
}
|
|
2643
|
+
this.app = render(
|
|
2644
|
+
/* @__PURE__ */ React4.createElement(
|
|
2645
|
+
App_default,
|
|
2646
|
+
{
|
|
2647
|
+
repositoryCount: this.repositoryCount,
|
|
2648
|
+
cronSchedule: this.cronSchedule,
|
|
2649
|
+
onManualSync: () => this.handleManualSync(),
|
|
2650
|
+
onReload: () => this.handleReload(),
|
|
2651
|
+
onQuit: () => this.handleQuit()
|
|
216
2652
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
2653
|
+
)
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
async handleManualSync() {
|
|
2657
|
+
this.setStatus("syncing");
|
|
2658
|
+
try {
|
|
2659
|
+
for (const service of this.syncServices) {
|
|
2660
|
+
await service.sync();
|
|
2661
|
+
}
|
|
2662
|
+
this.updateLastSyncTime();
|
|
2663
|
+
await this.calculateAndUpdateDiskSpace();
|
|
2664
|
+
} catch (error) {
|
|
2665
|
+
console.error("Manual sync failed:", error);
|
|
2666
|
+
} finally {
|
|
2667
|
+
this.setStatus("idle");
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
async handleReload() {
|
|
2671
|
+
try {
|
|
2672
|
+
if (!this.configPath) {
|
|
2673
|
+
this.setStatus("idle");
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
await this.waitForInProgressSyncs();
|
|
2677
|
+
this.cancelCronJobs();
|
|
2678
|
+
console.log("Reloading configuration...");
|
|
2679
|
+
this.setStatus("syncing");
|
|
2680
|
+
const configLoader = new ConfigLoaderService();
|
|
2681
|
+
const configFile = await configLoader.loadConfigFile(this.configPath);
|
|
2682
|
+
const newServices = [];
|
|
2683
|
+
for (const repoConfig of configFile.repositories) {
|
|
2684
|
+
try {
|
|
2685
|
+
const service = new WorktreeSyncService(repoConfig);
|
|
2686
|
+
await service.initialize();
|
|
2687
|
+
newServices.push(service);
|
|
2688
|
+
} catch (error) {
|
|
2689
|
+
console.error(`Failed to initialize repository ${repoConfig.name}: ${error.message}`);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
if (newServices.length === 0) {
|
|
2693
|
+
throw new Error("No repositories could be initialized from the configuration");
|
|
2694
|
+
}
|
|
2695
|
+
this.syncServices = newServices;
|
|
2696
|
+
this.repositoryCount = this.syncServices.length;
|
|
2697
|
+
this.setupCronJobs();
|
|
2698
|
+
const failures = [];
|
|
2699
|
+
for (const service of this.syncServices) {
|
|
2700
|
+
try {
|
|
2701
|
+
await service.sync();
|
|
2702
|
+
} catch (error) {
|
|
2703
|
+
const repoName = service.config.name || service.config.repoUrl;
|
|
2704
|
+
const errorMessage = error.message;
|
|
2705
|
+
console.error(`Failed to sync repository ${repoName}: ${errorMessage}`);
|
|
2706
|
+
failures.push({ repo: repoName, error: errorMessage });
|
|
220
2707
|
}
|
|
221
|
-
|
|
222
|
-
|
|
2708
|
+
}
|
|
2709
|
+
this.renderUI();
|
|
2710
|
+
this.updateLastSyncTime();
|
|
2711
|
+
await this.calculateAndUpdateDiskSpace();
|
|
2712
|
+
this.setStatus("idle");
|
|
2713
|
+
if (failures.length > 0) {
|
|
2714
|
+
console.warn(`Reload completed with ${failures.length} repository failure(s)`);
|
|
2715
|
+
}
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
console.error(`Reload failed: ${error.message}`);
|
|
2718
|
+
this.setupCronJobs();
|
|
2719
|
+
this.setStatus("idle");
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
async handleQuit() {
|
|
2723
|
+
await this.waitForInProgressSyncs();
|
|
2724
|
+
this.destroy();
|
|
2725
|
+
process.exit(0);
|
|
2726
|
+
}
|
|
2727
|
+
async waitForInProgressSyncs() {
|
|
2728
|
+
const inProgressServices = this.syncServices.filter((s) => s.isSyncInProgress());
|
|
2729
|
+
if (inProgressServices.length === 0) {
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
const syncChecks = inProgressServices.map(async (service) => {
|
|
2733
|
+
const timeout = 3e4;
|
|
2734
|
+
const checkInterval = 500;
|
|
2735
|
+
const startTime = Date.now();
|
|
2736
|
+
while (service.isSyncInProgress()) {
|
|
2737
|
+
if (Date.now() - startTime > timeout) {
|
|
2738
|
+
throw new Error("Timeout waiting for sync operations to complete");
|
|
223
2739
|
}
|
|
224
|
-
await
|
|
2740
|
+
await new Promise((resolve6) => setTimeout(resolve6, checkInterval));
|
|
2741
|
+
}
|
|
2742
|
+
});
|
|
2743
|
+
try {
|
|
2744
|
+
await Promise.all(syncChecks);
|
|
2745
|
+
} catch (error) {
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
updateLastSyncTime() {
|
|
2749
|
+
const methods = globalThis.__inkAppMethods;
|
|
2750
|
+
if (methods && methods.updateLastSyncTime) {
|
|
2751
|
+
methods.updateLastSyncTime();
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
setStatus(status) {
|
|
2755
|
+
const methods = globalThis.__inkAppMethods;
|
|
2756
|
+
if (methods && methods.setStatus) {
|
|
2757
|
+
methods.setStatus(status);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
setDiskSpace(diskSpace) {
|
|
2761
|
+
const methods = globalThis.__inkAppMethods;
|
|
2762
|
+
if (methods && methods.setDiskSpace) {
|
|
2763
|
+
methods.setDiskSpace(diskSpace);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
async calculateAndUpdateDiskSpace() {
|
|
2767
|
+
try {
|
|
2768
|
+
const bareRepoDirs = this.syncServices.map(
|
|
2769
|
+
(service) => service.config.bareRepoDir || getDefaultBareRepoDir(service.config.repoUrl)
|
|
2770
|
+
);
|
|
2771
|
+
const worktreeDirs = this.syncServices.map((service) => service.config.worktreeDir);
|
|
2772
|
+
const diskSpace = await calculateSyncDiskSpace(bareRepoDirs, worktreeDirs);
|
|
2773
|
+
this.setDiskSpace(diskSpace);
|
|
2774
|
+
} catch (error) {
|
|
2775
|
+
console.error("Failed to calculate disk space:", error);
|
|
2776
|
+
this.setDiskSpace("N/A");
|
|
225
2777
|
}
|
|
2778
|
+
}
|
|
2779
|
+
destroy() {
|
|
2780
|
+
this.cancelCronJobs();
|
|
2781
|
+
this.restoreConsole();
|
|
2782
|
+
if (this.app) {
|
|
2783
|
+
this.app.unmount();
|
|
2784
|
+
this.app = null;
|
|
2785
|
+
}
|
|
2786
|
+
delete globalThis.__inkAppMethods;
|
|
2787
|
+
}
|
|
2788
|
+
};
|
|
2789
|
+
|
|
2790
|
+
// src/utils/cli.ts
|
|
2791
|
+
import yargs from "yargs";
|
|
2792
|
+
import { hideBin } from "yargs/helpers";
|
|
2793
|
+
function parseArguments() {
|
|
2794
|
+
const argv = yargs(hideBin(process.argv)).option("config", {
|
|
2795
|
+
alias: "c",
|
|
2796
|
+
type: "string",
|
|
2797
|
+
description: "Path to JavaScript config file"
|
|
2798
|
+
}).option("filter", {
|
|
2799
|
+
alias: "f",
|
|
2800
|
+
type: "string",
|
|
2801
|
+
description: "Filter repositories by name (supports wildcards and comma-separated values)"
|
|
2802
|
+
}).option("list", {
|
|
2803
|
+
alias: "l",
|
|
2804
|
+
type: "boolean",
|
|
2805
|
+
description: "List configured repositories and exit",
|
|
2806
|
+
default: false
|
|
2807
|
+
}).option("bareRepoDir", {
|
|
2808
|
+
alias: "b",
|
|
2809
|
+
type: "string",
|
|
2810
|
+
description: "Directory for storing bare repositories (default: .bare/<repo-name>)."
|
|
2811
|
+
}).option("repoUrl", {
|
|
2812
|
+
alias: "u",
|
|
2813
|
+
type: "string",
|
|
2814
|
+
description: "Git repository URL (e.g., SSH or HTTPS)."
|
|
2815
|
+
}).option("worktreeDir", {
|
|
2816
|
+
alias: "w",
|
|
2817
|
+
type: "string",
|
|
2818
|
+
description: "Absolute path to the directory for storing worktrees."
|
|
2819
|
+
}).option("cronSchedule", {
|
|
2820
|
+
alias: "s",
|
|
2821
|
+
type: "string",
|
|
2822
|
+
description: "Cron schedule for how often to run the sync.",
|
|
2823
|
+
default: "0 * * * *"
|
|
2824
|
+
}).option("runOnce", {
|
|
2825
|
+
type: "boolean",
|
|
2826
|
+
description: "Run the sync process once and then exit, without scheduling.",
|
|
2827
|
+
default: false
|
|
2828
|
+
}).option("branchMaxAge", {
|
|
2829
|
+
alias: "a",
|
|
2830
|
+
type: "string",
|
|
2831
|
+
description: "Maximum age of branches to sync (e.g., '30d', '6m', '1y')."
|
|
2832
|
+
}).option("skipLfs", {
|
|
2833
|
+
type: "boolean",
|
|
2834
|
+
description: "Skip Git LFS downloads when fetching and creating worktrees.",
|
|
2835
|
+
default: false
|
|
2836
|
+
}).option("no-update-existing", {
|
|
2837
|
+
type: "boolean",
|
|
2838
|
+
description: "Disable automatic updates of existing worktrees.",
|
|
2839
|
+
default: false
|
|
2840
|
+
}).option("debug", {
|
|
2841
|
+
alias: "d",
|
|
2842
|
+
type: "boolean",
|
|
2843
|
+
description: "Enable debug mode to show detailed reasons why worktrees are not cleaned up.",
|
|
2844
|
+
default: false
|
|
2845
|
+
}).help().alias("help", "h").parseSync();
|
|
2846
|
+
return {
|
|
2847
|
+
config: argv.config,
|
|
2848
|
+
filter: argv.filter,
|
|
2849
|
+
list: argv.list,
|
|
2850
|
+
repoUrl: argv.repoUrl,
|
|
2851
|
+
worktreeDir: argv.worktreeDir,
|
|
2852
|
+
cronSchedule: argv.cronSchedule,
|
|
2853
|
+
runOnce: argv.runOnce,
|
|
2854
|
+
bareRepoDir: argv.bareRepoDir,
|
|
2855
|
+
branchMaxAge: argv.branchMaxAge,
|
|
2856
|
+
skipLfs: argv.skipLfs,
|
|
2857
|
+
noUpdateExisting: argv["no-update-existing"],
|
|
2858
|
+
debug: argv.debug
|
|
2859
|
+
};
|
|
226
2860
|
}
|
|
227
|
-
|
|
228
|
-
|
|
2861
|
+
function isInteractiveMode(config) {
|
|
2862
|
+
return !config.repoUrl || !config.worktreeDir;
|
|
2863
|
+
}
|
|
2864
|
+
function reconstructCliCommand(config) {
|
|
2865
|
+
const executable = process.argv[1].includes("ts-node") ? "ts-node src/index.ts" : "sync-worktrees";
|
|
2866
|
+
const args = [];
|
|
2867
|
+
args.push(`--repoUrl "${config.repoUrl}"`);
|
|
2868
|
+
if (config.worktreeDir) {
|
|
2869
|
+
args.push(`--worktreeDir "${config.worktreeDir}"`);
|
|
2870
|
+
}
|
|
2871
|
+
if (config.bareRepoDir) {
|
|
2872
|
+
args.push(`--bareRepoDir "${config.bareRepoDir}"`);
|
|
2873
|
+
}
|
|
2874
|
+
if (config.cronSchedule && config.cronSchedule !== "0 * * * *") {
|
|
2875
|
+
args.push(`--cronSchedule "${config.cronSchedule}"`);
|
|
2876
|
+
}
|
|
2877
|
+
if (config.runOnce) {
|
|
2878
|
+
args.push("--runOnce");
|
|
2879
|
+
}
|
|
2880
|
+
if (config.branchMaxAge) {
|
|
2881
|
+
args.push(`--branchMaxAge "${config.branchMaxAge}"`);
|
|
2882
|
+
}
|
|
2883
|
+
if (config.skipLfs) {
|
|
2884
|
+
args.push("--skip-lfs");
|
|
2885
|
+
}
|
|
2886
|
+
if (config.updateExistingWorktrees === false) {
|
|
2887
|
+
args.push("--no-update-existing");
|
|
2888
|
+
}
|
|
2889
|
+
if (config.debug) {
|
|
2890
|
+
args.push("--debug");
|
|
2891
|
+
}
|
|
2892
|
+
return `${executable} ${args.join(" ")}`;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// src/utils/interactive.ts
|
|
2896
|
+
import * as path7 from "path";
|
|
2897
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
2898
|
+
|
|
2899
|
+
// src/utils/config-generator.ts
|
|
2900
|
+
import * as fs6 from "fs/promises";
|
|
2901
|
+
import * as path6 from "path";
|
|
2902
|
+
function serializeToESM(obj, indent = 0) {
|
|
2903
|
+
const spaces = " ".repeat(indent);
|
|
2904
|
+
const innerSpaces = " ".repeat(indent + 2);
|
|
2905
|
+
if (typeof obj === "string") {
|
|
2906
|
+
return `"${obj}"`;
|
|
2907
|
+
}
|
|
2908
|
+
if (typeof obj === "number" || typeof obj === "boolean") {
|
|
2909
|
+
return String(obj);
|
|
2910
|
+
}
|
|
2911
|
+
if (Array.isArray(obj)) {
|
|
2912
|
+
if (obj.length === 0) return "[]";
|
|
2913
|
+
const items = obj.map((item) => `${innerSpaces}${serializeToESM(item, indent + 2)}`).join(",\n");
|
|
2914
|
+
return `[
|
|
2915
|
+
${items}
|
|
2916
|
+
${spaces}]`;
|
|
2917
|
+
}
|
|
2918
|
+
if (obj && typeof obj === "object") {
|
|
2919
|
+
const entries = Object.entries(obj).filter(([_, value]) => value !== void 0).map(([key, value]) => {
|
|
2920
|
+
const serializedValue = serializeToESM(value, indent + 2);
|
|
2921
|
+
return `${innerSpaces}${key}: ${serializedValue}`;
|
|
2922
|
+
});
|
|
2923
|
+
if (entries.length === 0) return "{}";
|
|
2924
|
+
return `{
|
|
2925
|
+
${entries.join(",\n")}
|
|
2926
|
+
${spaces}}`;
|
|
2927
|
+
}
|
|
2928
|
+
return String(obj);
|
|
2929
|
+
}
|
|
2930
|
+
async function generateConfigFile(config, configPath) {
|
|
2931
|
+
const configDir = path6.dirname(configPath);
|
|
2932
|
+
await fs6.mkdir(configDir, { recursive: true });
|
|
2933
|
+
const worktreeDirRelative = path6.relative(configDir, config.worktreeDir);
|
|
2934
|
+
const useRelativeWorktree = !worktreeDirRelative.startsWith("../../../");
|
|
2935
|
+
const repoName = extractRepoNameFromUrl(config.repoUrl);
|
|
2936
|
+
const repository = {
|
|
2937
|
+
name: repoName,
|
|
2938
|
+
repoUrl: config.repoUrl,
|
|
2939
|
+
worktreeDir: useRelativeWorktree ? `./${worktreeDirRelative}` : config.worktreeDir
|
|
2940
|
+
};
|
|
2941
|
+
if (config.bareRepoDir) {
|
|
2942
|
+
const bareRepoDirRelative = path6.relative(configDir, config.bareRepoDir);
|
|
2943
|
+
const useRelativeBare = !bareRepoDirRelative.startsWith("../../../");
|
|
2944
|
+
repository.bareRepoDir = useRelativeBare ? `./${bareRepoDirRelative}` : config.bareRepoDir;
|
|
2945
|
+
}
|
|
2946
|
+
const configObject = {
|
|
2947
|
+
defaults: {
|
|
2948
|
+
cronSchedule: config.cronSchedule,
|
|
2949
|
+
runOnce: config.runOnce
|
|
2950
|
+
},
|
|
2951
|
+
repositories: [repository]
|
|
2952
|
+
};
|
|
2953
|
+
const configContent = `/**
|
|
2954
|
+
* Sync-worktrees configuration file
|
|
2955
|
+
* Generated on ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
2956
|
+
*/
|
|
2957
|
+
|
|
2958
|
+
export default ${serializeToESM(configObject)};
|
|
2959
|
+
`;
|
|
2960
|
+
await fs6.writeFile(configPath, configContent, "utf-8");
|
|
2961
|
+
}
|
|
2962
|
+
function getDefaultConfigPath() {
|
|
2963
|
+
return path6.join(process.cwd(), "sync-worktrees.config.js");
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// src/utils/interactive.ts
|
|
2967
|
+
async function promptForConfig(partialConfig) {
|
|
2968
|
+
console.log("\u{1F527} Welcome to sync-worktrees interactive setup!\n");
|
|
2969
|
+
let repoUrl = partialConfig.repoUrl;
|
|
2970
|
+
if (!repoUrl) {
|
|
2971
|
+
repoUrl = await input({
|
|
2972
|
+
message: "Enter the Git repository URL (e.g., https://github.com/user/repo.git):",
|
|
2973
|
+
validate: (value) => {
|
|
2974
|
+
if (!value.trim()) {
|
|
2975
|
+
return "Repository URL is required";
|
|
2976
|
+
}
|
|
2977
|
+
try {
|
|
2978
|
+
if (!value.match(/^(https?:\/\/|ssh:\/\/|git@|file:\/\/).*$/)) {
|
|
2979
|
+
return "Please enter a valid Git URL (https://, ssh://, git@, or file://)";
|
|
2980
|
+
}
|
|
2981
|
+
return true;
|
|
2982
|
+
} catch {
|
|
2983
|
+
return "Please enter a valid URL";
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
let worktreeDir = partialConfig.worktreeDir;
|
|
2989
|
+
if (!worktreeDir) {
|
|
2990
|
+
const repoName = repoUrl ? extractRepoNameFromUrl(repoUrl) : "";
|
|
2991
|
+
const defaultWorktreeDir = repoName ? `./${repoName}` : "";
|
|
2992
|
+
worktreeDir = await input({
|
|
2993
|
+
message: "Enter the directory for storing worktrees:",
|
|
2994
|
+
default: defaultWorktreeDir,
|
|
2995
|
+
validate: (value) => {
|
|
2996
|
+
if (!value.trim() && !defaultWorktreeDir) {
|
|
2997
|
+
return "Worktree directory is required";
|
|
2998
|
+
}
|
|
2999
|
+
return true;
|
|
3000
|
+
}
|
|
3001
|
+
});
|
|
3002
|
+
if (!worktreeDir.trim() && defaultWorktreeDir) {
|
|
3003
|
+
worktreeDir = defaultWorktreeDir;
|
|
3004
|
+
}
|
|
3005
|
+
if (!path7.isAbsolute(worktreeDir)) {
|
|
3006
|
+
worktreeDir = path7.resolve(worktreeDir);
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
let bareRepoDir = partialConfig.bareRepoDir;
|
|
3010
|
+
const askForBareDir = await confirm({
|
|
3011
|
+
message: "Would you like to specify a custom location for the bare repository?",
|
|
3012
|
+
default: false
|
|
3013
|
+
});
|
|
3014
|
+
if (askForBareDir) {
|
|
3015
|
+
bareRepoDir = await input({
|
|
3016
|
+
message: "Enter the directory for the bare repository:",
|
|
3017
|
+
default: "",
|
|
3018
|
+
validate: (value) => {
|
|
3019
|
+
if (!value.trim()) {
|
|
3020
|
+
return "Bare repository directory is required";
|
|
3021
|
+
}
|
|
3022
|
+
return true;
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
if (!path7.isAbsolute(bareRepoDir)) {
|
|
3026
|
+
bareRepoDir = path7.resolve(bareRepoDir);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
let runOnce = partialConfig.runOnce;
|
|
3030
|
+
let cronSchedule = partialConfig.cronSchedule || "0 * * * *";
|
|
3031
|
+
if (runOnce === void 0) {
|
|
3032
|
+
const runMode = await select({
|
|
3033
|
+
message: "How would you like to run the sync?",
|
|
3034
|
+
choices: [
|
|
3035
|
+
{ name: "Run once", value: "once" },
|
|
3036
|
+
{ name: "Schedule with cron", value: "scheduled" }
|
|
3037
|
+
]
|
|
3038
|
+
});
|
|
3039
|
+
runOnce = runMode === "once";
|
|
3040
|
+
if (!runOnce && !partialConfig.cronSchedule) {
|
|
3041
|
+
cronSchedule = await input({
|
|
3042
|
+
message: "Enter the cron schedule (or press enter for default):",
|
|
3043
|
+
default: "0 * * * *",
|
|
3044
|
+
validate: (value) => {
|
|
3045
|
+
if (!value.trim()) {
|
|
3046
|
+
return "Cron schedule is required";
|
|
3047
|
+
}
|
|
3048
|
+
const parts = value.trim().split(" ");
|
|
3049
|
+
if (parts.length < 5) {
|
|
3050
|
+
return "Invalid cron pattern. Expected format: '* * * * *'";
|
|
3051
|
+
}
|
|
3052
|
+
return true;
|
|
3053
|
+
}
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
const finalConfig = {
|
|
3058
|
+
repoUrl,
|
|
3059
|
+
worktreeDir,
|
|
3060
|
+
cronSchedule,
|
|
3061
|
+
runOnce: runOnce || false,
|
|
3062
|
+
bareRepoDir
|
|
3063
|
+
};
|
|
3064
|
+
console.log("\n\u{1F4CB} Configuration summary:");
|
|
3065
|
+
console.log(` Repository URL: ${finalConfig.repoUrl}`);
|
|
3066
|
+
console.log(` Worktrees: ${finalConfig.worktreeDir}`);
|
|
3067
|
+
if (finalConfig.bareRepoDir) {
|
|
3068
|
+
console.log(` Bare repo: ${finalConfig.bareRepoDir}`);
|
|
3069
|
+
} else {
|
|
3070
|
+
console.log(` Bare repo: .bare/<repo-name> (default)`);
|
|
3071
|
+
}
|
|
3072
|
+
if (finalConfig.runOnce) {
|
|
3073
|
+
console.log(` Mode: Run once`);
|
|
3074
|
+
} else {
|
|
3075
|
+
console.log(` Mode: Scheduled (${finalConfig.cronSchedule})`);
|
|
3076
|
+
}
|
|
3077
|
+
console.log("");
|
|
3078
|
+
const saveConfig = await confirm({
|
|
3079
|
+
message: "Would you like to save this configuration to a file for future use?",
|
|
3080
|
+
default: true
|
|
3081
|
+
});
|
|
3082
|
+
if (saveConfig) {
|
|
3083
|
+
const defaultConfigPath = getDefaultConfigPath();
|
|
3084
|
+
let configPath = await input({
|
|
3085
|
+
message: "Enter the path for the config file:",
|
|
3086
|
+
default: defaultConfigPath,
|
|
3087
|
+
validate: (value) => {
|
|
3088
|
+
if (!value.trim()) {
|
|
3089
|
+
return "Config file path is required";
|
|
3090
|
+
}
|
|
3091
|
+
if (!value.endsWith(".js")) {
|
|
3092
|
+
return "Config file must have a .js extension";
|
|
3093
|
+
}
|
|
3094
|
+
return true;
|
|
3095
|
+
}
|
|
3096
|
+
});
|
|
3097
|
+
if (!path7.isAbsolute(configPath)) {
|
|
3098
|
+
configPath = path7.resolve(configPath);
|
|
3099
|
+
}
|
|
3100
|
+
try {
|
|
3101
|
+
await generateConfigFile(finalConfig, configPath);
|
|
3102
|
+
console.log(`
|
|
3103
|
+
\u2705 Configuration saved to: ${configPath}`);
|
|
3104
|
+
console.log(`
|
|
3105
|
+
\u{1F4A1} You can now use this config file with:`);
|
|
3106
|
+
console.log(` sync-worktrees --config ${path7.relative(process.cwd(), configPath)}`);
|
|
3107
|
+
console.log("");
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
console.error(`
|
|
3110
|
+
\u274C Failed to save config file: ${error.message}`);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
return finalConfig;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// src/index.ts
|
|
3117
|
+
async function runSingleRepository(config) {
|
|
3118
|
+
const logger = Logger.createDefault(void 0, config.debug);
|
|
3119
|
+
logger.info("\n\u{1F4CB} CLI Command (for future reference):");
|
|
3120
|
+
logger.info(` ${reconstructCliCommand(config)}`);
|
|
3121
|
+
logger.info("");
|
|
3122
|
+
if (!config.logger) {
|
|
3123
|
+
config.logger = logger;
|
|
3124
|
+
}
|
|
3125
|
+
const syncService = new WorktreeSyncService(config);
|
|
3126
|
+
try {
|
|
3127
|
+
await syncService.initialize();
|
|
3128
|
+
if (config.runOnce) {
|
|
3129
|
+
logger.info("Running the sync process once as requested by --runOnce flag.");
|
|
3130
|
+
await syncService.sync();
|
|
3131
|
+
} else {
|
|
3132
|
+
const uiService = new InteractiveUIService([syncService], void 0, config.cronSchedule);
|
|
3133
|
+
await syncService.sync();
|
|
3134
|
+
uiService.updateLastSyncTime();
|
|
3135
|
+
void uiService.calculateAndUpdateDiskSpace();
|
|
3136
|
+
cron2.schedule(config.cronSchedule, async () => {
|
|
3137
|
+
try {
|
|
3138
|
+
uiService.setStatus("syncing");
|
|
3139
|
+
await syncService.sync();
|
|
3140
|
+
uiService.updateLastSyncTime();
|
|
3141
|
+
void uiService.calculateAndUpdateDiskSpace();
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
logger.error(`Error during scheduled sync: ${error.message}`, error);
|
|
3144
|
+
uiService.setStatus("idle");
|
|
3145
|
+
}
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
logger.error("\u274C Fatal Error during initialization:", error);
|
|
229
3150
|
process.exit(1);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
async function runMultipleRepositories(repositories, runOnce, configPath, maxParallel) {
|
|
3154
|
+
const services = /* @__PURE__ */ new Map();
|
|
3155
|
+
const globalLogger = Logger.createDefault();
|
|
3156
|
+
globalLogger.info(`
|
|
3157
|
+
\u{1F504} Syncing ${repositories.length} repositories...`);
|
|
3158
|
+
const limit = pLimit2(maxParallel ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
|
|
3159
|
+
const initResults = await Promise.allSettled(
|
|
3160
|
+
repositories.map(
|
|
3161
|
+
(repoConfig) => limit(async () => {
|
|
3162
|
+
const repoLogger = Logger.createDefault(repoConfig.name, repoConfig.debug);
|
|
3163
|
+
repoLogger.info(`
|
|
3164
|
+
\u{1F4E6} Repository: ${repoConfig.name}`);
|
|
3165
|
+
repoLogger.info(` URL: ${repoConfig.repoUrl}`);
|
|
3166
|
+
repoLogger.info(` Worktrees: ${repoConfig.worktreeDir}`);
|
|
3167
|
+
if (repoConfig.bareRepoDir) {
|
|
3168
|
+
repoLogger.info(` Bare repo: ${repoConfig.bareRepoDir}`);
|
|
3169
|
+
}
|
|
3170
|
+
if (!repoConfig.logger) {
|
|
3171
|
+
repoConfig.logger = repoLogger;
|
|
3172
|
+
}
|
|
3173
|
+
const syncService = new WorktreeSyncService(repoConfig);
|
|
3174
|
+
await syncService.initialize();
|
|
3175
|
+
return { name: repoConfig.name, service: syncService };
|
|
3176
|
+
})
|
|
3177
|
+
)
|
|
3178
|
+
);
|
|
3179
|
+
const servicesToSync = [];
|
|
3180
|
+
for (const result of initResults) {
|
|
3181
|
+
if (result.status === "fulfilled") {
|
|
3182
|
+
services.set(result.value.name, result.value.service);
|
|
3183
|
+
servicesToSync.push(result.value);
|
|
3184
|
+
} else {
|
|
3185
|
+
globalLogger.error(`\u274C Failed to initialize repository:`, result.reason);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
const syncResults = await Promise.allSettled(
|
|
3189
|
+
servicesToSync.map(
|
|
3190
|
+
({ name, service }) => limit(async () => {
|
|
3191
|
+
try {
|
|
3192
|
+
await service.sync();
|
|
3193
|
+
} catch (error) {
|
|
3194
|
+
globalLogger.error(`\u274C Error syncing repository '${name}':`, error);
|
|
3195
|
+
throw error;
|
|
3196
|
+
}
|
|
3197
|
+
})
|
|
3198
|
+
)
|
|
3199
|
+
);
|
|
3200
|
+
const successCount = syncResults.filter((r) => r.status === "fulfilled").length;
|
|
3201
|
+
globalLogger.info(`
|
|
3202
|
+
\u2705 Successfully synced ${successCount}/${servicesToSync.length} repositories`);
|
|
3203
|
+
if (!runOnce) {
|
|
3204
|
+
const uniqueSchedules = [...new Set(repositories.map((r) => r.cronSchedule))];
|
|
3205
|
+
const displaySchedule = uniqueSchedules.length === 1 ? uniqueSchedules[0] : void 0;
|
|
3206
|
+
const allServices = Array.from(services.values());
|
|
3207
|
+
const uiService = new InteractiveUIService(allServices, configPath, displaySchedule);
|
|
3208
|
+
uiService.updateLastSyncTime();
|
|
3209
|
+
void uiService.calculateAndUpdateDiskSpace();
|
|
3210
|
+
const cronJobs = /* @__PURE__ */ new Map();
|
|
3211
|
+
for (const repoConfig of repositories) {
|
|
3212
|
+
const syncService = services.get(repoConfig.name);
|
|
3213
|
+
if (!syncService) continue;
|
|
3214
|
+
if (!cronJobs.has(repoConfig.cronSchedule)) {
|
|
3215
|
+
cronJobs.set(repoConfig.cronSchedule, repoConfig.cronSchedule);
|
|
3216
|
+
cron2.schedule(repoConfig.cronSchedule, async () => {
|
|
3217
|
+
const reposToSync = repositories.filter((r) => r.cronSchedule === repoConfig.cronSchedule);
|
|
3218
|
+
uiService.setStatus("syncing");
|
|
3219
|
+
await Promise.allSettled(
|
|
3220
|
+
reposToSync.map(
|
|
3221
|
+
(repo) => limit(async () => {
|
|
3222
|
+
const service = services.get(repo.name);
|
|
3223
|
+
if (!service) return;
|
|
3224
|
+
globalLogger.info(`Running scheduled sync for: ${repo.name}`);
|
|
3225
|
+
try {
|
|
3226
|
+
await service.sync();
|
|
3227
|
+
} catch (error) {
|
|
3228
|
+
globalLogger.error(`Error syncing '${repo.name}':`, error);
|
|
3229
|
+
}
|
|
3230
|
+
})
|
|
3231
|
+
)
|
|
3232
|
+
);
|
|
3233
|
+
uiService.updateLastSyncTime();
|
|
3234
|
+
void uiService.calculateAndUpdateDiskSpace();
|
|
3235
|
+
});
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
globalLogger.info(`All ${repositories.length} repositories scheduled`);
|
|
3239
|
+
for (const [schedule3] of cronJobs) {
|
|
3240
|
+
const repoCount = repositories.filter((r) => r.cronSchedule === schedule3).length;
|
|
3241
|
+
globalLogger.info(`${schedule3}: ${repoCount} repository(ies)`);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
async function listRepositories(configPath, filter) {
|
|
3246
|
+
const configLoader = new ConfigLoaderService();
|
|
3247
|
+
try {
|
|
3248
|
+
const configFile = await configLoader.loadConfigFile(configPath);
|
|
3249
|
+
const configDir = path8.dirname(path8.resolve(configPath));
|
|
3250
|
+
let repositories = configFile.repositories.map(
|
|
3251
|
+
(repo) => configLoader.resolveRepositoryConfig(repo, configFile.defaults, configDir, configFile.retry)
|
|
3252
|
+
);
|
|
3253
|
+
if (filter) {
|
|
3254
|
+
repositories = configLoader.filterRepositories(repositories, filter);
|
|
3255
|
+
if (repositories.length === 0) {
|
|
3256
|
+
console.error(`\u274C No repositories match filter: ${filter}`);
|
|
3257
|
+
process.exit(1);
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
console.log("\n\u{1F4CB} Configured repositories:\n");
|
|
3261
|
+
repositories.forEach((repo, index) => {
|
|
3262
|
+
console.log(`${index + 1}. ${repo.name}`);
|
|
3263
|
+
console.log(` URL: ${repo.repoUrl}`);
|
|
3264
|
+
console.log(` Worktrees: ${repo.worktreeDir}`);
|
|
3265
|
+
console.log(` Schedule: ${repo.cronSchedule}`);
|
|
3266
|
+
console.log(` Run Once: ${repo.runOnce}`);
|
|
3267
|
+
if (repo.bareRepoDir) {
|
|
3268
|
+
console.log(` Bare repo: ${repo.bareRepoDir}`);
|
|
3269
|
+
}
|
|
3270
|
+
if (repo.skipLfs) {
|
|
3271
|
+
console.log(` Skip LFS: ${repo.skipLfs}`);
|
|
3272
|
+
}
|
|
3273
|
+
console.log("");
|
|
3274
|
+
});
|
|
3275
|
+
} catch (error) {
|
|
3276
|
+
console.error("\u274C Error loading config file:", error.message);
|
|
3277
|
+
process.exit(1);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
async function main() {
|
|
3281
|
+
const options = parseArguments();
|
|
3282
|
+
if (options.config) {
|
|
3283
|
+
const configLoader = new ConfigLoaderService();
|
|
3284
|
+
if (options.list) {
|
|
3285
|
+
await listRepositories(options.config, options.filter);
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
try {
|
|
3289
|
+
const configFile = await configLoader.loadConfigFile(options.config);
|
|
3290
|
+
const configDir = path8.dirname(path8.resolve(options.config));
|
|
3291
|
+
let repositories = configFile.repositories.map(
|
|
3292
|
+
(repo) => configLoader.resolveRepositoryConfig(repo, configFile.defaults, configDir, configFile.retry)
|
|
3293
|
+
);
|
|
3294
|
+
if (options.filter) {
|
|
3295
|
+
repositories = configLoader.filterRepositories(repositories, options.filter);
|
|
3296
|
+
if (repositories.length === 0) {
|
|
3297
|
+
console.error(`\u274C No repositories match filter: ${options.filter}`);
|
|
3298
|
+
process.exit(1);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
const globalRunOnce = options.runOnce ?? configFile.defaults?.runOnce ?? false;
|
|
3302
|
+
if (options.noUpdateExisting) {
|
|
3303
|
+
repositories = repositories.map((repo) => ({
|
|
3304
|
+
...repo,
|
|
3305
|
+
updateExistingWorktrees: false
|
|
3306
|
+
}));
|
|
3307
|
+
}
|
|
3308
|
+
if (options.debug) {
|
|
3309
|
+
repositories = repositories.map((repo) => ({
|
|
3310
|
+
...repo,
|
|
3311
|
+
debug: true
|
|
3312
|
+
}));
|
|
3313
|
+
}
|
|
3314
|
+
const maxParallel = configFile.parallelism?.maxRepositories ?? configFile.defaults?.parallelism?.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
|
|
3315
|
+
await runMultipleRepositories(repositories, globalRunOnce, options.config, maxParallel);
|
|
3316
|
+
} catch (error) {
|
|
3317
|
+
if (error instanceof Error && error.message.includes("Config file not found")) {
|
|
3318
|
+
console.error(`
|
|
3319
|
+
\u274C Config file not found: ${options.config}`);
|
|
3320
|
+
const createConfig = await confirm2({
|
|
3321
|
+
message: "Would you like to run interactive setup to create a config file?",
|
|
3322
|
+
default: true
|
|
3323
|
+
});
|
|
3324
|
+
if (createConfig) {
|
|
3325
|
+
const config = await promptForConfig({});
|
|
3326
|
+
await runSingleRepository(config);
|
|
3327
|
+
} else {
|
|
3328
|
+
console.log("\n\u{1F4A1} You can create a config file manually or run without --config for interactive setup.");
|
|
3329
|
+
process.exit(1);
|
|
3330
|
+
}
|
|
3331
|
+
} else {
|
|
3332
|
+
console.error("\u274C Error loading config file:", error.message);
|
|
3333
|
+
process.exit(1);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
} else {
|
|
3337
|
+
let config;
|
|
3338
|
+
if (isInteractiveMode(options)) {
|
|
3339
|
+
config = await promptForConfig(options);
|
|
3340
|
+
} else {
|
|
3341
|
+
config = options;
|
|
3342
|
+
}
|
|
3343
|
+
if (options.noUpdateExisting) {
|
|
3344
|
+
config.updateExistingWorktrees = false;
|
|
3345
|
+
} else if (config.updateExistingWorktrees === void 0) {
|
|
3346
|
+
config.updateExistingWorktrees = true;
|
|
3347
|
+
}
|
|
3348
|
+
if (options.debug !== void 0) {
|
|
3349
|
+
config.debug = options.debug;
|
|
3350
|
+
}
|
|
3351
|
+
await runSingleRepository(config);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
main().catch((error) => {
|
|
3355
|
+
console.error("\u274C Unhandled error:", error);
|
|
3356
|
+
process.exit(1);
|
|
230
3357
|
});
|
|
231
|
-
//# sourceMappingURL=index.js.map
|
|
3358
|
+
//# sourceMappingURL=index.js.map
|