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.
- package/README.md +13 -1
- package/dist/bin/forgemap.mjs +482 -276
- 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
|
-
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
|
|
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 });
|
|
@@ -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
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
if (
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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 });
|
|
@@ -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,
|