forgemap 0.6.0 → 0.7.0-dev-main.119-2edcfde

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,46 +373,18 @@ function cachePath(root) {
223
373
  const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
224
374
  return join(cacheDir(), `scan-${hash}.json`);
225
375
  }
226
- async function safeStat(path) {
227
- try {
228
- const s = await stat(path);
229
- return Math.trunc(s.mtimeMs);
230
- } catch {
231
- return 0;
232
- }
233
- }
234
- async function safeListDirs(path) {
235
- try {
236
- return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
237
- } catch {
238
- return [];
239
- }
240
- }
241
376
  /**
242
- * Fingerprint of every directory mtime down to depth 3 (root, forge.dir,
243
- * owner). Catches new clones / removals at any of those levels. Stops
244
- * short of stat-ing each repo dir — that would mostly duplicate the
245
- * scan it's meant to avoid.
246
- *
247
- * Stats are issued in parallel: one batch per forge for its forge.dir +
248
- * owner list, all forges in parallel. Beats the sequential version by
249
- * an order of magnitude at thousands of owners.
377
+ * Fingerprint of the layout, computed by the same walk that finds the repos —
378
+ * see {@link scanLayout}, which owns what goes into it. Since a directory is
379
+ * only known to be a repo once its entries have been read, the fingerprint
380
+ * cannot be cheaper than the scan; producing both from one walk is what keeps
381
+ * the cold path from paying for the tree twice.
250
382
  */
251
383
  async function computeFingerprint(config, configDir) {
252
- const root = resolveRoot(config.root, configDir);
253
- const perForge = await Promise.all(Object.values(config.forges).map(async (forge) => {
254
- const forgeRoot = join(root, forge.dir);
255
- const [forgeMtime, owners] = await Promise.all([safeStat(forgeRoot), safeListDirs(forgeRoot)]);
256
- const ownerEntries = await Promise.all(owners.map(async (owner) => {
257
- const ownerPath = join(forgeRoot, owner);
258
- return [ownerPath, await safeStat(ownerPath)];
259
- }));
260
- return [[forgeRoot, forgeMtime], ...ownerEntries];
261
- }));
262
- const entries = [[root, await safeStat(root)]];
263
- for (const group of perForge) entries.push(...group);
264
- entries.sort((a, b) => a[0].localeCompare(b[0]));
265
- return createHash("sha1").update(entries.map(([p, m]) => `${p}:${m}`).join("\n")).digest("hex");
384
+ return (await scanLayout({
385
+ config,
386
+ configDir
387
+ })).fingerprint;
266
388
  }
