@solaqua/gji 0.8.0 → 0.9.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 +24 -30
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +37 -0
- package/dist/clean.js +34 -19
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +59 -4
- package/dist/doctor.d.ts +28 -0
- package/dist/doctor.js +454 -0
- package/dist/gji-bundle.mjs +2514 -378
- package/dist/go.d.ts +2 -1
- package/dist/go.js +4 -1
- package/dist/index.js +12 -6
- package/dist/init.d.ts +15 -0
- package/dist/init.js +232 -16
- package/dist/open.d.ts +2 -1
- package/dist/open.js +2 -1
- package/dist/pr-open.d.ts +19 -0
- package/dist/pr-open.js +290 -0
- package/dist/pull-requests.d.ts +33 -0
- package/dist/pull-requests.js +384 -0
- package/dist/remove.js +2 -2
- package/dist/repo-registry.d.ts +5 -0
- package/dist/repo-registry.js +87 -14
- package/dist/shell-completion.js +278 -10
- package/dist/shell-setup.d.ts +9 -0
- package/dist/shell-setup.js +56 -0
- package/dist/warp.d.ts +3 -0
- package/dist/warp.js +8 -3
- package/dist/worktree-management.d.ts +2 -1
- package/dist/worktree-management.js +23 -7
- package/dist/worktree-picker.d.ts +12 -1
- package/dist/worktree-picker.js +84 -6
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +1 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-doctor.1 +19 -0
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +6 -3
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +1 -1
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +5 -1
- package/man/man1/gji-remove.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-run-hook.1 +1 -1
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync-files.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-warp.1 +1 -1
- package/man/man1/gji.1 +6 -2
- package/package.json +1 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const PULL_REQUEST_QUERY_TIMEOUT_MS = 2500;
|
|
5
|
+
export function parsePullRequestRemote(remoteUrl) {
|
|
6
|
+
const parsed = parseRemoteUrl(remoteUrl.trim());
|
|
7
|
+
if (parsed === null)
|
|
8
|
+
return null;
|
|
9
|
+
const forge = detectForge(parsed.host);
|
|
10
|
+
if (forge === null)
|
|
11
|
+
return null;
|
|
12
|
+
if (forge === "bitbucket" && parsed.host !== "bitbucket.org")
|
|
13
|
+
return null;
|
|
14
|
+
const path = parsed.path
|
|
15
|
+
.replace(/^\/+/, "")
|
|
16
|
+
.replace(/\.git$/, "")
|
|
17
|
+
.split("/")
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
if (path.length < 2)
|
|
20
|
+
return null;
|
|
21
|
+
if (forge === "gitlab") {
|
|
22
|
+
const repository = path.at(-1);
|
|
23
|
+
const namespace = path.slice(0, -1).join("/");
|
|
24
|
+
if (repository === undefined || namespace.length === 0)
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
forge,
|
|
28
|
+
host: parsed.host,
|
|
29
|
+
namespace,
|
|
30
|
+
repository,
|
|
31
|
+
webBaseUrl: `https://${parsed.host}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const namespace = path.at(-2);
|
|
35
|
+
const repository = path.at(-1);
|
|
36
|
+
if (namespace === undefined || repository === undefined)
|
|
37
|
+
return null;
|
|
38
|
+
return {
|
|
39
|
+
forge,
|
|
40
|
+
host: parsed.host,
|
|
41
|
+
namespace,
|
|
42
|
+
repository,
|
|
43
|
+
webBaseUrl: `https://${parsed.host}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function createPullRequestQuery(dependencies = {}) {
|
|
47
|
+
const runCommand = dependencies.runCommand ?? defaultRunCommand;
|
|
48
|
+
const fetcher = dependencies.fetch ?? fetch;
|
|
49
|
+
return {
|
|
50
|
+
findOpenPullRequest: (repoRoot, number) => findOpenPullRequest(repoRoot, number, runCommand, fetcher),
|
|
51
|
+
listOpenPullRequests: (repoRoot, sourceBranch) => listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher),
|
|
52
|
+
listOpenPullRequestsForRepository: (repoRoot) => listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher) {
|
|
56
|
+
const remote = await readPullRequestRemote(repoRoot, runCommand);
|
|
57
|
+
const cliResult = await queryProviderCli(remote, { kind: "branch", sourceBranch }, repoRoot, runCommand);
|
|
58
|
+
if (cliResult !== null)
|
|
59
|
+
return sortPullRequests(cliResult);
|
|
60
|
+
const apiResult = await queryPublicApi(remote, { kind: "branch", sourceBranch }, fetcher);
|
|
61
|
+
return sortPullRequests(apiResult);
|
|
62
|
+
}
|
|
63
|
+
async function listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher) {
|
|
64
|
+
const remote = await readPullRequestRemote(repoRoot, runCommand);
|
|
65
|
+
const cliResult = await queryProviderCli(remote, { kind: "repository" }, repoRoot, runCommand);
|
|
66
|
+
if (cliResult !== null)
|
|
67
|
+
return sortPullRequests(cliResult);
|
|
68
|
+
const apiResult = await queryPublicApi(remote, { kind: "repository" }, fetcher);
|
|
69
|
+
return sortPullRequests(apiResult);
|
|
70
|
+
}
|
|
71
|
+
async function findOpenPullRequest(repoRoot, number, runCommand, fetcher) {
|
|
72
|
+
const remote = await readPullRequestRemote(repoRoot, runCommand);
|
|
73
|
+
const cliResult = await queryProviderCli(remote, { kind: "number", number }, repoRoot, runCommand);
|
|
74
|
+
if (cliResult !== null)
|
|
75
|
+
return cliResult[0] ?? null;
|
|
76
|
+
const apiResult = await queryPublicApi(remote, { kind: "number", number }, fetcher);
|
|
77
|
+
return apiResult[0] ?? null;
|
|
78
|
+
}
|
|
79
|
+
async function readPullRequestRemote(repoRoot, runCommand) {
|
|
80
|
+
const result = await runCommand("git", ["remote", "get-url", "origin"], commandOptions(repoRoot));
|
|
81
|
+
const remote = parsePullRequestRemote(result.stdout);
|
|
82
|
+
if (remote === null) {
|
|
83
|
+
throw new Error("origin remote is unsupported or missing");
|
|
84
|
+
}
|
|
85
|
+
return remote;
|
|
86
|
+
}
|
|
87
|
+
async function queryProviderCli(remote, query, repoRoot, runCommand) {
|
|
88
|
+
const providerCommand = providerCliCommand(remote, query);
|
|
89
|
+
try {
|
|
90
|
+
const result = await runCommand(providerCommand.command, providerCommand.args, commandOptions(repoRoot));
|
|
91
|
+
return parseProviderOutput(remote, result.stdout, query);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Missing CLIs, unauthenticated CLIs, and CLI/network failures all use the public API fallback.
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function queryPublicApi(remote, query, fetcher) {
|
|
99
|
+
const url = publicApiUrl(remote, query);
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timer = setTimeout(() => controller.abort(), PULL_REQUEST_QUERY_TIMEOUT_MS);
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetcher(url, {
|
|
104
|
+
headers: { Accept: "application/json" },
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
});
|
|
107
|
+
if (response.status === 404 && query.kind === "number")
|
|
108
|
+
return [];
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error(`public API returned HTTP ${response.status}`);
|
|
111
|
+
}
|
|
112
|
+
const payload = await response.json();
|
|
113
|
+
return parseApiOutput(remote, payload, query);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function parseRemoteUrl(remoteUrl) {
|
|
120
|
+
const scpMatch = remoteUrl.match(/^[^@]+@([^:]+):(.+)$/);
|
|
121
|
+
if (scpMatch) {
|
|
122
|
+
return { host: scpMatch[1].toLowerCase(), path: scpMatch[2] };
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(remoteUrl);
|
|
126
|
+
if (!url.hostname)
|
|
127
|
+
return null;
|
|
128
|
+
return { host: url.hostname.toLowerCase(), path: url.pathname };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function detectForge(host) {
|
|
135
|
+
if (host === "github.com" || host.includes("github"))
|
|
136
|
+
return "github";
|
|
137
|
+
if (host === "gitlab.com" || host.includes("gitlab"))
|
|
138
|
+
return "gitlab";
|
|
139
|
+
if (host === "bitbucket.org" || host.includes("bitbucket")) {
|
|
140
|
+
return "bitbucket";
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function providerCliCommand(remote, query) {
|
|
145
|
+
const coordinate = providerRepositoryCoordinate(remote);
|
|
146
|
+
const sourceBranch = query.sourceBranch ?? "";
|
|
147
|
+
switch (remote.forge) {
|
|
148
|
+
case "github": {
|
|
149
|
+
if (query.kind === "number") {
|
|
150
|
+
return {
|
|
151
|
+
args: [
|
|
152
|
+
"pr",
|
|
153
|
+
"view",
|
|
154
|
+
String(query.number),
|
|
155
|
+
"--json",
|
|
156
|
+
"number,url,headRefName,state",
|
|
157
|
+
"--repo",
|
|
158
|
+
coordinate,
|
|
159
|
+
],
|
|
160
|
+
command: "gh",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
args: [
|
|
165
|
+
"pr",
|
|
166
|
+
"list",
|
|
167
|
+
"--state",
|
|
168
|
+
"open",
|
|
169
|
+
...(query.kind === "branch" ? ["--head", sourceBranch] : []),
|
|
170
|
+
"--json",
|
|
171
|
+
"number,url,headRefName,state",
|
|
172
|
+
"--limit",
|
|
173
|
+
"100",
|
|
174
|
+
"--repo",
|
|
175
|
+
coordinate,
|
|
176
|
+
],
|
|
177
|
+
command: "gh",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
case "gitlab": {
|
|
181
|
+
if (query.kind === "number") {
|
|
182
|
+
return {
|
|
183
|
+
args: [
|
|
184
|
+
"mr",
|
|
185
|
+
"view",
|
|
186
|
+
String(query.number),
|
|
187
|
+
"--output",
|
|
188
|
+
"json",
|
|
189
|
+
"--repo",
|
|
190
|
+
coordinate,
|
|
191
|
+
],
|
|
192
|
+
command: "glab",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
args: [
|
|
197
|
+
"mr",
|
|
198
|
+
"list",
|
|
199
|
+
"--state",
|
|
200
|
+
"opened",
|
|
201
|
+
...(query.kind === "branch" ? ["--source-branch", sourceBranch] : []),
|
|
202
|
+
"--output",
|
|
203
|
+
"json",
|
|
204
|
+
"--per-page",
|
|
205
|
+
"100",
|
|
206
|
+
"--repo",
|
|
207
|
+
coordinate,
|
|
208
|
+
],
|
|
209
|
+
command: "glab",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
case "bitbucket": {
|
|
213
|
+
if (query.kind === "number") {
|
|
214
|
+
return {
|
|
215
|
+
args: [
|
|
216
|
+
"pr",
|
|
217
|
+
"view",
|
|
218
|
+
String(query.number),
|
|
219
|
+
"--format",
|
|
220
|
+
"json",
|
|
221
|
+
"--repo",
|
|
222
|
+
coordinate,
|
|
223
|
+
],
|
|
224
|
+
command: "bb",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
args: [
|
|
229
|
+
"pr",
|
|
230
|
+
"list",
|
|
231
|
+
"--state",
|
|
232
|
+
"OPEN",
|
|
233
|
+
...(query.kind === "branch" ? ["--source", sourceBranch] : []),
|
|
234
|
+
"--format",
|
|
235
|
+
"json",
|
|
236
|
+
"--repo",
|
|
237
|
+
coordinate,
|
|
238
|
+
],
|
|
239
|
+
command: "bb",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function providerRepositoryCoordinate(remote) {
|
|
245
|
+
const coordinate = `${remote.namespace}/${remote.repository}`;
|
|
246
|
+
return remote.host === "github.com" || remote.host === "bitbucket.org"
|
|
247
|
+
? coordinate
|
|
248
|
+
: `${remote.host}/${coordinate}`;
|
|
249
|
+
}
|
|
250
|
+
function parseProviderOutput(remote, stdout, query) {
|
|
251
|
+
const payload = JSON.parse(stdout);
|
|
252
|
+
const values = remote.forge === "bitbucket" && isRecord(payload)
|
|
253
|
+
? Array.isArray(payload.values)
|
|
254
|
+
? payload.values
|
|
255
|
+
: []
|
|
256
|
+
: Array.isArray(payload)
|
|
257
|
+
? payload
|
|
258
|
+
: [payload];
|
|
259
|
+
return values
|
|
260
|
+
.map((value) => normalizePullRequest(value, query))
|
|
261
|
+
.filter((value) => value !== null)
|
|
262
|
+
.filter((value) => query.kind === "branch"
|
|
263
|
+
? value.sourceBranch === query.sourceBranch
|
|
264
|
+
: query.kind === "number"
|
|
265
|
+
? value.number === query.number
|
|
266
|
+
: true);
|
|
267
|
+
}
|
|
268
|
+
function parseApiOutput(remote, payload, query) {
|
|
269
|
+
const values = remote.forge === "bitbucket" && isRecord(payload)
|
|
270
|
+
? Array.isArray(payload.values)
|
|
271
|
+
? payload.values
|
|
272
|
+
: []
|
|
273
|
+
: query.kind === "number"
|
|
274
|
+
? [payload]
|
|
275
|
+
: Array.isArray(payload)
|
|
276
|
+
? payload
|
|
277
|
+
: [];
|
|
278
|
+
return values
|
|
279
|
+
.map((value) => normalizePullRequest(value, query))
|
|
280
|
+
.filter((value) => value !== null)
|
|
281
|
+
.filter((value) => query.kind === "branch"
|
|
282
|
+
? value.sourceBranch === query.sourceBranch
|
|
283
|
+
: query.kind === "number"
|
|
284
|
+
? value.number === query.number
|
|
285
|
+
: true);
|
|
286
|
+
}
|
|
287
|
+
function normalizePullRequest(value, query) {
|
|
288
|
+
if (!isRecord(value))
|
|
289
|
+
return null;
|
|
290
|
+
const number = numberValue(value.number ?? value.iid ?? value.id);
|
|
291
|
+
const head = isRecord(value.head) ? value.head : null;
|
|
292
|
+
const source = isRecord(value.source) ? value.source : null;
|
|
293
|
+
const sourceBranch = stringValue(value.headRefName ??
|
|
294
|
+
(head !== null ? head.ref : undefined) ??
|
|
295
|
+
value.source_branch ??
|
|
296
|
+
(source !== null && isRecord(source.branch)
|
|
297
|
+
? source.branch.name
|
|
298
|
+
: undefined));
|
|
299
|
+
const url = stringValue(value.html_url ??
|
|
300
|
+
value.web_url ??
|
|
301
|
+
value.url ??
|
|
302
|
+
(isRecord(value.links) && isRecord(value.links.html)
|
|
303
|
+
? value.links.html.href
|
|
304
|
+
: undefined));
|
|
305
|
+
const state = stringValue(value.state)?.toLowerCase();
|
|
306
|
+
if (number === null || url === null)
|
|
307
|
+
return null;
|
|
308
|
+
if (query.kind !== "number" && sourceBranch === null)
|
|
309
|
+
return null;
|
|
310
|
+
if (query.kind === "number" && number !== query.number)
|
|
311
|
+
return null;
|
|
312
|
+
if (state !== undefined && !["open", "opened"].includes(state))
|
|
313
|
+
return null;
|
|
314
|
+
return {
|
|
315
|
+
number,
|
|
316
|
+
sourceBranch: sourceBranch ?? "",
|
|
317
|
+
url,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function publicApiUrl(remote, query) {
|
|
321
|
+
const branch = encodeURIComponent(query.sourceBranch ?? "");
|
|
322
|
+
const number = query.number === undefined ? "" : String(query.number);
|
|
323
|
+
switch (remote.forge) {
|
|
324
|
+
case "github": {
|
|
325
|
+
const coordinate = `${encodeURIComponent(remote.namespace)}/${encodeURIComponent(remote.repository)}`;
|
|
326
|
+
const apiBase = remote.host === "github.com"
|
|
327
|
+
? "https://api.github.com"
|
|
328
|
+
: `https://${remote.host}/api/v3`;
|
|
329
|
+
if (query.kind === "number") {
|
|
330
|
+
return `${apiBase}/repos/${coordinate}/pulls/${number}`;
|
|
331
|
+
}
|
|
332
|
+
return `${apiBase}/repos/${coordinate}/pulls?state=open&per_page=100`;
|
|
333
|
+
}
|
|
334
|
+
case "gitlab": {
|
|
335
|
+
const project = encodeURIComponent(`${remote.namespace}/${remote.repository}`);
|
|
336
|
+
if (query.kind === "number") {
|
|
337
|
+
return `${remote.webBaseUrl}/api/v4/projects/${project}/merge_requests/${number}`;
|
|
338
|
+
}
|
|
339
|
+
return query.kind === "branch"
|
|
340
|
+
? `${remote.webBaseUrl}/api/v4/projects/${project}/merge_requests?state=opened&source_branch=${branch}&per_page=100`
|
|
341
|
+
: `${remote.webBaseUrl}/api/v4/projects/${project}/merge_requests?state=opened&per_page=100`;
|
|
342
|
+
}
|
|
343
|
+
case "bitbucket": {
|
|
344
|
+
const coordinate = `${encodeURIComponent(remote.namespace)}/${encodeURIComponent(remote.repository)}`;
|
|
345
|
+
if (query.kind === "number") {
|
|
346
|
+
return `https://api.bitbucket.org/2.0/repositories/${coordinate}/pullrequests/${number}`;
|
|
347
|
+
}
|
|
348
|
+
return query.kind === "branch"
|
|
349
|
+
? `https://api.bitbucket.org/2.0/repositories/${coordinate}/pullrequests?state=OPEN&source.branch.name=${branch}&pagelen=100`
|
|
350
|
+
: `https://api.bitbucket.org/2.0/repositories/${coordinate}/pullrequests?state=OPEN&pagelen=100`;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function sortPullRequests(pullRequests) {
|
|
355
|
+
return [...pullRequests].sort((a, b) => a.number - b.number);
|
|
356
|
+
}
|
|
357
|
+
function commandOptions(cwd) {
|
|
358
|
+
return {
|
|
359
|
+
cwd,
|
|
360
|
+
env: {
|
|
361
|
+
...process.env,
|
|
362
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
363
|
+
GH_PROMPT_DISABLED: "1",
|
|
364
|
+
GLAB_NON_INTERACTIVE: "1",
|
|
365
|
+
},
|
|
366
|
+
timeout: PULL_REQUEST_QUERY_TIMEOUT_MS,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
async function defaultRunCommand(command, args, options) {
|
|
370
|
+
return execFileAsync(command, args, options);
|
|
371
|
+
}
|
|
372
|
+
function isRecord(value) {
|
|
373
|
+
return typeof value === "object" && value !== null;
|
|
374
|
+
}
|
|
375
|
+
function stringValue(value) {
|
|
376
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
377
|
+
}
|
|
378
|
+
function numberValue(value) {
|
|
379
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
380
|
+
return value;
|
|
381
|
+
if (typeof value === "string" && /^\d+$/.test(value))
|
|
382
|
+
return Number(value);
|
|
383
|
+
return null;
|
|
384
|
+
}
|
package/dist/remove.js
CHANGED
|
@@ -4,7 +4,7 @@ import { loadEffectiveConfig } from "./config.js";
|
|
|
4
4
|
import { isHeadless } from "./headless.js";
|
|
5
5
|
import { extractHooks, runHook } from "./hooks.js";
|
|
6
6
|
import { writeShellOutput } from "./shell-handoff.js";
|
|
7
|
-
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError,
|
|
7
|
+
import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeForceRemovalError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
|
|
8
8
|
import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
|
|
9
9
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
|
|
10
10
|
const REMOVE_OUTPUT_FILE_ENV = "GJI_REMOVE_OUTPUT_FILE";
|
|
@@ -83,7 +83,7 @@ export function createRemoveCommand(dependencies = {}) {
|
|
|
83
83
|
await removeWorktree(repository.repoRoot, worktree.path);
|
|
84
84
|
}
|
|
85
85
|
catch (error) {
|
|
86
|
-
if (!
|
|
86
|
+
if (!isWorktreeForceRemovalError(error)) {
|
|
87
87
|
throw error;
|
|
88
88
|
}
|
|
89
89
|
if (!options.force &&
|
package/dist/repo-registry.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ export interface RepoRegistryEntry {
|
|
|
3
3
|
name: string;
|
|
4
4
|
path: string;
|
|
5
5
|
}
|
|
6
|
+
export interface RegistryRemovalResult {
|
|
7
|
+
removedPaths: string[];
|
|
8
|
+
skippedPaths: string[];
|
|
9
|
+
}
|
|
6
10
|
export declare function REGISTRY_FILE_PATH(home?: string): string;
|
|
7
11
|
export declare function loadRegistry(home?: string): Promise<RepoRegistryEntry[]>;
|
|
8
12
|
export declare function registerRepo(repoPath: string, home?: string): Promise<void>;
|
|
13
|
+
export declare function removeMissingRegistryEntries(paths: ReadonlySet<string>, home?: string): Promise<RegistryRemovalResult>;
|
package/dist/repo-registry.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, mkdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
2
4
|
import { homedir } from "node:os";
|
|
3
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
6
|
import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
|
|
5
7
|
const REGISTRY_FILE_NAME = "repos.json";
|
|
6
8
|
const MAX_REGISTRY_ENTRIES = 100;
|
|
9
|
+
const registryLocks = new Map();
|
|
7
10
|
export function REGISTRY_FILE_PATH(home = homedir()) {
|
|
8
11
|
const configDir = process.env.GJI_CONFIG_DIR;
|
|
9
12
|
if (configDir) {
|
|
@@ -34,20 +37,90 @@ async function canonicalizeRepoPath(repoPath) {
|
|
|
34
37
|
}
|
|
35
38
|
export async function registerRepo(repoPath, home = homedir()) {
|
|
36
39
|
const registryPath = REGISTRY_FILE_PATH(home);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
await withRegistryLock(registryPath, async () => {
|
|
41
|
+
const existing = await normalizeRegistryForWrite(await loadRegistry(home));
|
|
42
|
+
const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
|
|
43
|
+
// Skip write if this repo is already the most-recently-used entry (common case).
|
|
44
|
+
if (existing.length > 0 && existing[0].path === canonicalRepoPath)
|
|
45
|
+
return;
|
|
46
|
+
const entry = {
|
|
47
|
+
lastUsed: Date.now(),
|
|
48
|
+
name: basename(canonicalRepoPath),
|
|
49
|
+
path: canonicalRepoPath,
|
|
50
|
+
};
|
|
51
|
+
const filtered = existing.filter((e) => e.path !== canonicalRepoPath);
|
|
52
|
+
const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
|
|
53
|
+
await writeRegistry(next, registryPath);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function removeMissingRegistryEntries(paths, home = homedir()) {
|
|
57
|
+
if (paths.size === 0)
|
|
58
|
+
return { removedPaths: [], skippedPaths: [] };
|
|
59
|
+
const registryPath = REGISTRY_FILE_PATH(home);
|
|
60
|
+
return withRegistryLock(registryPath, async () => {
|
|
61
|
+
const entries = await loadRegistry(home);
|
|
62
|
+
const candidates = entries.filter((entry) => paths.has(entry.path));
|
|
63
|
+
const status = await Promise.all(candidates.map(async (entry) => ({
|
|
64
|
+
entry,
|
|
65
|
+
missing: await isMissingPath(entry.path),
|
|
66
|
+
})));
|
|
67
|
+
const removedPaths = status
|
|
68
|
+
.filter(({ missing }) => missing)
|
|
69
|
+
.map(({ entry }) => entry.path);
|
|
70
|
+
const skippedPaths = status
|
|
71
|
+
.filter(({ missing }) => !missing)
|
|
72
|
+
.map(({ entry }) => entry.path);
|
|
73
|
+
if (removedPaths.length > 0) {
|
|
74
|
+
await writeRegistry(entries.filter((entry) => !removedPaths.includes(entry.path)), registryPath);
|
|
75
|
+
}
|
|
76
|
+
return { removedPaths, skippedPaths };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function withRegistryLock(registryPath, operation) {
|
|
80
|
+
const previous = registryLocks.get(registryPath);
|
|
81
|
+
let release;
|
|
82
|
+
const current = new Promise((resolve) => {
|
|
83
|
+
release = resolve;
|
|
84
|
+
});
|
|
85
|
+
registryLocks.set(registryPath, current);
|
|
86
|
+
if (previous)
|
|
87
|
+
await previous;
|
|
88
|
+
try {
|
|
89
|
+
return await operation();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
release();
|
|
93
|
+
if (registryLocks.get(registryPath) === current) {
|
|
94
|
+
registryLocks.delete(registryPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function isMissingPath(path) {
|
|
99
|
+
try {
|
|
100
|
+
await access(path, constants.F_OK);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return isMissingPathError(error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function isMissingPathError(error) {
|
|
108
|
+
if (!(error instanceof Error) || !("code" in error))
|
|
109
|
+
return false;
|
|
110
|
+
const code = error.code;
|
|
111
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
112
|
+
}
|
|
113
|
+
async function writeRegistry(entries, registryPath) {
|
|
49
114
|
await mkdir(dirname(registryPath), { recursive: true });
|
|
50
|
-
|
|
115
|
+
const temporaryPath = `${registryPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
116
|
+
try {
|
|
117
|
+
await writeFile(temporaryPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
|
|
118
|
+
await rename(temporaryPath, registryPath);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
51
124
|
}
|
|
52
125
|
async function normalizeRegistryForWrite(entries) {
|
|
53
126
|
const normalized = [];
|