forgemap 0.7.0-dev.115-dc2b700 → 0.7.0-dev.117-ac8b2c6
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 +504 -170
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +3 -3
package/dist/bin/forgemap.mjs
CHANGED
|
@@ -175,36 +175,182 @@ 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 namespace accumulated below the forge dir; the repo takes
|
|
260
|
+
* the last one, so a repo needs at least two.
|
|
261
|
+
*/
|
|
262
|
+
async function walk(ctx, dirPath, segments) {
|
|
263
|
+
if (segments.length >= 10) {
|
|
264
|
+
ctx.hints.push({
|
|
265
|
+
path: dirPath,
|
|
266
|
+
reason: "too-deep"
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const entries = await readEntries$1(dirPath);
|
|
271
|
+
if (!entries) {
|
|
272
|
+
if (segments.length === 0) ctx.entries.push([dirPath, "d:0:[]"]);
|
|
273
|
+
else ctx.hints.push({
|
|
274
|
+
path: dirPath,
|
|
275
|
+
reason: "no-repo"
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (entries.isRepo) {
|
|
280
|
+
ctx.entries.push([dirPath, "r"]);
|
|
281
|
+
if (segments.length < 2) {
|
|
282
|
+
ctx.hints.push({
|
|
283
|
+
path: dirPath,
|
|
284
|
+
reason: "missing-namespace"
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const owner = segments.slice(0, -1).join("/");
|
|
289
|
+
const repo = segments.at(-1);
|
|
290
|
+
ctx.repos.push({
|
|
291
|
+
forgeName: ctx.forgeName,
|
|
292
|
+
forge: ctx.forge,
|
|
293
|
+
owner,
|
|
294
|
+
repo,
|
|
295
|
+
localPath: dirPath,
|
|
296
|
+
slug: `${owner}/${repo}`
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const names = [...entries.dirs].sort();
|
|
301
|
+
const mtime = await safeStat(dirPath);
|
|
302
|
+
ctx.entries.push([dirPath, `d:${mtime}:${JSON.stringify(names)}`]);
|
|
303
|
+
if (names.length === 0) {
|
|
304
|
+
if (segments.length > 0) ctx.hints.push({
|
|
305
|
+
path: dirPath,
|
|
306
|
+
reason: "no-repo"
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await mapLimit(names, WALK_CONCURRENCY, (name) => walk(ctx, join(dirPath, name), [...segments, name]));
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Walk the configured layout once, producing the repos, the branches that
|
|
314
|
+
* yielded none, and a fingerprint of what was observed.
|
|
315
|
+
*
|
|
316
|
+
* The fingerprint records each namespace directory's mtime **and** its sorted
|
|
317
|
+
* child names, plus which directories turned out to be repos. The names are
|
|
318
|
+
* what make it reliable: mtimes compare at millisecond granularity, so two
|
|
319
|
+
* clones landing inside the same millisecond hash identically and a stale
|
|
320
|
+
* cache would win. Recording the repo classification is what catches a plain
|
|
321
|
+
* directory becoming a checkout (`git init`) without any name moving at all.
|
|
322
|
+
*/
|
|
323
|
+
async function scanLayout(options) {
|
|
188
324
|
const { config, configDir } = options;
|
|
189
325
|
const root = resolveRoot(config.root, configDir);
|
|
326
|
+
const contexts = await mapLimit(Object.entries(config.forges), WALK_CONCURRENCY, async ([forgeName, forge]) => {
|
|
327
|
+
const ctx = {
|
|
328
|
+
forgeName,
|
|
329
|
+
forge,
|
|
330
|
+
repos: [],
|
|
331
|
+
hints: [],
|
|
332
|
+
entries: []
|
|
333
|
+
};
|
|
334
|
+
await walk(ctx, join(root, forge.dir), []);
|
|
335
|
+
return ctx;
|
|
336
|
+
});
|
|
337
|
+
const entries = [[root, `m:${await safeStat(root)}`]];
|
|
190
338
|
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
|
-
}
|
|
339
|
+
const hints = [];
|
|
340
|
+
for (const ctx of contexts) {
|
|
341
|
+
repos.push(...ctx.repos);
|
|
342
|
+
hints.push(...ctx.hints);
|
|
343
|
+
entries.push(...ctx.entries);
|
|
206
344
|
}
|
|
207
|
-
|
|
345
|
+
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
346
|
+
return {
|
|
347
|
+
repos,
|
|
348
|
+
hints,
|
|
349
|
+
fingerprint: createHash("sha1").update(entries.map(([p, marker]) => `${p}:${marker}`).join("\n")).digest("hex")
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async function scanRepos(options) {
|
|
353
|
+
return (await scanLayout(options)).repos;
|
|
208
354
|
}
|
|
209
355
|
//#endregion
|
|
210
356
|
//#region src/repos/cache.ts
|
|
@@ -223,54 +369,18 @@ function cachePath(root) {
|
|
|
223
369
|
const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
|
|
224
370
|
return join(cacheDir(), `scan-${hash}.json`);
|
|
225
371
|
}
|
|
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
372
|
/**
|
|
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.
|
|
373
|
+
* Fingerprint of the layout, computed by the same walk that finds the repos —
|
|
374
|
+
* see {@link scanLayout}, which owns what goes into it. Since a directory is
|
|
375
|
+
* only known to be a repo once its entries have been read, the fingerprint
|
|
376
|
+
* cannot be cheaper than the scan; producing both from one walk is what keeps
|
|
377
|
+
* the cold path from paying for the tree twice.
|
|
257
378
|
*/
|
|
258
379
|
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");
|
|
380
|
+
return (await scanLayout({
|
|
381
|
+
config,
|
|
382
|
+
configDir
|
|
383
|
+
})).fingerprint;
|
|
274
384
|
}
|
|
275
385
|
async function readCacheFile(file) {
|
|
276
386
|
try {
|
|
@@ -292,26 +402,35 @@ async function scanReposCached(options) {
|
|
|
292
402
|
if (cached) {
|
|
293
403
|
const age = Date.now() - cached.writtenAt;
|
|
294
404
|
if (trustTtl && age < ttl()) return cached.repos;
|
|
295
|
-
const
|
|
296
|
-
|
|
405
|
+
const scan = await scanLayout({
|
|
406
|
+
config,
|
|
407
|
+
configDir
|
|
408
|
+
});
|
|
409
|
+
if (cached.fingerprint === scan.fingerprint) {
|
|
297
410
|
await writeCacheFile(file, {
|
|
298
411
|
...cached,
|
|
299
412
|
writtenAt: Date.now()
|
|
300
413
|
});
|
|
301
414
|
return cached.repos;
|
|
302
415
|
}
|
|
416
|
+
await writeCacheFile(file, {
|
|
417
|
+
fingerprint: scan.fingerprint,
|
|
418
|
+
writtenAt: Date.now(),
|
|
419
|
+
repos: scan.repos
|
|
420
|
+
});
|
|
421
|
+
return scan.repos;
|
|
303
422
|
}
|
|
304
423
|
}
|
|
305
|
-
const
|
|
424
|
+
const scan = await scanLayout({
|
|
306
425
|
config,
|
|
307
426
|
configDir
|
|
308
427
|
});
|
|
309
428
|
await writeCacheFile(file, {
|
|
310
|
-
fingerprint:
|
|
429
|
+
fingerprint: scan.fingerprint,
|
|
311
430
|
writtenAt: Date.now(),
|
|
312
|
-
repos
|
|
431
|
+
repos: scan.repos
|
|
313
432
|
});
|
|
314
|
-
return repos;
|
|
433
|
+
return scan.repos;
|
|
315
434
|
}
|
|
316
435
|
/**
|
|
317
436
|
* Append a freshly-cloned repo to the cache without touching the
|
|
@@ -348,9 +467,15 @@ async function removeCachedRepo(options, localPath) {
|
|
|
348
467
|
}
|
|
349
468
|
//#endregion
|
|
350
469
|
//#region src/utils/exec.ts
|
|
351
|
-
function execInherit(command, args) {
|
|
470
|
+
function execInherit(command, args, options = {}) {
|
|
352
471
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
353
|
-
const child = spawn(command, args, {
|
|
472
|
+
const child = spawn(command, args, {
|
|
473
|
+
env: options.env ? {
|
|
474
|
+
...process.env,
|
|
475
|
+
...options.env
|
|
476
|
+
} : void 0,
|
|
477
|
+
stdio: "inherit"
|
|
478
|
+
});
|
|
354
479
|
child.on("error", rejectPromise);
|
|
355
480
|
child.on("close", (code) => {
|
|
356
481
|
resolvePromise({ code: code ?? 0 });
|
|
@@ -476,33 +601,13 @@ function isRepoMissing(stderr) {
|
|
|
476
601
|
return /repository not found/.test(s) || /remote:.*not found/.test(s) || /\b404\b/.test(s) || /could not find repository/.test(s);
|
|
477
602
|
}
|
|
478
603
|
//#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
604
|
//#region src/forges/github.ts
|
|
500
|
-
var GRAPHQL_CHUNK = 100;
|
|
501
|
-
var FALLBACK_CONCURRENCY = 8;
|
|
605
|
+
var GRAPHQL_CHUNK$1 = 100;
|
|
606
|
+
var FALLBACK_CONCURRENCY$1 = 8;
|
|
502
607
|
var GH_TIMEOUT_MS = 2e4;
|
|
503
608
|
/** Single-repo REST check. `gh api` follows the redirect a renamed/transferred
|
|
504
609
|
* repo issues, so the returned full_name reveals the canonical owner/repo. */
|
|
505
|
-
async function checkOne(owner, repo) {
|
|
610
|
+
async function checkOne$1(owner, repo) {
|
|
506
611
|
const result = await execCapture("gh", [
|
|
507
612
|
"api",
|
|
508
613
|
`repos/${owner}/${repo}`,
|
|
@@ -539,7 +644,7 @@ async function checkOne(owner, repo) {
|
|
|
539
644
|
canonicalUrl: `https://github.com/${canonicalOwner}/${canonicalRepo}.git`
|
|
540
645
|
};
|
|
541
646
|
}
|
|
542
|
-
function buildQuery(chunk) {
|
|
647
|
+
function buildQuery$1(chunk) {
|
|
543
648
|
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
649
|
}
|
|
545
650
|
var githubAdapter = {
|
|
@@ -558,7 +663,7 @@ var githubAdapter = {
|
|
|
558
663
|
state: "unknown",
|
|
559
664
|
reason: "gh not installed"
|
|
560
665
|
};
|
|
561
|
-
return checkOne(owner, repo);
|
|
666
|
+
return checkOne$1(owner, repo);
|
|
562
667
|
},
|
|
563
668
|
/**
|
|
564
669
|
* One GraphQL request resolves up to GRAPHQL_CHUNK repos at once. GraphQL
|
|
@@ -573,13 +678,13 @@ var githubAdapter = {
|
|
|
573
678
|
reason: "gh not installed"
|
|
574
679
|
}));
|
|
575
680
|
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);
|
|
681
|
+
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK$1) {
|
|
682
|
+
const chunk = inputs.slice(start, start + GRAPHQL_CHUNK$1);
|
|
578
683
|
const res = await execCapture("gh", [
|
|
579
684
|
"api",
|
|
580
685
|
"graphql",
|
|
581
686
|
"-f",
|
|
582
|
-
`query=${buildQuery(chunk)}`
|
|
687
|
+
`query=${buildQuery$1(chunk)}`
|
|
583
688
|
], { timeoutMs: GH_TIMEOUT_MS });
|
|
584
689
|
let data = null;
|
|
585
690
|
try {
|
|
@@ -601,8 +706,144 @@ var githubAdapter = {
|
|
|
601
706
|
}
|
|
602
707
|
}
|
|
603
708
|
}
|
|
709
|
+
await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY$1, async (index) => {
|
|
710
|
+
results[index] = await checkOne$1(inputs[index].owner, inputs[index].repo);
|
|
711
|
+
});
|
|
712
|
+
return results;
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/forges/gitlab.ts
|
|
717
|
+
var GRAPHQL_CHUNK = 100;
|
|
718
|
+
var FALLBACK_CONCURRENCY = 8;
|
|
719
|
+
var GLAB_TIMEOUT_MS = 2e4;
|
|
720
|
+
var MISSING_GLAB = "GitLab CLI (`glab`) is not installed. Install it from https://gitlab.com/gitlab-org/cli and run `glab auth login`.";
|
|
721
|
+
/**
|
|
722
|
+
* Pin every invocation to the forge's own host. `glab repo clone` has no
|
|
723
|
+
* `--hostname` flag, and leaning on the user's global `glab config set host`
|
|
724
|
+
* would make behaviour depend on state forgemap never set.
|
|
725
|
+
*/
|
|
726
|
+
function glabEnv(forge) {
|
|
727
|
+
return { GITLAB_HOST: forge.host };
|
|
728
|
+
}
|
|
729
|
+
/** Split GitLab's `full path` into namespace and project. */
|
|
730
|
+
function splitFullPath(fullPath) {
|
|
731
|
+
const segments = fullPath.split("/").filter(Boolean);
|
|
732
|
+
if (segments.length < 2) return null;
|
|
733
|
+
return {
|
|
734
|
+
owner: segments.slice(0, -1).join("/"),
|
|
735
|
+
repo: segments.at(-1)
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Single-project REST check. `projects/<url-encoded path>` answers with
|
|
740
|
+
* `path_with_namespace`, GitLab's counterpart to GitHub's `full_name`, so a
|
|
741
|
+
* differing answer is a move and a 404 is a deletion.
|
|
742
|
+
*/
|
|
743
|
+
async function checkOne(forge, owner, repo) {
|
|
744
|
+
const result = await execCapture("glab", ["api", `projects/${encodeURIComponent(`${owner}/${repo}`)}`], {
|
|
745
|
+
timeoutMs: GLAB_TIMEOUT_MS,
|
|
746
|
+
env: glabEnv(forge)
|
|
747
|
+
});
|
|
748
|
+
if (result.timedOut) return {
|
|
749
|
+
state: "unknown",
|
|
750
|
+
reason: "glab api timed out"
|
|
751
|
+
};
|
|
752
|
+
if (result.code !== 0) {
|
|
753
|
+
if (/404|not found/i.test(result.stderr)) return { state: "gone" };
|
|
754
|
+
return {
|
|
755
|
+
state: "unknown",
|
|
756
|
+
reason: result.stderr.trim() || `glab api exited with code ${result.code}`
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
let fullPath;
|
|
760
|
+
try {
|
|
761
|
+
fullPath = JSON.parse(result.stdout).path_with_namespace;
|
|
762
|
+
} catch {
|
|
763
|
+
fullPath = void 0;
|
|
764
|
+
}
|
|
765
|
+
const canonical = fullPath ? splitFullPath(fullPath) : null;
|
|
766
|
+
if (!canonical) return {
|
|
767
|
+
state: "unknown",
|
|
768
|
+
reason: "could not parse glab api path_with_namespace"
|
|
769
|
+
};
|
|
770
|
+
if (canonical.owner === owner && canonical.repo === repo) return {
|
|
771
|
+
state: "exists",
|
|
772
|
+
canonical
|
|
773
|
+
};
|
|
774
|
+
return {
|
|
775
|
+
state: "moved",
|
|
776
|
+
canonical,
|
|
777
|
+
canonicalUrl: `https://${forge.host}/${canonical.owner}/${canonical.repo}.git`
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function buildQuery(chunk) {
|
|
781
|
+
return `query {\n${chunk.map((input, i) => ` r${i}: project(fullPath: ${JSON.stringify(`${input.owner}/${input.repo}`)}) { fullPath }`).join("\n")}\n}`;
|
|
782
|
+
}
|
|
783
|
+
var gitlabAdapter = {
|
|
784
|
+
async clone({ forge, owner, repo, dest }) {
|
|
785
|
+
if (!await hasCommand("glab")) throw new Error(MISSING_GLAB);
|
|
786
|
+
const { code } = await execInherit("glab", [
|
|
787
|
+
"repo",
|
|
788
|
+
"clone",
|
|
789
|
+
`${owner}/${repo}`,
|
|
790
|
+
dest
|
|
791
|
+
], { env: glabEnv(forge) });
|
|
792
|
+
if (code !== 0) throw new Error(`glab repo clone exited with code ${code}`);
|
|
793
|
+
},
|
|
794
|
+
async checkRemote({ forge, owner, repo }) {
|
|
795
|
+
if (!await hasCommand("glab")) return {
|
|
796
|
+
state: "unknown",
|
|
797
|
+
reason: "glab not installed"
|
|
798
|
+
};
|
|
799
|
+
return checkOne(forge, owner, repo);
|
|
800
|
+
},
|
|
801
|
+
/**
|
|
802
|
+
* Mirrors the GitHub adapter: one aliased GraphQL request resolves up to
|
|
803
|
+
* GRAPHQL_CHUNK projects, and each miss — `null` could mean gone *or*
|
|
804
|
+
* renamed — costs a single REST call to tell the two apart.
|
|
805
|
+
*
|
|
806
|
+
* Every input in a batch belongs to one forge (the importer groups by
|
|
807
|
+
* server dir), so the host is taken from the first.
|
|
808
|
+
*/
|
|
809
|
+
async checkRemotes(inputs) {
|
|
810
|
+
if (inputs.length === 0) return [];
|
|
811
|
+
if (!await hasCommand("glab")) return inputs.map(() => ({
|
|
812
|
+
state: "unknown",
|
|
813
|
+
reason: "glab not installed"
|
|
814
|
+
}));
|
|
815
|
+
const forge = inputs[0].forge;
|
|
816
|
+
const results = Array.from({ length: inputs.length }, () => null);
|
|
817
|
+
for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK) {
|
|
818
|
+
const chunk = inputs.slice(start, start + GRAPHQL_CHUNK);
|
|
819
|
+
const res = await execCapture("glab", [
|
|
820
|
+
"api",
|
|
821
|
+
"graphql",
|
|
822
|
+
"-f",
|
|
823
|
+
`query=${buildQuery(chunk)}`
|
|
824
|
+
], {
|
|
825
|
+
timeoutMs: GLAB_TIMEOUT_MS,
|
|
826
|
+
env: glabEnv(forge)
|
|
827
|
+
});
|
|
828
|
+
let data = null;
|
|
829
|
+
try {
|
|
830
|
+
const parsed = JSON.parse(res.stdout);
|
|
831
|
+
data = parsed.data ?? parsed;
|
|
832
|
+
} catch {
|
|
833
|
+
data = null;
|
|
834
|
+
}
|
|
835
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
836
|
+
const node = data?.[`r${i}`];
|
|
837
|
+
const canonical = node?.fullPath ? splitFullPath(node.fullPath) : null;
|
|
838
|
+
if (canonical) results[start + i] = {
|
|
839
|
+
state: "exists",
|
|
840
|
+
canonical
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
}
|
|
604
844
|
await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY, async (index) => {
|
|
605
|
-
|
|
845
|
+
const input = inputs[index];
|
|
846
|
+
results[index] = await checkOne(input.forge, input.owner, input.repo);
|
|
606
847
|
});
|
|
607
848
|
return results;
|
|
608
849
|
}
|
|
@@ -612,8 +853,8 @@ var githubAdapter = {
|
|
|
612
853
|
function getForgeAdapter(type) {
|
|
613
854
|
switch (type) {
|
|
614
855
|
case "github": return githubAdapter;
|
|
856
|
+
case "gitlab": return gitlabAdapter;
|
|
615
857
|
case "git": return gitAdapter;
|
|
616
|
-
case "gitlab":
|
|
617
858
|
case "gitea":
|
|
618
859
|
case "codeberg": throw new Error(`Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`);
|
|
619
860
|
default: throw new Error(`Unknown forge type: ${String(type)}`);
|
|
@@ -621,21 +862,29 @@ function getForgeAdapter(type) {
|
|
|
621
862
|
}
|
|
622
863
|
//#endregion
|
|
623
864
|
//#region src/slug/parse.ts
|
|
624
|
-
var
|
|
625
|
-
var
|
|
626
|
-
var
|
|
865
|
+
var SEGMENT = String.raw`[\w.-]+`;
|
|
866
|
+
var SHORT_RE = new RegExp(`^(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
|
|
867
|
+
var NAMED_RE = new RegExp(`^(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
|
|
868
|
+
var SSH_RE = new RegExp(`^git@(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT}?)(?:\\.git)?$`);
|
|
627
869
|
function stripGitSuffix(repo) {
|
|
628
870
|
return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
|
|
629
871
|
}
|
|
630
872
|
/**
|
|
873
|
+
* Split `<namespace…>/<repo>` into its two halves: the **last** segment is the
|
|
874
|
+
* repo, everything before it the namespace. Collapses to today's result
|
|
875
|
+
* whenever there are exactly two segments.
|
|
876
|
+
*/
|
|
877
|
+
function splitPath(segments) {
|
|
878
|
+
return {
|
|
879
|
+
owner: segments.slice(0, -1).join("/"),
|
|
880
|
+
repo: stripGitSuffix(segments.at(-1))
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
631
884
|
* 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
|
|
885
|
+
* {@link parseSlug} accepts — `namespace/repo`, `forge:namespace/repo`, SSH
|
|
886
|
+
* and URL — contains a `/`, so a bare term like `gild` can never be one and is
|
|
634
887
|
* 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
888
|
*/
|
|
640
889
|
function looksLikeSlug(input) {
|
|
641
890
|
return input.trim().includes("/");
|
|
@@ -646,8 +895,7 @@ function parseSlug(input) {
|
|
|
646
895
|
const ssh = SSH_RE.exec(trimmed);
|
|
647
896
|
if (ssh) return {
|
|
648
897
|
host: ssh[1],
|
|
649
|
-
|
|
650
|
-
repo: stripGitSuffix(ssh[3])
|
|
898
|
+
...splitPath([...ssh[2].split("/"), ssh[3]])
|
|
651
899
|
};
|
|
652
900
|
if (/^https?:\/\//.test(trimmed)) {
|
|
653
901
|
let url;
|
|
@@ -656,25 +904,22 @@ function parseSlug(input) {
|
|
|
656
904
|
} catch {
|
|
657
905
|
throw new Error(`Invalid URL: ${trimmed}`);
|
|
658
906
|
}
|
|
659
|
-
|
|
660
|
-
|
|
907
|
+
let segments = url.pathname.split("/").filter(Boolean);
|
|
908
|
+
const separator = segments.indexOf("-");
|
|
909
|
+
if (separator !== -1) segments = segments.slice(0, separator);
|
|
910
|
+
if (segments.length < 2) throw new Error(`URL must contain a namespace and repo: ${trimmed}`);
|
|
661
911
|
return {
|
|
662
912
|
host: url.host,
|
|
663
|
-
|
|
664
|
-
repo: stripGitSuffix(segments[1])
|
|
913
|
+
...splitPath(segments)
|
|
665
914
|
};
|
|
666
915
|
}
|
|
667
916
|
const named = NAMED_RE.exec(trimmed);
|
|
668
917
|
if (named) return {
|
|
669
918
|
forgeName: named[1],
|
|
670
|
-
|
|
671
|
-
repo: stripGitSuffix(named[3])
|
|
919
|
+
...splitPath([...named[2].split("/"), named[3]])
|
|
672
920
|
};
|
|
673
921
|
const short = SHORT_RE.exec(trimmed);
|
|
674
|
-
if (short) return
|
|
675
|
-
owner: short[1],
|
|
676
|
-
repo: stripGitSuffix(short[2])
|
|
677
|
-
};
|
|
922
|
+
if (short) return splitPath([...short[1].split("/"), short[2]]);
|
|
678
923
|
throw new Error(`Unrecognized slug format: ${input}`);
|
|
679
924
|
}
|
|
680
925
|
//#endregion
|
|
@@ -1013,35 +1258,43 @@ async function classifyRemotes(candidates) {
|
|
|
1013
1258
|
}));
|
|
1014
1259
|
return results;
|
|
1015
1260
|
}
|
|
1016
|
-
|
|
1261
|
+
/**
|
|
1262
|
+
* Collect the empty namespace directories at and below `path`, deepest first,
|
|
1263
|
+
* and report whether `path` itself turned out to be one.
|
|
1264
|
+
*
|
|
1265
|
+
* A namespace is empty when it holds nothing, or holds only namespaces that
|
|
1266
|
+
* are themselves empty — which is what makes this follow a nested layout
|
|
1267
|
+
* rather than the single owner level it used to assume. The walk never enters
|
|
1268
|
+
* a repo: a `.git` entry means the directory is a checkout, and a checkout is
|
|
1269
|
+
* never a leftover however empty its subdirectories are.
|
|
1270
|
+
*/
|
|
1271
|
+
async function collectEmptyDirs(path, depth, empties) {
|
|
1272
|
+
if (depth > 10) return false;
|
|
1273
|
+
let entries;
|
|
1017
1274
|
try {
|
|
1018
|
-
|
|
1275
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
1019
1276
|
} catch {
|
|
1020
|
-
return
|
|
1277
|
+
return false;
|
|
1021
1278
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1279
|
+
if (entries.some((e) => e.name === ".git")) return false;
|
|
1280
|
+
if (entries.length === 0) {
|
|
1281
|
+
empties.push(path);
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
if (entries.some((e) => !e.isDirectory())) return false;
|
|
1285
|
+
let allEmpty = true;
|
|
1286
|
+
for (const entry of entries) if (!await collectEmptyDirs(join(path, entry.name), depth + 1, empties)) allEmpty = false;
|
|
1287
|
+
if (allEmpty) empties.push(path);
|
|
1288
|
+
return allEmpty;
|
|
1289
|
+
}
|
|
1290
|
+
/** Empty namespace directories (server dir included) under the configured
|
|
1291
|
+
* forge dirs, deepest first. Detection only — no removal. */
|
|
1025
1292
|
async function findEmptyDirs(root, config) {
|
|
1026
1293
|
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
|
-
}
|
|
1294
|
+
for (const forge of Object.values(config.forges)) await collectEmptyDirs(join(root, forge.dir), 0, empties);
|
|
1042
1295
|
return empties;
|
|
1043
1296
|
}
|
|
1044
|
-
/** Remove the dirs from findEmptyDirs (
|
|
1297
|
+
/** Remove the dirs from findEmptyDirs (children before their parents). */
|
|
1045
1298
|
async function pruneEmptyDirs(root, config) {
|
|
1046
1299
|
const empties = await findEmptyDirs(root, config);
|
|
1047
1300
|
let removed = 0;
|
|
@@ -1233,6 +1486,8 @@ function resolveSlug(parsed, options) {
|
|
|
1233
1486
|
forgeName = config.defaultForge;
|
|
1234
1487
|
forge = candidate;
|
|
1235
1488
|
}
|
|
1489
|
+
const depthError = checkNamespaceDepth(forgeName, forge.type, parsed.owner);
|
|
1490
|
+
if (depthError) throw new Error(depthError);
|
|
1236
1491
|
const localPath = join(resolveRoot(config.root, configDir), forge.dir, parsed.owner, parsed.repo);
|
|
1237
1492
|
return {
|
|
1238
1493
|
forgeName,
|
|
@@ -2254,34 +2509,51 @@ var forgeCommand = defineCommand({
|
|
|
2254
2509
|
});
|
|
2255
2510
|
//#endregion
|
|
2256
2511
|
//#region src/repos/import.ts
|
|
2257
|
-
async function
|
|
2512
|
+
async function readEntries(path) {
|
|
2258
2513
|
try {
|
|
2259
|
-
|
|
2514
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
2515
|
+
return {
|
|
2516
|
+
dirs: entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name),
|
|
2517
|
+
isRepo: entries.some((e) => e.name === GIT_MARKER)
|
|
2518
|
+
};
|
|
2260
2519
|
} catch (error) {
|
|
2261
|
-
if (error.code === "ENOENT") return
|
|
2520
|
+
if (error.code === "ENOENT") return null;
|
|
2262
2521
|
throw error;
|
|
2263
2522
|
}
|
|
2264
2523
|
}
|
|
2265
2524
|
/**
|
|
2266
|
-
* Structure-driven
|
|
2267
|
-
*
|
|
2268
|
-
*
|
|
2525
|
+
* Structure-driven walk of `<path>/<serverDir>/<namespace…>/<repo>`. Unlike
|
|
2526
|
+
* `scanRepos` this is config-free: every top-level directory is a candidate
|
|
2527
|
+
* server dir, and the names are discovered rather than configured.
|
|
2528
|
+
*
|
|
2529
|
+
* A `.git` entry ends a branch, so a nested namespace is adopted as readily as
|
|
2530
|
+
* a flat one and a repo's own subdirectories are never mistaken for more of
|
|
2531
|
+
* the layout. A branch that dead-ends without one is still surfaced — that is
|
|
2532
|
+
* the candidate `analyzeLocal` reports as `not-a-git-repo`, which is the whole
|
|
2533
|
+
* reason `import` looks at directories a scan would simply skip.
|
|
2269
2534
|
*/
|
|
2270
|
-
async function
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
}
|
|
2535
|
+
async function discoverBelow(serverDir, dirPath, segments, found) {
|
|
2536
|
+
if (segments.length >= 10) return;
|
|
2537
|
+
const entries = await readEntries(dirPath);
|
|
2538
|
+
const isLeaf = entries === null || entries.dirs.length === 0;
|
|
2539
|
+
if (segments.length >= 2 && (entries?.isRepo || isLeaf)) {
|
|
2540
|
+
found.push({
|
|
2541
|
+
serverDir,
|
|
2542
|
+
owner: segments.slice(0, -1).join("/"),
|
|
2543
|
+
repo: segments.at(-1),
|
|
2544
|
+
localPath: dirPath
|
|
2545
|
+
});
|
|
2546
|
+
return;
|
|
2283
2547
|
}
|
|
2284
|
-
return
|
|
2548
|
+
if (entries === null || entries.isRepo) return;
|
|
2549
|
+
for (const name of entries.dirs) await discoverBelow(serverDir, join(dirPath, name), [...segments, name], found);
|
|
2550
|
+
}
|
|
2551
|
+
async function discoverForgemapLayout(path) {
|
|
2552
|
+
const root = await readEntries(path);
|
|
2553
|
+
if (!root) return [];
|
|
2554
|
+
const found = [];
|
|
2555
|
+
for (const serverDir of root.dirs) await discoverBelow(serverDir, join(path, serverDir), [], found);
|
|
2556
|
+
return found;
|
|
2285
2557
|
}
|
|
2286
2558
|
function forgeTypeForHost(host) {
|
|
2287
2559
|
return host === "github.com" ? "github" : "git";
|
|
@@ -2943,7 +3215,7 @@ var infoCommand = defineCommand({
|
|
|
2943
3215
|
async run({ args }) {
|
|
2944
3216
|
const binary = resolveBinary(process.argv[1]);
|
|
2945
3217
|
const info = {
|
|
2946
|
-
version: "0.7.0-dev.
|
|
3218
|
+
version: "0.7.0-dev.117-ac8b2c6",
|
|
2947
3219
|
build: detectBuild(binary.resolved),
|
|
2948
3220
|
binary,
|
|
2949
3221
|
node: process.version,
|
|
@@ -3754,6 +4026,7 @@ async function runChecks(config, configDir) {
|
|
|
3754
4026
|
const types = new Set(Object.values(config.forges).map((f) => f.type));
|
|
3755
4027
|
const needsGit = types.has("git") || types.size > 0;
|
|
3756
4028
|
const needsGh = types.has("github");
|
|
4029
|
+
const gitlabForges = Object.entries(config.forges).filter(([, forge]) => forge.type === "gitlab");
|
|
3757
4030
|
if (needsGit) checks.push(await hasCommand("git") ? {
|
|
3758
4031
|
name: "git CLI",
|
|
3759
4032
|
severity: "ok",
|
|
@@ -3784,8 +4057,69 @@ async function runChecks(config, configDir) {
|
|
|
3784
4057
|
severity: "fail",
|
|
3785
4058
|
message: "install from https://cli.github.com/"
|
|
3786
4059
|
});
|
|
4060
|
+
if (gitlabForges.length > 0) if (await hasCommand("glab")) {
|
|
4061
|
+
checks.push({
|
|
4062
|
+
name: "glab CLI",
|
|
4063
|
+
severity: "ok",
|
|
4064
|
+
message: "on PATH"
|
|
4065
|
+
});
|
|
4066
|
+
for (const [name, forge] of gitlabForges) {
|
|
4067
|
+
const auth = await execCapture("glab", [
|
|
4068
|
+
"auth",
|
|
4069
|
+
"status",
|
|
4070
|
+
"--hostname",
|
|
4071
|
+
forge.host
|
|
4072
|
+
]);
|
|
4073
|
+
checks.push(auth.code === 0 ? {
|
|
4074
|
+
name: `glab auth (${name})`,
|
|
4075
|
+
severity: "ok",
|
|
4076
|
+
message: `authenticated at ${forge.host}`
|
|
4077
|
+
} : {
|
|
4078
|
+
name: `glab auth (${name})`,
|
|
4079
|
+
severity: "warn",
|
|
4080
|
+
message: `not logged in — run \`glab auth login --hostname ${forge.host}\``
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
} else checks.push({
|
|
4084
|
+
name: "glab CLI",
|
|
4085
|
+
severity: "fail",
|
|
4086
|
+
message: "install from https://gitlab.com/gitlab-org/cli"
|
|
4087
|
+
});
|
|
4088
|
+
checks.push(await layoutCheck(config, configDir));
|
|
3787
4089
|
return checks;
|
|
3788
4090
|
}
|
|
4091
|
+
var HINT_LABEL = {
|
|
4092
|
+
"no-repo": "holds no git repo",
|
|
4093
|
+
"missing-namespace": "is a repo with no namespace above it",
|
|
4094
|
+
"too-deep": `is deeper than 10 levels`
|
|
4095
|
+
};
|
|
4096
|
+
var HINTS_SHOWN = 5;
|
|
4097
|
+
/**
|
|
4098
|
+
* A repo is a directory holding a `.git` entry, so a branch that never reaches
|
|
4099
|
+
* one simply drops out of `list`, `status` and `pick`. That is easy to read as
|
|
4100
|
+
* "where did my repo go", which is exactly the question this command exists to
|
|
4101
|
+
* answer — so name the branches rather than leaving them silent. A hint, never
|
|
4102
|
+
* a failure: an odd layout is not a broken one.
|
|
4103
|
+
*/
|
|
4104
|
+
async function layoutCheck(config, configDir) {
|
|
4105
|
+
const { repos, hints } = await scanLayout({
|
|
4106
|
+
config,
|
|
4107
|
+
configDir
|
|
4108
|
+
});
|
|
4109
|
+
const count = `${repos.length} repo${repos.length === 1 ? "" : "s"}`;
|
|
4110
|
+
if (hints.length === 0) return {
|
|
4111
|
+
name: "layout",
|
|
4112
|
+
severity: "ok",
|
|
4113
|
+
message: count
|
|
4114
|
+
};
|
|
4115
|
+
const shown = hints.slice(0, HINTS_SHOWN).map((hint) => `${hint.path} ${HINT_LABEL[hint.reason]}`);
|
|
4116
|
+
const rest = hints.length > HINTS_SHOWN ? ` (+${hints.length - HINTS_SHOWN} more)` : "";
|
|
4117
|
+
return {
|
|
4118
|
+
name: "layout",
|
|
4119
|
+
severity: "warn",
|
|
4120
|
+
message: `${count}; ${shown.join(", ")}${rest}`
|
|
4121
|
+
};
|
|
4122
|
+
}
|
|
3789
4123
|
function severitySymbol(severity) {
|
|
3790
4124
|
if (severity === "ok") return colors.green("✓");
|
|
3791
4125
|
if (severity === "warn") return colors.yellow("!");
|
|
@@ -4081,7 +4415,7 @@ var completionCommand = defineCommand({
|
|
|
4081
4415
|
runMain(defineCommand({
|
|
4082
4416
|
meta: {
|
|
4083
4417
|
name: "forgemap",
|
|
4084
|
-
version: "0.7.0-dev.
|
|
4418
|
+
version: "0.7.0-dev.117-ac8b2c6",
|
|
4085
4419
|
description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
|
|
4086
4420
|
},
|
|
4087
4421
|
subCommands: {
|