@descix/cli 1.0.13 → 1.0.15
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/bin/descix.js +3 -4
- package/lib/commands/corpus.js +61 -10
- package/lib/commands/kb.js +75 -53
- package/lib/core/staleBlobNames.js +53 -0
- package/lib/verb-invokes.generated.json +2 -1
- package/package.json +1 -1
package/bin/descix.js
CHANGED
|
@@ -2229,14 +2229,13 @@ corpusCommand
|
|
|
2229
2229
|
});
|
|
2230
2230
|
|
|
2231
2231
|
// ============ KB Doctor (M3, 2026-04-20) ============
|
|
2232
|
-
// Compare
|
|
2233
|
-
// logs for per-file 0-chunk warnings. Exits
|
|
2232
|
+
// Compare the live corpus files against the last sync's walk (orphans / missing) and scan recent
|
|
2233
|
+
// sync logs for per-file 0-chunk warnings. Exits 1 on orphans or missing files.
|
|
2234
2234
|
kbCommand
|
|
2235
2235
|
.command('doctor')
|
|
2236
|
-
.description('
|
|
2236
|
+
.description('Check a KB against its last sync by FILE IDENTITY: live corpus files that were not walked (orphans) and walked files that are not live (missing)')
|
|
2237
2237
|
.requiredOption('-a, --app <id>', 'App ID')
|
|
2238
2238
|
.requiredOption('-k, --kb <name>', 'KB name')
|
|
2239
|
-
.option('-t, --threshold <ratio>', 'Drift threshold as a fraction (default: 0.05)', parseFloat)
|
|
2240
2239
|
.option('--live', 'Compute vectorCount from the TRUE live Pinecone scope (bypass the cached counter, which lies after an interrupted op)')
|
|
2241
2240
|
.option('--reconcile', 'Compute the live count AND write it back to rag_vector_count so the cached read is truthful again (implies --live)')
|
|
2242
2241
|
.action(async (options) => {
|
package/lib/commands/corpus.js
CHANGED
|
@@ -156,6 +156,21 @@ async function chunkCorpusFile(fileEntry, context) {
|
|
|
156
156
|
}).filter(Boolean);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The walked files that produce NO chunks, for this sync's state. Returns null (UNKNOWN) when a
|
|
162
|
+
* file carried over unchanged comes from a state that never recorded the field: a partial list
|
|
163
|
+
* would make `kb doctor` report a file as lost from the store when it is merely empty.
|
|
164
|
+
*/
|
|
165
|
+
export function zeroChunkBlobShas(previousState, localBlobShas, newOrChangedFiles, zeroThisRun) {
|
|
166
|
+
const changed = new Set(newOrChangedFiles.map((f) => f.blob_sha));
|
|
167
|
+
const carriedUnchanged = [...localBlobShas].filter((sha) => !changed.has(sha));
|
|
168
|
+
if (carriedUnchanged.length > 0 && !Array.isArray(previousState?.zero_chunk_blob_shas)) return null;
|
|
169
|
+
const previousZero = new Set(previousState?.zero_chunk_blob_shas || []);
|
|
170
|
+
const carried = carriedUnchanged.filter((sha) => previousZero.has(sha));
|
|
171
|
+
return [...new Set([...carried, ...zeroThisRun])].sort();
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
/**
|
|
160
175
|
* Read the previously-synced chunk total out of sync state, for REPORTING only.
|
|
161
176
|
*
|
|
@@ -660,26 +675,41 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
660
675
|
const localBlobShas = new Set(files.map(f => f.blob_sha));
|
|
661
676
|
const newOrChangedFiles = files.filter(f => !previousBlobShas.has(f.blob_sha));
|
|
662
677
|
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
678
|
+
// STALE = what is LIVE in this KB's corpus scheme and absent from the walk. ONE
|
|
679
|
+
// derivation, read from the store itself — never from local history.
|
|
680
|
+
//
|
|
681
|
+
// It used to be "previously synced blob_shas minus the walk", which is only as good as
|
|
682
|
+
// the history. Measured 2026-09-18 (GODSWORLD-DEV, PROD): the first origin-keyed sync
|
|
683
|
+
// had NO history for its origin, so it purged nothing, and a superseded INDEX.md stayed
|
|
684
|
+
// retrievable beside its replacement indefinitely — no later incremental sync could
|
|
685
|
+
// ever name it. Lost state, a repointed origin and a hand edit all fail the same way.
|
|
686
|
+
// A `corpus:` file_id is written only by this manifest's sync, so a live corpus id the
|
|
687
|
+
// walk does not produce is stale by definition. Both cases are covered:
|
|
688
|
+
// (a) file deleted from the corpus; (b) file modified in place (new blob_sha).
|
|
689
|
+
// --rebuild purges the whole scope instead, so it computes no per-file list.
|
|
690
|
+
const localFileIds = new Set(files.map(f => `corpus:${f.blob_sha}`));
|
|
691
|
+
let fileIdsToDelete = [];
|
|
692
|
+
if (!options.rebuild) {
|
|
693
|
+
const remote = await listRemoteFileIds(apiClient, appId, kbName);
|
|
694
|
+
fileIdsToDelete = remote.file_ids
|
|
695
|
+
.filter(id => typeof id === 'string' && id.startsWith('corpus:') && !localFileIds.has(id))
|
|
696
|
+
.sort();
|
|
697
|
+
}
|
|
672
698
|
|
|
673
699
|
const unchangedCount = files.length - newOrChangedFiles.length;
|
|
674
700
|
|
|
675
701
|
if (options.verbose) {
|
|
676
702
|
console.log(chalk.gray(` New/changed files: ${newOrChangedFiles.length}`));
|
|
677
703
|
console.log(chalk.gray(` Unchanged files: ${unchangedCount}`));
|
|
678
|
-
console.log(chalk.gray(` Stale
|
|
704
|
+
console.log(chalk.gray(` Stale corpus file_ids live in the KB (delete): ${fileIdsToDelete.length}`));
|
|
679
705
|
}
|
|
680
706
|
|
|
681
707
|
// 3d. Chunk new/changed files
|
|
682
708
|
const allChunks = [];
|
|
709
|
+
// Files this run chunked to NOTHING — recorded so `kb doctor` can tell "empty by design"
|
|
710
|
+
// from "lost from the store". A file that FAILED to chunk is not added: it is missing
|
|
711
|
+
// content, and the doctor should say so.
|
|
712
|
+
const zeroChunkShasThisRun = [];
|
|
683
713
|
if (newOrChangedFiles.length > 0) {
|
|
684
714
|
const chunkSpinner = ora(` Chunking ${newOrChangedFiles.length} file(s)...`).start();
|
|
685
715
|
|
|
@@ -689,6 +719,7 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
689
719
|
allChunks.push(...chunks);
|
|
690
720
|
|
|
691
721
|
if (chunks.length === 0) {
|
|
722
|
+
zeroChunkShasThisRun.push(fileEntry.blob_sha);
|
|
692
723
|
// M1 (2026-04-20): always surface 0-chunk files even without -v.
|
|
693
724
|
// These are the silent failures that caused the 77% EGPT gap.
|
|
694
725
|
// 'kb doctor' scans sync logs for this exact string.
|
|
@@ -713,6 +744,21 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
713
744
|
console.log(chalk.cyan(`\n [dry-run] Plan for ${kbName}:`));
|
|
714
745
|
console.log(chalk.white(` Would upsert: ${allChunks.length} chunks from ${newOrChangedFiles.length} file(s)`));
|
|
715
746
|
console.log(chalk.white(` Would delete (stale-file): ${fileIdsToDelete.length} blob SHA(s)`));
|
|
747
|
+
// NAME them, so the operator sees WHICH documents a real run removes, not just how many.
|
|
748
|
+
// A corpus file_id is a git blob SHA, so the corpus's own repositories can say where it lived.
|
|
749
|
+
if (fileIdsToDelete.length > 0) {
|
|
750
|
+
const { describeStaleBlobs } = await import('../core/staleBlobNames.js');
|
|
751
|
+
const { resolveOwningRepo } = await import('../core/ManifestLoader.js');
|
|
752
|
+
const repoRoots = [...new Set(files
|
|
753
|
+
.map((f) => resolveOwningRepo(f.absolute_path)?.repoRoot)
|
|
754
|
+
.filter(Boolean))];
|
|
755
|
+
for (const s of describeStaleBlobs(fileIdsToDelete, repoRoots)) {
|
|
756
|
+
const where = s.path
|
|
757
|
+
? `${s.repo.split('/').pop()}:${s.path} (last seen in ${s.commit.substring(0, 8)})`
|
|
758
|
+
: 'not in any local history of this corpus — cannot be named';
|
|
759
|
+
console.log(chalk.gray(` - ${s.file_id.substring(0, 16)}… ${where}`));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
716
762
|
if (options.rebuild) {
|
|
717
763
|
console.log(chalk.white(
|
|
718
764
|
` Would purge (rebuild): FULL scope ${communityId}/${appId}/${kbName} ` +
|
|
@@ -938,6 +984,11 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
938
984
|
chunks_upserted: isReportedCount(upserted) ? upserted : null,
|
|
939
985
|
chunks_deleted: isReportedCount(deleted) ? deleted : null,
|
|
940
986
|
synced_blob_shas: allSyncedBlobShas,
|
|
987
|
+
// Walked files that produced no chunks. KNOWN only when every file's outcome is known:
|
|
988
|
+
// chunked this run, or carried from a previous state that recorded the field. A state
|
|
989
|
+
// written before this field existed leaves unchanged files unaccounted for, so the
|
|
990
|
+
// field is null (UNKNOWN) rather than a partial list the doctor would read as complete.
|
|
991
|
+
zero_chunk_blob_shas: zeroChunkBlobShas(previousState, localBlobShas, newOrChangedFiles, zeroChunkShasThisRun),
|
|
941
992
|
// The retrieval canary's verdict — read this, not `total_chunks`, to know whether the
|
|
942
993
|
// last sync's content was CONFIRMED searchable or only confirmed present.
|
|
943
994
|
retrieval_canary: retrievalCanary
|
package/lib/commands/kb.js
CHANGED
|
@@ -27,7 +27,7 @@ import { requireInteractive } from '../interactive.js';
|
|
|
27
27
|
import { WorkspaceConfig } from '../workspace-config.js';
|
|
28
28
|
import { hydrateKb, pushStaging, checkStagingFiles } from '../core/Hydrator.js';
|
|
29
29
|
import { processKb } from '../core/Chunker.js';
|
|
30
|
-
import { syncKb, getSyncStatus } from '../core/Syncer.js';
|
|
30
|
+
import { syncKb, getSyncStatus, listRemoteFileIds } from '../core/Syncer.js';
|
|
31
31
|
import * as driveADC from '../google-storage-adc.js';
|
|
32
32
|
// The one canonical KB-sync surface, from its owner. Never spell it as a literal here: these
|
|
33
33
|
// commands' next steps are read by a developer as instructions, so they must name the live verb
|
|
@@ -252,32 +252,30 @@ export async function runKbPush(apiClient, options) {
|
|
|
252
252
|
|
|
253
253
|
// ============ M3: `descix kb doctor` — drift detector (2026-04-20) ============
|
|
254
254
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* drift direction and exits non-zero when drift exceeds the threshold.
|
|
255
|
+
* Check a KB against its last corpus sync by FILE IDENTITY, and scan the most recent verbose
|
|
256
|
+
* sync log (if any) for per-file 0-chunk warnings.
|
|
258
257
|
*
|
|
259
|
-
* WHY
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
258
|
+
* WHY IDENTITY, NOT COUNT
|
|
259
|
+
* -----------------------
|
|
260
|
+
* The first version compared Pinecone's vectorCount with the sync-state's total_chunks. But the
|
|
261
|
+
* sync writes total_chunks FROM the reconciled live count, so the comparison was the store
|
|
262
|
+
* against a copy of itself: drift 0 by construction, "HEALTHY" over a superseded document that
|
|
263
|
+
* was still live and retrievable (measured 2026-09-18, GODSWORLD-DEV, PROD). vectorCount also
|
|
264
|
+
* lags deletes. What matters is WHICH files are live, so that is what this checks.
|
|
266
265
|
*
|
|
267
266
|
* BEHAVIOUR
|
|
268
267
|
* ---------
|
|
269
|
-
* descix kb doctor -a <app> -k <kb>
|
|
268
|
+
* descix kb doctor -a <app> -k <kb> [--live | --reconcile]
|
|
270
269
|
*
|
|
271
|
-
* (a)
|
|
272
|
-
*
|
|
273
|
-
* (
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* (e) Exits non-zero on drift above threshold (default: 5%).
|
|
270
|
+
* (a) get_kb_rag_status for vectorCount — reported as information only. --live / --reconcile
|
|
271
|
+
* still check the server's CACHED counter against the live store, a separate question.
|
|
272
|
+
* (b) Reads this origin's sync-state: the walked blob SHAs and the zero-chunk ones.
|
|
273
|
+
* (c) Lists the live corpus file_ids and reports:
|
|
274
|
+
* ORPHANS — live but not walked: retrievable stale content. Fails.
|
|
275
|
+
* MISSING — walked, has content, not live. Fails. UNVERIFIABLE (not failing) when the
|
|
276
|
+
* state predates the zero-chunk record, rather than guessing.
|
|
277
|
+
* (d) Scans the most recent logs/kb-sync-*.log for '0-chunk' / 'skipped' warnings.
|
|
278
|
+
* (e) Exits 1 on orphans or missing files, 2 when the state cannot be read.
|
|
281
279
|
*/
|
|
282
280
|
export async function runKbDoctor(apiClient, options) {
|
|
283
281
|
const { WorkspaceConfig } = await import('../workspace-config.js');
|
|
@@ -293,10 +291,6 @@ export async function runKbDoctor(apiClient, options) {
|
|
|
293
291
|
const appRoot = appMeta.absolutePath;
|
|
294
292
|
const communityId = appMeta.communityId || appMeta.community_id || null;
|
|
295
293
|
|
|
296
|
-
// Drift threshold (fraction). CEO guidance 2026-04-20: ~5% is reasonable.
|
|
297
|
-
const DRIFT_THRESHOLD = Number.isFinite(Number(options.threshold))
|
|
298
|
-
? Number(options.threshold) : 0.05;
|
|
299
|
-
|
|
300
294
|
const scope = communityId ? `${communityId}/${appId}/${kbName}` : `${appId}/${kbName}`;
|
|
301
295
|
console.log(chalk.cyan(`\n🩺 kb doctor — ${scope}\n`));
|
|
302
296
|
|
|
@@ -362,23 +356,33 @@ export async function runKbDoctor(apiClient, options) {
|
|
|
362
356
|
console.log(chalk.gray(` Run 'descix kb corpus sync -a ${appId} -k ${kbName}' against this origin first.`));
|
|
363
357
|
process.exit(2);
|
|
364
358
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
359
|
+
// (c) IDENTITY, not count. Which corpus files are LIVE, against which files the last sync
|
|
360
|
+
// WALKED. The count comparison this replaced read the store against `total_chunks`, which the
|
|
361
|
+
// sync itself writes FROM the reconciled live count — so drift was zero by construction and
|
|
362
|
+
// the check could not fail (measured 2026-09-18, GODSWORLD-DEV: "HEALTHY" printed over a
|
|
363
|
+
// superseded INDEX.md that was still live and retrievable). Identity catches exactly that.
|
|
364
|
+
if (!Array.isArray(syncStateRaw.synced_blob_shas)) {
|
|
365
|
+
console.log(chalk.yellow(` ⚠ sync-state records no synced_blob_shas at ${statePathForOrigin} — the walked set is UNKNOWN.`));
|
|
366
|
+
process.exit(2);
|
|
368
367
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
console.log(`
|
|
379
|
-
console.log(`
|
|
380
|
-
|
|
381
|
-
|
|
368
|
+
const report = diagnoseCorpusIdentity({
|
|
369
|
+
liveFileIds: (await listRemoteFileIds(apiClient, appId, kbName)).file_ids,
|
|
370
|
+
walkedBlobShas: syncStateRaw.synced_blob_shas,
|
|
371
|
+
zeroChunkBlobShas: syncStateRaw.zero_chunk_blob_shas,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
console.log(` Walked by last sync : ${chalk.white(report.walked)} file(s)` +
|
|
375
|
+
(report.zeroKnown ? chalk.gray(` (${report.zeroChunk} empty by design)`) : ''));
|
|
376
|
+
console.log(` Live corpus files : ${chalk.white(report.live)}`);
|
|
377
|
+
console.log(` Orphans (live, not walked) : ${report.orphans.length ? chalk.red(report.orphans.length) : chalk.green(0)}`);
|
|
378
|
+
report.orphans.slice(0, 10).forEach((id) => console.log(chalk.red(` ${id}`)));
|
|
379
|
+
if (report.zeroKnown) {
|
|
380
|
+
console.log(` Missing (walked, not live) : ${report.missing.length ? chalk.red(report.missing.length) : chalk.green(0)}`);
|
|
381
|
+
report.missing.slice(0, 10).forEach((id) => console.log(chalk.red(` ${id}`)));
|
|
382
|
+
} else {
|
|
383
|
+
console.log(chalk.yellow(' Missing (walked, not live) : UNVERIFIABLE — this state predates the record of empty files; the next sync writes it.'));
|
|
384
|
+
}
|
|
385
|
+
console.log(chalk.gray(` Pinecone vectorCount : ${vectorCount} (informational — it lags deletes and is not a health signal)`));
|
|
382
386
|
console.log(` Index : ${ragStatus.indexName || '(unknown)'}`);
|
|
383
387
|
console.log(` Last sync (server) : ${ragStatus.lastSync || '(unknown)'}`);
|
|
384
388
|
console.log(` Last sync (local) : ${syncStateRaw.timestamp || '(unknown)'}`);
|
|
@@ -420,19 +424,37 @@ export async function runKbDoctor(apiClient, options) {
|
|
|
420
424
|
|
|
421
425
|
// (e) Exit
|
|
422
426
|
console.log();
|
|
423
|
-
if (
|
|
424
|
-
console.log(chalk.red(` ✗
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
console.log(chalk.gray(` rm "${syncStatePath}"`));
|
|
428
|
-
console.log(chalk.gray(` descix kb corpus sync -a ${appId} -k ${kbName}\n`));
|
|
429
|
-
} else {
|
|
430
|
-
console.log(chalk.gray(` Recommended: clear stale vectors and re-sync to rebuild Pinecone from local state.\n`));
|
|
431
|
-
}
|
|
432
|
-
process.exit(1);
|
|
427
|
+
if (report.orphans.length > 0) {
|
|
428
|
+
console.log(chalk.red(` ✗ ORPHANS: ${report.orphans.length} corpus file(s) are live and retrievable but were not in the last walk.`));
|
|
429
|
+
console.log(chalk.gray(` A plain sync purges them (it reconciles against the live KB). Preview it first:`));
|
|
430
|
+
console.log(chalk.gray(` descix kb corpus sync -a ${appId} -k ${kbName} --dry-run\n`));
|
|
433
431
|
}
|
|
432
|
+
if (report.missing.length > 0) {
|
|
433
|
+
console.log(chalk.red(` ✗ MISSING: ${report.missing.length} walked file(s) hold chunks but are not live.`));
|
|
434
|
+
console.log(chalk.gray(` A plain sync skips files its state already records, so rebuild:`));
|
|
435
|
+
console.log(chalk.gray(` descix kb corpus sync -a ${appId} -k ${kbName} --rebuild --dry-run\n`));
|
|
436
|
+
}
|
|
437
|
+
if (report.orphans.length > 0 || report.missing.length > 0) process.exit(1);
|
|
434
438
|
|
|
435
|
-
|
|
439
|
+
if (report.zeroKnown) {
|
|
440
|
+
console.log(chalk.green(` ✓ HEALTHY — every live corpus file was walked, and every walked file with content is live.\n`));
|
|
441
|
+
} else {
|
|
442
|
+
console.log(chalk.green(` ✓ No orphans.`) + chalk.yellow(` Missing-content check pending the next sync.\n`));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Compare the live corpus file set against the last walk. Pure, so the verdict is testable
|
|
448
|
+
* without a store. Only `corpus:` ids are this manifest's to judge; other schemes are ignored.
|
|
449
|
+
*/
|
|
450
|
+
export function diagnoseCorpusIdentity({ liveFileIds, walkedBlobShas, zeroChunkBlobShas }) {
|
|
451
|
+
const live = new Set((liveFileIds || []).filter((id) => typeof id === 'string' && id.startsWith('corpus:')));
|
|
452
|
+
const walked = new Set((walkedBlobShas || []).map((sha) => `corpus:${sha}`));
|
|
453
|
+
const zeroKnown = Array.isArray(zeroChunkBlobShas);
|
|
454
|
+
const zero = new Set((zeroChunkBlobShas || []).map((sha) => `corpus:${sha}`));
|
|
455
|
+
const orphans = [...live].filter((id) => !walked.has(id)).sort();
|
|
456
|
+
const missing = zeroKnown ? [...walked].filter((id) => !zero.has(id) && !live.has(id)).sort() : [];
|
|
457
|
+
return { live: live.size, walked: walked.size, zeroKnown, zeroChunk: zero.size, orphans, missing };
|
|
436
458
|
}
|
|
437
459
|
|
|
438
460
|
export default {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Name the stale corpus files a sync would delete, so an operator sees WHICH documents go
|
|
3
|
+
* before a real run — not just how many. (GODSWORLD-DEV, 2026-09-18: a dry run reported
|
|
4
|
+
* "Would delete (stale-file): 3" on a KB where the three might matter, with no way to tell
|
|
5
|
+
* which three.)
|
|
6
|
+
*
|
|
7
|
+
* A `corpus:` file_id IS a git blob SHA, so the corpus's own repositories can say which path
|
|
8
|
+
* held that blob, and in which commit. No store round-trip and no server change. A blob that
|
|
9
|
+
* no local history holds is reported as such rather than guessed at.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'child_process';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse `git log --all --no-abbrev --format=%h --raw --find-object=<sha>` output: the most
|
|
16
|
+
* recent commit that added or removed that blob, and the path it lived at.
|
|
17
|
+
* @returns {{commit:string, path:string} | null}
|
|
18
|
+
*/
|
|
19
|
+
export function parseFindObjectRaw(output, sha) {
|
|
20
|
+
let commit = null;
|
|
21
|
+
for (const line of String(output || '').split('\n')) {
|
|
22
|
+
if (/^[0-9a-f]{7,40}$/.test(line.trim())) { commit = line.trim(); continue; }
|
|
23
|
+
const m = line.match(/^:\d{6} \d{6} ([0-9a-f]{40}) ([0-9a-f]{40}) \S+\t(.+)$/);
|
|
24
|
+
if (m && commit && (m[1] === sha || m[2] === sha)) {
|
|
25
|
+
return { commit, path: m[3].split('\t').pop() };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve each stale `corpus:<sha>` id to where that blob lived, searching each repo root.
|
|
33
|
+
* @param {string[]} fileIds - `corpus:<sha>` ids
|
|
34
|
+
* @param {string[]} repoRoots - the repositories that own this corpus's sources
|
|
35
|
+
* @returns {Array<{file_id:string, repo:string|null, commit:string|null, path:string|null}>}
|
|
36
|
+
*/
|
|
37
|
+
export function describeStaleBlobs(fileIds, repoRoots) {
|
|
38
|
+
return fileIds.map((file_id) => {
|
|
39
|
+
const sha = file_id.replace(/^corpus:/, '');
|
|
40
|
+
for (const repo of repoRoots) {
|
|
41
|
+
let out = '';
|
|
42
|
+
try {
|
|
43
|
+
out = execFileSync('git', ['log', '--all', '--no-abbrev', '--format=%h', '--raw', `--find-object=${sha}`],
|
|
44
|
+
{ cwd: repo, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 });
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const hit = parseFindObjectRaw(out, sha);
|
|
49
|
+
if (hit) return { file_id, repo, commit: hit.commit, path: hit.path };
|
|
50
|
+
}
|
|
51
|
+
return { file_id, repo: null, commit: null, path: null };
|
|
52
|
+
});
|
|
53
|
+
}
|