forgemap 0.7.0-dev.115-dc2b700 → 0.7.0-dev.117-ac8b2c6

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