@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The index's WAL checkpoints, on a thread of their own while a run saves.
|
|
2
|
+
// SQLite checkpoints on the committing connection once the WAL passes 1,000
|
|
3
|
+
// pages: on a cold run that was the main thread, the scheduler's, copying
|
|
4
|
+
// pages and waiting on two fsyncs a checkpoint, stalls of up to 750 ms in
|
|
5
|
+
// a cold 8,002-task run (2026-11-06). Here a second connection checkpoints
|
|
6
|
+
// PASSIVE on a timer: it never waits on a lock and never blocks the writer,
|
|
7
|
+
// which appends as before; the run's connection checkpoints nothing until
|
|
8
|
+
// its close, which finds the WAL nearly copied.
|
|
9
|
+
|
|
10
|
+
import { SQLITE_FCNTL_PERSIST_WAL } from './schema.js'
|
|
11
|
+
|
|
12
|
+
/** How often the thread checkpoints: about 1,000 pages of a cold run's writes. */
|
|
13
|
+
const EVERY_MS = 100
|
|
14
|
+
|
|
15
|
+
// The thread as source, not a file: a compiled vx carries only its entry
|
|
16
|
+
// points' graphs (spawn-off.ts does the same).
|
|
17
|
+
const WORKER = `
|
|
18
|
+
const { Database } = require('bun:sqlite')
|
|
19
|
+
let db
|
|
20
|
+
let timer
|
|
21
|
+
const schemas = []
|
|
22
|
+
const tick = () => {
|
|
23
|
+
for (const s of schemas) {
|
|
24
|
+
try {
|
|
25
|
+
db.exec('PRAGMA ' + s + '.wal_checkpoint(PASSIVE)')
|
|
26
|
+
} catch {
|
|
27
|
+
// Busy or gone: the next tick, or the run's close, copies it.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
self.onmessage = (e) => {
|
|
32
|
+
const m = e.data
|
|
33
|
+
if (m.open !== undefined) {
|
|
34
|
+
db = new Database(m.open.index)
|
|
35
|
+
// As Cache opens it: a close here must not delete the WAL (O-10).
|
|
36
|
+
db.fileControl(${SQLITE_FCNTL_PERSIST_WAL}, 1)
|
|
37
|
+
schemas.push('main')
|
|
38
|
+
if (m.open.store !== undefined) {
|
|
39
|
+
// Finalized: a statement still alive left the close a zombie
|
|
40
|
+
// connection, and a write right after the run was SQLITE_BUSY
|
|
41
|
+
// (shared-store.test.ts).
|
|
42
|
+
const attach = db.prepare('ATTACH DATABASE ? AS store')
|
|
43
|
+
attach.run(m.open.store)
|
|
44
|
+
attach.finalize()
|
|
45
|
+
db.fileControl('store', ${SQLITE_FCNTL_PERSIST_WAL}, 1)
|
|
46
|
+
schemas.push('store')
|
|
47
|
+
}
|
|
48
|
+
timer = setInterval(tick, m.everyMs)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
clearInterval(timer)
|
|
52
|
+
try {
|
|
53
|
+
db?.close()
|
|
54
|
+
} catch {
|
|
55
|
+
// The run's own close copies what is left.
|
|
56
|
+
}
|
|
57
|
+
postMessage({ ended: true })
|
|
58
|
+
}
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
export class WalCheckpointer {
|
|
62
|
+
private readonly worker: Worker
|
|
63
|
+
private failed = false
|
|
64
|
+
private ended: (() => void) | undefined
|
|
65
|
+
|
|
66
|
+
/** Starts checkpointing `files` every `everyMs`; `onError` hears a thread that could not. */
|
|
67
|
+
constructor(
|
|
68
|
+
files: { index: string; store?: string },
|
|
69
|
+
onError: () => void,
|
|
70
|
+
everyMs: number = EVERY_MS,
|
|
71
|
+
) {
|
|
72
|
+
const w = new Worker(URL.createObjectURL(new Blob([WORKER], { type: 'text/javascript' })))
|
|
73
|
+
// A run that throws before `stop` must still exit.
|
|
74
|
+
w.unref()
|
|
75
|
+
w.onmessage = () => this.ended?.()
|
|
76
|
+
w.onerror = (e) => {
|
|
77
|
+
e.preventDefault()
|
|
78
|
+
this.failed = true
|
|
79
|
+
this.ended?.()
|
|
80
|
+
onError()
|
|
81
|
+
}
|
|
82
|
+
w.postMessage({ open: files, everyMs })
|
|
83
|
+
this.worker = w
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Stops the timer and closes the thread's connection, so the run's close is the last. */
|
|
87
|
+
async stop(): Promise<void> {
|
|
88
|
+
if (!this.failed) {
|
|
89
|
+
this.worker.ref()
|
|
90
|
+
await new Promise<void>((resolve) => {
|
|
91
|
+
this.ended = resolve
|
|
92
|
+
this.worker.postMessage({ end: true })
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
this.worker.terminate()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A close that could not wait for `stop`: the thread ends where it is. */
|
|
99
|
+
terminate(): void {
|
|
100
|
+
this.worker.terminate()
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/cache/file-hashes.ts
CHANGED
|
@@ -138,7 +138,7 @@ export class FileHashStore {
|
|
|
138
138
|
* form while this hashes the worktree bytes, so `inputs.ts` drops
|
|
139
139
|
* the index OID for those paths and routes them here.
|
|
140
140
|
*/
|
|
141
|
-
async hashFile(filePath: string): Promise<string> {
|
|
141
|
+
async hashFile(filePath: string, seen?: (ctimeMs: number) => void): Promise<string> {
|
|
142
142
|
// lstatSync intentional: a single stat is ~1.6µs (Bun 1.3); the
|
|
143
143
|
// async-stat equivalent adds ~75µs of Promise machinery per call.
|
|
144
144
|
// Promise.all over the batched callers (key derivation) gives no
|
|
@@ -154,6 +154,7 @@ export class FileHashStore {
|
|
|
154
154
|
// a more useful error.
|
|
155
155
|
return await this.hashFileFromDisk(filePath)
|
|
156
156
|
}
|
|
157
|
+
seen?.(st.ctimeMs)
|
|
157
158
|
// A symlink's identity is git's: the blob of its TARGET STRING, its
|
|
158
159
|
// mode-120000 index OID, so a retarget moves it. What it points to
|
|
159
160
|
// folds after it (linked-inputs.ts). No memo: readlink is one syscall,
|
package/src/cache/fold-memo.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface FoldMemo {
|
|
|
22
22
|
|
|
23
23
|
export class IndexFoldMemo implements FoldMemo {
|
|
24
24
|
private rows: Map<string, FoldMemoRow> | undefined
|
|
25
|
+
private readonly runRows = new Map<string, FoldMemoRow | null>()
|
|
25
26
|
private readonly dirty = new Map<string, FoldMemoRow>()
|
|
26
27
|
|
|
27
28
|
constructor(
|
|
@@ -32,18 +33,37 @@ export class IndexFoldMemo implements FoldMemo {
|
|
|
32
33
|
|
|
33
34
|
/** The key remembered for `taskId` under `probe`, if that is the probe it was folded under. */
|
|
34
35
|
get(taskId: string, probe: string): string | undefined {
|
|
35
|
-
|
|
36
|
+
const row = this.dirty.get(taskId) ?? this.read(taskId)
|
|
37
|
+
return row?.probe === probe ? row.key : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private read(taskId: string): FoldMemoRow | null | undefined {
|
|
41
|
+
// A run's own rows (`\0run\0…`, the graph's) are read one each: a warm
|
|
42
|
+
// run whose keys are remembered whole asks for no task's row, and
|
|
43
|
+
// reading all of them held every task's row to the end of the run.
|
|
44
|
+
if (taskId.startsWith('\0')) {
|
|
45
|
+
let row = this.runRows.get(taskId)
|
|
46
|
+
if (row === undefined) {
|
|
47
|
+
row = this.db
|
|
48
|
+
.query('SELECT probe, key FROM fold_memo WHERE task_id = ?')
|
|
49
|
+
.get(taskId) as FoldMemoRow | null
|
|
50
|
+
this.runRows.set(taskId, row)
|
|
51
|
+
}
|
|
52
|
+
return row
|
|
53
|
+
}
|
|
54
|
+
// One read of every task's row: a run that folds asks for nearly all of them.
|
|
36
55
|
this.rows ??= new Map(
|
|
37
56
|
(
|
|
38
|
-
this.db
|
|
57
|
+
this.db
|
|
58
|
+
.query('SELECT task_id, probe, key FROM fold_memo WHERE task_id >= ?')
|
|
59
|
+
.all('\u0001') as Array<{
|
|
39
60
|
task_id: string
|
|
40
61
|
probe: string
|
|
41
62
|
key: string
|
|
42
63
|
}>
|
|
43
64
|
).map((r) => [r.task_id, { probe: r.probe, key: r.key }]),
|
|
44
65
|
)
|
|
45
|
-
|
|
46
|
-
return row?.probe === probe ? row.key : undefined
|
|
66
|
+
return this.rows.get(taskId)
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
set(taskId: string, probe: string, key: string): void {
|
package/src/cache/fs-clock.ts
CHANGED
|
@@ -40,6 +40,32 @@ export function stampGrainMs(stampMs: number): number {
|
|
|
40
40
|
return stampMs % 1000 === 0 ? 1000 : 0
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* "Now" as the file system will stamp the next write, not as `Date.now()`
|
|
45
|
+
* reads it: a stamp read off a file written and removed in `dir` (the
|
|
46
|
+
* run's cache dir), and every later write's mtime and ctime is at or after
|
|
47
|
+
* it. Linux before 6.13 stamps from its coarse clock, a tick behind the
|
|
48
|
+
* fine clock `Date.now()` reads — on the Linux bench box 2026-09-11, 2 of
|
|
49
|
+
* 3,000 tight writes carried an mtime 5.8 ms EARLIER than a `Date.now()`
|
|
50
|
+
* taken before the write, and more under CPU load. That lag is under the
|
|
51
|
+
* one `measureFsClockLag` applies, so a window that asks "written after
|
|
52
|
+
* this moment?" takes the moment from here: `vx watch`'s arm and cycle
|
|
53
|
+
* start (an edit right after the ready line was dropped as "before the
|
|
54
|
+
* arm"), and the re-check's command start. A dir that cannot take the
|
|
55
|
+
* file falls back to `Date.now()` less the measured lag.
|
|
56
|
+
*/
|
|
57
|
+
export function fsClockNow(dir: string): number {
|
|
58
|
+
const stamp = path.join(dir, `.vx-clock-${process.pid}`)
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(stamp, '')
|
|
61
|
+
const at = statSync(stamp).mtimeMs
|
|
62
|
+
unlinkSync(stamp)
|
|
63
|
+
return at
|
|
64
|
+
} catch {
|
|
65
|
+
return Date.now() - lagMs
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
/**
|
|
44
70
|
* Measure the lag from a file written and removed in `dir` (the run's cache
|
|
45
71
|
* dir: on the workspace's file system by default, and ignored by git and
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import os from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import {
|
|
11
|
+
accessSync,
|
|
11
12
|
closeSync,
|
|
13
|
+
constants as fsConstants,
|
|
12
14
|
existsSync,
|
|
13
15
|
fstatSync,
|
|
14
16
|
lstatSync,
|
|
@@ -27,9 +29,11 @@ import {
|
|
|
27
29
|
import {
|
|
28
30
|
checkIgnoreSync,
|
|
29
31
|
executablePath,
|
|
32
|
+
gitCaseArgs,
|
|
30
33
|
gitEnv,
|
|
31
34
|
gitFailure,
|
|
32
35
|
gitSpawnRefusal,
|
|
36
|
+
ignoreCaseLies,
|
|
33
37
|
isInstalledPath,
|
|
34
38
|
notAWorkTree,
|
|
35
39
|
relPosix,
|
|
@@ -41,7 +45,18 @@ import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
|
41
45
|
/** A name no ignore rule names, standing for a file a task may create. */
|
|
42
46
|
const FRESH_NAME = '__vx_probe__'
|
|
43
47
|
|
|
44
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Which of `dirs` (relative to `root`) git ignores a new file directly in,
|
|
50
|
+
* in one `git check-ignore`: `GitFilesCache.newFilesIgnored`'s probe, which
|
|
51
|
+
* watch asks of the entries a task names (`namedIgnoredReach`).
|
|
52
|
+
*/
|
|
53
|
+
export function newFilesIgnoredIn(root: string, dirs: readonly string[]): Set<string> {
|
|
54
|
+
const probes = dirs.map((d) => `${d}/${FRESH_NAME}`)
|
|
55
|
+
const hit = checkIgnoreSync(root, probes)
|
|
56
|
+
return new Set(dirs.filter((_, i) => hit?.has(probes[i]!) === true))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
|
|
45
60
|
export interface RepoFacts {
|
|
46
61
|
/** `--show-prefix`: the directory's path below the worktree root, `''` at the root. */
|
|
47
62
|
prefix: string
|
|
@@ -51,6 +66,8 @@ export interface RepoFacts {
|
|
|
51
66
|
objectFormat: 'sha1' | 'sha256'
|
|
52
67
|
/** `--git-path index` (it honours `GIT_INDEX_FILE`), relative to the directory asked. */
|
|
53
68
|
indexFile: string
|
|
69
|
+
/** `--absolute-git-dir`: the per-worktree git dir, which the sandbox walls off when it lies outside. */
|
|
70
|
+
gitDir: string
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
const repoFactsMemo = new Map<string, RepoFacts>()
|
|
@@ -82,6 +99,7 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
82
99
|
'--show-object-format',
|
|
83
100
|
'--git-path',
|
|
84
101
|
'index',
|
|
102
|
+
'--absolute-git-dir',
|
|
85
103
|
],
|
|
86
104
|
env: gitEnv(),
|
|
87
105
|
cwd: dir,
|
|
@@ -95,9 +113,12 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
95
113
|
// One line per flag, in order. A git that does not know
|
|
96
114
|
// `--show-object-format` echoes it back, as it does any unknown flag,
|
|
97
115
|
// which reads as sha1 — what the flag's own spawn answered there too.
|
|
98
|
-
const [prefix = '', commonDir = '', format = '', indexFile = ''] = new TextDecoder(
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
const [prefix = '', commonDir = '', format = '', indexFile = '', gitDir = ''] = new TextDecoder(
|
|
117
|
+
'utf-8',
|
|
118
|
+
{
|
|
119
|
+
ignoreBOM: true,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
101
122
|
.decode(proc.stdout)
|
|
102
123
|
.split('\n')
|
|
103
124
|
.map((l) => l.trim())
|
|
@@ -106,6 +127,7 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
106
127
|
commonDir,
|
|
107
128
|
objectFormat: format === 'sha256' ? 'sha256' : 'sha1',
|
|
108
129
|
indexFile,
|
|
130
|
+
gitDir,
|
|
109
131
|
}
|
|
110
132
|
repoFactsMemo.set(dir, facts)
|
|
111
133
|
return facts
|
|
@@ -172,6 +194,7 @@ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
|
|
|
172
194
|
commonDir: relPosix(real, dotGit),
|
|
173
195
|
objectFormat,
|
|
174
196
|
indexFile: path.relative(real, path.join(dotGit, 'index')).split(path.sep).join('/'),
|
|
197
|
+
gitDir: dotGit,
|
|
175
198
|
}
|
|
176
199
|
}
|
|
177
200
|
const parent = path.dirname(at)
|
|
@@ -221,6 +244,24 @@ interface PartitionDigest {
|
|
|
221
244
|
digest: () => string | undefined
|
|
222
245
|
}
|
|
223
246
|
|
|
247
|
+
/**
|
|
248
|
+
* `compute`'s answer, computed on the first ask, after which `compute` is
|
|
249
|
+
* let go: a digest's records are a rope of three strings per file, ~3.7 MB
|
|
250
|
+
* of heap at 1,601 projects, held to the end of the run otherwise. Built
|
|
251
|
+
* here, not in the caller, so the caller's scope (which holds `compute`)
|
|
252
|
+
* is not the getter's.
|
|
253
|
+
*/
|
|
254
|
+
function once<T>(compute: (() => T) | undefined): () => T | undefined {
|
|
255
|
+
let value: T | undefined
|
|
256
|
+
return () => {
|
|
257
|
+
if (compute !== undefined) {
|
|
258
|
+
value = compute()
|
|
259
|
+
compute = undefined
|
|
260
|
+
}
|
|
261
|
+
return value
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
224
265
|
/**
|
|
225
266
|
* A partition's trusted OIDs, built on first read: a warm run whose keys
|
|
226
267
|
* come back from the fold memo reads none, and building 125,000 abs-path
|
|
@@ -252,6 +293,8 @@ class OidSlot {
|
|
|
252
293
|
|
|
253
294
|
interface IgnoredListing {
|
|
254
295
|
root: string
|
|
296
|
+
/** `root/` when `root` is absolute and normalized: a normalized path under it is relative as its rest. */
|
|
297
|
+
base: string | undefined
|
|
255
298
|
paths: readonly string[]
|
|
256
299
|
asked: Map<string, boolean>
|
|
257
300
|
}
|
|
@@ -314,7 +357,8 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
314
357
|
/**
|
|
315
358
|
* Under `core.ignorecase` (macOS's default volume) git's `Foo.txt` is the
|
|
316
359
|
* disk's `foo.txt` after a rename git never saw, so paths are compared
|
|
317
|
-
* case-folded. Decided at the first note: the run's `git var -l
|
|
360
|
+
* case-folded. Decided at the first note: the run's `git var -l` (false
|
|
361
|
+
* there when the disk does not fold case, `ignoreCaseLies`), else
|
|
318
362
|
* the platform.
|
|
319
363
|
*/
|
|
320
364
|
private foldCase: boolean | undefined
|
|
@@ -385,7 +429,13 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
385
429
|
private ignored: IgnoredListing | null = null
|
|
386
430
|
|
|
387
431
|
setIgnored(root: string, paths: readonly string[]): void {
|
|
388
|
-
|
|
432
|
+
const base =
|
|
433
|
+
path.sep === '/' && path.isAbsolute(root) && path.normalize(root) === root
|
|
434
|
+
? root.endsWith('/')
|
|
435
|
+
? root
|
|
436
|
+
: `${root}/`
|
|
437
|
+
: undefined
|
|
438
|
+
this.ignored = { root, base, paths, asked: new Map() }
|
|
389
439
|
}
|
|
390
440
|
|
|
391
441
|
/**
|
|
@@ -400,7 +450,17 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
400
450
|
newFilesIgnored(dir: string): boolean {
|
|
401
451
|
const ig = this.ignored
|
|
402
452
|
if (ig === null || ig.paths.length === 0) return false
|
|
403
|
-
|
|
453
|
+
// Asked once per project a run keys: `path.relative` was ~2 ms of
|
|
454
|
+
// 1,601 (2026-11-07). Below a normalized root, a path with no `.`
|
|
455
|
+
// segment and no doubled or trailing slash is its rest.
|
|
456
|
+
const rel =
|
|
457
|
+
ig.base !== undefined &&
|
|
458
|
+
dir.startsWith(ig.base) &&
|
|
459
|
+
!dir.includes('/.') &&
|
|
460
|
+
!dir.includes('//') &&
|
|
461
|
+
!dir.endsWith('/')
|
|
462
|
+
? dir.slice(ig.base.length)
|
|
463
|
+
: relPosix(ig.root, dir)
|
|
404
464
|
if (rel === '' || rel.startsWith('../')) return false
|
|
405
465
|
let known = ig.asked.get(rel)
|
|
406
466
|
if (known !== undefined) return known
|
|
@@ -413,10 +473,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
413
473
|
else hi = mid
|
|
414
474
|
}
|
|
415
475
|
known = false
|
|
416
|
-
if (ig.paths[lo]?.startsWith(under) === true)
|
|
417
|
-
const probe = `${under}${FRESH_NAME}`
|
|
418
|
-
known = checkIgnoreSync(ig.root, [probe])?.has(probe) === true
|
|
419
|
-
}
|
|
476
|
+
if (ig.paths[lo]?.startsWith(under) === true) known = newFilesIgnoredIn(ig.root, [rel]).size > 0
|
|
420
477
|
ig.asked.set(rel, known)
|
|
421
478
|
return known
|
|
422
479
|
}
|
|
@@ -543,11 +600,10 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
543
600
|
snapshot: readonly string[],
|
|
544
601
|
digest: () => string | undefined,
|
|
545
602
|
): void {
|
|
546
|
-
let memo: { value: string | undefined } | undefined
|
|
547
603
|
this.digests.set(projectDir, {
|
|
548
604
|
snapshot,
|
|
549
605
|
oids: this.oids.get(projectDir),
|
|
550
|
-
digest: (
|
|
606
|
+
digest: once(digest),
|
|
551
607
|
})
|
|
552
608
|
}
|
|
553
609
|
|
|
@@ -565,6 +621,20 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
565
621
|
this.oids.set(projectDir, new OidSlot(build))
|
|
566
622
|
}
|
|
567
623
|
|
|
624
|
+
/**
|
|
625
|
+
* Each file of `snapshot` by its absolute path, filled as callers spell
|
|
626
|
+
* them: one string per file for the run, however many memo keys resolve
|
|
627
|
+
* it and whether the OID map names it too. Each spelled its own: a cold
|
|
628
|
+
* 9,603-task run held ~96,000 path strings for ~32,000 files.
|
|
629
|
+
*/
|
|
630
|
+
absPaths(snapshot: readonly string[]): Array<string | undefined> {
|
|
631
|
+
let paths = this.abs.get(snapshot)
|
|
632
|
+
if (paths === undefined) this.abs.set(snapshot, (paths = []))
|
|
633
|
+
return paths
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private readonly abs = new WeakMap<readonly string[], Array<string | undefined>>()
|
|
637
|
+
|
|
568
638
|
/** Snapshot if still valid for these input globs; undefined → re-spawn. */
|
|
569
639
|
snapshotFor(projectDir: string, inputGlobs: readonly Bun.Glob[]): readonly string[] | undefined {
|
|
570
640
|
const snap = this.get(projectDir)
|
|
@@ -685,12 +755,66 @@ const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
|
685
755
|
|
|
686
756
|
const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
|
|
687
757
|
|
|
758
|
+
const UNOPENED = "warning: could not open directory '"
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Refuses a walk git did not finish. `could not open directory` is a
|
|
762
|
+
* warning and the exit is 0, so the files under it were in no key: past
|
|
763
|
+
* PATH_MAX (4096 bytes on Linux) an edit there was a stale hit. A directory
|
|
764
|
+
* this user may not read is left out as before: no task of theirs reads it
|
|
765
|
+
* either, and a root-owned volume beside the projects must not stop every
|
|
766
|
+
* run. git cuts its message at 4096 bytes, so a long path comes with no
|
|
767
|
+
* reason. `cwd`: where git ran; it names the directory from its work tree's top.
|
|
768
|
+
*/
|
|
769
|
+
function refuseUnopenedDirs(cwd: string, stderr: string): void {
|
|
770
|
+
if (!stderr.includes(UNOPENED)) return
|
|
771
|
+
const prefix = repoFacts(cwd)?.prefix ?? ''
|
|
772
|
+
const top = path.resolve(
|
|
773
|
+
cwd,
|
|
774
|
+
...prefix
|
|
775
|
+
.split('/')
|
|
776
|
+
.filter((s) => s !== '')
|
|
777
|
+
.map(() => '..'),
|
|
778
|
+
)
|
|
779
|
+
for (const line of stderr.split('\n')) {
|
|
780
|
+
if (!line.startsWith(UNOPENED)) continue
|
|
781
|
+
const rest = line.slice(UNOPENED.length)
|
|
782
|
+
const end = rest.indexOf("': ")
|
|
783
|
+
const dir = end === -1 ? rest : rest.slice(0, end)
|
|
784
|
+
const at = path.resolve(top, dir)
|
|
785
|
+
// Gone since git read its parent (a run lock's staging directory in a
|
|
786
|
+
// TMPDIR inside the workspace): no file is under it to miss. Only a
|
|
787
|
+
// line git did not cut: a cut one may name a prefix of a real path.
|
|
788
|
+
if (end !== -1 && vanished(at)) continue
|
|
789
|
+
try {
|
|
790
|
+
accessSync(at, fsConstants.R_OK | fsConstants.X_OK)
|
|
791
|
+
} catch (err) {
|
|
792
|
+
if ((err as NodeJS.ErrnoException).code === 'EACCES') continue
|
|
793
|
+
}
|
|
794
|
+
const shown = dir.length > 100 ? `${dir.slice(0, 40)}…${dir.slice(-40)}` : dir
|
|
795
|
+
throw new UserError(
|
|
796
|
+
`git could not open the directory ${shown}, so no key would see the files under it. Shorten the path, or ignore it in .gitignore.`,
|
|
797
|
+
'VX_E_GIT',
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function vanished(p: string): boolean {
|
|
803
|
+
try {
|
|
804
|
+
lstatSync(p)
|
|
805
|
+
return false
|
|
806
|
+
} catch (err) {
|
|
807
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
808
|
+
return code === 'ENOENT' || code === 'ENOTDIR'
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
688
812
|
/**
|
|
689
813
|
* Run `git ls-files -s --others --exclude-standard -z .` in `cwd`.
|
|
690
814
|
* One spawn yields BOTH the file list (identical visibility to the
|
|
691
815
|
* pre-v20 `--cached --others` form, verified empirically: same set
|
|
692
|
-
* including staged-but-deleted files
|
|
693
|
-
*
|
|
816
|
+
* including staged-but-deleted files; an unmerged path, listed there
|
|
817
|
+
* once per stage, is listed once) AND each tracked file's index OID — the heart of the
|
|
694
818
|
* Turbo-parity "hashes come from git's index" fast path. Throws a
|
|
695
819
|
* `UserError` when git is unavailable or `cwd` isn't a git work
|
|
696
820
|
* tree; vx requires git. `-z` survives filenames with newlines /
|
|
@@ -700,7 +824,16 @@ export function runGitLsFiles(cwd: string, env = gitEnv()): GitLsResult {
|
|
|
700
824
|
let proc
|
|
701
825
|
try {
|
|
702
826
|
proc = Bun.spawnSync({
|
|
703
|
-
cmd: [
|
|
827
|
+
cmd: [
|
|
828
|
+
executablePath('git'),
|
|
829
|
+
...gitCaseArgs(cwd),
|
|
830
|
+
'ls-files',
|
|
831
|
+
'-s',
|
|
832
|
+
'--others',
|
|
833
|
+
'--exclude-standard',
|
|
834
|
+
'-z',
|
|
835
|
+
'.',
|
|
836
|
+
],
|
|
704
837
|
env,
|
|
705
838
|
cwd,
|
|
706
839
|
stdout: 'pipe',
|
|
@@ -714,6 +847,7 @@ export function runGitLsFiles(cwd: string, env = gitEnv()): GitLsResult {
|
|
|
714
847
|
// cannot be enumerated, and the two have different fixes.
|
|
715
848
|
throw gitFailure(cwd, 'ls-files', proc.exitCode, new TextDecoder().decode(proc.stderr).trim())
|
|
716
849
|
}
|
|
850
|
+
refuseUnopenedDirs(cwd, new TextDecoder().decode(proc.stderr))
|
|
717
851
|
const { text, undecodable } = decodeGitZ(proc.stdout)
|
|
718
852
|
const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
|
|
719
853
|
const nested = expandNestedRepos(cwd, parsed.files, parsed.gitlinks)
|
|
@@ -915,6 +1049,14 @@ function parseLsFilesOutput(
|
|
|
915
1049
|
const m = LS_FILES_STAGE_RE.exec(record)
|
|
916
1050
|
const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
|
|
917
1051
|
if (m === null && isInstalledPath(filePath)) continue
|
|
1052
|
+
// An unmerged path prints once per stage (1, 2, 3), in a row. Listed
|
|
1053
|
+
// each time, the key folded the file once per stage, so a conflict
|
|
1054
|
+
// resolved on disk keyed apart from the same bytes once added. It
|
|
1055
|
+
// carries no OID either way: its worktree bytes are what folds.
|
|
1056
|
+
if (m !== null && m[4] !== '0' && files.length > 0 && files[files.length - 1] === filePath) {
|
|
1057
|
+
if (m[2] === '160000') gitlinks.add(filePath)
|
|
1058
|
+
continue
|
|
1059
|
+
}
|
|
918
1060
|
files.push(filePath)
|
|
919
1061
|
if (undecodableRecords.size > 0 && undecodableRecords.has(record)) undecodable.push(filePath)
|
|
920
1062
|
if (m === null) continue
|
|
@@ -1790,6 +1932,9 @@ export async function startGitEnumeration(
|
|
|
1790
1932
|
pathspecs: readonly string[],
|
|
1791
1933
|
): Promise<GitEnumeration> {
|
|
1792
1934
|
attributeReads.clear()
|
|
1935
|
+
// A dir is a path, never pathspec magic: `:top` listed `top`, `*g` matched
|
|
1936
|
+
// `:top/src/log`, and a project's edit left its key (a stale hit).
|
|
1937
|
+
const literal = pathspecs.map((p) => (p === '.' ? p : `:(literal)${p}`))
|
|
1793
1938
|
// The spawns are independent — run them concurrently so the
|
|
1794
1939
|
// bulk-populate costs max(ls-files, status) wall time, not the sum
|
|
1795
1940
|
// (status alone is ~74 ms on a 1000-project tree; serial spawning
|
|
@@ -1911,7 +2056,7 @@ export async function startGitEnumeration(
|
|
|
1911
2056
|
// tracked paths are dirty AND which files are untracked. Asking
|
|
1912
2057
|
// `ls-files --others` for the untracked set walked the same tree a second
|
|
1913
2058
|
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1914
|
-
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...
|
|
2059
|
+
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...literal])
|
|
1915
2060
|
// Read synchronously: `status` spawns once the read is in, and an async
|
|
1916
2061
|
// read's answer waited for the main thread to yield, behind the workspace
|
|
1917
2062
|
// config and discovery (~5 ms of a 1,200-task warm run's wait for git).
|
|
@@ -1944,9 +2089,10 @@ export async function startGitEnumeration(
|
|
|
1944
2089
|
// any other, and the filter gate below must see it (A-19).
|
|
1945
2090
|
// `--no-renames`: a deletion paired as an unmerged path's rename source
|
|
1946
2091
|
// prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
|
|
1947
|
-
const spawnStatus = (index: string | undefined) =>
|
|
2092
|
+
const spawnStatus = (index: string | undefined, args: readonly string[] = []) =>
|
|
1948
2093
|
spawnGit(
|
|
1949
2094
|
[
|
|
2095
|
+
...args,
|
|
1950
2096
|
'status',
|
|
1951
2097
|
'--porcelain',
|
|
1952
2098
|
'-z',
|
|
@@ -1954,7 +2100,7 @@ export async function startGitEnumeration(
|
|
|
1954
2100
|
'--ignored=matching',
|
|
1955
2101
|
'--no-renames',
|
|
1956
2102
|
'--',
|
|
1957
|
-
...
|
|
2103
|
+
...literal,
|
|
1958
2104
|
],
|
|
1959
2105
|
undefined,
|
|
1960
2106
|
index === undefined ? undefined : { GIT_INDEX_FILE: index },
|
|
@@ -1984,7 +2130,23 @@ export async function startGitEnumeration(
|
|
|
1984
2130
|
attributesInIndex({ tracked: parsedLs.files, workspaceRoot, gitDir, gitPrefix, pathspecs })
|
|
1985
2131
|
? spawnGit(CHECK_ATTR, [...parsedLs.oids.keys()].join('\0'))
|
|
1986
2132
|
: undefined
|
|
1987
|
-
const [,
|
|
2133
|
+
const [, firstStatus, gitVars] = await running
|
|
2134
|
+
// `core.ignorecase` is known only once `var` answers, so a status run
|
|
2135
|
+
// under a setting the disk does not hold is run again without it: a
|
|
2136
|
+
// cost only that misconfigured repository pays.
|
|
2137
|
+
const caseBlind =
|
|
2138
|
+
facts !== null &&
|
|
2139
|
+
gitVars !== null &&
|
|
2140
|
+
gitVars.exitCode === 0 &&
|
|
2141
|
+
gitVars.stdout.includes('core.ignorecase') &&
|
|
2142
|
+
gitBool(gitVarList(gitVars.stdout).get('core.ignorecase')) === true &&
|
|
2143
|
+
ignoreCaseLies(workspaceRoot, path.resolve(workspaceRoot, gitDir, 'HEAD'))
|
|
2144
|
+
const status = caseBlind ? await spawnStatus(statusCopy, gitCaseArgs(workspaceRoot)) : firstStatus
|
|
2145
|
+
// The listing as git reads it with the override, which `trackedKey` reads too.
|
|
2146
|
+
const vars =
|
|
2147
|
+
caseBlind && gitVars !== null
|
|
2148
|
+
? { ...gitVars, stdout: `${gitVars.stdout}core.ignorecase=false\n` }
|
|
2149
|
+
: gitVars
|
|
1988
2150
|
const indexMoved = indexStamp() !== stampBefore
|
|
1989
2151
|
// The key stands for the index `ls-files` listed only when that index
|
|
1990
2152
|
// was written before the enumeration began (git writes it by rename, so
|
|
@@ -2019,6 +2181,7 @@ export async function startGitEnumeration(
|
|
|
2019
2181
|
}
|
|
2020
2182
|
if (parsedLs === undefined)
|
|
2021
2183
|
throw gitFailure(workspaceRoot, 'ls-files', ls.exitCode, ls.stderr.trim())
|
|
2184
|
+
if (status !== null && status.exitCode === 0) refuseUnopenedDirs(workspaceRoot, status.stderr)
|
|
2022
2185
|
const { files: tracked, oids, flagged, gitlinks, undecodable } = parsedLs
|
|
2023
2186
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
2024
2187
|
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
@@ -2119,7 +2282,7 @@ export async function startGitEnumeration(
|
|
|
2119
2282
|
return null
|
|
2120
2283
|
}
|
|
2121
2284
|
const run = await spawnGit(
|
|
2122
|
-
['ls-files', '-s', '-v', '-z', '--debug', '--', ...
|
|
2285
|
+
['ls-files', '-s', '-v', '-z', '--debug', '--', ...literal],
|
|
2123
2286
|
undefined,
|
|
2124
2287
|
statusCopy === undefined ? undefined : { GIT_INDEX_FILE: statusCopy },
|
|
2125
2288
|
)
|
|
@@ -2300,10 +2463,13 @@ export async function applyGitEnumeration(
|
|
|
2300
2463
|
relPrefix === spelled ? abs : (rel: string) => path.join(projectDir, rel.slice(prefix.length))
|
|
2301
2464
|
const projOids = (): Map<string, string> => {
|
|
2302
2465
|
const oids = new Map<string, string>()
|
|
2466
|
+
// `resolveFiles` spells `matches[j]` the same, so both take one string.
|
|
2467
|
+
const paths = base !== undefined && projAbs === abs ? cache.absPaths(matches) : undefined
|
|
2303
2468
|
for (let i = start; i < end; i++) {
|
|
2304
2469
|
const rel = sorted[i]!
|
|
2305
2470
|
const oid = trusted.get(rel)
|
|
2306
|
-
if (oid
|
|
2471
|
+
if (oid === undefined) continue
|
|
2472
|
+
oids.set(paths === undefined ? projAbs(rel) : (paths[i - start] ??= abs(rel)), oid)
|
|
2307
2473
|
}
|
|
2308
2474
|
return oids
|
|
2309
2475
|
}
|