@vzn/vx 0.0.21 → 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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -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/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/cache/git-inputs.ts
CHANGED
|
@@ -6,8 +6,65 @@
|
|
|
6
6
|
// task declared. Nothing here reads a config or applies a boundary.
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { existsSync } from 'node:fs'
|
|
10
|
-
import { UserError } from '../util/index.js'
|
|
9
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
10
|
+
import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
|
|
11
|
+
|
|
12
|
+
/** Three facts of the repository a directory is in, from one `git rev-parse`. */
|
|
13
|
+
export interface RepoFacts {
|
|
14
|
+
/** `--show-prefix`: the directory's path below the worktree root, `''` at the root. */
|
|
15
|
+
prefix: string
|
|
16
|
+
/** `--git-common-dir` as git prints it, relative to the directory asked. */
|
|
17
|
+
commonDir: string
|
|
18
|
+
/** `--show-object-format`: the hash the index's blob OIDs are in. */
|
|
19
|
+
objectFormat: 'sha1' | 'sha256'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const repoFactsMemo = new Map<string, RepoFacts>()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The enumeration needs the prefix and the common dir, the file hasher the
|
|
26
|
+
* object format: one spawn answers all three, once per directory for the
|
|
27
|
+
* life of the process — none of them moves while vx runs. A failure (no
|
|
28
|
+
* git, not a repository) is not remembered; the callers degrade on null.
|
|
29
|
+
* Synchronous so a reader that cannot wait (`hashBytes`, in the config
|
|
30
|
+
* load) and the enumeration share one answer whichever asks first.
|
|
31
|
+
*/
|
|
32
|
+
export function repoFacts(dir: string): RepoFacts | null {
|
|
33
|
+
const hit = repoFactsMemo.get(dir)
|
|
34
|
+
if (hit !== undefined) return hit
|
|
35
|
+
let proc
|
|
36
|
+
try {
|
|
37
|
+
proc = Bun.spawnSync({
|
|
38
|
+
cmd: [
|
|
39
|
+
executablePath('git'),
|
|
40
|
+
'rev-parse',
|
|
41
|
+
'--show-prefix',
|
|
42
|
+
'--git-common-dir',
|
|
43
|
+
'--show-object-format',
|
|
44
|
+
],
|
|
45
|
+
cwd: dir,
|
|
46
|
+
stdout: 'pipe',
|
|
47
|
+
stderr: 'ignore',
|
|
48
|
+
})
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
if (proc.exitCode !== 0) return null
|
|
53
|
+
// One line per flag, in order. A git that does not know
|
|
54
|
+
// `--show-object-format` echoes it back, as it does any unknown flag,
|
|
55
|
+
// which reads as sha1 — what the flag's own spawn answered there too.
|
|
56
|
+
const [prefix = '', commonDir = '', format = ''] = new TextDecoder()
|
|
57
|
+
.decode(proc.stdout)
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((l) => l.trim())
|
|
60
|
+
const facts: RepoFacts = {
|
|
61
|
+
prefix,
|
|
62
|
+
commonDir,
|
|
63
|
+
objectFormat: format === 'sha256' ? 'sha256' : 'sha1',
|
|
64
|
+
}
|
|
65
|
+
repoFactsMemo.set(dir, facts)
|
|
66
|
+
return facts
|
|
67
|
+
}
|
|
11
68
|
|
|
12
69
|
export class GitFilesCache extends Map<string, readonly string[]> {
|
|
13
70
|
private changed = new Map<string, string[]>()
|
|
@@ -40,6 +97,27 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
40
97
|
* populate runs, or when the status spawn failed (non-repo).
|
|
41
98
|
*/
|
|
42
99
|
private dirty: boolean | null = null
|
|
100
|
+
/**
|
|
101
|
+
* Absolute paths, as git's lossy decoding spells them, of enumerated
|
|
102
|
+
* files whose names are not UTF-8 (`decodeGitZ`). Such a name cannot
|
|
103
|
+
* be opened from a string, so `resolveFiles` refuses one it would fold.
|
|
104
|
+
*/
|
|
105
|
+
private undecodable = new Set<string>()
|
|
106
|
+
/**
|
|
107
|
+
* When the enumeration that vouched for `oids` started (ms since the
|
|
108
|
+
* epoch): an index OID says what a file held when `git status` looked,
|
|
109
|
+
* so a file changed since may hold something else (item 743). Unset on
|
|
110
|
+
* a cache no enumeration filled; its OIDs are then re-checked by content.
|
|
111
|
+
*/
|
|
112
|
+
enumeratedAtMs: number | undefined
|
|
113
|
+
|
|
114
|
+
get undecodableNames(): ReadonlySet<string> {
|
|
115
|
+
return this.undecodable
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
markUndecodable(absPaths: readonly string[]): void {
|
|
119
|
+
for (const p of absPaths) this.undecodable.add(p)
|
|
120
|
+
}
|
|
43
121
|
|
|
44
122
|
setWorkspaceRoot(root: string): void {
|
|
45
123
|
this.wsRoot = root
|
|
@@ -106,6 +184,11 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
106
184
|
}
|
|
107
185
|
|
|
108
186
|
private recordChanged(partitionDir: string, relPaths: readonly string[]): void {
|
|
187
|
+
// A partition with no snapshot has nothing for a mark to invalidate
|
|
188
|
+
// (`snapshotFor` answers undefined either way), so this guard bounds
|
|
189
|
+
// memory for a project the enumeration left without a partition; it
|
|
190
|
+
// is not a rule, and deleting it survives the whole core suite (item
|
|
191
|
+
// 648).
|
|
109
192
|
if (!this.has(partitionDir)) return
|
|
110
193
|
const cur = this.changed.get(partitionDir)
|
|
111
194
|
if (cur) cur.push(...relPaths)
|
|
@@ -149,6 +232,12 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
149
232
|
this.oids.delete(key)
|
|
150
233
|
return super.delete(key)
|
|
151
234
|
}
|
|
235
|
+
|
|
236
|
+
override clear(): void {
|
|
237
|
+
this.changed.clear()
|
|
238
|
+
this.oids.clear()
|
|
239
|
+
super.clear()
|
|
240
|
+
}
|
|
152
241
|
}
|
|
153
242
|
|
|
154
243
|
/** No glob metacharacter — the entry names one exact path. */
|
|
@@ -168,8 +257,48 @@ interface GitLsResult {
|
|
|
168
257
|
oids: Map<string, string>
|
|
169
258
|
/** Paths flagged skip-worktree / assume-unchanged (only when `-v` was passed). */
|
|
170
259
|
flagged: Set<string>
|
|
260
|
+
/** cwd-relative paths whose names are not UTF-8, spelled lossily (`decodeGitZ`). */
|
|
261
|
+
undecodable: string[]
|
|
171
262
|
}
|
|
172
263
|
|
|
264
|
+
/**
|
|
265
|
+
* git's `-z` output as text, plus the NUL-separated records that were not
|
|
266
|
+
* UTF-8. git prints a path's bytes as they are, and a lossy decode turns
|
|
267
|
+
* `x\xffy` into `x\ufffdy`, which names no file: the name reached the
|
|
268
|
+
* input set, failed the disk probe, and dropped out of the key silently,
|
|
269
|
+
* so every edit to it was a hit (turborepo#9345). Nothing in vx can open a
|
|
270
|
+
* path a string cannot spell, so such a record is kept (lossily, so a
|
|
271
|
+
* glob still matches it) and named, for `resolveFiles` to refuse.
|
|
272
|
+
*
|
|
273
|
+
* The common output holds no U+FFFD at all. Measured on a 1.3 MB,
|
|
274
|
+
* 15,000-record listing (min of 2,000, interleaved): the lossy decode plus
|
|
275
|
+
* the U+FFFD check cost 0.17 ms against 0.10 for the `Response.text()` it
|
|
276
|
+
* replaced, and the check alone 0.003; a fatal decode of the whole output
|
|
277
|
+
* ran 0.73 ms at the median against 0.26. Only output holding a U+FFFD,
|
|
278
|
+
* from an invalid byte or from a name that really holds one, is split at
|
|
279
|
+
* its NULs and each record decoded fatally, which tells the two apart.
|
|
280
|
+
*/
|
|
281
|
+
function decodeGitZ(bytes: Uint8Array): { text: string; undecodable: Set<string> } {
|
|
282
|
+
const undecodable = new Set<string>()
|
|
283
|
+
const text = LOSSY_UTF8.decode(bytes)
|
|
284
|
+
if (!text.includes('\ufffd')) return { text, undecodable }
|
|
285
|
+
let start = 0
|
|
286
|
+
for (let i = 0; i <= bytes.length; i++) {
|
|
287
|
+
if (i < bytes.length && bytes[i] !== 0) continue
|
|
288
|
+
const record = bytes.subarray(start, i)
|
|
289
|
+
try {
|
|
290
|
+
FATAL_UTF8.decode(record)
|
|
291
|
+
} catch {
|
|
292
|
+
undecodable.add(LOSSY_UTF8.decode(record))
|
|
293
|
+
}
|
|
294
|
+
start = i + 1
|
|
295
|
+
}
|
|
296
|
+
return { text, undecodable }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const LOSSY_UTF8 = new TextDecoder()
|
|
300
|
+
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
|
|
301
|
+
|
|
173
302
|
const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
|
|
174
303
|
|
|
175
304
|
/**
|
|
@@ -187,15 +316,13 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
187
316
|
let proc
|
|
188
317
|
try {
|
|
189
318
|
proc = Bun.spawnSync({
|
|
190
|
-
cmd: ['git', 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
|
|
319
|
+
cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
|
|
191
320
|
cwd,
|
|
192
321
|
stdout: 'pipe',
|
|
193
322
|
stderr: 'pipe',
|
|
194
323
|
})
|
|
195
324
|
} catch {
|
|
196
|
-
throw
|
|
197
|
-
`vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
|
|
198
|
-
)
|
|
325
|
+
throw gitSpawnRefusal(cwd)
|
|
199
326
|
}
|
|
200
327
|
if (proc.exitCode !== 0) {
|
|
201
328
|
// Exit 128 = not a git work tree; other non-zero = git failure.
|
|
@@ -206,7 +333,21 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
206
333
|
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
207
334
|
)
|
|
208
335
|
}
|
|
209
|
-
|
|
336
|
+
const { text, undecodable } = decodeGitZ(proc.stdout)
|
|
337
|
+
return parseLsFilesOutput(text, undecodable)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A file's identity as the key folds it: its blob OID, prefixed by its git
|
|
342
|
+
* mode unless that is a plain file's (100644). A blob OID holds no mode, so
|
|
343
|
+
* a `chmod +x` or a symlink swapped for a file of its target's bytes kept
|
|
344
|
+
* the key while `git status` and `--affected` saw the change, and the task
|
|
345
|
+
* replayed the old output (item 887). The plain mode stays unprefixed, so
|
|
346
|
+
* only executables and symlinks moved key. `hashFile` spells it the same
|
|
347
|
+
* way from a stat.
|
|
348
|
+
*/
|
|
349
|
+
export function fileIdentity(mode: string, oid: string): string {
|
|
350
|
+
return mode === '100644' ? oid : `${mode}:${oid}`
|
|
210
351
|
}
|
|
211
352
|
|
|
212
353
|
// Each `ls-files -s -v` record is `[<flag> ]<mode> <oid> <stage>\t<path>` —
|
|
@@ -214,25 +355,24 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
214
355
|
// `h`, …) in front. `--others` paths print bare; with `-z`, core.quotePath
|
|
215
356
|
// quoting is off, so a bare path containing a literal tab still cannot
|
|
216
357
|
// match the fixed-form prefix. Both answers come from ONE spawn.
|
|
217
|
-
function parseLsFilesOutput(out: string): GitLsResult {
|
|
358
|
+
function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>): GitLsResult {
|
|
218
359
|
const files: string[] = []
|
|
219
360
|
const oids = new Map<string, string>()
|
|
220
361
|
const flagged = new Set<string>()
|
|
221
|
-
|
|
362
|
+
const undecodable: string[] = []
|
|
363
|
+
if (out.length === 0) return { files, oids, flagged, undecodable }
|
|
222
364
|
// NUL-separated; trailing NUL produces an empty segment we skip.
|
|
223
365
|
for (const record of out.split('\0')) {
|
|
224
366
|
if (record.length === 0) continue
|
|
225
367
|
const m = LS_FILES_STAGE_RE.exec(record)
|
|
226
|
-
|
|
227
|
-
files.push(record) // --others entry: bare path
|
|
228
|
-
continue
|
|
229
|
-
}
|
|
230
|
-
const filePath = record.slice(m[0].length)
|
|
368
|
+
const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
|
|
231
369
|
files.push(filePath)
|
|
370
|
+
if (undecodableRecords.size > 0 && undecodableRecords.has(record)) undecodable.push(filePath)
|
|
371
|
+
if (m === null) continue
|
|
232
372
|
const mode = m[2]!
|
|
233
373
|
const stage = m[4]!
|
|
234
374
|
if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
|
|
235
|
-
oids.set(filePath, m[3]!)
|
|
375
|
+
oids.set(filePath, fileIdentity(mode, m[3]!))
|
|
236
376
|
}
|
|
237
377
|
// A LOWERCASE letter means skip-worktree or assume-unchanged (`S` is the
|
|
238
378
|
// older spelling of skip-worktree): git has been told to stop looking at
|
|
@@ -240,7 +380,7 @@ function parseLsFilesOutput(out: string): GitLsResult {
|
|
|
240
380
|
const flag = m[1]
|
|
241
381
|
if (flag !== undefined && (flag === 'S' || (flag >= 'a' && flag <= 'z'))) flagged.add(filePath)
|
|
242
382
|
}
|
|
243
|
-
return { files, oids, flagged }
|
|
383
|
+
return { files, oids, flagged, undecodable }
|
|
244
384
|
}
|
|
245
385
|
|
|
246
386
|
/** One completed `git` invocation. */
|
|
@@ -248,10 +388,12 @@ interface GitRun {
|
|
|
248
388
|
exitCode: number
|
|
249
389
|
stdout: string
|
|
250
390
|
stderr: string
|
|
391
|
+
/** `stdout`'s records that were not UTF-8 (`decodeGitZ`). */
|
|
392
|
+
undecodable: ReadonlySet<string>
|
|
251
393
|
}
|
|
252
394
|
|
|
253
395
|
/**
|
|
254
|
-
* Parse `git check-attr -z text eol ident` output — a flat stream of
|
|
396
|
+
* Parse `git check-attr -z text eol ident filter working-tree-encoding` output — a flat stream of
|
|
255
397
|
* `<path>\0<attr>\0<value>\0` triples — into the set of paths where a clean
|
|
256
398
|
* filter can rewrite bytes. `unspecified` means no rule matched and
|
|
257
399
|
* `unset` (`-text`) explicitly disables conversion; both leave the index blob
|
|
@@ -280,12 +422,14 @@ export function parseCheckAttrOutput(out: string): Set<string> {
|
|
|
280
422
|
* 1. If `core.autocrlf` is true/input, conversion applies to every
|
|
281
423
|
* auto-detected text file with no attribute needed — trust nothing.
|
|
282
424
|
* 2. Else, if no attributes source exists anywhere (no in-tree
|
|
283
|
-
* `.gitattributes`, no `$GIT_DIR/info/attributes`,
|
|
284
|
-
* `
|
|
285
|
-
* zero extra work. This is the
|
|
425
|
+
* `.gitattributes`, no `$GIT_DIR/info/attributes`, none of the files
|
|
426
|
+
* outside the tree git reads — `attributeFilesOutsideTree`), no rule
|
|
427
|
+
* can name a filter — return untouched, zero extra work. This is the
|
|
428
|
+
* default `git init` repo.
|
|
286
429
|
* 3. Otherwise ask `git check-attr` (measured 21 ms; it resolves attributes
|
|
287
430
|
* from the index WITHOUT reading worktree content) and drop only the
|
|
288
|
-
* paths that actually carry `text`/`eol`/`ident
|
|
431
|
+
* paths that actually carry `text`/`eol`/`ident`/`filter`/
|
|
432
|
+
* `working-tree-encoding`.
|
|
289
433
|
*
|
|
290
434
|
* Never throws: a probe that fails leaves the map as-is, which is exactly the
|
|
291
435
|
* behaviour before this gate existed.
|
|
@@ -293,75 +437,213 @@ export function parseCheckAttrOutput(out: string): Set<string> {
|
|
|
293
437
|
async function dropFilteredOids(
|
|
294
438
|
trusted: Map<string, string>,
|
|
295
439
|
args: {
|
|
440
|
+
/** Every path the enumeration listed: tracked (dirty ones too) and untracked. */
|
|
441
|
+
listed: readonly string[]
|
|
296
442
|
workspaceRoot: string
|
|
297
443
|
gitDir: string
|
|
298
|
-
|
|
444
|
+
gitPrefix: string
|
|
445
|
+
pathspecs: readonly string[]
|
|
446
|
+
gitVars: string
|
|
299
447
|
spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
|
|
300
448
|
},
|
|
301
449
|
): Promise<void> {
|
|
302
450
|
if (trusted.size === 0) return
|
|
303
|
-
if (autocrlfConverts(args.
|
|
451
|
+
if (autocrlfConverts(args.gitVars)) {
|
|
304
452
|
trusted.clear()
|
|
305
453
|
return
|
|
306
454
|
}
|
|
307
455
|
// Cheap checks first, and nothing is materialized until one of them fires —
|
|
308
456
|
// on a 15k-file repo with no attributes this whole function is a size check,
|
|
309
|
-
// a
|
|
310
|
-
//
|
|
457
|
+
// a line scan of `git var -l`, three stats, and a key walk that exits on the
|
|
458
|
+
// first `.gitattributes` it does not find.
|
|
311
459
|
let attributesPossible =
|
|
312
|
-
args.
|
|
460
|
+
attributeFilesOutsideTree(args.gitVars, process.env).some((f) =>
|
|
461
|
+
existsSync(path.resolve(args.workspaceRoot, f)),
|
|
462
|
+
) ||
|
|
313
463
|
(args.gitDir !== '' &&
|
|
314
464
|
existsSync(path.resolve(args.workspaceRoot, args.gitDir, 'info', 'attributes')))
|
|
465
|
+
// Every listed path, not only the trusted ones: git applies a
|
|
466
|
+
// `.gitattributes` that is untracked or modified as well, and scanned in
|
|
467
|
+
// the trusted set alone such a file was never seen, so a CRLF file's LF
|
|
468
|
+
// index blob keyed it through an edit (item 977).
|
|
315
469
|
if (!attributesPossible) {
|
|
316
|
-
for (const rel of
|
|
470
|
+
for (const rel of args.listed) {
|
|
317
471
|
if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) {
|
|
318
472
|
attributesPossible = true
|
|
319
473
|
break
|
|
320
474
|
}
|
|
321
475
|
}
|
|
322
476
|
}
|
|
477
|
+
// The scan above can only see what the enumeration LISTED, and a scoped
|
|
478
|
+
// run lists the project dirs alone (`gitPathspecs`). A `.gitattributes`
|
|
479
|
+
// ABOVE them — the workspace root's own, which is where a monorepo puts
|
|
480
|
+
// it — is therefore invisible, and the gate read "no attributes
|
|
481
|
+
// anywhere" and kept every filtered OID. Measured on the real CLI: the
|
|
482
|
+
// same workspace and the same CRLF→LF edit is a miss under `--all`
|
|
483
|
+
// (pathspec `.`, so the root file is listed) and a STALE HIT under
|
|
484
|
+
// `--filter app`. Git resolves attributes from every directory between
|
|
485
|
+
// the repo root and the file, so walk those directories — bounded by
|
|
486
|
+
// depth and project count, never by file count, which is what the
|
|
487
|
+
// gate's cost rule cares about.
|
|
488
|
+
if (!attributesPossible) attributesPossible = attributesAbove(args)
|
|
323
489
|
if (!attributesPossible) return
|
|
324
490
|
|
|
491
|
+
// `filter` and `working-tree-encoding` rewrite bytes too: a clean driver
|
|
492
|
+
// (nbstripout, a `sed`) can map two worktree files to one blob, so the
|
|
493
|
+
// blob's OID keyed an edit git calls clean, and the run replayed the old
|
|
494
|
+
// output (item 978). Git LFS is a `filter` as well: its files are hashed
|
|
495
|
+
// from disk now, the one honest identity of what a task reads.
|
|
325
496
|
const res = await args.spawnGit(
|
|
326
|
-
['check-attr', '--stdin', '-z', 'text', 'eol', 'ident'],
|
|
497
|
+
['check-attr', '--stdin', '-z', 'text', 'eol', 'ident', 'filter', 'working-tree-encoding'],
|
|
327
498
|
[...trusted.keys()].join('\0'),
|
|
328
499
|
)
|
|
329
500
|
if (res === null || res.exitCode !== 0) return
|
|
330
501
|
for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
|
|
331
502
|
}
|
|
332
503
|
|
|
333
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
504
|
+
/**
|
|
505
|
+
* The repository root, derived from the workspace root and git's own
|
|
506
|
+
* `--show-prefix` (the repo→workspace path). Exact and free: the prefix
|
|
507
|
+
* came from the same spawn. Deriving it from the git DIRECTORY instead
|
|
508
|
+
* is wrong in a linked worktree, where `--git-dir` names
|
|
509
|
+
* `<main>/.git/worktrees/<name>` — not an ancestor of the worktree's
|
|
510
|
+
* files at all, so a walk stopping there never stops and runs to `/`.
|
|
511
|
+
*/
|
|
512
|
+
export function repoRootOf(workspaceRoot: string, gitPrefix: string): string {
|
|
513
|
+
const depth = gitPrefix.split('/').filter((p) => p !== '' && p !== '.').length
|
|
514
|
+
return depth === 0 ? workspaceRoot : path.resolve(workspaceRoot, ...Array(depth).fill('..'))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Is there a `.gitattributes` at or above the scanned dirs, up to the repo
|
|
519
|
+
* root? Only the directories are stat'd — one per level per pathspec — so
|
|
520
|
+
* this stays a handful of syscalls on any repo size.
|
|
521
|
+
*/
|
|
522
|
+
function attributesAbove(args: {
|
|
523
|
+
workspaceRoot: string
|
|
524
|
+
gitPrefix: string
|
|
525
|
+
pathspecs: readonly string[]
|
|
526
|
+
}): boolean {
|
|
527
|
+
const top = repoRootOf(args.workspaceRoot, args.gitPrefix)
|
|
528
|
+
const seen = new Set<string>()
|
|
529
|
+
for (const spec of args.pathspecs) {
|
|
530
|
+
let dir = path.resolve(args.workspaceRoot, spec)
|
|
531
|
+
for (;;) {
|
|
532
|
+
if (!seen.has(dir)) {
|
|
533
|
+
seen.add(dir)
|
|
534
|
+
if (existsSync(path.join(dir, '.gitattributes'))) return true
|
|
535
|
+
}
|
|
536
|
+
const parent = path.dirname(dir)
|
|
537
|
+
// Stop at the repo root, and never walk past the filesystem root on a
|
|
538
|
+
// workspace whose git dir could not be read.
|
|
539
|
+
if (dir === top || parent === dir) break
|
|
540
|
+
dir = parent
|
|
340
541
|
}
|
|
341
542
|
}
|
|
342
543
|
return false
|
|
343
544
|
}
|
|
344
545
|
|
|
345
|
-
|
|
546
|
+
/**
|
|
547
|
+
* `git var -l` as `name → value`, the last line for a name winning, as the
|
|
548
|
+
* last value of a config key does in git. Config keys come first and
|
|
549
|
+
* lower-cased; git's own variables (`GIT_ATTR_GLOBAL`, …) follow.
|
|
550
|
+
*/
|
|
551
|
+
function gitVarList(listing: string): Map<string, string> {
|
|
552
|
+
const vars = new Map<string, string>()
|
|
553
|
+
for (const line of listing.split('\n')) {
|
|
554
|
+
const eq = line.indexOf('=')
|
|
555
|
+
if (eq > 0) vars.set(line.slice(0, eq), line.slice(eq + 1))
|
|
556
|
+
}
|
|
557
|
+
return vars
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** `true` when git may rewrite bytes for EVERY auto-detected text file. */
|
|
561
|
+
export function autocrlfConverts(gitVars: string): boolean {
|
|
562
|
+
const v = gitVarList(gitVars).get('core.autocrlf')?.trim().toLowerCase()
|
|
563
|
+
return v === 'true' || v === 'input'
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The attributes files outside the work tree git reads, from the same
|
|
568
|
+
* `git var -l` the gate already spawns. Git 2.42 and later name both:
|
|
569
|
+
* `GIT_ATTR_GLOBAL` is `core.attributesFile` or, unset, the default
|
|
570
|
+
* `$XDG_CONFIG_HOME/git/attributes` (`~/.config/git/attributes` without
|
|
571
|
+
* it), and `GIT_ATTR_SYSTEM` is the build's `$(sysconfdir)/gitattributes`,
|
|
572
|
+
* absent under `GIT_ATTR_NOSYSTEM`. The gate read only the config key, so
|
|
573
|
+
* `* text` in the default file left a CRLF file's LF blob trusted, a
|
|
574
|
+
* stale hit (the upstream survey's row X).
|
|
575
|
+
*
|
|
576
|
+
* An older git names neither; its global lookup is the documented one,
|
|
577
|
+
* mirrored here, and its system file sits where its build put it, which
|
|
578
|
+
* only git knows: `/etc/gitattributes` for a `/usr` build, else
|
|
579
|
+
* `<prefix>/etc/gitattributes` beside the binary's `bin/`. A path may be
|
|
580
|
+
* relative (a relative `core.attributesFile`), to the workspace root.
|
|
581
|
+
*/
|
|
582
|
+
export function attributeFilesOutsideTree(
|
|
583
|
+
gitVars: string,
|
|
584
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
585
|
+
): string[] {
|
|
586
|
+
const vars = gitVarList(gitVars)
|
|
587
|
+
const global = vars.get('GIT_ATTR_GLOBAL')
|
|
588
|
+
const system = vars.get('GIT_ATTR_SYSTEM')
|
|
589
|
+
if (global !== undefined || system !== undefined || vars.has('GIT_CONFIG_GLOBAL')) {
|
|
590
|
+
return [global, system].filter((f): f is string => f !== undefined && f !== '')
|
|
591
|
+
}
|
|
592
|
+
const out: string[] = []
|
|
593
|
+
const configured = vars.get('core.attributesfile')
|
|
594
|
+
const home = env.HOME ?? ''
|
|
595
|
+
if (configured !== undefined && configured !== '') {
|
|
596
|
+
out.push(configured.startsWith('~/') ? path.join(home, configured.slice(2)) : configured)
|
|
597
|
+
} else if ((env.XDG_CONFIG_HOME ?? '') !== '') {
|
|
598
|
+
out.push(path.join(env.XDG_CONFIG_HOME!, 'git', 'attributes'))
|
|
599
|
+
} else if (home !== '') {
|
|
600
|
+
out.push(path.join(home, '.config', 'git', 'attributes'))
|
|
601
|
+
}
|
|
602
|
+
const noSystem = (env.GIT_ATTR_NOSYSTEM ?? '').toLowerCase()
|
|
603
|
+
if (!['1', 'true', 'yes', 'on'].includes(noSystem)) {
|
|
604
|
+
out.push('/etc/gitattributes')
|
|
605
|
+
try {
|
|
606
|
+
const prefix = path.dirname(path.dirname(realpathSync(executablePath('git'))))
|
|
607
|
+
out.push(path.join(prefix, 'etc', 'gitattributes'))
|
|
608
|
+
} catch {
|
|
609
|
+
// No git to resolve: the enumeration has already refused the run.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return out
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function parseStatusOutput(
|
|
616
|
+
out: string,
|
|
617
|
+
undecodableRecords: ReadonlySet<string>,
|
|
618
|
+
): { dirty: Set<string>; untracked: string[]; undecodable: Set<string> } {
|
|
346
619
|
const tokens = out.split('\0')
|
|
347
620
|
const dirty = new Set<string>()
|
|
348
621
|
const untracked: string[] = []
|
|
622
|
+
const undecodable = new Set<string>()
|
|
349
623
|
for (let i = 0; i < tokens.length; i++) {
|
|
350
624
|
const token = tokens[i]!
|
|
351
625
|
if (token.length < 4) continue
|
|
626
|
+
if (undecodableRecords.size > 0 && undecodableRecords.has(token))
|
|
627
|
+
undecodable.add(token.slice(3))
|
|
352
628
|
if (token[0] === '?') {
|
|
353
629
|
untracked.push(token.slice(3))
|
|
354
630
|
continue
|
|
355
631
|
}
|
|
356
632
|
dirty.add(token.slice(3))
|
|
357
|
-
//
|
|
633
|
+
// An R or C in either column → the next token is the rename/copy
|
|
634
|
+
// source path. Y carries it for a rename git sees in the worktree (the
|
|
635
|
+
// copy added with `git add -N`, the original removed): read on X alone,
|
|
636
|
+
// the source was parsed as a record of its own, stayed trusted, and a
|
|
637
|
+
// deleted file was keyed from the index (item 976).
|
|
358
638
|
const x = token[0]
|
|
359
|
-
|
|
639
|
+
const y = token[1]
|
|
640
|
+
const renamed = x === 'R' || x === 'C' || y === 'R' || y === 'C'
|
|
641
|
+
if (renamed && i + 1 < tokens.length && tokens[i + 1]!.length > 0) {
|
|
360
642
|
i++
|
|
361
643
|
dirty.add(tokens[i]!)
|
|
362
644
|
}
|
|
363
645
|
}
|
|
364
|
-
return { dirty, untracked }
|
|
646
|
+
return { dirty, untracked, undecodable }
|
|
365
647
|
}
|
|
366
648
|
|
|
367
649
|
/**
|
|
@@ -427,6 +709,10 @@ export interface GitEnumeration {
|
|
|
427
709
|
trusted: Map<string, string>
|
|
428
710
|
/** Whether the worktree had uncommitted changes; null when `git status` failed. */
|
|
429
711
|
dirty: boolean | null
|
|
712
|
+
/** Workspace-relative paths whose names are not UTF-8 (`decodeGitZ`). */
|
|
713
|
+
undecodable: readonly string[]
|
|
714
|
+
/** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
|
|
715
|
+
startedAtMs: number
|
|
430
716
|
}
|
|
431
717
|
|
|
432
718
|
/**
|
|
@@ -467,49 +753,66 @@ export async function startGitEnumeration(
|
|
|
467
753
|
const spawnGit = async (args: string[], stdin?: string): Promise<GitRun | null> => {
|
|
468
754
|
try {
|
|
469
755
|
const proc = Bun.spawn({
|
|
470
|
-
|
|
756
|
+
// `status` refreshes the index when it can take `index.lock`, so
|
|
757
|
+
// the user's own `git add` or `commit` failed on the lock while a
|
|
758
|
+
// vx run held it (12 in 1,165 across 80 runs, item 880). Read-only, as
|
|
759
|
+
// editors and prompts run it; a clean tree costs the same.
|
|
760
|
+
cmd: [executablePath('git'), '--no-optional-locks', ...args],
|
|
471
761
|
cwd: workspaceRoot,
|
|
472
762
|
stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
|
|
473
763
|
stdout: 'pipe',
|
|
474
764
|
stderr: 'pipe',
|
|
475
765
|
})
|
|
476
766
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
477
|
-
new Response(proc.stdout).
|
|
767
|
+
new Response(proc.stdout).bytes(),
|
|
478
768
|
new Response(proc.stderr).text(),
|
|
479
769
|
proc.exited,
|
|
480
770
|
])
|
|
481
|
-
|
|
771
|
+
const { text, undecodable } = decodeGitZ(stdout)
|
|
772
|
+
return { exitCode, stdout: text, stderr, undecodable }
|
|
482
773
|
} catch {
|
|
483
774
|
return null
|
|
484
775
|
}
|
|
485
776
|
}
|
|
486
|
-
|
|
777
|
+
const startedAtMs = Date.now()
|
|
778
|
+
// Four spawns at most (the rev-parse is memoized per process), one
|
|
779
|
+
// worktree walk. `ls-files -s -v` reads the INDEX only
|
|
487
780
|
// (~9 ms on a 1000-project tree) and answers two questions at once: every
|
|
488
781
|
// tracked path's OID and its cache-state flag. `status -uall` is the one
|
|
489
782
|
// command that walks the worktree, and it answers two as well: which
|
|
490
783
|
// tracked paths are dirty AND which files are untracked. Asking
|
|
491
784
|
// `ls-files --others` for the untracked set walked the same tree a second
|
|
492
785
|
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
493
|
-
const
|
|
786
|
+
const running = Promise.all([
|
|
494
787
|
spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
|
|
495
788
|
spawnGit(['status', '--porcelain', '-z', '-uall', '--', ...pathspecs]),
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
spawnGit(['
|
|
504
|
-
// Reads three config keys, no tree scan — the gate for whether a clean
|
|
505
|
-
// filter can rewrite bytes between the index and the worktree. Exits 1
|
|
506
|
-
// when none are set, which is the common case and means "no gate".
|
|
507
|
-
spawnGit(['config', '--get-regexp', '^core\\.(autocrlf|eol|attributesfile)$']),
|
|
789
|
+
// The gate for whether a clean filter can rewrite bytes between the
|
|
790
|
+
// index and the worktree: git's merged config (`core.autocrlf`) and,
|
|
791
|
+
// from git 2.42, the attributes files it reads outside the tree
|
|
792
|
+
// (`attributeFilesOutsideTree`). No tree scan. It replaced a
|
|
793
|
+
// `config --get-regexp` of three keys at the same cost (1.29 against
|
|
794
|
+
// 1.32 ms, min of 30), which could not name the default global file.
|
|
795
|
+
// Not foldable into the rev-parse below: rev-parse prints no config.
|
|
796
|
+
spawnGit(['var', '-l']),
|
|
508
797
|
])
|
|
798
|
+
// Asked while the three above run, from the memo the file hasher reads
|
|
799
|
+
// too (`repoFacts`). `prefix` is the repo→workspace path (empty when the
|
|
800
|
+
// workspace root IS the git root): `ls-files` prints cwd(workspace)-
|
|
801
|
+
// relative paths but `status` prints repo-root-relative ones, so when the
|
|
802
|
+
// workspace root is a SUBDIR of the git repo the two disagree; this lets
|
|
803
|
+
// us key both the same way below. `commonDir` locates `info/attributes`
|
|
804
|
+
// for the filter gate: it is not always `.git/` (a linked worktree's
|
|
805
|
+
// `.git` is a FILE pointing elsewhere), and it is the COMMON dir rather
|
|
806
|
+
// than the per-worktree one because that is where git reads the file
|
|
807
|
+
// from — probed, 2026-09-20: from inside a worktree `git check-attr text`
|
|
808
|
+
// goes `unspecified` → `auto` when the rule is written to the common
|
|
809
|
+
// dir's `info/attributes`, while the per-worktree gitdir has no such file
|
|
810
|
+
// at all. `--git-dir` named the per-worktree directory, so the gate
|
|
811
|
+
// looked where the rule can never be.
|
|
812
|
+
const facts = repoFacts(workspaceRoot)
|
|
813
|
+
const [ls, status, vars] = await running
|
|
509
814
|
if (ls === null) {
|
|
510
|
-
throw
|
|
511
|
-
`vx requires git: failed to spawn 'git' (working dir: ${workspaceRoot}). Install git and re-run.`,
|
|
512
|
-
)
|
|
815
|
+
throw gitSpawnRefusal(workspaceRoot)
|
|
513
816
|
}
|
|
514
817
|
if (ls.exitCode !== 0) {
|
|
515
818
|
const stderr = ls.stderr.trim()
|
|
@@ -518,7 +821,12 @@ export async function startGitEnumeration(
|
|
|
518
821
|
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
519
822
|
)
|
|
520
823
|
}
|
|
521
|
-
const {
|
|
824
|
+
const {
|
|
825
|
+
files: tracked,
|
|
826
|
+
oids,
|
|
827
|
+
flagged,
|
|
828
|
+
undecodable,
|
|
829
|
+
} = parseLsFilesOutput(ls.stdout, ls.undecodable)
|
|
522
830
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
523
831
|
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
524
832
|
// OID map. Without this, when the workspace root is a git subdir, a modified
|
|
@@ -526,13 +834,12 @@ export async function startGitEnumeration(
|
|
|
526
834
|
// a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
|
|
527
835
|
// the common case) is a zero-cost no-op. Paths above the workspace can't be
|
|
528
836
|
// inputs, so they drop out of the set.
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
prefixRes !== null && prefixRes.exitCode === 0 ? prefixRes.stdout.split('\n') : []
|
|
532
|
-
const gitPrefix = (revLines[0] ?? '').trim()
|
|
533
|
-
const gitDir = (revLines[1] ?? '').trim()
|
|
837
|
+
const gitPrefix = facts?.prefix ?? ''
|
|
838
|
+
const gitDir = facts?.commonDir ?? ''
|
|
534
839
|
const parsedStatus =
|
|
535
|
-
status !== null && status.exitCode === 0
|
|
840
|
+
status !== null && status.exitCode === 0
|
|
841
|
+
? parseStatusOutput(status.stdout, status.undecodable)
|
|
842
|
+
: null
|
|
536
843
|
const dirty = parsedStatus === null ? null : stripPrefixFromSet(parsedStatus.dirty, gitPrefix)
|
|
537
844
|
// Untracked files are inputs too (a new file is the commonest edit there
|
|
538
845
|
// is). They come from the status walk, repo-root-relative like the dirty
|
|
@@ -557,7 +864,7 @@ export async function startGitEnumeration(
|
|
|
557
864
|
// fall out of the input set while unmaterialized.
|
|
558
865
|
for (const rel of flagged) trusted.delete(rel)
|
|
559
866
|
// An index OID is only the file's content hash when git stores the worktree
|
|
560
|
-
// bytes VERBATIM. Under a clean filter (`text`/`eol`/`ident`) the blob is a
|
|
867
|
+
// bytes VERBATIM. Under a clean filter (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) the blob is a
|
|
561
868
|
// DIFFERENT sequence of bytes — the LF-normalized form — while the task
|
|
562
869
|
// reads the CRLF worktree file. `git status` compares AFTER filtering, so
|
|
563
870
|
// such a file reports clean and keeps its OID: the CRLF and LF states then
|
|
@@ -568,12 +875,18 @@ export async function startGitEnumeration(
|
|
|
568
875
|
// all along. The only cost is the read, so the gate below is about paying it
|
|
569
876
|
// ONLY where a filter can actually apply.
|
|
570
877
|
await dropFilteredOids(trusted, {
|
|
878
|
+
listed: all,
|
|
571
879
|
workspaceRoot,
|
|
572
880
|
gitDir,
|
|
573
|
-
|
|
881
|
+
gitPrefix,
|
|
882
|
+
pathspecs,
|
|
883
|
+
gitVars: vars !== null && vars.exitCode === 0 ? vars.stdout : '',
|
|
574
884
|
spawnGit,
|
|
575
885
|
})
|
|
576
|
-
|
|
886
|
+
if (parsedStatus !== null && parsedStatus.undecodable.size > 0) {
|
|
887
|
+
undecodable.push(...stripPrefixFromSet(parsedStatus.undecodable, gitPrefix))
|
|
888
|
+
}
|
|
889
|
+
return { all, trusted, dirty: worktreeDirty, undecodable, startedAtMs }
|
|
577
890
|
}
|
|
578
891
|
|
|
579
892
|
/** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
|
|
@@ -586,6 +899,8 @@ export function applyGitEnumeration(
|
|
|
586
899
|
): void {
|
|
587
900
|
const { all, trusted } = enumeration
|
|
588
901
|
cache.setWorktreeDirty(enumeration.dirty)
|
|
902
|
+
cache.enumeratedAtMs = enumeration.startedAtMs
|
|
903
|
+
cache.markUndecodable(enumeration.undecodable.map((rel) => path.join(workspaceRoot, rel)))
|
|
589
904
|
// Sort once, then each project's files are a contiguous range found
|
|
590
905
|
// by binary search on its `dir/` prefix — O((F+P) log F) instead of
|
|
591
906
|
// the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
|
|
@@ -623,6 +938,20 @@ export function applyGitEnumeration(
|
|
|
623
938
|
const oid = trusted.get(rel)
|
|
624
939
|
if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
|
|
625
940
|
}
|
|
941
|
+
// An empty slice is a directory git did not see, not an empty project:
|
|
942
|
+
// a project has at least its package.json, tracked or untracked. What
|
|
943
|
+
// hides a directory from this listing is a nested repository — a
|
|
944
|
+
// submodule, an embedded repository — which the workspace's git holds as
|
|
945
|
+
// ONE entry (a gitlink, `dir/` when untracked) and, under a pathspec
|
|
946
|
+
// naming the project, as nothing at all; or a directory ignored
|
|
947
|
+
// outright. Storing the empty slice made the key never move: `cache.inputs
|
|
948
|
+
// matched no files`, then a stale hit under a green run once the source
|
|
949
|
+
// changed (2026-09-16). No partition instead: `resolveFiles` spawns
|
|
950
|
+
// `git ls-files` in the project's own directory, which a nested
|
|
951
|
+
// repository answers, and the files hash by content (no index OID
|
|
952
|
+
// trusted from here) — one spawn per such project per run, nothing for
|
|
953
|
+
// a workspace without one.
|
|
954
|
+
if (matches.length === 0) continue
|
|
626
955
|
// setOids AFTER set — set() drops the project's OID slot.
|
|
627
956
|
cache.set(projectDir, matches)
|
|
628
957
|
cache.setOids(projectDir, projOids)
|