sync-worktrees 2.1.0 → 3.0.1
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 +144 -56
- package/dist/index.js +3222 -920
- package/dist/index.js.map +4 -4
- package/dist/mcp-server.js +3852 -0
- package/dist/mcp-server.js.map +7 -0
- package/package.json +16 -3
|
@@ -0,0 +1,3852 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/mcp/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/mcp/context.ts
|
|
7
|
+
import * as fs7 from "fs/promises";
|
|
8
|
+
import * as path8 from "path";
|
|
9
|
+
import simpleGit4 from "simple-git";
|
|
10
|
+
|
|
11
|
+
// src/constants.ts
|
|
12
|
+
var GIT_CONSTANTS = {
|
|
13
|
+
REMOTE_PREFIX: "origin/",
|
|
14
|
+
REMOTE_NAME: "origin",
|
|
15
|
+
HEAD_REF: "/HEAD",
|
|
16
|
+
DEFAULT_BRANCH: "main",
|
|
17
|
+
COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
|
|
18
|
+
BARE_DIR_NAME: ".bare",
|
|
19
|
+
DIVERGED_DIR_NAME: ".diverged",
|
|
20
|
+
LFS_HEADER: "version https://git-lfs.github.com/spec/",
|
|
21
|
+
SUBMODULE_STATUS_ADDED: "+",
|
|
22
|
+
SUBMODULE_STATUS_REMOVED: "-",
|
|
23
|
+
GITDIR_PREFIX: "gitdir:",
|
|
24
|
+
GIT_CHECK_IGNORE_NO_MATCH: "exit code: 1",
|
|
25
|
+
REFS: {
|
|
26
|
+
HEADS: "refs/heads/",
|
|
27
|
+
REMOTES: "refs/remotes/origin",
|
|
28
|
+
REMOTES_ORIGIN: "refs/remotes/origin/*"
|
|
29
|
+
},
|
|
30
|
+
FETCH_CONFIG: "+refs/heads/*:refs/remotes/origin/*"
|
|
31
|
+
};
|
|
32
|
+
var GIT_OPERATIONS = {
|
|
33
|
+
MERGE_HEAD: "MERGE_HEAD",
|
|
34
|
+
CHERRY_PICK_HEAD: "CHERRY_PICK_HEAD",
|
|
35
|
+
REVERT_HEAD: "REVERT_HEAD",
|
|
36
|
+
BISECT_LOG: "BISECT_LOG",
|
|
37
|
+
REBASE_MERGE: "rebase-merge",
|
|
38
|
+
REBASE_APPLY: "rebase-apply"
|
|
39
|
+
};
|
|
40
|
+
var DEFAULT_CONFIG = {
|
|
41
|
+
CRON_SCHEDULE: "0 * * * *",
|
|
42
|
+
RETRY: {
|
|
43
|
+
MAX_ATTEMPTS: 3,
|
|
44
|
+
MAX_LFS_RETRIES: 2,
|
|
45
|
+
INITIAL_DELAY_MS: 1e3,
|
|
46
|
+
MAX_DELAY_MS: 3e4,
|
|
47
|
+
BACKOFF_MULTIPLIER: 2,
|
|
48
|
+
JITTER_MS: 500
|
|
49
|
+
},
|
|
50
|
+
PARALLELISM: {
|
|
51
|
+
MAX_REPOSITORIES: 2,
|
|
52
|
+
MAX_WORKTREE_CREATION: 1,
|
|
53
|
+
MAX_WORKTREE_UPDATES: 3,
|
|
54
|
+
MAX_WORKTREE_REMOVAL: 3,
|
|
55
|
+
MAX_STATUS_CHECKS: 20,
|
|
56
|
+
MAX_BRANCH_FETCHES: 3,
|
|
57
|
+
MAX_SAFE_TOTAL_CONCURRENT_OPS: 100
|
|
58
|
+
},
|
|
59
|
+
UPDATE_EXISTING_WORKTREES: true,
|
|
60
|
+
HOOK_TIMEOUT_MS: 6e4
|
|
61
|
+
};
|
|
62
|
+
var ERROR_MESSAGES = {
|
|
63
|
+
GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
|
|
64
|
+
ALREADY_EXISTS: "already exists",
|
|
65
|
+
ALREADY_REGISTERED: "already registered worktree",
|
|
66
|
+
FAST_FORWARD_FAILED: [
|
|
67
|
+
"Not possible to fast-forward",
|
|
68
|
+
"fatal: Not possible to fast-forward, aborting",
|
|
69
|
+
"cannot fast-forward"
|
|
70
|
+
],
|
|
71
|
+
NO_UPSTREAM: [
|
|
72
|
+
"fatal: no upstream configured",
|
|
73
|
+
"no upstream configured for branch",
|
|
74
|
+
"fatal: ambiguous argument",
|
|
75
|
+
"unknown revision or path"
|
|
76
|
+
],
|
|
77
|
+
LFS_ERROR: ["smudge filter lfs failed", "git-lfs", "LFS"],
|
|
78
|
+
EXDEV: "EXDEV"
|
|
79
|
+
};
|
|
80
|
+
var ENV_CONSTANTS = {
|
|
81
|
+
GIT_LFS_SKIP_SMUDGE: "GIT_LFS_SKIP_SMUDGE",
|
|
82
|
+
NODE_ENV_TEST: "test"
|
|
83
|
+
};
|
|
84
|
+
var PATH_CONSTANTS = {
|
|
85
|
+
GIT_DIR: ".git",
|
|
86
|
+
README: "README"
|
|
87
|
+
};
|
|
88
|
+
var METADATA_CONSTANTS = {
|
|
89
|
+
MAX_HISTORY_ENTRIES: 10,
|
|
90
|
+
METADATA_FILENAME: "sync-metadata.json",
|
|
91
|
+
WORKTREE_METADATA_PATH: ".git/worktrees",
|
|
92
|
+
DIVERGED_INFO_FILE: ".diverged-info.json",
|
|
93
|
+
DIVERGED_REASON: "diverged-history-with-changes",
|
|
94
|
+
ACTION_CREATED: "created",
|
|
95
|
+
ACTION_UPDATED: "updated",
|
|
96
|
+
ACTION_FETCHED: "fetched"
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/services/config-loader.service.ts
|
|
100
|
+
import * as fs from "fs/promises";
|
|
101
|
+
import * as path from "path";
|
|
102
|
+
import { pathToFileURL } from "url";
|
|
103
|
+
import * as cron from "node-cron";
|
|
104
|
+
|
|
105
|
+
// src/utils/branch-filter.ts
|
|
106
|
+
function matchesPattern(name, pattern) {
|
|
107
|
+
if (pattern.includes("*")) {
|
|
108
|
+
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
109
|
+
const regex = new RegExp("^" + escapedPattern + "$");
|
|
110
|
+
return regex.test(name);
|
|
111
|
+
}
|
|
112
|
+
return name === pattern;
|
|
113
|
+
}
|
|
114
|
+
function filterBranchesByName(branches, include, exclude) {
|
|
115
|
+
let result = branches;
|
|
116
|
+
if (include && include.length > 0) {
|
|
117
|
+
result = result.filter((branch) => include.some((pattern) => matchesPattern(branch, pattern)));
|
|
118
|
+
}
|
|
119
|
+
if (exclude && exclude.length > 0) {
|
|
120
|
+
result = result.filter((branch) => !exclude.some((pattern) => matchesPattern(branch, pattern)));
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/services/config-loader.service.ts
|
|
126
|
+
var ConfigLoaderService = class {
|
|
127
|
+
async loadConfigFile(configPath) {
|
|
128
|
+
const absolutePath = path.resolve(configPath);
|
|
129
|
+
try {
|
|
130
|
+
await fs.access(absolutePath);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(`Config file not found: ${absolutePath}`);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const fileUrl = pathToFileURL(absolutePath);
|
|
136
|
+
fileUrl.searchParams.set("t", Date.now().toString());
|
|
137
|
+
const configModule = await import(fileUrl.href);
|
|
138
|
+
const config = configModule.default;
|
|
139
|
+
if (!config) {
|
|
140
|
+
throw new Error("Config file must use 'export default' syntax");
|
|
141
|
+
}
|
|
142
|
+
this.validateConfigFile(config);
|
|
143
|
+
return config;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof Error && error.message.includes("Config file not found")) {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`Failed to load config file: ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
validateConfigFile(config) {
|
|
152
|
+
if (!config || typeof config !== "object") {
|
|
153
|
+
throw new Error("Config file must export an object");
|
|
154
|
+
}
|
|
155
|
+
const configObj = config;
|
|
156
|
+
if (!Array.isArray(configObj.repositories)) {
|
|
157
|
+
throw new Error("Config file must have a 'repositories' array");
|
|
158
|
+
}
|
|
159
|
+
if (configObj.repositories.length === 0) {
|
|
160
|
+
throw new Error("Config file must have at least one repository");
|
|
161
|
+
}
|
|
162
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
163
|
+
configObj.repositories.forEach((repo, index) => {
|
|
164
|
+
if (!repo || typeof repo !== "object") {
|
|
165
|
+
throw new Error(`Repository at index ${index} must be an object`);
|
|
166
|
+
}
|
|
167
|
+
const repoObj = repo;
|
|
168
|
+
if (!repoObj.name || typeof repoObj.name !== "string") {
|
|
169
|
+
throw new Error(`Repository at index ${index} must have a 'name' property`);
|
|
170
|
+
}
|
|
171
|
+
if (seenNames.has(repoObj.name)) {
|
|
172
|
+
throw new Error(`Duplicate repository name: ${repoObj.name}`);
|
|
173
|
+
}
|
|
174
|
+
seenNames.add(repoObj.name);
|
|
175
|
+
if (!repoObj.repoUrl || typeof repoObj.repoUrl !== "string") {
|
|
176
|
+
throw new Error(`Repository '${repoObj.name}' must have a 'repoUrl' property`);
|
|
177
|
+
}
|
|
178
|
+
if (!this.isValidGitUrl(repoObj.repoUrl)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Repository '${repoObj.name}' has invalid 'repoUrl': '${repoObj.repoUrl}'. Expected an HTTP(S), SSH, Git protocol URL, or a local/file path (file://, absolute filesystem path)`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (!repoObj.worktreeDir || typeof repoObj.worktreeDir !== "string") {
|
|
184
|
+
throw new Error(`Repository '${repoObj.name}' must have a 'worktreeDir' property`);
|
|
185
|
+
}
|
|
186
|
+
if (repoObj.bareRepoDir !== void 0 && typeof repoObj.bareRepoDir !== "string") {
|
|
187
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'bareRepoDir' property`);
|
|
188
|
+
}
|
|
189
|
+
if (repoObj.cronSchedule !== void 0 && typeof repoObj.cronSchedule !== "string") {
|
|
190
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'cronSchedule' property`);
|
|
191
|
+
}
|
|
192
|
+
if (typeof repoObj.cronSchedule === "string" && !cron.validate(repoObj.cronSchedule)) {
|
|
193
|
+
throw new Error(`Repository '${repoObj.name}' has invalid cron expression: '${repoObj.cronSchedule}'`);
|
|
194
|
+
}
|
|
195
|
+
if (repoObj.runOnce !== void 0 && typeof repoObj.runOnce !== "boolean") {
|
|
196
|
+
throw new Error(`Repository '${repoObj.name}' has invalid 'runOnce' property`);
|
|
197
|
+
}
|
|
198
|
+
if (repoObj.filesToCopyOnBranchCreate !== void 0) {
|
|
199
|
+
this.validateFilesToCopyConfig(repoObj.filesToCopyOnBranchCreate, `Repository '${repoObj.name}'`);
|
|
200
|
+
}
|
|
201
|
+
if (repoObj.hooks !== void 0) {
|
|
202
|
+
this.validateHooksConfig(repoObj.hooks, `Repository '${repoObj.name}'`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
if (configObj.defaults) {
|
|
206
|
+
if (typeof configObj.defaults !== "object") {
|
|
207
|
+
throw new Error("'defaults' must be an object");
|
|
208
|
+
}
|
|
209
|
+
const defaults = configObj.defaults;
|
|
210
|
+
if (defaults.cronSchedule !== void 0 && typeof defaults.cronSchedule !== "string") {
|
|
211
|
+
throw new Error("Invalid 'cronSchedule' in defaults");
|
|
212
|
+
}
|
|
213
|
+
if (typeof defaults.cronSchedule === "string" && !cron.validate(defaults.cronSchedule)) {
|
|
214
|
+
throw new Error(`Invalid cron expression in defaults: '${defaults.cronSchedule}'`);
|
|
215
|
+
}
|
|
216
|
+
if (defaults.runOnce !== void 0 && typeof defaults.runOnce !== "boolean") {
|
|
217
|
+
throw new Error("Invalid 'runOnce' in defaults");
|
|
218
|
+
}
|
|
219
|
+
if (defaults.retry !== void 0 && typeof defaults.retry !== "object") {
|
|
220
|
+
throw new Error("Invalid 'retry' in defaults");
|
|
221
|
+
}
|
|
222
|
+
if (defaults.filesToCopyOnBranchCreate !== void 0) {
|
|
223
|
+
this.validateFilesToCopyConfig(defaults.filesToCopyOnBranchCreate, "defaults");
|
|
224
|
+
}
|
|
225
|
+
if (defaults.hooks !== void 0) {
|
|
226
|
+
this.validateHooksConfig(defaults.hooks, "defaults");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (configObj.retry !== void 0) {
|
|
230
|
+
if (typeof configObj.retry !== "object") {
|
|
231
|
+
throw new Error("'retry' must be an object");
|
|
232
|
+
}
|
|
233
|
+
const retry2 = configObj.retry;
|
|
234
|
+
if (retry2.maxAttempts !== void 0) {
|
|
235
|
+
if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
|
|
236
|
+
throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (retry2.maxLfsRetries !== void 0) {
|
|
240
|
+
if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
|
|
241
|
+
throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
|
|
245
|
+
throw new Error("Invalid 'initialDelayMs' in retry config");
|
|
246
|
+
}
|
|
247
|
+
if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
|
|
248
|
+
throw new Error("Invalid 'maxDelayMs' in retry config");
|
|
249
|
+
}
|
|
250
|
+
if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
|
|
251
|
+
throw new Error("Invalid 'backoffMultiplier' in retry config");
|
|
252
|
+
}
|
|
253
|
+
const initialDelay = retry2.initialDelayMs ?? DEFAULT_CONFIG.RETRY.INITIAL_DELAY_MS;
|
|
254
|
+
const maxDelay = retry2.maxDelayMs ?? DEFAULT_CONFIG.RETRY.MAX_DELAY_MS;
|
|
255
|
+
if (initialDelay > maxDelay) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Invalid retry config: 'initialDelayMs' (${initialDelay}) must not exceed 'maxDelayMs' (${maxDelay})`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (configObj.parallelism !== void 0) {
|
|
262
|
+
this.validateParallelismConfig(configObj.parallelism, "global");
|
|
263
|
+
}
|
|
264
|
+
if (configObj.defaults && typeof configObj.defaults === "object") {
|
|
265
|
+
const defaults = configObj.defaults;
|
|
266
|
+
if (defaults.parallelism !== void 0) {
|
|
267
|
+
this.validateParallelismConfig(defaults.parallelism, "defaults");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
validateParallelismConfig(parallelism, context) {
|
|
272
|
+
if (typeof parallelism !== "object" || parallelism === null) {
|
|
273
|
+
throw new Error(`'parallelism' in ${context} must be an object`);
|
|
274
|
+
}
|
|
275
|
+
const config = parallelism;
|
|
276
|
+
const positiveIntFields = [
|
|
277
|
+
"maxRepositories",
|
|
278
|
+
"maxWorktreeCreation",
|
|
279
|
+
"maxWorktreeUpdates",
|
|
280
|
+
"maxWorktreeRemoval",
|
|
281
|
+
"maxStatusChecks",
|
|
282
|
+
"maxBranchFetches"
|
|
283
|
+
];
|
|
284
|
+
for (const field of positiveIntFields) {
|
|
285
|
+
const value = config[field];
|
|
286
|
+
if (value !== void 0 && (typeof value !== "number" || value < 1)) {
|
|
287
|
+
throw new Error(`Invalid '${field}' in ${context} parallelism config. Must be a positive number`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const maxRepos = config.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
|
|
291
|
+
const maxCreation = config.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
292
|
+
const maxUpdates = config.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
293
|
+
const maxRemoval = config.maxWorktreeRemoval ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_REMOVAL;
|
|
294
|
+
const maxStatus = config.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
295
|
+
const maxPerRepoOps = maxCreation + maxUpdates + maxRemoval + maxStatus;
|
|
296
|
+
const totalMaxConcurrent = maxRepos * maxPerRepoOps;
|
|
297
|
+
if (totalMaxConcurrent > DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS) {
|
|
298
|
+
const safeMaxRepos = Math.floor(DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS / maxPerRepoOps);
|
|
299
|
+
throw new Error(
|
|
300
|
+
`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.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
validateFilesToCopyConfig(filesToCopy, context) {
|
|
305
|
+
if (!Array.isArray(filesToCopy)) {
|
|
306
|
+
throw new Error(`'filesToCopyOnBranchCreate' in ${context} must be an array`);
|
|
307
|
+
}
|
|
308
|
+
for (let i = 0; i < filesToCopy.length; i++) {
|
|
309
|
+
const pattern = filesToCopy[i];
|
|
310
|
+
if (typeof pattern !== "string" || pattern.trim() === "") {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`'filesToCopyOnBranchCreate' in ${context} must contain only non-empty strings (invalid at index ${i})`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
validateHooksConfig(hooks, context) {
|
|
318
|
+
if (typeof hooks !== "object" || hooks === null) {
|
|
319
|
+
throw new Error(`'hooks' in ${context} must be an object`);
|
|
320
|
+
}
|
|
321
|
+
const hooksObj = hooks;
|
|
322
|
+
if (hooksObj.onBranchCreated !== void 0) {
|
|
323
|
+
if (!Array.isArray(hooksObj.onBranchCreated)) {
|
|
324
|
+
throw new Error(`'hooks.onBranchCreated' in ${context} must be an array`);
|
|
325
|
+
}
|
|
326
|
+
for (let i = 0; i < hooksObj.onBranchCreated.length; i++) {
|
|
327
|
+
const command = hooksObj.onBranchCreated[i];
|
|
328
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`'hooks.onBranchCreated' in ${context} must contain only non-empty strings (invalid at index ${i})`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
resolveRepositoryConfig(repo, defaults, configDir, globalRetry) {
|
|
337
|
+
const resolved = {
|
|
338
|
+
name: repo.name,
|
|
339
|
+
repoUrl: repo.repoUrl,
|
|
340
|
+
worktreeDir: this.resolvePath(repo.worktreeDir, configDir),
|
|
341
|
+
cronSchedule: repo.cronSchedule ?? defaults?.cronSchedule ?? DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
342
|
+
runOnce: repo.runOnce ?? defaults?.runOnce ?? false
|
|
343
|
+
};
|
|
344
|
+
if (repo.bareRepoDir) {
|
|
345
|
+
resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
|
|
346
|
+
}
|
|
347
|
+
if (repo.branchMaxAge || defaults?.branchMaxAge) {
|
|
348
|
+
resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
|
|
349
|
+
}
|
|
350
|
+
if (repo.branchInclude || defaults?.branchInclude) {
|
|
351
|
+
resolved.branchInclude = repo.branchInclude ?? defaults?.branchInclude;
|
|
352
|
+
}
|
|
353
|
+
if (repo.branchExclude || defaults?.branchExclude) {
|
|
354
|
+
resolved.branchExclude = repo.branchExclude ?? defaults?.branchExclude;
|
|
355
|
+
}
|
|
356
|
+
if (repo.skipLfs !== void 0 || defaults?.skipLfs !== void 0) {
|
|
357
|
+
resolved.skipLfs = repo.skipLfs ?? defaults?.skipLfs ?? false;
|
|
358
|
+
}
|
|
359
|
+
if (repo.retry || defaults?.retry || globalRetry) {
|
|
360
|
+
resolved.retry = {
|
|
361
|
+
...globalRetry || {},
|
|
362
|
+
...defaults?.retry || {},
|
|
363
|
+
...repo.retry || {}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (repo.parallelism || defaults?.parallelism) {
|
|
367
|
+
resolved.parallelism = {
|
|
368
|
+
...defaults?.parallelism || {},
|
|
369
|
+
...repo.parallelism || {}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
|
|
373
|
+
resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
|
|
374
|
+
}
|
|
375
|
+
if (repo.filesToCopyOnBranchCreate || defaults?.filesToCopyOnBranchCreate) {
|
|
376
|
+
const files = repo.filesToCopyOnBranchCreate ?? defaults?.filesToCopyOnBranchCreate;
|
|
377
|
+
resolved.filesToCopyOnBranchCreate = files?.map((f) => this.resolvePath(f, configDir));
|
|
378
|
+
}
|
|
379
|
+
if (repo.hooks || defaults?.hooks) {
|
|
380
|
+
resolved.hooks = {
|
|
381
|
+
...defaults?.hooks || {},
|
|
382
|
+
...repo.hooks || {}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return resolved;
|
|
386
|
+
}
|
|
387
|
+
isValidGitUrl(url) {
|
|
388
|
+
if (/^https?:\/\/.+/.test(url)) return true;
|
|
389
|
+
if (/^(ssh:\/\/|git@).+/.test(url)) return true;
|
|
390
|
+
if (/^git:\/\/.+/.test(url)) return true;
|
|
391
|
+
if (/^(file:\/\/|\/|[A-Za-z]:\\)/.test(url)) return true;
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
resolvePath(inputPath, baseDir) {
|
|
395
|
+
if (path.isAbsolute(inputPath)) {
|
|
396
|
+
return inputPath;
|
|
397
|
+
}
|
|
398
|
+
return path.resolve(baseDir || process.cwd(), inputPath);
|
|
399
|
+
}
|
|
400
|
+
filterRepositories(repositories, filter) {
|
|
401
|
+
if (!filter) {
|
|
402
|
+
return repositories;
|
|
403
|
+
}
|
|
404
|
+
const patterns = filter.split(",").map((p) => p.trim());
|
|
405
|
+
return repositories.filter((repo) => {
|
|
406
|
+
return patterns.some((pattern) => matchesPattern(repo.name, pattern));
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
async buildRepositories(configPath, overrides) {
|
|
410
|
+
const configFile = await this.loadConfigFile(configPath);
|
|
411
|
+
const configDir = path.dirname(path.resolve(configPath));
|
|
412
|
+
let repositories = configFile.repositories.map(
|
|
413
|
+
(repo) => this.resolveRepositoryConfig(repo, configFile.defaults, configDir, configFile.retry)
|
|
414
|
+
);
|
|
415
|
+
if (overrides?.filter) {
|
|
416
|
+
repositories = this.filterRepositories(repositories, overrides.filter);
|
|
417
|
+
}
|
|
418
|
+
if (overrides?.noUpdateExisting) {
|
|
419
|
+
repositories = repositories.map((repo) => ({ ...repo, updateExistingWorktrees: false }));
|
|
420
|
+
}
|
|
421
|
+
if (overrides?.debug) {
|
|
422
|
+
repositories = repositories.map((repo) => ({ ...repo, debug: true }));
|
|
423
|
+
}
|
|
424
|
+
return { repositories, configFile, configDir };
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// src/services/logger.service.ts
|
|
429
|
+
var Logger = class _Logger {
|
|
430
|
+
repoName;
|
|
431
|
+
debugEnabled;
|
|
432
|
+
outputFn;
|
|
433
|
+
constructor(options = {}) {
|
|
434
|
+
this.repoName = options.repoName;
|
|
435
|
+
this.debugEnabled = options.debug ?? false;
|
|
436
|
+
this.outputFn = options.outputFn;
|
|
437
|
+
}
|
|
438
|
+
prefix() {
|
|
439
|
+
return this.repoName ? `[${this.repoName}] ` : "";
|
|
440
|
+
}
|
|
441
|
+
debug(message, ...args) {
|
|
442
|
+
if (!this.debugEnabled) return;
|
|
443
|
+
const formattedMessage = this.prefix() + this.formatMessage(message, args);
|
|
444
|
+
if (this.outputFn) {
|
|
445
|
+
this.outputFn(formattedMessage, "debug");
|
|
446
|
+
} else {
|
|
447
|
+
console.log(formattedMessage);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
info(message, ...args) {
|
|
451
|
+
const formattedMessage = this.prefix() + this.formatMessage(message, args);
|
|
452
|
+
if (this.outputFn) {
|
|
453
|
+
this.outputFn(formattedMessage, "info");
|
|
454
|
+
} else {
|
|
455
|
+
console.log(formattedMessage);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
warn(message, ...args) {
|
|
459
|
+
const formattedMessage = this.prefix() + this.formatMessage(message, args);
|
|
460
|
+
if (this.outputFn) {
|
|
461
|
+
this.outputFn(formattedMessage, "warn");
|
|
462
|
+
} else {
|
|
463
|
+
console.warn(formattedMessage);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
error(message, error) {
|
|
467
|
+
let formattedMessage = this.prefix() + message;
|
|
468
|
+
if (error instanceof Error) {
|
|
469
|
+
formattedMessage += ` ${error.message}`;
|
|
470
|
+
} else if (error) {
|
|
471
|
+
formattedMessage += ` ${String(error)}`;
|
|
472
|
+
}
|
|
473
|
+
if (this.outputFn) {
|
|
474
|
+
this.outputFn(formattedMessage, "error");
|
|
475
|
+
} else {
|
|
476
|
+
if (error instanceof Error) {
|
|
477
|
+
console.error(this.prefix() + message, error);
|
|
478
|
+
} else if (error) {
|
|
479
|
+
console.error(this.prefix() + message, error);
|
|
480
|
+
} else {
|
|
481
|
+
console.error(this.prefix() + message);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
table(content) {
|
|
486
|
+
const formattedMessage = "\n" + content + "\n";
|
|
487
|
+
if (this.outputFn) {
|
|
488
|
+
this.outputFn(formattedMessage, "info");
|
|
489
|
+
} else {
|
|
490
|
+
console.log(formattedMessage);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
formatMessage(message, args) {
|
|
494
|
+
if (args.length === 0) {
|
|
495
|
+
return message;
|
|
496
|
+
}
|
|
497
|
+
return args.reduce((msg, arg) => msg.replace("%s", String(arg)), message);
|
|
498
|
+
}
|
|
499
|
+
static createDefault(repoName, debug) {
|
|
500
|
+
return new _Logger({ repoName, debug });
|
|
501
|
+
}
|
|
502
|
+
withPassthrough(passthrough) {
|
|
503
|
+
const upstream = this.outputFn;
|
|
504
|
+
return new _Logger({
|
|
505
|
+
repoName: this.repoName,
|
|
506
|
+
debug: this.debugEnabled,
|
|
507
|
+
outputFn: (msg, level) => {
|
|
508
|
+
if (upstream) {
|
|
509
|
+
upstream(msg, level);
|
|
510
|
+
} else {
|
|
511
|
+
defaultConsoleOutput(msg, level);
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
passthrough(msg, level);
|
|
515
|
+
} catch {
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
function defaultConsoleOutput(msg, level) {
|
|
522
|
+
if (level === "warn") console.warn(msg);
|
|
523
|
+
else if (level === "error") console.error(msg);
|
|
524
|
+
else console.log(msg);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/services/worktree-sync.service.ts
|
|
528
|
+
import * as fs6 from "fs/promises";
|
|
529
|
+
import * as path7 from "path";
|
|
530
|
+
import pLimit from "p-limit";
|
|
531
|
+
|
|
532
|
+
// src/utils/date-filter.ts
|
|
533
|
+
function parseDuration(durationStr) {
|
|
534
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
535
|
+
if (!match) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
const value = parseInt(match[1], 10);
|
|
539
|
+
const unit = match[2];
|
|
540
|
+
const multipliers = {
|
|
541
|
+
h: 60 * 60 * 1e3,
|
|
542
|
+
// hours
|
|
543
|
+
d: 24 * 60 * 60 * 1e3,
|
|
544
|
+
// days
|
|
545
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
546
|
+
// weeks
|
|
547
|
+
m: 30 * 24 * 60 * 60 * 1e3,
|
|
548
|
+
// months (approximate)
|
|
549
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
550
|
+
// years (approximate)
|
|
551
|
+
};
|
|
552
|
+
return value * multipliers[unit];
|
|
553
|
+
}
|
|
554
|
+
function filterBranchesByAge(branches, maxAge) {
|
|
555
|
+
const maxAgeMs = parseDuration(maxAge);
|
|
556
|
+
if (maxAgeMs === null) {
|
|
557
|
+
console.warn(`Invalid duration format: ${maxAge}. Using all branches.`);
|
|
558
|
+
return branches;
|
|
559
|
+
}
|
|
560
|
+
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
|
561
|
+
return branches.filter(({ lastActivity }) => lastActivity >= cutoffDate);
|
|
562
|
+
}
|
|
563
|
+
function formatDuration(durationStr) {
|
|
564
|
+
const match = durationStr.match(/^(\d+)([hdwmy])$/);
|
|
565
|
+
if (!match) {
|
|
566
|
+
return durationStr;
|
|
567
|
+
}
|
|
568
|
+
const value = parseInt(match[1], 10);
|
|
569
|
+
const unit = match[2];
|
|
570
|
+
const unitNames = {
|
|
571
|
+
h: value === 1 ? "hour" : "hours",
|
|
572
|
+
d: value === 1 ? "day" : "days",
|
|
573
|
+
w: value === 1 ? "week" : "weeks",
|
|
574
|
+
m: value === 1 ? "month" : "months",
|
|
575
|
+
y: value === 1 ? "year" : "years"
|
|
576
|
+
};
|
|
577
|
+
return `${value} ${unitNames[unit]}`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/utils/lfs-error.ts
|
|
581
|
+
function getErrorMessage(error) {
|
|
582
|
+
if (error instanceof Error) {
|
|
583
|
+
return error.message;
|
|
584
|
+
}
|
|
585
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
586
|
+
return String(error.message);
|
|
587
|
+
}
|
|
588
|
+
return String(error);
|
|
589
|
+
}
|
|
590
|
+
var LFS_ERROR_PATTERNS = Object.freeze([
|
|
591
|
+
"smudge filter lfs failed",
|
|
592
|
+
"Object does not exist on the server",
|
|
593
|
+
"external filter 'git-lfs filter-process' failed"
|
|
594
|
+
]);
|
|
595
|
+
function isLfsError(errorMessage) {
|
|
596
|
+
return LFS_ERROR_PATTERNS.some((pattern) => errorMessage.includes(pattern));
|
|
597
|
+
}
|
|
598
|
+
function isLfsErrorFromError(error) {
|
|
599
|
+
return isLfsError(getErrorMessage(error));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/utils/retry.ts
|
|
603
|
+
var DEFAULT_OPTIONS = {
|
|
604
|
+
maxAttempts: "unlimited",
|
|
605
|
+
maxLfsRetries: 2,
|
|
606
|
+
initialDelayMs: 1e3,
|
|
607
|
+
maxDelayMs: 6e5,
|
|
608
|
+
// 10 minutes
|
|
609
|
+
backoffMultiplier: 2,
|
|
610
|
+
jitterMs: 0,
|
|
611
|
+
shouldRetry: (error, context) => {
|
|
612
|
+
const err = error;
|
|
613
|
+
if (isLfsErrorFromError(error)) {
|
|
614
|
+
if (context) {
|
|
615
|
+
context.isLfsError = true;
|
|
616
|
+
}
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
if (err.code === "ENOTFOUND" || err.code === "ECONNREFUSED" || err.code === "ETIMEDOUT") {
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
if (err.code === "EBUSY" || err.code === "ENOENT" || err.code === "EACCES") {
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
if (err.message?.includes("Could not read from remote repository")) {
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
if (err.message?.includes("fatal: unable to access")) {
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
},
|
|
633
|
+
onRetry: () => {
|
|
634
|
+
},
|
|
635
|
+
lfsRetryHandler: (_context) => {
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
async function retry(fn, options = {}) {
|
|
639
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
640
|
+
let attempt = 1;
|
|
641
|
+
let lfsAttempt = 0;
|
|
642
|
+
const lfsContext = { isLfsError: false };
|
|
643
|
+
while (true) {
|
|
644
|
+
try {
|
|
645
|
+
return await fn();
|
|
646
|
+
} catch (error) {
|
|
647
|
+
lfsContext.isLfsError = false;
|
|
648
|
+
if (!opts.shouldRetry(error, lfsContext)) {
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
if (lfsContext.isLfsError) {
|
|
652
|
+
lfsAttempt++;
|
|
653
|
+
if (lfsAttempt > opts.maxLfsRetries) {
|
|
654
|
+
const err = error;
|
|
655
|
+
throw new Error(
|
|
656
|
+
`LFS error retry limit exceeded (${opts.maxLfsRetries} attempts). Consider using --skip-lfs option to bypass LFS downloads.`,
|
|
657
|
+
{ cause: err }
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const isLastAttempt = opts.maxAttempts !== "unlimited" && attempt >= opts.maxAttempts;
|
|
662
|
+
if (isLastAttempt) {
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
if (lfsContext.isLfsError && opts.lfsRetryHandler) {
|
|
666
|
+
opts.lfsRetryHandler(lfsContext);
|
|
667
|
+
}
|
|
668
|
+
const baseDelay = Math.min(opts.initialDelayMs * Math.pow(opts.backoffMultiplier, attempt - 1), opts.maxDelayMs);
|
|
669
|
+
const jitter = opts.jitterMs > 0 ? Math.random() * opts.jitterMs : 0;
|
|
670
|
+
const delay = baseDelay + jitter;
|
|
671
|
+
opts.onRetry(error, attempt, lfsContext);
|
|
672
|
+
await new Promise((resolve9) => setTimeout(resolve9, delay));
|
|
673
|
+
attempt++;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/utils/timing.ts
|
|
679
|
+
import Table from "cli-table3";
|
|
680
|
+
var Timer = class {
|
|
681
|
+
startTime;
|
|
682
|
+
endTime;
|
|
683
|
+
constructor() {
|
|
684
|
+
this.startTime = Date.now();
|
|
685
|
+
}
|
|
686
|
+
stop() {
|
|
687
|
+
this.endTime = Date.now();
|
|
688
|
+
return this.getDuration();
|
|
689
|
+
}
|
|
690
|
+
getDuration() {
|
|
691
|
+
const end = this.endTime ?? Date.now();
|
|
692
|
+
return end - this.startTime;
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
var PhaseTimer = class {
|
|
696
|
+
phases = /* @__PURE__ */ new Map();
|
|
697
|
+
currentPhase;
|
|
698
|
+
startPhase(name, parallelism) {
|
|
699
|
+
if (this.currentPhase) {
|
|
700
|
+
this.endPhase();
|
|
701
|
+
}
|
|
702
|
+
this.currentPhase = name;
|
|
703
|
+
this.phases.set(name, { timer: new Timer(), parallelism });
|
|
704
|
+
}
|
|
705
|
+
endPhase() {
|
|
706
|
+
if (this.currentPhase) {
|
|
707
|
+
const phase = this.phases.get(this.currentPhase);
|
|
708
|
+
if (phase) {
|
|
709
|
+
phase.timer.stop();
|
|
710
|
+
}
|
|
711
|
+
this.currentPhase = void 0;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
setPhaseCount(name, count) {
|
|
715
|
+
const phase = this.phases.get(name);
|
|
716
|
+
if (phase) {
|
|
717
|
+
phase.count = count;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
getResults() {
|
|
721
|
+
if (this.currentPhase) {
|
|
722
|
+
this.endPhase();
|
|
723
|
+
}
|
|
724
|
+
const results = [];
|
|
725
|
+
for (const [name, { timer, count, parallelism }] of this.phases.entries()) {
|
|
726
|
+
const duration = timer.getDuration();
|
|
727
|
+
const result = {
|
|
728
|
+
name,
|
|
729
|
+
duration,
|
|
730
|
+
count
|
|
731
|
+
};
|
|
732
|
+
if (count && count > 0 && parallelism && parallelism > 1) {
|
|
733
|
+
const batches = Math.ceil(count / parallelism);
|
|
734
|
+
const avgTimePerBatch = duration / batches;
|
|
735
|
+
const theoreticalSequentialTime = count * avgTimePerBatch;
|
|
736
|
+
result.efficiency = theoreticalSequentialTime > 0 ? Math.round(theoreticalSequentialTime / duration * 100) : 100;
|
|
737
|
+
}
|
|
738
|
+
results.push(result);
|
|
739
|
+
}
|
|
740
|
+
return results;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
function formatDuration2(ms) {
|
|
744
|
+
if (ms < 1e3) {
|
|
745
|
+
return `${ms}ms`;
|
|
746
|
+
}
|
|
747
|
+
if (ms < 6e4) {
|
|
748
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
749
|
+
}
|
|
750
|
+
const minutes = Math.floor(ms / 6e4);
|
|
751
|
+
const seconds = Math.floor(ms % 6e4 / 1e3);
|
|
752
|
+
return `${minutes}m ${seconds}s`;
|
|
753
|
+
}
|
|
754
|
+
function formatTimingTable(totalDuration, phaseResults, repoName) {
|
|
755
|
+
const header = repoName ? `Performance Summary - [${repoName}]` : "Performance Summary";
|
|
756
|
+
const table = new Table({
|
|
757
|
+
head: ["Operation", "Duration", "Efficiency"],
|
|
758
|
+
colWidths: [35, 12, 12],
|
|
759
|
+
style: {
|
|
760
|
+
head: ["cyan", "bold"],
|
|
761
|
+
border: ["gray"]
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
table.push([{ colSpan: 3, content: header, hAlign: "center" }]);
|
|
765
|
+
table.push(["Total Sync", formatDuration2(totalDuration), ""]);
|
|
766
|
+
for (let i = 0; i < phaseResults.length; i++) {
|
|
767
|
+
const result = phaseResults[i];
|
|
768
|
+
const isLast = i === phaseResults.length - 1;
|
|
769
|
+
const countStr = result.count ? ` (${result.count})` : "";
|
|
770
|
+
const prefix = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
771
|
+
const name = ` ${prefix} ${result.name}${countStr}`;
|
|
772
|
+
const efficiency = result.efficiency ? `${result.efficiency}%` : "";
|
|
773
|
+
table.push([name, formatDuration2(result.duration), efficiency]);
|
|
774
|
+
}
|
|
775
|
+
return table.toString();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// src/services/git.service.ts
|
|
779
|
+
import * as fs4 from "fs/promises";
|
|
780
|
+
import * as path4 from "path";
|
|
781
|
+
import simpleGit3 from "simple-git";
|
|
782
|
+
|
|
783
|
+
// src/utils/git-url.ts
|
|
784
|
+
function extractRepoNameFromUrl(gitUrl) {
|
|
785
|
+
const url = gitUrl.trim();
|
|
786
|
+
const sshMatch = url.match(/^git@[^:]+:(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
787
|
+
if (sshMatch) {
|
|
788
|
+
return sshMatch[1];
|
|
789
|
+
}
|
|
790
|
+
const sshUrlMatch = url.match(/^ssh:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
791
|
+
if (sshUrlMatch) {
|
|
792
|
+
return sshUrlMatch[1];
|
|
793
|
+
}
|
|
794
|
+
const httpsMatch = url.match(/^https?:\/\/[^/]+\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
795
|
+
if (httpsMatch) {
|
|
796
|
+
return httpsMatch[1];
|
|
797
|
+
}
|
|
798
|
+
const fileMatch = url.match(/^file:\/\/(?:.+\/)?([^/]+?)(?:\.git)?$/);
|
|
799
|
+
if (fileMatch) {
|
|
800
|
+
return fileMatch[1];
|
|
801
|
+
}
|
|
802
|
+
throw new Error(`Invalid Git URL format: ${gitUrl}`);
|
|
803
|
+
}
|
|
804
|
+
function getDefaultBareRepoDir(repoUrl, baseDir = ".bare") {
|
|
805
|
+
const repoName = extractRepoNameFromUrl(repoUrl);
|
|
806
|
+
return `${baseDir}/${repoName}`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/utils/worktree-list-parser.ts
|
|
810
|
+
function parseWorktreeListPorcelain(output) {
|
|
811
|
+
const worktrees = [];
|
|
812
|
+
let current = {};
|
|
813
|
+
const flush = () => {
|
|
814
|
+
if (!current.path) {
|
|
815
|
+
current = {};
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
worktrees.push({
|
|
819
|
+
path: current.path,
|
|
820
|
+
branch: current.branch ?? null,
|
|
821
|
+
head: current.head ?? null,
|
|
822
|
+
detached: current.detached ?? false,
|
|
823
|
+
prunable: current.prunable ?? false,
|
|
824
|
+
locked: current.locked ?? false
|
|
825
|
+
});
|
|
826
|
+
current = {};
|
|
827
|
+
};
|
|
828
|
+
for (const line of output.split("\n")) {
|
|
829
|
+
if (line.startsWith("worktree ")) {
|
|
830
|
+
flush();
|
|
831
|
+
current.path = line.substring("worktree ".length);
|
|
832
|
+
} else if (line.startsWith("branch ")) {
|
|
833
|
+
current.branch = line.substring("branch ".length).replace("refs/heads/", "");
|
|
834
|
+
} else if (line.startsWith("HEAD ")) {
|
|
835
|
+
current.head = line.substring("HEAD ".length);
|
|
836
|
+
} else if (line === "detached") {
|
|
837
|
+
current.detached = true;
|
|
838
|
+
} else if (line === "prunable" || line.startsWith("prunable ")) {
|
|
839
|
+
current.prunable = true;
|
|
840
|
+
} else if (line === "locked" || line.startsWith("locked ")) {
|
|
841
|
+
current.locked = true;
|
|
842
|
+
} else if (line.trim() === "") {
|
|
843
|
+
flush();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
flush();
|
|
847
|
+
return worktrees;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/services/worktree-metadata.service.ts
|
|
851
|
+
import * as fs2 from "fs/promises";
|
|
852
|
+
import * as path2 from "path";
|
|
853
|
+
import simpleGit from "simple-git";
|
|
854
|
+
var WorktreeMetadataService = class {
|
|
855
|
+
logger;
|
|
856
|
+
constructor(logger) {
|
|
857
|
+
this.logger = logger ?? Logger.createDefault();
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Gets the internal worktree directory name from a worktree path.
|
|
861
|
+
* Git uses the basename of the worktree path as the internal directory name.
|
|
862
|
+
* For example: /worktrees/fix/test-branch -> test-branch (not fix/test-branch)
|
|
863
|
+
*/
|
|
864
|
+
getWorktreeDirectoryName(worktreePath) {
|
|
865
|
+
return path2.basename(worktreePath);
|
|
866
|
+
}
|
|
867
|
+
async getMetadataPath(bareRepoPath, worktreeName) {
|
|
868
|
+
if (worktreeName.includes("/") || worktreeName.includes("\\")) {
|
|
869
|
+
throw new Error(
|
|
870
|
+
`getMetadataPath requires a filesystem-safe worktree directory name, got '${worktreeName}'. Use getMetadataPathFromWorktreePath when starting from a raw branch name.`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return path2.join(
|
|
874
|
+
bareRepoPath,
|
|
875
|
+
METADATA_CONSTANTS.WORKTREE_METADATA_PATH,
|
|
876
|
+
worktreeName,
|
|
877
|
+
METADATA_CONSTANTS.METADATA_FILENAME
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
async getMetadataPathFromWorktreePath(bareRepoPath, worktreePath) {
|
|
881
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
882
|
+
return this.getMetadataPath(bareRepoPath, worktreeDirName);
|
|
883
|
+
}
|
|
884
|
+
async saveMetadata(bareRepoPath, worktreeName, metadata) {
|
|
885
|
+
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
886
|
+
await fs2.mkdir(path2.dirname(metadataPath), { recursive: true });
|
|
887
|
+
const tmpPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
|
888
|
+
let renamed = false;
|
|
889
|
+
try {
|
|
890
|
+
await fs2.writeFile(tmpPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
891
|
+
try {
|
|
892
|
+
await fs2.rename(tmpPath, metadataPath);
|
|
893
|
+
renamed = true;
|
|
894
|
+
} catch (err) {
|
|
895
|
+
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
896
|
+
await fs2.copyFile(tmpPath, metadataPath);
|
|
897
|
+
} else {
|
|
898
|
+
throw err;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
} finally {
|
|
902
|
+
if (!renamed) {
|
|
903
|
+
await fs2.unlink(tmpPath).catch(() => void 0);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async loadMetadata(bareRepoPath, worktreeName) {
|
|
908
|
+
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
909
|
+
try {
|
|
910
|
+
const content = await fs2.readFile(metadataPath, "utf-8");
|
|
911
|
+
return JSON.parse(content);
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async loadMetadataFromPath(bareRepoPath, worktreePath) {
|
|
917
|
+
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
918
|
+
try {
|
|
919
|
+
const content = await fs2.readFile(metadataPath, "utf-8");
|
|
920
|
+
const metadata = JSON.parse(content);
|
|
921
|
+
if (!await this.validateMetadata(metadata)) {
|
|
922
|
+
this.logger.warn(`Corrupted metadata for ${worktreePath}, treating as missing`);
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
return metadata;
|
|
926
|
+
} catch {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
async deleteMetadata(bareRepoPath, worktreeName) {
|
|
931
|
+
const metadataPath = await this.getMetadataPath(bareRepoPath, worktreeName);
|
|
932
|
+
try {
|
|
933
|
+
await fs2.unlink(metadataPath);
|
|
934
|
+
} catch (error) {
|
|
935
|
+
if (error.code !== "ENOENT") {
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
async deleteMetadataFromPath(bareRepoPath, worktreePath) {
|
|
941
|
+
const metadataPath = await this.getMetadataPathFromWorktreePath(bareRepoPath, worktreePath);
|
|
942
|
+
try {
|
|
943
|
+
await fs2.unlink(metadataPath);
|
|
944
|
+
} catch (error) {
|
|
945
|
+
if (error.code !== "ENOENT") {
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async updateLastSync(bareRepoPath, worktreeName, commit, action = "updated") {
|
|
951
|
+
const existing = await this.loadMetadata(bareRepoPath, worktreeName);
|
|
952
|
+
if (!existing) {
|
|
953
|
+
this.logger.warn(
|
|
954
|
+
`No metadata found for worktree ${worktreeName}; skipping update because upstream/parent context is unavailable`
|
|
955
|
+
);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
existing.lastSyncCommit = commit;
|
|
959
|
+
existing.lastSyncDate = (/* @__PURE__ */ new Date()).toISOString();
|
|
960
|
+
existing.syncHistory.push({
|
|
961
|
+
date: existing.lastSyncDate,
|
|
962
|
+
commit,
|
|
963
|
+
action
|
|
964
|
+
});
|
|
965
|
+
if (existing.syncHistory.length > METADATA_CONSTANTS.MAX_HISTORY_ENTRIES) {
|
|
966
|
+
existing.syncHistory = existing.syncHistory.slice(-METADATA_CONSTANTS.MAX_HISTORY_ENTRIES);
|
|
967
|
+
}
|
|
968
|
+
await this.saveMetadata(bareRepoPath, worktreeName, existing);
|
|
969
|
+
}
|
|
970
|
+
async updateLastSyncFromPath(bareRepoPath, worktreePath, commit, action = "updated", defaultBranch) {
|
|
971
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
972
|
+
const existing = await this.loadMetadataFromPath(bareRepoPath, worktreePath);
|
|
973
|
+
if (!existing) {
|
|
974
|
+
this.logger.warn(`No metadata found for worktree ${worktreeDirName}`);
|
|
975
|
+
this.logger.info(` Attempting to create initial metadata...`);
|
|
976
|
+
try {
|
|
977
|
+
const worktreeGit = simpleGit(worktreePath);
|
|
978
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
979
|
+
const branchSummary = await worktreeGit.branch();
|
|
980
|
+
const actualBranchName = branchSummary.current;
|
|
981
|
+
if (!actualBranchName) {
|
|
982
|
+
throw new Error("Could not determine current branch name");
|
|
983
|
+
}
|
|
984
|
+
let upstreamBranch = `origin/${actualBranchName}`;
|
|
985
|
+
try {
|
|
986
|
+
const configuredUpstream = await worktreeGit.raw([
|
|
987
|
+
"rev-parse",
|
|
988
|
+
"--abbrev-ref",
|
|
989
|
+
`${actualBranchName}@{upstream}`
|
|
990
|
+
]);
|
|
991
|
+
if (configuredUpstream.trim()) {
|
|
992
|
+
upstreamBranch = configuredUpstream.trim();
|
|
993
|
+
}
|
|
994
|
+
} catch {
|
|
995
|
+
}
|
|
996
|
+
const parentBranch = defaultBranch || GIT_CONSTANTS.DEFAULT_BRANCH;
|
|
997
|
+
await this.createInitialMetadataFromPath(
|
|
998
|
+
bareRepoPath,
|
|
999
|
+
worktreePath,
|
|
1000
|
+
currentCommit.trim(),
|
|
1001
|
+
upstreamBranch,
|
|
1002
|
+
parentBranch,
|
|
1003
|
+
currentCommit.trim()
|
|
1004
|
+
);
|
|
1005
|
+
this.logger.info(` \u2705 Created metadata for ${worktreeDirName}`);
|
|
1006
|
+
return;
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
this.logger.error(` \u274C Failed to create metadata`, error);
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
existing.lastSyncCommit = commit;
|
|
1013
|
+
existing.lastSyncDate = (/* @__PURE__ */ new Date()).toISOString();
|
|
1014
|
+
existing.syncHistory.push({
|
|
1015
|
+
date: existing.lastSyncDate,
|
|
1016
|
+
commit,
|
|
1017
|
+
action
|
|
1018
|
+
});
|
|
1019
|
+
if (existing.syncHistory.length > METADATA_CONSTANTS.MAX_HISTORY_ENTRIES) {
|
|
1020
|
+
existing.syncHistory = existing.syncHistory.slice(-METADATA_CONSTANTS.MAX_HISTORY_ENTRIES);
|
|
1021
|
+
}
|
|
1022
|
+
await this.saveMetadata(bareRepoPath, worktreeDirName, existing);
|
|
1023
|
+
}
|
|
1024
|
+
async createInitialMetadata(bareRepoPath, worktreeName, commit, upstreamBranch, parentBranch, parentCommit) {
|
|
1025
|
+
const metadata = {
|
|
1026
|
+
lastSyncCommit: commit,
|
|
1027
|
+
lastSyncDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1028
|
+
upstreamBranch,
|
|
1029
|
+
createdFrom: {
|
|
1030
|
+
branch: parentBranch,
|
|
1031
|
+
commit: parentCommit
|
|
1032
|
+
},
|
|
1033
|
+
syncHistory: [
|
|
1034
|
+
{
|
|
1035
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1036
|
+
commit,
|
|
1037
|
+
action: "created"
|
|
1038
|
+
}
|
|
1039
|
+
]
|
|
1040
|
+
};
|
|
1041
|
+
await this.saveMetadata(bareRepoPath, worktreeName, metadata);
|
|
1042
|
+
}
|
|
1043
|
+
async createInitialMetadataFromPath(bareRepoPath, worktreePath, commit, upstreamBranch, parentBranch, parentCommit) {
|
|
1044
|
+
const worktreeDirName = this.getWorktreeDirectoryName(worktreePath);
|
|
1045
|
+
const metadata = {
|
|
1046
|
+
lastSyncCommit: commit,
|
|
1047
|
+
lastSyncDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1048
|
+
upstreamBranch,
|
|
1049
|
+
createdFrom: {
|
|
1050
|
+
branch: parentBranch,
|
|
1051
|
+
commit: parentCommit
|
|
1052
|
+
},
|
|
1053
|
+
syncHistory: [
|
|
1054
|
+
{
|
|
1055
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1056
|
+
commit,
|
|
1057
|
+
action: "created"
|
|
1058
|
+
}
|
|
1059
|
+
]
|
|
1060
|
+
};
|
|
1061
|
+
await this.saveMetadata(bareRepoPath, worktreeDirName, metadata);
|
|
1062
|
+
}
|
|
1063
|
+
async validateMetadata(metadata) {
|
|
1064
|
+
if (!metadata.lastSyncCommit || !metadata.lastSyncDate || !metadata.upstreamBranch) {
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
if (!/^[0-9a-f]+$/i.test(metadata.lastSyncCommit)) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
if (Number.isNaN(new Date(metadata.lastSyncDate).getTime())) {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// src/services/worktree-status.service.ts
|
|
1078
|
+
import * as fs3 from "fs/promises";
|
|
1079
|
+
import * as path3 from "path";
|
|
1080
|
+
import simpleGit2 from "simple-git";
|
|
1081
|
+
|
|
1082
|
+
// src/errors/index.ts
|
|
1083
|
+
var SyncWorktreesError = class extends Error {
|
|
1084
|
+
constructor(message, code, cause) {
|
|
1085
|
+
super(message);
|
|
1086
|
+
this.code = code;
|
|
1087
|
+
this.cause = cause;
|
|
1088
|
+
this.name = this.constructor.name;
|
|
1089
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
1090
|
+
if (cause && cause.stack) {
|
|
1091
|
+
this.stack = `${this.stack}
|
|
1092
|
+
Caused by: ${cause.stack}`;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
var GitError = class extends SyncWorktreesError {
|
|
1097
|
+
constructor(message, code, cause) {
|
|
1098
|
+
super(message, `GIT_${code}`, cause);
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
var GitOperationError = class extends GitError {
|
|
1102
|
+
constructor(operation, details, cause) {
|
|
1103
|
+
super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
var WorktreeError = class extends SyncWorktreesError {
|
|
1107
|
+
constructor(message, code, cause) {
|
|
1108
|
+
super(message, `WORKTREE_${code}`, cause);
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
var WorktreeNotCleanError = class extends WorktreeError {
|
|
1112
|
+
constructor(path10, reasons) {
|
|
1113
|
+
super(`Worktree at '${path10}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
|
|
1114
|
+
this.path = path10;
|
|
1115
|
+
this.reasons = reasons;
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// src/services/worktree-status.service.ts
|
|
1120
|
+
var OPERATION_FILES = [
|
|
1121
|
+
{ file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
|
|
1122
|
+
{ file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
|
|
1123
|
+
{ file: GIT_OPERATIONS.REVERT_HEAD, type: "revert" },
|
|
1124
|
+
{ file: GIT_OPERATIONS.BISECT_LOG, type: "bisect" },
|
|
1125
|
+
{ file: GIT_OPERATIONS.REBASE_MERGE, type: "rebase" },
|
|
1126
|
+
{ file: GIT_OPERATIONS.REBASE_APPLY, type: "rebase (apply)" }
|
|
1127
|
+
];
|
|
1128
|
+
var WorktreeStatusService = class {
|
|
1129
|
+
constructor(config = {}, logger) {
|
|
1130
|
+
this.config = config;
|
|
1131
|
+
this.logger = logger ?? Logger.createDefault();
|
|
1132
|
+
}
|
|
1133
|
+
gitInstances = /* @__PURE__ */ new Map();
|
|
1134
|
+
logger;
|
|
1135
|
+
async checkWorktreeStatus(worktreePath) {
|
|
1136
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1137
|
+
const status = await worktreeGit.status();
|
|
1138
|
+
const hasTrackedChanges = status.modified.length > 0 || status.deleted.length > 0 || status.renamed.length > 0 || status.created.length > 0 || status.conflicted.length > 0;
|
|
1139
|
+
if (hasTrackedChanges) {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
if (status.not_added.length > 0) {
|
|
1143
|
+
const untrackedFiles = status.not_added;
|
|
1144
|
+
const notIgnoredFiles = await this.filterUntrackedFiles(worktreePath, untrackedFiles);
|
|
1145
|
+
return notIgnoredFiles.length === 0;
|
|
1146
|
+
}
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
async getFullWorktreeStatus(worktreePath, includeDetails = false, lastSyncCommit) {
|
|
1150
|
+
try {
|
|
1151
|
+
await fs3.access(worktreePath);
|
|
1152
|
+
} catch {
|
|
1153
|
+
return {
|
|
1154
|
+
isClean: true,
|
|
1155
|
+
hasUnpushedCommits: false,
|
|
1156
|
+
hasStashedChanges: false,
|
|
1157
|
+
hasOperationInProgress: false,
|
|
1158
|
+
hasModifiedSubmodules: false,
|
|
1159
|
+
upstreamGone: false,
|
|
1160
|
+
canRemove: true,
|
|
1161
|
+
reasons: []
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
const snap = await this.collectSnapshot(worktreePath, lastSyncCommit);
|
|
1165
|
+
const isClean = this.deriveIsClean(snap);
|
|
1166
|
+
const hasUnpushedCommits = !snap.detached && (snap.unpushedCount ?? 1) > 0;
|
|
1167
|
+
const hasStashedChanges = snap.stashTotal === null ? true : snap.stashTotal > 0;
|
|
1168
|
+
const hasOperationInProgress = snap.gitDir === null ? true : snap.operationFile !== null;
|
|
1169
|
+
const hasModifiedSubmodules = this.deriveModifiedSubmodules(snap).length > 0 || snap.submoduleStatus === null;
|
|
1170
|
+
const upstreamGone = !snap.detached && snap.upstream !== null && snap.remoteBranches.length > 0 ? !snap.remoteBranches.includes(snap.upstream) : false;
|
|
1171
|
+
const reasons = [];
|
|
1172
|
+
if (!isClean) reasons.push("uncommitted changes");
|
|
1173
|
+
if (hasUnpushedCommits) reasons.push("unpushed commits");
|
|
1174
|
+
if (hasOperationInProgress) reasons.push("operation in progress");
|
|
1175
|
+
if (hasModifiedSubmodules) reasons.push("modified submodules");
|
|
1176
|
+
const canRemove = isClean && !hasUnpushedCommits && !hasOperationInProgress && !hasModifiedSubmodules;
|
|
1177
|
+
const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
|
|
1178
|
+
return {
|
|
1179
|
+
isClean,
|
|
1180
|
+
hasUnpushedCommits,
|
|
1181
|
+
hasStashedChanges,
|
|
1182
|
+
hasOperationInProgress,
|
|
1183
|
+
hasModifiedSubmodules,
|
|
1184
|
+
upstreamGone,
|
|
1185
|
+
canRemove,
|
|
1186
|
+
reasons,
|
|
1187
|
+
details
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
async collectSnapshot(worktreePath, lastSyncCommit) {
|
|
1191
|
+
const git = this.createGitInstance(worktreePath);
|
|
1192
|
+
const [status, branchResult, remoteBranchesResult, stashResult, submoduleResult, gitDirResult] = await Promise.all([
|
|
1193
|
+
git.status().catch((e) => {
|
|
1194
|
+
this.logger.error(`Error reading status for ${worktreePath}`, e);
|
|
1195
|
+
return null;
|
|
1196
|
+
}),
|
|
1197
|
+
git.branch().catch(() => null),
|
|
1198
|
+
git.branch(["-r"]).catch(() => null),
|
|
1199
|
+
git.stashList().catch((e) => {
|
|
1200
|
+
this.logger.error(`Error checking stash`, e);
|
|
1201
|
+
return null;
|
|
1202
|
+
}),
|
|
1203
|
+
git.raw(["submodule", "status"]).catch((e) => {
|
|
1204
|
+
this.logger.error(`Error checking submodule status`, e);
|
|
1205
|
+
return null;
|
|
1206
|
+
}),
|
|
1207
|
+
this.resolveGitDir(worktreePath).catch((e) => {
|
|
1208
|
+
this.logger.error(`Error checking operation in progress for ${worktreePath}`, e);
|
|
1209
|
+
return null;
|
|
1210
|
+
})
|
|
1211
|
+
]);
|
|
1212
|
+
const currentBranch = branchResult?.current ?? null;
|
|
1213
|
+
const detached = !branchResult?.current || Boolean(branchResult?.detached);
|
|
1214
|
+
let upstream = null;
|
|
1215
|
+
let unpushedCount = null;
|
|
1216
|
+
if (!detached && currentBranch) {
|
|
1217
|
+
const revListArgs = lastSyncCommit ? ["rev-list", "--count", `${lastSyncCommit}..HEAD`] : ["rev-list", "--count", currentBranch, "--not", "--remotes"];
|
|
1218
|
+
const [upstreamResult, unpushedResult] = await Promise.all([
|
|
1219
|
+
git.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]).then(
|
|
1220
|
+
(raw) => ({ ok: true, value: raw }),
|
|
1221
|
+
(error) => ({ ok: false, error })
|
|
1222
|
+
),
|
|
1223
|
+
git.raw(revListArgs).then(
|
|
1224
|
+
(raw) => ({ ok: true, value: raw }),
|
|
1225
|
+
(error) => ({ ok: false, error })
|
|
1226
|
+
)
|
|
1227
|
+
]);
|
|
1228
|
+
if (upstreamResult.ok) {
|
|
1229
|
+
upstream = upstreamResult.value.trim() || null;
|
|
1230
|
+
} else {
|
|
1231
|
+
const errorMessage = getErrorMessage(upstreamResult.error);
|
|
1232
|
+
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")) {
|
|
1233
|
+
this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (unpushedResult.ok) {
|
|
1237
|
+
unpushedCount = parseInt(unpushedResult.value.trim(), 10);
|
|
1238
|
+
} else {
|
|
1239
|
+
this.logger.error(`Error checking unpushed commits`, unpushedResult.error);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const operationFile = gitDirResult ? await this.detectOperationFile(gitDirResult) : null;
|
|
1243
|
+
let untrackedNotIgnored = [];
|
|
1244
|
+
if (status && status.not_added.length > 0) {
|
|
1245
|
+
try {
|
|
1246
|
+
untrackedNotIgnored = await this.filterUntrackedFiles(worktreePath, status.not_added);
|
|
1247
|
+
} catch {
|
|
1248
|
+
untrackedNotIgnored = status.not_added;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
exists: true,
|
|
1253
|
+
status,
|
|
1254
|
+
currentBranch,
|
|
1255
|
+
detached,
|
|
1256
|
+
remoteBranches: remoteBranchesResult?.all ?? [],
|
|
1257
|
+
upstream,
|
|
1258
|
+
unpushedCount,
|
|
1259
|
+
stashTotal: stashResult?.total ?? null,
|
|
1260
|
+
submoduleStatus: submoduleResult,
|
|
1261
|
+
operationFile,
|
|
1262
|
+
gitDir: gitDirResult,
|
|
1263
|
+
untrackedNotIgnored
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
deriveIsClean(snap) {
|
|
1267
|
+
const status = snap.status;
|
|
1268
|
+
if (!status) return false;
|
|
1269
|
+
const hasTracked = status.modified.length > 0 || status.deleted.length > 0 || status.renamed.length > 0 || status.created.length > 0 || status.conflicted.length > 0;
|
|
1270
|
+
if (hasTracked) return false;
|
|
1271
|
+
return snap.untrackedNotIgnored.length === 0;
|
|
1272
|
+
}
|
|
1273
|
+
deriveModifiedSubmodules(snap) {
|
|
1274
|
+
if (!snap.submoduleStatus) return [];
|
|
1275
|
+
const modified = [];
|
|
1276
|
+
for (const line of snap.submoduleStatus.split("\n").filter((l) => l.trim())) {
|
|
1277
|
+
const firstChar = line.charAt(0);
|
|
1278
|
+
if (firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_ADDED || firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_REMOVED) {
|
|
1279
|
+
const match = line.match(/^[+-]\s*(\S+)/);
|
|
1280
|
+
if (match) modified.push(match[1]);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return modified;
|
|
1284
|
+
}
|
|
1285
|
+
buildStatusDetails(snap) {
|
|
1286
|
+
const status = snap.status;
|
|
1287
|
+
const details = {
|
|
1288
|
+
modifiedFiles: status?.modified.length ?? 0,
|
|
1289
|
+
deletedFiles: status?.deleted.length ?? 0,
|
|
1290
|
+
renamedFiles: status?.renamed.length ?? 0,
|
|
1291
|
+
createdFiles: status?.created.length ?? 0,
|
|
1292
|
+
conflictedFiles: status?.conflicted.length ?? 0,
|
|
1293
|
+
untrackedFiles: snap.untrackedNotIgnored.length
|
|
1294
|
+
};
|
|
1295
|
+
if (status) {
|
|
1296
|
+
if (status.modified.length > 0) details.modifiedFilesList = status.modified;
|
|
1297
|
+
if (status.deleted.length > 0) details.deletedFilesList = status.deleted;
|
|
1298
|
+
if (status.renamed.length > 0) {
|
|
1299
|
+
details.renamedFilesList = status.renamed.map((r) => ({ from: r.from, to: r.to }));
|
|
1300
|
+
}
|
|
1301
|
+
if (status.created.length > 0) details.createdFilesList = status.created;
|
|
1302
|
+
if (status.conflicted.length > 0) details.conflictedFilesList = status.conflicted;
|
|
1303
|
+
}
|
|
1304
|
+
if (snap.untrackedNotIgnored.length > 0) details.untrackedFilesList = snap.untrackedNotIgnored;
|
|
1305
|
+
if (!snap.detached && snap.unpushedCount !== null) details.unpushedCommitCount = snap.unpushedCount;
|
|
1306
|
+
if (snap.stashTotal !== null) details.stashCount = snap.stashTotal;
|
|
1307
|
+
const opType = this.operationTypeFromFile(snap.operationFile);
|
|
1308
|
+
if (opType) details.operationType = opType;
|
|
1309
|
+
const modSubs = this.deriveModifiedSubmodules(snap);
|
|
1310
|
+
if (modSubs.length > 0) details.modifiedSubmodules = modSubs;
|
|
1311
|
+
return details;
|
|
1312
|
+
}
|
|
1313
|
+
operationTypeFromFile(file) {
|
|
1314
|
+
if (!file) return void 0;
|
|
1315
|
+
return OPERATION_FILES.find((op) => op.file === file)?.type;
|
|
1316
|
+
}
|
|
1317
|
+
async detectOperationFile(gitDir) {
|
|
1318
|
+
const results = await Promise.all(
|
|
1319
|
+
OPERATION_FILES.map(
|
|
1320
|
+
({ file }) => fs3.access(path3.join(gitDir, file)).then(
|
|
1321
|
+
() => true,
|
|
1322
|
+
() => false
|
|
1323
|
+
)
|
|
1324
|
+
)
|
|
1325
|
+
);
|
|
1326
|
+
const idx = results.findIndex(Boolean);
|
|
1327
|
+
return idx >= 0 ? OPERATION_FILES[idx].file : null;
|
|
1328
|
+
}
|
|
1329
|
+
async hasUnpushedCommits(worktreePath, lastSyncCommit) {
|
|
1330
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1331
|
+
try {
|
|
1332
|
+
if (await this.isDetachedHead(worktreeGit)) {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
const branchSummary = await worktreeGit.branch();
|
|
1336
|
+
const currentBranch = branchSummary.current;
|
|
1337
|
+
if (lastSyncCommit) {
|
|
1338
|
+
try {
|
|
1339
|
+
const newCommitsResult = await worktreeGit.raw(["rev-list", "--count", `${lastSyncCommit}..HEAD`]);
|
|
1340
|
+
const newCommitsCount = parseInt(newCommitsResult.trim(), 10);
|
|
1341
|
+
return newCommitsCount > 0;
|
|
1342
|
+
} catch {
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const result = await worktreeGit.raw(["rev-list", "--count", currentBranch, "--not", "--remotes"]);
|
|
1346
|
+
const unpushedCount = parseInt(result.trim(), 10);
|
|
1347
|
+
return unpushedCount > 0;
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
this.logger.error(`Error checking unpushed commits`, error);
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async hasUpstreamGone(worktreePath) {
|
|
1354
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1355
|
+
try {
|
|
1356
|
+
if (await this.isDetachedHead(worktreeGit)) {
|
|
1357
|
+
return false;
|
|
1358
|
+
}
|
|
1359
|
+
const branchSummary = await worktreeGit.branch();
|
|
1360
|
+
const currentBranch = branchSummary.current;
|
|
1361
|
+
const upstream = await worktreeGit.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]);
|
|
1362
|
+
const remoteBranches = await worktreeGit.branch(["-r"]);
|
|
1363
|
+
return !remoteBranches.all.includes(upstream.trim());
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
const errorMessage = getErrorMessage(error);
|
|
1366
|
+
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")) {
|
|
1367
|
+
return false;
|
|
1368
|
+
}
|
|
1369
|
+
this.logger.error(`Unexpected error checking upstream status for ${worktreePath}: ${errorMessage}`);
|
|
1370
|
+
return true;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
async hasStashedChanges(worktreePath) {
|
|
1374
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1375
|
+
try {
|
|
1376
|
+
const stashList = await worktreeGit.stashList();
|
|
1377
|
+
return stashList.total > 0;
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
this.logger.error(`Error checking stash`, error);
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
async hasModifiedSubmodules(worktreePath) {
|
|
1384
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1385
|
+
try {
|
|
1386
|
+
const result = await worktreeGit.raw(["submodule", "status"]);
|
|
1387
|
+
const lines = result.split("\n").filter((line) => line.trim());
|
|
1388
|
+
for (const line of lines) {
|
|
1389
|
+
const firstChar = line.charAt(0);
|
|
1390
|
+
if (firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_ADDED || firstChar === GIT_CONSTANTS.SUBMODULE_STATUS_REMOVED) {
|
|
1391
|
+
return true;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return false;
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
this.logger.error(`Error checking submodule status`, error);
|
|
1397
|
+
return true;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
async hasOperationInProgress(worktreePath) {
|
|
1401
|
+
try {
|
|
1402
|
+
const gitDir = await this.resolveGitDir(worktreePath);
|
|
1403
|
+
return await this.detectOperationFile(gitDir) !== null;
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
this.logger.error(`Error checking operation in progress for ${worktreePath}`, error);
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
async validateWorktreeForRemoval(worktreePath, lastSyncCommit) {
|
|
1410
|
+
const status = await this.getFullWorktreeStatus(worktreePath, false, lastSyncCommit);
|
|
1411
|
+
if (!status.canRemove) {
|
|
1412
|
+
throw new WorktreeNotCleanError(worktreePath, status.reasons);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
async filterUntrackedFiles(worktreePath, files) {
|
|
1416
|
+
if (files.length === 0) return [];
|
|
1417
|
+
const worktreeGit = this.createGitInstance(worktreePath);
|
|
1418
|
+
try {
|
|
1419
|
+
const result = await worktreeGit.raw(["check-ignore", "--", ...files]);
|
|
1420
|
+
const ignoredFiles = new Set(
|
|
1421
|
+
result.trim().split("\n").filter((f) => f)
|
|
1422
|
+
);
|
|
1423
|
+
return files.filter((f) => !ignoredFiles.has(f));
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
const errorMessage = getErrorMessage(error);
|
|
1426
|
+
if (errorMessage.includes(GIT_CONSTANTS.GIT_CHECK_IGNORE_NO_MATCH)) {
|
|
1427
|
+
return files;
|
|
1428
|
+
}
|
|
1429
|
+
throw error;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
async isDetachedHead(worktreeGit) {
|
|
1433
|
+
try {
|
|
1434
|
+
const branchSummary = await worktreeGit.branch();
|
|
1435
|
+
return !branchSummary.current || branchSummary.detached;
|
|
1436
|
+
} catch {
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
async resolveGitDir(worktreePath) {
|
|
1441
|
+
const gitPath = path3.join(worktreePath, PATH_CONSTANTS.GIT_DIR);
|
|
1442
|
+
try {
|
|
1443
|
+
const stat4 = await fs3.stat(gitPath);
|
|
1444
|
+
if (stat4.isFile()) {
|
|
1445
|
+
const content = await fs3.readFile(gitPath, "utf-8");
|
|
1446
|
+
const gitdirMatch = content.match(new RegExp(`^${GIT_CONSTANTS.GITDIR_PREFIX}\\s*(.+)$`, "m"));
|
|
1447
|
+
if (gitdirMatch) {
|
|
1448
|
+
return path3.resolve(worktreePath, gitdirMatch[1].trim());
|
|
1449
|
+
}
|
|
1450
|
+
throw new GitOperationError("resolve-git-dir", `Failed to parse gitdir from .git file at ${gitPath}`);
|
|
1451
|
+
}
|
|
1452
|
+
return gitPath;
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
throw new GitOperationError(
|
|
1455
|
+
"resolve-git-dir",
|
|
1456
|
+
`Failed to resolve .git directory for ${worktreePath}`,
|
|
1457
|
+
error instanceof Error ? error : void 0
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
createGitInstance(worktreePath) {
|
|
1462
|
+
const key = `${path3.resolve(worktreePath)}::${this.config.skipLfs ? "1" : "0"}`;
|
|
1463
|
+
let git = this.gitInstances.get(key);
|
|
1464
|
+
if (!git) {
|
|
1465
|
+
git = this.config.skipLfs ? simpleGit2(worktreePath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit2(worktreePath);
|
|
1466
|
+
this.gitInstances.set(key, git);
|
|
1467
|
+
}
|
|
1468
|
+
return git;
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
// src/services/git.service.ts
|
|
1473
|
+
var GitService = class {
|
|
1474
|
+
constructor(config, logger) {
|
|
1475
|
+
this.config = config;
|
|
1476
|
+
this.logger = logger ?? Logger.createDefault(void 0, config.debug);
|
|
1477
|
+
this.bareRepoPath = this.config.bareRepoDir || getDefaultBareRepoDir(this.config.repoUrl);
|
|
1478
|
+
this.mainWorktreePath = path4.join(this.config.worktreeDir, GIT_CONSTANTS.DEFAULT_BRANCH);
|
|
1479
|
+
this.metadataService = new WorktreeMetadataService(this.logger);
|
|
1480
|
+
this.statusService = new WorktreeStatusService({ skipLfs: this.config.skipLfs }, this.logger);
|
|
1481
|
+
}
|
|
1482
|
+
git = null;
|
|
1483
|
+
bareRepoPath;
|
|
1484
|
+
mainWorktreePath;
|
|
1485
|
+
defaultBranch = GIT_CONSTANTS.DEFAULT_BRANCH;
|
|
1486
|
+
// Will be updated after detection
|
|
1487
|
+
metadataService;
|
|
1488
|
+
statusService;
|
|
1489
|
+
logger;
|
|
1490
|
+
lfsSkipOverride = false;
|
|
1491
|
+
gitInstances = /* @__PURE__ */ new Map();
|
|
1492
|
+
getCachedGit(dirPath, useLfsSkip = false) {
|
|
1493
|
+
const key = `${path4.resolve(dirPath)}::${useLfsSkip ? "1" : "0"}`;
|
|
1494
|
+
let git = this.gitInstances.get(key);
|
|
1495
|
+
if (!git) {
|
|
1496
|
+
git = useLfsSkip ? simpleGit3(dirPath).env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3(dirPath);
|
|
1497
|
+
this.gitInstances.set(key, git);
|
|
1498
|
+
}
|
|
1499
|
+
return git;
|
|
1500
|
+
}
|
|
1501
|
+
updateLogger(logger) {
|
|
1502
|
+
this.logger = logger;
|
|
1503
|
+
}
|
|
1504
|
+
async initialize() {
|
|
1505
|
+
const { repoUrl } = this.config;
|
|
1506
|
+
try {
|
|
1507
|
+
await fs4.access(path4.join(this.bareRepoPath, "HEAD"));
|
|
1508
|
+
} catch {
|
|
1509
|
+
this.logger.info(`Cloning from "${repoUrl}" as bare repository into "${this.bareRepoPath}"...`);
|
|
1510
|
+
await fs4.mkdir(path4.dirname(this.bareRepoPath), { recursive: true });
|
|
1511
|
+
const cloneGit = this.isLfsSkipEnabled() ? simpleGit3().env({ [ENV_CONSTANTS.GIT_LFS_SKIP_SMUDGE]: "1" }) : simpleGit3();
|
|
1512
|
+
await cloneGit.clone(repoUrl, this.bareRepoPath, ["--bare"]);
|
|
1513
|
+
this.logger.info("\u2705 Clone successful.");
|
|
1514
|
+
}
|
|
1515
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
1516
|
+
try {
|
|
1517
|
+
const existingConfig = await bareGit.raw(["config", "--get-all", "remote.origin.fetch"]);
|
|
1518
|
+
const targetConfig = "+refs/heads/*:refs/remotes/origin/*";
|
|
1519
|
+
if (!existingConfig.includes(targetConfig)) {
|
|
1520
|
+
await bareGit.addConfig("remote.origin.fetch", targetConfig);
|
|
1521
|
+
}
|
|
1522
|
+
} catch {
|
|
1523
|
+
await bareGit.addConfig("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*");
|
|
1524
|
+
}
|
|
1525
|
+
this.logger.info("Fetching remote branches...");
|
|
1526
|
+
await bareGit.fetch(["--all"]);
|
|
1527
|
+
this.defaultBranch = await this.detectDefaultBranch(bareGit);
|
|
1528
|
+
this.mainWorktreePath = path4.join(this.config.worktreeDir, this.defaultBranch);
|
|
1529
|
+
let needsMainWorktree = true;
|
|
1530
|
+
try {
|
|
1531
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1532
|
+
needsMainWorktree = !worktrees.some((w) => path4.resolve(w.path) === path4.resolve(this.mainWorktreePath));
|
|
1533
|
+
} catch {
|
|
1534
|
+
}
|
|
1535
|
+
if (needsMainWorktree) {
|
|
1536
|
+
this.logger.info(`Creating ${this.defaultBranch} worktree at "${this.mainWorktreePath}"...`);
|
|
1537
|
+
await fs4.mkdir(this.config.worktreeDir, { recursive: true });
|
|
1538
|
+
const absoluteWorktreePath = path4.resolve(this.mainWorktreePath);
|
|
1539
|
+
const branches = await bareGit.branch();
|
|
1540
|
+
const defaultBranchExists = branches.all.includes(this.defaultBranch);
|
|
1541
|
+
try {
|
|
1542
|
+
if (defaultBranchExists) {
|
|
1543
|
+
await bareGit.raw(["worktree", "add", absoluteWorktreePath, this.defaultBranch]);
|
|
1544
|
+
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
1545
|
+
await worktreeGit.branch(["--set-upstream-to", `origin/${this.defaultBranch}`, this.defaultBranch]);
|
|
1546
|
+
} else {
|
|
1547
|
+
await bareGit.raw([
|
|
1548
|
+
"worktree",
|
|
1549
|
+
"add",
|
|
1550
|
+
"--track",
|
|
1551
|
+
"-b",
|
|
1552
|
+
this.defaultBranch,
|
|
1553
|
+
absoluteWorktreePath,
|
|
1554
|
+
`origin/${this.defaultBranch}`
|
|
1555
|
+
]);
|
|
1556
|
+
}
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
const errorMessage = getErrorMessage(error);
|
|
1559
|
+
if (errorMessage.includes("already exists")) {
|
|
1560
|
+
this.logger.info(
|
|
1561
|
+
`${this.defaultBranch} worktree directory already exists at '${absoluteWorktreePath}', skipping creation.`
|
|
1562
|
+
);
|
|
1563
|
+
} else {
|
|
1564
|
+
throw error;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const updatedWorktrees = await this.getWorktreesFromBare(bareGit);
|
|
1568
|
+
const mainWorktreeRegistered = updatedWorktrees.some(
|
|
1569
|
+
(w) => path4.resolve(w.path) === path4.resolve(this.mainWorktreePath)
|
|
1570
|
+
);
|
|
1571
|
+
if (!mainWorktreeRegistered) {
|
|
1572
|
+
if (process.env.NODE_ENV !== ENV_CONSTANTS.NODE_ENV_TEST) {
|
|
1573
|
+
this.logger.warn(`Main worktree was created but not found in worktree list. This may cause issues.`);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
this.git = this.getCachedGit(this.mainWorktreePath);
|
|
1578
|
+
return this.git;
|
|
1579
|
+
}
|
|
1580
|
+
getGit() {
|
|
1581
|
+
if (!this.git) {
|
|
1582
|
+
throw new Error("Git service not initialized. Call initialize() first.");
|
|
1583
|
+
}
|
|
1584
|
+
return this.git;
|
|
1585
|
+
}
|
|
1586
|
+
isInitialized() {
|
|
1587
|
+
return this.git !== null;
|
|
1588
|
+
}
|
|
1589
|
+
getDefaultBranch() {
|
|
1590
|
+
return this.defaultBranch;
|
|
1591
|
+
}
|
|
1592
|
+
async fetchAll() {
|
|
1593
|
+
this.assertInitialized();
|
|
1594
|
+
this.logger.info("Fetching latest data from remote...");
|
|
1595
|
+
const git = this.getCachedGit(this.mainWorktreePath, this.isLfsSkipEnabled());
|
|
1596
|
+
await git.fetch(["--all", "--prune"]);
|
|
1597
|
+
}
|
|
1598
|
+
async fetchBranch(branchName) {
|
|
1599
|
+
this.assertInitialized();
|
|
1600
|
+
const git = this.getCachedGit(this.mainWorktreePath, this.isLfsSkipEnabled());
|
|
1601
|
+
await git.fetch(["origin", branchName, "--prune"]);
|
|
1602
|
+
}
|
|
1603
|
+
assertInitialized() {
|
|
1604
|
+
if (!this.git) {
|
|
1605
|
+
throw new Error("Git service not initialized. Call initialize() first.");
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
async getRemoteBranches() {
|
|
1609
|
+
const git = this.getGit();
|
|
1610
|
+
const branches = await git.branch(["-r"]);
|
|
1611
|
+
return branches.all.filter((b) => b.startsWith("origin/") && !b.endsWith("/HEAD")).map((b) => b.replace("origin/", "")).filter((b) => b !== "origin" && b.length > 0);
|
|
1612
|
+
}
|
|
1613
|
+
async getRemoteBranchesWithActivity() {
|
|
1614
|
+
const git = this.getGit();
|
|
1615
|
+
const result = await git.raw([
|
|
1616
|
+
"for-each-ref",
|
|
1617
|
+
"--format=%(refname:short)|%(committerdate:iso8601)",
|
|
1618
|
+
"refs/remotes/origin"
|
|
1619
|
+
]);
|
|
1620
|
+
const branches = [];
|
|
1621
|
+
const lines = result.trim().split("\n").filter((line) => line);
|
|
1622
|
+
for (const line of lines) {
|
|
1623
|
+
const [ref, dateStr] = line.split("|", 2);
|
|
1624
|
+
if (ref && dateStr && !ref.endsWith("/HEAD")) {
|
|
1625
|
+
const branch = ref.replace("origin/", "");
|
|
1626
|
+
if (branch === "origin" || branch.length === 0) {
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const lastActivity = new Date(dateStr);
|
|
1630
|
+
if (!isNaN(lastActivity.getTime())) {
|
|
1631
|
+
branches.push({ branch, lastActivity });
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return branches;
|
|
1636
|
+
}
|
|
1637
|
+
async verifyLfsFilesDownloaded(worktreePath, branchName) {
|
|
1638
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
1639
|
+
try {
|
|
1640
|
+
const lfsFiles = await worktreeGit.raw(["lfs", "ls-files", "--name-only"]);
|
|
1641
|
+
const lfsFileList = lfsFiles.trim().split("\n").filter((f) => f.length > 0);
|
|
1642
|
+
if (lfsFileList.length === 0) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
if (this.config.debug) {
|
|
1646
|
+
this.logger.info(` - Verifying ${lfsFileList.length} LFS files are downloaded...`);
|
|
1647
|
+
}
|
|
1648
|
+
const sampleSize = Math.min(5, lfsFileList.length);
|
|
1649
|
+
const samplesToCheck = [];
|
|
1650
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
1651
|
+
const randomIndex = Math.floor(Math.random() * lfsFileList.length);
|
|
1652
|
+
samplesToCheck.push(lfsFileList[randomIndex]);
|
|
1653
|
+
}
|
|
1654
|
+
let retries = 0;
|
|
1655
|
+
const maxRetries = 30;
|
|
1656
|
+
const retryDelay = 1e3;
|
|
1657
|
+
while (retries < maxRetries) {
|
|
1658
|
+
let allDownloaded = true;
|
|
1659
|
+
const notDownloaded = [];
|
|
1660
|
+
for (const file of samplesToCheck) {
|
|
1661
|
+
const filePath = path4.join(worktreePath, file);
|
|
1662
|
+
try {
|
|
1663
|
+
const handle = await fs4.open(filePath, "r");
|
|
1664
|
+
try {
|
|
1665
|
+
const buffer = Buffer.alloc(200);
|
|
1666
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
1667
|
+
const header = buffer.subarray(0, bytesRead).toString("utf8");
|
|
1668
|
+
if (header.startsWith(GIT_CONSTANTS.LFS_HEADER)) {
|
|
1669
|
+
allDownloaded = false;
|
|
1670
|
+
notDownloaded.push(file);
|
|
1671
|
+
}
|
|
1672
|
+
} finally {
|
|
1673
|
+
await handle.close();
|
|
1674
|
+
}
|
|
1675
|
+
} catch {
|
|
1676
|
+
allDownloaded = false;
|
|
1677
|
+
notDownloaded.push(file);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
if (allDownloaded) {
|
|
1681
|
+
if (this.config.debug) {
|
|
1682
|
+
this.logger.info(` - \u2705 LFS files verified (${samplesToCheck.length} samples checked)`);
|
|
1683
|
+
}
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
retries++;
|
|
1687
|
+
if (retries < maxRetries) {
|
|
1688
|
+
await new Promise((resolve9) => setTimeout(resolve9, retryDelay));
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
this.logger.warn(
|
|
1692
|
+
` - \u26A0\uFE0F Warning: Some LFS files may not be fully downloaded after ${maxRetries} seconds. This might cause issues if tools access the worktree immediately.`
|
|
1693
|
+
);
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
this.logger.warn(` - \u26A0\uFE0F Warning: Could not verify LFS files for '${branchName}': ${error}`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
async createWorktreeMetadata(bareGit, worktreePath, branchName) {
|
|
1699
|
+
try {
|
|
1700
|
+
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
1701
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
1702
|
+
const parentCommit = await bareGit.revparse([this.defaultBranch]);
|
|
1703
|
+
await this.metadataService.createInitialMetadataFromPath(
|
|
1704
|
+
this.bareRepoPath,
|
|
1705
|
+
worktreePath,
|
|
1706
|
+
currentCommit.trim(),
|
|
1707
|
+
`origin/${branchName}`,
|
|
1708
|
+
this.defaultBranch,
|
|
1709
|
+
parentCommit.trim()
|
|
1710
|
+
);
|
|
1711
|
+
} catch (metadataError) {
|
|
1712
|
+
this.logger.error(` - \u274C Failed to create metadata for '${branchName}': ${metadataError}`);
|
|
1713
|
+
throw new Error(`Metadata creation failed for ${branchName}. This worktree cannot be auto-managed.`);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
async addWorktree(branchName, worktreePath) {
|
|
1717
|
+
const bareGit = this.getCachedGit(this.bareRepoPath, this.isLfsSkipEnabled());
|
|
1718
|
+
const absoluteWorktreePath = path4.resolve(worktreePath);
|
|
1719
|
+
await fs4.mkdir(path4.dirname(absoluteWorktreePath), { recursive: true });
|
|
1720
|
+
try {
|
|
1721
|
+
await fs4.access(absoluteWorktreePath);
|
|
1722
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1723
|
+
const isValidWorktree = worktrees.some((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1724
|
+
if (isValidWorktree) {
|
|
1725
|
+
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1726
|
+
return;
|
|
1727
|
+
} else {
|
|
1728
|
+
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}'`);
|
|
1729
|
+
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1730
|
+
}
|
|
1731
|
+
} catch {
|
|
1732
|
+
}
|
|
1733
|
+
try {
|
|
1734
|
+
const branches = await bareGit.branch();
|
|
1735
|
+
const localBranchExists = branches.all.includes(branchName);
|
|
1736
|
+
if (localBranchExists || branchName.includes("/")) {
|
|
1737
|
+
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
1738
|
+
const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
|
|
1739
|
+
await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
|
|
1740
|
+
} else {
|
|
1741
|
+
await bareGit.raw([
|
|
1742
|
+
"worktree",
|
|
1743
|
+
"add",
|
|
1744
|
+
"--track",
|
|
1745
|
+
"-b",
|
|
1746
|
+
branchName,
|
|
1747
|
+
absoluteWorktreePath,
|
|
1748
|
+
`origin/${branchName}`
|
|
1749
|
+
]);
|
|
1750
|
+
}
|
|
1751
|
+
this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
|
|
1752
|
+
if (!this.isLfsSkipEnabled()) {
|
|
1753
|
+
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1757
|
+
} catch (metadataError) {
|
|
1758
|
+
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1759
|
+
try {
|
|
1760
|
+
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1761
|
+
} catch {
|
|
1762
|
+
}
|
|
1763
|
+
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1764
|
+
}
|
|
1765
|
+
} catch (error) {
|
|
1766
|
+
const errorMessage = getErrorMessage(error);
|
|
1767
|
+
if (errorMessage.includes("Metadata creation failed")) {
|
|
1768
|
+
throw error;
|
|
1769
|
+
}
|
|
1770
|
+
if (errorMessage.includes("already registered worktree")) {
|
|
1771
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1772
|
+
const existingWorktree = worktrees.find((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1773
|
+
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1774
|
+
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation`);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
this.logger.warn(` - Worktree already registered but missing. Pruning and retrying...`);
|
|
1778
|
+
await bareGit.raw(["worktree", "prune"]);
|
|
1779
|
+
try {
|
|
1780
|
+
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1781
|
+
} catch {
|
|
1782
|
+
}
|
|
1783
|
+
try {
|
|
1784
|
+
await bareGit.raw([
|
|
1785
|
+
"worktree",
|
|
1786
|
+
"add",
|
|
1787
|
+
"--track",
|
|
1788
|
+
"-b",
|
|
1789
|
+
branchName,
|
|
1790
|
+
absoluteWorktreePath,
|
|
1791
|
+
`origin/${branchName}`
|
|
1792
|
+
]);
|
|
1793
|
+
this.logger.info(` - Created worktree for '${branchName}' after pruning`);
|
|
1794
|
+
if (!this.isLfsSkipEnabled()) {
|
|
1795
|
+
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
1796
|
+
}
|
|
1797
|
+
try {
|
|
1798
|
+
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1799
|
+
} catch (metadataError) {
|
|
1800
|
+
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1801
|
+
try {
|
|
1802
|
+
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1803
|
+
} catch {
|
|
1804
|
+
}
|
|
1805
|
+
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1806
|
+
}
|
|
1807
|
+
return;
|
|
1808
|
+
} catch (retryError) {
|
|
1809
|
+
this.logger.error(` - Failed to create worktree after pruning: ${retryError}`);
|
|
1810
|
+
throw retryError;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
const isTrackingError = errorMessage.includes("not a valid object name") || errorMessage.includes("not a commit") || errorMessage.includes("cannot set up tracking") || errorMessage.includes("does not track") || errorMessage.includes("remote tracking branch") || errorMessage.includes("no such remote ref");
|
|
1814
|
+
if (!isTrackingError) {
|
|
1815
|
+
throw error;
|
|
1816
|
+
}
|
|
1817
|
+
this.logger.warn(` - Failed to create worktree with tracking, falling back to simple add: ${error}`);
|
|
1818
|
+
try {
|
|
1819
|
+
await fs4.access(absoluteWorktreePath);
|
|
1820
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1821
|
+
const isValidWorktree = worktrees.some((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1822
|
+
if (isValidWorktree) {
|
|
1823
|
+
this.logger.info(` - Worktree for '${branchName}' already exists at '${absoluteWorktreePath}'`);
|
|
1824
|
+
return;
|
|
1825
|
+
} else {
|
|
1826
|
+
this.logger.info(` - Cleaning up orphaned directory at '${absoluteWorktreePath}' before fallback attempt`);
|
|
1827
|
+
await fs4.rm(absoluteWorktreePath, { recursive: true, force: true });
|
|
1828
|
+
}
|
|
1829
|
+
} catch {
|
|
1830
|
+
}
|
|
1831
|
+
try {
|
|
1832
|
+
await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
|
|
1833
|
+
this.logger.info(` - Created worktree for '${branchName}' (without tracking)`);
|
|
1834
|
+
if (!this.isLfsSkipEnabled()) {
|
|
1835
|
+
await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
|
|
1836
|
+
}
|
|
1837
|
+
try {
|
|
1838
|
+
await this.createWorktreeMetadata(bareGit, absoluteWorktreePath, branchName);
|
|
1839
|
+
} catch (metadataError) {
|
|
1840
|
+
this.logger.warn(` - Metadata creation failed for '${branchName}', removing worktree to prevent orphan`);
|
|
1841
|
+
try {
|
|
1842
|
+
await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
|
|
1843
|
+
} catch {
|
|
1844
|
+
}
|
|
1845
|
+
throw new Error(`Metadata creation failed for '${branchName}': ${getErrorMessage(metadataError)}`);
|
|
1846
|
+
}
|
|
1847
|
+
} catch (fallbackError) {
|
|
1848
|
+
const fallbackErrorMessage = getErrorMessage(fallbackError);
|
|
1849
|
+
if (fallbackErrorMessage.includes("already registered worktree")) {
|
|
1850
|
+
const worktrees = await this.getWorktreesFromBare(bareGit);
|
|
1851
|
+
const existingWorktree = worktrees.find((w) => path4.resolve(w.path) === absoluteWorktreePath);
|
|
1852
|
+
if (existingWorktree && !existingWorktree.isPrunable) {
|
|
1853
|
+
this.logger.info(` - Worktree for '${branchName}' was created by concurrent operation during fallback`);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
throw fallbackError;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
async removeWorktree(worktreePath) {
|
|
1862
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
1863
|
+
await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
1864
|
+
this.logger.info(` - \u2705 Safely removed stale worktree at '${worktreePath}'.`);
|
|
1865
|
+
try {
|
|
1866
|
+
await this.metadataService.deleteMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
1867
|
+
} catch (metadataError) {
|
|
1868
|
+
this.logger.warn(`Failed to delete metadata for worktree: ${metadataError}`);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
async pruneWorktrees() {
|
|
1872
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
1873
|
+
await bareGit.raw(["worktree", "prune"]);
|
|
1874
|
+
this.logger.info("Pruned worktree metadata.");
|
|
1875
|
+
}
|
|
1876
|
+
async checkWorktreeStatus(worktreePath) {
|
|
1877
|
+
return this.statusService.checkWorktreeStatus(worktreePath);
|
|
1878
|
+
}
|
|
1879
|
+
async hasUnpushedCommits(worktreePath) {
|
|
1880
|
+
const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
1881
|
+
return this.statusService.hasUnpushedCommits(worktreePath, metadata?.lastSyncCommit);
|
|
1882
|
+
}
|
|
1883
|
+
async hasUpstreamGone(worktreePath) {
|
|
1884
|
+
return this.statusService.hasUpstreamGone(worktreePath);
|
|
1885
|
+
}
|
|
1886
|
+
async hasStashedChanges(worktreePath) {
|
|
1887
|
+
return this.statusService.hasStashedChanges(worktreePath);
|
|
1888
|
+
}
|
|
1889
|
+
async getFullWorktreeStatus(worktreePath, includeDetails = false) {
|
|
1890
|
+
const metadata = await this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
1891
|
+
return this.statusService.getFullWorktreeStatus(worktreePath, includeDetails, metadata?.lastSyncCommit);
|
|
1892
|
+
}
|
|
1893
|
+
async hasModifiedSubmodules(worktreePath) {
|
|
1894
|
+
return this.statusService.hasModifiedSubmodules(worktreePath);
|
|
1895
|
+
}
|
|
1896
|
+
async hasOperationInProgress(worktreePath) {
|
|
1897
|
+
return this.statusService.hasOperationInProgress(worktreePath);
|
|
1898
|
+
}
|
|
1899
|
+
async getCurrentBranch() {
|
|
1900
|
+
const git = this.getGit();
|
|
1901
|
+
const branchSummary = await git.branch();
|
|
1902
|
+
return branchSummary.current;
|
|
1903
|
+
}
|
|
1904
|
+
async detectDefaultBranch(bareGit) {
|
|
1905
|
+
try {
|
|
1906
|
+
const headRef = await bareGit.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
1907
|
+
const branch = headRef.trim().split("/").pop();
|
|
1908
|
+
if (branch) {
|
|
1909
|
+
return branch;
|
|
1910
|
+
}
|
|
1911
|
+
} catch {
|
|
1912
|
+
try {
|
|
1913
|
+
await bareGit.raw(["remote", "set-head", "origin", "-a"]);
|
|
1914
|
+
const headRef = await bareGit.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
1915
|
+
const branch = headRef.trim().split("/").pop();
|
|
1916
|
+
if (branch) {
|
|
1917
|
+
return branch;
|
|
1918
|
+
}
|
|
1919
|
+
} catch {
|
|
1920
|
+
try {
|
|
1921
|
+
const remoteBranches = await bareGit.branch(["-r"]);
|
|
1922
|
+
const commonDefaults = GIT_CONSTANTS.COMMON_DEFAULT_BRANCHES;
|
|
1923
|
+
for (const defaultName of commonDefaults) {
|
|
1924
|
+
if (remoteBranches.all.some((branch) => branch === `origin/${defaultName}`)) {
|
|
1925
|
+
return defaultName;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
} catch {
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return GIT_CONSTANTS.DEFAULT_BRANCH;
|
|
1933
|
+
}
|
|
1934
|
+
setLfsSkipEnabled(value) {
|
|
1935
|
+
this.lfsSkipOverride = value;
|
|
1936
|
+
}
|
|
1937
|
+
isLfsSkipEnabled() {
|
|
1938
|
+
return this.config.skipLfs || this.lfsSkipOverride;
|
|
1939
|
+
}
|
|
1940
|
+
async getWorktrees() {
|
|
1941
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
1942
|
+
return this.getWorktreesFromBare(bareGit);
|
|
1943
|
+
}
|
|
1944
|
+
async isWorktreeBehind(worktreePath) {
|
|
1945
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
1946
|
+
try {
|
|
1947
|
+
const branchSummary = await worktreeGit.branch();
|
|
1948
|
+
const currentBranch = branchSummary.current;
|
|
1949
|
+
const upstreamInfo = await worktreeGit.raw(["rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`]);
|
|
1950
|
+
if (!upstreamInfo.trim()) {
|
|
1951
|
+
return false;
|
|
1952
|
+
}
|
|
1953
|
+
const behindCount = await worktreeGit.raw(["rev-list", "--count", `HEAD..${upstreamInfo.trim()}`]);
|
|
1954
|
+
return parseInt(behindCount.trim(), 10) > 0;
|
|
1955
|
+
} catch {
|
|
1956
|
+
return false;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
async updateWorktree(worktreePath) {
|
|
1960
|
+
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
1961
|
+
const branchSummary = await worktreeGit.branch();
|
|
1962
|
+
const currentBranch = branchSummary.current;
|
|
1963
|
+
await worktreeGit.merge([`origin/${currentBranch}`, "--ff-only"]);
|
|
1964
|
+
try {
|
|
1965
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
1966
|
+
await this.metadataService.updateLastSyncFromPath(
|
|
1967
|
+
this.bareRepoPath,
|
|
1968
|
+
worktreePath,
|
|
1969
|
+
currentCommit.trim(),
|
|
1970
|
+
"updated",
|
|
1971
|
+
this.defaultBranch
|
|
1972
|
+
);
|
|
1973
|
+
} catch (metadataError) {
|
|
1974
|
+
this.logger.warn(`Failed to update metadata for worktree: ${metadataError}`);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
async hasDivergedHistory(worktreePath, expectedBranch) {
|
|
1978
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
1979
|
+
const branchInfo = await worktreeGit.branch();
|
|
1980
|
+
if (branchInfo.current !== expectedBranch) {
|
|
1981
|
+
this.logger.warn(`Branch mismatch in hasDivergedHistory: expected ${expectedBranch}, got ${branchInfo.current}`);
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1984
|
+
try {
|
|
1985
|
+
await worktreeGit.raw(["merge-base", "--is-ancestor", "HEAD", `origin/${expectedBranch}`]);
|
|
1986
|
+
return false;
|
|
1987
|
+
} catch {
|
|
1988
|
+
return true;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
async canFastForward(worktreePath, branch) {
|
|
1992
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
1993
|
+
try {
|
|
1994
|
+
const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
|
|
1995
|
+
const mergeBaseSha = mergeBase.trim();
|
|
1996
|
+
const headSha = await worktreeGit.revparse(["HEAD"]);
|
|
1997
|
+
const headShaTrimmed = headSha.trim();
|
|
1998
|
+
return mergeBaseSha === headShaTrimmed;
|
|
1999
|
+
} catch {
|
|
2000
|
+
return false;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
async isLocalAheadOfRemote(worktreePath, branch) {
|
|
2004
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
2005
|
+
try {
|
|
2006
|
+
const mergeBase = await worktreeGit.raw(["merge-base", "HEAD", `origin/${branch}`]);
|
|
2007
|
+
const mergeBaseSha = mergeBase.trim();
|
|
2008
|
+
const remoteSha = await worktreeGit.revparse([`origin/${branch}`]);
|
|
2009
|
+
const remoteShaTrimmed = remoteSha.trim();
|
|
2010
|
+
return mergeBaseSha === remoteShaTrimmed;
|
|
2011
|
+
} catch {
|
|
2012
|
+
return false;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
async compareTreeContent(worktreePath, branch) {
|
|
2016
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
2017
|
+
try {
|
|
2018
|
+
const localTree = await worktreeGit.raw(["rev-parse", "HEAD^{tree}"]);
|
|
2019
|
+
const remoteTree = await worktreeGit.raw(["rev-parse", `origin/${branch}^{tree}`]);
|
|
2020
|
+
return localTree.trim() === remoteTree.trim();
|
|
2021
|
+
} catch (error) {
|
|
2022
|
+
this.logger.error(`Error comparing tree content: ${error}`);
|
|
2023
|
+
return false;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
async resetToUpstream(worktreePath, branch) {
|
|
2027
|
+
const worktreeGit = this.getCachedGit(worktreePath, this.isLfsSkipEnabled());
|
|
2028
|
+
await worktreeGit.reset(["--hard", `origin/${branch}`]);
|
|
2029
|
+
try {
|
|
2030
|
+
const currentCommit = await worktreeGit.revparse(["HEAD"]);
|
|
2031
|
+
await this.metadataService.updateLastSyncFromPath(
|
|
2032
|
+
this.bareRepoPath,
|
|
2033
|
+
worktreePath,
|
|
2034
|
+
currentCommit.trim(),
|
|
2035
|
+
"updated",
|
|
2036
|
+
this.defaultBranch
|
|
2037
|
+
);
|
|
2038
|
+
} catch (metadataError) {
|
|
2039
|
+
this.logger.warn(`Failed to update metadata after reset: ${metadataError}`);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
async getCurrentCommit(worktreePath) {
|
|
2043
|
+
const worktreeGit = this.getCachedGit(worktreePath);
|
|
2044
|
+
const commit = await worktreeGit.revparse(["HEAD"]);
|
|
2045
|
+
return commit.trim();
|
|
2046
|
+
}
|
|
2047
|
+
async getRemoteCommit(ref) {
|
|
2048
|
+
const git = this.getCachedGit(this.bareRepoPath);
|
|
2049
|
+
const commit = await git.revparse([ref]);
|
|
2050
|
+
return commit.trim();
|
|
2051
|
+
}
|
|
2052
|
+
async branchExists(branchName) {
|
|
2053
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
2054
|
+
const localBranches = await bareGit.branch();
|
|
2055
|
+
const local = localBranches.all.includes(branchName);
|
|
2056
|
+
const remoteBranches = await bareGit.branch(["-r"]);
|
|
2057
|
+
const remote = remoteBranches.all.includes(`origin/${branchName}`);
|
|
2058
|
+
return { local, remote };
|
|
2059
|
+
}
|
|
2060
|
+
async getLocalBranches() {
|
|
2061
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
2062
|
+
const branches = await bareGit.branch();
|
|
2063
|
+
return branches.all;
|
|
2064
|
+
}
|
|
2065
|
+
async createBranch(branchName, baseBranch) {
|
|
2066
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
2067
|
+
await bareGit.raw(["branch", branchName, `origin/${baseBranch}`]);
|
|
2068
|
+
this.logger.info(`Created branch '${branchName}' from '${baseBranch}'`);
|
|
2069
|
+
}
|
|
2070
|
+
async pushBranch(branchName) {
|
|
2071
|
+
const bareGit = this.getCachedGit(this.bareRepoPath);
|
|
2072
|
+
await bareGit.push(["origin", `${branchName}:${branchName}`, "-u"]);
|
|
2073
|
+
this.logger.info(`Pushed branch '${branchName}' to remote`);
|
|
2074
|
+
}
|
|
2075
|
+
async getWorktreeMetadata(worktreePath) {
|
|
2076
|
+
return this.metadataService.loadMetadataFromPath(this.bareRepoPath, worktreePath);
|
|
2077
|
+
}
|
|
2078
|
+
async getWorktreesFromBare(bareGit) {
|
|
2079
|
+
const result = await bareGit.raw(["worktree", "list", "--porcelain"]);
|
|
2080
|
+
return parseWorktreeListPorcelain(result).filter((w) => !w.detached && w.branch !== null).map((w) => ({
|
|
2081
|
+
path: w.path,
|
|
2082
|
+
branch: w.branch,
|
|
2083
|
+
isPrunable: w.prunable
|
|
2084
|
+
}));
|
|
2085
|
+
}
|
|
2086
|
+
};
|
|
2087
|
+
|
|
2088
|
+
// src/services/path-resolution.service.ts
|
|
2089
|
+
import { createHash } from "crypto";
|
|
2090
|
+
import * as fs5 from "fs";
|
|
2091
|
+
import * as path6 from "path";
|
|
2092
|
+
|
|
2093
|
+
// src/utils/path-compare.ts
|
|
2094
|
+
import * as path5 from "path";
|
|
2095
|
+
var CASE_INSENSITIVE_PLATFORMS = /* @__PURE__ */ new Set(["darwin"]);
|
|
2096
|
+
function isCaseInsensitiveFs(platform = process.platform) {
|
|
2097
|
+
return CASE_INSENSITIVE_PLATFORMS.has(platform);
|
|
2098
|
+
}
|
|
2099
|
+
function normalizePathForCompare(p, platform = process.platform) {
|
|
2100
|
+
const resolved = path5.resolve(p);
|
|
2101
|
+
return isCaseInsensitiveFs(platform) ? resolved.toLowerCase() : resolved;
|
|
2102
|
+
}
|
|
2103
|
+
function pathsEqual(a, b, platform = process.platform) {
|
|
2104
|
+
return normalizePathForCompare(a, platform) === normalizePathForCompare(b, platform);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// src/services/path-resolution.service.ts
|
|
2108
|
+
var BRANCH_STEM_MAX = 80;
|
|
2109
|
+
var BRANCH_HASH_LEN = 8;
|
|
2110
|
+
var PathResolutionService = class {
|
|
2111
|
+
sanitizeBranchName(branchName) {
|
|
2112
|
+
const stem = branchName.replace(/\//g, "-").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, BRANCH_STEM_MAX);
|
|
2113
|
+
const hash = createHash("sha256").update(branchName).digest("hex").slice(0, BRANCH_HASH_LEN);
|
|
2114
|
+
return `${stem}-${hash}`;
|
|
2115
|
+
}
|
|
2116
|
+
getBranchWorktreePath(worktreeDir, branchName) {
|
|
2117
|
+
return path6.join(worktreeDir, this.sanitizeBranchName(branchName));
|
|
2118
|
+
}
|
|
2119
|
+
resolveRealPath(inputPath) {
|
|
2120
|
+
const absolute = path6.resolve(inputPath);
|
|
2121
|
+
const missing = [];
|
|
2122
|
+
let current = absolute;
|
|
2123
|
+
while (!fs5.existsSync(current)) {
|
|
2124
|
+
const parent = path6.dirname(current);
|
|
2125
|
+
if (parent === current) {
|
|
2126
|
+
return absolute;
|
|
2127
|
+
}
|
|
2128
|
+
missing.unshift(path6.basename(current));
|
|
2129
|
+
current = parent;
|
|
2130
|
+
}
|
|
2131
|
+
try {
|
|
2132
|
+
return path6.join(fs5.realpathSync(current), ...missing);
|
|
2133
|
+
} catch {
|
|
2134
|
+
return absolute;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
isResolvedPathInsideBase(resolved, resolvedBase) {
|
|
2138
|
+
const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
2139
|
+
const a = fold(resolved);
|
|
2140
|
+
const b = fold(resolvedBase);
|
|
2141
|
+
if (a === b) return true;
|
|
2142
|
+
return a.length > b.length && a.charAt(b.length) === path6.sep && a.startsWith(b);
|
|
2143
|
+
}
|
|
2144
|
+
normalizeWorktreePath(worktreePath, worktreeBaseDir) {
|
|
2145
|
+
const resolved = this.resolveRealPath(worktreePath);
|
|
2146
|
+
const resolvedBase = this.resolveRealPath(worktreeBaseDir);
|
|
2147
|
+
if (!this.isResolvedPathInsideBase(resolved, resolvedBase)) {
|
|
2148
|
+
throw new Error(`Worktree path '${worktreePath}' is outside base directory '${worktreeBaseDir}'`);
|
|
2149
|
+
}
|
|
2150
|
+
return path6.relative(resolvedBase, resolved);
|
|
2151
|
+
}
|
|
2152
|
+
isPathInsideBaseDir(targetPath, baseDir) {
|
|
2153
|
+
const resolved = this.resolveRealPath(targetPath);
|
|
2154
|
+
const resolvedBase = this.resolveRealPath(baseDir);
|
|
2155
|
+
return this.isResolvedPathInsideBase(resolved, resolvedBase);
|
|
2156
|
+
}
|
|
2157
|
+
extractBranchFromWorktreePath(worktreePath, worktreeBaseDir) {
|
|
2158
|
+
return this.normalizeWorktreePath(worktreePath, worktreeBaseDir);
|
|
2159
|
+
}
|
|
2160
|
+
};
|
|
2161
|
+
|
|
2162
|
+
// src/services/worktree-sync.service.ts
|
|
2163
|
+
var WorktreeSyncService = class {
|
|
2164
|
+
constructor(config) {
|
|
2165
|
+
this.config = config;
|
|
2166
|
+
this.logger = config.logger ?? Logger.createDefault(void 0, config.debug);
|
|
2167
|
+
this.gitService = new GitService(config, this.logger);
|
|
2168
|
+
}
|
|
2169
|
+
gitService;
|
|
2170
|
+
logger;
|
|
2171
|
+
syncInProgress = false;
|
|
2172
|
+
pathResolution = new PathResolutionService();
|
|
2173
|
+
progressListeners = /* @__PURE__ */ new Set();
|
|
2174
|
+
async initialize() {
|
|
2175
|
+
this.emitProgress({ phase: "initialize", message: "Initializing repository" });
|
|
2176
|
+
await this.gitService.initialize();
|
|
2177
|
+
this.emitProgress({ phase: "initialize", message: "Repository initialized" });
|
|
2178
|
+
}
|
|
2179
|
+
isInitialized() {
|
|
2180
|
+
return this.gitService.isInitialized();
|
|
2181
|
+
}
|
|
2182
|
+
isSyncInProgress() {
|
|
2183
|
+
return this.syncInProgress;
|
|
2184
|
+
}
|
|
2185
|
+
getGitService() {
|
|
2186
|
+
return this.gitService;
|
|
2187
|
+
}
|
|
2188
|
+
updateLogger(logger) {
|
|
2189
|
+
this.logger = logger;
|
|
2190
|
+
this.gitService.updateLogger(logger);
|
|
2191
|
+
}
|
|
2192
|
+
onProgress(listener) {
|
|
2193
|
+
this.progressListeners.add(listener);
|
|
2194
|
+
return () => this.progressListeners.delete(listener);
|
|
2195
|
+
}
|
|
2196
|
+
emitProgress(event) {
|
|
2197
|
+
for (const listener of this.progressListeners) {
|
|
2198
|
+
try {
|
|
2199
|
+
listener(event);
|
|
2200
|
+
} catch {
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
async sync() {
|
|
2205
|
+
if (this.syncInProgress) {
|
|
2206
|
+
this.logger.warn("\u26A0\uFE0F Sync already in progress, skipping...");
|
|
2207
|
+
return { started: false, reason: "in_progress" };
|
|
2208
|
+
}
|
|
2209
|
+
this.syncInProgress = true;
|
|
2210
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
|
|
2211
|
+
const totalTimer = new Timer();
|
|
2212
|
+
const phaseTimer = new PhaseTimer();
|
|
2213
|
+
const syncContext = { lfsSkipEnabled: false };
|
|
2214
|
+
const retryOptions = this.createRetryOptions(syncContext);
|
|
2215
|
+
try {
|
|
2216
|
+
await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
|
|
2219
|
+
throw error;
|
|
2220
|
+
} finally {
|
|
2221
|
+
if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
2222
|
+
this.gitService.setLfsSkipEnabled(false);
|
|
2223
|
+
}
|
|
2224
|
+
this.syncInProgress = false;
|
|
2225
|
+
this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
|
|
2226
|
+
`);
|
|
2227
|
+
if (this.config.debug) {
|
|
2228
|
+
const totalDuration = totalTimer.stop();
|
|
2229
|
+
const phaseResults = phaseTimer.getResults();
|
|
2230
|
+
const repoName = this.config.name;
|
|
2231
|
+
this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
return { started: true };
|
|
2235
|
+
}
|
|
2236
|
+
createRetryOptions(syncContext) {
|
|
2237
|
+
return {
|
|
2238
|
+
maxAttempts: this.config.retry?.maxAttempts ?? 3,
|
|
2239
|
+
maxLfsRetries: this.config.retry?.maxLfsRetries ?? 2,
|
|
2240
|
+
initialDelayMs: this.config.retry?.initialDelayMs ?? 1e3,
|
|
2241
|
+
maxDelayMs: this.config.retry?.maxDelayMs ?? 3e4,
|
|
2242
|
+
backoffMultiplier: this.config.retry?.backoffMultiplier ?? 2,
|
|
2243
|
+
onRetry: (error, attempt, context) => {
|
|
2244
|
+
const errorMessage = getErrorMessage(error);
|
|
2245
|
+
this.logger.info(`
|
|
2246
|
+
\u26A0\uFE0F Sync attempt ${attempt} failed: ${errorMessage}`);
|
|
2247
|
+
if (context?.isLfsError && !this.config.skipLfs) {
|
|
2248
|
+
this.logger.info(`\u{1F504} LFS error detected. Will retry with LFS skipped...`);
|
|
2249
|
+
} else {
|
|
2250
|
+
this.logger.info(`\u{1F504} Retrying synchronization...
|
|
2251
|
+
`);
|
|
2252
|
+
}
|
|
2253
|
+
},
|
|
2254
|
+
lfsRetryHandler: () => {
|
|
2255
|
+
if (!this.config.skipLfs && !syncContext.lfsSkipEnabled) {
|
|
2256
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for this sync...");
|
|
2257
|
+
this.gitService.setLfsSkipEnabled(true);
|
|
2258
|
+
syncContext.lfsSkipEnabled = true;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
async runSyncAttempt(phaseTimer, syncContext) {
|
|
2264
|
+
await this.gitService.pruneWorktrees();
|
|
2265
|
+
await this.fetchLatestRemoteData(phaseTimer, syncContext);
|
|
2266
|
+
const { remoteBranches, defaultBranch } = await this.resolveSyncBranches();
|
|
2267
|
+
await fs6.mkdir(this.config.worktreeDir, { recursive: true });
|
|
2268
|
+
const worktrees = await this.gitService.getWorktrees();
|
|
2269
|
+
this.logger.info(`Found ${worktrees.length} existing Git worktrees.`);
|
|
2270
|
+
await this.cleanupOrphanedDirectories(worktrees);
|
|
2271
|
+
await this.createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer);
|
|
2272
|
+
await this.pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer);
|
|
2273
|
+
if (this.config.updateExistingWorktrees !== false) {
|
|
2274
|
+
await this.updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer);
|
|
2275
|
+
}
|
|
2276
|
+
await this.finalizeSyncAttempt(phaseTimer);
|
|
2277
|
+
}
|
|
2278
|
+
async fetchLatestRemoteData(phaseTimer, syncContext) {
|
|
2279
|
+
this.logger.info("Step 1: Fetching latest data from remote...");
|
|
2280
|
+
phaseTimer.startPhase("Phase 1: Fetch");
|
|
2281
|
+
this.emitProgress({ phase: "fetch", message: "Fetching latest data from remote" });
|
|
2282
|
+
try {
|
|
2283
|
+
await this.gitService.fetchAll();
|
|
2284
|
+
} catch (fetchError) {
|
|
2285
|
+
const errorMessage = getErrorMessage(fetchError);
|
|
2286
|
+
if (isLfsError(errorMessage) && !syncContext.lfsSkipEnabled && !this.config.skipLfs) {
|
|
2287
|
+
this.logger.info("\u26A0\uFE0F Fetch all failed due to LFS error. Attempting branch-by-branch fetch...");
|
|
2288
|
+
this.logger.info("\u26A0\uFE0F Temporarily disabling LFS downloads for branch-by-branch fetch...");
|
|
2289
|
+
this.gitService.setLfsSkipEnabled(true);
|
|
2290
|
+
syncContext.lfsSkipEnabled = true;
|
|
2291
|
+
await this.fetchBranchByBranch();
|
|
2292
|
+
} else {
|
|
2293
|
+
throw fetchError;
|
|
2294
|
+
}
|
|
2295
|
+
} finally {
|
|
2296
|
+
phaseTimer.endPhase();
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
async resolveSyncBranches() {
|
|
2300
|
+
const remoteBranches = this.config.branchMaxAge ? await this.getRemoteBranchesFilteredByActivity() : await this.getRemoteBranchesFilteredByName();
|
|
2301
|
+
const defaultBranch = this.gitService.getDefaultBranch();
|
|
2302
|
+
if (!remoteBranches.includes(defaultBranch)) {
|
|
2303
|
+
remoteBranches.push(defaultBranch);
|
|
2304
|
+
this.logger.info(`Ensuring default branch '${defaultBranch}' is retained.`);
|
|
2305
|
+
}
|
|
2306
|
+
return { remoteBranches, defaultBranch };
|
|
2307
|
+
}
|
|
2308
|
+
async getRemoteBranchesFilteredByActivity() {
|
|
2309
|
+
const branchesWithActivity = await this.gitService.getRemoteBranchesWithActivity();
|
|
2310
|
+
this.logger.info(`Found ${branchesWithActivity.length} remote branches.`);
|
|
2311
|
+
const branchNames = filterBranchesByName(
|
|
2312
|
+
branchesWithActivity.map((b) => b.branch),
|
|
2313
|
+
this.config.branchInclude,
|
|
2314
|
+
this.config.branchExclude
|
|
2315
|
+
);
|
|
2316
|
+
if (branchNames.length < branchesWithActivity.length) {
|
|
2317
|
+
this.logger.info(
|
|
2318
|
+
`After branch name filtering: ${branchNames.length} of ${branchesWithActivity.length} branches.`
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
const branchNameSet = new Set(branchNames);
|
|
2322
|
+
const filteredByName = branchesWithActivity.filter((b) => branchNameSet.has(b.branch));
|
|
2323
|
+
const filteredBranches = filterBranchesByAge(filteredByName, this.config.branchMaxAge);
|
|
2324
|
+
const remoteBranches = filteredBranches.map((b) => b.branch);
|
|
2325
|
+
this.logger.info(
|
|
2326
|
+
`After filtering by age (${formatDuration(this.config.branchMaxAge)}): ${remoteBranches.length} branches.`
|
|
2327
|
+
);
|
|
2328
|
+
if (filteredByName.length > remoteBranches.length) {
|
|
2329
|
+
const excludedCount = filteredByName.length - remoteBranches.length;
|
|
2330
|
+
this.logger.info(` - Excluded ${excludedCount} stale branches.`);
|
|
2331
|
+
}
|
|
2332
|
+
return remoteBranches;
|
|
2333
|
+
}
|
|
2334
|
+
async getRemoteBranchesFilteredByName() {
|
|
2335
|
+
const allBranches = await this.gitService.getRemoteBranches();
|
|
2336
|
+
this.logger.info(`Found ${allBranches.length} remote branches.`);
|
|
2337
|
+
const remoteBranches = filterBranchesByName(allBranches, this.config.branchInclude, this.config.branchExclude);
|
|
2338
|
+
if (remoteBranches.length < allBranches.length) {
|
|
2339
|
+
this.logger.info(`After branch name filtering: ${remoteBranches.length} of ${allBranches.length} branches.`);
|
|
2340
|
+
}
|
|
2341
|
+
return remoteBranches;
|
|
2342
|
+
}
|
|
2343
|
+
async finalizeSyncAttempt(phaseTimer) {
|
|
2344
|
+
phaseTimer.startPhase("Phase 5: Cleanup");
|
|
2345
|
+
this.emitProgress({ phase: "cleanup", message: "Pruning worktree metadata" });
|
|
2346
|
+
await this.gitService.pruneWorktrees();
|
|
2347
|
+
this.logger.info("Step 5: Pruned worktree metadata.");
|
|
2348
|
+
phaseTimer.endPhase();
|
|
2349
|
+
}
|
|
2350
|
+
async createNewWorktreesWithTiming(remoteBranches, worktrees, defaultBranch, phaseTimer) {
|
|
2351
|
+
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
2352
|
+
phaseTimer.startPhase("Phase 2: Create", maxConcurrent);
|
|
2353
|
+
this.emitProgress({ phase: "create", message: "Creating worktrees for new branches" });
|
|
2354
|
+
await this.createNewWorktrees(remoteBranches, worktrees, defaultBranch);
|
|
2355
|
+
const existingBranches = new Set(worktrees.map((w) => w.branch));
|
|
2356
|
+
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
2357
|
+
phaseTimer.setPhaseCount("Phase 2: Create", newBranches.length);
|
|
2358
|
+
phaseTimer.endPhase();
|
|
2359
|
+
}
|
|
2360
|
+
async createNewWorktrees(remoteBranches, worktrees, defaultBranch) {
|
|
2361
|
+
const existingBranches = new Set(worktrees.map((w) => w.branch));
|
|
2362
|
+
const newBranches = remoteBranches.filter((b) => !existingBranches.has(b) && b !== defaultBranch);
|
|
2363
|
+
if (newBranches.length === 0) {
|
|
2364
|
+
this.logger.info("Step 2: No new branches to create worktrees for.");
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const reservedPaths = /* @__PURE__ */ new Map();
|
|
2368
|
+
for (const w of worktrees) {
|
|
2369
|
+
reservedPaths.set(path7.resolve(w.path), w.branch);
|
|
2370
|
+
}
|
|
2371
|
+
const plan = [];
|
|
2372
|
+
for (const branchName of newBranches) {
|
|
2373
|
+
const worktreePath = this.pathResolution.getBranchWorktreePath(this.config.worktreeDir, branchName);
|
|
2374
|
+
const resolved = path7.resolve(worktreePath);
|
|
2375
|
+
const conflict = reservedPaths.get(resolved);
|
|
2376
|
+
if (conflict && conflict !== branchName) {
|
|
2377
|
+
this.logger.error(
|
|
2378
|
+
` \u274C Skipping '${branchName}': sanitized worktree path '${worktreePath}' collides with existing branch '${conflict}'.`
|
|
2379
|
+
);
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
reservedPaths.set(resolved, branchName);
|
|
2383
|
+
plan.push({ branchName, worktreePath });
|
|
2384
|
+
}
|
|
2385
|
+
this.logger.info(`Step 2: Creating ${plan.length} new worktrees...`);
|
|
2386
|
+
const maxConcurrent = this.config.parallelism?.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
|
|
2387
|
+
const limit = pLimit(maxConcurrent);
|
|
2388
|
+
const results = await Promise.allSettled(
|
|
2389
|
+
plan.map(
|
|
2390
|
+
({ branchName, worktreePath }) => limit(async () => {
|
|
2391
|
+
try {
|
|
2392
|
+
await this.gitService.addWorktree(branchName, worktreePath);
|
|
2393
|
+
this.logger.info(` \u2705 Created worktree for '${branchName}'`);
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
this.logger.error(` \u274C Failed to create worktree for '${branchName}':`, getErrorMessage(error));
|
|
2396
|
+
throw error;
|
|
2397
|
+
}
|
|
2398
|
+
})
|
|
2399
|
+
)
|
|
2400
|
+
);
|
|
2401
|
+
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
2402
|
+
this.logger.info(` Created ${successCount}/${plan.length} worktrees successfully`);
|
|
2403
|
+
}
|
|
2404
|
+
async pruneOldWorktreesWithTiming(remoteBranches, worktrees, phaseTimer) {
|
|
2405
|
+
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2406
|
+
phaseTimer.startPhase("Phase 3: Prune", maxConcurrent);
|
|
2407
|
+
this.emitProgress({ phase: "prune", message: "Pruning stale worktrees" });
|
|
2408
|
+
await this.pruneOldWorktrees(remoteBranches, worktrees);
|
|
2409
|
+
const deletedWorktrees = worktrees.filter((w) => !remoteBranches.includes(w.branch));
|
|
2410
|
+
phaseTimer.setPhaseCount("Phase 3: Prune", deletedWorktrees.length);
|
|
2411
|
+
phaseTimer.endPhase();
|
|
2412
|
+
}
|
|
2413
|
+
async pruneOldWorktrees(remoteBranches, worktrees) {
|
|
2414
|
+
const deletedWorktrees = worktrees.filter((w) => !remoteBranches.includes(w.branch));
|
|
2415
|
+
if (deletedWorktrees.length > 0) {
|
|
2416
|
+
this.logger.info(`Step 3: Checking ${deletedWorktrees.length} stale worktrees to prune...`);
|
|
2417
|
+
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2418
|
+
const limit = pLimit(maxConcurrent);
|
|
2419
|
+
const statusResults = await Promise.allSettled(
|
|
2420
|
+
deletedWorktrees.map(
|
|
2421
|
+
({ branch: branchName, path: worktreePath }) => limit(async () => {
|
|
2422
|
+
const status = await this.gitService.getFullWorktreeStatus(worktreePath, this.config.debug);
|
|
2423
|
+
return { branchName, worktreePath, status };
|
|
2424
|
+
}).catch((error) => {
|
|
2425
|
+
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { branchName });
|
|
2426
|
+
})
|
|
2427
|
+
)
|
|
2428
|
+
);
|
|
2429
|
+
const toRemove = [];
|
|
2430
|
+
const toSkip = [];
|
|
2431
|
+
for (const result of statusResults) {
|
|
2432
|
+
if (result.status === "fulfilled") {
|
|
2433
|
+
const { branchName, worktreePath, status } = result.value;
|
|
2434
|
+
if (status.canRemove) {
|
|
2435
|
+
toRemove.push({ branchName, worktreePath });
|
|
2436
|
+
} else {
|
|
2437
|
+
toSkip.push({ branchName, worktreePath, status });
|
|
2438
|
+
}
|
|
2439
|
+
} else {
|
|
2440
|
+
const branchName = result.reason?.branchName ?? "unknown";
|
|
2441
|
+
this.logger.error(` - Error checking worktree '${branchName}':`, result.reason);
|
|
2442
|
+
this.logger.warn(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to status check failure (conservative)`);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
if (toRemove.length > 0) {
|
|
2446
|
+
const removeLimit = pLimit(
|
|
2447
|
+
this.config.parallelism?.maxWorktreeRemoval ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_REMOVAL
|
|
2448
|
+
);
|
|
2449
|
+
const removeResults = await Promise.allSettled(
|
|
2450
|
+
toRemove.map(
|
|
2451
|
+
({ branchName, worktreePath }) => removeLimit(async () => {
|
|
2452
|
+
try {
|
|
2453
|
+
const recheck = await this.gitService.getFullWorktreeStatus(worktreePath, false);
|
|
2454
|
+
if (!recheck.canRemove) {
|
|
2455
|
+
this.logger.warn(
|
|
2456
|
+
` \u26A0\uFE0F Skipping removal of '${branchName}' - status changed since initial check: ${recheck.reasons.join(", ")}`
|
|
2457
|
+
);
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
await this.gitService.removeWorktree(worktreePath);
|
|
2461
|
+
this.logger.info(` \u2705 Removed worktree for '${branchName}'`);
|
|
2462
|
+
} catch (error) {
|
|
2463
|
+
this.logger.error(` \u274C Failed to remove worktree for '${branchName}':`, getErrorMessage(error));
|
|
2464
|
+
throw error;
|
|
2465
|
+
}
|
|
2466
|
+
})
|
|
2467
|
+
)
|
|
2468
|
+
);
|
|
2469
|
+
const removedCount = removeResults.filter((r) => r.status === "fulfilled").length;
|
|
2470
|
+
this.logger.info(` Removed ${removedCount}/${toRemove.length} worktrees successfully`);
|
|
2471
|
+
}
|
|
2472
|
+
if (toSkip.length > 0) {
|
|
2473
|
+
this.logger.info(` Skipped ${toSkip.length} worktree(s) with local changes or unpushed commits`);
|
|
2474
|
+
}
|
|
2475
|
+
for (const { branchName, worktreePath, status } of toSkip) {
|
|
2476
|
+
if (status.upstreamGone && status.hasUnpushedCommits) {
|
|
2477
|
+
this.logger.warn(` - \u26A0\uFE0F Cannot automatically remove '${branchName}' - upstream branch was deleted.`);
|
|
2478
|
+
this.logger.info(` Please review manually: cd ${worktreePath} && git log`);
|
|
2479
|
+
this.logger.info(
|
|
2480
|
+
` If changes were squash-merged, you can safely remove with: git worktree remove ${worktreePath}`
|
|
2481
|
+
);
|
|
2482
|
+
} else {
|
|
2483
|
+
this.logger.info(` - \u26A0\uFE0F Skipping removal of '${branchName}' due to: ${status.reasons.join(", ")}.`);
|
|
2484
|
+
}
|
|
2485
|
+
if (this.config.debug && status.details) {
|
|
2486
|
+
this.logDebugDetails(branchName, status.details);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
} else {
|
|
2490
|
+
this.logger.info("Step 3: No stale worktrees to prune.");
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
logDebugDetails(branchName, details) {
|
|
2494
|
+
this.logger.info(`
|
|
2495
|
+
\u{1F50D} Debug details for '${branchName}':`);
|
|
2496
|
+
if (details.modifiedFiles > 0 && details.modifiedFilesList) {
|
|
2497
|
+
this.logger.info(` - Modified files (${details.modifiedFiles}):`);
|
|
2498
|
+
details.modifiedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2499
|
+
}
|
|
2500
|
+
if (details.deletedFiles > 0 && details.deletedFilesList) {
|
|
2501
|
+
this.logger.info(` - Deleted files (${details.deletedFiles}):`);
|
|
2502
|
+
details.deletedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2503
|
+
}
|
|
2504
|
+
if (details.renamedFiles > 0 && details.renamedFilesList) {
|
|
2505
|
+
this.logger.info(` - Renamed files (${details.renamedFiles}):`);
|
|
2506
|
+
details.renamedFilesList.forEach((file) => this.logger.info(` \u2022 ${file.from} \u2192 ${file.to}`));
|
|
2507
|
+
}
|
|
2508
|
+
if (details.createdFiles > 0 && details.createdFilesList) {
|
|
2509
|
+
this.logger.info(` - Created files (${details.createdFiles}):`);
|
|
2510
|
+
details.createdFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2511
|
+
}
|
|
2512
|
+
if (details.conflictedFiles > 0 && details.conflictedFilesList) {
|
|
2513
|
+
this.logger.info(` - Conflicted files (${details.conflictedFiles}):`);
|
|
2514
|
+
details.conflictedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2515
|
+
}
|
|
2516
|
+
if (details.untrackedFiles > 0 && details.untrackedFilesList) {
|
|
2517
|
+
this.logger.info(` - Untracked files (not ignored) (${details.untrackedFiles}):`);
|
|
2518
|
+
details.untrackedFilesList.forEach((file) => this.logger.info(` \u2022 ${file}`));
|
|
2519
|
+
}
|
|
2520
|
+
if (details.unpushedCommitCount !== void 0 && details.unpushedCommitCount > 0) {
|
|
2521
|
+
this.logger.info(` - Unpushed commits: ${details.unpushedCommitCount}`);
|
|
2522
|
+
}
|
|
2523
|
+
if (details.stashCount !== void 0 && details.stashCount > 0) {
|
|
2524
|
+
this.logger.info(` - Stashed changes: ${details.stashCount}`);
|
|
2525
|
+
}
|
|
2526
|
+
if (details.operationType) {
|
|
2527
|
+
this.logger.info(` - Operation in progress: ${details.operationType}`);
|
|
2528
|
+
}
|
|
2529
|
+
if (details.modifiedSubmodules && details.modifiedSubmodules.length > 0) {
|
|
2530
|
+
this.logger.info(` - Modified submodules (${details.modifiedSubmodules.length}):`);
|
|
2531
|
+
details.modifiedSubmodules.forEach((submodule) => this.logger.info(` \u2022 ${submodule}`));
|
|
2532
|
+
}
|
|
2533
|
+
this.logger.info("");
|
|
2534
|
+
}
|
|
2535
|
+
async fetchBranchByBranch() {
|
|
2536
|
+
this.logger.info("Fetching branches individually to isolate LFS errors...");
|
|
2537
|
+
const remoteBranches = await this.gitService.getRemoteBranches();
|
|
2538
|
+
this.logger.info(`Found ${remoteBranches.length} remote branches to fetch.`);
|
|
2539
|
+
const fetchLimit = pLimit(
|
|
2540
|
+
this.config.parallelism?.maxBranchFetches ?? DEFAULT_CONFIG.PARALLELISM.MAX_BRANCH_FETCHES
|
|
2541
|
+
);
|
|
2542
|
+
const failedBranches = [];
|
|
2543
|
+
let successCount = 0;
|
|
2544
|
+
const results = await Promise.allSettled(
|
|
2545
|
+
remoteBranches.map(
|
|
2546
|
+
(branch) => fetchLimit(async () => {
|
|
2547
|
+
await this.gitService.fetchBranch(branch);
|
|
2548
|
+
return branch;
|
|
2549
|
+
})
|
|
2550
|
+
)
|
|
2551
|
+
);
|
|
2552
|
+
for (let i = 0; i < results.length; i++) {
|
|
2553
|
+
const result = results[i];
|
|
2554
|
+
if (result.status === "fulfilled") {
|
|
2555
|
+
successCount++;
|
|
2556
|
+
} else {
|
|
2557
|
+
const errorMessage = getErrorMessage(result.reason);
|
|
2558
|
+
this.logger.info(` \u26A0\uFE0F Failed to fetch branch '${remoteBranches[i]}': ${errorMessage}`);
|
|
2559
|
+
failedBranches.push(remoteBranches[i]);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
this.logger.info(`Branch-by-branch fetch completed: ${successCount}/${remoteBranches.length} successful`);
|
|
2563
|
+
if (failedBranches.length > 0) {
|
|
2564
|
+
this.logger.info(`\u26A0\uFE0F Failed to fetch ${failedBranches.length} branches due to errors.`);
|
|
2565
|
+
this.logger.info(` These branches will be skipped: ${failedBranches.join(", ")}`);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
async updateExistingWorktreesWithTiming(worktrees, remoteBranches, phaseTimer) {
|
|
2569
|
+
const maxConcurrent = this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
|
|
2570
|
+
phaseTimer.startPhase("Phase 4: Update", maxConcurrent);
|
|
2571
|
+
this.emitProgress({ phase: "update", message: "Updating existing worktrees" });
|
|
2572
|
+
await this.updateExistingWorktrees(worktrees, remoteBranches);
|
|
2573
|
+
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
2574
|
+
phaseTimer.setPhaseCount("Phase 4: Update", activeWorktrees.length);
|
|
2575
|
+
phaseTimer.endPhase();
|
|
2576
|
+
}
|
|
2577
|
+
async updateExistingWorktrees(worktrees, remoteBranches) {
|
|
2578
|
+
this.logger.info("Step 4: Checking for worktrees that need updates...");
|
|
2579
|
+
const divergedDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
2580
|
+
try {
|
|
2581
|
+
const diverged = await fs6.readdir(divergedDir);
|
|
2582
|
+
if (diverged.length > 0) {
|
|
2583
|
+
this.logger.info(
|
|
2584
|
+
`\u{1F4E6} Note: ${diverged.length} diverged worktree(s) in ${path7.relative(process.cwd(), divergedDir)}`
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
} catch {
|
|
2588
|
+
}
|
|
2589
|
+
const activeWorktrees = worktrees.filter((w) => remoteBranches.includes(w.branch));
|
|
2590
|
+
const maxConcurrent = this.config.parallelism?.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
|
|
2591
|
+
const limit = pLimit(maxConcurrent);
|
|
2592
|
+
const checkResults = await Promise.allSettled(
|
|
2593
|
+
activeWorktrees.map(
|
|
2594
|
+
(worktree) => limit(async () => {
|
|
2595
|
+
try {
|
|
2596
|
+
await fs6.access(worktree.path);
|
|
2597
|
+
} catch {
|
|
2598
|
+
return null;
|
|
2599
|
+
}
|
|
2600
|
+
const hasOp = await this.gitService.hasOperationInProgress(worktree.path);
|
|
2601
|
+
if (hasOp) return null;
|
|
2602
|
+
const isClean = await this.gitService.checkWorktreeStatus(worktree.path);
|
|
2603
|
+
if (!isClean) return null;
|
|
2604
|
+
const canFastForward = await this.gitService.canFastForward(worktree.path, worktree.branch);
|
|
2605
|
+
if (!canFastForward) {
|
|
2606
|
+
const isAhead = await this.gitService.isLocalAheadOfRemote(worktree.path, worktree.branch);
|
|
2607
|
+
if (isAhead) {
|
|
2608
|
+
this.logger.info(`\u23ED\uFE0F Skipping '${worktree.branch}' - has unpushed commits`);
|
|
2609
|
+
return null;
|
|
2610
|
+
}
|
|
2611
|
+
return { action: "diverged", worktree };
|
|
2612
|
+
}
|
|
2613
|
+
const isBehind = await this.gitService.isWorktreeBehind(worktree.path);
|
|
2614
|
+
return isBehind ? { action: "update", worktree } : null;
|
|
2615
|
+
})
|
|
2616
|
+
)
|
|
2617
|
+
);
|
|
2618
|
+
const worktreesToUpdate = [];
|
|
2619
|
+
const divergedWorktrees = [];
|
|
2620
|
+
for (const result of checkResults) {
|
|
2621
|
+
if (result.status === "fulfilled" && result.value) {
|
|
2622
|
+
if (result.value.action === "update") {
|
|
2623
|
+
worktreesToUpdate.push(result.value.worktree);
|
|
2624
|
+
} else {
|
|
2625
|
+
divergedWorktrees.push(result.value.worktree);
|
|
2626
|
+
}
|
|
2627
|
+
} else if (result.status === "rejected") {
|
|
2628
|
+
this.logger.error(` - Error checking worktree:`, result.reason);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
const updateLimit = pLimit(
|
|
2632
|
+
this.config.parallelism?.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES
|
|
2633
|
+
);
|
|
2634
|
+
const mutationTasks = [];
|
|
2635
|
+
for (const worktree of worktreesToUpdate) {
|
|
2636
|
+
mutationTasks.push(
|
|
2637
|
+
updateLimit(async () => {
|
|
2638
|
+
try {
|
|
2639
|
+
this.logger.info(` - Updating worktree '${worktree.branch}'...`);
|
|
2640
|
+
await this.gitService.updateWorktree(worktree.path);
|
|
2641
|
+
this.logger.info(` \u2705 Successfully updated '${worktree.branch}'.`);
|
|
2642
|
+
} catch (error) {
|
|
2643
|
+
const errorMessage = getErrorMessage(error);
|
|
2644
|
+
if (ERROR_MESSAGES.FAST_FORWARD_FAILED.some((msg) => errorMessage.includes(msg))) {
|
|
2645
|
+
this.logger.info(
|
|
2646
|
+
` \u26A0\uFE0F Branch '${worktree.branch}' cannot be fast-forwarded. Checking for divergence...`
|
|
2647
|
+
);
|
|
2648
|
+
try {
|
|
2649
|
+
await this.handleDivergedBranch(worktree);
|
|
2650
|
+
} catch (divergedError) {
|
|
2651
|
+
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, divergedError);
|
|
2652
|
+
throw divergedError;
|
|
2653
|
+
}
|
|
2654
|
+
} else {
|
|
2655
|
+
this.logger.error(` \u274C Failed to update '${worktree.branch}':`, error);
|
|
2656
|
+
throw error;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
return { type: "update", branch: worktree.branch };
|
|
2660
|
+
})
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
for (const worktree of divergedWorktrees) {
|
|
2664
|
+
mutationTasks.push(
|
|
2665
|
+
updateLimit(async () => {
|
|
2666
|
+
try {
|
|
2667
|
+
await this.handleDivergedBranch(worktree);
|
|
2668
|
+
} catch (error) {
|
|
2669
|
+
this.logger.error(` \u274C Failed to handle diverged branch '${worktree.branch}':`, error);
|
|
2670
|
+
throw error;
|
|
2671
|
+
}
|
|
2672
|
+
return { type: "diverged", branch: worktree.branch };
|
|
2673
|
+
})
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
if (mutationTasks.length > 0) {
|
|
2677
|
+
if (worktreesToUpdate.length > 0) {
|
|
2678
|
+
this.logger.info(` - Found ${worktreesToUpdate.length} worktrees behind their upstream branches.`);
|
|
2679
|
+
}
|
|
2680
|
+
if (divergedWorktrees.length > 0) {
|
|
2681
|
+
this.logger.info(` - Found ${divergedWorktrees.length} diverged worktrees to handle.`);
|
|
2682
|
+
}
|
|
2683
|
+
const mutationResults = await Promise.allSettled(mutationTasks);
|
|
2684
|
+
const successCount = mutationResults.filter((r) => r.status === "fulfilled").length;
|
|
2685
|
+
this.logger.info(` Processed ${successCount}/${mutationTasks.length} worktrees successfully`);
|
|
2686
|
+
} else {
|
|
2687
|
+
this.logger.info(" - All worktrees are up to date.");
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
async cleanupOrphanedDirectories(worktrees) {
|
|
2691
|
+
try {
|
|
2692
|
+
const worktreeRelativePaths = worktrees.map((w) => path7.relative(this.config.worktreeDir, w.path));
|
|
2693
|
+
const allDirs = await fs6.readdir(this.config.worktreeDir);
|
|
2694
|
+
const regularDirs = allDirs.filter((dir) => !dir.startsWith("."));
|
|
2695
|
+
const orphanedDirs = [];
|
|
2696
|
+
for (const dir of regularDirs) {
|
|
2697
|
+
const isPartOfWorktree = worktreeRelativePaths.some((worktreePath) => {
|
|
2698
|
+
return worktreePath === dir || worktreePath.startsWith(dir + path7.sep);
|
|
2699
|
+
});
|
|
2700
|
+
if (!isPartOfWorktree) {
|
|
2701
|
+
orphanedDirs.push(dir);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
if (orphanedDirs.length > 0) {
|
|
2705
|
+
this.logger.info(`Found ${orphanedDirs.length} orphaned directories: ${orphanedDirs.join(", ")}`);
|
|
2706
|
+
for (const dir of orphanedDirs) {
|
|
2707
|
+
const dirPath = path7.join(this.config.worktreeDir, dir);
|
|
2708
|
+
try {
|
|
2709
|
+
const stat4 = await fs6.stat(dirPath);
|
|
2710
|
+
if (stat4.isDirectory()) {
|
|
2711
|
+
await fs6.rm(dirPath, { recursive: true, force: true });
|
|
2712
|
+
this.logger.info(` - Removed orphaned directory: ${dir}`);
|
|
2713
|
+
}
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
this.logger.error(` - Failed to remove orphaned directory ${dir}:`, error);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
} catch (error) {
|
|
2720
|
+
this.logger.error("Error during orphaned directory cleanup:", error);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
async handleDivergedBranch(worktree) {
|
|
2724
|
+
this.logger.info(`\u26A0\uFE0F Branch '${worktree.branch}' has diverged from upstream. Analyzing...`);
|
|
2725
|
+
const treesIdentical = await this.gitService.compareTreeContent(worktree.path, worktree.branch);
|
|
2726
|
+
if (treesIdentical) {
|
|
2727
|
+
this.logger.info(`\u2705 Branch '${worktree.branch}' was rebased but files are identical. Resetting to upstream...`);
|
|
2728
|
+
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
2729
|
+
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
2730
|
+
} else {
|
|
2731
|
+
const hasLocalChanges = await this.hasLocalChangesSinceLastSync(worktree.path);
|
|
2732
|
+
if (!hasLocalChanges) {
|
|
2733
|
+
this.logger.info(
|
|
2734
|
+
`\u2705 Branch '${worktree.branch}' has diverged but you made no local changes. Resetting to upstream...`
|
|
2735
|
+
);
|
|
2736
|
+
await this.gitService.resetToUpstream(worktree.path, worktree.branch);
|
|
2737
|
+
this.logger.info(` Successfully updated '${worktree.branch}' to match upstream.`);
|
|
2738
|
+
} else {
|
|
2739
|
+
this.logger.info(`\u{1F512} Branch '${worktree.branch}' has diverged with local changes. Moving to diverged...`);
|
|
2740
|
+
const divergedPath = await this.divergeWorktree(worktree.path, worktree.branch);
|
|
2741
|
+
const relativePath = path7.relative(process.cwd(), divergedPath);
|
|
2742
|
+
this.logger.info(` Moved to: ${relativePath}`);
|
|
2743
|
+
this.logger.info(` Your local changes are preserved. To review:`);
|
|
2744
|
+
this.logger.info(` cd ${relativePath}`);
|
|
2745
|
+
this.logger.info(` git diff origin/${worktree.branch}`);
|
|
2746
|
+
await this.gitService.removeWorktree(worktree.path);
|
|
2747
|
+
await this.gitService.addWorktree(worktree.branch, worktree.path);
|
|
2748
|
+
this.logger.info(` Created fresh worktree from upstream at: ${worktree.path}`);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
async hasLocalChangesSinceLastSync(worktreePath) {
|
|
2753
|
+
try {
|
|
2754
|
+
const metadata = await this.gitService.getWorktreeMetadata(worktreePath);
|
|
2755
|
+
if (!metadata || !metadata.lastSyncCommit) {
|
|
2756
|
+
return true;
|
|
2757
|
+
}
|
|
2758
|
+
const currentCommit = await this.gitService.getCurrentCommit(worktreePath);
|
|
2759
|
+
return currentCommit !== metadata.lastSyncCommit;
|
|
2760
|
+
} catch {
|
|
2761
|
+
return true;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
async divergeWorktree(worktreePath, branchName) {
|
|
2765
|
+
const divergedBaseDir = path7.join(this.config.worktreeDir, GIT_CONSTANTS.DIVERGED_DIR_NAME);
|
|
2766
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
2767
|
+
const uniqueSuffix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
|
2768
|
+
const safeBranchName = this.pathResolution.sanitizeBranchName(branchName);
|
|
2769
|
+
const divergedName = `${timestamp}-${safeBranchName}-${uniqueSuffix}`;
|
|
2770
|
+
const divergedPath = path7.join(divergedBaseDir, divergedName);
|
|
2771
|
+
await fs6.mkdir(divergedBaseDir, { recursive: true });
|
|
2772
|
+
try {
|
|
2773
|
+
await fs6.rename(worktreePath, divergedPath);
|
|
2774
|
+
} catch (err) {
|
|
2775
|
+
if (err.code === ERROR_MESSAGES.EXDEV) {
|
|
2776
|
+
await fs6.cp(worktreePath, divergedPath, { recursive: true });
|
|
2777
|
+
await fs6.rm(worktreePath, { recursive: true, force: true });
|
|
2778
|
+
} else {
|
|
2779
|
+
throw err;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
const metadata = {
|
|
2783
|
+
originalBranch: branchName,
|
|
2784
|
+
divergedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2785
|
+
reason: METADATA_CONSTANTS.DIVERGED_REASON,
|
|
2786
|
+
originalPath: worktreePath,
|
|
2787
|
+
localCommit: await this.gitService.getCurrentCommit(divergedPath),
|
|
2788
|
+
remoteCommit: await this.gitService.getRemoteCommit(`origin/${branchName}`),
|
|
2789
|
+
instruction: `To preserve your changes:
|
|
2790
|
+
1. Review: git diff origin/${branchName}
|
|
2791
|
+
2. Keep changes: git push --force-with-lease origin ${branchName}
|
|
2792
|
+
3. Discard changes: rm -rf this directory
|
|
2793
|
+
|
|
2794
|
+
Original worktree location: ${worktreePath}`
|
|
2795
|
+
};
|
|
2796
|
+
await fs6.writeFile(
|
|
2797
|
+
path7.join(divergedPath, METADATA_CONSTANTS.DIVERGED_INFO_FILE),
|
|
2798
|
+
JSON.stringify(metadata, null, 2)
|
|
2799
|
+
);
|
|
2800
|
+
return divergedPath;
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
|
|
2804
|
+
// src/mcp/context.ts
|
|
2805
|
+
var AUTO_DETECT_PREFIX = "__auto_detected__:";
|
|
2806
|
+
var DISCOVERY_CACHE_TTL_MS = 5e3;
|
|
2807
|
+
var EMPTY_CAPABILITIES = {
|
|
2808
|
+
canListWorktrees: false,
|
|
2809
|
+
canGetStatus: false,
|
|
2810
|
+
canCreateWorktree: false,
|
|
2811
|
+
canRemoveWorktree: false,
|
|
2812
|
+
canUpdateWorktree: false,
|
|
2813
|
+
canSync: false,
|
|
2814
|
+
canInitialize: false
|
|
2815
|
+
};
|
|
2816
|
+
function buildUnsupportedContext(currentPath, reason) {
|
|
2817
|
+
return {
|
|
2818
|
+
isWorktree: false,
|
|
2819
|
+
kind: "unsupported",
|
|
2820
|
+
currentBranch: null,
|
|
2821
|
+
currentWorktreePath: currentPath,
|
|
2822
|
+
bareRepoPath: null,
|
|
2823
|
+
repoUrl: null,
|
|
2824
|
+
worktreeDir: null,
|
|
2825
|
+
allWorktrees: [],
|
|
2826
|
+
configLoaded: false,
|
|
2827
|
+
repoName: null,
|
|
2828
|
+
capabilities: EMPTY_CAPABILITIES,
|
|
2829
|
+
reasons: [reason]
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
function createStderrLogger(repoName) {
|
|
2833
|
+
return new Logger({
|
|
2834
|
+
repoName,
|
|
2835
|
+
outputFn: (msg) => {
|
|
2836
|
+
process.stderr.write(msg + "\n");
|
|
2837
|
+
}
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
var RepositoryContext = class {
|
|
2841
|
+
repos = /* @__PURE__ */ new Map();
|
|
2842
|
+
currentRepo = null;
|
|
2843
|
+
configPath = null;
|
|
2844
|
+
configLoader = new ConfigLoaderService();
|
|
2845
|
+
discoveryCache = /* @__PURE__ */ new Map();
|
|
2846
|
+
async loadConfig(configPath) {
|
|
2847
|
+
const absolutePath = path8.resolve(configPath);
|
|
2848
|
+
const configFile = await this.configLoader.loadConfigFile(absolutePath);
|
|
2849
|
+
for (const [name, entry] of this.repos) {
|
|
2850
|
+
if (entry.source === "config") {
|
|
2851
|
+
this.repos.delete(name);
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
this.configPath = absolutePath;
|
|
2855
|
+
const configDir = path8.dirname(absolutePath);
|
|
2856
|
+
const globalDefaults = configFile.defaults;
|
|
2857
|
+
for (const repo of configFile.repositories) {
|
|
2858
|
+
const resolved = this.configLoader.resolveRepositoryConfig(repo, globalDefaults, configDir, configFile.retry);
|
|
2859
|
+
this.repos.set(resolved.name, {
|
|
2860
|
+
name: resolved.name,
|
|
2861
|
+
config: resolved,
|
|
2862
|
+
source: "config"
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
if (this.currentRepo && !this.repos.has(this.currentRepo)) {
|
|
2866
|
+
this.currentRepo = null;
|
|
2867
|
+
}
|
|
2868
|
+
if (!this.currentRepo && configFile.repositories.length > 0) {
|
|
2869
|
+
this.currentRepo = configFile.repositories[0].name;
|
|
2870
|
+
}
|
|
2871
|
+
return configFile.repositories;
|
|
2872
|
+
}
|
|
2873
|
+
async detectFromPath(dirPath) {
|
|
2874
|
+
const absolutePath = path8.resolve(dirPath);
|
|
2875
|
+
const cached = this.discoveryCache.get(absolutePath);
|
|
2876
|
+
if (cached && await this.isCacheFresh(cached)) {
|
|
2877
|
+
return cached.result;
|
|
2878
|
+
}
|
|
2879
|
+
const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
|
|
2880
|
+
if (result.isWorktree && result.bareRepoPath && adminDir) {
|
|
2881
|
+
const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
|
|
2882
|
+
safeMtimeMs(path8.join(adminDir, "HEAD")),
|
|
2883
|
+
safeMtimeMs(path8.join(result.bareRepoPath, "worktrees"))
|
|
2884
|
+
]);
|
|
2885
|
+
this.discoveryCache.set(absolutePath, {
|
|
2886
|
+
result,
|
|
2887
|
+
cachedAt: Date.now(),
|
|
2888
|
+
worktreeAdminDir: adminDir,
|
|
2889
|
+
worktreeHeadMtimeMs,
|
|
2890
|
+
worktreesDirMtimeMs
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
return result;
|
|
2894
|
+
}
|
|
2895
|
+
invalidateDiscovered() {
|
|
2896
|
+
this.discoveryCache.clear();
|
|
2897
|
+
}
|
|
2898
|
+
/** @internal Test-only helper — registers a repo entry without going through config loading. */
|
|
2899
|
+
__registerForTest(name, entry) {
|
|
2900
|
+
this.repos.set(name, { ...entry, name });
|
|
2901
|
+
}
|
|
2902
|
+
/** @internal Test-only helper — sets the current repo pointer. */
|
|
2903
|
+
__setCurrentRepoForTest(name) {
|
|
2904
|
+
this.currentRepo = name;
|
|
2905
|
+
}
|
|
2906
|
+
/** @internal Test-only helper — returns the size of the internal repo map. */
|
|
2907
|
+
__repoCountForTest() {
|
|
2908
|
+
return this.repos.size;
|
|
2909
|
+
}
|
|
2910
|
+
/** @internal Test-only helper — returns the size of the discovery cache. */
|
|
2911
|
+
__discoveryCacheSizeForTest() {
|
|
2912
|
+
return this.discoveryCache.size;
|
|
2913
|
+
}
|
|
2914
|
+
bootstrapCurrentRepo(candidate) {
|
|
2915
|
+
if (this.currentRepo !== null) return;
|
|
2916
|
+
if (!this.repos.has(candidate)) return;
|
|
2917
|
+
if (this.repos.size !== 1) return;
|
|
2918
|
+
this.currentRepo = candidate;
|
|
2919
|
+
}
|
|
2920
|
+
async isCacheFresh(cached) {
|
|
2921
|
+
if (Date.now() - cached.cachedAt >= DISCOVERY_CACHE_TTL_MS) return false;
|
|
2922
|
+
if (!cached.worktreeAdminDir || !cached.result.bareRepoPath) return true;
|
|
2923
|
+
const [currentHeadMtime, currentWorktreesDirMtime] = await Promise.all([
|
|
2924
|
+
safeMtimeMs(path8.join(cached.worktreeAdminDir, "HEAD")),
|
|
2925
|
+
safeMtimeMs(path8.join(cached.result.bareRepoPath, "worktrees"))
|
|
2926
|
+
]);
|
|
2927
|
+
return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
|
|
2928
|
+
}
|
|
2929
|
+
async detectFromPathUncached(absolutePath) {
|
|
2930
|
+
const reasons = [];
|
|
2931
|
+
const located = await findWorktreeRoot(absolutePath);
|
|
2932
|
+
const worktreeRoot = located?.worktreeRoot ?? absolutePath;
|
|
2933
|
+
const unsupported = (reason, isWorktree = false) => {
|
|
2934
|
+
reasons.push(reason);
|
|
2935
|
+
return {
|
|
2936
|
+
result: {
|
|
2937
|
+
isWorktree,
|
|
2938
|
+
kind: "unsupported",
|
|
2939
|
+
currentBranch: null,
|
|
2940
|
+
currentWorktreePath: worktreeRoot,
|
|
2941
|
+
bareRepoPath: null,
|
|
2942
|
+
repoUrl: null,
|
|
2943
|
+
worktreeDir: null,
|
|
2944
|
+
allWorktrees: [],
|
|
2945
|
+
configLoaded: this.configPath !== null,
|
|
2946
|
+
repoName: null,
|
|
2947
|
+
capabilities: EMPTY_CAPABILITIES,
|
|
2948
|
+
reasons
|
|
2949
|
+
},
|
|
2950
|
+
adminDir: null
|
|
2951
|
+
};
|
|
2952
|
+
};
|
|
2953
|
+
if (!located) {
|
|
2954
|
+
return unsupported("No .git file found in path or any parent directory");
|
|
2955
|
+
}
|
|
2956
|
+
if (located.kind === "regular-git-dir") {
|
|
2957
|
+
return unsupported("Directory has .git folder (regular repo, not a sync-worktrees worktree)");
|
|
2958
|
+
}
|
|
2959
|
+
const gitFileContent = located.gitFileContent;
|
|
2960
|
+
const gitdirMatch = gitFileContent.match(/^gitdir:\s*(.+)$/m);
|
|
2961
|
+
if (!gitdirMatch) {
|
|
2962
|
+
return unsupported("Invalid .git file format (missing gitdir line)");
|
|
2963
|
+
}
|
|
2964
|
+
const gitdir = gitdirMatch[1].trim();
|
|
2965
|
+
const resolvedGitdir = path8.isAbsolute(gitdir) ? gitdir : path8.resolve(worktreeRoot, gitdir);
|
|
2966
|
+
const worktreesMatch = resolvedGitdir.match(/^(.+?)[/\\]worktrees[/\\][^/\\]+$/);
|
|
2967
|
+
if (!worktreesMatch) {
|
|
2968
|
+
return unsupported("gitdir does not follow worktree structure (missing /worktrees/<name>)");
|
|
2969
|
+
}
|
|
2970
|
+
const bareRepoPath = path8.resolve(worktreesMatch[1]);
|
|
2971
|
+
const adminDir = path8.resolve(resolvedGitdir);
|
|
2972
|
+
let repoUrl = null;
|
|
2973
|
+
let worktrees = [];
|
|
2974
|
+
let currentBranch = null;
|
|
2975
|
+
try {
|
|
2976
|
+
const bareGit = simpleGit4(bareRepoPath);
|
|
2977
|
+
try {
|
|
2978
|
+
const remoteResult = await bareGit.remote(["get-url", "origin"]);
|
|
2979
|
+
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
2980
|
+
repoUrl = urlStr || null;
|
|
2981
|
+
} catch {
|
|
2982
|
+
reasons.push("Could not read remote origin URL");
|
|
2983
|
+
}
|
|
2984
|
+
const listOutput = await bareGit.raw(["worktree", "list", "--porcelain"]);
|
|
2985
|
+
worktrees = parseWorktreeList(listOutput, worktreeRoot);
|
|
2986
|
+
const current = worktrees.find((w) => w.isCurrent);
|
|
2987
|
+
if (current) {
|
|
2988
|
+
currentBranch = current.branch;
|
|
2989
|
+
}
|
|
2990
|
+
} catch (err) {
|
|
2991
|
+
reasons.push(`Failed to read bare repo at ${bareRepoPath}: ${err.message}`);
|
|
2992
|
+
return {
|
|
2993
|
+
result: {
|
|
2994
|
+
isWorktree: true,
|
|
2995
|
+
kind: "unsupported",
|
|
2996
|
+
currentBranch: null,
|
|
2997
|
+
currentWorktreePath: worktreeRoot,
|
|
2998
|
+
bareRepoPath,
|
|
2999
|
+
repoUrl: null,
|
|
3000
|
+
worktreeDir: null,
|
|
3001
|
+
allWorktrees: [],
|
|
3002
|
+
configLoaded: this.configPath !== null,
|
|
3003
|
+
repoName: null,
|
|
3004
|
+
capabilities: EMPTY_CAPABILITIES,
|
|
3005
|
+
reasons
|
|
3006
|
+
},
|
|
3007
|
+
adminDir
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
const worktreeDir = path8.dirname(worktreeRoot);
|
|
3011
|
+
const capabilities = {
|
|
3012
|
+
canListWorktrees: true,
|
|
3013
|
+
canGetStatus: true,
|
|
3014
|
+
canCreateWorktree: repoUrl !== null,
|
|
3015
|
+
canRemoveWorktree: true,
|
|
3016
|
+
canUpdateWorktree: true,
|
|
3017
|
+
canSync: false,
|
|
3018
|
+
canInitialize: false
|
|
3019
|
+
};
|
|
3020
|
+
if (!repoUrl) {
|
|
3021
|
+
reasons.push("create_worktree unavailable: no remote origin URL detected");
|
|
3022
|
+
}
|
|
3023
|
+
const foldPath = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
3024
|
+
const foldedBare = foldPath(bareRepoPath);
|
|
3025
|
+
let matchedConfig = null;
|
|
3026
|
+
for (const entry of this.repos.values()) {
|
|
3027
|
+
if (entry.source === "config") {
|
|
3028
|
+
const entryBare = entry.config.bareRepoDir ? path8.resolve(entry.config.bareRepoDir) : null;
|
|
3029
|
+
if (entryBare && foldPath(entryBare) === foldedBare) {
|
|
3030
|
+
matchedConfig = entry;
|
|
3031
|
+
break;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
let repoName = null;
|
|
3036
|
+
let kind = "unmanaged";
|
|
3037
|
+
if (matchedConfig) {
|
|
3038
|
+
repoName = matchedConfig.name;
|
|
3039
|
+
kind = "managed";
|
|
3040
|
+
capabilities.canSync = true;
|
|
3041
|
+
capabilities.canInitialize = true;
|
|
3042
|
+
} else if (repoUrl) {
|
|
3043
|
+
const syntheticConfig = {
|
|
3044
|
+
repoUrl,
|
|
3045
|
+
worktreeDir,
|
|
3046
|
+
bareRepoDir: bareRepoPath,
|
|
3047
|
+
cronSchedule: DEFAULT_CONFIG.CRON_SCHEDULE,
|
|
3048
|
+
runOnce: true
|
|
3049
|
+
};
|
|
3050
|
+
const detectedKey = `${AUTO_DETECT_PREFIX}${path8.basename(bareRepoPath)}@${bareRepoPath}`;
|
|
3051
|
+
if (!this.repos.has(detectedKey)) {
|
|
3052
|
+
this.repos.set(detectedKey, {
|
|
3053
|
+
name: detectedKey,
|
|
3054
|
+
config: syntheticConfig,
|
|
3055
|
+
source: "detected"
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
repoName = detectedKey;
|
|
3059
|
+
reasons.push("sync/initialize unavailable: no config file loaded (running in auto-detect mode)");
|
|
3060
|
+
} else {
|
|
3061
|
+
reasons.push("sync/initialize unavailable: no config file and no remote URL");
|
|
3062
|
+
}
|
|
3063
|
+
if (repoName) {
|
|
3064
|
+
this.bootstrapCurrentRepo(repoName);
|
|
3065
|
+
}
|
|
3066
|
+
const discovered = {
|
|
3067
|
+
isWorktree: true,
|
|
3068
|
+
kind,
|
|
3069
|
+
currentBranch,
|
|
3070
|
+
currentWorktreePath: worktreeRoot,
|
|
3071
|
+
bareRepoPath,
|
|
3072
|
+
repoUrl,
|
|
3073
|
+
worktreeDir,
|
|
3074
|
+
allWorktrees: worktrees,
|
|
3075
|
+
configLoaded: this.configPath !== null,
|
|
3076
|
+
repoName,
|
|
3077
|
+
capabilities,
|
|
3078
|
+
reasons
|
|
3079
|
+
};
|
|
3080
|
+
if (repoName) {
|
|
3081
|
+
const entry = this.repos.get(repoName);
|
|
3082
|
+
if (entry) {
|
|
3083
|
+
entry.discovered = discovered;
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
return { result: discovered, adminDir };
|
|
3087
|
+
}
|
|
3088
|
+
async getService(repoName) {
|
|
3089
|
+
const name = repoName ?? this.currentRepo;
|
|
3090
|
+
if (!name) {
|
|
3091
|
+
throw new Error("No repository specified and no current repository set");
|
|
3092
|
+
}
|
|
3093
|
+
const entry = this.repos.get(name);
|
|
3094
|
+
if (!entry) {
|
|
3095
|
+
throw new Error(`Repository '${name}' not found. Load a config or run detect_context first.`);
|
|
3096
|
+
}
|
|
3097
|
+
if (!entry.service) {
|
|
3098
|
+
const logger = createStderrLogger(entry.name);
|
|
3099
|
+
entry.service = new WorktreeSyncService({
|
|
3100
|
+
...entry.config,
|
|
3101
|
+
logger
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
return entry.service;
|
|
3105
|
+
}
|
|
3106
|
+
getEntry(repoName) {
|
|
3107
|
+
const name = repoName ?? this.currentRepo;
|
|
3108
|
+
if (!name) return null;
|
|
3109
|
+
return this.repos.get(name) ?? null;
|
|
3110
|
+
}
|
|
3111
|
+
getDiscoveredContext(repoName) {
|
|
3112
|
+
const entry = this.getEntry(repoName);
|
|
3113
|
+
return entry?.discovered ?? null;
|
|
3114
|
+
}
|
|
3115
|
+
getCurrentRepo() {
|
|
3116
|
+
return this.currentRepo;
|
|
3117
|
+
}
|
|
3118
|
+
setCurrentRepo(repoName) {
|
|
3119
|
+
if (!this.repos.has(repoName)) {
|
|
3120
|
+
throw new Error(`Repository '${repoName}' not found`);
|
|
3121
|
+
}
|
|
3122
|
+
this.currentRepo = repoName;
|
|
3123
|
+
}
|
|
3124
|
+
getRepositoryList() {
|
|
3125
|
+
return Array.from(this.repos.values()).map((e) => ({
|
|
3126
|
+
name: e.name,
|
|
3127
|
+
repoUrl: e.config.repoUrl,
|
|
3128
|
+
worktreeDir: e.config.worktreeDir,
|
|
3129
|
+
source: e.source
|
|
3130
|
+
}));
|
|
3131
|
+
}
|
|
3132
|
+
getConfigPath() {
|
|
3133
|
+
return this.configPath;
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
function parseWorktreeList(output, currentPath) {
|
|
3137
|
+
const resolvedCurrent = path8.resolve(currentPath);
|
|
3138
|
+
const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
3139
|
+
const foldedCurrent = fold(resolvedCurrent);
|
|
3140
|
+
const results = [];
|
|
3141
|
+
for (const wt of parseWorktreeListPorcelain(output)) {
|
|
3142
|
+
const resolved = path8.resolve(wt.path);
|
|
3143
|
+
const branch = wt.branch ?? (wt.detached ? `(detached ${(wt.head ?? "").slice(0, 7)})` : null);
|
|
3144
|
+
if (!branch) continue;
|
|
3145
|
+
results.push({
|
|
3146
|
+
path: resolved,
|
|
3147
|
+
branch,
|
|
3148
|
+
isCurrent: fold(resolved) === foldedCurrent
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
return results;
|
|
3152
|
+
}
|
|
3153
|
+
async function safeMtimeMs(filePath) {
|
|
3154
|
+
try {
|
|
3155
|
+
const stat4 = await fs7.stat(filePath);
|
|
3156
|
+
return stat4.mtimeMs;
|
|
3157
|
+
} catch {
|
|
3158
|
+
return null;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
async function findWorktreeRoot(startPath) {
|
|
3162
|
+
let current = path8.resolve(startPath);
|
|
3163
|
+
const root = path8.parse(current).root;
|
|
3164
|
+
while (true) {
|
|
3165
|
+
const gitPath = path8.join(current, ".git");
|
|
3166
|
+
try {
|
|
3167
|
+
const content = await fs7.readFile(gitPath, "utf-8");
|
|
3168
|
+
return { kind: "worktree-file", worktreeRoot: current, gitFileContent: content };
|
|
3169
|
+
} catch (err) {
|
|
3170
|
+
const code = err.code;
|
|
3171
|
+
if (code === "EISDIR") {
|
|
3172
|
+
return { kind: "regular-git-dir", worktreeRoot: current };
|
|
3173
|
+
}
|
|
3174
|
+
if (code !== "ENOENT") {
|
|
3175
|
+
return null;
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
if (current === root) return null;
|
|
3179
|
+
const parent = path8.dirname(current);
|
|
3180
|
+
if (parent === current) return null;
|
|
3181
|
+
current = parent;
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
// src/mcp/server.ts
|
|
3186
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3187
|
+
import { z } from "zod";
|
|
3188
|
+
|
|
3189
|
+
// src/mcp/handlers.ts
|
|
3190
|
+
import * as path9 from "path";
|
|
3191
|
+
import pLimit2 from "p-limit";
|
|
3192
|
+
import simpleGit5 from "simple-git";
|
|
3193
|
+
|
|
3194
|
+
// src/utils/git-validation.ts
|
|
3195
|
+
function isValidGitBranchName(name) {
|
|
3196
|
+
if (!name.trim()) {
|
|
3197
|
+
return { valid: false, error: "Branch name cannot be empty" };
|
|
3198
|
+
}
|
|
3199
|
+
if (name === "@") {
|
|
3200
|
+
return { valid: false, error: "Branch name cannot be '@'" };
|
|
3201
|
+
}
|
|
3202
|
+
if (name.startsWith("-")) {
|
|
3203
|
+
return { valid: false, error: "Branch name cannot start with '-'" };
|
|
3204
|
+
}
|
|
3205
|
+
if (name.startsWith("/") || name.endsWith("/")) {
|
|
3206
|
+
return { valid: false, error: "Branch name cannot start or end with '/'" };
|
|
3207
|
+
}
|
|
3208
|
+
if (name.endsWith(".lock")) {
|
|
3209
|
+
return { valid: false, error: "Branch name cannot end with '.lock'" };
|
|
3210
|
+
}
|
|
3211
|
+
if (name.includes("..")) {
|
|
3212
|
+
return { valid: false, error: "Branch name cannot contain '..'" };
|
|
3213
|
+
}
|
|
3214
|
+
if (name.includes("@{")) {
|
|
3215
|
+
return { valid: false, error: "Branch name cannot contain '@{'" };
|
|
3216
|
+
}
|
|
3217
|
+
if (name.includes("/.") || name.includes("./")) {
|
|
3218
|
+
return { valid: false, error: "Branch name cannot contain '/.' or './'" };
|
|
3219
|
+
}
|
|
3220
|
+
if (name.startsWith(".") || name.endsWith(".")) {
|
|
3221
|
+
return { valid: false, error: "Branch name cannot start or end with '.'" };
|
|
3222
|
+
}
|
|
3223
|
+
if (name.includes("//")) {
|
|
3224
|
+
return { valid: false, error: "Branch name cannot contain consecutive slashes" };
|
|
3225
|
+
}
|
|
3226
|
+
for (const component of name.split("/")) {
|
|
3227
|
+
if (component === "") {
|
|
3228
|
+
return { valid: false, error: "Branch name cannot contain empty path components" };
|
|
3229
|
+
}
|
|
3230
|
+
if (component.startsWith(".") || component.endsWith(".")) {
|
|
3231
|
+
return { valid: false, error: "Branch name path components cannot start or end with '.'" };
|
|
3232
|
+
}
|
|
3233
|
+
if (component.endsWith(".lock")) {
|
|
3234
|
+
return { valid: false, error: "Branch name path components cannot end with '.lock'" };
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
if (/[\x00-\x20\x7f~^:?*[\\]/.test(name)) {
|
|
3238
|
+
return { valid: false, error: "Branch name contains invalid characters" };
|
|
3239
|
+
}
|
|
3240
|
+
return { valid: true };
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
// src/mcp/utils.ts
|
|
3244
|
+
function formatToolResponse(data) {
|
|
3245
|
+
return {
|
|
3246
|
+
content: [
|
|
3247
|
+
{
|
|
3248
|
+
type: "text",
|
|
3249
|
+
text: JSON.stringify(data, null, 2)
|
|
3250
|
+
}
|
|
3251
|
+
]
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
function formatErrorResponse(error) {
|
|
3255
|
+
let code = "UNKNOWN_ERROR";
|
|
3256
|
+
let message = String(error);
|
|
3257
|
+
if (error instanceof SyncWorktreesError) {
|
|
3258
|
+
code = error.code;
|
|
3259
|
+
message = error.message;
|
|
3260
|
+
} else if (error instanceof Error) {
|
|
3261
|
+
code = "INTERNAL_ERROR";
|
|
3262
|
+
message = error.message;
|
|
3263
|
+
}
|
|
3264
|
+
const body = {
|
|
3265
|
+
error: true,
|
|
3266
|
+
code,
|
|
3267
|
+
message
|
|
3268
|
+
};
|
|
3269
|
+
if (process.env.DEBUG && error instanceof Error && error.stack) {
|
|
3270
|
+
body.stack = error.stack;
|
|
3271
|
+
}
|
|
3272
|
+
return {
|
|
3273
|
+
content: [
|
|
3274
|
+
{
|
|
3275
|
+
type: "text",
|
|
3276
|
+
text: JSON.stringify(body, null, 2)
|
|
3277
|
+
}
|
|
3278
|
+
],
|
|
3279
|
+
isError: true
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
var CapabilityUnavailableError = class extends SyncWorktreesError {
|
|
3283
|
+
constructor(capability, reasons) {
|
|
3284
|
+
super(`Capability '${capability}' unavailable: ${reasons.join(", ")}`, "CAPABILITY_UNAVAILABLE");
|
|
3285
|
+
}
|
|
3286
|
+
};
|
|
3287
|
+
var SyncInProgressError = class extends SyncWorktreesError {
|
|
3288
|
+
constructor(repoName) {
|
|
3289
|
+
super(`Sync already in progress for '${repoName}'`, "SYNC_IN_PROGRESS");
|
|
3290
|
+
}
|
|
3291
|
+
};
|
|
3292
|
+
function wrapHandler(fn) {
|
|
3293
|
+
return async (params, extra) => {
|
|
3294
|
+
try {
|
|
3295
|
+
return await fn(params, extra);
|
|
3296
|
+
} catch (error) {
|
|
3297
|
+
return formatErrorResponse(error);
|
|
3298
|
+
}
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
// src/mcp/handlers.ts
|
|
3303
|
+
var pathResolution = new PathResolutionService();
|
|
3304
|
+
function ensureCapability(discovered, key, toolName) {
|
|
3305
|
+
if (!discovered) return;
|
|
3306
|
+
if (!discovered.capabilities[key]) {
|
|
3307
|
+
throw new CapabilityUnavailableError(toolName, discovered.reasons);
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
async function ensureNotSyncing(ctx, repoName) {
|
|
3311
|
+
const entry = ctx.getEntry(repoName);
|
|
3312
|
+
if (!entry?.service) return;
|
|
3313
|
+
if (entry.service.isSyncInProgress()) {
|
|
3314
|
+
throw new SyncInProgressError(entry.name);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
async function getReadyService(ctx, repoName, options = {}) {
|
|
3318
|
+
const discovered = ctx.getDiscoveredContext(repoName);
|
|
3319
|
+
if (options.capability && options.toolName) {
|
|
3320
|
+
ensureCapability(discovered, options.capability, options.toolName);
|
|
3321
|
+
}
|
|
3322
|
+
if (options.ensureNotSyncing) {
|
|
3323
|
+
await ensureNotSyncing(ctx, repoName);
|
|
3324
|
+
}
|
|
3325
|
+
const service = await ctx.getService(repoName);
|
|
3326
|
+
if (options.ensureInitialized && !service.isInitialized()) {
|
|
3327
|
+
await service.initialize();
|
|
3328
|
+
}
|
|
3329
|
+
return {
|
|
3330
|
+
discovered,
|
|
3331
|
+
service,
|
|
3332
|
+
git: service.getGitService()
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
async function ensureRepoWorktreePath(ctx, params, git) {
|
|
3336
|
+
await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
|
|
3337
|
+
return path9.resolve(params.path);
|
|
3338
|
+
}
|
|
3339
|
+
async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
|
|
3340
|
+
const discovered = ctx.getDiscoveredContext(repoName);
|
|
3341
|
+
if (discovered?.allWorktrees.length) {
|
|
3342
|
+
const match = discovered.allWorktrees.some((w) => pathsEqual(w.path, targetPath));
|
|
3343
|
+
if (match) return;
|
|
3344
|
+
}
|
|
3345
|
+
try {
|
|
3346
|
+
const worktrees = await git.getWorktrees();
|
|
3347
|
+
if (worktrees.some((w) => pathsEqual(w.path, targetPath))) return;
|
|
3348
|
+
} catch {
|
|
3349
|
+
}
|
|
3350
|
+
throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
|
|
3351
|
+
}
|
|
3352
|
+
function deriveLabel(status, isCurrent) {
|
|
3353
|
+
if (isCurrent) return "current";
|
|
3354
|
+
if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
|
|
3355
|
+
if (status.upstreamGone) return "stale";
|
|
3356
|
+
return "clean";
|
|
3357
|
+
}
|
|
3358
|
+
async function getDivergence(worktreePath) {
|
|
3359
|
+
try {
|
|
3360
|
+
const git = simpleGit5(worktreePath);
|
|
3361
|
+
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
3362
|
+
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
3363
|
+
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|
|
3364
|
+
} catch {
|
|
3365
|
+
return null;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
async function handleDetectContext(ctx, params, _extra) {
|
|
3369
|
+
const target = params.path ?? process.cwd();
|
|
3370
|
+
const discovered = await ctx.detectFromPath(target);
|
|
3371
|
+
return formatToolResponse(discovered);
|
|
3372
|
+
}
|
|
3373
|
+
async function handleListWorktrees(ctx, params, _extra) {
|
|
3374
|
+
const { discovered, git } = await getReadyService(ctx, params.repoName, {
|
|
3375
|
+
capability: "canListWorktrees",
|
|
3376
|
+
toolName: "list_worktrees"
|
|
3377
|
+
});
|
|
3378
|
+
let worktrees;
|
|
3379
|
+
try {
|
|
3380
|
+
worktrees = await git.getWorktrees();
|
|
3381
|
+
} catch {
|
|
3382
|
+
if (discovered) {
|
|
3383
|
+
worktrees = discovered.allWorktrees.map((w) => ({ path: w.path, branch: w.branch }));
|
|
3384
|
+
} else {
|
|
3385
|
+
throw new Error("Cannot list worktrees - service not initialized and no detected context");
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
const currentPath = discovered?.currentWorktreePath ?? null;
|
|
3389
|
+
const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
3390
|
+
const results = await Promise.all(
|
|
3391
|
+
worktrees.map(
|
|
3392
|
+
(wt) => limit(async () => {
|
|
3393
|
+
const resolvedPath = path9.resolve(wt.path);
|
|
3394
|
+
const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
|
|
3395
|
+
const [status, divergence, metadata] = await Promise.all([
|
|
3396
|
+
git.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
3397
|
+
getDivergence(wt.path),
|
|
3398
|
+
git.getWorktreeMetadata(wt.path).catch(() => null)
|
|
3399
|
+
]);
|
|
3400
|
+
return {
|
|
3401
|
+
path: resolvedPath,
|
|
3402
|
+
branch: wt.branch,
|
|
3403
|
+
isCurrent,
|
|
3404
|
+
label: status ? deriveLabel(status, isCurrent) : "unknown",
|
|
3405
|
+
status,
|
|
3406
|
+
divergence,
|
|
3407
|
+
safeToRemove: status ? status.canRemove && !status.upstreamGone : false,
|
|
3408
|
+
lastSyncAt: metadata?.lastSyncDate ?? null
|
|
3409
|
+
};
|
|
3410
|
+
})
|
|
3411
|
+
)
|
|
3412
|
+
);
|
|
3413
|
+
return formatToolResponse({ worktrees: results });
|
|
3414
|
+
}
|
|
3415
|
+
async function handleGetWorktreeStatus(ctx, params, _extra) {
|
|
3416
|
+
const { git } = await getReadyService(ctx, params.repoName, {
|
|
3417
|
+
capability: "canGetStatus",
|
|
3418
|
+
toolName: "get_worktree_status"
|
|
3419
|
+
});
|
|
3420
|
+
const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
3421
|
+
const [status, divergence] = await Promise.all([
|
|
3422
|
+
git.getFullWorktreeStatus(params.path, params.includeDetails ?? false),
|
|
3423
|
+
getDivergence(params.path)
|
|
3424
|
+
]);
|
|
3425
|
+
return formatToolResponse({
|
|
3426
|
+
path: resolvedPath,
|
|
3427
|
+
...status,
|
|
3428
|
+
divergence
|
|
3429
|
+
});
|
|
3430
|
+
}
|
|
3431
|
+
async function handleCreateWorktree(ctx, params, _extra) {
|
|
3432
|
+
const { branchName, baseBranch, push } = params;
|
|
3433
|
+
const validation = isValidGitBranchName(branchName);
|
|
3434
|
+
if (!validation.valid) {
|
|
3435
|
+
throw new Error(`Invalid branch name '${branchName}': ${validation.error}`);
|
|
3436
|
+
}
|
|
3437
|
+
const { service, git } = await getReadyService(ctx, params.repoName, {
|
|
3438
|
+
capability: "canCreateWorktree",
|
|
3439
|
+
toolName: "create_worktree",
|
|
3440
|
+
ensureInitialized: true,
|
|
3441
|
+
ensureNotSyncing: true
|
|
3442
|
+
});
|
|
3443
|
+
const existence = await git.branchExists(branchName);
|
|
3444
|
+
let created = false;
|
|
3445
|
+
let pushed = false;
|
|
3446
|
+
if (!existence.local && !existence.remote) {
|
|
3447
|
+
if (!baseBranch) {
|
|
3448
|
+
throw new Error(`Branch '${branchName}' does not exist. Provide 'baseBranch' to create it.`);
|
|
3449
|
+
}
|
|
3450
|
+
await git.createBranch(branchName, baseBranch);
|
|
3451
|
+
created = true;
|
|
3452
|
+
}
|
|
3453
|
+
const worktreeDir = service.config.worktreeDir;
|
|
3454
|
+
const worktreePath = pathResolution.getBranchWorktreePath(worktreeDir, branchName);
|
|
3455
|
+
const existing = await git.getWorktrees();
|
|
3456
|
+
const collision = existing.find((w) => pathsEqual(w.path, worktreePath) && w.branch !== branchName);
|
|
3457
|
+
if (collision) {
|
|
3458
|
+
throw new Error(
|
|
3459
|
+
`Sanitized worktree path '${worktreePath}' collides with existing branch '${collision.branch}'. Rename or remove the conflicting branch first.`
|
|
3460
|
+
);
|
|
3461
|
+
}
|
|
3462
|
+
await git.addWorktree(branchName, worktreePath);
|
|
3463
|
+
ctx.invalidateDiscovered();
|
|
3464
|
+
if (created && push) {
|
|
3465
|
+
await git.pushBranch(branchName);
|
|
3466
|
+
pushed = true;
|
|
3467
|
+
}
|
|
3468
|
+
return formatToolResponse({
|
|
3469
|
+
success: true,
|
|
3470
|
+
branchName,
|
|
3471
|
+
worktreePath: path9.resolve(worktreePath),
|
|
3472
|
+
created,
|
|
3473
|
+
pushed
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
async function handleRemoveWorktree(ctx, params, _extra) {
|
|
3477
|
+
const { git } = await getReadyService(ctx, params.repoName, {
|
|
3478
|
+
capability: "canRemoveWorktree",
|
|
3479
|
+
toolName: "remove_worktree",
|
|
3480
|
+
ensureInitialized: true,
|
|
3481
|
+
ensureNotSyncing: true
|
|
3482
|
+
});
|
|
3483
|
+
const removedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
3484
|
+
if (!params.force) {
|
|
3485
|
+
const status = await git.getFullWorktreeStatus(params.path, false);
|
|
3486
|
+
if (!status.canRemove) {
|
|
3487
|
+
throw new Error(`Cannot remove worktree: ${status.reasons.join(", ")}. Use force=true to override.`);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
await git.removeWorktree(params.path);
|
|
3491
|
+
ctx.invalidateDiscovered();
|
|
3492
|
+
return formatToolResponse({
|
|
3493
|
+
success: true,
|
|
3494
|
+
removedPath
|
|
3495
|
+
});
|
|
3496
|
+
}
|
|
3497
|
+
async function handleSync(ctx, params, extra) {
|
|
3498
|
+
const { service } = await getReadyService(ctx, params.repoName, {
|
|
3499
|
+
capability: "canSync",
|
|
3500
|
+
toolName: "sync",
|
|
3501
|
+
ensureInitialized: true
|
|
3502
|
+
});
|
|
3503
|
+
const dispose = attachProgressReporter(service, extra);
|
|
3504
|
+
try {
|
|
3505
|
+
const start = Date.now();
|
|
3506
|
+
const result = await service.sync();
|
|
3507
|
+
if (!result.started) {
|
|
3508
|
+
throw new SyncInProgressError(ctx.getEntry(params.repoName)?.name ?? params.repoName ?? "unknown");
|
|
3509
|
+
}
|
|
3510
|
+
const duration = Date.now() - start;
|
|
3511
|
+
ctx.invalidateDiscovered();
|
|
3512
|
+
return formatToolResponse({ success: true, duration });
|
|
3513
|
+
} finally {
|
|
3514
|
+
dispose();
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
async function handleUpdateWorktree(ctx, params, _extra) {
|
|
3518
|
+
const { git } = await getReadyService(ctx, params.repoName, {
|
|
3519
|
+
capability: "canUpdateWorktree",
|
|
3520
|
+
toolName: "update_worktree",
|
|
3521
|
+
ensureInitialized: true,
|
|
3522
|
+
ensureNotSyncing: true
|
|
3523
|
+
});
|
|
3524
|
+
const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
|
|
3525
|
+
await git.updateWorktree(params.path);
|
|
3526
|
+
ctx.invalidateDiscovered();
|
|
3527
|
+
return formatToolResponse({
|
|
3528
|
+
success: true,
|
|
3529
|
+
worktreePath
|
|
3530
|
+
});
|
|
3531
|
+
}
|
|
3532
|
+
async function handleInitialize(ctx, params, extra) {
|
|
3533
|
+
const { service } = await getReadyService(ctx, params.repoName, {
|
|
3534
|
+
capability: "canInitialize",
|
|
3535
|
+
toolName: "initialize",
|
|
3536
|
+
ensureNotSyncing: true
|
|
3537
|
+
});
|
|
3538
|
+
const dispose = attachProgressReporter(service, extra);
|
|
3539
|
+
try {
|
|
3540
|
+
await service.initialize();
|
|
3541
|
+
const git = service.getGitService();
|
|
3542
|
+
ctx.invalidateDiscovered();
|
|
3543
|
+
return formatToolResponse({
|
|
3544
|
+
success: true,
|
|
3545
|
+
defaultBranch: git.getDefaultBranch(),
|
|
3546
|
+
worktreeDir: service.config.worktreeDir
|
|
3547
|
+
});
|
|
3548
|
+
} finally {
|
|
3549
|
+
dispose();
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
async function handleLoadConfig(ctx, params, _extra) {
|
|
3553
|
+
const configPath = params.configPath ?? process.env.SYNC_WORKTREES_CONFIG;
|
|
3554
|
+
if (!configPath) {
|
|
3555
|
+
throw new Error("configPath required (or set SYNC_WORKTREES_CONFIG env var)");
|
|
3556
|
+
}
|
|
3557
|
+
await ctx.loadConfig(configPath);
|
|
3558
|
+
return formatToolResponse({
|
|
3559
|
+
configPath: path9.resolve(configPath),
|
|
3560
|
+
currentRepository: ctx.getCurrentRepo(),
|
|
3561
|
+
repositories: ctx.getRepositoryList()
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
async function handleSetCurrentRepository(ctx, params, _extra) {
|
|
3565
|
+
ctx.setCurrentRepo(params.repoName);
|
|
3566
|
+
return formatToolResponse({
|
|
3567
|
+
currentRepository: ctx.getCurrentRepo(),
|
|
3568
|
+
repositories: ctx.getRepositoryList()
|
|
3569
|
+
});
|
|
3570
|
+
}
|
|
3571
|
+
function attachProgressReporter(service, extra) {
|
|
3572
|
+
const token = extra?._meta?.progressToken;
|
|
3573
|
+
if (token === void 0 || !extra) return () => {
|
|
3574
|
+
};
|
|
3575
|
+
if (!service.onProgress) return () => {
|
|
3576
|
+
};
|
|
3577
|
+
let progressCounter = 0;
|
|
3578
|
+
const unsubscribe = service.onProgress((event) => {
|
|
3579
|
+
progressCounter++;
|
|
3580
|
+
void extra.sendNotification({
|
|
3581
|
+
method: "notifications/progress",
|
|
3582
|
+
params: {
|
|
3583
|
+
progressToken: token,
|
|
3584
|
+
progress: progressCounter,
|
|
3585
|
+
message: `[${event.phase}] ${event.message}`
|
|
3586
|
+
}
|
|
3587
|
+
}).catch(() => {
|
|
3588
|
+
});
|
|
3589
|
+
});
|
|
3590
|
+
return unsubscribe;
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
// src/mcp/server.ts
|
|
3594
|
+
var REPO_NAME_DESCRIBE = "Repository name from loaded config. If omitted, uses the current repository set via set_current_repository or the only loaded repo.";
|
|
3595
|
+
var PATH_DESCRIBE_SUFFIX = "Absolute path preferred; relative paths resolve from the server's CWD.";
|
|
3596
|
+
var SERVER_INSTRUCTIONS = "Before running git worktree operations, call `detect_context` to learn the current repo, current branch, and which capabilities are available. It reports whether the working directory is inside a sync-worktrees-managed workspace, lists sibling worktrees, and explains why any capability is disabled.";
|
|
3597
|
+
function createServer(context) {
|
|
3598
|
+
const server = new McpServer(
|
|
3599
|
+
{
|
|
3600
|
+
name: "sync-worktrees",
|
|
3601
|
+
version: "1.0.0"
|
|
3602
|
+
},
|
|
3603
|
+
{
|
|
3604
|
+
instructions: SERVER_INSTRUCTIONS
|
|
3605
|
+
}
|
|
3606
|
+
);
|
|
3607
|
+
server.registerResource(
|
|
3608
|
+
"workspace",
|
|
3609
|
+
"sync-worktrees://workspace",
|
|
3610
|
+
{
|
|
3611
|
+
title: "Workspace context",
|
|
3612
|
+
description: "Current sync-worktrees workspace context: whether CWD is inside a managed worktree, the current branch, sibling worktrees, and available capabilities. Returns { isWorktree: false } when CWD is outside any workspace.",
|
|
3613
|
+
mimeType: "application/json"
|
|
3614
|
+
},
|
|
3615
|
+
async (uri) => {
|
|
3616
|
+
let discovered;
|
|
3617
|
+
try {
|
|
3618
|
+
discovered = await context.detectFromPath(process.cwd());
|
|
3619
|
+
} catch (err) {
|
|
3620
|
+
discovered = buildUnsupportedContext(process.cwd(), err instanceof Error ? err.message : String(err));
|
|
3621
|
+
}
|
|
3622
|
+
return {
|
|
3623
|
+
contents: [
|
|
3624
|
+
{
|
|
3625
|
+
uri: uri.href,
|
|
3626
|
+
mimeType: "application/json",
|
|
3627
|
+
text: JSON.stringify(discovered, null, 2)
|
|
3628
|
+
}
|
|
3629
|
+
]
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
);
|
|
3633
|
+
server.registerTool(
|
|
3634
|
+
"detect_context",
|
|
3635
|
+
{
|
|
3636
|
+
description: "Detect sync-worktrees structure from a filesystem path. Reads .git file, resolves bare repo, discovers sibling worktrees. Defaults to CWD. Use when: bootstrapping from an unknown checkout with no config loaded. Returns: discovered repo root, bare repo path, all sibling worktrees, current worktree path, capabilities.",
|
|
3637
|
+
inputSchema: {
|
|
3638
|
+
path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD.")
|
|
3639
|
+
},
|
|
3640
|
+
annotations: {
|
|
3641
|
+
title: "Detect sync-worktrees context",
|
|
3642
|
+
readOnlyHint: true,
|
|
3643
|
+
idempotentHint: true,
|
|
3644
|
+
openWorldHint: false
|
|
3645
|
+
}
|
|
3646
|
+
},
|
|
3647
|
+
wrapHandler((params, extra) => handleDetectContext(context, params, extra))
|
|
3648
|
+
);
|
|
3649
|
+
server.registerTool(
|
|
3650
|
+
"list_worktrees",
|
|
3651
|
+
{
|
|
3652
|
+
description: "List all worktrees of a repository with enriched status. Returns: array of { path, branch, isCurrent, label (clean|dirty|stale|current|unknown), status, divergence (ahead/behind), safeToRemove, lastSyncAt }.",
|
|
3653
|
+
inputSchema: {
|
|
3654
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
3655
|
+
},
|
|
3656
|
+
annotations: {
|
|
3657
|
+
title: "List worktrees with status",
|
|
3658
|
+
readOnlyHint: true,
|
|
3659
|
+
idempotentHint: true,
|
|
3660
|
+
openWorldHint: false
|
|
3661
|
+
}
|
|
3662
|
+
},
|
|
3663
|
+
wrapHandler((params, extra) => handleListWorktrees(context, params, extra))
|
|
3664
|
+
);
|
|
3665
|
+
server.registerTool(
|
|
3666
|
+
"get_worktree_status",
|
|
3667
|
+
{
|
|
3668
|
+
description: "Get detailed status for one worktree (dirty files, unpushed commits, stashes, upstream gone, operations in progress). Returns: full status object plus divergence { ahead, behind } and resolved absolute path.",
|
|
3669
|
+
inputSchema: {
|
|
3670
|
+
path: z.string().describe(`Worktree path. ${PATH_DESCRIBE_SUFFIX}`),
|
|
3671
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
|
|
3672
|
+
includeDetails: z.boolean().optional().describe("If true, includes file-level lists (modified, untracked, staged). Default: false (counts only).")
|
|
3673
|
+
},
|
|
3674
|
+
annotations: {
|
|
3675
|
+
title: "Get worktree status",
|
|
3676
|
+
readOnlyHint: true,
|
|
3677
|
+
idempotentHint: true,
|
|
3678
|
+
openWorldHint: false
|
|
3679
|
+
}
|
|
3680
|
+
},
|
|
3681
|
+
wrapHandler((params, extra) => handleGetWorktreeStatus(context, params, extra))
|
|
3682
|
+
);
|
|
3683
|
+
server.registerTool(
|
|
3684
|
+
"create_worktree",
|
|
3685
|
+
{
|
|
3686
|
+
description: "Create a worktree for a branch. If the branch exists (local or remote), checks it out; otherwise creates it from baseBranch. Optionally pushes the new branch to origin. Key params: baseBranch is required only when the branch does not yet exist \u2014 pass it defensively if unsure. push=true only affects newly created branches. Preconditions: repository must be initialized (auto-runs on first call). Returns: { success, branchName, worktreePath, created, pushed }.",
|
|
3687
|
+
inputSchema: {
|
|
3688
|
+
branchName: z.string().describe("Branch name. Slashes and special chars are sanitized for the worktree directory name."),
|
|
3689
|
+
baseBranch: z.string().optional().describe(
|
|
3690
|
+
"Base branch for creating a new branch. Required if branchName does not exist locally or remotely; ignored otherwise."
|
|
3691
|
+
),
|
|
3692
|
+
push: z.boolean().optional().describe("Push the newly created branch to origin. Ignored if the branch already existed."),
|
|
3693
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
3694
|
+
},
|
|
3695
|
+
annotations: {
|
|
3696
|
+
title: "Create worktree",
|
|
3697
|
+
readOnlyHint: false,
|
|
3698
|
+
destructiveHint: false,
|
|
3699
|
+
idempotentHint: false,
|
|
3700
|
+
openWorldHint: true
|
|
3701
|
+
}
|
|
3702
|
+
},
|
|
3703
|
+
wrapHandler((params, extra) => handleCreateWorktree(context, params, extra))
|
|
3704
|
+
);
|
|
3705
|
+
server.registerTool(
|
|
3706
|
+
"remove_worktree",
|
|
3707
|
+
{
|
|
3708
|
+
description: "Remove a worktree. Runs safety checks first: rejects if worktree is dirty, has unpushed commits, has stashes, or has an in-progress git operation (merge/rebase/cherry-pick/revert/bisect). force=true: runs `git worktree remove --force`, which DELETES uncommitted and untracked files in the worktree directory. Branch ref, stashes, and remote state are preserved. Returns: { success, removedPath }.",
|
|
3709
|
+
inputSchema: {
|
|
3710
|
+
path: z.string().describe(`Worktree path to remove. ${PATH_DESCRIBE_SUFFIX}`),
|
|
3711
|
+
force: z.boolean().optional().describe(
|
|
3712
|
+
"Skip safety checks and delete uncommitted/untracked files in the worktree directory. Branch ref is preserved. Default: false."
|
|
3713
|
+
),
|
|
3714
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
3715
|
+
},
|
|
3716
|
+
annotations: {
|
|
3717
|
+
title: "Remove worktree",
|
|
3718
|
+
readOnlyHint: false,
|
|
3719
|
+
destructiveHint: true,
|
|
3720
|
+
idempotentHint: false,
|
|
3721
|
+
openWorldHint: false
|
|
3722
|
+
}
|
|
3723
|
+
},
|
|
3724
|
+
wrapHandler((params, extra) => handleRemoveWorktree(context, params, extra))
|
|
3725
|
+
);
|
|
3726
|
+
server.registerTool(
|
|
3727
|
+
"sync",
|
|
3728
|
+
{
|
|
3729
|
+
description: "Full repo-wide synchronization: fetch all, create worktrees for new remote branches, remove worktrees for pruned remote branches (clean only), fast-forward existing worktrees. Emits progress notifications. Do not use when: you only need to update one worktree \u2014 use update_worktree. Only need to create one \u2014 use create_worktree. Preconditions: config must be loaded (load_config) and the repository initialized (auto-runs on first call). Returns: { success, duration } after sync completes.",
|
|
3730
|
+
inputSchema: {
|
|
3731
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
3732
|
+
},
|
|
3733
|
+
annotations: {
|
|
3734
|
+
title: "Sync repository worktrees",
|
|
3735
|
+
readOnlyHint: false,
|
|
3736
|
+
destructiveHint: true,
|
|
3737
|
+
idempotentHint: false,
|
|
3738
|
+
openWorldHint: true
|
|
3739
|
+
}
|
|
3740
|
+
},
|
|
3741
|
+
wrapHandler((params, extra) => handleSync(context, params, extra))
|
|
3742
|
+
);
|
|
3743
|
+
server.registerTool(
|
|
3744
|
+
"update_worktree",
|
|
3745
|
+
{
|
|
3746
|
+
description: "Fast-forward one worktree to match its upstream. No merge commits, no rebasing, aborts if not fast-forwardable. Do not use when: you want to update every worktree in the repo \u2014 use sync.",
|
|
3747
|
+
inputSchema: {
|
|
3748
|
+
path: z.string().describe(`Worktree path to fast-forward. ${PATH_DESCRIBE_SUFFIX}`),
|
|
3749
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
3750
|
+
},
|
|
3751
|
+
annotations: {
|
|
3752
|
+
title: "Fast-forward one worktree",
|
|
3753
|
+
readOnlyHint: false,
|
|
3754
|
+
destructiveHint: false,
|
|
3755
|
+
idempotentHint: true,
|
|
3756
|
+
openWorldHint: true
|
|
3757
|
+
}
|
|
3758
|
+
},
|
|
3759
|
+
wrapHandler((params, extra) => handleUpdateWorktree(context, params, extra))
|
|
3760
|
+
);
|
|
3761
|
+
server.registerTool(
|
|
3762
|
+
"initialize",
|
|
3763
|
+
{
|
|
3764
|
+
description: "Initialize a repository: clone as bare repo if missing, create main worktree. Safe to call on already-initialized repos (no-op-ish). Emits progress notifications. Preconditions: config must be loaded (load_config) so the repo's URL and paths are known. Returns: { success, defaultBranch, worktreeDir }.",
|
|
3765
|
+
inputSchema: {
|
|
3766
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
3767
|
+
},
|
|
3768
|
+
annotations: {
|
|
3769
|
+
title: "Initialize repository",
|
|
3770
|
+
readOnlyHint: false,
|
|
3771
|
+
destructiveHint: false,
|
|
3772
|
+
idempotentHint: true,
|
|
3773
|
+
openWorldHint: true
|
|
3774
|
+
}
|
|
3775
|
+
},
|
|
3776
|
+
wrapHandler((params, extra) => handleInitialize(context, params, extra))
|
|
3777
|
+
);
|
|
3778
|
+
server.registerTool(
|
|
3779
|
+
"load_config",
|
|
3780
|
+
{
|
|
3781
|
+
description: "Load or reload a sync-worktrees JavaScript config file into the server's session. Replaces any previously loaded repositories. Call this before sync/initialize/create_worktree when using a config-driven workflow. Returns: { configPath, currentRepository, repositories: [{ name, repoPath, worktreeDir, ... }] }.",
|
|
3782
|
+
inputSchema: {
|
|
3783
|
+
configPath: z.string().optional().describe(
|
|
3784
|
+
"Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."
|
|
3785
|
+
)
|
|
3786
|
+
},
|
|
3787
|
+
annotations: {
|
|
3788
|
+
title: "Load sync-worktrees config",
|
|
3789
|
+
readOnlyHint: false,
|
|
3790
|
+
destructiveHint: false,
|
|
3791
|
+
idempotentHint: true,
|
|
3792
|
+
openWorldHint: false
|
|
3793
|
+
}
|
|
3794
|
+
},
|
|
3795
|
+
wrapHandler((params, extra) => handleLoadConfig(context, params, extra))
|
|
3796
|
+
);
|
|
3797
|
+
server.registerTool(
|
|
3798
|
+
"set_current_repository",
|
|
3799
|
+
{
|
|
3800
|
+
description: "Set the current repository for subsequent tool calls that omit repoName. Session-scoped; not persisted across server restarts. Preconditions: load_config must have been called so the name is known.",
|
|
3801
|
+
inputSchema: {
|
|
3802
|
+
repoName: z.string().describe("Repository name as listed in the loaded config's `repositories[].name`.")
|
|
3803
|
+
},
|
|
3804
|
+
annotations: {
|
|
3805
|
+
title: "Set current repository",
|
|
3806
|
+
readOnlyHint: false,
|
|
3807
|
+
destructiveHint: false,
|
|
3808
|
+
idempotentHint: true,
|
|
3809
|
+
openWorldHint: false
|
|
3810
|
+
}
|
|
3811
|
+
},
|
|
3812
|
+
wrapHandler((params, extra) => handleSetCurrentRepository(context, params, extra))
|
|
3813
|
+
);
|
|
3814
|
+
return server;
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
// src/mcp/index.ts
|
|
3818
|
+
async function main() {
|
|
3819
|
+
const context = new RepositoryContext();
|
|
3820
|
+
const configPath = process.env.SYNC_WORKTREES_CONFIG;
|
|
3821
|
+
if (configPath) {
|
|
3822
|
+
try {
|
|
3823
|
+
await context.loadConfig(configPath);
|
|
3824
|
+
process.stderr.write(`[sync-worktrees-mcp] Loaded config: ${configPath}
|
|
3825
|
+
`);
|
|
3826
|
+
} catch (err) {
|
|
3827
|
+
process.stderr.write(`[sync-worktrees-mcp] Failed to load SYNC_WORKTREES_CONFIG: ${err.message}
|
|
3828
|
+
`);
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
try {
|
|
3832
|
+
const discovered = await context.detectFromPath(process.cwd());
|
|
3833
|
+
if (discovered.isWorktree) {
|
|
3834
|
+
process.stderr.write(
|
|
3835
|
+
`[sync-worktrees-mcp] Auto-detected ${discovered.kind} worktree at ${discovered.currentWorktreePath} (branch: ${discovered.currentBranch})
|
|
3836
|
+
`
|
|
3837
|
+
);
|
|
3838
|
+
}
|
|
3839
|
+
} catch (err) {
|
|
3840
|
+
process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
|
|
3841
|
+
`);
|
|
3842
|
+
}
|
|
3843
|
+
const server = createServer(context);
|
|
3844
|
+
const transport = new StdioServerTransport();
|
|
3845
|
+
await server.connect(transport);
|
|
3846
|
+
}
|
|
3847
|
+
main().catch((err) => {
|
|
3848
|
+
process.stderr.write(`[sync-worktrees-mcp] Fatal error: ${err.message}
|
|
3849
|
+
`);
|
|
3850
|
+
process.exit(1);
|
|
3851
|
+
});
|
|
3852
|
+
//# sourceMappingURL=mcp-server.js.map
|