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.
- package/README.md +18 -1
- package/dist/bin/forgemap.mjs +540 -290
- package/dist/bin/forgemap.mjs.map +1 -1
- package/package.json +1 -1
package/dist/bin/forgemap.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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) =>
|
|
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
|
|
895
|
-
const remoteStates = await classifyRemotes(
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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 = [
|
|
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
|
|
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,
|