forgemap 0.7.0 → 0.8.0

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