@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -43,6 +43,15 @@ export class ChainedCache implements CacheLayer {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
46
|
+
// The layer that pulled this hash in a prefetch answers first. Layers
|
|
47
|
+
// may share one local store, and an earlier layer found the pulled copy
|
|
48
|
+
// there and called it a local hit: a second remote's hit was counted as
|
|
49
|
+
// saved locally, in the outcome, the summary and telemetry (item 889).
|
|
50
|
+
const owner = this.hitLayer.get(hash)
|
|
51
|
+
if (owner !== undefined) {
|
|
52
|
+
const entry = await owner.get(hash, ctx)
|
|
53
|
+
if (entry !== null) return entry
|
|
54
|
+
}
|
|
46
55
|
for (const layer of this.layers) {
|
|
47
56
|
const entry = await layer.get(hash, ctx)
|
|
48
57
|
if (entry !== null) {
|
|
@@ -127,6 +136,9 @@ export class ChainedCache implements CacheLayer {
|
|
|
127
136
|
recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
|
|
128
137
|
return this.layers[0]!.recordOutputDirs?.(hash, projectDir, prefixes) ?? Promise.resolve()
|
|
129
138
|
}
|
|
139
|
+
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
140
|
+
this.layers[0]!.recordOutputStamps?.(hash, projectDir, workspaceRoot)
|
|
141
|
+
}
|
|
130
142
|
|
|
131
143
|
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
132
144
|
return this.layers[0]!.loadOutputDirsBatch?.(hashes) ?? new Map()
|
|
@@ -155,16 +167,8 @@ export class ChainedCache implements CacheLayer {
|
|
|
155
167
|
}
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
ingest(hash: string,
|
|
159
|
-
return this.layers[0]!.ingest(hash,
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
recordRun(run: RunRecord): void {
|
|
163
|
-
this.layers[0]!.recordRun(run)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
recordRuns(runs: readonly RunRecord[]): void {
|
|
167
|
-
this.layers[0]!.recordRuns(runs)
|
|
170
|
+
ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
171
|
+
return this.layers[0]!.ingest(hash, body, meta)
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The config-evaluation cache tables (`config_evals`, `config_closures`):
|
|
2
|
+
// what `workspace/config-cache.ts` reads and writes through the
|
|
3
|
+
// `ConfigEvalStore` contract, honouring the run's local read/write axes.
|
|
4
|
+
// Owns its statements over the store's handle; `Cache` delegates.
|
|
5
|
+
|
|
6
|
+
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
7
|
+
|
|
8
|
+
export class ConfigEvalTable {
|
|
9
|
+
private readonly selectConfigEval: ReturnType<Database['prepare']>
|
|
10
|
+
private readonly insertConfigEval: ReturnType<Database['prepare']>
|
|
11
|
+
private readonly upsertConfigClosure: ReturnType<Database['prepare']>
|
|
12
|
+
private readonly read: boolean
|
|
13
|
+
private readonly write: boolean
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly db: Database,
|
|
17
|
+
policy: { read: boolean; write: boolean },
|
|
18
|
+
) {
|
|
19
|
+
this.read = policy.read
|
|
20
|
+
this.write = policy.write
|
|
21
|
+
this.selectConfigEval = this.db.prepare('SELECT json FROM config_evals WHERE key = ?')
|
|
22
|
+
this.insertConfigEval = this.db.prepare(
|
|
23
|
+
'INSERT OR REPLACE INTO config_evals(key, json, created_at) VALUES (?, ?, ?)',
|
|
24
|
+
)
|
|
25
|
+
this.upsertConfigClosure = this.db.prepare(
|
|
26
|
+
'INSERT INTO config_closures(config_path, files_json, created_at) VALUES (?, ?, ?) ON CONFLICT(config_path) DO UPDATE SET files_json = excluded.files_json, created_at = excluded.created_at',
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `ConfigEvalStore`: a cached config evaluation, honouring the local READ axis. */
|
|
31
|
+
getConfigEval(key: string): string | null {
|
|
32
|
+
if (!this.read) return null
|
|
33
|
+
const row = this.selectConfigEval.get(key) as { json: string } | null
|
|
34
|
+
return row?.json ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `ConfigEvalStore`: each config's ordered closure, one `IN` query per 900 paths. */
|
|
38
|
+
getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
|
|
39
|
+
const out = new Map<string, string[]>()
|
|
40
|
+
if (!this.read || configPaths.length === 0) return out
|
|
41
|
+
for (let i = 0; i < configPaths.length; i += 900) {
|
|
42
|
+
const chunk = configPaths.slice(i, i + 900)
|
|
43
|
+
const rows = this.db
|
|
44
|
+
.query(
|
|
45
|
+
`SELECT config_path, files_json FROM config_closures WHERE config_path IN (${chunk.map(() => '?').join(',')})`,
|
|
46
|
+
)
|
|
47
|
+
.all(...(chunk as readonly SQLQueryBindings[])) as Array<{
|
|
48
|
+
config_path: string
|
|
49
|
+
files_json: string
|
|
50
|
+
}>
|
|
51
|
+
for (const r of rows) out.set(r.config_path, JSON.parse(r.files_json) as string[])
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `ConfigEvalStore`: remember a config's ordered closure, honouring the local WRITE axis. */
|
|
57
|
+
putConfigClosure(configPath: string, files: readonly string[]): void {
|
|
58
|
+
if (!this.write) return
|
|
59
|
+
this.upsertConfigClosure.run(configPath, JSON.stringify(files), Date.now())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `ConfigEvalStore`: a round's closures in ONE transaction (each upsert was its own, item 615). */
|
|
63
|
+
putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
|
|
64
|
+
if (!this.write || entries.length === 0) return
|
|
65
|
+
const now = Date.now()
|
|
66
|
+
this.db.transaction(() => {
|
|
67
|
+
for (const [configPath, files] of entries)
|
|
68
|
+
this.upsertConfigClosure.run(configPath, JSON.stringify(files), now)
|
|
69
|
+
})()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `ConfigEvalStore`: the batched read — one `IN` query per 900 keys, honouring the local READ axis. */
|
|
73
|
+
getConfigEvals(keys: readonly string[]): Map<string, string> {
|
|
74
|
+
const out = new Map<string, string>()
|
|
75
|
+
if (!this.read || keys.length === 0) return out
|
|
76
|
+
for (let i = 0; i < keys.length; i += 900) {
|
|
77
|
+
const chunk = keys.slice(i, i + 900)
|
|
78
|
+
const rows = this.db
|
|
79
|
+
.query(
|
|
80
|
+
`SELECT key, json FROM config_evals WHERE key IN (${chunk.map(() => '?').join(',')})`,
|
|
81
|
+
)
|
|
82
|
+
.all(...(chunk as readonly SQLQueryBindings[])) as Array<{ key: string; json: string }>
|
|
83
|
+
for (const r of rows) out.set(r.key, r.json)
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `ConfigEvalStore`: remember a validated evaluation, honouring the local WRITE axis. */
|
|
89
|
+
putConfigEval(key: string, json: string): void {
|
|
90
|
+
if (!this.write) return
|
|
91
|
+
this.insertConfigEval.run(key, json, Date.now())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `ConfigEvalStore`: a round's evaluations in ONE transaction. */
|
|
95
|
+
putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
|
|
96
|
+
if (!this.write || entries.length === 0) return
|
|
97
|
+
const now = Date.now()
|
|
98
|
+
this.db.transaction(() => {
|
|
99
|
+
for (const [key, json] of entries) this.insertConfigEval.run(key, json, now)
|
|
100
|
+
})()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Retention: a row not WRITTEN since `cutoff`. A hit does not refresh it
|
|
105
|
+
* (that would be a write per config on every warm run), so a config that
|
|
106
|
+
* hit for thirty days is evaluated once more and stored again.
|
|
107
|
+
*/
|
|
108
|
+
pruneOlderThan(cutoff: number): void {
|
|
109
|
+
this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
|
|
110
|
+
this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The per-file content-hash memo. A file's cache-key contribution is its
|
|
2
|
+
// git blob OID; a clean tracked file takes it from the index (inputs.ts),
|
|
3
|
+
// everything else is hashed here from the worktree bytes — memoized on
|
|
4
|
+
// (mtime, size, ctime, ino) in `file_hashes`, the same four fields git's
|
|
5
|
+
// own index keys on — and a symlink hashes as the blob of its target
|
|
6
|
+
// string, exactly as the index stores it. Owns its two statements over
|
|
7
|
+
// the store's handle; `Cache` delegates.
|
|
8
|
+
|
|
9
|
+
import type { Database } from 'bun:sqlite'
|
|
10
|
+
import { lstatSync, readlinkSync } from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { fileIdentity, repoFacts } from './git-inputs.js'
|
|
13
|
+
import { FILE_HASH_RACY_MS } from './layer.js'
|
|
14
|
+
|
|
15
|
+
export class FileHashStore {
|
|
16
|
+
private readonly selectFileHash: ReturnType<Database['prepare']>
|
|
17
|
+
private readonly upsertFileHash: ReturnType<Database['prepare']>
|
|
18
|
+
private objectFormat: 'sha1' | 'sha256' | null = null
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly db: Database,
|
|
22
|
+
/** Where the object format is asked when no `repoDir` is given and no file is named. */
|
|
23
|
+
private readonly cacheDir: string,
|
|
24
|
+
/** The local WRITE axis: off, and a miss is hashed but not remembered (a read-only cache). */
|
|
25
|
+
private readonly write: boolean = true,
|
|
26
|
+
/**
|
|
27
|
+
* The directory whose repository answers the object format — the
|
|
28
|
+
* workspace root, in a run: the enumeration asks git there too, and
|
|
29
|
+
* `repoFacts` answers both from one spawn. Absent, the directory of the
|
|
30
|
+
* first file hashed answers.
|
|
31
|
+
*/
|
|
32
|
+
private readonly repoDir?: string,
|
|
33
|
+
) {
|
|
34
|
+
this.selectFileHash = this.db.prepare(
|
|
35
|
+
'SELECT mtime_ms, size_bytes, ctime_ms, ino, content_hash FROM file_hashes WHERE path = ?',
|
|
36
|
+
)
|
|
37
|
+
this.upsertFileHash = this.db.prepare(`
|
|
38
|
+
INSERT INTO file_hashes(path, mtime_ms, size_bytes, ctime_ms, ino, content_hash, seen_at)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
41
|
+
mtime_ms = excluded.mtime_ms,
|
|
42
|
+
size_bytes = excluded.size_bytes,
|
|
43
|
+
ctime_ms = excluded.ctime_ms,
|
|
44
|
+
ino = excluded.ino,
|
|
45
|
+
content_hash = excluded.content_hash,
|
|
46
|
+
seen_at = excluded.seen_at
|
|
47
|
+
`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Content-hash a file (as a git blob OID, v20) with an mtime+size
|
|
52
|
+
* fast path. If the `file_hashes` table has a row for `path` whose
|
|
53
|
+
* `(mtime_ms, size_bytes, ctime_ms, ino)` all match the current
|
|
54
|
+
* stat, we reuse the stored content_hash (a memory + SQLite lookup,
|
|
55
|
+
* no disk read). Otherwise we read + hash + upsert. All four fields
|
|
56
|
+
* are load-bearing — see the comment at the comparison.
|
|
57
|
+
*
|
|
58
|
+
* The OID is byte-identical to what `git hash-object` (and the git
|
|
59
|
+
* index) computes for the same content — for a symlink, the index's
|
|
60
|
+
* mode-120000 blob of the link text — so this fallback and the
|
|
61
|
+
* `CacheKeyInput.fileHashes` index-OID fast path agree on any file
|
|
62
|
+
* git stores verbatim. They do NOT agree when a clean filter
|
|
63
|
+
* (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) is active: the index blob is the filtered
|
|
64
|
+
* form while this hashes the worktree bytes, so `inputs.ts` drops
|
|
65
|
+
* the index OID for those paths and routes them here.
|
|
66
|
+
*/
|
|
67
|
+
async hashFile(filePath: string): Promise<string> {
|
|
68
|
+
// lstatSync intentional: a single stat is ~1.6µs (Bun 1.3); the
|
|
69
|
+
// async-stat equivalent adds ~75µs of Promise machinery per call.
|
|
70
|
+
// Promise.all over the batched callers (key derivation) gives no
|
|
71
|
+
// I/O parallelism benefit because the stat is faster than the
|
|
72
|
+
// threadpool dispatch overhead. lstat, not stat, so a symlink is
|
|
73
|
+
// seen as one.
|
|
74
|
+
let st
|
|
75
|
+
try {
|
|
76
|
+
st = lstatSync(filePath)
|
|
77
|
+
} catch {
|
|
78
|
+
// Caller is responsible for skipping files that don't exist;
|
|
79
|
+
// fall through to the content-hash path which will throw with
|
|
80
|
+
// a more useful error.
|
|
81
|
+
return await this.hashFileFromDisk(filePath)
|
|
82
|
+
}
|
|
83
|
+
// A symlink folds as git folds it: the blob of its TARGET STRING, which
|
|
84
|
+
// is its mode-120000 index OID. Not the bytes behind it — a link to a
|
|
85
|
+
// directory has none, a dangling one has none, and a link to a file
|
|
86
|
+
// outside the project would fold bytes `git diff` and `--affected`
|
|
87
|
+
// cannot see. A link to a file inside the project tracks that file's
|
|
88
|
+
// content only when the file matches the task's globs too. No
|
|
89
|
+
// memo: readlink is one syscall, and the row would be keyed on the
|
|
90
|
+
// link's own stat, not its target's.
|
|
91
|
+
if (st.isSymbolicLink()) {
|
|
92
|
+
return fileIdentity(
|
|
93
|
+
'120000',
|
|
94
|
+
this.hashBytes(new TextEncoder().encode(readlinkSync(filePath))),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
// git's mode for a regular file: 100755 when the owner may execute it.
|
|
98
|
+
// The memo below holds the content alone; the mode rides this stat.
|
|
99
|
+
const mode = (st.mode & 0o100) !== 0 ? '100755' : '100644'
|
|
100
|
+
const mtimeMs = Math.floor(st.mtimeMs)
|
|
101
|
+
const size = st.size
|
|
102
|
+
// ctime + ino are what make this memo SAFE, not merely fast. mtime is
|
|
103
|
+
// caller-settable, so (mtime, size) alone hands back the previous run's
|
|
104
|
+
// digest for genuinely different bytes whenever a producer preserves
|
|
105
|
+
// mtime — `tar -x`, `unzip`, `cp -p`, `rsync --times`, any
|
|
106
|
+
// SOURCE_DATE_EPOCH generator — which is a stale cache hit. `utimes`
|
|
107
|
+
// cannot suppress ctime without root, and an atomic write-then-rename
|
|
108
|
+
// changes the inode; git's own index keys on ctime+ino+dev for exactly
|
|
109
|
+
// this reason. Both fields come from the stat we already took.
|
|
110
|
+
const ctimeMs = Math.floor(st.ctimeMs)
|
|
111
|
+
const ino = Number(st.ino)
|
|
112
|
+
const row = this.selectFileHash.get(filePath) as
|
|
113
|
+
| {
|
|
114
|
+
mtime_ms: number
|
|
115
|
+
size_bytes: number
|
|
116
|
+
ctime_ms: number
|
|
117
|
+
ino: number
|
|
118
|
+
content_hash: string
|
|
119
|
+
}
|
|
120
|
+
| undefined
|
|
121
|
+
if (
|
|
122
|
+
row &&
|
|
123
|
+
row.mtime_ms === mtimeMs &&
|
|
124
|
+
row.size_bytes === size &&
|
|
125
|
+
row.ctime_ms === ctimeMs &&
|
|
126
|
+
row.ino === ino
|
|
127
|
+
) {
|
|
128
|
+
return fileIdentity(mode, row.content_hash)
|
|
129
|
+
}
|
|
130
|
+
const ch = await this.hashFileFromDisk(filePath)
|
|
131
|
+
// git's racy-clean rule, applied to the memo: a stat taken in the same
|
|
132
|
+
// tick as the file's last change cannot be trusted next time, because a
|
|
133
|
+
// rewrite landing in that tick keeps ctime (and, with mtime restored,
|
|
134
|
+
// every other field) equal and would hand back THIS digest for other
|
|
135
|
+
// bytes — seen once on ubuntu CI, on a docs-only commit. So a file
|
|
136
|
+
// changed within the window is hashed again on its next call rather
|
|
137
|
+
// than memoised; the warm path never meets it (keys are derived long
|
|
138
|
+
// after the files were written).
|
|
139
|
+
if (this.write && Date.now() - ctimeMs >= FILE_HASH_RACY_MS) {
|
|
140
|
+
this.upsertFileHash.run(filePath, mtimeMs, size, ctimeMs, ino, ch, Date.now())
|
|
141
|
+
}
|
|
142
|
+
return fileIdentity(mode, ch)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `hashFile` for many paths at once, with ONE memo query. The per-file
|
|
147
|
+
* form costs a SQLite point lookup each; a warm config load keys 1,000
|
|
148
|
+
* closures that way (7.7 ms of `get` in a 2026-09-09 profile), where one
|
|
149
|
+
* `IN` query over all of them costs well under a millisecond. Same stat
|
|
150
|
+
* fields, same racy-clean rule, same digest. A path that cannot be
|
|
151
|
+
* stat'ed is absent from the result (the caller decides what a missing
|
|
152
|
+
* identity means) rather than thrown, so one vanished preset does not
|
|
153
|
+
* fail the batch for every other config.
|
|
154
|
+
*/
|
|
155
|
+
async hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
|
|
156
|
+
const out = new Map<string, string>()
|
|
157
|
+
if (paths.length === 0) return out
|
|
158
|
+
interface Stat {
|
|
159
|
+
/** git's mode for it, as `hashFile` spells the identity. */
|
|
160
|
+
mode: string
|
|
161
|
+
mtimeMs: number
|
|
162
|
+
size: number
|
|
163
|
+
ctimeMs: number
|
|
164
|
+
ino: number
|
|
165
|
+
}
|
|
166
|
+
const stats = new Map<string, Stat>()
|
|
167
|
+
for (const p of paths) {
|
|
168
|
+
if (stats.has(p) || out.has(p)) continue
|
|
169
|
+
try {
|
|
170
|
+
// lstat, not stat: `hashFile` folds a symlink as git folds it —
|
|
171
|
+
// the blob of its TARGET STRING — and this form promises the same
|
|
172
|
+
// digest. Following the link here instead hashed the target's
|
|
173
|
+
// BYTES, so the same path identified differently depending on
|
|
174
|
+
// which entry point the caller used, and the batch folded bytes
|
|
175
|
+
// `git diff` and `--affected` cannot see.
|
|
176
|
+
const st = lstatSync(p)
|
|
177
|
+
if (st.isSymbolicLink()) {
|
|
178
|
+
// No memo, for `hashFile`'s reason: the row would be keyed on
|
|
179
|
+
// the link's own stat, not its target's.
|
|
180
|
+
out.set(
|
|
181
|
+
p,
|
|
182
|
+
fileIdentity('120000', this.hashBytes(new TextEncoder().encode(readlinkSync(p)))),
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
stats.set(p, {
|
|
187
|
+
mode: (st.mode & 0o100) !== 0 ? '100755' : '100644',
|
|
188
|
+
mtimeMs: Math.floor(st.mtimeMs),
|
|
189
|
+
size: st.size,
|
|
190
|
+
ctimeMs: Math.floor(st.ctimeMs),
|
|
191
|
+
ino: Number(st.ino),
|
|
192
|
+
})
|
|
193
|
+
} catch {
|
|
194
|
+
// absent from the result
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const wanted = [...stats.keys()]
|
|
198
|
+
const misses: string[] = []
|
|
199
|
+
// SQLite's bound-variable ceiling is 32,766 (999 on old builds); 500
|
|
200
|
+
// keeps a closure list of any size inside either.
|
|
201
|
+
for (let i = 0; i < wanted.length; i += 500) {
|
|
202
|
+
const chunk = wanted.slice(i, i + 500)
|
|
203
|
+
const rows = this.db
|
|
204
|
+
.query(
|
|
205
|
+
`SELECT path, mtime_ms, size_bytes, ctime_ms, ino, content_hash FROM file_hashes WHERE path IN (${chunk.map(() => '?').join(',')})`,
|
|
206
|
+
)
|
|
207
|
+
.all(...chunk) as Array<{
|
|
208
|
+
path: string
|
|
209
|
+
mtime_ms: number
|
|
210
|
+
size_bytes: number
|
|
211
|
+
ctime_ms: number
|
|
212
|
+
ino: number
|
|
213
|
+
content_hash: string
|
|
214
|
+
}>
|
|
215
|
+
const byPath = new Map(rows.map((r) => [r.path, r]))
|
|
216
|
+
for (const p of chunk) {
|
|
217
|
+
const st = stats.get(p)!
|
|
218
|
+
const row = byPath.get(p)
|
|
219
|
+
if (
|
|
220
|
+
row &&
|
|
221
|
+
row.mtime_ms === st.mtimeMs &&
|
|
222
|
+
row.size_bytes === st.size &&
|
|
223
|
+
row.ctime_ms === st.ctimeMs &&
|
|
224
|
+
row.ino === st.ino
|
|
225
|
+
) {
|
|
226
|
+
out.set(p, fileIdentity(st.mode, row.content_hash))
|
|
227
|
+
} else misses.push(p)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (misses.length === 0) return out
|
|
231
|
+
const digests = await Promise.all(
|
|
232
|
+
misses.map((p) => this.hashFileFromDisk(p).catch(() => undefined)),
|
|
233
|
+
)
|
|
234
|
+
const now = Date.now()
|
|
235
|
+
this.db.transaction(() => {
|
|
236
|
+
for (let i = 0; i < misses.length; i++) {
|
|
237
|
+
const digest = digests[i]
|
|
238
|
+
if (digest === undefined) continue
|
|
239
|
+
const p = misses[i]!
|
|
240
|
+
const st = stats.get(p)!
|
|
241
|
+
out.set(p, fileIdentity(st.mode, digest))
|
|
242
|
+
// The same racy-clean rule as `hashFile`: a stat taken within the
|
|
243
|
+
// window of the file's last change is not memoised.
|
|
244
|
+
if (this.write && now - st.ctimeMs >= FILE_HASH_RACY_MS) {
|
|
245
|
+
this.upsertFileHash.run(p, st.mtimeMs, st.size, st.ctimeMs, st.ino, digest, now)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
})()
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Git blob OID of the file's bytes:
|
|
254
|
+
* `hex(HASH("blob " + byteLength + "\0" + content))`, where HASH is
|
|
255
|
+
* the repo's object format. Same value `git hash-object` prints and
|
|
256
|
+
* the same value the index stores. Computed in-process — no git
|
|
257
|
+
* spawn per file.
|
|
258
|
+
*/
|
|
259
|
+
private async hashFileFromDisk(filePath: string): Promise<string> {
|
|
260
|
+
return this.hashBytes(await Bun.file(filePath).bytes(), filePath)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `git hash-object` of `bytes`: the blob OID in the repo's object format —
|
|
265
|
+
* what `hashFile` returns for a file holding them, with no stat and no
|
|
266
|
+
* memo row. `nearPath` says which repo's format applies (the cache dir's
|
|
267
|
+
* when absent).
|
|
268
|
+
*/
|
|
269
|
+
hashBytes(bytes: Uint8Array, nearPath?: string): string {
|
|
270
|
+
const hasher = new Bun.CryptoHasher(
|
|
271
|
+
this.objectFormat ?? this.detectObjectFormat(nearPath ?? this.cacheDir),
|
|
272
|
+
)
|
|
273
|
+
hasher.update(`blob ${bytes.byteLength}\0`)
|
|
274
|
+
hasher.update(bytes)
|
|
275
|
+
return hasher.digest('hex')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Repo object format — sha1 unless the repo was created with
|
|
280
|
+
* `--object-format=sha256`. Asked once per store, and only when at least
|
|
281
|
+
* one file misses the mtime+size memo; `repoFacts` shares the spawn with
|
|
282
|
+
* the input enumeration. Outside a repo (unit fixtures), or with no git,
|
|
283
|
+
* sha1, which is still a deterministic blob-OID domain.
|
|
284
|
+
*/
|
|
285
|
+
private detectObjectFormat(nearPath: string): 'sha1' | 'sha256' {
|
|
286
|
+
const detected = repoFacts(this.repoDir ?? path.dirname(nearPath))?.objectFormat ?? 'sha1'
|
|
287
|
+
this.objectFormat = detected
|
|
288
|
+
return detected
|
|
289
|
+
}
|
|
290
|
+
}
|