forgemap 0.4.1-dev.69-291072b → 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}`);
508
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}`);
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 });
@@ -659,78 +715,48 @@ async function hasUnpushedCommits(localPath) {
659
715
  "-1"
660
716
  ]);
661
717
  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("/");
718
+ return result.stdout.trim().length > 0;
677
719
  }
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}`);
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 });
@@ -2959,6 +3164,7 @@ const rootCommand = defineCommand({
2959
3164
  clone: cloneCommand,
2960
3165
  import: importCommand,
2961
3166
  cleanup: cleanupCommand,
3167
+ delete: deleteCommand,
2962
3168
  cd: cdCommand,
2963
3169
  path: pathCommand,
2964
3170
  open: openCommand,