@vzn/vx 0.0.477 → 0.0.478
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/package.json +5 -5
- package/src/cache/cache.ts +13 -2
- package/src/cache/file-hashes.ts +21 -0
- package/src/cache/git-inputs.ts +146 -56
- package/src/cache/index.ts +1 -0
- package/src/cache/schema.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.478",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.478",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.478",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.478",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.478"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -228,7 +228,10 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
|
|
|
228
228
|
// hit's stored stdout (up to 16 MB) with its overflow pages: 200
|
|
229
229
|
// hits of 1 MB cost the close 125-150 ms. The cache KEY is
|
|
230
230
|
// unchanged.
|
|
231
|
-
|
|
231
|
+
// v30: blob_verdicts — the blob-size check's verdict by a hash of the
|
|
232
|
+
// index file (A-60), so a warm run reads one row. The cache KEY is
|
|
233
|
+
// unchanged.
|
|
234
|
+
export const SCHEMA_VERSION = 'v30'
|
|
232
235
|
|
|
233
236
|
/** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
|
|
234
237
|
const SELECT_ENTRY =
|
|
@@ -685,7 +688,7 @@ export class Cache implements CacheLayer {
|
|
|
685
688
|
}
|
|
686
689
|
if (found === SCHEMA_VERSION) return null
|
|
687
690
|
this.db.exec(
|
|
688
|
-
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
|
|
691
|
+
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS blob_verdicts; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
|
|
689
692
|
)
|
|
690
693
|
this.db
|
|
691
694
|
.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
|
|
@@ -831,6 +834,14 @@ export class Cache implements CacheLayer {
|
|
|
831
834
|
rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
|
|
832
835
|
this.guard(() => this.files.rememberBlobSizes(sizes))
|
|
833
836
|
}
|
|
837
|
+
/** `BlobSizeMemo`: the paths an index with this digest distrusts, when known. */
|
|
838
|
+
blobVerdict(digest: string): string[] | undefined {
|
|
839
|
+
return this.guard(() => this.files.blobVerdict(digest))
|
|
840
|
+
}
|
|
841
|
+
/** `BlobSizeMemo`: remember a verdict, honouring the local WRITE axis. */
|
|
842
|
+
rememberBlobVerdict(digest: string, paths: readonly string[]): void {
|
|
843
|
+
this.guard(() => this.files.rememberBlobVerdict(digest, paths))
|
|
844
|
+
}
|
|
834
845
|
/**
|
|
835
846
|
* `relPosix` against the run's workspace root, memoized: the same three
|
|
836
847
|
* thousand files are re-relativized for every task of the project, which
|
package/src/cache/file-hashes.ts
CHANGED
|
@@ -74,6 +74,7 @@ export class FileHashStore {
|
|
|
74
74
|
this.db.transaction(() => {
|
|
75
75
|
this.db.prepare('DELETE FROM file_hashes WHERE seen_at < ?').run(cutoff)
|
|
76
76
|
this.db.prepare('DELETE FROM blob_sizes WHERE seen_at < ?').run(cutoff)
|
|
77
|
+
this.db.prepare('DELETE FROM blob_verdicts WHERE seen_at < ?').run(cutoff)
|
|
77
78
|
this.db
|
|
78
79
|
.prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
|
|
79
80
|
.run(FILE_HASHES_SWEPT_AT, String(now))
|
|
@@ -109,6 +110,26 @@ export class FileHashStore {
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/** The paths a stored verdict distrusts, or undefined when none is stored for `digest`. */
|
|
114
|
+
blobVerdict(digest: string): string[] | undefined {
|
|
115
|
+
const row = this.db.query('SELECT paths FROM blob_verdicts WHERE digest = ?').get(digest) as {
|
|
116
|
+
paths: string
|
|
117
|
+
} | null
|
|
118
|
+
return row === null ? undefined : (JSON.parse(row.paths) as string[])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Store a verdict; a memo, so a full disk skips it. */
|
|
122
|
+
rememberBlobVerdict(digest: string, paths: readonly string[]): void {
|
|
123
|
+
if (!this.write) return
|
|
124
|
+
try {
|
|
125
|
+
this.db
|
|
126
|
+
.query('INSERT OR REPLACE INTO blob_verdicts(digest, paths, seen_at) VALUES (?, ?, ?)')
|
|
127
|
+
.run(digest, JSON.stringify(paths), Date.now())
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (!isIndexFull(err)) throw err
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
/**
|
|
113
134
|
* Content-hash a file (as a git blob OID, v20) with an mtime+size
|
|
114
135
|
* fast path. If the `file_hashes` table has a row for `path` whose
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
10
|
-
import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
|
|
10
|
+
import { UserError, executablePath, gitSpawnRefusal, xxh3hex } from '../util/index.js'
|
|
11
|
+
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
11
12
|
|
|
12
13
|
/** Three facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
|
|
13
14
|
export interface RepoFacts {
|
|
@@ -17,6 +18,8 @@ export interface RepoFacts {
|
|
|
17
18
|
commonDir: string
|
|
18
19
|
/** `--show-object-format`: the hash the index's blob OIDs are in. */
|
|
19
20
|
objectFormat: 'sha1' | 'sha256'
|
|
21
|
+
/** `--git-path index` (it honours `GIT_INDEX_FILE`), relative to the directory asked. */
|
|
22
|
+
indexFile: string
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const repoFactsMemo = new Map<string, RepoFacts>()
|
|
@@ -46,6 +49,8 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
46
49
|
'--show-prefix',
|
|
47
50
|
'--git-common-dir',
|
|
48
51
|
'--show-object-format',
|
|
52
|
+
'--git-path',
|
|
53
|
+
'index',
|
|
49
54
|
],
|
|
50
55
|
cwd: dir,
|
|
51
56
|
stdout: 'pipe',
|
|
@@ -58,7 +63,7 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
58
63
|
// One line per flag, in order. A git that does not know
|
|
59
64
|
// `--show-object-format` echoes it back, as it does any unknown flag,
|
|
60
65
|
// which reads as sha1 — what the flag's own spawn answered there too.
|
|
61
|
-
const [prefix = '', commonDir = '', format = ''] = new TextDecoder()
|
|
66
|
+
const [prefix = '', commonDir = '', format = '', indexFile = ''] = new TextDecoder()
|
|
62
67
|
.decode(proc.stdout)
|
|
63
68
|
.split('\n')
|
|
64
69
|
.map((l) => l.trim())
|
|
@@ -66,13 +71,15 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
66
71
|
prefix,
|
|
67
72
|
commonDir,
|
|
68
73
|
objectFormat: format === 'sha256' ? 'sha256' : 'sha1',
|
|
74
|
+
indexFile,
|
|
69
75
|
}
|
|
70
76
|
repoFactsMemo.set(dir, facts)
|
|
71
77
|
return facts
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
/** Variables that move where git finds the repository: with any set, git is asked. */
|
|
80
|
+
/** Variables that move where git finds the repository or its index: with any set, git is asked. */
|
|
75
81
|
const GIT_LOCATION_ENV = [
|
|
82
|
+
'GIT_INDEX_FILE',
|
|
76
83
|
'GIT_DIR',
|
|
77
84
|
'GIT_WORK_TREE',
|
|
78
85
|
'GIT_COMMON_DIR',
|
|
@@ -106,6 +113,7 @@ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
|
|
|
106
113
|
prefix: below === '' ? '' : `${below}/`,
|
|
107
114
|
commonDir: path.relative(real, dotGit).split(path.sep).join('/'),
|
|
108
115
|
objectFormat,
|
|
116
|
+
indexFile: path.relative(real, path.join(dotGit, 'index')).split(path.sep).join('/'),
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
const parent = path.dirname(at)
|
|
@@ -613,10 +621,33 @@ function splitLsDebug(run: GitRun): {
|
|
|
613
621
|
return { records, sizes, undecodable }
|
|
614
622
|
}
|
|
615
623
|
|
|
616
|
-
/**
|
|
624
|
+
/**
|
|
625
|
+
* What the blob-size check keeps between runs (`Cache` is one): each blob's
|
|
626
|
+
* size, fixed for its OID, and each verdict, the paths an index distrusts,
|
|
627
|
+
* fixed for the index file's bytes (`IndexBlobCheck.key`).
|
|
628
|
+
*/
|
|
617
629
|
export interface BlobSizeMemo {
|
|
618
630
|
knownBlobSizes(oids: readonly string[]): Map<string, number>
|
|
619
631
|
rememberBlobSizes(sizes: ReadonlyMap<string, number>): void
|
|
632
|
+
blobVerdict(key: string): string[] | undefined
|
|
633
|
+
rememberBlobVerdict(key: string, paths: readonly string[]): void
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Each regular stage-0 index entry's path, raw OID and recorded worktree size, in index order. */
|
|
637
|
+
export interface IndexBlobs {
|
|
638
|
+
paths: string[]
|
|
639
|
+
oids: string[]
|
|
640
|
+
sizes: number[]
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** What the blob-size check reads, lazily: the verdict's key, and the listing a miss needs. */
|
|
644
|
+
export interface IndexBlobCheck {
|
|
645
|
+
/** xxh3 of the index file and the pathspecs; undefined when the file could not be read. */
|
|
646
|
+
key: string | undefined
|
|
647
|
+
/** The entries `ls-files --debug` lists, or null when it fails. */
|
|
648
|
+
blobs(): Promise<IndexBlobs | null>
|
|
649
|
+
/** The key as the index stands now: a verdict is stored only for the index it came from. */
|
|
650
|
+
rekey(): Promise<string | undefined>
|
|
620
651
|
}
|
|
621
652
|
|
|
622
653
|
/**
|
|
@@ -625,53 +656,58 @@ export interface BlobSizeMemo {
|
|
|
625
656
|
* was added from, and git holds a stat-clean entry clean without re-reading
|
|
626
657
|
* it once the filter is gone (`core.autocrlf` turned off, a `.gitattributes`
|
|
627
658
|
* rule removed): `status` and the filter gate, which reads today's config,
|
|
628
|
-
* both let the blob stand for bytes it does not hold.
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
659
|
+
* both let the blob stand for bytes it does not hold. Which paths an index
|
|
660
|
+
* distrusts is a function of the index alone, so `memo` keeps the verdict
|
|
661
|
+
* by a hash of the index file and a warm run reads one row: the `--debug`
|
|
662
|
+
* listing and a lookup per entry cost 550 ms at 100,000 files (2026-10-02).
|
|
663
|
+
* An index with no verdict spawns that listing (the recorded sizes), asks
|
|
664
|
+
* the per-OID sizes, and `cat-file` for the ones `memo` lacks; the verdict
|
|
665
|
+
* is stored only if the index is still the one it was read from. A smudged
|
|
666
|
+
* (racy) entry records 0 and is hashed from disk. An answer that cannot be
|
|
667
|
+
* read trusts nothing. A filter that keeps the size (`tr a-z A-Z`) is not
|
|
668
|
+
* caught: only a read of every trusted file would be (caching.md, step 12).
|
|
635
669
|
*/
|
|
636
670
|
async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
|
|
637
|
-
const { trusted,
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const entry = indexed.get(rel)
|
|
645
|
-
if (entry === undefined) continue // a symlink: its blob is its target string
|
|
646
|
-
rels.push(rel)
|
|
647
|
-
entries.push(entry)
|
|
648
|
-
wanted.add(entry.oid)
|
|
649
|
-
}
|
|
650
|
-
if (wanted.size === 0) return
|
|
651
|
-
const sizes = memo?.knownBlobSizes([...wanted]) ?? new Map<string, number>()
|
|
652
|
-
const unknown = [...wanted].filter((oid) => !sizes.has(oid))
|
|
653
|
-
if (unknown.length > 0) {
|
|
654
|
-
const run = await enumeration.catFile(unknown.join('\n') + '\n')
|
|
655
|
-
if (run === null || run.exitCode !== 0) {
|
|
671
|
+
const { trusted, blobCheck } = enumeration
|
|
672
|
+
if (trusted.size === 0) return
|
|
673
|
+
const key = blobCheck.key
|
|
674
|
+
let resized = key === undefined ? undefined : memo?.blobVerdict(key)
|
|
675
|
+
if (resized === undefined) {
|
|
676
|
+
const blobs = await blobCheck.blobs()
|
|
677
|
+
if (blobs === null) {
|
|
656
678
|
trusted.clear()
|
|
657
679
|
return
|
|
658
680
|
}
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
681
|
+
const unique = [...new Set(blobs.oids)]
|
|
682
|
+
const sizes = memo?.knownBlobSizes(unique) ?? new Map<string, number>()
|
|
683
|
+
const unknown = unique.filter((oid) => !sizes.has(oid))
|
|
684
|
+
if (unknown.length > 0) {
|
|
685
|
+
const run = await enumeration.catFile(unknown.join('\n') + '\n')
|
|
686
|
+
if (run === null || run.exitCode !== 0) {
|
|
687
|
+
trusted.clear()
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
const learned = new Map<string, number>()
|
|
691
|
+
for (const line of run.stdout.split('\n')) {
|
|
692
|
+
const sp = line.indexOf(' ')
|
|
693
|
+
const n = Number(line.slice(sp + 1))
|
|
694
|
+
if (sp > 0 && line.slice(sp + 1) !== '' && Number.isInteger(n))
|
|
695
|
+
learned.set(line.slice(0, sp), n)
|
|
696
|
+
}
|
|
697
|
+
for (const [oid, n] of learned) sizes.set(oid, n)
|
|
698
|
+
memo?.rememberBlobSizes(learned)
|
|
699
|
+
}
|
|
700
|
+
resized = []
|
|
701
|
+
for (let i = 0; i < blobs.oids.length; i++) {
|
|
702
|
+
const size = sizes.get(blobs.oids[i]!)
|
|
703
|
+
// The index keeps the low 32 bits of a size.
|
|
704
|
+
if (size === undefined || size % 2 ** 32 !== blobs.sizes[i]) resized.push(blobs.paths[i]!)
|
|
705
|
+
}
|
|
706
|
+
if (key !== undefined && (await blobCheck.rekey()) === key) {
|
|
707
|
+
memo?.rememberBlobVerdict(key, resized)
|
|
665
708
|
}
|
|
666
|
-
for (const [oid, n] of learned) sizes.set(oid, n)
|
|
667
|
-
memo?.rememberBlobSizes(learned)
|
|
668
|
-
}
|
|
669
|
-
for (let i = 0; i < rels.length; i++) {
|
|
670
|
-
const entry = entries[i]!
|
|
671
|
-
const size = sizes.get(entry.oid)
|
|
672
|
-
// The index keeps the low 32 bits of a size.
|
|
673
|
-
if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(rels[i]!)
|
|
674
709
|
}
|
|
710
|
+
for (const rel of resized) trusted.delete(rel)
|
|
675
711
|
}
|
|
676
712
|
|
|
677
713
|
/** One completed `git` invocation. */
|
|
@@ -1074,8 +1110,8 @@ export interface GitEnumeration {
|
|
|
1074
1110
|
undecodable: readonly string[]
|
|
1075
1111
|
/** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
|
|
1076
1112
|
startedAtMs: number
|
|
1077
|
-
/**
|
|
1078
|
-
|
|
1113
|
+
/** The blob-size check's input (`dropResizedOids`). */
|
|
1114
|
+
blobCheck: IndexBlobCheck
|
|
1079
1115
|
/** `git cat-file --batch-check` over `stdin`'s OIDs; null when it could not spawn. */
|
|
1080
1116
|
catFile(stdin: string): Promise<{ exitCode: number; stdout: string } | null>
|
|
1081
1117
|
}
|
|
@@ -1156,12 +1192,7 @@ export async function startGitEnumeration(
|
|
|
1156
1192
|
// tracked paths are dirty AND which files are untracked. Asking
|
|
1157
1193
|
// `ls-files --others` for the untracked set walked the same tree a second
|
|
1158
1194
|
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1159
|
-
|
|
1160
|
-
// (`dropResizedOids`, A-60).
|
|
1161
|
-
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs]).then(
|
|
1162
|
-
(run) =>
|
|
1163
|
-
run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug: splitLsDebug(run) },
|
|
1164
|
-
)
|
|
1195
|
+
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
|
|
1165
1196
|
const running = Promise.all([
|
|
1166
1197
|
listing,
|
|
1167
1198
|
// `--ignored=matching` names an ignored path without descending into an
|
|
@@ -1203,7 +1234,50 @@ export async function startGitEnumeration(
|
|
|
1203
1234
|
// at all. `--git-dir` named the per-worktree directory, so the gate
|
|
1204
1235
|
// looked where the rule can never be.
|
|
1205
1236
|
const facts = repoFacts(workspaceRoot)
|
|
1206
|
-
|
|
1237
|
+
// The blob-size check's verdict is a function of the index's entries, so
|
|
1238
|
+
// the index file's bytes key it: one read, where `ls-files --debug` and a
|
|
1239
|
+
// lookup per entry cost 550 ms at 100,000 files (A-60).
|
|
1240
|
+
const pathspecKey = xxh3hex(pathspecs.join('\0'))
|
|
1241
|
+
const indexFile =
|
|
1242
|
+
facts === null || facts.indexFile === ''
|
|
1243
|
+
? undefined
|
|
1244
|
+
: path.resolve(workspaceRoot, facts.indexFile)
|
|
1245
|
+
const readIndexKey = async (): Promise<
|
|
1246
|
+
{ key: string; stamp: string; ctimeMs: number } | undefined
|
|
1247
|
+
> => {
|
|
1248
|
+
if (indexFile === undefined) return undefined
|
|
1249
|
+
try {
|
|
1250
|
+
const st = lstatSync(indexFile)
|
|
1251
|
+
const bytes = await Bun.file(indexFile).bytes()
|
|
1252
|
+
return {
|
|
1253
|
+
key: `${xxh3hex(bytes)}:${pathspecKey}`,
|
|
1254
|
+
stamp: `${st.ino}:${st.ctimeMs}:${st.size}`,
|
|
1255
|
+
ctimeMs: st.ctimeMs,
|
|
1256
|
+
}
|
|
1257
|
+
} catch {
|
|
1258
|
+
return undefined
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const indexKeyRead = readIndexKey()
|
|
1262
|
+
const [ls, status, vars] = await running
|
|
1263
|
+
// The key stands for the index `ls-files` listed only when that index
|
|
1264
|
+
// was written before the enumeration began (git writes it by rename, so
|
|
1265
|
+
// the stamp moves with any write) and is still in place after it: a
|
|
1266
|
+
// `git add` between the listing and the read paired one index's verdict
|
|
1267
|
+
// with the other's entries. Otherwise this run asks for no verdict.
|
|
1268
|
+
const read = await indexKeyRead
|
|
1269
|
+
let indexKey: string | undefined
|
|
1270
|
+
if (
|
|
1271
|
+
read !== undefined &&
|
|
1272
|
+
read.ctimeMs < startedAtMs - racyWindowMs(read.ctimeMs, FILE_HASH_RACY_MS)
|
|
1273
|
+
) {
|
|
1274
|
+
try {
|
|
1275
|
+
const st = lstatSync(indexFile!)
|
|
1276
|
+
if (`${st.ino}:${st.ctimeMs}:${st.size}` === read.stamp) indexKey = read.key
|
|
1277
|
+
} catch {
|
|
1278
|
+
// gone: no key
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1207
1281
|
if (ls === null) {
|
|
1208
1282
|
throw gitSpawnRefusal(workspaceRoot)
|
|
1209
1283
|
}
|
|
@@ -1214,14 +1288,13 @@ export async function startGitEnumeration(
|
|
|
1214
1288
|
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
1215
1289
|
)
|
|
1216
1290
|
}
|
|
1217
|
-
const indexed = new Map<string, { oid: string; size: number }>()
|
|
1218
1291
|
const {
|
|
1219
1292
|
files: tracked,
|
|
1220
1293
|
oids,
|
|
1221
1294
|
flagged,
|
|
1222
1295
|
gitlinks,
|
|
1223
1296
|
undecodable,
|
|
1224
|
-
} = parseLsFilesOutput(
|
|
1297
|
+
} = parseLsFilesOutput(ls.stdout.length === 0 ? [] : ls.stdout.split('\0'), ls.undecodable)
|
|
1225
1298
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
1226
1299
|
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
1227
1300
|
// OID map. Without this, when the workspace root is a git subdir, a modified
|
|
@@ -1299,7 +1372,24 @@ export async function startGitEnumeration(
|
|
|
1299
1372
|
untracked: dirty === null ? null : untracked,
|
|
1300
1373
|
undecodable,
|
|
1301
1374
|
startedAtMs,
|
|
1302
|
-
|
|
1375
|
+
blobCheck: {
|
|
1376
|
+
key: indexKey,
|
|
1377
|
+
async blobs() {
|
|
1378
|
+
const run = await spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs])
|
|
1379
|
+
if (run === null || run.exitCode !== 0) return null
|
|
1380
|
+
const debug = splitLsDebug(run)
|
|
1381
|
+
const indexed = new Map<string, { oid: string; size: number }>()
|
|
1382
|
+
parseLsFilesOutput(debug.records, debug.undecodable, { sizes: debug.sizes, indexed })
|
|
1383
|
+
const blobs: IndexBlobs = { paths: [], oids: [], sizes: [] }
|
|
1384
|
+
for (const [rel, { oid, size }] of indexed) {
|
|
1385
|
+
blobs.paths.push(rel)
|
|
1386
|
+
blobs.oids.push(oid)
|
|
1387
|
+
blobs.sizes.push(size)
|
|
1388
|
+
}
|
|
1389
|
+
return blobs
|
|
1390
|
+
},
|
|
1391
|
+
rekey: async () => (await readIndexKey())?.key,
|
|
1392
|
+
},
|
|
1303
1393
|
// A missing blob is an answer ("missing"), never a fetch from a partial
|
|
1304
1394
|
// clone's promisor remote.
|
|
1305
1395
|
catFile: (stdin) =>
|
package/src/cache/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ export {
|
|
|
56
56
|
lazyGitEnumeration,
|
|
57
57
|
type LazyGitEnumeration,
|
|
58
58
|
type GitEnumeration,
|
|
59
|
+
type BlobSizeMemo,
|
|
59
60
|
} from './git-inputs.js'
|
|
60
61
|
export { LayeredCache, type RemoteCacheLayer } from './layered-cache.js'
|
|
61
62
|
export { ChainedCache } from './chained-cache.js'
|
package/src/cache/schema.ts
CHANGED
|
@@ -144,6 +144,13 @@ export function createTables(db: Database): void {
|
|
|
144
144
|
size INTEGER NOT NULL,
|
|
145
145
|
seen_at INTEGER NOT NULL
|
|
146
146
|
);
|
|
147
|
+
-- v30: the paths an index distrusts, by a hash of the index file and the
|
|
148
|
+
-- pathspecs (A-60): a warm run reads one row, not one per blob.
|
|
149
|
+
CREATE TABLE IF NOT EXISTS blob_verdicts (
|
|
150
|
+
digest TEXT PRIMARY KEY,
|
|
151
|
+
paths TEXT NOT NULL,
|
|
152
|
+
seen_at INTEGER NOT NULL
|
|
153
|
+
);
|
|
147
154
|
-- v16: per-output-file fingerprints, scoped by the cache entry
|
|
148
155
|
-- that produced them. Lets loadOutputFilesBatch(hashes) answer
|
|
149
156
|
-- "for entry X, what are its outputs supposed to look like?"
|