forgemap 0.6.0 → 0.7.0-dev-main.119-2edcfde
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 -168
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +13 -13
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,46 +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
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* scan
|
|
246
|
-
*
|
|
247
|
-
* Stats are issued in parallel: one batch per forge for its forge.dir +
|
|
248
|
-
* owner list, all forges in parallel. Beats the sequential version by
|
|
249
|
-
* 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.
|
|
250
382
|
*/
|
|
251
383
|
async function computeFingerprint(config, configDir) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const ownerEntries = await Promise.all(owners.map(async (owner) => {
|
|
257
|
-
const ownerPath = join(forgeRoot, owner);
|
|
258
|
-
return [ownerPath, await safeStat(ownerPath)];
|
|
259
|
-
}));
|
|
260
|
-
return [[forgeRoot, forgeMtime], ...ownerEntries];
|
|
261
|
-
}));
|
|
262
|
-
const entries = [[root, await safeStat(root)]];
|
|
263
|
-
for (const group of perForge) entries.push(...group);
|
|
264
|
-
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
265
|
-
return createHash("sha1").update(entries.map(([p, m]) => `${p}:${m}`).join("\n")).digest("hex");
|
|
384
|
+
return (await scanLayout({
|
|
385
|
+
config,
|
|
386
|
+
configDir
|
|
387
|
+
})).fingerprint;
|
|
266
388
|
}
|
|
267
389
|
async function readCacheFile(file) {
|
|
268
390
|
try {
|
|
@@ -284,26 +406,35 @@ async function scanReposCached(options) {
|
|
|
284
406
|
if (cached) {
|
|
285
407
|
const age = Date.now() - cached.writtenAt;
|
|
286
408
|
if (trustTtl && age < ttl()) return cached.repos;
|
|
287
|
-
const
|
|
288
|
-
|
|
409
|
+
const scan = await scanLayout({
|
|
410
|
+
config,
|
|
411
|
+
configDir
|
|
412
|
+
});
|
|
413
|
+
if (cached.fingerprint === scan.fingerprint) {
|
|
289
414
|
await writeCacheFile(file, {
|
|
290
415
|
...cached,
|
|
291
416
|
writtenAt: Date.now()
|
|
292
417
|
});
|
|
293
418
|
return cached.repos;
|
|
294
419
|
}
|
|
420
|
+
await writeCacheFile(file, {
|
|
421
|
+
fingerprint: scan.fingerprint,
|
|
422
|
+
writtenAt: Date.now(),
|
|
423
|
+
repos: scan.repos
|
|
424
|
+
});
|
|
425
|
+
return scan.repos;
|
|
295
426
|
}
|
|
296
427
|
}
|
|
297
|
-
const
|
|
428
|
+
const scan = await scanLayout({
|
|
298
429
|
config,
|
|
299
430
|
configDir
|
|
300
431
|
});
|
|
301
432
|
await writeCacheFile(file, {
|
|
302
|
-
fingerprint:
|
|
433
|
+
fingerprint: scan.fingerprint,
|
|
303
434
|
writtenAt: Date.now(),
|
|
304
|
-
repos
|
|
435
|
+
repos: scan.repos
|
|
305
436
|
});
|
|
306
|
-
return repos;
|
|
437
|
+
return scan.repos;
|
|
307
438
|
}
|
|
308
439
|
/**
|
|
309
440
|
* Append a freshly-cloned repo to the cache without touching the
|
|
@@ -340,9 +471,15 @@ async function removeCachedRepo(options, localPath) {
|
|
|
340
471
|
}
|
|
341
472
|
//#endregion
|
|
342
473
|
//#region src/utils/exec.ts
|
|
343
|
-
function execInherit(command, args) {
|
|
474
|
+
function execInherit(command, args, options = {}) {
|
|
344
475
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
345
|
-
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
|
+
});
|
|
346
483
|
child.on("error", rejectPromise);
|
|
347
484
|
child.on("close", (code) => {
|
|
348
485
|
resolvePromise({ code: code ?? 0 });
|
|
@@ -468,33 +605,13 @@ function isRepoMissing(stderr) {
|
|
|
468
605
|
return /repository not found/.test(s) || /remote:.*not found/.test(s) || /\b404\b/.test(s) || /could not find repository/.test(s);
|
|
469
606
|
}
|
|
470
607
|
//#endregion
|
|
471
|
-
//#region src/utils/concurrency.ts
|
|
472
|
-
/**
|
|
473
|
-
* Map over `items` running at most `limit` calls of `fn` at once, preserving
|
|
474
|
-
* input order in the result. Keeps `import` from spawning one subprocess per
|
|
475
|
-
* repo all at once when checking remotes across a large tree.
|
|
476
|
-
*/
|
|
477
|
-
async function mapLimit(items, limit, fn) {
|
|
478
|
-
const results = Array.from({ length: items.length });
|
|
479
|
-
const max = Math.max(1, Math.min(limit, items.length));
|
|
480
|
-
let next = 0;
|
|
481
|
-
async function worker() {
|
|
482
|
-
while (next < items.length) {
|
|
483
|
-
const index = next++;
|
|
484
|
-
results[index] = await fn(items[index], index);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
await Promise.all(Array.from({ length: max }, () => worker()));
|
|
488
|
-
return results;
|
|
489
|
-
}
|
|
490
|
-
//#endregion
|
|
491
608
|
//#region src/forges/github.ts
|
|
492
|
-
var GRAPHQL_CHUNK = 100;
|
|
493
|
-
var FALLBACK_CONCURRENCY = 8;
|
|
609
|
+
var GRAPHQL_CHUNK$1 = 100;
|
|
610
|
+
var FALLBACK_CONCURRENCY$1 = 8;
|
|
494
611
|
var GH_TIMEOUT_MS = 2e4;
|
|
495
612
|
/** Single-repo REST check. `gh api` follows the redirect a renamed/transferred
|
|
496
613
|
* repo issues, so the returned full_name reveals the canonical owner/repo. */
|
|
497
|
-
async function checkOne(owner, repo) {
|
|
614
|
+
async function checkOne$1(owner, repo) {
|
|
498
615
|
const result = await execCapture("gh", [
|
|
499
616
|
"api",
|
|
500
617
|
`repos/${owner}/${repo}`,
|
|
@@ -531,7 +648,7 @@ async function checkOne(owner, repo) {
|
|
|
531
648
|
canonicalUrl: `https://github.com/${canonicalOwner}/${canonicalRepo}.git`
|
|
532
649
|
};
|
|
533
650
|
}
|
|
534
|
-
function buildQuery(chunk) {
|
|
651
|
+
function buildQuery$1(chunk) {
|
|
535
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}`;
|
|
536
653
|
}
|
|
537
654
|
var githubAdapter = {
|
|
@@ -550,7 +667,7 @@ var githubAdapter = {
|
|
|
550
667
|
state: "unknown",
|
|
551
668
|
reason: "gh not installed"
|
|
552
669
|
};
|
|
553
|
-
return checkOne(owner, repo);
|
|
670
|
+
return checkOne$1(owner, repo);
|
|
554
671
|
},
|
|
555
672
|
/**
|
|
556
673
|
* One GraphQL request resolves up to GRAPHQL_CHUNK repos at once. GraphQL
|
|
@@ -565,13 +682,13 @@ var githubAdapter = {
|
|
|
565
682
|
reason: "gh not installed"
|
|
566
683
|
}));
|
|
567
684
|
const results = Array.from({ length: inputs.length }, () => null);
|
|
568
|
-
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK) {
|
|
569
|
-
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);
|
|
570
687
|
const res = await execCapture("gh", [
|
|
571
688
|
"api",
|
|
572
689
|
"graphql",
|
|
573
690
|
"-f",
|
|
574
|
-
`query=${buildQuery(chunk)}`
|
|
691
|
+
`query=${buildQuery$1(chunk)}`
|
|
575
692
|
], { timeoutMs: GH_TIMEOUT_MS });
|
|
576
693
|
let data = null;
|
|
577
694
|
try {
|
|
@@ -593,8 +710,153 @@ var githubAdapter = {
|
|
|
593
710
|
}
|
|
594
711
|
}
|
|
595
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
|
+
}
|
|
596
857
|
await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY, async (index) => {
|
|
597
|
-
|
|
858
|
+
const input = inputs[index];
|
|
859
|
+
results[index] = await checkOne(input.forge, input.owner, input.repo);
|
|
598
860
|
});
|
|
599
861
|
return results;
|
|
600
862
|
}
|
|
@@ -604,8 +866,8 @@ var githubAdapter = {
|
|
|
604
866
|
function getForgeAdapter(type) {
|
|
605
867
|
switch (type) {
|
|
606
868
|
case "github": return githubAdapter;
|
|
869
|
+
case "gitlab": return gitlabAdapter;
|
|
607
870
|
case "git": return gitAdapter;
|
|
608
|
-
case "gitlab":
|
|
609
871
|
case "gitea":
|
|
610
872
|
case "codeberg": throw new Error(`Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`);
|
|
611
873
|
default: throw new Error(`Unknown forge type: ${String(type)}`);
|
|
@@ -613,21 +875,29 @@ function getForgeAdapter(type) {
|
|
|
613
875
|
}
|
|
614
876
|
//#endregion
|
|
615
877
|
//#region src/slug/parse.ts
|
|
616
|
-
var
|
|
617
|
-
var
|
|
618
|
-
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)?$`);
|
|
619
882
|
function stripGitSuffix(repo) {
|
|
620
883
|
return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
|
|
621
884
|
}
|
|
622
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
|
+
/**
|
|
623
897
|
* Whether the input is *shaped* like a strict slug. Every form
|
|
624
|
-
* {@link parseSlug} accepts — `
|
|
625
|
-
* 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
|
|
626
900
|
* free to be treated as a fuzzy query instead.
|
|
627
|
-
*
|
|
628
|
-
* Shaped-like is deliberately not the same as valid: `foo/bar/baz` is shaped
|
|
629
|
-
* like a slug, so it stays a hard parse error rather than silently degrading
|
|
630
|
-
* into a fuzzy search for something the user clearly meant as a slug.
|
|
631
901
|
*/
|
|
632
902
|
function looksLikeSlug(input) {
|
|
633
903
|
return input.trim().includes("/");
|
|
@@ -638,8 +908,7 @@ function parseSlug(input) {
|
|
|
638
908
|
const ssh = SSH_RE.exec(trimmed);
|
|
639
909
|
if (ssh) return {
|
|
640
910
|
host: ssh[1],
|
|
641
|
-
|
|
642
|
-
repo: stripGitSuffix(ssh[3])
|
|
911
|
+
...splitPath([...ssh[2].split("/"), ssh[3]])
|
|
643
912
|
};
|
|
644
913
|
if (/^https?:\/\//.test(trimmed)) {
|
|
645
914
|
let url;
|
|
@@ -648,25 +917,22 @@ function parseSlug(input) {
|
|
|
648
917
|
} catch {
|
|
649
918
|
throw new Error(`Invalid URL: ${trimmed}`);
|
|
650
919
|
}
|
|
651
|
-
|
|
652
|
-
|
|
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}`);
|
|
653
924
|
return {
|
|
654
925
|
host: url.host,
|
|
655
|
-
|
|
656
|
-
repo: stripGitSuffix(segments[1])
|
|
926
|
+
...splitPath(segments)
|
|
657
927
|
};
|
|
658
928
|
}
|
|
659
929
|
const named = NAMED_RE.exec(trimmed);
|
|
660
930
|
if (named) return {
|
|
661
931
|
forgeName: named[1],
|
|
662
|
-
|
|
663
|
-
repo: stripGitSuffix(named[3])
|
|
932
|
+
...splitPath([...named[2].split("/"), named[3]])
|
|
664
933
|
};
|
|
665
934
|
const short = SHORT_RE.exec(trimmed);
|
|
666
|
-
if (short) return
|
|
667
|
-
owner: short[1],
|
|
668
|
-
repo: stripGitSuffix(short[2])
|
|
669
|
-
};
|
|
935
|
+
if (short) return splitPath([...short[1].split("/"), short[2]]);
|
|
670
936
|
throw new Error(`Unrecognized slug format: ${input}`);
|
|
671
937
|
}
|
|
672
938
|
//#endregion
|
|
@@ -950,16 +1216,31 @@ function remoteBlocker(state) {
|
|
|
950
1216
|
if (state === "exists" || state === "moved") return null;
|
|
951
1217
|
return state === "gone" ? "remote no longer exists" : "remote unreachable";
|
|
952
1218
|
}
|
|
953
|
-
/**
|
|
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
|
+
*/
|
|
954
1230
|
async function classifyRemotes(candidates) {
|
|
955
|
-
const
|
|
1231
|
+
const groups = /* @__PURE__ */ new Map();
|
|
956
1232
|
for (const c of candidates) {
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
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
|
+
});
|
|
960
1241
|
}
|
|
961
1242
|
const results = /* @__PURE__ */ new Map();
|
|
962
|
-
await Promise.all(Array.from(
|
|
1243
|
+
await Promise.all(Array.from(groups.values(), async ({ type, items }) => {
|
|
963
1244
|
const inputs = items.map((c) => ({
|
|
964
1245
|
forge: c.repo.forge,
|
|
965
1246
|
owner: c.owner,
|
|
@@ -1005,35 +1286,43 @@ async function classifyRemotes(candidates) {
|
|
|
1005
1286
|
}));
|
|
1006
1287
|
return results;
|
|
1007
1288
|
}
|
|
1008
|
-
|
|
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;
|
|
1009
1302
|
try {
|
|
1010
|
-
|
|
1303
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
1011
1304
|
} catch {
|
|
1012
|
-
return
|
|
1305
|
+
return false;
|
|
1013
1306
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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. */
|
|
1017
1320
|
async function findEmptyDirs(root, config) {
|
|
1018
1321
|
const empties = [];
|
|
1019
|
-
for (const forge of Object.values(config.forges))
|
|
1020
|
-
const serverPath = join(root, forge.dir);
|
|
1021
|
-
const owners = await safeReaddir(serverPath);
|
|
1022
|
-
if (owners === null) continue;
|
|
1023
|
-
let emptyCount = 0;
|
|
1024
|
-
for (const owner of owners) {
|
|
1025
|
-
const ownerPath = join(serverPath, owner);
|
|
1026
|
-
const inner = await safeReaddir(ownerPath);
|
|
1027
|
-
if (inner !== null && inner.length === 0) {
|
|
1028
|
-
empties.push(ownerPath);
|
|
1029
|
-
emptyCount++;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
if (owners.length === 0 || emptyCount === owners.length) empties.push(serverPath);
|
|
1033
|
-
}
|
|
1322
|
+
for (const forge of Object.values(config.forges)) await collectEmptyDirs(join(root, forge.dir), 0, empties);
|
|
1034
1323
|
return empties;
|
|
1035
1324
|
}
|
|
1036
|
-
/** Remove the dirs from findEmptyDirs (
|
|
1325
|
+
/** Remove the dirs from findEmptyDirs (children before their parents). */
|
|
1037
1326
|
async function pruneEmptyDirs(root, config) {
|
|
1038
1327
|
const empties = await findEmptyDirs(root, config);
|
|
1039
1328
|
let removed = 0;
|
|
@@ -1225,6 +1514,8 @@ function resolveSlug(parsed, options) {
|
|
|
1225
1514
|
forgeName = config.defaultForge;
|
|
1226
1515
|
forge = candidate;
|
|
1227
1516
|
}
|
|
1517
|
+
const depthError = checkNamespaceDepth(forgeName, forge.type, parsed.owner);
|
|
1518
|
+
if (depthError) throw new Error(depthError);
|
|
1228
1519
|
const localPath = join(resolveRoot(config.root, configDir), forge.dir, parsed.owner, parsed.repo);
|
|
1229
1520
|
return {
|
|
1230
1521
|
forgeName,
|
|
@@ -2246,34 +2537,51 @@ var forgeCommand = defineCommand({
|
|
|
2246
2537
|
});
|
|
2247
2538
|
//#endregion
|
|
2248
2539
|
//#region src/repos/import.ts
|
|
2249
|
-
async function
|
|
2540
|
+
async function readEntries(path) {
|
|
2250
2541
|
try {
|
|
2251
|
-
|
|
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
|
+
};
|
|
2252
2547
|
} catch (error) {
|
|
2253
|
-
if (error.code === "ENOENT") return
|
|
2548
|
+
if (error.code === "ENOENT") return null;
|
|
2254
2549
|
throw error;
|
|
2255
2550
|
}
|
|
2256
2551
|
}
|
|
2257
2552
|
/**
|
|
2258
|
-
* Structure-driven
|
|
2259
|
-
*
|
|
2260
|
-
*
|
|
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.
|
|
2261
2562
|
*/
|
|
2262
|
-
async function
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
}
|
|
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;
|
|
2275
2575
|
}
|
|
2276
|
-
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;
|
|
2277
2585
|
}
|
|
2278
2586
|
function forgeTypeForHost(host) {
|
|
2279
2587
|
return host === "github.com" ? "github" : "git";
|
|
@@ -2935,7 +3243,7 @@ var infoCommand = defineCommand({
|
|
|
2935
3243
|
async run({ args }) {
|
|
2936
3244
|
const binary = resolveBinary(process.argv[1]);
|
|
2937
3245
|
const info = {
|
|
2938
|
-
version: "0.
|
|
3246
|
+
version: "0.7.0-dev-main.119-2edcfde",
|
|
2939
3247
|
build: detectBuild(binary.resolved),
|
|
2940
3248
|
binary,
|
|
2941
3249
|
node: process.version,
|
|
@@ -3746,6 +4054,7 @@ async function runChecks(config, configDir) {
|
|
|
3746
4054
|
const types = new Set(Object.values(config.forges).map((f) => f.type));
|
|
3747
4055
|
const needsGit = types.has("git") || types.size > 0;
|
|
3748
4056
|
const needsGh = types.has("github");
|
|
4057
|
+
const gitlabForges = Object.entries(config.forges).filter(([, forge]) => forge.type === "gitlab");
|
|
3749
4058
|
if (needsGit) checks.push(await hasCommand("git") ? {
|
|
3750
4059
|
name: "git CLI",
|
|
3751
4060
|
severity: "ok",
|
|
@@ -3776,8 +4085,69 @@ async function runChecks(config, configDir) {
|
|
|
3776
4085
|
severity: "fail",
|
|
3777
4086
|
message: "install from https://cli.github.com/"
|
|
3778
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));
|
|
3779
4117
|
return checks;
|
|
3780
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
|
+
}
|
|
3781
4151
|
function severitySymbol(severity) {
|
|
3782
4152
|
if (severity === "ok") return colors.green("✓");
|
|
3783
4153
|
if (severity === "warn") return colors.yellow("!");
|
|
@@ -4073,7 +4443,7 @@ var completionCommand = defineCommand({
|
|
|
4073
4443
|
runMain(defineCommand({
|
|
4074
4444
|
meta: {
|
|
4075
4445
|
name: "forgemap",
|
|
4076
|
-
version: "0.
|
|
4446
|
+
version: "0.7.0-dev-main.119-2edcfde",
|
|
4077
4447
|
description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
|
|
4078
4448
|
},
|
|
4079
4449
|
subCommands: {
|