forgemap 0.7.0 → 0.8.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 +15 -10
- package/dist/bin/forgemap.mjs +538 -176
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +3 -3
package/dist/bin/forgemap.mjs
CHANGED
|
@@ -175,36 +175,186 @@ async function loadForgeMapConfig(options = {}) {
|
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
//#endregion
|
|
178
|
+
//#region src/utils/concurrency.ts
|
|
179
|
+
/**
|
|
180
|
+
* Map over `items` running at most `limit` calls of `fn` at once, preserving
|
|
181
|
+
* input order in the result. Keeps `import` from spawning one subprocess per
|
|
182
|
+
* repo all at once when checking remotes across a large tree.
|
|
183
|
+
*/
|
|
184
|
+
async function mapLimit(items, limit, fn) {
|
|
185
|
+
const results = Array.from({ length: items.length });
|
|
186
|
+
const max = Math.max(1, Math.min(limit, items.length));
|
|
187
|
+
let next = 0;
|
|
188
|
+
async function worker() {
|
|
189
|
+
while (next < items.length) {
|
|
190
|
+
const index = next++;
|
|
191
|
+
results[index] = await fn(items[index], index);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
await Promise.all(Array.from({ length: max }, () => worker()));
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The entry that marks a directory as a repo. A **file** counts as much as a
|
|
199
|
+
* directory: linked worktrees and submodules record their git dir in a `.git`
|
|
200
|
+
* file, and an `isDirectory()` test would skip them silently.
|
|
201
|
+
*/
|
|
202
|
+
var GIT_MARKER = ".git";
|
|
203
|
+
/**
|
|
204
|
+
* How many namespace segments a forge type accepts. GitHub, Gitea and Codeberg
|
|
205
|
+
* have exactly one level of owner; GitLab nests arbitrarily, and `git` is the
|
|
206
|
+
* documented fallback for a GitLab-shaped remote, so it nests too.
|
|
207
|
+
*/
|
|
208
|
+
function namespaceDepthLimit(type) {
|
|
209
|
+
switch (type) {
|
|
210
|
+
case "gitlab":
|
|
211
|
+
case "git": return 9;
|
|
212
|
+
case "github":
|
|
213
|
+
case "gitea":
|
|
214
|
+
case "codeberg": return 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Check a parsed namespace against the forge it was resolved to. Returns an
|
|
219
|
+
* error message, or `null` when the depth is acceptable.
|
|
220
|
+
*
|
|
221
|
+
* This lives here rather than in `parseSlug` on purpose: the parser has no
|
|
222
|
+
* forge, and keeping it pure is worth more than an earlier error message.
|
|
223
|
+
*/
|
|
224
|
+
function checkNamespaceDepth(forgeName, type, namespace) {
|
|
225
|
+
const depth = namespace.split("/").filter(Boolean).length;
|
|
226
|
+
const limit = namespaceDepthLimit(type);
|
|
227
|
+
if (depth <= limit) return null;
|
|
228
|
+
if (limit === 1) return `Forge "${forgeName}" (type ${type}) does not support nested namespaces: "${namespace}" has ${depth} segments, expected 1.`;
|
|
229
|
+
return `Namespace "${namespace}" is ${depth} segments deep; forgemap supports at most ${limit}.`;
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
178
232
|
//#region src/repos/scan.ts
|
|
179
|
-
|
|
233
|
+
var WALK_CONCURRENCY = 32;
|
|
234
|
+
async function readEntries$1(path) {
|
|
180
235
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
236
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
237
|
+
return {
|
|
238
|
+
dirs: entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name),
|
|
239
|
+
isRepo: entries.some((e) => e.name === GIT_MARKER)
|
|
240
|
+
};
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
185
243
|
}
|
|
186
244
|
}
|
|
187
|
-
async function
|
|
245
|
+
async function safeStat(path) {
|
|
246
|
+
try {
|
|
247
|
+
const s = await stat(path);
|
|
248
|
+
return Math.trunc(s.mtimeMs);
|
|
249
|
+
} catch {
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* One rule for every forge type: a directory holding a `.git` entry **is** a
|
|
255
|
+
* repo, and everything above it is namespace. The walk stops at the first
|
|
256
|
+
* marker and never descends into a repo, which keeps submodules and nested
|
|
257
|
+
* checkouts out without a special case for either.
|
|
258
|
+
*
|
|
259
|
+
* `segments` is the path accumulated below the forge dir; the repo takes the
|
|
260
|
+
* last one and the namespace the rest, so a repo needs at least two.
|
|
261
|
+
*
|
|
262
|
+
* The cap counts those segments inclusive of the repo's own — a namespace at
|
|
263
|
+
* exactly MAX_NAMESPACE_DEPTH must still have its repo visited, or the
|
|
264
|
+
* resolver would accept a path the scanner can never find.
|
|
265
|
+
*/
|
|
266
|
+
async function walk(ctx, dirPath, segments) {
|
|
267
|
+
if (segments.length > 10) {
|
|
268
|
+
ctx.hints.push({
|
|
269
|
+
path: dirPath,
|
|
270
|
+
reason: "too-deep"
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const entries = await readEntries$1(dirPath);
|
|
275
|
+
if (!entries) {
|
|
276
|
+
if (segments.length === 0) ctx.entries.push([dirPath, "d:0:[]"]);
|
|
277
|
+
else ctx.hints.push({
|
|
278
|
+
path: dirPath,
|
|
279
|
+
reason: "no-repo"
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (entries.isRepo) {
|
|
284
|
+
ctx.entries.push([dirPath, "r"]);
|
|
285
|
+
if (segments.length < 2) {
|
|
286
|
+
ctx.hints.push({
|
|
287
|
+
path: dirPath,
|
|
288
|
+
reason: "missing-namespace"
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const owner = segments.slice(0, -1).join("/");
|
|
293
|
+
const repo = segments.at(-1);
|
|
294
|
+
ctx.repos.push({
|
|
295
|
+
forgeName: ctx.forgeName,
|
|
296
|
+
forge: ctx.forge,
|
|
297
|
+
owner,
|
|
298
|
+
repo,
|
|
299
|
+
localPath: dirPath,
|
|
300
|
+
slug: `${owner}/${repo}`
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const names = [...entries.dirs].sort();
|
|
305
|
+
const mtime = await safeStat(dirPath);
|
|
306
|
+
ctx.entries.push([dirPath, `d:${mtime}:${JSON.stringify(names)}`]);
|
|
307
|
+
if (names.length === 0) {
|
|
308
|
+
if (segments.length > 0) ctx.hints.push({
|
|
309
|
+
path: dirPath,
|
|
310
|
+
reason: "no-repo"
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
await mapLimit(names, WALK_CONCURRENCY, (name) => walk(ctx, join(dirPath, name), [...segments, name]));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Walk the configured layout once, producing the repos, the branches that
|
|
318
|
+
* yielded none, and a fingerprint of what was observed.
|
|
319
|
+
*
|
|
320
|
+
* The fingerprint records each namespace directory's mtime **and** its sorted
|
|
321
|
+
* child names, plus which directories turned out to be repos. The names are
|
|
322
|
+
* what make it reliable: mtimes compare at millisecond granularity, so two
|
|
323
|
+
* clones landing inside the same millisecond hash identically and a stale
|
|
324
|
+
* cache would win. Recording the repo classification is what catches a plain
|
|
325
|
+
* directory becoming a checkout (`git init`) without any name moving at all.
|
|
326
|
+
*/
|
|
327
|
+
async function scanLayout(options) {
|
|
188
328
|
const { config, configDir } = options;
|
|
189
329
|
const root = resolveRoot(config.root, configDir);
|
|
330
|
+
const contexts = await mapLimit(Object.entries(config.forges), WALK_CONCURRENCY, async ([forgeName, forge]) => {
|
|
331
|
+
const ctx = {
|
|
332
|
+
forgeName,
|
|
333
|
+
forge,
|
|
334
|
+
repos: [],
|
|
335
|
+
hints: [],
|
|
336
|
+
entries: []
|
|
337
|
+
};
|
|
338
|
+
await walk(ctx, join(root, forge.dir), []);
|
|
339
|
+
return ctx;
|
|
340
|
+
});
|
|
341
|
+
const entries = [[root, `m:${await safeStat(root)}`]];
|
|
190
342
|
const repos = [];
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const repoNames = await listDirs$1(ownerPath);
|
|
197
|
-
for (const repo of repoNames) repos.push({
|
|
198
|
-
forgeName,
|
|
199
|
-
forge,
|
|
200
|
-
owner,
|
|
201
|
-
repo,
|
|
202
|
-
localPath: join(ownerPath, repo),
|
|
203
|
-
slug: `${owner}/${repo}`
|
|
204
|
-
});
|
|
205
|
-
}
|
|
343
|
+
const hints = [];
|
|
344
|
+
for (const ctx of contexts) {
|
|
345
|
+
repos.push(...ctx.repos);
|
|
346
|
+
hints.push(...ctx.hints);
|
|
347
|
+
entries.push(...ctx.entries);
|
|
206
348
|
}
|
|
207
|
-
|
|
349
|
+
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
350
|
+
return {
|
|
351
|
+
repos,
|
|
352
|
+
hints,
|
|
353
|
+
fingerprint: createHash("sha1").update(entries.map(([p, marker]) => `${p}:${marker}`).join("\n")).digest("hex")
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
async function scanRepos(options) {
|
|
357
|
+
return (await scanLayout(options)).repos;
|
|
208
358
|
}
|
|
209
359
|
//#endregion
|
|
210
360
|
//#region src/repos/cache.ts
|
|
@@ -223,54 +373,18 @@ function cachePath(root) {
|
|
|
223
373
|
const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
|
|
224
374
|
return join(cacheDir(), `scan-${hash}.json`);
|
|
225
375
|
}
|
|
226
|
-
async function safeStat(path) {
|
|
227
|
-
try {
|
|
228
|
-
const s = await stat(path);
|
|
229
|
-
return Math.trunc(s.mtimeMs);
|
|
230
|
-
} catch {
|
|
231
|
-
return 0;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
async function safeListDirs(path) {
|
|
235
|
-
try {
|
|
236
|
-
return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
|
|
237
|
-
} catch {
|
|
238
|
-
return [];
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
376
|
/**
|
|
242
|
-
* Fingerprint of the layout
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* The repo names are what make the check reliable. An owner's mtime
|
|
248
|
-
* alone is not enough: a repo cloned beside an existing one moves only
|
|
249
|
-
* that mtime, and mtimes are compared at millisecond granularity, so
|
|
250
|
-
* two clones landing inside the same millisecond hash identically and
|
|
251
|
-
* a stale cache wins. Listing the names makes invalidation independent
|
|
252
|
-
* of clock and filesystem timestamp resolution.
|
|
253
|
-
*
|
|
254
|
-
* Stats are issued in parallel: one batch per forge for its forge.dir +
|
|
255
|
-
* owner list, all forges in parallel. Beats the sequential version by
|
|
256
|
-
* an order of magnitude at thousands of owners.
|
|
377
|
+
* Fingerprint of the layout, computed by the same walk that finds the repos —
|
|
378
|
+
* see {@link scanLayout}, which owns what goes into it. Since a directory is
|
|
379
|
+
* only known to be a repo once its entries have been read, the fingerprint
|
|
380
|
+
* cannot be cheaper than the scan; producing both from one walk is what keeps
|
|
381
|
+
* the cold path from paying for the tree twice.
|
|
257
382
|
*/
|
|
258
383
|
async function computeFingerprint(config, configDir) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const ownerEntries = await Promise.all(owners.map(async (owner) => {
|
|
264
|
-
const ownerPath = join(forgeRoot, owner);
|
|
265
|
-
const [mtime, repos] = await Promise.all([safeStat(ownerPath), safeListDirs(ownerPath)]);
|
|
266
|
-
return [ownerPath, `${mtime}:${JSON.stringify(repos.sort())}`];
|
|
267
|
-
}));
|
|
268
|
-
return [[forgeRoot, String(forgeMtime)], ...ownerEntries];
|
|
269
|
-
}));
|
|
270
|
-
const entries = [[root, String(await safeStat(root))]];
|
|
271
|
-
for (const group of perForge) entries.push(...group);
|
|
272
|
-
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
273
|
-
return createHash("sha1").update(entries.map(([p, marker]) => `${p}:${marker}`).join("\n")).digest("hex");
|
|
384
|
+
return (await scanLayout({
|
|
385
|
+
config,
|
|
386
|
+
configDir
|
|
387
|
+
})).fingerprint;
|
|
274
388
|
}
|
|
275
389
|
async function readCacheFile(file) {
|
|
276
390
|
try {
|
|
@@ -292,26 +406,35 @@ async function scanReposCached(options) {
|
|
|
292
406
|
if (cached) {
|
|
293
407
|
const age = Date.now() - cached.writtenAt;
|
|
294
408
|
if (trustTtl && age < ttl()) return cached.repos;
|
|
295
|
-
const
|
|
296
|
-
|
|
409
|
+
const scan = await scanLayout({
|
|
410
|
+
config,
|
|
411
|
+
configDir
|
|
412
|
+
});
|
|
413
|
+
if (cached.fingerprint === scan.fingerprint) {
|
|
297
414
|
await writeCacheFile(file, {
|
|
298
415
|
...cached,
|
|
299
416
|
writtenAt: Date.now()
|
|
300
417
|
});
|
|
301
418
|
return cached.repos;
|
|
302
419
|
}
|
|
420
|
+
await writeCacheFile(file, {
|
|
421
|
+
fingerprint: scan.fingerprint,
|
|
422
|
+
writtenAt: Date.now(),
|
|
423
|
+
repos: scan.repos
|
|
424
|
+
});
|
|
425
|
+
return scan.repos;
|
|
303
426
|
}
|
|
304
427
|
}
|
|
305
|
-
const
|
|
428
|
+
const scan = await scanLayout({
|
|
306
429
|
config,
|
|
307
430
|
configDir
|
|
308
431
|
});
|
|
309
432
|
await writeCacheFile(file, {
|
|
310
|
-
fingerprint:
|
|
433
|
+
fingerprint: scan.fingerprint,
|
|
311
434
|
writtenAt: Date.now(),
|
|
312
|
-
repos
|
|
435
|
+
repos: scan.repos
|
|
313
436
|
});
|
|
314
|
-
return repos;
|
|
437
|
+
return scan.repos;
|
|
315
438
|
}
|
|
316
439
|
/**
|
|
317
440
|
* Append a freshly-cloned repo to the cache without touching the
|
|
@@ -348,9 +471,15 @@ async function removeCachedRepo(options, localPath) {
|
|
|
348
471
|
}
|
|
349
472
|
//#endregion
|
|
350
473
|
//#region src/utils/exec.ts
|
|
351
|
-
function execInherit(command, args) {
|
|
474
|
+
function execInherit(command, args, options = {}) {
|
|
352
475
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
353
|
-
const child = spawn(command, args, {
|
|
476
|
+
const child = spawn(command, args, {
|
|
477
|
+
env: options.env ? {
|
|
478
|
+
...process.env,
|
|
479
|
+
...options.env
|
|
480
|
+
} : void 0,
|
|
481
|
+
stdio: "inherit"
|
|
482
|
+
});
|
|
354
483
|
child.on("error", rejectPromise);
|
|
355
484
|
child.on("close", (code) => {
|
|
356
485
|
resolvePromise({ code: code ?? 0 });
|
|
@@ -476,33 +605,13 @@ function isRepoMissing(stderr) {
|
|
|
476
605
|
return /repository not found/.test(s) || /remote:.*not found/.test(s) || /\b404\b/.test(s) || /could not find repository/.test(s);
|
|
477
606
|
}
|
|
478
607
|
//#endregion
|
|
479
|
-
//#region src/utils/concurrency.ts
|
|
480
|
-
/**
|
|
481
|
-
* Map over `items` running at most `limit` calls of `fn` at once, preserving
|
|
482
|
-
* input order in the result. Keeps `import` from spawning one subprocess per
|
|
483
|
-
* repo all at once when checking remotes across a large tree.
|
|
484
|
-
*/
|
|
485
|
-
async function mapLimit(items, limit, fn) {
|
|
486
|
-
const results = Array.from({ length: items.length });
|
|
487
|
-
const max = Math.max(1, Math.min(limit, items.length));
|
|
488
|
-
let next = 0;
|
|
489
|
-
async function worker() {
|
|
490
|
-
while (next < items.length) {
|
|
491
|
-
const index = next++;
|
|
492
|
-
results[index] = await fn(items[index], index);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
await Promise.all(Array.from({ length: max }, () => worker()));
|
|
496
|
-
return results;
|
|
497
|
-
}
|
|
498
|
-
//#endregion
|
|
499
608
|
//#region src/forges/github.ts
|
|
500
|
-
var GRAPHQL_CHUNK = 100;
|
|
501
|
-
var FALLBACK_CONCURRENCY = 8;
|
|
609
|
+
var GRAPHQL_CHUNK$1 = 100;
|
|
610
|
+
var FALLBACK_CONCURRENCY$1 = 8;
|
|
502
611
|
var GH_TIMEOUT_MS = 2e4;
|
|
503
612
|
/** Single-repo REST check. `gh api` follows the redirect a renamed/transferred
|
|
504
613
|
* repo issues, so the returned full_name reveals the canonical owner/repo. */
|
|
505
|
-
async function checkOne(owner, repo) {
|
|
614
|
+
async function checkOne$1(owner, repo) {
|
|
506
615
|
const result = await execCapture("gh", [
|
|
507
616
|
"api",
|
|
508
617
|
`repos/${owner}/${repo}`,
|
|
@@ -539,7 +648,7 @@ async function checkOne(owner, repo) {
|
|
|
539
648
|
canonicalUrl: `https://github.com/${canonicalOwner}/${canonicalRepo}.git`
|
|
540
649
|
};
|
|
541
650
|
}
|
|
542
|
-
function buildQuery(chunk) {
|
|
651
|
+
function buildQuery$1(chunk) {
|
|
543
652
|
return `query {\n${chunk.map((input, i) => ` r${i}: repository(owner: ${JSON.stringify(input.owner)}, name: ${JSON.stringify(input.repo)}) { nameWithOwner }`).join("\n")}\n}`;
|
|
544
653
|
}
|
|
545
654
|
var githubAdapter = {
|
|
@@ -558,7 +667,7 @@ var githubAdapter = {
|
|
|
558
667
|
state: "unknown",
|
|
559
668
|
reason: "gh not installed"
|
|
560
669
|
};
|
|
561
|
-
return checkOne(owner, repo);
|
|
670
|
+
return checkOne$1(owner, repo);
|
|
562
671
|
},
|
|
563
672
|
/**
|
|
564
673
|
* One GraphQL request resolves up to GRAPHQL_CHUNK repos at once. GraphQL
|
|
@@ -573,13 +682,13 @@ var githubAdapter = {
|
|
|
573
682
|
reason: "gh not installed"
|
|
574
683
|
}));
|
|
575
684
|
const results = Array.from({ length: inputs.length }, () => null);
|
|
576
|
-
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK) {
|
|
577
|
-
const chunk = inputs.slice(start, start + GRAPHQL_CHUNK);
|
|
685
|
+
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK$1) {
|
|
686
|
+
const chunk = inputs.slice(start, start + GRAPHQL_CHUNK$1);
|
|
578
687
|
const res = await execCapture("gh", [
|
|
579
688
|
"api",
|
|
580
689
|
"graphql",
|
|
581
690
|
"-f",
|
|
582
|
-
`query=${buildQuery(chunk)}`
|
|
691
|
+
`query=${buildQuery$1(chunk)}`
|
|
583
692
|
], { timeoutMs: GH_TIMEOUT_MS });
|
|
584
693
|
let data = null;
|
|
585
694
|
try {
|
|
@@ -601,8 +710,153 @@ var githubAdapter = {
|
|
|
601
710
|
}
|
|
602
711
|
}
|
|
603
712
|
}
|
|
713
|
+
await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY$1, async (index) => {
|
|
714
|
+
results[index] = await checkOne$1(inputs[index].owner, inputs[index].repo);
|
|
715
|
+
});
|
|
716
|
+
return results;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/forges/gitlab.ts
|
|
721
|
+
var GRAPHQL_CHUNK = 100;
|
|
722
|
+
var FALLBACK_CONCURRENCY = 8;
|
|
723
|
+
var GLAB_TIMEOUT_MS = 2e4;
|
|
724
|
+
var MISSING_GLAB = "GitLab CLI (`glab`) is not installed. Install it from https://gitlab.com/gitlab-org/cli and run `glab auth login`.";
|
|
725
|
+
/**
|
|
726
|
+
* Pin every invocation to the forge's own host. `glab repo clone` has no
|
|
727
|
+
* `--hostname` flag, and leaning on the user's global `glab config set host`
|
|
728
|
+
* would make behaviour depend on state forgemap never set.
|
|
729
|
+
*/
|
|
730
|
+
function glabEnv(forge) {
|
|
731
|
+
return { GITLAB_HOST: forge.host };
|
|
732
|
+
}
|
|
733
|
+
/** Split GitLab's `full path` into namespace and project. */
|
|
734
|
+
function splitFullPath(fullPath) {
|
|
735
|
+
const segments = fullPath.split("/").filter(Boolean);
|
|
736
|
+
if (segments.length < 2) return null;
|
|
737
|
+
return {
|
|
738
|
+
owner: segments.slice(0, -1).join("/"),
|
|
739
|
+
repo: segments.at(-1)
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Single-project REST check. `projects/<url-encoded path>` answers with
|
|
744
|
+
* `path_with_namespace`, GitLab's counterpart to GitHub's `full_name`, so a
|
|
745
|
+
* differing answer is a move and a 404 is a deletion.
|
|
746
|
+
*/
|
|
747
|
+
async function checkOne(forge, owner, repo) {
|
|
748
|
+
const result = await execCapture("glab", ["api", `projects/${encodeURIComponent(`${owner}/${repo}`)}`], {
|
|
749
|
+
timeoutMs: GLAB_TIMEOUT_MS,
|
|
750
|
+
env: glabEnv(forge)
|
|
751
|
+
});
|
|
752
|
+
if (result.timedOut) return {
|
|
753
|
+
state: "unknown",
|
|
754
|
+
reason: "glab api timed out"
|
|
755
|
+
};
|
|
756
|
+
if (result.code !== 0) {
|
|
757
|
+
if (/404|not found/i.test(result.stderr)) return { state: "gone" };
|
|
758
|
+
return {
|
|
759
|
+
state: "unknown",
|
|
760
|
+
reason: result.stderr.trim() || `glab api exited with code ${result.code}`
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
let fullPath;
|
|
764
|
+
try {
|
|
765
|
+
fullPath = JSON.parse(result.stdout).path_with_namespace;
|
|
766
|
+
} catch {
|
|
767
|
+
fullPath = void 0;
|
|
768
|
+
}
|
|
769
|
+
const canonical = fullPath ? splitFullPath(fullPath) : null;
|
|
770
|
+
if (!canonical) return {
|
|
771
|
+
state: "unknown",
|
|
772
|
+
reason: "could not parse glab api path_with_namespace"
|
|
773
|
+
};
|
|
774
|
+
if (canonical.owner === owner && canonical.repo === repo) return {
|
|
775
|
+
state: "exists",
|
|
776
|
+
canonical
|
|
777
|
+
};
|
|
778
|
+
return {
|
|
779
|
+
state: "moved",
|
|
780
|
+
canonical,
|
|
781
|
+
canonicalUrl: `https://${forge.host}/${canonical.owner}/${canonical.repo}.git`
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function buildQuery(chunk) {
|
|
785
|
+
return `query {\n${chunk.map((input, i) => ` r${i}: project(fullPath: ${JSON.stringify(`${input.owner}/${input.repo}`)}) { fullPath }`).join("\n")}\n}`;
|
|
786
|
+
}
|
|
787
|
+
var gitlabAdapter = {
|
|
788
|
+
async clone({ forge, owner, repo, dest }) {
|
|
789
|
+
if (!await hasCommand("glab")) throw new Error(MISSING_GLAB);
|
|
790
|
+
const { code } = await execInherit("glab", [
|
|
791
|
+
"repo",
|
|
792
|
+
"clone",
|
|
793
|
+
`${owner}/${repo}`,
|
|
794
|
+
dest
|
|
795
|
+
], { env: glabEnv(forge) });
|
|
796
|
+
if (code !== 0) throw new Error(`glab repo clone exited with code ${code}`);
|
|
797
|
+
},
|
|
798
|
+
async checkRemote({ forge, owner, repo }) {
|
|
799
|
+
if (!await hasCommand("glab")) return {
|
|
800
|
+
state: "unknown",
|
|
801
|
+
reason: "glab not installed"
|
|
802
|
+
};
|
|
803
|
+
return checkOne(forge, owner, repo);
|
|
804
|
+
},
|
|
805
|
+
/**
|
|
806
|
+
* Mirrors the GitHub adapter: one aliased GraphQL request resolves up to
|
|
807
|
+
* GRAPHQL_CHUNK projects, and each miss — `null` could mean gone *or*
|
|
808
|
+
* renamed — costs a single REST call to tell the two apart.
|
|
809
|
+
*
|
|
810
|
+
* A batch may span several configured GitLab forges, so the inputs are
|
|
811
|
+
* grouped by host before they are chunked: one server must never be asked
|
|
812
|
+
* about another's projects. Where the same full path exists on both — a
|
|
813
|
+
* public mirror of an internal project — the wrong server would otherwise
|
|
814
|
+
* answer `exists` for a project this one does not have.
|
|
815
|
+
*/
|
|
816
|
+
async checkRemotes(inputs) {
|
|
817
|
+
if (inputs.length === 0) return [];
|
|
818
|
+
if (!await hasCommand("glab")) return inputs.map(() => ({
|
|
819
|
+
state: "unknown",
|
|
820
|
+
reason: "glab not installed"
|
|
821
|
+
}));
|
|
822
|
+
const results = Array.from({ length: inputs.length }, () => null);
|
|
823
|
+
const byHost = /* @__PURE__ */ new Map();
|
|
824
|
+
inputs.forEach((input, index) => {
|
|
825
|
+
const indices = byHost.get(input.forge.host);
|
|
826
|
+
if (indices) indices.push(index);
|
|
827
|
+
else byHost.set(input.forge.host, [index]);
|
|
828
|
+
});
|
|
829
|
+
for (const indices of byHost.values()) for (let start = 0; start < indices.length; start += GRAPHQL_CHUNK) {
|
|
830
|
+
const slice = indices.slice(start, start + GRAPHQL_CHUNK);
|
|
831
|
+
const chunk = slice.map((index) => inputs[index]);
|
|
832
|
+
const res = await execCapture("glab", [
|
|
833
|
+
"api",
|
|
834
|
+
"graphql",
|
|
835
|
+
"-f",
|
|
836
|
+
`query=${buildQuery(chunk)}`
|
|
837
|
+
], {
|
|
838
|
+
timeoutMs: GLAB_TIMEOUT_MS,
|
|
839
|
+
env: glabEnv(chunk[0].forge)
|
|
840
|
+
});
|
|
841
|
+
let data = null;
|
|
842
|
+
try {
|
|
843
|
+
const parsed = JSON.parse(res.stdout);
|
|
844
|
+
data = parsed.data ?? parsed;
|
|
845
|
+
} catch {
|
|
846
|
+
data = null;
|
|
847
|
+
}
|
|
848
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
849
|
+
const node = data?.[`r${i}`];
|
|
850
|
+
const canonical = node?.fullPath ? splitFullPath(node.fullPath) : null;
|
|
851
|
+
if (canonical) results[slice[i]] = {
|
|
852
|
+
state: "exists",
|
|
853
|
+
canonical
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
604
857
|
await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY, async (index) => {
|
|
605
|
-
|
|
858
|
+
const input = inputs[index];
|
|
859
|
+
results[index] = await checkOne(input.forge, input.owner, input.repo);
|
|
606
860
|
});
|
|
607
861
|
return results;
|
|
608
862
|
}
|
|
@@ -612,8 +866,8 @@ var githubAdapter = {
|
|
|
612
866
|
function getForgeAdapter(type) {
|
|
613
867
|
switch (type) {
|
|
614
868
|
case "github": return githubAdapter;
|
|
869
|
+
case "gitlab": return gitlabAdapter;
|
|
615
870
|
case "git": return gitAdapter;
|
|
616
|
-
case "gitlab":
|
|
617
871
|
case "gitea":
|
|
618
872
|
case "codeberg": throw new Error(`Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`);
|
|
619
873
|
default: throw new Error(`Unknown forge type: ${String(type)}`);
|
|
@@ -621,21 +875,29 @@ function getForgeAdapter(type) {
|
|
|
621
875
|
}
|
|
622
876
|
//#endregion
|
|
623
877
|
//#region src/slug/parse.ts
|
|
624
|
-
var
|
|
625
|
-
var
|
|
626
|
-
var
|
|
878
|
+
var SEGMENT = String.raw`[\w.-]+`;
|
|
879
|
+
var SHORT_RE = new RegExp(`^(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
|
|
880
|
+
var NAMED_RE = new RegExp(`^(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
|
|
881
|
+
var SSH_RE = new RegExp(`^git@(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT}?)(?:\\.git)?$`);
|
|
627
882
|
function stripGitSuffix(repo) {
|
|
628
883
|
return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
|
|
629
884
|
}
|
|
630
885
|
/**
|
|
886
|
+
* Split `<namespace…>/<repo>` into its two halves: the **last** segment is the
|
|
887
|
+
* repo, everything before it the namespace. Collapses to today's result
|
|
888
|
+
* whenever there are exactly two segments.
|
|
889
|
+
*/
|
|
890
|
+
function splitPath(segments) {
|
|
891
|
+
return {
|
|
892
|
+
owner: segments.slice(0, -1).join("/"),
|
|
893
|
+
repo: stripGitSuffix(segments.at(-1))
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
631
897
|
* Whether the input is *shaped* like a strict slug. Every form
|
|
632
|
-
* {@link parseSlug} accepts — `
|
|
633
|
-
* URL — contains a `/`, so a bare term like `gild` can never be one and is
|
|
898
|
+
* {@link parseSlug} accepts — `namespace/repo`, `forge:namespace/repo`, SSH
|
|
899
|
+
* and URL — contains a `/`, so a bare term like `gild` can never be one and is
|
|
634
900
|
* free to be treated as a fuzzy query instead.
|
|
635
|
-
*
|
|
636
|
-
* Shaped-like is deliberately not the same as valid: `foo/bar/baz` is shaped
|
|
637
|
-
* like a slug, so it stays a hard parse error rather than silently degrading
|
|
638
|
-
* into a fuzzy search for something the user clearly meant as a slug.
|
|
639
901
|
*/
|
|
640
902
|
function looksLikeSlug(input) {
|
|
641
903
|
return input.trim().includes("/");
|
|
@@ -646,8 +908,7 @@ function parseSlug(input) {
|
|
|
646
908
|
const ssh = SSH_RE.exec(trimmed);
|
|
647
909
|
if (ssh) return {
|
|
648
910
|
host: ssh[1],
|
|
649
|
-
|
|
650
|
-
repo: stripGitSuffix(ssh[3])
|
|
911
|
+
...splitPath([...ssh[2].split("/"), ssh[3]])
|
|
651
912
|
};
|
|
652
913
|
if (/^https?:\/\//.test(trimmed)) {
|
|
653
914
|
let url;
|
|
@@ -656,25 +917,22 @@ function parseSlug(input) {
|
|
|
656
917
|
} catch {
|
|
657
918
|
throw new Error(`Invalid URL: ${trimmed}`);
|
|
658
919
|
}
|
|
659
|
-
|
|
660
|
-
|
|
920
|
+
let segments = url.pathname.split("/").filter(Boolean);
|
|
921
|
+
const separator = segments.indexOf("-");
|
|
922
|
+
if (separator !== -1) segments = segments.slice(0, separator);
|
|
923
|
+
if (segments.length < 2) throw new Error(`URL must contain a namespace and repo: ${trimmed}`);
|
|
661
924
|
return {
|
|
662
925
|
host: url.host,
|
|
663
|
-
|
|
664
|
-
repo: stripGitSuffix(segments[1])
|
|
926
|
+
...splitPath(segments)
|
|
665
927
|
};
|
|
666
928
|
}
|
|
667
929
|
const named = NAMED_RE.exec(trimmed);
|
|
668
930
|
if (named) return {
|
|
669
931
|
forgeName: named[1],
|
|
670
|
-
|
|
671
|
-
repo: stripGitSuffix(named[3])
|
|
932
|
+
...splitPath([...named[2].split("/"), named[3]])
|
|
672
933
|
};
|
|
673
934
|
const short = SHORT_RE.exec(trimmed);
|
|
674
|
-
if (short) return
|
|
675
|
-
owner: short[1],
|
|
676
|
-
repo: stripGitSuffix(short[2])
|
|
677
|
-
};
|
|
935
|
+
if (short) return splitPath([...short[1].split("/"), short[2]]);
|
|
678
936
|
throw new Error(`Unrecognized slug format: ${input}`);
|
|
679
937
|
}
|
|
680
938
|
//#endregion
|
|
@@ -958,16 +1216,31 @@ function remoteBlocker(state) {
|
|
|
958
1216
|
if (state === "exists" || state === "moved") return null;
|
|
959
1217
|
return state === "gone" ? "remote no longer exists" : "remote unreachable";
|
|
960
1218
|
}
|
|
961
|
-
/**
|
|
1219
|
+
/**
|
|
1220
|
+
* Check each candidate's remote, grouped by forge **type and host** so an
|
|
1221
|
+
* adapter can batch.
|
|
1222
|
+
*
|
|
1223
|
+
* The host is half the key, not a detail: two configured forges of one type —
|
|
1224
|
+
* gitlab.com beside a self-hosted instance — are two different servers, and a
|
|
1225
|
+
* batch spanning both would ask one of them about the other's projects. Where
|
|
1226
|
+
* the same path exists on each (a public mirror of an internal project), that
|
|
1227
|
+
* answers `exists` for the wrong server, and `remoteBlocker` reads `exists` as
|
|
1228
|
+
* "safe to delete".
|
|
1229
|
+
*/
|
|
962
1230
|
async function classifyRemotes(candidates) {
|
|
963
|
-
const
|
|
1231
|
+
const groups = /* @__PURE__ */ new Map();
|
|
964
1232
|
for (const c of candidates) {
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
1233
|
+
const { type, host } = c.repo.forge;
|
|
1234
|
+
const key = `${type}\0${host}`;
|
|
1235
|
+
const group = groups.get(key);
|
|
1236
|
+
if (group) group.items.push(c);
|
|
1237
|
+
else groups.set(key, {
|
|
1238
|
+
type,
|
|
1239
|
+
items: [c]
|
|
1240
|
+
});
|
|
968
1241
|
}
|
|
969
1242
|
const results = /* @__PURE__ */ new Map();
|
|
970
|
-
await Promise.all(Array.from(
|
|
1243
|
+
await Promise.all(Array.from(groups.values(), async ({ type, items }) => {
|
|
971
1244
|
const inputs = items.map((c) => ({
|
|
972
1245
|
forge: c.repo.forge,
|
|
973
1246
|
owner: c.owner,
|
|
@@ -1013,35 +1286,43 @@ async function classifyRemotes(candidates) {
|
|
|
1013
1286
|
}));
|
|
1014
1287
|
return results;
|
|
1015
1288
|
}
|
|
1016
|
-
|
|
1289
|
+
/**
|
|
1290
|
+
* Collect the empty namespace directories at and below `path`, deepest first,
|
|
1291
|
+
* and report whether `path` itself turned out to be one.
|
|
1292
|
+
*
|
|
1293
|
+
* A namespace is empty when it holds nothing, or holds only namespaces that
|
|
1294
|
+
* are themselves empty — which is what makes this follow a nested layout
|
|
1295
|
+
* rather than the single owner level it used to assume. The walk never enters
|
|
1296
|
+
* a repo: a `.git` entry means the directory is a checkout, and a checkout is
|
|
1297
|
+
* never a leftover however empty its subdirectories are.
|
|
1298
|
+
*/
|
|
1299
|
+
async function collectEmptyDirs(path, depth, empties) {
|
|
1300
|
+
if (depth > 10) return false;
|
|
1301
|
+
let entries;
|
|
1017
1302
|
try {
|
|
1018
|
-
|
|
1303
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
1019
1304
|
} catch {
|
|
1020
|
-
return
|
|
1305
|
+
return false;
|
|
1021
1306
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1307
|
+
if (entries.some((e) => e.name === ".git")) return false;
|
|
1308
|
+
if (entries.length === 0) {
|
|
1309
|
+
empties.push(path);
|
|
1310
|
+
return true;
|
|
1311
|
+
}
|
|
1312
|
+
if (entries.some((e) => !e.isDirectory())) return false;
|
|
1313
|
+
let allEmpty = true;
|
|
1314
|
+
for (const entry of entries) if (!await collectEmptyDirs(join(path, entry.name), depth + 1, empties)) allEmpty = false;
|
|
1315
|
+
if (allEmpty) empties.push(path);
|
|
1316
|
+
return allEmpty;
|
|
1317
|
+
}
|
|
1318
|
+
/** Empty namespace directories (server dir included) under the configured
|
|
1319
|
+
* forge dirs, deepest first. Detection only — no removal. */
|
|
1025
1320
|
async function findEmptyDirs(root, config) {
|
|
1026
1321
|
const empties = [];
|
|
1027
|
-
for (const forge of Object.values(config.forges))
|
|
1028
|
-
const serverPath = join(root, forge.dir);
|
|
1029
|
-
const owners = await safeReaddir(serverPath);
|
|
1030
|
-
if (owners === null) continue;
|
|
1031
|
-
let emptyCount = 0;
|
|
1032
|
-
for (const owner of owners) {
|
|
1033
|
-
const ownerPath = join(serverPath, owner);
|
|
1034
|
-
const inner = await safeReaddir(ownerPath);
|
|
1035
|
-
if (inner !== null && inner.length === 0) {
|
|
1036
|
-
empties.push(ownerPath);
|
|
1037
|
-
emptyCount++;
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
if (owners.length === 0 || emptyCount === owners.length) empties.push(serverPath);
|
|
1041
|
-
}
|
|
1322
|
+
for (const forge of Object.values(config.forges)) await collectEmptyDirs(join(root, forge.dir), 0, empties);
|
|
1042
1323
|
return empties;
|
|
1043
1324
|
}
|
|
1044
|
-
/** Remove the dirs from findEmptyDirs (
|
|
1325
|
+
/** Remove the dirs from findEmptyDirs (children before their parents). */
|
|
1045
1326
|
async function pruneEmptyDirs(root, config) {
|
|
1046
1327
|
const empties = await findEmptyDirs(root, config);
|
|
1047
1328
|
let removed = 0;
|
|
@@ -1233,6 +1514,8 @@ function resolveSlug(parsed, options) {
|
|
|
1233
1514
|
forgeName = config.defaultForge;
|
|
1234
1515
|
forge = candidate;
|
|
1235
1516
|
}
|
|
1517
|
+
const depthError = checkNamespaceDepth(forgeName, forge.type, parsed.owner);
|
|
1518
|
+
if (depthError) throw new Error(depthError);
|
|
1236
1519
|
const localPath = join(resolveRoot(config.root, configDir), forge.dir, parsed.owner, parsed.repo);
|
|
1237
1520
|
return {
|
|
1238
1521
|
forgeName,
|
|
@@ -2254,34 +2537,51 @@ var forgeCommand = defineCommand({
|
|
|
2254
2537
|
});
|
|
2255
2538
|
//#endregion
|
|
2256
2539
|
//#region src/repos/import.ts
|
|
2257
|
-
async function
|
|
2540
|
+
async function readEntries(path) {
|
|
2258
2541
|
try {
|
|
2259
|
-
|
|
2542
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
2543
|
+
return {
|
|
2544
|
+
dirs: entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name),
|
|
2545
|
+
isRepo: entries.some((e) => e.name === GIT_MARKER)
|
|
2546
|
+
};
|
|
2260
2547
|
} catch (error) {
|
|
2261
|
-
if (error.code === "ENOENT") return
|
|
2548
|
+
if (error.code === "ENOENT") return null;
|
|
2262
2549
|
throw error;
|
|
2263
2550
|
}
|
|
2264
2551
|
}
|
|
2265
2552
|
/**
|
|
2266
|
-
* Structure-driven
|
|
2267
|
-
*
|
|
2268
|
-
*
|
|
2553
|
+
* Structure-driven walk of `<path>/<serverDir>/<namespace…>/<repo>`. Unlike
|
|
2554
|
+
* `scanRepos` this is config-free: every top-level directory is a candidate
|
|
2555
|
+
* server dir, and the names are discovered rather than configured.
|
|
2556
|
+
*
|
|
2557
|
+
* A `.git` entry ends a branch, so a nested namespace is adopted as readily as
|
|
2558
|
+
* a flat one and a repo's own subdirectories are never mistaken for more of
|
|
2559
|
+
* the layout. A branch that dead-ends without one is still surfaced — that is
|
|
2560
|
+
* the candidate `analyzeLocal` reports as `not-a-git-repo`, which is the whole
|
|
2561
|
+
* reason `import` looks at directories a scan would simply skip.
|
|
2269
2562
|
*/
|
|
2270
|
-
async function
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
}
|
|
2563
|
+
async function discoverBelow(serverDir, dirPath, segments, found) {
|
|
2564
|
+
if (segments.length > 10) return;
|
|
2565
|
+
const entries = await readEntries(dirPath);
|
|
2566
|
+
const isLeaf = entries === null || entries.dirs.length === 0;
|
|
2567
|
+
if (segments.length >= 2 && (entries?.isRepo || isLeaf)) {
|
|
2568
|
+
found.push({
|
|
2569
|
+
serverDir,
|
|
2570
|
+
owner: segments.slice(0, -1).join("/"),
|
|
2571
|
+
repo: segments.at(-1),
|
|
2572
|
+
localPath: dirPath
|
|
2573
|
+
});
|
|
2574
|
+
return;
|
|
2283
2575
|
}
|
|
2284
|
-
return
|
|
2576
|
+
if (entries === null || entries.isRepo) return;
|
|
2577
|
+
for (const name of entries.dirs) await discoverBelow(serverDir, join(dirPath, name), [...segments, name], found);
|
|
2578
|
+
}
|
|
2579
|
+
async function discoverForgemapLayout(path) {
|
|
2580
|
+
const root = await readEntries(path);
|
|
2581
|
+
if (!root) return [];
|
|
2582
|
+
const found = [];
|
|
2583
|
+
for (const serverDir of root.dirs) await discoverBelow(serverDir, join(path, serverDir), [], found);
|
|
2584
|
+
return found;
|
|
2285
2585
|
}
|
|
2286
2586
|
function forgeTypeForHost(host) {
|
|
2287
2587
|
return host === "github.com" ? "github" : "git";
|
|
@@ -2943,7 +3243,7 @@ var infoCommand = defineCommand({
|
|
|
2943
3243
|
async run({ args }) {
|
|
2944
3244
|
const binary = resolveBinary(process.argv[1]);
|
|
2945
3245
|
const info = {
|
|
2946
|
-
version: "0.
|
|
3246
|
+
version: "0.8.0",
|
|
2947
3247
|
build: detectBuild(binary.resolved),
|
|
2948
3248
|
binary,
|
|
2949
3249
|
node: process.version,
|
|
@@ -3754,6 +4054,7 @@ async function runChecks(config, configDir) {
|
|
|
3754
4054
|
const types = new Set(Object.values(config.forges).map((f) => f.type));
|
|
3755
4055
|
const needsGit = types.has("git") || types.size > 0;
|
|
3756
4056
|
const needsGh = types.has("github");
|
|
4057
|
+
const gitlabForges = Object.entries(config.forges).filter(([, forge]) => forge.type === "gitlab");
|
|
3757
4058
|
if (needsGit) checks.push(await hasCommand("git") ? {
|
|
3758
4059
|
name: "git CLI",
|
|
3759
4060
|
severity: "ok",
|
|
@@ -3784,8 +4085,69 @@ async function runChecks(config, configDir) {
|
|
|
3784
4085
|
severity: "fail",
|
|
3785
4086
|
message: "install from https://cli.github.com/"
|
|
3786
4087
|
});
|
|
4088
|
+
if (gitlabForges.length > 0) if (await hasCommand("glab")) {
|
|
4089
|
+
checks.push({
|
|
4090
|
+
name: "glab CLI",
|
|
4091
|
+
severity: "ok",
|
|
4092
|
+
message: "on PATH"
|
|
4093
|
+
});
|
|
4094
|
+
for (const [name, forge] of gitlabForges) {
|
|
4095
|
+
const auth = await execCapture("glab", [
|
|
4096
|
+
"auth",
|
|
4097
|
+
"status",
|
|
4098
|
+
"--hostname",
|
|
4099
|
+
forge.host
|
|
4100
|
+
]);
|
|
4101
|
+
checks.push(auth.code === 0 ? {
|
|
4102
|
+
name: `glab auth (${name})`,
|
|
4103
|
+
severity: "ok",
|
|
4104
|
+
message: `authenticated at ${forge.host}`
|
|
4105
|
+
} : {
|
|
4106
|
+
name: `glab auth (${name})`,
|
|
4107
|
+
severity: "warn",
|
|
4108
|
+
message: `not logged in — run \`glab auth login --hostname ${forge.host}\``
|
|
4109
|
+
});
|
|
4110
|
+
}
|
|
4111
|
+
} else checks.push({
|
|
4112
|
+
name: "glab CLI",
|
|
4113
|
+
severity: "fail",
|
|
4114
|
+
message: "install from https://gitlab.com/gitlab-org/cli"
|
|
4115
|
+
});
|
|
4116
|
+
checks.push(await layoutCheck(config, configDir));
|
|
3787
4117
|
return checks;
|
|
3788
4118
|
}
|
|
4119
|
+
var HINT_LABEL = {
|
|
4120
|
+
"no-repo": "holds no git repo",
|
|
4121
|
+
"missing-namespace": "is a repo with no namespace above it",
|
|
4122
|
+
"too-deep": `is deeper than 10 levels`
|
|
4123
|
+
};
|
|
4124
|
+
var HINTS_SHOWN = 5;
|
|
4125
|
+
/**
|
|
4126
|
+
* A repo is a directory holding a `.git` entry, so a branch that never reaches
|
|
4127
|
+
* one simply drops out of `list`, `status` and `pick`. That is easy to read as
|
|
4128
|
+
* "where did my repo go", which is exactly the question this command exists to
|
|
4129
|
+
* answer — so name the branches rather than leaving them silent. A hint, never
|
|
4130
|
+
* a failure: an odd layout is not a broken one.
|
|
4131
|
+
*/
|
|
4132
|
+
async function layoutCheck(config, configDir) {
|
|
4133
|
+
const { repos, hints } = await scanLayout({
|
|
4134
|
+
config,
|
|
4135
|
+
configDir
|
|
4136
|
+
});
|
|
4137
|
+
const count = `${repos.length} repo${repos.length === 1 ? "" : "s"}`;
|
|
4138
|
+
if (hints.length === 0) return {
|
|
4139
|
+
name: "layout",
|
|
4140
|
+
severity: "ok",
|
|
4141
|
+
message: count
|
|
4142
|
+
};
|
|
4143
|
+
const shown = hints.slice(0, HINTS_SHOWN).map((hint) => `${hint.path} ${HINT_LABEL[hint.reason]}`);
|
|
4144
|
+
const rest = hints.length > HINTS_SHOWN ? ` (+${hints.length - HINTS_SHOWN} more)` : "";
|
|
4145
|
+
return {
|
|
4146
|
+
name: "layout",
|
|
4147
|
+
severity: "warn",
|
|
4148
|
+
message: `${count}; ${shown.join(", ")}${rest}`
|
|
4149
|
+
};
|
|
4150
|
+
}
|
|
3789
4151
|
function severitySymbol(severity) {
|
|
3790
4152
|
if (severity === "ok") return colors.green("✓");
|
|
3791
4153
|
if (severity === "warn") return colors.yellow("!");
|
|
@@ -4081,7 +4443,7 @@ var completionCommand = defineCommand({
|
|
|
4081
4443
|
runMain(defineCommand({
|
|
4082
4444
|
meta: {
|
|
4083
4445
|
name: "forgemap",
|
|
4084
|
-
version: "0.
|
|
4446
|
+
version: "0.8.0",
|
|
4085
4447
|
description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
|
|
4086
4448
|
},
|
|
4087
4449
|
subCommands: {
|