forgemap 0.7.0 → 0.8.0-dev.133-c1be991

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.
@@ -76,7 +76,8 @@ function findConfigUp(start) {
76
76
  }
77
77
  /** Fallback config under $XDG_CONFIG_HOME/forgemap (or ~/.config/forgemap). */
78
78
  function findGlobalConfig() {
79
- const dir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "forgemap");
79
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
80
+ const dir = join(base, "forgemap");
80
81
  for (const baseName of CONFIG_BASENAMES) {
81
82
  const candidate = join(dir, baseName);
82
83
  if (existsSync(candidate)) return candidate;
@@ -175,36 +176,186 @@ async function loadForgeMapConfig(options = {}) {
175
176
  };
176
177
  }
177
178
  //#endregion
179
+ //#region src/utils/concurrency.ts
180
+ /**
181
+ * Map over `items` running at most `limit` calls of `fn` at once, preserving
182
+ * input order in the result. Keeps `import` from spawning one subprocess per
183
+ * repo all at once when checking remotes across a large tree.
184
+ */
185
+ async function mapLimit(items, limit, fn) {
186
+ const results = Array.from({ length: items.length });
187
+ const max = Math.max(1, Math.min(limit, items.length));
188
+ let next = 0;
189
+ async function worker() {
190
+ while (next < items.length) {
191
+ const index = next++;
192
+ results[index] = await fn(items[index], index);
193
+ }
194
+ }
195
+ await Promise.all(Array.from({ length: max }, () => worker()));
196
+ return results;
197
+ }
198
+ /**
199
+ * The entry that marks a directory as a repo. A **file** counts as much as a
200
+ * directory: linked worktrees and submodules record their git dir in a `.git`
201
+ * file, and an `isDirectory()` test would skip them silently.
202
+ */
203
+ var GIT_MARKER = ".git";
204
+ /**
205
+ * How many namespace segments a forge type accepts. GitHub, Gitea and Codeberg
206
+ * have exactly one level of owner; GitLab nests arbitrarily, and `git` is the
207
+ * documented fallback for a GitLab-shaped remote, so it nests too.
208
+ */
209
+ function namespaceDepthLimit(type) {
210
+ switch (type) {
211
+ case "gitlab":
212
+ case "git": return 9;
213
+ case "github":
214
+ case "gitea":
215
+ case "codeberg": return 1;
216
+ }
217
+ }
218
+ /**
219
+ * Check a parsed namespace against the forge it was resolved to. Returns an
220
+ * error message, or `null` when the depth is acceptable.
221
+ *
222
+ * This lives here rather than in `parseSlug` on purpose: the parser has no
223
+ * forge, and keeping it pure is worth more than an earlier error message.
224
+ */
225
+ function checkNamespaceDepth(forgeName, type, namespace) {
226
+ const depth = namespace.split("/").filter(Boolean).length;
227
+ const limit = namespaceDepthLimit(type);
228
+ if (depth <= limit) return null;
229
+ if (limit === 1) return `Forge "${forgeName}" (type ${type}) does not support nested namespaces: "${namespace}" has ${depth} segments, expected 1.`;
230
+ return `Namespace "${namespace}" is ${depth} segments deep; forgemap supports at most ${limit}.`;
231
+ }
232
+ //#endregion
178
233
  //#region src/repos/scan.ts
179
- async function listDirs$1(path) {
234
+ var WALK_CONCURRENCY = 32;
235
+ async function readEntries$1(path) {
180
236
  try {
181
- return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
182
- } catch (error) {
183
- if (error.code === "ENOENT") return [];
184
- throw error;
237
+ const entries = await readdir(path, { withFileTypes: true });
238
+ return {
239
+ dirs: entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name),
240
+ isRepo: entries.some((e) => e.name === GIT_MARKER)
241
+ };
242
+ } catch {
243
+ return null;
185
244
  }
186
245
  }