267
389
  async function readCacheFile(file) {
268
390
  try {
@@ -284,26 +406,35 @@ async function scanReposCached(options) {
284
406
  if (cached) {
285
407
  const age = Date.now() - cached.writtenAt;
286
408
  if (trustTtl && age < ttl()) return cached.repos;
287
- const fingerprint = await computeFingerprint(config, configDir);
288
- if (cached.fingerprint === fingerprint) {
409
+ const scan = await scanLayout({
410
+ config,
411
+ configDir
412
+ });
413
+ if (cached.fingerprint === scan.fingerprint) {
289
414
  await writeCacheFile(file, {
290
415
  ...cached,
291
416
  writtenAt: Date.now()
292
417
  });
293
418
  return cached.repos;
294
419
  }
420
+ await writeCacheFile(file, {
421
+ fingerprint: scan.fingerprint,
422
+ writtenAt: Date.now(),
423
+ repos: scan.repos
424
+ });
425
+ return scan.repos;
295
426
  }
296
427
  }
297
- const repos = await scanRepos({
428
+ const scan = await scanLayout({
298
429
  config,
299
430
  configDir
300
431
  });
301
432
  await writeCacheFile(file, {
302
- fingerprint: await computeFingerprint(config, configDir),
433
+ fingerprint: scan.fingerprint,
303
434
  writtenAt: Date.now(),
304
- repos
435
+ repos: scan.repos
305
436
  });
306
- return repos;
437
+ return scan.repos;
307
438
  }
308
439
  /**
309
440
  * Append a freshly-cloned repo to the cache without touching the
@@ -340,9 +471,15 @@ async function removeCachedRepo(options, localPath) {
340
471
  }
341
472
  //#endregion
342
473
  //#region src/utils/exec.ts
343
- function execInherit(command, args) {
474
+ function execInherit(command, args, options = {}) {
344
475
  return new Promise((resolvePromise, rejectPromise) => {
345
- const child = spawn(command, args, { 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
+ });
346
483
  child.on("error", rejectPromise);
347
484
  child.on("close", (code) => {
348
485
  resolvePromise({ code: code ?? 0 });
@@ -468,33 +605,13 @@ function isRepoMissing(stderr) {
468
605
  return /repository not found/.test(s) || /remote:.*not found/.test(s) || /\b404\b/.test(s) || /could not find repository/.test(s);
469
606
  }
470
607
  //#endregion
471
- //#region src/utils/concurrency.ts
472
- /**
473
- * Map over `items` running at most `limit` calls of `fn` at once, preserving
474
- * input order in the result. Keeps `import` from spawning one subprocess per
475
- * repo all at once when checking remotes across a large tree.
476
- */
477
- async function mapLimit(items, limit, fn) {
478
- const results = Array.from({ length: items.length });
479
- const max = Math.max(1, Math.min(limit, items.length));
480
- let next = 0;
481
- async function worker() {
482
- while (next < items.length) {
483
- const index = next++;
484
- results[index] = await fn(items[index], index);
485
- }
486
- }
487
- await Promise.all(Array.from({ length: max }, () => worker()));
488
- return results;
489
- }
490
- //#endregion
491
608
  //#region src/forges/github.ts
492
- var GRAPHQL_CHUNK = 100;
493
- var FALLBACK_CONCURRENCY = 8;
609
+ var GRAPHQL_CHUNK$1 = 100;
610
+ var FALLBACK_CONCURRENCY$1 = 8;
494
611
  var GH_TIMEOUT_MS = 2e4;
495
612
  /** Single-repo REST check. `gh api` follows the redirect a renamed/transferred
496
613
  * repo issues, so the returned full_name reveals the canonical owner/repo. */
497
- async function checkOne(owner, repo) {
614
+ async function checkOne$1(owner, repo) {
498
615
  const result = await execCapture("gh", [
499
616
  "api",
500
617
  `repos/${owner}/${repo}`,
@@ -531,7 +648,7 @@ async function checkOne(owner, repo) {
531
648
  canonicalUrl: `https://github.com/${canonicalOwner}/${canonicalRepo}.git`
532
649
  };
533
650
  }
534
- function buildQuery(chunk) {
651
+ function buildQuery$1(chunk) {
535
652
  return `query {\n${chunk.map((input, i) => ` r${i}: repository(owner: ${JSON.stringify(input.owner)}, name: ${JSON.stringify(input.repo)}) { nameWithOwner }`).join("\n")}\n}`;
536
653
  }
537
654
  var githubAdapter = {
@@ -550,7 +667,7 @@ var githubAdapter = {
550
667
  state: "unknown",
551
668
  reason: "gh not installed"
552
669
  };
553
- return checkOne(owner, repo);
670
+ return checkOne$1(owner, repo);
554
671
  },
555
672
  /**
556
673
  * One GraphQL request resolves up to GRAPHQL_CHUNK repos at once. GraphQL
@@ -565,13 +682,13 @@ var githubAdapter = {
565
682
  reason: "gh not installed"
566
683
  }));
567
684
  const results = Array.from({ length: inputs.length }, () => null);
568
- for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK) {
569
- const chunk = inputs.slice(start, start + GRAPHQL_CHUNK);
685
+ for (let start = 0; start < inputs.length; start += GRAPHQL_CHUNK$1) {
686
+ const chunk = inputs.slice(start, start + GRAPHQL_CHUNK$1);
570
687
  const res = await execCapture("gh", [
571
688
  "api",
572
689
  "graphql",
573
690
  "-f",
574
- `query=${buildQuery(chunk)}`
691
+ `query=${buildQuery$1(chunk)}`
575
692
  ], { timeoutMs: GH_TIMEOUT_MS });
576
693
  let data = null;
577
694
  try {
@@ -593,8 +710,153 @@ var githubAdapter = {
593
710
  }
594
711
  }
595
712
  }
713
+ await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY$1, async (index) => {
714
+ results[index] = await checkOne$1(inputs[index].owner, inputs[index].repo);
715
+ });
716
+ return results;
717
+ }
718
+ };
719
+ //#endregion
720
+ //#region src/forges/gitlab.ts
721
+ var GRAPHQL_CHUNK = 100;
722
+ var FALLBACK_CONCURRENCY = 8;
723
+ var GLAB_TIMEOUT_MS = 2e4;
724
+ var MISSING_GLAB = "GitLab CLI (`glab`) is not installed. Install it from https://gitlab.com/gitlab-org/cli and run `glab auth login`.";
725
+ /**
726
+ * Pin every invocation to the forge's own host. `glab repo clone` has no
727
+ * `--hostname` flag, and leaning on the user's global `glab config set host`
728
+ * would make behaviour depend on state forgemap never set.
729
+ */
730
+ function glabEnv(forge) {
731
+ return { GITLAB_HOST: forge.host };
732
+ }
733
+ /** Split GitLab's `full path` into namespace and project. */
734
+ function splitFullPath(fullPath) {
735
+ const segments = fullPath.split("/").filter(Boolean);
736
+ if (segments.length < 2) return null;
737
+ return {
738
+ owner: segments.slice(0, -1).join("/"),
739
+ repo: segments.at(-1)
740
+ };
741
+ }
742
+ /**
743
+ * Single-project REST check. `projects/<url-encoded path>` answers with
744
+ * `path_with_namespace`, GitLab's counterpart to GitHub's `full_name`, so a
745
+ * differing answer is a move and a 404 is a deletion.
746
+ */
747
+ async function checkOne(forge, owner, repo) {
748
+ const result = await execCapture("glab", ["api", `projects/${encodeURIComponent(`${owner}/${repo}`)}`], {
749
+ timeoutMs: GLAB_TIMEOUT_MS,
750
+ env: glabEnv(forge)
751
+ });
752
+ if (result.timedOut) return {
753
+ state: "unknown",
754
+ reason: "glab api timed out"
755
+ };
756
+ if (result.code !== 0) {
757
+ if (/404|not found/i.test(result.stderr)) return { state: "gone" };
758
+ return {
759
+ state: "unknown",
760
+ reason: result.stderr.trim() || `glab api exited with code ${result.code}`
761
+ };
762
+ }
763
+ let fullPath;
764
+ try {
765
+ fullPath = JSON.parse(result.stdout).path_with_namespace;
766
+ } catch {
767
+ fullPath = void 0;
768
+ }
769
+ const canonical = fullPath ? splitFullPath(fullPath) : null;
770
+ if (!canonical) return {
771
+ state: "unknown",
772
+ reason: "could not parse glab api path_with_namespace"
773
+ };
774
+ if (canonical.owner === owner && canonical.repo === repo) return {
775
+ state: "exists",
776
+ canonical
777
+ };
778
+ return {
779
+ state: "moved",
780
+ canonical,
781
+ canonicalUrl: `https://${forge.host}/${canonical.owner}/${canonical.repo}.git`
782
+ };
783
+ }
784
+ function buildQuery(chunk) {
785
+ return `query {\n${chunk.map((input, i) => ` r${i}: project(fullPath: ${JSON.stringify(`${input.owner}/${input.repo}`)}) { fullPath }`).join("\n")}\n}`;
786
+ }
787
+ var gitlabAdapter = {
788
+ async clone({ forge, owner, repo, dest }) {
789
+ if (!await hasCommand("glab")) throw new Error(MISSING_GLAB);
790
+ const { code } = await execInherit("glab", [
791
+ "repo",
792
+ "clone",
793
+ `${owner}/${repo}`,
794
+ dest
795
+ ], { env: glabEnv(forge) });
796
+ if (code !== 0) throw new Error(`glab repo clone exited with code ${code}`);
797
+ },
798
+ async checkRemote({ forge, owner, repo }) {
799
+ if (!await hasCommand("glab")) return {
800
+ state: "unknown",
801
+ reason: "glab not installed"
802
+ };
803
+ return checkOne(forge, owner, repo);
804
+ },
805
+ /**
806
+ * Mirrors the GitHub adapter: one aliased GraphQL request resolves up to
807
+ * GRAPHQL_CHUNK projects, and each miss — `null` could mean gone *or*
808
+ * renamed — costs a single REST call to tell the two apart.
809
+ *
810
+ * A batch may span several configured GitLab forges, so the inputs are
811
+ * grouped by host before they are chunked: one server must never be asked
812
+ * about another's projects. Where the same full path exists on both — a
813
+ * public mirror of an internal project — the wrong server would otherwise
814
+ * answer `exists` for a project this one does not have.
815
+ */
816
+ async checkRemotes(inputs) {
817
+ if (inputs.length === 0) return [];
818
+ if (!await hasCommand("glab")) return inputs.map(() => ({
819
+ state: "unknown",
820
+ reason: "glab not installed"
821
+ }));
822
+ const results = Array.from({ length: inputs.length }, () => null);
823
+ const byHost = /* @__PURE__ */ new Map();
824
+ inputs.forEach((input, index) => {
825
+ const indices = byHost.get(input.forge.host);
826
+ if (indices) indices.push(index);
827
+ else byHost.set(input.forge.host, [index]);
828
+ });
829
+ for (const indices of byHost.values()) for (let start = 0; start < indices.length; start += GRAPHQL_CHUNK) {
830
+ const slice = indices.slice(start, start + GRAPHQL_CHUNK);
831
+ const chunk = slice.map((index) => inputs[index]);
832
+ const res = await execCapture("glab", [
833
+ "api",
834
+ "graphql",
835
+ "-f",
836
+ `query=${buildQuery(chunk)}`
837
+ ], {
838
+ timeoutMs: GLAB_TIMEOUT_MS,
839
+ env: glabEnv(chunk[0].forge)
840
+ });
841
+ let data = null;
842
+ try {
843
+ const parsed = JSON.parse(res.stdout);
844
+ data = parsed.data ?? parsed;
845
+ } catch {
846
+ data = null;
847
+ }
848
+ for (let i = 0; i < chunk.length; i++) {
849
+ const node = data?.[`r${i}`];
850
+ const canonical = node?.fullPath ? splitFullPath(node.fullPath) : null;
851
+ if (canonical) results[slice[i]] = {
852
+ state: "exists",
853
+ canonical
854
+ };
855
+ }
856
+ }
596
857
  await mapLimit(results.flatMap((r, i) => r === null ? [i] : []), FALLBACK_CONCURRENCY, async (index) => {
597
- 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);
598
860
  });
599
861
  return results;
600
862
  }
@@ -604,8 +866,8 @@ var githubAdapter = {
604
866
  function getForgeAdapter(type) {
605
867
  switch (type) {
606
868
  case "github": return githubAdapter;
869
+ case "gitlab": return gitlabAdapter;
607
870
  case "git": return gitAdapter;
608
- case "gitlab":
609
871
  case "gitea":
610
872
  case "codeberg": throw new Error(`Forge type "${type}" is not implemented yet. Use type: 'git' for a vanilla git-clone fallback.`);
611
873
  default: throw new Error(`Unknown forge type: ${String(type)}`);
@@ -613,21 +875,29 @@ function getForgeAdapter(type) {
613
875
  }
614
876
  //#endregion
615
877
  //#region src/slug/parse.ts
616
- var SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
617
- var NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
618
- 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)?$`);
619
882
  function stripGitSuffix(repo) {
620
883
  return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
621
884
  }
622
885
  /**
886
+ * Split `<namespace…>/<repo>` into its two halves: the **last** segment is the
887
+ * repo, everything before it the namespace. Collapses to today's result
888
+ * whenever there are exactly two segments.
889
+ */
890
+ function splitPath(segments) {
891
+ return {
892
+ owner: segments.slice(0, -1).join("/"),
893
+ repo: stripGitSuffix(segments.at(-1))
894
+ };
895
+ }
896
+ /**
623
897
  * Whether the input is *shaped* like a strict slug. Every form
624
- * {@link parseSlug} accepts — `owner/repo`, `forge:owner/repo`, SSH and
625
- * URL — contains a `/`, so a bare term like `gild` can never be one and is
898
+ * {@link parseSlug} accepts — `namespace/repo`, `forge:namespace/repo`, SSH
899
+ * and URL — contains a `/`, so a bare term like `gild` can never be one and is
626
900
  * free to be treated as a fuzzy query instead.
627
- *
628
- * Shaped-like is deliberately not the same as valid: `foo/bar/baz` is shaped
629
- * like a slug, so it stays a hard parse error rather than silently degrading
630
- * into a fuzzy search for something the user clearly meant as a slug.
631
901
  */
632
902
  function looksLikeSlug(input) {
633
903
  return input.trim().includes("/");
@@ -638,8 +908,7 @@ function parseSlug(input) {
638
908
  const ssh = SSH_RE.exec(trimmed);
639
909
  if (ssh) return {
640
910
  host: ssh[1],
641
- owner: ssh[2],
642
- repo: stripGitSuffix(ssh[3])
911
+ ...splitPath([...ssh[2].split("/"), ssh[3]])
643
912
  };
644
913
  if (/^https?:\/\//.test(trimmed)) {
645
914
  let url;
@@ -648,25 +917,22 @@ function parseSlug(input) {
648
917
  } catch {
649
918
  throw new Error(`Invalid URL: ${trimmed}`);
650
919
  }
651
- const segments = url.pathname.split("/").filter(Boolean);
652
- 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}`);
653
924
  return {
654
925
  host: url.host,
655
- owner: segments[0],
656
- repo: stripGitSuffix(segments[1])
926
+ ...splitPath(segments)
657
927
  };
658
928
  }
659
929
  const named = NAMED_RE.exec(trimmed);
660
930
  if (named) return {
661
931
  forgeName: named[1],
662
- owner: named[2],
663
- repo: stripGitSuffix(named[3])
932
+ ...splitPath([...named[2].split("/"), named[3]])
664
933
  };
665
934
  const short = SHORT_RE.exec(trimmed);
666
- if (short) return {
667
- owner: short[1],
668
- repo: stripGitSuffix(short[2])
669
- };
935
+ if (short) return splitPath([...short[1].split("/"), short[2]]);
670
936
  throw new Error(`Unrecognized slug format: ${input}`);
671
937
  }
672
938
  //#endregion
@@ -950,16 +1216,31 @@ function remoteBlocker(state) {
950
1216
  if (state === "exists" || state === "moved") return null;
951
1217
  return state === "gone" ? "remote no longer exists" : "remote unreachable";
952
1218
  }
953
- /** 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
+ */
954
1230
  async function classifyRemotes(candidates) {
955
- const byType = /* @__PURE__ */ new Map();
1231
+ const groups = /* @__PURE__ */ new Map();
956
1232
  for (const c of candidates) {
957
- const list = byType.get(c.repo.forge.type);
958
- if (list) list.push(c);
959
- 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
+ });
960
1241
  }
961
1242
  const results = /* @__PURE__ */ new Map();
962
- await Promise.all(Array.from(byType, async ([type, items]) => {
1243
+ await Promise.all(Array.from(groups.values(), async ({ type, items }) => {
963
1244
  const inputs = items.map((c) => ({
964
1245
  forge: c.repo.forge,
965
1246
  owner: c.owner,
@@ -1005,35 +1286,43 @@ async function classifyRemotes(candidates) {
1005
1286
  }));
1006
1287
  return results;
1007
1288
  }
1008
- 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;
1009
1302
  try {
1010
- return await readdir(path);
1303
+ entries = await readdir(path, { withFileTypes: true });
1011
1304
  } catch {
1012
- return null;
1305
+ return false;
1013
1306
  }
1014
- }
1015
- /** Empty owner directories (and a server directory that holds only such empty
1016
- * 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. */
1017
1320
  async function findEmptyDirs(root, config) {
1018
1321
  const empties = [];
1019
- for (const forge of Object.values(config.forges)) {
1020
- const serverPath = join(root, forge.dir);
1021
- const owners = await safeReaddir(serverPath);
1022
- if (owners === null) continue;
1023
- let emptyCount = 0;
1024
- for (const owner of owners) {
1025
- const ownerPath = join(serverPath, owner);
1026
- const inner = await safeReaddir(ownerPath);
1027
- if (inner !== null && inner.length === 0) {
1028
- empties.push(ownerPath);
1029
- emptyCount++;
1030
- }
1031
- }
1032
- if (owners.length === 0 || emptyCount === owners.length) empties.push(serverPath);
1033
- }
1322
+ for (const forge of Object.values(config.forges)) await collectEmptyDirs(join(root, forge.dir), 0, empties);
1034
1323
  return empties;
1035
1324
  }
1036
- /** Remove the dirs from findEmptyDirs (owners before server dirs). */
1325
+ /** Remove the dirs from findEmptyDirs (children before their parents). */
1037
1326
  async function pruneEmptyDirs(root, config) {
1038
1327
  const empties = await findEmptyDirs(root, config);
1039
1328
  let removed = 0;
@@ -1225,6 +1514,8 @@ function resolveSlug(parsed, options) {
1225
1514
  forgeName = config.defaultForge;
1226
1515
  forge = candidate;
1227
1516
  }
1517
+ const depthError = checkNamespaceDepth(forgeName, forge.type, parsed.owner);
1518
+ if (depthError) throw new Error(depthError);
1228
1519
  const localPath = join(resolveRoot(config.root, configDir), forge.dir, parsed.owner, parsed.repo);
1229
1520
  return {
1230
1521
  forgeName,
@@ -2246,34 +2537,51 @@ var forgeCommand = defineCommand({
2246
2537
  });
2247
2538
  //#endregion
2248
2539
  //#region src/repos/import.ts
2249
- async function listDirs(path) {
2540
+ async function readEntries(path) {
2250
2541
  try {
2251
- 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
+ };
2252
2547
  } catch (error) {
2253
- if (error.code === "ENOENT") return [];
2548
+ if (error.code === "ENOENT") return null;
2254
2549
  throw error;
2255
2550
  }
2256
2551
  }
2257
2552
  /**
2258
- * Structure-driven depth-3 walk of `<path>/<serverDir>/<owner>/<repo>`.
2259
- * Unlike `scanRepos`, this is config-free: every top-level directory is a
2260
- * 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.
2261
2562
  */
2262
- async function discoverForgemapLayout(path) {
2263
- const repos = [];
2264
- for (const serverDir of await listDirs(path)) {
2265
- const serverPath = join(path, serverDir);
2266
- for (const owner of await listDirs(serverPath)) {
2267
- const ownerPath = join(serverPath, owner);
2268
- for (const repo of await listDirs(ownerPath)) repos.push({
2269
- serverDir,
2270
- owner,
2271
- repo,
2272
- localPath: join(ownerPath, repo)
2273
- });
2274
- }
2563
+ async function discoverBelow(serverDir, dirPath, segments, found) {
2564
+ if (segments.length > 10) return;
2565
+ const entries = await readEntries(dirPath);
2566
+ const isLeaf = entries === null || entries.dirs.length === 0;
2567
+ if (segments.length >= 2 && (entries?.isRepo || isLeaf)) {
2568
+ found.push({
2569
+ serverDir,
2570
+ owner: segments.slice(0, -1).join("/"),
2571
+ repo: segments.at(-1),
2572
+ localPath: dirPath
2573
+ });
2574
+ return;
2275
2575
  }
2276
- return 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;
2277
2585
  }
2278
2586
  function forgeTypeForHost(host) {
2279
2587
  return host === "github.com" ? "github" : "git";
@@ -2935,7 +3243,7 @@ var infoCommand = defineCommand({
2935
3243
  async run({ args }) {
2936
3244
  const binary = resolveBinary(process.argv[1]);
2937
3245
  const info = {
2938
- version: "0.6.0",
3246
+ version: "0.7.0-dev-main.119-2edcfde",
2939
3247
  build: detectBuild(binary.resolved),
2940
3248
  binary,
2941
3249
  node: process.version,
@@ -3746,6 +4054,7 @@ async function runChecks(config, configDir) {
3746
4054
  const types = new Set(Object.values(config.forges).map((f) => f.type));
3747
4055
  const needsGit = types.has("git") || types.size > 0;
3748
4056
  const needsGh = types.has("github");
4057
+ const gitlabForges = Object.entries(config.forges).filter(([, forge]) => forge.type === "gitlab");
3749
4058
  if (needsGit) checks.push(await hasCommand("git") ? {
3750
4059
  name: "git CLI",
3751
4060
  severity: "ok",
@@ -3776,8 +4085,69 @@ async function runChecks(config, configDir) {
3776
4085
  severity: "fail",
3777
4086
  message: "install from https://cli.github.com/"
3778
4087
  });
4088
+ if (gitlabForges.length > 0) if (await hasCommand("glab")) {
4089
+ checks.push({
4090
+ name: "glab CLI",
4091
+ severity: "ok",
4092
+ message: "on PATH"
4093
+ });
4094
+ for (const [name, forge] of gitlabForges) {
4095
+ const auth = await execCapture("glab", [
4096
+ "auth",
4097
+ "status",
4098
+ "--hostname",
4099
+ forge.host
4100
+ ]);
4101
+ checks.push(auth.code === 0 ? {
4102
+ name: `glab auth (${name})`,
4103
+ severity: "ok",
4104
+ message: `authenticated at ${forge.host}`
4105
+ } : {
4106
+ name: `glab auth (${name})`,
4107
+ severity: "warn",
4108
+ message: `not logged in — run \`glab auth login --hostname ${forge.host}\``
4109
+ });
4110
+ }
4111
+ } else checks.push({
4112
+ name: "glab CLI",
4113
+ severity: "fail",
4114
+ message: "install from https://gitlab.com/gitlab-org/cli"
4115
+ });
4116
+ checks.push(await layoutCheck(config, configDir));
3779
4117
  return checks;
3780
4118
  }
4119
+ var HINT_LABEL = {
4120
+ "no-repo": "holds no git repo",
4121
+ "missing-namespace": "is a repo with no namespace above it",
4122
+ "too-deep": `is deeper than 10 levels`
4123
+ };
4124
+ var HINTS_SHOWN = 5;
4125
+ /**
4126
+ * A repo is a directory holding a `.git` entry, so a branch that never reaches
4127
+ * one simply drops out of `list`, `status` and `pick`. That is easy to read as
4128
+ * "where did my repo go", which is exactly the question this command exists to
4129
+ * answer — so name the branches rather than leaving them silent. A hint, never
4130
+ * a failure: an odd layout is not a broken one.
4131
+ */
4132
+ async function layoutCheck(config, configDir) {
4133
+ const { repos, hints } = await scanLayout({
4134
+ config,
4135
+ configDir
4136
+ });
4137
+ const count = `${repos.length} repo${repos.length === 1 ? "" : "s"}`;
4138
+ if (hints.length === 0) return {
4139
+ name: "layout",
4140
+ severity: "ok",
4141
+ message: count
4142
+ };
4143
+ const shown = hints.slice(0, HINTS_SHOWN).map((hint) => `${hint.path} ${HINT_LABEL[hint.reason]}`);
4144
+ const rest = hints.length > HINTS_SHOWN ? ` (+${hints.length - HINTS_SHOWN} more)` : "";
4145
+ return {
4146
+ name: "layout",
4147
+ severity: "warn",
4148
+ message: `${count}; ${shown.join(", ")}${rest}`
4149
+ };
4150
+ }
3781
4151
  function severitySymbol(severity) {
3782
4152
  if (severity === "ok") return colors.green("✓");
3783
4153
  if (severity === "warn") return colors.yellow("!");
@@ -4073,7 +4443,7 @@ var completionCommand = defineCommand({
4073
4443
  runMain(defineCommand({
4074
4444
  meta: {
4075
4445
  name: "forgemap",
4076
- version: "0.6.0",
4446
+ version: "0.7.0-dev-main.119-2edcfde",
4077
4447
  description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
4078
4448
  },
4079
4449
  subCommands: {