forgemap 0.4.1-dev.68-66824fb → 0.4.1-dev.70-e2b787f

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.
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
- import { readdir, readFile, mkdir, writeFile, stat, rm, rmdir, rename, access } from "node:fs/promises";
4
+ import { readdir, readFile, mkdir, writeFile, stat, rmdir, rm, rename, access } from "node:fs/promises";
5
5
  import { colors, formatTree } from "consola/utils";
6
6
  import { isAbsolute, resolve, dirname, join } from "pathe";
7
7
  import { homedir } from "node:os";
8
8
  import { existsSync } from "node:fs";
9
9
  import { loadConfig } from "c12";
10
- import { spawn } from "node:child_process";
11
10
  import { createHash } from "node:crypto";
11
+ import { spawn } from "node:child_process";
12
12
  import Fuse from "fuse.js";
13
13
  const cdCommand = defineCommand({
14
14
  meta: {
@@ -110,6 +110,163 @@ async function loadForgeMapConfig(options = {}) {
110
110
  cwd
111
111
  };
112
112
  }
113
+ async function listDirs$1(path) {
114
+ try {
115
+ const entries = await readdir(path, { withFileTypes: true });
116
+ return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
117
+ } catch (error) {
118
+ if (error.code === "ENOENT") return [];
119
+ throw error;
120
+ }
121
+ }
122
+ async function scanRepos(options) {
123
+ const { config, configDir } = options;
124
+ const root = resolveRoot(config.root, configDir);
125
+ const repos = [];
126
+ for (const [forgeName, forge] of Object.entries(config.forges)) {
127
+ const forgeRoot = join(root, forge.dir);
128
+ const owners = await listDirs$1(forgeRoot);
129
+ for (const owner of owners) {
130
+ const ownerPath = join(forgeRoot, owner);
131
+ const repoNames = await listDirs$1(ownerPath);
132
+ for (const repo of repoNames) {
133
+ repos.push({
134
+ forgeName,
135
+ forge,
136
+ owner,
137
+ repo,
138
+ localPath: join(ownerPath, repo),
139
+ slug: `${owner}/${repo}`
140
+ });
141
+ }
142
+ }
143
+ }
144
+ return repos;
145
+ }
146
+ const DEFAULT_TTL_MS = 6e4;
147
+ function ttl() {
148
+ const env = process.env.FORGEMAP_CACHE_TTL_MS;
149
+ if (!env) return DEFAULT_TTL_MS;
150
+ const parsed = Number.parseInt(env, 10);
151
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_TTL_MS;
152
+ }
153
+ function cacheDir() {
154
+ const xdg = process.env.XDG_CACHE_HOME;
155
+ return xdg ? join(xdg, "forgemap") : join(homedir(), ".cache", "forgemap");
156
+ }
157
+ function cachePath(root) {
158
+ const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
159
+ return join(cacheDir(), `scan-${hash}.json`);
160
+ }
161
+ async function safeStat(path) {
162
+ try {
163
+ const s = await stat(path);
164
+ return Math.trunc(s.mtimeMs);
165
+ } catch {
166
+ return 0;
167
+ }
168
+ }
169
+ async function safeListDirs(path) {
170
+ try {
171
+ const entries = await readdir(path, { withFileTypes: true });
172
+ return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
173
+ } catch {
174
+ return [];
175
+ }
176
+ }
177
+ async function computeFingerprint(config, configDir) {
178
+ const root = resolveRoot(config.root, configDir);
179
+ const perForge = await Promise.all(
180
+ Object.values(config.forges).map(async (forge) => {
181
+ const forgeRoot = join(root, forge.dir);
182
+ const [forgeMtime, owners] = await Promise.all([
183
+ safeStat(forgeRoot),
184
+ safeListDirs(forgeRoot)
185
+ ]);
186
+ const ownerEntries = await Promise.all(
187
+ owners.map(async (owner) => {
188
+ const ownerPath = join(forgeRoot, owner);
189
+ return [ownerPath, await safeStat(ownerPath)];
190
+ })
191
+ );
192
+ return [[forgeRoot, forgeMtime], ...ownerEntries];
193
+ })
194
+ );
195
+ const entries = [[root, await safeStat(root)]];
196
+ for (const group of perForge) entries.push(...group);
197
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
198
+ return createHash("sha1").update(entries.map(([p, m]) => `${p}:${m}`).join("\n")).digest("hex");
199
+ }
200
+ async function readCacheFile(file) {
201
+ try {
202
+ const raw = await readFile(file, "utf8");
203
+ return JSON.parse(raw);
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+ async function writeCacheFile(file, payload) {
209
+ await mkdir(cacheDir(), { recursive: true });
210
+ await writeFile(file, JSON.stringify(payload), "utf8");
211
+ }
212
+ async function scanReposCached(options) {
213
+ const { config, configDir, useCache = true, trustTtl = true } = options;
214
+ const root = resolveRoot(config.root, configDir);
215
+ const file = cachePath(root);
216
+ if (useCache) {
217
+ const cached = await readCacheFile(file);
218
+ if (cached) {
219
+ const age = Date.now() - cached.writtenAt;
220
+ if (trustTtl && age < ttl()) {
221
+ return cached.repos;
222
+ }
223
+ const fingerprint2 = await computeFingerprint(config, configDir);
224
+ if (cached.fingerprint === fingerprint2) {
225
+ await writeCacheFile(file, { ...cached, writtenAt: Date.now() });
226
+ return cached.repos;
227
+ }
228
+ }
229
+ }
230
+ const repos = await scanRepos({ config, configDir });
231
+ const fingerprint = await computeFingerprint(config, configDir);
232
+ await writeCacheFile(file, {
233
+ fingerprint,
234
+ writtenAt: Date.now(),
235
+ repos
236
+ });
237
+ return repos;
238
+ }
239
+ async function appendCachedRepo(options, repo) {
240
+ const { config, configDir } = options;
241
+ const root = resolveRoot(config.root, configDir);
242
+ const file = cachePath(root);
243
+ const cached = await readCacheFile(file);
244
+ if (!cached) {
245
+ return;
246
+ }
247
+ if (cached.repos.some((r) => r.localPath === repo.localPath)) {
248
+ return;
249
+ }
250
+ await writeCacheFile(file, {
251
+ fingerprint: await computeFingerprint(config, configDir),
252
+ writtenAt: Date.now(),
253
+ repos: [...cached.repos, repo]
254
+ });
255
+ }
256
+ async function removeCachedRepo(options, localPath) {
257
+ const { config, configDir } = options;
258
+ const root = resolveRoot(config.root, configDir);
259
+ const file = cachePath(root);
260
+ const cached = await readCacheFile(file);
261
+ if (!cached) return;
262
+ const next = cached.repos.filter((r) => r.localPath !== localPath);
263
+ if (next.length === cached.repos.length) return;
264
+ await writeCacheFile(file, {
265
+ fingerprint: await computeFingerprint(config, configDir),
266
+ writtenAt: Date.now(),
267
+ repos: next
268
+ });
269
+ }
113
270
  function execInherit(command, args) {
114
271
  return new Promise((resolvePromise, rejectPromise) => {
115
272
  const child = spawn(command, args, { stdio: "inherit" });
@@ -390,162 +547,61 @@ function getForgeAdapter(type) {
390
547
  }
391
548
  }
392
549
  }
393
- async function listDirs$1(path) {
394
- try {
395
- const entries = await readdir(path, { withFileTypes: true });
396
- return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
397
- } catch (error) {
398
- if (error.code === "ENOENT") return [];
399
- throw error;
400
- }
401
- }
402
- async function scanRepos(options) {
403
- const { config, configDir } = options;
404
- const root = resolveRoot(config.root, configDir);
405
- const repos = [];
406
- for (const [forgeName, forge] of Object.entries(config.forges)) {
407
- const forgeRoot = join(root, forge.dir);
408
- const owners = await listDirs$1(forgeRoot);
409
- for (const owner of owners) {
410
- const ownerPath = join(forgeRoot, owner);
411
- const repoNames = await listDirs$1(ownerPath);
412
- for (const repo of repoNames) {
413
- repos.push({
414
- forgeName,
415
- forge,
416
- owner,
417
- repo,
418
- localPath: join(ownerPath, repo),
419
- slug: `${owner}/${repo}`
420
- });
421
- }
422
- }
423
- }
424
- return repos;
425
- }
426
- const DEFAULT_TTL_MS = 6e4;
427
- function ttl() {
428
- const env = process.env.FORGEMAP_CACHE_TTL_MS;
429
- if (!env) return DEFAULT_TTL_MS;
430
- const parsed = Number.parseInt(env, 10);
431
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_TTL_MS;
432
- }
433
- function cacheDir() {
434
- const xdg = process.env.XDG_CACHE_HOME;
435
- return xdg ? join(xdg, "forgemap") : join(homedir(), ".cache", "forgemap");
436
- }
437
- function cachePath(root) {
438
- const hash = createHash("sha1").update(root).digest("hex").slice(0, 16);
439
- return join(cacheDir(), `scan-${hash}.json`);
550
+ const SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
551
+ const NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
552
+ const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
553
+ function stripGitSuffix(repo) {
554
+ return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
440
555
  }
441
- async function safeStat(path) {
442
- try {
443
- const s = await stat(path);
444
- return Math.trunc(s.mtimeMs);
445
- } catch {
446
- return 0;
447
- }
556
+ function looksLikeSlug(input) {
557
+ return input.trim().includes("/");
448
558
  }
449
- async function safeListDirs(path) {
450
- try {
451
- const entries = await readdir(path, { withFileTypes: true });
452
- return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
453
- } catch {
454
- return [];
559
+ function parseSlug(input) {
560
+ const trimmed = input.trim();
561
+ if (!trimmed) {
562
+ throw new Error("Slug is empty");
455
563
  }
456
- }
457
- async function computeFingerprint(config, configDir) {
458
- const root = resolveRoot(config.root, configDir);
459
- const perForge = await Promise.all(
460
- Object.values(config.forges).map(async (forge) => {
461
- const forgeRoot = join(root, forge.dir);
462
- const [forgeMtime, owners] = await Promise.all([
463
- safeStat(forgeRoot),
464
- safeListDirs(forgeRoot)
465
- ]);
466
- const ownerEntries = await Promise.all(
467
- owners.map(async (owner) => {
468
- const ownerPath = join(forgeRoot, owner);
469
- return [ownerPath, await safeStat(ownerPath)];
470
- })
471
- );
472
- return [[forgeRoot, forgeMtime], ...ownerEntries];
473
- })
474
- );
475
- const entries = [[root, await safeStat(root)]];
476
- for (const group of perForge) entries.push(...group);
477
- entries.sort((a, b) => a[0].localeCompare(b[0]));
478
- return createHash("sha1").update(entries.map(([p, m]) => `${p}:${m}`).join("\n")).digest("hex");
479
- }
480
- async function readCacheFile(file) {
481
- try {
482
- const raw = await readFile(file, "utf8");
483
- return JSON.parse(raw);
484
- } catch {
485
- return null;
564
+ const ssh = SSH_RE.exec(trimmed);
565
+ if (ssh) {
566
+ return {
567
+ host: ssh[1],
568
+ owner: ssh[2],
569
+ repo: stripGitSuffix(ssh[3])
570
+ };
486
571
  }
487
- }
488
- async function writeCacheFile(file, payload) {
489
- await mkdir(cacheDir(), { recursive: true });
490
- await writeFile(file, JSON.stringify(payload), "utf8");
491
- }
492
- async function scanReposCached(options) {
493
- const { config, configDir, useCache = true, trustTtl = true } = options;
494
- const root = resolveRoot(config.root, configDir);
495
- const file = cachePath(root);
496
- if (useCache) {
497
- const cached = await readCacheFile(file);
498
- if (cached) {
499
- const age = Date.now() - cached.writtenAt;
500
- if (trustTtl && age < ttl()) {
501
- return cached.repos;
502
- }
503
- const fingerprint2 = await computeFingerprint(config, configDir);
504
- if (cached.fingerprint === fingerprint2) {
505
- await writeCacheFile(file, { ...cached, writtenAt: Date.now() });
506
- return cached.repos;
507
- }
572
+ if (/^https?:\/\//.test(trimmed)) {
573
+ let url;
574
+ try {
575
+ url = new URL(trimmed);
576
+ } catch {
577
+ throw new Error(`Invalid URL: ${trimmed}`);
578
+ }
579
+ const segments = url.pathname.split("/").filter(Boolean);
580
+ if (segments.length < 2) {
581
+ throw new Error(`URL must contain owner and repo: ${trimmed}`);
508
582
  }
583
+ return {
584
+ host: url.host,
585
+ owner: segments[0],
586
+ repo: stripGitSuffix(segments[1])
587
+ };
509
588
  }
510
- const repos = await scanRepos({ config, configDir });
511
- const fingerprint = await computeFingerprint(config, configDir);
512
- await writeCacheFile(file, {
513
- fingerprint,
514
- writtenAt: Date.now(),
515
- repos
516
- });
517
- return repos;
518
- }
519
- async function appendCachedRepo(options, repo) {
520
- const { config, configDir } = options;
521
- const root = resolveRoot(config.root, configDir);
522
- const file = cachePath(root);
523
- const cached = await readCacheFile(file);
524
- if (!cached) {
525
- return;
589
+ const named = NAMED_RE.exec(trimmed);
590
+ if (named) {
591
+ return {
592
+ forgeName: named[1],
593
+ owner: named[2],
594
+ repo: stripGitSuffix(named[3])
595
+ };
526
596
  }
527
- if (cached.repos.some((r) => r.localPath === repo.localPath)) {
528
- return;
597
+ const short = SHORT_RE.exec(trimmed);
598
+ if (short) {
599
+ return {
600
+ owner: short[1],
601
+ repo: stripGitSuffix(short[2])
602
+ };
529
603
  }
530
- await writeCacheFile(file, {
531
- fingerprint: await computeFingerprint(config, configDir),
532
- writtenAt: Date.now(),
533
- repos: [...cached.repos, repo]
534
- });
535
- }
536
- async function removeCachedRepo(options, localPath) {
537
- const { config, configDir } = options;
538
- const root = resolveRoot(config.root, configDir);
539
- const file = cachePath(root);
540
- const cached = await readCacheFile(file);
541
- if (!cached) return;
542
- const next = cached.repos.filter((r) => r.localPath !== localPath);
543
- if (next.length === cached.repos.length) return;
544
- await writeCacheFile(file, {
545
- fingerprint: await computeFingerprint(config, configDir),
546
- writtenAt: Date.now(),
547
- repos: next
548
- });
604
+ throw new Error(`Unrecognized slug format: ${input}`);
549
605
  }
550
606
  async function gitIn(cwd, args) {
551
607
  return execCapture("git", args, { cwd });
@@ -650,87 +706,57 @@ async function getLastCommitUnix(localPath) {
650
706
  return Number.isFinite(ts) ? ts : null;
651
707
  }
652
708
  async function hasUnpushedCommits(localPath) {
653
- const result = await gitIn(localPath, [
654
- "log",
655
- "--branches",
656
- "--not",
657
- "--remotes",
658
- "--format=%H",
659
- "-1"
660
- ]);
661
- if (result.code !== 0) return true;
662
- return result.stdout.trim().length > 0;
663
- }
664
- async function countStashes(localPath) {
665
- const result = await gitIn(localPath, ["stash", "list", "--format=%gd"]);
666
- if (result.code !== 0) return 0;
667
- return result.stdout.split("\n").filter((line) => line.trim().length > 0).length;
668
- }
669
- const SHORT_RE = /^([\w.-]+)\/([\w.-]+)$/;
670
- const NAMED_RE = /^([\w.-]+):([\w.-]+)\/([\w.-]+)$/;
671
- const SSH_RE = /^git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/;
672
- function stripGitSuffix(repo) {
673
- return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
674
- }
675
- function looksLikeSlug(input) {
676
- return input.trim().includes("/");
677
- }
678
- function parseSlug(input) {
679
- const trimmed = input.trim();
680
- if (!trimmed) {
681
- throw new Error("Slug is empty");
682
- }
683
- const ssh = SSH_RE.exec(trimmed);
684
- if (ssh) {
685
- return {
686
- host: ssh[1],
687
- owner: ssh[2],
688
- repo: stripGitSuffix(ssh[3])
689
- };
690
- }
691
- if (/^https?:\/\//.test(trimmed)) {
692
- let url;
693
- try {
694
- url = new URL(trimmed);
695
- } catch {
696
- throw new Error(`Invalid URL: ${trimmed}`);
697
- }
698
- const segments = url.pathname.split("/").filter(Boolean);
699
- if (segments.length < 2) {
700
- throw new Error(`URL must contain owner and repo: ${trimmed}`);
709
+ const result = await gitIn(localPath, [
710
+ "log",
711
+ "--branches",
712
+ "--not",
713
+ "--remotes",
714
+ "--format=%H",
715
+ "-1"
716
+ ]);
717
+ if (result.code !== 0) return true;
718
+ return result.stdout.trim().length > 0;
719
+ }
720
+ async function getUnpushedBranches(localPath) {
721
+ const listed = await gitIn(localPath, [
722
+ "for-each-ref",
723
+ "--format=%(refname:short)",
724
+ "refs/heads"
725
+ ]);
726
+ if (listed.code !== 0) return [];
727
+ const branches = listed.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
728
+ const unpushed = [];
729
+ for (const branch of branches) {
730
+ const result = await gitIn(localPath, [
731
+ "log",
732
+ branch,
733
+ "--not",
734
+ "--remotes",
735
+ "--format=%H",
736
+ "-1"
737
+ ]);
738
+ if (result.code === 0 && result.stdout.trim().length > 0) {
739
+ unpushed.push(branch);
701
740
  }
702
- return {
703
- host: url.host,
704
- owner: segments[0],
705
- repo: stripGitSuffix(segments[1])
706
- };
707
- }
708
- const named = NAMED_RE.exec(trimmed);
709
- if (named) {
710
- return {
711
- forgeName: named[1],
712
- owner: named[2],
713
- repo: stripGitSuffix(named[3])
714
- };
715
- }
716
- const short = SHORT_RE.exec(trimmed);
717
- if (short) {
718
- return {
719
- owner: short[1],
720
- repo: stripGitSuffix(short[2])
721
- };
722
741
  }
723
- throw new Error(`Unrecognized slug format: ${input}`);
742
+ return unpushed;
743
+ }
744
+ async function countStashes(localPath) {
745
+ const result = await gitIn(localPath, ["stash", "list", "--format=%gd"]);
746
+ if (result.code !== 0) return 0;
747
+ return result.stdout.split("\n").filter((line) => line.trim().length > 0).length;
724
748
  }
725
- const DAY_SECONDS = 86400;
726
- const LOCAL_CONCURRENCY$1 = 16;
727
749
  const REMOTE_CONCURRENCY$1 = 10;
728
- async function evaluate(repo, cutoffUnix) {
750
+ async function evaluateRepo(repo, options = {}) {
729
751
  if (!await isGitRepo(repo.localPath)) return null;
730
752
  const origin = await getOriginUrl(repo.localPath);
731
753
  if (!origin) return null;
732
754
  const lastCommitUnix = await getLastCommitUnix(repo.localPath);
733
- if (lastCommitUnix === null || lastCommitUnix > cutoffUnix) return null;
755
+ if (options.cutoffUnix !== void 0) {
756
+ if (lastCommitUnix === null || lastCommitUnix > options.cutoffUnix) {
757
+ return null;
758
+ }
759
+ }
734
760
  const status = await getRepoStatus(repo.localPath);
735
761
  const dirty = status.dirty;
736
762
  const stashes = status.stashes;
@@ -754,6 +780,30 @@ async function evaluate(repo, cutoffUnix) {
754
780
  stashes
755
781
  };
756
782
  }
783
+ const UNCOMMITTED = "uncommitted changes";
784
+ const UNPUSHED = "unpushed commits";
785
+ const STASHED = "stashed work";
786
+ function stashedReason(stashes) {
787
+ return `${STASHED} (${stashes} stash${stashes === 1 ? "" : "es"})`;
788
+ }
789
+ function localGateOverride(reason) {
790
+ if (reason === UNCOMMITTED) return "--include-dirty";
791
+ if (reason === UNPUSHED) return "--include-unpushed";
792
+ if (reason.startsWith(STASHED)) return "--include-stashed";
793
+ return void 0;
794
+ }
795
+ function localBlocker(evaluation, overrides) {
796
+ if (evaluation.dirty && !overrides.includeDirty) return UNCOMMITTED;
797
+ if (evaluation.unpushed && !overrides.includeUnpushed) return UNPUSHED;
798
+ if (evaluation.stashes > 0 && !overrides.includeStashed) {
799
+ return stashedReason(evaluation.stashes);
800
+ }
801
+ return null;
802
+ }
803
+ function remoteBlocker(state) {
804
+ if (state === "exists" || state === "moved") return null;
805
+ return state === "gone" ? "remote no longer exists" : "remote unreachable";
806
+ }
757
807
  async function classifyRemotes(candidates) {
758
808
  const byType = /* @__PURE__ */ new Map();
759
809
  for (const c of candidates) {
@@ -812,6 +862,48 @@ async function classifyRemotes(candidates) {
812
862
  );
813
863
  return results;
814
864
  }
865
+ async function safeReaddir(path) {
866
+ try {
867
+ return await readdir(path);
868
+ } catch {
869
+ return null;
870
+ }
871
+ }
872
+ async function findEmptyDirs(root, config) {
873
+ const empties = [];
874
+ for (const forge of Object.values(config.forges)) {
875
+ const serverPath = join(root, forge.dir);
876
+ const owners = await safeReaddir(serverPath);
877
+ if (owners === null) continue;
878
+ let emptyCount = 0;
879
+ for (const owner of owners) {
880
+ const ownerPath = join(serverPath, owner);
881
+ const inner = await safeReaddir(ownerPath);
882
+ if (inner !== null && inner.length === 0) {
883
+ empties.push(ownerPath);
884
+ emptyCount++;
885
+ }
886
+ }
887
+ if (owners.length === 0 || emptyCount === owners.length) {
888
+ empties.push(serverPath);
889
+ }
890
+ }
891
+ return empties;
892
+ }
893
+ async function pruneEmptyDirs(root, config) {
894
+ const empties = await findEmptyDirs(root, config);
895
+ let removed = 0;
896
+ for (const dir of empties) {
897
+ try {
898
+ await rmdir(dir);
899
+ removed++;
900
+ } catch {
901
+ }
902
+ }
903
+ return removed;
904
+ }
905
+ const DAY_SECONDS = 86400;
906
+ const LOCAL_CONCURRENCY$1 = 16;
815
907
  function ageDays(lastCommitUnix) {
816
908
  return Math.floor(
817
909
  Date.now() / 1e3 / DAY_SECONDS - lastCommitUnix / DAY_SECONDS
@@ -886,35 +978,21 @@ const cleanupCommand = defineCommand({
886
978
  const stale = (await mapLimit(
887
979
  repos,
888
980
  LOCAL_CONCURRENCY$1,
889
- (repo) => evaluate(repo, cutoffUnix)
981
+ (repo) => evaluateRepo(repo, { cutoffUnix })
890
982
  )).filter((c) => c !== null);
891
983
  const includeDirty = Boolean(args["include-dirty"]);
892
984
  const includeUnpushed = Boolean(args["include-unpushed"]);
893
985
  const includeStashed = Boolean(args["include-stashed"]);
894
- const localOk = (c) => (!c.dirty || includeDirty) && (!c.unpushed || includeUnpushed) && (c.stashes === 0 || includeStashed);
895
- const remoteStates = await classifyRemotes(stale.filter(localOk));
986
+ const overrides = { includeDirty, includeUnpushed, includeStashed };
987
+ const remoteStates = await classifyRemotes(
988
+ stale.filter((c) => localBlocker(c, overrides) === null)
989
+ );
896
990
  const candidates = [];
897
991
  const kept = [];
898
992
  for (const c of stale) {
899
- if (c.dirty && !includeDirty) {
900
- kept.push({ repo: c, reason: "uncommitted changes" });
901
- } else if (c.unpushed && !includeUnpushed) {
902
- kept.push({ repo: c, reason: "unpushed commits" });
903
- } else if (c.stashes > 0 && !includeStashed) {
904
- kept.push({
905
- repo: c,
906
- reason: `stashed work (${c.stashes} stash${c.stashes === 1 ? "" : "es"})`
907
- });
908
- } else {
909
- const state = remoteStates.get(c.repo.localPath)?.state;
910
- if (state === "exists" || state === "moved") candidates.push(c);
911
- else {
912
- kept.push({
913
- repo: c,
914
- reason: state === "gone" ? "remote no longer exists" : "remote unreachable"
915
- });
916
- }
917
- }
993
+ const reason = localBlocker(c, overrides) ?? remoteBlocker(remoteStates.get(c.repo.localPath)?.state);
994
+ if (reason === null) candidates.push(c);
995
+ else kept.push({ repo: c, reason });
918
996
  }
919
997
  candidates.sort((a, b) => a.lastCommitUnix - b.lastCommitUnix);
920
998
  kept.sort((a, b) => a.repo.lastCommitUnix - b.repo.lastCommitUnix);
@@ -1008,46 +1086,6 @@ const cleanupCommand = defineCommand({
1008
1086
  }
1009
1087
  }
1010
1088
  });
1011
- async function safeReaddir(path) {
1012
- try {
1013
- return await readdir(path);
1014
- } catch {
1015
- return null;
1016
- }
1017
- }
1018
- async function findEmptyDirs(root, config) {
1019
- const empties = [];
1020
- for (const forge of Object.values(config.forges)) {
1021
- const serverPath = join(root, forge.dir);
1022
- const owners = await safeReaddir(serverPath);
1023
- if (owners === null) continue;
1024
- let emptyCount = 0;
1025
- for (const owner of owners) {
1026
- const ownerPath = join(serverPath, owner);
1027
- const inner = await safeReaddir(ownerPath);
1028
- if (inner !== null && inner.length === 0) {
1029
- empties.push(ownerPath);
1030
- emptyCount++;
1031
- }
1032
- }
1033
- if (owners.length === 0 || emptyCount === owners.length) {
1034
- empties.push(serverPath);
1035
- }
1036
- }
1037
- return empties;
1038
- }
1039
- async function pruneEmptyDirs(root, config) {
1040
- const empties = await findEmptyDirs(root, config);
1041
- let removed = 0;
1042
- for (const dir of empties) {
1043
- try {
1044
- await rmdir(dir);
1045
- removed++;
1046
- } catch {
1047
- }
1048
- }
1049
- return removed;
1050
- }
1051
1089
  function findForgeByHost(forges, host) {
1052
1090
  for (const [name, forge] of Object.entries(forges)) {
1053
1091
  if (forge.host.toLowerCase() === host.toLowerCase()) {
@@ -1233,6 +1271,7 @@ const SUBCOMMANDS = [
1233
1271
  "clone",
1234
1272
  "import",
1235
1273
  "cleanup",
1274
+ "delete",
1236
1275
  "cd",
1237
1276
  "path",
1238
1277
  "open",
@@ -1245,7 +1284,15 @@ const SUBCOMMANDS = [
1245
1284
  "completion",
1246
1285
  "config"
1247
1286
  ];
1248
- const SLUG_COMMANDS = ["clone", "cd", "path", "open", "search", "pick"];
1287
+ const SLUG_COMMANDS = [
1288
+ "clone",
1289
+ "cd",
1290
+ "path",
1291
+ "open",
1292
+ "search",
1293
+ "pick",
1294
+ "delete"
1295
+ ];
1249
1296
  function renderBash() {
1250
1297
  return `# forgemap bash completion — drop into your ~/.bashrc:
1251
1298
  # eval "$(forgemap completion bash)"
@@ -1501,6 +1548,164 @@ const configCommand = defineCommand({
1501
1548
  show: configShowCommand
1502
1549
  }
1503
1550
  });
1551
+ const deleteCommand = defineCommand({
1552
+ meta: {
1553
+ name: "delete",
1554
+ description: "Delete one local repo by slug, behind the same safety gates as cleanup (no staleness requirement)"
1555
+ },
1556
+ args: {
1557
+ slug: {
1558
+ type: "positional",
1559
+ description: "owner/repo, forge:owner/repo, or full URL",
1560
+ required: true
1561
+ },
1562
+ "dry-run": {
1563
+ type: "boolean",
1564
+ description: "Only report what would happen; never prompt or delete",
1565
+ default: false
1566
+ },
1567
+ yes: {
1568
+ type: "boolean",
1569
+ description: "Skip the interactive confirmation (deletes immediately)",
1570
+ default: false
1571
+ },
1572
+ "include-dirty": {
1573
+ type: "boolean",
1574
+ description: "Also delete when there are uncommitted changes (those changes are lost)",
1575
+ default: false
1576
+ },
1577
+ "include-unpushed": {
1578
+ type: "boolean",
1579
+ description: "Also delete when there are unpushed commits (those commits are lost)",
1580
+ default: false
1581
+ },
1582
+ "include-stashed": {
1583
+ type: "boolean",
1584
+ description: "Also delete when there is stashed work (that stash is lost)",
1585
+ default: false
1586
+ },
1587
+ config: {
1588
+ type: "string",
1589
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
1590
+ }
1591
+ },
1592
+ async run({ args }) {
1593
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1594
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
1595
+ let resolved;
1596
+ try {
1597
+ resolved = resolveSlug(parseSlug(args.slug), {
1598
+ config: loaded.config,
1599
+ configDir
1600
+ });
1601
+ } catch (error) {
1602
+ consola.error(error.message);
1603
+ process.exitCode = 1;
1604
+ return;
1605
+ }
1606
+ const repo = {
1607
+ forgeName: resolved.forgeName,
1608
+ forge: resolved.forge,
1609
+ owner: resolved.owner,
1610
+ repo: resolved.repo,
1611
+ localPath: resolved.localPath,
1612
+ slug: `${resolved.owner}/${resolved.repo}`
1613
+ };
1614
+ if (!existsSync(repo.localPath)) {
1615
+ consola.error(
1616
+ `No local repo at ${repo.localPath} — nothing to delete for ${repo.forgeName}:${repo.slug}.`
1617
+ );
1618
+ process.exitCode = 1;
1619
+ return;
1620
+ }
1621
+ const evaluation = await evaluateRepo(repo);
1622
+ if (!evaluation) {
1623
+ consola.error(
1624
+ `Refusing to delete ${colors.cyan(`${repo.forgeName}:${repo.slug}`)} — ${repo.localPath} is not a git repo with an "origin" remote, so there is no remote copy to fall back on. Remove it by hand if you are sure.`
1625
+ );
1626
+ process.exitCode = 1;
1627
+ return;
1628
+ }
1629
+ process.stdout.write(
1630
+ `${colors.bold(`${repo.forgeName}:${repo.slug}`)} ${colors.dim(repo.localPath)}
1631
+ `
1632
+ );
1633
+ const unpushedBranches = evaluation.unpushed ? await getUnpushedBranches(repo.localPath) : [];
1634
+ const losses = [];
1635
+ if (evaluation.dirty) losses.push("uncommitted changes");
1636
+ if (evaluation.unpushed) {
1637
+ losses.push(
1638
+ unpushedBranches.length > 0 ? `unpushed commits on ${unpushedBranches.join(", ")}` : "unpushed commits"
1639
+ );
1640
+ }
1641
+ if (evaluation.stashes > 0) {
1642
+ losses.push(
1643
+ `${evaluation.stashes} stash${evaluation.stashes === 1 ? "" : "es"}`
1644
+ );
1645
+ }
1646
+ if (losses.length > 0) {
1647
+ process.stdout.write(
1648
+ ` ${colors.red("local-only work:")} ${losses.join("; ")}
1649
+ `
1650
+ );
1651
+ }
1652
+ process.stdout.write("\n");
1653
+ const remoteStates = await classifyRemotes([evaluation]);
1654
+ const remoteReason = remoteBlocker(remoteStates.get(repo.localPath)?.state);
1655
+ if (remoteReason) {
1656
+ consola.error(
1657
+ `Refusing to delete — ${remoteReason}. This is never overridable: the local copy may be the only one left.`
1658
+ );
1659
+ process.exitCode = 1;
1660
+ return;
1661
+ }
1662
+ const localReason = localBlocker(evaluation, {
1663
+ includeDirty: Boolean(args["include-dirty"]),
1664
+ includeUnpushed: Boolean(args["include-unpushed"]),
1665
+ includeStashed: Boolean(args["include-stashed"])
1666
+ });
1667
+ if (localReason) {
1668
+ const hint = localGateOverride(localReason);
1669
+ consola.error(
1670
+ `Refusing to delete — ${localReason}${hint ? `. Pass ${hint} to delete anyway (that work is lost)` : ""}.`
1671
+ );
1672
+ process.exitCode = 1;
1673
+ return;
1674
+ }
1675
+ if (args["dry-run"]) {
1676
+ consola.info("Dry run — nothing deleted.");
1677
+ return;
1678
+ }
1679
+ if (losses.length > 0) {
1680
+ consola.warn(
1681
+ `This repo has local-only work that will be permanently lost: ${losses.join("; ")}.`
1682
+ );
1683
+ }
1684
+ let confirmed = args.yes;
1685
+ if (!confirmed) {
1686
+ const answer = await consola.prompt(
1687
+ `Type "yes" to delete ${repo.slug} locally:`,
1688
+ { type: "text", cancel: "null" }
1689
+ );
1690
+ confirmed = typeof answer === "string" && answer.trim() === "yes";
1691
+ }
1692
+ if (!confirmed) {
1693
+ consola.info("Aborted — nothing deleted.");
1694
+ return;
1695
+ }
1696
+ await rm(repo.localPath, { recursive: true, force: true });
1697
+ await removeCachedRepo(
1698
+ { config: loaded.config, configDir },
1699
+ repo.localPath
1700
+ );
1701
+ consola.success(`Deleted ${repo.localPath}`);
1702
+ const root = resolveRoot(loaded.config.root, configDir);
1703
+ const emptied = await pruneEmptyDirs(root, loaded.config);
1704
+ if (emptied > 0) {
1705
+ consola.success(`Removed ${emptied} empty folder(s).`);
1706
+ }
1707
+ }
1708
+ });
1504
1709
  async function listDirs(path) {
1505
1710
  try {
1506
1711
  const entries = await readdir(path, { withFileTypes: true });
@@ -2294,6 +2499,44 @@ const pickCommand = defineCommand({
2294
2499
  }
2295
2500
  }
2296
2501
  });
2502
+ const FLAG = "--filter";
2503
+ const filterArg = {
2504
+ type: "string",
2505
+ description: "Restrict to repos whose owner or forge name matches. Repeatable; a repo passes if it matches any value."
2506
+ };
2507
+ function collectFilterArgs(rawArgs) {
2508
+ const values = [];
2509
+ for (let i = 0; i < rawArgs.length; i++) {
2510
+ const arg = rawArgs[i];
2511
+ if (arg === "--") break;
2512
+ if (arg === FLAG) {
2513
+ const next = rawArgs[i + 1];
2514
+ if (next !== void 0 && !next.startsWith("-")) {
2515
+ values.push(next);
2516
+ i++;
2517
+ }
2518
+ continue;
2519
+ }
2520
+ if (arg.startsWith(`${FLAG}=`)) values.push(arg.slice(FLAG.length + 1));
2521
+ }
2522
+ return values;
2523
+ }
2524
+ function normalizeFilters(value) {
2525
+ if (value === void 0) return [];
2526
+ const values = Array.isArray(value) ? value : [value];
2527
+ return values.map((v) => v.trim()).filter((v) => v.length > 0);
2528
+ }
2529
+ function resolveFilters(rawArgs, value) {
2530
+ const fromRawArgs = collectFilterArgs(rawArgs);
2531
+ return normalizeFilters(fromRawArgs.length > 0 ? fromRawArgs : value);
2532
+ }
2533
+ function filterRepos(repos, filters) {
2534
+ if (filters.length === 0) return repos;
2535
+ const wanted = new Set(filters.map((f) => f.toLowerCase()));
2536
+ return repos.filter(
2537
+ (r) => wanted.has(r.owner.toLowerCase()) || wanted.has(r.forgeName.toLowerCase())
2538
+ );
2539
+ }
2297
2540
  function renderTree$1(repos) {
2298
2541
  const byForge = /* @__PURE__ */ new Map();
2299
2542
  for (const r of repos) {
@@ -2334,6 +2577,7 @@ const searchCommand = defineCommand({
2334
2577
  description: "Output format: auto (default), pretty, path, or slug. auto picks pretty in a TTY, path when piped.",
2335
2578
  default: "auto"
2336
2579
  },
2580
+ filter: filterArg,
2337
2581
  limit: {
2338
2582
  type: "string",
2339
2583
  description: "Maximum number of matches to print (default: unlimited)"
@@ -2343,10 +2587,11 @@ const searchCommand = defineCommand({
2343
2587
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2344
2588
  }
2345
2589
  },
2346
- async run({ args }) {
2590
+ async run({ args, rawArgs }) {
2347
2591
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2348
2592
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2349
- const repos = await scanRepos({ config: loaded.config, configDir });
2593
+ const scanned = await scanRepos({ config: loaded.config, configDir });
2594
+ const repos = filterRepos(scanned, resolveFilters(rawArgs, args.filter));
2350
2595
  const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
2351
2596
  const items = matchRepos(repos, args.query, limit);
2352
2597
  const allowed = ["auto", "pretty", "path", "slug"];
@@ -2559,6 +2804,7 @@ const statusCommand = defineCommand({
2559
2804
  type: "string",
2560
2805
  description: "Restrict to a single forge alias"
2561
2806
  },
2807
+ filter: filterArg,
2562
2808
  query: {
2563
2809
  type: "string",
2564
2810
  description: "Fuzzy filter against <owner>/<repo>"
@@ -2573,7 +2819,7 @@ const statusCommand = defineCommand({
2573
2819
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2574
2820
  }
2575
2821
  },
2576
- async run({ args }) {
2822
+ async run({ args, rawArgs }) {
2577
2823
  if (!ALLOWED_FORMATS.includes(args.format)) {
2578
2824
  consola.error(
2579
2825
  `Invalid --format value "${args.format}". Allowed: ${ALLOWED_FORMATS.join(", ")}.`
@@ -2591,6 +2837,7 @@ const statusCommand = defineCommand({
2591
2837
  if (args.forge) {
2592
2838
  repos = repos.filter((r) => r.forgeName === args.forge);
2593
2839
  }
2840
+ repos = filterRepos(repos, resolveFilters(rawArgs, args.filter));
2594
2841
  if (args.query) {
2595
2842
  const fuse = new Fuse(repos, {
2596
2843
  keys: ["slug", "owner", "repo"],
@@ -2672,6 +2919,7 @@ const syncCommand = defineCommand({
2672
2919
  type: "string",
2673
2920
  description: "Restrict to a single forge alias"
2674
2921
  },
2922
+ filter: filterArg,
2675
2923
  query: {
2676
2924
  type: "string",
2677
2925
  description: "Fuzzy filter against <owner>/<repo>"
@@ -2686,7 +2934,7 @@ const syncCommand = defineCommand({
2686
2934
  description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2687
2935
  }
2688
2936
  },
2689
- async run({ args }) {
2937
+ async run({ args, rawArgs }) {
2690
2938
  const loaded = await loadForgeMapConfig({ configFile: args.config });
2691
2939
  const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2692
2940
  let repos = await scanReposCached({
@@ -2697,6 +2945,7 @@ const syncCommand = defineCommand({
2697
2945
  if (args.forge) {
2698
2946
  repos = repos.filter((r) => r.forgeName === args.forge);
2699
2947
  }
2948
+ repos = filterRepos(repos, resolveFilters(rawArgs, args.filter));
2700
2949
  if (args.query) {
2701
2950
  const fuse = new Fuse(repos, {
2702
2951
  keys: ["slug", "owner", "repo"],
@@ -2915,6 +3164,7 @@ const rootCommand = defineCommand({
2915
3164
  clone: cloneCommand,
2916
3165
  import: importCommand,
2917
3166
  cleanup: cleanupCommand,
3167
+ delete: deleteCommand,
2918
3168
  cd: cdCommand,
2919
3169
  path: pathCommand,
2920
3170
  open: openCommand,