187
- async function scanRepos(options) {
246
+ async function safeStat(path) {
247
+ try {
248
+ const s = await stat(path);
249
+ return Math.trunc(s.mtimeMs);
250
+ } catch {
251
+ return 0;
252
+ }
253
+ }
254
+ /**
255
+ * One rule for every forge type: a directory holding a `.git` entry **is** a
256
+ * repo, and everything above it is namespace. The walk stops at the first
257
+ * marker and never descends into a repo, which keeps submodules and nested
258
+ * checkouts out without a special case for either.
259
+ *
260
+ * `segments` is the path accumulated below the forge dir; the repo takes the
261
+ * last one and the namespace the rest, so a repo needs at least two.
262
+ *
263
+ * The cap counts those segments inclusive of the repo's own — a namespace at
264
+ * exactly MAX_NAMESPACE_DEPTH must still have its repo visited, or the
265
+ * resolver would accept a path the scanner can never find.
266
+ */
267
+ async function walk(ctx, dirPath, segments) {
268
+ if (segments.length > 10) {
269
+ ctx.hints.push({
270
+ path: dirPath,
271
+ reason: "too-deep"
272
+ });
273
+ return;
274
+ }
275
+ const entries = await readEntries$1(dirPath);
276
+ if (!entries) {
277
+ if (segments.length === 0) ctx.entries.push([dirPath, "d:0:[]"]);
278
+ else ctx.hints.push({
279
+ path: dirPath,
280
+ reason: "no-repo"
281
+ });
282
+ return;
283
+ }
284
+ if (entries.isRepo) {
285
+ ctx.entries.push([dirPath, "r"]);
286
+ if (segments.length < 2) {
287
+ ctx.hints.push({
288
+ path: dirPath,
289
+ reason: "missing-namespace"
290
+ });
291
+ return;
292
+ }
293
+ const owner = segments.slice(0, -1).join("/");
294
+ const repo = segments.at(-1);
295
+ ctx.repos.push({
296
+ forgeName: ctx.forgeName,
297
+ forge: ctx.forge,
298
+ owner,
299
+ repo,
300
+ localPath: dirPath,
301
+ slug: `${owner}/${repo}`
302
+ });
303
+ return;
304
+ }
305
+ const names = [...entries.dirs].sort();
306
+ const mtime = await safeStat(dirPath);
307
+ ctx.entries.push([dirPath, `d:${mtime}:${JSON.stringify(names)}`]);
308
+ if (names.length === 0) {
309
+ if (segments.length > 0) ctx.hints.push({
310
+ path: dirPath,
311
+ reason: "no-repo"
312
+ });
313
+ return;
314
+ }
315
+ await mapLimit(names, WALK_CONCURRENCY, (name) => walk(ctx, join(dirPath, name), [...segments, name]));
316
+ }
317
+ /**
318
+ * Walk the configured layout once, producing the repos, the branches that
319
+ * yielded none, and a fingerprint of what was observed.
320
+ *
321
+ * The fingerprint records each namespace directory's mtime **and** its sorted
322
+ * child names, plus which directories turned out to be repos. The names are
323
+ * what make it reliable: mtimes compare at millisecond granularity, so two
324
+ * clones landing inside the same millisecond hash identically and a stale
325
+ * cache would win. Recording the repo classification is what catches a plain
326
+ * directory becoming a checkout (`git init`) without any name moving at all.
327
+ */
328
+ async function scanLayout(options) {
188
329
  const { config, configDir } = options;
189
330
  const root = resolveRoot(config.root, configDir);
331
+ const contexts = await mapLimit(Object.entries(config.forges), WALK_CONCURRENCY, async ([forgeName, forge]) => {
332
+ const ctx = {
333
+ forgeName,
334
+ forge,
335
+ repos: [],
336
+ hints: [],
337
+ entries: []
338
+ };
339
+ await walk(ctx, join(root, forge.dir), []);
340
+ return ctx;
341
+ });
342
+ const entries = [[root, `m:${await safeStat(root)}`]];
190
343
  const repos = [];
191
- for (const [forgeName, forge] of Object.entries(config.forges)) {
192
- const forgeRoot = join(root, forge.dir);
193
- const owners = await listDirs$1(forgeRoot);
194
- for (const owner of owners) {
195
- const ownerPath = join(forgeRoot, owner);
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
- }
344
+ const hints = [];
345
+ for (const ctx of contexts) {
346
+ repos.push(...ctx.repos);
347
+ hints.push(...ctx.hints);
348
+ entries.push(...ctx.entries);
206
349
  }
207
- return repos;
350
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
351
+ return {
352
+ repos,
353
+ hints,
354
+ fingerprint: createHash("sha1").update(entries.map(([p, marker]) => `${p}:${marker}`).join("\n")).digest("hex")
355
+ };
356
+ }
357
+ async function scanRepos(options) {
358
+ return (await scanLayout(options)).repos;
208
359
  }
209
360
  //#endregion
210
361
  //#region src/repos/cache.ts
@@ -223,54 +374,18 @@ function cachePath(root) {
223
374
  const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
224
375
  return join(cacheDir(), `scan-${hash}.json`);
225
376
  }
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
377
  /**
242
- * Fingerprint of the layout: directory mtimes for root, forge.dir and
243
- * every owner, plus the repo names each owner holds. Catches new clones
244
- * / removals at any of those levels. Stops short of stat-ing each repo
245
- * dir — that would mostly duplicate the scan it's meant to avoid.
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.
378
+ * Fingerprint of the layout, computed by the same walk that finds the repos —
379
+ * see {@link scanLayout}, which owns what goes into it. Since a directory is
380
+ * only known to be a repo once its entries have been read, the fingerprint
381
+ * cannot be cheaper than the scan; producing both from one walk is what keeps
382
+ * the cold path from paying for the tree twice.
257
383
  */
258
384
  async function computeFingerprint(config, configDir) {
259
- const root = resolveRoot(config.root, configDir);
260
- const perForge = await Promise.all(Object.values(config.forges).map(async (forge) => {
261
- const forgeRoot = join(root, forge.dir);
262
- const [forgeMtime, owners] = await Promise.all([safeStat(forgeRoot), safeListDirs(forgeRoot)]);
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");
385
+ return (await scanLayout({
386
+ config,
387
+ configDir
388
+ })).fingerprint;
274
389
  }
275
390
  async function readCacheFile(file) {
276
391
  try {
@@ -292,26 +407,35 @@ async function scanReposCached(options) {
292
407
  if (cached) {
293
408
  const age = Date.now() - cached.writtenAt;
294
409
  if (trustTtl && age < ttl()) return cached.repos;
295
- const fingerprint = await computeFingerprint(config, configDir);
296
- if (cached.fingerprint === fingerprint) {
410
+ const scan = await scanLayout({
411
+ config,
412
+ configDir
413
+ });
414
+ if (cached.fingerprint === scan.fingerprint) {
297
415
  await writeCacheFile(file, {
298
416
  ...cached,
299
417
  writtenAt: Date.now()
300
418
  });
301
419
  return cached.repos;
302
420
  }
421
+ await writeCacheFile(file, {
422
+ fingerprint: scan.fingerprint,
423
+ writtenAt: Date.now(),
424
+ repos: scan.repos
425
+ });
426
+ return scan.repos;
303
427
  }
304
428
  }
305
- const repos = await scanRepos({
429
+ const scan = await scanLayout({
306
430
  config,
307
431
  configDir
308
432
  });
309
433
  await writeCacheFile(file, {
310
- fingerprint: await computeFingerprint(config, configDir),
434
+ fingerprint: scan.fingerprint,
311
435
  writtenAt: Date.now(),
312
- repos
436
+ repos: scan.repos
313
437
  });
314
- return repos;
438
+ return scan.repos;
315
439
  }
316
440
  /**
317
441
  * Append a freshly-cloned repo to the cache without touching the
@@ -348,9 +472,15 @@ async function removeCachedRepo(options, localPath) {
348
472
  }
349
473
  //#endregion
350
474
  //#region src/utils/exec.ts
351
- function execInherit(command, args) {
475
+ function execInherit(command, args, options = {}) {
352
476
  return new Promise((resolvePromise, rejectPromise) => {
353
- const child = spawn(command, args, { stdio: "inherit" });
477
+ const child = spawn(command, args, {
478
+ env: options.env ? {
479
+ ...process.env,
480
+ ...options.env
481
+ } : void 0,
482
+ stdio: "inherit"
483
+ });
354
484
  child.on("error", rejectPromise);
355
485
  child.on("close", (code) => {
356
486
  resolvePromise({ code: code ?? 0 });
@@ -476,33 +606,13 @@ function isRepoMissing(stderr) {
476
606
  return /repository not found/.test(s) || /remote:.*not found/.test(s) || /\b404\b/.test(s) || /could not find repository/.test(s);
477
607
  }
478
608
  //#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
609
  //#region src/forges/github.ts
500
- var GRAPHQL_CHUNK = 100;
501
- var FALLBACK_CONCURRENCY = 8;
610
+ var GRAPHQL_CHUNK$1 = 100;
611
+ var FALLBACK_CONCURRENCY$1 = 8;
502
612
  var GH_TIMEOUT_MS = 2e4;
503
613
  /** Single-repo REST check. `gh api` follows the redirect a renamed/transferred
504
614
  * repo issues, so the returned full_name reveals the canonical owner/repo. */
505
- async function checkOne(owner, repo) {
615
+ async function checkOne$1(owner, repo) {
506
616
  const result = await execCapture("gh", [
507
617
  "api",
508
618
  `repos/${owner}/${repo}`,
@@ -539,7 +649,7 @@ async function checkOne(owner, repo) {
539
649
  canonicalUrl: `https://github.com/${canonicalOwner}/${canonicalRepo}.git`
540
650
  };
541
651
  }
542
- function buildQuery(chunk) {
652
+ function buildQuery$1(chunk) {
543
653
  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
654
  }
545
655
  var githubAdapter = {
@@ -558,7 +668,7 @@ var githubAdapter = {
558
668
  state: "unknown",
559
669
  reason: "gh not installed"
560
670
  };
561
- return checkOne(owner, repo);
671
+ return checkOne$1(owner, repo);
562
672
  },
563
673
  /**
564
674
  * One GraphQL request resolves up to GRAPHQL_CHUNK repos at once. GraphQL
@@ -573,13 +683,13 @@ var githubAdapter = {
573
683
  reason: "gh not installed"
574
684
  }));
575
685
  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);
686
+ for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK$1) {
687
+ const chunk = inputs.slice(start, start + GRAPHQL_CHUNK$1);
578
688
  const res = await execCapture("gh", [
579
689
  "api",
580
690
  "graphql",
581
691
  "-f",
582
- `query=${buildQuery(chunk)}`
692
+ `query=${buildQuery$1(chunk)}`
583
693
  ], { timeoutMs: GH_TIMEOUT_MS });
584
694
  let data = null;
585
695
  try {
@@ -601,8 +711,153 @@ var githubAdapter = {
601
711
  }
602
712
  }
603
713
  }
714
+ await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY$1, async (index) => {
715
+ results[index] = await checkOne$1(inputs[index].owner, inputs[index].repo);
716
+ });
717
+ return results;
718
+ }
719
+ };
720
+ //#endregion
721
+ //#region src/forges/gitlab.ts
722
+ var GRAPHQL_CHUNK = 100;
723
+ var FALLBACK_CONCURRENCY = 8;
724
+ var GLAB_TIMEOUT_MS = 2e4;
725
+ var MISSING_GLAB = "GitLab CLI (`glab`) is not installed. Install it from https://gitlab.com/gitlab-org/cli and run `glab auth login`.";
726
+ /**
727
+ * Pin every invocation to the forge's own host. `glab repo clone` has no
728
+ * `--hostname` flag, and leaning on the user's global `glab config set host`
729
+ * would make behaviour depend on state forgemap never set.
730
+ */
731
+ function glabEnv(forge) {
732
+ return { GITLAB_HOST: forge.host };
733
+ }
734
+ /** Split GitLab's `full path` into namespace and project. */
735
+ function splitFullPath(fullPath) {
736
+ const segments = fullPath.split("/").filter(Boolean);
737
+ if (segments.length < 2) return null;
738
+ return {
739
+ owner: segments.slice(0, -1).join("/"),
740
+ repo: segments.at(-1)
741
+ };
742
+ }
743
+ /**
744
+ * Single-project REST check. `projects/<url-encoded path>` answers with
745
+ * `path_with_namespace`, GitLab's counterpart to GitHub's `full_name`, so a
746
+ * differing answer is a move and a 404 is a deletion.
747
+ */
748
+ async function checkOne(forge, owner, repo) {
749
+ const result = await execCapture("glab", ["api", `projects/${encodeURIComponent(`${owner}/${repo}`)}`], {
750
+ timeoutMs: GLAB_TIMEOUT_MS,
751
+ env: glabEnv(forge)
752
+ });
753
+ if (result.timedOut) return {
754
+ state: "unknown",
755
+ reason: "glab api timed out"
756
+ };
757
+ if (result.code !== 0) {
758
+ if (/404|not found/i.test(result.stderr)) return { state: "gone" };
759
+ return {
760
+ state: "unknown",
761
+ reason: result.stderr.trim() || `glab api exited with code ${result.code}`
762
+ };
763
+ }
764
+ let fullPath;
765
+ try {
766
+ fullPath = JSON.parse(result.stdout).path_with_namespace;
767
+ } catch {
768
+ fullPath = void 0;
769
+ }
770
+ const canonical = fullPath ? splitFullPath(fullPath) : null;
771
+ if (!canonical) return {
772
+ state: "unknown",
773
+ reason: "could not parse glab api path_with_namespace"
774
+ };
775
+ if (canonical.owner === owner && canonical.repo === repo) return {
776
+ state: "exists",
777
+ canonical
778
+ };
779
+ return {
780
+ state: "moved",
781
+ canonical,
782
+ canonicalUrl: `https://${forge.host}/${canonical.owner}/${canonical.repo}.git`
783
+ };
784
+ }
785
+ function buildQuery(chunk) {
786
+ return `query {\n${chunk.map((input, i) => ` r${i}: project(fullPath: ${JSON.stringify(`${input.owner}/${input.repo}`)}) { fullPath }`).join("\n")}\n}`;
787
+ }
788
+ var gitlabAdapter = {
789
+ async clone({ forge, owner, repo, dest }) {
790
+ if (!await hasCommand("glab")) throw new Error(MISSING_GLAB);
791
+ const { code } = await execInherit("glab", [
792
+ "repo",
793
+ "clone",
794
+ `${owner}/${repo}`,
795
+ dest
796
+ ], { env: glabEnv(forge) });
797
+ if (code !== 0) throw new Error(`glab repo clone exited with code ${code}`);
798
+ },
799
+ async checkRemote({ forge, owner, repo }) {
800
+ if (!await hasCommand("glab")) return {
801
+ state: "unknown",
802
+ reason: "glab not installed"
803
+ };
804
+ return checkOne(forge, owner, repo);
805
+ },
806
+ /**
807
+ * Mirrors the GitHub adapter: one aliased GraphQL request resolves up to
808
+ * GRAPHQL_CHUNK projects, and each miss — `null` could mean gone *or*
809
+ * renamed — costs a single REST call to tell the two apart.
810
+ *
811
+ * A batch may span several configured GitLab forges, so the inputs are
812
+ * grouped by host before they are chunked: one server must never be asked
813
+ * about another's projects. Where the same full path exists on both — a
814
+ * public mirror of an internal project — the wrong server would otherwise
815
+ * answer `exists` for a project this one does not have.
816
+ */
817
+ async checkRemotes(inputs) {
818
+ if (inputs.length === 0) return [];
819
+ if (!await hasCommand("glab")) return inputs.map(() => ({
820
+ state: "unknown",
821
+ reason: "glab not installed"
822
+ }));
823
+ const results = Array.from({ length: inputs.length }, () => null);
824
+ const byHost = /* @__PURE__ */ new Map();
825
+ inputs.forEach((input, index) => {
826
+ const indices = byHost.get(input.forge.host);
827
+ if (indices) indices.push(index);
828
+ else byHost.set(input.forge.host, [index]);
829
+ });
830
+ for (const indices of byHost.values()) for (let start = 0; start < indices.length; start += GRAPHQL_CHUNK) {
831
+ const slice = indices.slice(start, start + GRAPHQL_CHUNK);
832
+ const chunk = slice.map((index) => inputs[index]);
833
+ const res = await execCapture("glab", [
834
+ "api",
835
+ "graphql",
836
+ "-f",
837
+ `query=${buildQuery(chunk)}`
838
+ ], {
839
+ timeoutMs: GLAB_TIMEOUT_MS,
840
+ env: glabEnv(chunk[0].forge)
841
+ });
842
+ let data = null;
843
+ try {
844
+ const parsed = JSON.parse(res.stdout);
845
+ data = parsed.data ?? parsed;
846
+ } catch {
847
+ data = null;
848
+ }
849
+ for (let i = 0; i < chunk.length; i++) {
850
+ const node = data?.[`r${i}`];
851
+ const canonical = node?.fullPath ? splitFullPath(node.fullPath) : null;
852
+ if (canonical) results[slice[i]] = {
853
+ state: "exists",
854
+ canonical
855
+ };
856
+ }
857
+ }
604
858
  await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY, async (index) => {
605
- results[index] = await checkOne(inputs[index].owner, inputs[index].repo);
859
+ const input = inputs[index];
860
+ results[index] = await checkOne(input.forge, input.owner, input.repo);
606
861
  });
607
862
  return results;
608
863
  }
@@ -612,8 +867,8 @@ var githubAdapter = {
612
867
  function getForgeAdapter(type) {
613
868
  switch (type) {
614
869
  case "github": return githubAdapter;
870
+ case "gitlab": return gitlabAdapter;
615
871
  case "git": return gitAdapter;
616
- case "gitlab":
617
872
  case "gitea":
618
873
  case "codeberg": throw new Error(`Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`);
619
874
  default: throw new Error(`Unknown forge type: ${String(type)}`);
@@ -621,21 +876,29 @@ function getForgeAdapter(type) {
621
876
  }
622
877
  //#endregion
623
878
  //#region src/slug/parse.ts
624
- var SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
625
- var NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
626
- var SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
879
+ var SEGMENT = String.raw`[\w.-]+`;
880
+ var SHORT_RE = new RegExp(`^(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
881
+ var NAMED_RE = new RegExp(`^(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT})$`);
882
+ var SSH_RE = new RegExp(`^git@(${SEGMENT}):(${SEGMENT}(?:/${SEGMENT})*)/(${SEGMENT}?)(?:\\.git)?$`);
627
883
  function stripGitSuffix(repo) {
628
884
  return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
629
885
  }
630
886
  /**
887
+ * Split `<namespace…>/<repo>` into its two halves: the **last** segment is the
888
+ * repo, everything before it the namespace. Collapses to today's result
889
+ * whenever there are exactly two segments.
890
+ */
891
+ function splitPath(segments) {
892
+ return {
893
+ owner: segments.slice(0, -1).join("/"),
894
+ repo: stripGitSuffix(segments.at(-1))
895
+ };
896
+ }
897
+ /**
631
898
  * Whether the input is *shaped* like a strict slug. Every form
632
- * {@link parseSlug} accepts — `owner/repo`, `forge:owner/repo`, SSH and
633
- * URL — contains a `/`, so a bare term like `gild` can never be one and is
899
+ * {@link parseSlug} accepts — `namespace/repo`, `forge:namespace/repo`, SSH
900
+ * and URL — contains a `/`, so a bare term like `gild` can never be one and is
634
901
  * 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
902
  */
640
903
  function looksLikeSlug(input) {
641
904
  return input.trim().includes("/");
@@ -646,8 +909,7 @@ function parseSlug(input) {
646
909
  const ssh = SSH_RE.exec(trimmed);
647
910
  if (ssh) return {
648
911
  host: ssh[1],
649
- owner: ssh[2],
650
- repo: stripGitSuffix(ssh[3])
912
+ ...splitPath([...ssh[2].split("/"), ssh[3]])
651
913
  };
652
914
  if (/^https?:\/\//.test(trimmed)) {
653
915
  let url;
@@ -656,25 +918,22 @@ function parseSlug(input) {
656
918
  } catch {
657
919
  throw new Error(`Invalid URL: ${trimmed}`);
658
920
  }
659
- const segments = url.pathname.split("/").filter(Boolean);
660
- if (segments.length < 2) throw new Error(`URL must contain owner and repo: ${trimmed}`);
921
+ let segments = url.pathname.split("/").filter(Boolean);
922
+ const separator = segments.indexOf("-");
923
+ if (separator !== -1) segments = segments.slice(0, separator);
924
+ if (segments.length < 2) throw new Error(`URL must contain a namespace and repo: ${trimmed}`);
661
925
  return {
662
926
  host: url.host,
663
- owner: segments[0],
664
- repo: stripGitSuffix(segments[1])
927
+ ...splitPath(segments)
665
928
  };
666
929
  }
667
930
  const named = NAMED_RE.exec(trimmed);
668
931
  if (named) return {
669
932
  forgeName: named[1],
670
- owner: named[2],
671
- repo: stripGitSuffix(named[3])
933
+ ...splitPath([...named[2].split("/"), named[3]])
672
934
  };
673
935
  const short = SHORT_RE.exec(trimmed);
674
- if (short) return {
675
- owner: short[1],
676
- repo: stripGitSuffix(short[2])
677
- };
936
+ if (short) return splitPath([...short[1].split("/"), short[2]]);
678
937
  throw new Error(`Unrecognized slug format: ${input}`);
679
938
  }
680
939
  //#endregion
@@ -958,16 +1217,31 @@ function remoteBlocker(state) {
958
1217
  if (state === "exists" || state === "moved") return null;
959
1218
  return state === "gone" ? "remote no longer exists" : "remote unreachable";
960
1219
  }
961
- /** Check each candidate's remote, grouped by forge so GitHub can batch. */
1220
+ /**
1221
+ * Check each candidate's remote, grouped by forge **type and host** so an
1222
+ * adapter can batch.
1223
+ *
1224
+ * The host is half the key, not a detail: two configured forges of one type —
1225
+ * gitlab.com beside a self-hosted instance — are two different servers, and a
1226
+ * batch spanning both would ask one of them about the other's projects. Where
1227
+ * the same path exists on each (a public mirror of an internal project), that
1228
+ * answers `exists` for the wrong server, and `remoteBlocker` reads `exists` as
1229
+ * "safe to delete".
1230
+ */
962
1231
  async function classifyRemotes(candidates) {
963
- const byType = /* @__PURE__ */ new Map();
1232
+ const groups = /* @__PURE__ */ new Map();
964
1233
  for (const c of candidates) {
965
- const list = byType.get(c.repo.forge.type);
966
- if (list) list.push(c);
967
- else byType.set(c.repo.forge.type, [c]);
1234
+ const { type, host } = c.repo.forge;
1235
+ const key = `${type}\0${host}`;
1236
+ const group = groups.get(key);
1237
+ if (group) group.items.push(c);
1238
+ else groups.set(key, {
1239
+ type,
1240
+ items: [c]
1241
+ });
968
1242
  }
969
1243
  const results = /* @__PURE__ */ new Map();
970
- await Promise.all(Array.from(byType, async ([type, items]) => {
1244
+ await Promise.all(Array.from(groups.values(), async ({ type, items }) => {
971
1245
  const inputs = items.map((c) => ({
972
1246
  forge: c.repo.forge,
973
1247
  owner: c.owner,
@@ -1013,35 +1287,43 @@ async function classifyRemotes(candidates) {
1013
1287
  }));
1014
1288
  return results;
1015
1289
  }
1016
- async function safeReaddir(path) {
1290
+ /**
1291
+ * Collect the empty namespace directories at and below `path`, deepest first,
1292
+ * and report whether `path` itself turned out to be one.
1293
+ *
1294
+ * A namespace is empty when it holds nothing, or holds only namespaces that
1295
+ * are themselves empty — which is what makes this follow a nested layout
1296
+ * rather than the single owner level it used to assume. The walk never enters
1297
+ * a repo: a `.git` entry means the directory is a checkout, and a checkout is
1298
+ * never a leftover however empty its subdirectories are.
1299
+ */
1300
+ async function collectEmptyDirs(path, depth, empties) {
1301
+ if (depth > 10) return false;
1302
+ let entries;
1017
1303
  try {
1018
- return await readdir(path);
1304
+ entries = await readdir(path, { withFileTypes: true });
1019
1305
  } catch {
1020
- return null;
1306
+ return false;
1021
1307
  }
1022
- }
1023
- /** Empty owner directories (and a server directory that holds only such empty
1024
- * owners) under the configured forge dirs. Detection only — no removal. */
1308
+ if (entries.some((e) => e.name === ".git")) return false;
1309
+ if (entries.length === 0) {
1310
+ empties.push(path);
1311
+ return true;
1312
+ }
1313
+ if (entries.some((e) => !e.isDirectory())) return false;
1314
+ let allEmpty = true;
1315
+ for (const entry of entries) if (!await collectEmptyDirs(join(path, entry.name), depth + 1, empties)) allEmpty = false;
1316
+ if (allEmpty) empties.push(path);
1317
+ return allEmpty;
1318
+ }
1319
+ /** Empty namespace directories (server dir included) under the configured
1320
+ * forge dirs, deepest first. Detection only — no removal. */
1025
1321
  async function findEmptyDirs(root, config) {
1026
1322
  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
- }
1323
+ for (const forge of Object.values(config.forges)) await collectEmptyDirs(join(root, forge.dir), 0, empties);
1042
1324
  return empties;
1043
1325
  }
1044
- /** Remove the dirs from findEmptyDirs (owners before server dirs). */
1326
+ /** Remove the dirs from findEmptyDirs (children before their parents). */
1045
1327
  async function pruneEmptyDirs(root, config) {
1046
1328
  const empties = await findEmptyDirs(root, config);
1047
1329
  let removed = 0;
@@ -1233,7 +1515,10 @@ function resolveSlug(parsed, options) {
1233
1515
  forgeName = config.defaultForge;
1234
1516
  forge = candidate;
1235
1517
  }
1236
- const localPath = join(resolveRoot(config.root, configDir), forge.dir, parsed.owner, parsed.repo);
1518
+ const depthError = checkNamespaceDepth(forgeName, forge.type, parsed.owner);
1519
+ if (depthError) throw new Error(depthError);
1520
+ const root = resolveRoot(config.root, configDir);
1521
+ const localPath = join(root, forge.dir, parsed.owner, parsed.repo);
1237
1522
  return {
1238
1523
  forgeName,
1239
1524
  forge,
@@ -1417,7 +1702,8 @@ ${forgeEntries}
1417
1702
  /** Write a `forgemap.config.ts` into `outDir`. Returns null when the file
1418
1703
  * already exists and `force` is not set (the caller decides how to report). */
1419
1704
  async function writeConfigFile(config, options) {
1420
- const target = join(resolve(process.cwd(), options.outDir), "forgemap.config.ts");
1705
+ const outDir = resolve(process.cwd(), options.outDir);
1706
+ const target = join(outDir, "forgemap.config.ts");
1421
1707
  await mkdir(dirname(target), { recursive: true });
1422
1708
  try {
1423
1709
  await writeFile(target, renderConfigModule(config), {
@@ -1441,6 +1727,57 @@ var DEFAULT_CONFIG = {
1441
1727
  dir: "comGithub"
1442
1728
  } }
1443
1729
  };
1730
+ var configInitCommand = defineCommand({
1731
+ meta: {
1732
+ name: "init",
1733
+ description: "Create a forgemap.config.ts in the current (or given) directory"
1734
+ },
1735
+ args: {
1736
+ out: {
1737
+ type: "string",
1738
+ description: "Directory to write forgemap.config.ts into",
1739
+ default: "."
1740
+ },
1741
+ force: {
1742
+ type: "boolean",
1743
+ description: "Overwrite if forgemap.config.ts already exists",
1744
+ default: false
1745
+ }
1746
+ },
1747
+ async run({ args }) {
1748
+ const result = await writeConfigFile(DEFAULT_CONFIG, {
1749
+ outDir: args.out,
1750
+ force: args.force
1751
+ });
1752
+ if (!result) {
1753
+ const target = join(resolve(process.cwd(), args.out), "forgemap.config.ts");
1754
+ consola.error(`${target} already exists. Use --force to overwrite.`);
1755
+ process.exitCode = 1;
1756
+ return;
1757
+ }
1758
+ consola.success(`Wrote ${result.path}`);
1759
+ }
1760
+ });
1761
+ //#endregion
1762
+ //#region src/commands/config/show.ts
1763
+ var configShowCommand = defineCommand({
1764
+ meta: {
1765
+ name: "show",
1766
+ description: "Print the resolved forgemap config and its source path"
1767
+ },
1768
+ args: { config: {
1769
+ type: "string",
1770
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
1771
+ } },
1772
+ async run({ args }) {
1773
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1774
+ process.stdout.write(JSON.stringify({
1775
+ configFile: loaded.configFile ?? null,
1776
+ cwd: loaded.cwd,
1777
+ config: loaded.config
1778
+ }, null, 2) + "\n");
1779
+ }
1780
+ });
1444
1781
  //#endregion
1445
1782
  //#region src/commands/config/index.ts
1446
1783
  var configCommand = defineCommand({
@@ -1449,55 +1786,8 @@ var configCommand = defineCommand({
1449
1786
  description: "Manage the forgemap config file"
1450
1787
  },
1451
1788
  subCommands: {
1452
- init: defineCommand({
1453
- meta: {
1454
- name: "init",
1455
- description: "Create a forgemap.config.ts in the current (or given) directory"
1456
- },
1457
- args: {
1458
- out: {
1459
- type: "string",
1460
- description: "Directory to write forgemap.config.ts into",
1461
- default: "."
1462
- },
1463
- force: {
1464
- type: "boolean",
1465
- description: "Overwrite if forgemap.config.ts already exists",
1466
- default: false
1467
- }
1468
- },
1469
- async run({ args }) {
1470
- const result = await writeConfigFile(DEFAULT_CONFIG, {
1471
- outDir: args.out,
1472
- force: args.force
1473
- });
1474
- if (!result) {
1475
- const target = join(resolve(process.cwd(), args.out), "forgemap.config.ts");
1476
- consola.error(`${target} already exists. Use --force to overwrite.`);
1477
- process.exitCode = 1;
1478
- return;
1479
- }
1480
- consola.success(`Wrote ${result.path}`);
1481
- }
1482
- }),
1483
- show: defineCommand({
1484
- meta: {
1485
- name: "show",
1486
- description: "Print the resolved forgemap config and its source path"
1487
- },
1488
- args: { config: {
1489
- type: "string",
1490
- description: "Path to forgemap.config.ts (overrides walk-up discovery)"
1491
- } },
1492
- async run({ args }) {
1493
- const loaded = await loadForgeMapConfig({ configFile: args.config });
1494
- process.stdout.write(JSON.stringify({
1495
- configFile: loaded.configFile ?? null,
1496
- cwd: loaded.cwd,
1497
- config: loaded.config
1498
- }, null, 2) + "\n");
1499
- }
1500
- })
1789
+ init: configInitCommand,
1790
+ show: configShowCommand
1501
1791
  }
1502
1792
  });
1503
1793
  //#endregion
@@ -2215,14 +2505,16 @@ var forgeRemoveCommand = defineCommand({
2215
2505
  if (!(key in forges)) return fail(`No forge "${key}" in ${file}. Configured: ${Object.keys(forges).join(", ")}.`);
2216
2506
  const remaining = Object.keys(forges).filter((k) => k !== key);
2217
2507
  let newDefault;
2218
- if (loaded.config.defaultForge === key && remaining.length > 0) if (typeof args.default === "string") {
2219
- if (!remaining.includes(args.default)) return fail(`Cannot set default to "${args.default}" — not a remaining forge (${remaining.join(", ")}).`);
2220
- newDefault = args.default;
2221
- } else if (tty) {
2222
- const answer = await promptSelect(`"${key}" is the default forge. Pick a new default:`, [...remaining, LEAVE_UNSET]);
2223
- if (answer === null) return abort();
2224
- if (answer !== LEAVE_UNSET) newDefault = answer;
2225
- } else consola.warn(`Removing the default forge "${key}"; defaultForge now points at a missing forge. Pass --default to reassign it.`);
2508
+ if (loaded.config.defaultForge === key && remaining.length > 0) {
2509
+ if (typeof args.default === "string") {
2510
+ if (!remaining.includes(args.default)) return fail(`Cannot set default to "${args.default}" — not a remaining forge (${remaining.join(", ")}).`);
2511
+ newDefault = args.default;
2512
+ } else if (tty) {
2513
+ const answer = await promptSelect(`"${key}" is the default forge. Pick a new default:`, [...remaining, LEAVE_UNSET]);
2514
+ if (answer === null) return abort();
2515
+ if (answer !== LEAVE_UNSET) newDefault = answer;
2516
+ } else consola.warn(`Removing the default forge "${key}"; defaultForge now points at a missing forge. Pass --default to reassign it.`);
2517
+ }
2226
2518
  consola.info(`Remove forge "${key}" from ${file}${newDefault ? ` (new default: "${newDefault}")` : ""}`);
2227
2519
  if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort();
2228
2520
  if (await applyChange(file, (c) => {
@@ -2254,34 +2546,51 @@ var forgeCommand = defineCommand({
2254
2546
  });
2255
2547
  //#endregion
2256
2548
  //#region src/repos/import.ts
2257
- async function listDirs(path) {
2549
+ async function readEntries(path) {
2258
2550
  try {
2259
- return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
2551
+ const entries = await readdir(path, { withFileTypes: true });
2552
+ return {
2553
+ dirs: entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name),
2554
+ isRepo: entries.some((e) => e.name === GIT_MARKER)
2555
+ };
2260
2556
  } catch (error) {
2261
- if (error.code === "ENOENT") return [];
2557
+ if (error.code === "ENOENT") return null;
2262
2558
  throw error;
2263
2559
  }
2264
2560
  }
2265
2561
  /**
2266
- * Structure-driven depth-3 walk of `<path>/<serverDir>/<owner>/<repo>`.
2267
- * Unlike `scanRepos`, this is config-free: every top-level directory is a
2268
- * candidate server dir, and the names are discovered rather than configured.
2562
+ * Structure-driven walk of `<path>/<serverDir>/<namespace…>/<repo>`. Unlike
2563
+ * `scanRepos` this is config-free: every top-level directory is a candidate
2564
+ * server dir, and the names are discovered rather than configured.
2565
+ *
2566
+ * A `.git` entry ends a branch, so a nested namespace is adopted as readily as
2567
+ * a flat one and a repo's own subdirectories are never mistaken for more of
2568
+ * the layout. A branch that dead-ends without one is still surfaced — that is
2569
+ * the candidate `analyzeLocal` reports as `not-a-git-repo`, which is the whole
2570
+ * reason `import` looks at directories a scan would simply skip.
2269
2571
  */
2270
- async function discoverForgemapLayout(path) {
2271
- const repos = [];
2272
- for (const serverDir of await listDirs(path)) {
2273
- const serverPath = join(path, serverDir);
2274
- for (const owner of await listDirs(serverPath)) {
2275
- const ownerPath = join(serverPath, owner);
2276
- for (const repo of await listDirs(ownerPath)) repos.push({
2277
- serverDir,
2278
- owner,
2279
- repo,
2280
- localPath: join(ownerPath, repo)
2281
- });
2282
- }
2572
+ async function discoverBelow(serverDir, dirPath, segments, found) {
2573
+ if (segments.length > 10) return;
2574
+ const entries = await readEntries(dirPath);
2575
+ const isLeaf = entries === null || entries.dirs.length === 0;
2576
+ if (segments.length >= 2 && (entries?.isRepo || isLeaf)) {
2577
+ found.push({
2578
+ serverDir,
2579
+ owner: segments.slice(0, -1).join("/"),
2580
+ repo: segments.at(-1),
2581
+ localPath: dirPath
2582
+ });
2583
+ return;
2283
2584
  }
2284
- return repos;
2585
+ if (entries === null || entries.isRepo) return;
2586
+ for (const name of entries.dirs) await discoverBelow(serverDir, join(dirPath, name), [...segments, name], found);
2587
+ }
2588
+ async function discoverForgemapLayout(path) {
2589
+ const root = await readEntries(path);
2590
+ if (!root) return [];
2591
+ const found = [];
2592
+ for (const serverDir of root.dirs) await discoverBelow(serverDir, join(path, serverDir), [], found);
2593
+ return found;
2285
2594
  }
2286
2595
  function forgeTypeForHost(host) {
2287
2596
  return host === "github.com" ? "github" : "git";
@@ -2421,13 +2730,11 @@ function pushRemoteFinding(report, parsed, result, path) {
2421
2730
  message: `remote ${parsed.owner}/${parsed.repo} no longer exists`
2422
2731
  });
2423
2732
  break;
2424
- case "unknown":
2425
- report.findings.push({
2426
- kind: "remote-check-unknown",
2427
- severity: "warn",
2428
- message: `remote check inconclusive: ${result.reason}`
2429
- });
2430
- break;
2733
+ case "unknown": report.findings.push({
2734
+ kind: "remote-check-unknown",
2735
+ severity: "warn",
2736
+ message: `remote check inconclusive: ${result.reason}`
2737
+ });
2431
2738
  }
2432
2739
  }
2433
2740
  /** Run the network check for one forge's repos, preferring the batched
@@ -2943,7 +3250,7 @@ var infoCommand = defineCommand({
2943
3250
  async run({ args }) {
2944
3251
  const binary = resolveBinary(process.argv[1]);
2945
3252
  const info = {
2946
- version: "0.7.0",
3253
+ version: "0.8.0-dev.133-c1be991",
2947
3254
  build: detectBuild(binary.resolved),
2948
3255
  binary,
2949
3256
  node: process.version,
@@ -3069,6 +3376,7 @@ function renderTree$1(repos) {
3069
3376
  var listCommand = defineCommand({
3070
3377
  meta: {
3071
3378
  name: "list",
3379
+ alias: "ls",
3072
3380
  description: "List cloned repos; with a query, fuzzy-match by owner/repo and print matches"
3073
3381
  },
3074
3382
  args: {
@@ -3754,6 +4062,7 @@ async function runChecks(config, configDir) {
3754
4062
  const types = new Set(Object.values(config.forges).map((f) => f.type));
3755
4063
  const needsGit = types.has("git") || types.size > 0;
3756
4064
  const needsGh = types.has("github");
4065
+ const gitlabForges = Object.entries(config.forges).filter(([, forge]) => forge.type === "gitlab");
3757
4066
  if (needsGit) checks.push(await hasCommand("git") ? {
3758
4067
  name: "git CLI",
3759
4068
  severity: "ok",
@@ -3763,29 +4072,94 @@ async function runChecks(config, configDir) {
3763
4072
  severity: "fail",
3764
4073
  message: "install from https://git-scm.com/"
3765
4074
  });
3766
- if (needsGh) if (await hasCommand("gh")) {
3767
- checks.push({
4075
+ if (needsGh) {
4076
+ if (await hasCommand("gh")) {
4077
+ checks.push({
4078
+ name: "gh CLI",
4079
+ severity: "ok",
4080
+ message: "on PATH"
4081
+ });
4082
+ const auth = await execCapture("gh", ["auth", "status"]);
4083
+ checks.push(auth.code === 0 ? {
4084
+ name: "gh auth",
4085
+ severity: "ok",
4086
+ message: "authenticated"
4087
+ } : {
4088
+ name: "gh auth",
4089
+ severity: "warn",
4090
+ message: "not logged in — run `gh auth login`"
4091
+ });
4092
+ } else checks.push({
3768
4093
  name: "gh CLI",
3769
- severity: "ok",
3770
- message: "on PATH"
4094
+ severity: "fail",
4095
+ message: "install from https://cli.github.com/"
3771
4096
  });
3772
- const auth = await execCapture("gh", ["auth", "status"]);
3773
- checks.push(auth.code === 0 ? {
3774
- name: "gh auth",
3775
- severity: "ok",
3776
- message: "authenticated"
3777
- } : {
3778
- name: "gh auth",
3779
- severity: "warn",
3780
- message: "not logged in — run `gh auth login`"
4097
+ }
4098
+ if (gitlabForges.length > 0) {
4099
+ if (await hasCommand("glab")) {
4100
+ checks.push({
4101
+ name: "glab CLI",
4102
+ severity: "ok",
4103
+ message: "on PATH"
4104
+ });
4105
+ for (const [name, forge] of gitlabForges) {
4106
+ const auth = await execCapture("glab", [
4107
+ "auth",
4108
+ "status",
4109
+ "--hostname",
4110
+ forge.host
4111
+ ]);
4112
+ checks.push(auth.code === 0 ? {
4113
+ name: `glab auth (${name})`,
4114
+ severity: "ok",
4115
+ message: `authenticated at ${forge.host}`
4116
+ } : {
4117
+ name: `glab auth (${name})`,
4118
+ severity: "warn",
4119
+ message: `not logged in — run \`glab auth login --hostname ${forge.host}\``
4120
+ });
4121
+ }
4122
+ } else checks.push({
4123
+ name: "glab CLI",
4124
+ severity: "fail",
4125
+ message: "install from https://gitlab.com/gitlab-org/cli"
3781
4126
  });
3782
- } else checks.push({
3783
- name: "gh CLI",
3784
- severity: "fail",
3785
- message: "install from https://cli.github.com/"
3786
- });
4127
+ }
4128
+ checks.push(await layoutCheck(config, configDir));
3787
4129
  return checks;
3788
4130
  }
4131
+ var HINT_LABEL = {
4132
+ "no-repo": "holds no git repo",
4133
+ "missing-namespace": "is a repo with no namespace above it",
4134
+ "too-deep": `is deeper than 10 levels`
4135
+ };
4136
+ var HINTS_SHOWN = 5;
4137
+ /**
4138
+ * A repo is a directory holding a `.git` entry, so a branch that never reaches
4139
+ * one simply drops out of `list`, `status` and `pick`. That is easy to read as
4140
+ * "where did my repo go", which is exactly the question this command exists to
4141
+ * answer — so name the branches rather than leaving them silent. A hint, never
4142
+ * a failure: an odd layout is not a broken one.
4143
+ */
4144
+ async function layoutCheck(config, configDir) {
4145
+ const { repos, hints } = await scanLayout({
4146
+ config,
4147
+ configDir
4148
+ });
4149
+ const count = `${repos.length} repo${repos.length === 1 ? "" : "s"}`;
4150
+ if (hints.length === 0) return {
4151
+ name: "layout",
4152
+ severity: "ok",
4153
+ message: count
4154
+ };
4155
+ const shown = hints.slice(0, HINTS_SHOWN).map((hint) => `${hint.path} ${HINT_LABEL[hint.reason]}`);
4156
+ const rest = hints.length > HINTS_SHOWN ? ` (+${hints.length - HINTS_SHOWN} more)` : "";
4157
+ return {
4158
+ name: "layout",
4159
+ severity: "warn",
4160
+ message: `${count}; ${shown.join(", ")}${rest}`
4161
+ };
4162
+ }
3789
4163
  function severitySymbol(severity) {
3790
4164
  if (severity === "ok") return colors.green("✓");
3791
4165
  if (severity === "warn") return colors.yellow("!");
@@ -3857,6 +4231,21 @@ var SHELL_POSITIONAL = /* @__PURE__ */ new Set(["completion", "shell-init"]);
3857
4231
  function argsOf(cmd) {
3858
4232
  return cmd.args ?? {};
3859
4233
  }
4234
+ /** Subcommand aliases, read off citty's own `meta.alias` so the completion and
4235
+ * the dispatcher cannot disagree about what `ls` means. Every forgemap command
4236
+ * declares `meta` as a plain object literal, never a thunk. */
4237
+ function aliasesOf(cmd) {
4238
+ const alias = cmd.meta?.alias;
4239
+ if (!alias) return [];
4240
+ return Array.isArray(alias) ? [...alias] : [alias];
4241
+ }
4242
+ /** Every name that reaches a command on the command line: its own, plus its
4243
+ * aliases. This is what the *recognition* arms key on — `commandSpecs()`'s
4244
+ * `name` alone is what the depth-1 *suggestion* list offers, which is how an
4245
+ * alias completes its arguments without being advertised as a command. */
4246
+ function namesOf(spec) {
4247
+ return [spec.name, ...spec.aliases];
4248
+ }
3860
4249
  /** Flag names a command exposes, derived from its `defineCommand` args so a new
3861
4250
  * flag surfaces in completion automatically. Positionals are handled
3862
4251
  * separately; negatable booleans (those with a `negativeDescription`) also get
@@ -3895,6 +4284,7 @@ function commandSpecs() {
3895
4284
  ["forge", forgeCommand]
3896
4285
  ].map(([name, cmd]) => ({
3897
4286
  name,
4287
+ aliases: aliasesOf(cmd),
3898
4288
  flags: flagsOf(cmd),
3899
4289
  flagValues: STATIC_FLAG_VALUES[name] ?? {},
3900
4290
  positionalValues: SHELL_POSITIONAL.has(name) ? [...SUPPORTED_SHELLS] : [],
@@ -3902,17 +4292,17 @@ function commandSpecs() {
3902
4292
  }));
3903
4293
  }
3904
4294
  function flagValuePairs(specs) {
3905
- return specs.flatMap((s) => Object.entries(s.flagValues).map(([flag, values]) => [
3906
- s.name,
4295
+ return specs.flatMap((s) => namesOf(s).flatMap((name) => Object.entries(s.flagValues).map(([flag, values]) => [
4296
+ name,
3907
4297
  flag,
3908
4298
  values
3909
- ]));
4299
+ ])));
3910
4300
  }
3911
4301
  function renderBash(specs) {
3912
4302
  const names = specs.map((s) => s.name).join(" ");
3913
4303
  const valueArms = flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) COMPREPLY=( $(compgen -W "${values.join(" ")}" -- "$cur") ); return ;;`).join("\n");
3914
- const flagArms = specs.filter((s) => s.flags.length > 0).map((s) => ` ${s.name}) flags="${s.flags.join(" ")}" ;;`).join("\n");
3915
- const slugCmds = specs.filter((s) => s.slugs).map((s) => s.name);
4304
+ const flagArms = specs.filter((s) => s.flags.length > 0).flatMap((s) => namesOf(s).map((name) => ` ${name}) flags="${s.flags.join(" ")}" ;;`)).join("\n");
4305
+ const slugCmds = specs.filter((s) => s.slugs).flatMap(namesOf);
3916
4306
  return `# forgemap bash completion — drop into your ~/.bashrc:
3917
4307
  # eval "$(forgemap completion bash)"
3918
4308
  _forgemap_completion() {
@@ -3947,7 +4337,7 @@ ${[slugCmds.length > 0 ? ` ${slugCmds.join("|")})
3947
4337
  local slugs
3948
4338
  slugs=$(forgemap list --format slug 2>/dev/null)
3949
4339
  COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
3950
- ;;` : "", ...specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.name}) COMPREPLY=( $(compgen -W "${s.positionalValues.join(" ")}" -- "$cur") ) ;;`)].filter(Boolean).join("\n")}
4340
+ ;;` : "", ...specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${namesOf(s).join("|")}) COMPREPLY=( $(compgen -W "${s.positionalValues.join(" ")}" -- "$cur") ) ;;`)].filter(Boolean).join("\n")}
3951
4341
  esac
3952
4342
  }
3953
4343
  complete -F _forgemap_completion forgemap
@@ -3976,19 +4366,19 @@ ${flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) compad
3976
4366
  # Flag names for the current subcommand.
3977
4367
  if [[ "$cur" == -* ]]; then
3978
4368
  case "$cmd" in
3979
- ${specs.filter((s) => s.flags.length > 0).map((s) => ` ${s.name}) compadd -- ${s.flags.join(" ")}; return ;;`).join("\n")}
4369
+ ${specs.filter((s) => s.flags.length > 0).flatMap((s) => namesOf(s).map((name) => ` ${name}) compadd -- ${s.flags.join(" ")}; return ;;`)).join("\n")}
3980
4370
  esac
3981
4371
  return
3982
4372
  fi
3983
4373
 
3984
4374
  # Positional values (repo slugs, or a shell name).
3985
4375
  case "$cmd" in
3986
- ${specs.filter((s) => s.slugs).map((s) => s.name).join("|")})
4376
+ ${specs.filter((s) => s.slugs).flatMap(namesOf).join("|")})
3987
4377
  local -a slugs
3988
4378
  slugs=("\${(@f)$(forgemap list --format slug 2>/dev/null)}")
3989
4379
  _describe 'slug' slugs
3990
4380
  ;;
3991
- ${specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.name}) compadd ${s.positionalValues.join(" ")} ;;`).join("\n")}
4381
+ ${specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${namesOf(s).join("|")}) compadd ${s.positionalValues.join(" ")} ;;`).join("\n")}
3992
4382
  esac
3993
4383
  }
3994
4384
  compdef _forgemap forgemap
@@ -4000,7 +4390,7 @@ function renderFish(specs) {
4000
4390
  const long = flag.replace(/^--/, "");
4001
4391
  const values = s.flagValues[flag];
4002
4392
  const valuePart = values ? ` -x -a '${values.join(" ")}'` : "";
4003
- return `complete -c forgemap -n '__fish_seen_subcommand_from ${s.name}' -l ${long}${valuePart}`;
4393
+ return `complete -c forgemap -n '__fish_seen_subcommand_from ${namesOf(s).join(" ")}' -l ${long}${valuePart}`;
4004
4394
  })).join("\n");
4005
4395
  const shellCmds = specs.filter((s) => s.positionalValues.length > 0);
4006
4396
  return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
@@ -4015,7 +4405,7 @@ ${flagLines}
4015
4405
  # Shell flavor for completion / shell-init (depth 2).
4016
4406
  function __forgemap_needs_shell
4017
4407
  set -l tokens (commandline -opc)
4018
- set -l shell_cmds ${shellCmds.map((s) => `"${s.name}"`).join(" ")}
4408
+ set -l shell_cmds ${shellCmds.flatMap(namesOf).map((name) => `"${name}"`).join(" ")}
4019
4409
  if test (count $tokens) -ge 2; and contains $tokens[2] $shell_cmds
4020
4410
  return 0
4021
4411
  end
@@ -4026,7 +4416,7 @@ complete -c forgemap -f -n '__forgemap_needs_shell' -a '${shellCmds[0]?.position
4026
4416
  # Slugs (depth 2) for commands that take one.
4027
4417
  function __forgemap_needs_slug
4028
4418
  set -l tokens (commandline -opc)
4029
- set -l slug_cmds ${specs.filter((s) => s.slugs).map((s) => `"${s.name}"`).join(" ")}
4419
+ set -l slug_cmds ${specs.filter((s) => s.slugs).flatMap(namesOf).map((name) => `"${name}"`).join(" ")}
4030
4420
  if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
4031
4421
  return 0
4032
4422
  end
@@ -4077,11 +4467,11 @@ var completionCommand = defineCommand({
4077
4467
  }
4078
4468
  });
4079
4469
  //#endregion
4080
- //#region src/bin/forgemap.ts
4081
- runMain(defineCommand({
4470
+ //#region src/cli.ts
4471
+ var rootCommand = defineCommand({
4082
4472
  meta: {
4083
4473
  name: "forgemap",
4084
- version: "0.7.0",
4474
+ version: "0.8.0-dev.133-c1be991",
4085
4475
  description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
4086
4476
  },
4087
4477
  subCommands: {
@@ -4103,7 +4493,10 @@ runMain(defineCommand({
4103
4493
  config: configCommand,
4104
4494
  forge: forgeCommand
4105
4495
  }
4106
- }));
4496
+ });
4497
+ //#endregion
4498
+ //#region src/bin/forgemap.ts
4499
+ runMain(rootCommand);
4107
4500
  //#endregion
4108
4501
 
4109
4502
  //# sourceMappingURL=forgemap.mjs.map