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