@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/cache/git-inputs.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
checkIgnoreSync,
|
|
25
25
|
executablePath,
|
|
26
26
|
gitEnv,
|
|
27
|
+
gitFailure,
|
|
27
28
|
gitSpawnRefusal,
|
|
28
29
|
isInstalledPath,
|
|
29
30
|
notAWorkTree,
|
|
@@ -178,7 +179,12 @@ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
|
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* `[extensions] objectFormat` of a repository config, sha1 when unset;
|
|
184
|
+
* undefined when unsure, or when `core.worktree` puts the work tree
|
|
185
|
+
* somewhere other than the `.git` directory's parent, which the prefix
|
|
186
|
+
* read off the disk assumes.
|
|
187
|
+
*/
|
|
182
188
|
function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
|
|
183
189
|
let section = ''
|
|
184
190
|
let format: 'sha1' | 'sha256' = 'sha1'
|
|
@@ -191,7 +197,10 @@ function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
|
|
|
191
197
|
if (section === 'include' || section === 'includeif') return undefined
|
|
192
198
|
continue
|
|
193
199
|
}
|
|
200
|
+
if (section === 'core' && /^worktree\b/i.test(line)) return undefined
|
|
194
201
|
if (section !== 'extensions') continue
|
|
202
|
+
// `config.worktree` may set `core.worktree` too.
|
|
203
|
+
if (/^worktreeconfig\b/i.test(line)) return undefined
|
|
195
204
|
const kv = /^([A-Za-z0-9-]+)\s*=\s*(.*)$/.exec(line)
|
|
196
205
|
if (kv === null || kv[1]!.toLowerCase() !== 'objectformat') continue
|
|
197
206
|
const value = kv[2]!.toLowerCase()
|
|
@@ -244,6 +253,12 @@ interface IgnoredListing {
|
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
export class GitFilesCache extends Map<string, readonly string[]> {
|
|
256
|
+
/**
|
|
257
|
+
* vx's own directories inside the workspace (the run's cache directory),
|
|
258
|
+
* spelled under the workspace root as the run spells it: no input, even
|
|
259
|
+
* where a glob walks an ignored prefix on disk.
|
|
260
|
+
*/
|
|
261
|
+
vxDirs: readonly string[] = []
|
|
247
262
|
private changed = new Map<string, string[]>()
|
|
248
263
|
/**
|
|
249
264
|
* Per-project trusted index OIDs: absolute path → git blob OID, for
|
|
@@ -304,7 +319,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
304
319
|
this.foldCase ??=
|
|
305
320
|
this.vars === null
|
|
306
321
|
? process.platform === 'darwin'
|
|
307
|
-
: this.vars.get('core.ignorecase')
|
|
322
|
+
: gitBool(this.vars.get('core.ignorecase')) === true
|
|
308
323
|
return this.foldCase ? abs.toLowerCase() : abs
|
|
309
324
|
}
|
|
310
325
|
|
|
@@ -328,7 +343,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
328
343
|
|
|
329
344
|
/**
|
|
330
345
|
* Whether git tracks a file under `dir`: committed (or `git add`ed)
|
|
331
|
-
* files are the user's, never cleaned (
|
|
346
|
+
* files are the user's, never cleaned (2026-10-14: "never
|
|
332
347
|
* delete committed file"). A directory no enumeration covered (a
|
|
333
348
|
* nested repository, a cache no enumeration filled) is listed once.
|
|
334
349
|
* git lists a submodule as its gitlink path alone, so a file under a
|
|
@@ -551,12 +566,34 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
551
566
|
const snap = this.get(projectDir)
|
|
552
567
|
if (snap === undefined) return undefined
|
|
553
568
|
const pending = this.changed.get(projectDir)
|
|
554
|
-
if (pending !== undefined &&
|
|
555
|
-
return undefined
|
|
556
|
-
}
|
|
569
|
+
if (pending !== undefined && this.pendingMatch(pending, inputGlobs)) return undefined
|
|
557
570
|
return snap
|
|
558
571
|
}
|
|
559
572
|
|
|
573
|
+
/**
|
|
574
|
+
* How far each glob has read a `changed` list, -1 once it matched: the
|
|
575
|
+
* list only grows until a reset replaces it, so each glob reads each path
|
|
576
|
+
* once. Every task asked again from the start: 9000 tasks writing into
|
|
577
|
+
* one project cost 10 s of reads (2026-10-25).
|
|
578
|
+
*/
|
|
579
|
+
private readonly read = new WeakMap<readonly string[], Map<Bun.Glob, number>>()
|
|
580
|
+
|
|
581
|
+
private pendingMatch(pending: readonly string[], globs: readonly Bun.Glob[]): boolean {
|
|
582
|
+
let upTo = this.read.get(pending)
|
|
583
|
+
if (upTo === undefined) this.read.set(pending, (upTo = new Map()))
|
|
584
|
+
for (const g of globs) {
|
|
585
|
+
let i = upTo.get(g) ?? 0
|
|
586
|
+
if (i < 0) return true
|
|
587
|
+
for (; i < pending.length; i++)
|
|
588
|
+
if (g.match(pending[i]!)) {
|
|
589
|
+
upTo.set(g, -1)
|
|
590
|
+
return true
|
|
591
|
+
}
|
|
592
|
+
upTo.set(g, i)
|
|
593
|
+
}
|
|
594
|
+
return false
|
|
595
|
+
}
|
|
596
|
+
|
|
560
597
|
override set(key: string, value: readonly string[]): this {
|
|
561
598
|
this.changed.delete(key)
|
|
562
599
|
this.oids.delete(key)
|
|
@@ -655,12 +692,12 @@ const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])
|
|
|
655
692
|
* tree; vx requires git. `-z` survives filenames with newlines /
|
|
656
693
|
* spaces.
|
|
657
694
|
*/
|
|
658
|
-
export function runGitLsFiles(cwd: string): GitLsResult {
|
|
695
|
+
export function runGitLsFiles(cwd: string, env = gitEnv()): GitLsResult {
|
|
659
696
|
let proc
|
|
660
697
|
try {
|
|
661
698
|
proc = Bun.spawnSync({
|
|
662
699
|
cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
|
|
663
|
-
env
|
|
700
|
+
env,
|
|
664
701
|
cwd,
|
|
665
702
|
stdout: 'pipe',
|
|
666
703
|
stderr: 'pipe',
|
|
@@ -669,9 +706,9 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
669
706
|
throw gitSpawnRefusal(cwd)
|
|
670
707
|
}
|
|
671
708
|
if (proc.exitCode !== 0) {
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
throw
|
|
709
|
+
// Not a work tree, or git failing in one: either way the inputs
|
|
710
|
+
// cannot be enumerated, and the two have different fixes.
|
|
711
|
+
throw gitFailure(cwd, 'ls-files', proc.exitCode, new TextDecoder().decode(proc.stderr).trim())
|
|
675
712
|
}
|
|
676
713
|
const { text, undecodable } = decodeGitZ(proc.stdout)
|
|
677
714
|
const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
|
|
@@ -684,7 +721,9 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
684
721
|
/**
|
|
685
722
|
* The files git tracks under `dir`, absolute: one `ls-files` of the index,
|
|
686
723
|
* no worktree walk. Outside a work tree (an embedder's temp dir) nothing
|
|
687
|
-
* is tracked.
|
|
724
|
+
* is tracked. A git that fails otherwise refuses: read as "nothing
|
|
725
|
+
* tracked", every committed file under `dir` was the output clean's to
|
|
726
|
+
* delete (2026-10-28).
|
|
688
727
|
*/
|
|
689
728
|
function listTracked(dir: string): string[] {
|
|
690
729
|
let proc
|
|
@@ -697,9 +736,16 @@ function listTracked(dir: string): string[] {
|
|
|
697
736
|
stderr: 'pipe',
|
|
698
737
|
})
|
|
699
738
|
} catch {
|
|
700
|
-
|
|
739
|
+
throw gitSpawnRefusal(dir)
|
|
740
|
+
}
|
|
741
|
+
if (proc.exitCode !== 0) {
|
|
742
|
+
const stderr = new TextDecoder().decode(proc.stderr).trim()
|
|
743
|
+
if (/not a git repository/i.test(stderr)) return []
|
|
744
|
+
throw new UserError(
|
|
745
|
+
`git ls-files failed (exit ${proc.exitCode}) in ${dir}: ${stderr || 'no output'}. Run it there to see why.`,
|
|
746
|
+
'VX_E_GIT',
|
|
747
|
+
)
|
|
701
748
|
}
|
|
702
|
-
if (proc.exitCode !== 0) return []
|
|
703
749
|
const { text } = decodeGitZ(proc.stdout)
|
|
704
750
|
return text
|
|
705
751
|
.split('\0')
|
|
@@ -742,13 +788,67 @@ function expandNestedRepos(
|
|
|
742
788
|
for (const f of walkFiles(abs)) out.push(`${nested}/${f}`)
|
|
743
789
|
continue
|
|
744
790
|
}
|
|
745
|
-
|
|
791
|
+
let inner
|
|
792
|
+
try {
|
|
793
|
+
inner = runGitLsFiles(abs, nestedRepoEnv())
|
|
794
|
+
} catch {
|
|
795
|
+
// A `.git` git cannot open (a submodule's file naming a lost
|
|
796
|
+
// `.git/modules` entry) answers as no `.git` does: by a walk. It
|
|
797
|
+
// refused the whole run as "not inside a git work tree".
|
|
798
|
+
for (const f of walkFiles(abs)) out.push(`${nested}/${f}`)
|
|
799
|
+
continue
|
|
800
|
+
}
|
|
746
801
|
for (const f of inner.files) out.push(`${nested}/${f}`)
|
|
747
802
|
for (const f of inner.undecodable) undecodable.push(`${nested}/${f}`)
|
|
748
803
|
}
|
|
749
804
|
return { files: out ?? files, undecodable }
|
|
750
805
|
}
|
|
751
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Variables that name the repository git works on (git's `local_repo_env`),
|
|
809
|
+
* which a nested repository's git must not inherit. A pre-commit hook runs
|
|
810
|
+
* vx with `GIT_INDEX_FILE` or `GIT_DIR` naming the OUTER repository, and
|
|
811
|
+
* the nested listing read the outer index: a file the nested repository
|
|
812
|
+
* tracks under an ignore rule fell out of the inputs, a stale hit. git
|
|
813
|
+
* clears the same list for a submodule's commands; config variables stay.
|
|
814
|
+
*/
|
|
815
|
+
const LOCAL_REPO_ENV = new Set([
|
|
816
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
817
|
+
'GIT_COMMON_DIR',
|
|
818
|
+
'GIT_DIR',
|
|
819
|
+
'GIT_GRAFT_FILE',
|
|
820
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
821
|
+
'GIT_INDEX_FILE',
|
|
822
|
+
'GIT_INTERNAL_SUPER_PREFIX',
|
|
823
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
824
|
+
'GIT_OBJECT_DIRECTORY',
|
|
825
|
+
'GIT_PREFIX',
|
|
826
|
+
'GIT_REPLACE_REF_BASE',
|
|
827
|
+
'GIT_SHALLOW_FILE',
|
|
828
|
+
'GIT_WORK_TREE',
|
|
829
|
+
])
|
|
830
|
+
|
|
831
|
+
function nestedRepoEnv(): Record<string, string> {
|
|
832
|
+
const env = gitEnv()
|
|
833
|
+
for (const name of LOCAL_REPO_ENV) delete env[name]
|
|
834
|
+
return env
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The environment a listing of `dir` runs in: `nestedRepoEnv` when a
|
|
839
|
+
* location variable is set and `dir` sits in a repository nested below
|
|
840
|
+
* `workspaceRoot` (a `.git` at it or between), whose git must not read the
|
|
841
|
+
* workspace repository's index; git's own environment otherwise.
|
|
842
|
+
*/
|
|
843
|
+
export function listingEnv(dir: string, workspaceRoot: string): Record<string, string> {
|
|
844
|
+
if (![...LOCAL_REPO_ENV].some((name) => process.env[name] !== undefined)) return gitEnv()
|
|
845
|
+
for (let at = dir; at !== workspaceRoot && at.startsWith(workspaceRoot); at = path.dirname(at)) {
|
|
846
|
+
if (existsSync(path.join(at, '.git'))) return nestedRepoEnv()
|
|
847
|
+
if (path.dirname(at) === at) break
|
|
848
|
+
}
|
|
849
|
+
return gitEnv()
|
|
850
|
+
}
|
|
851
|
+
|
|
752
852
|
/** Every file and symlink under `dir`, relative and `/`-separated; a `.git` is skipped, a link not followed. */
|
|
753
853
|
function walkFiles(dir: string, rel = ''): string[] {
|
|
754
854
|
let entries
|
|
@@ -1216,10 +1316,30 @@ function gitVarList(listing: string): Map<string, string> {
|
|
|
1216
1316
|
for (const line of listing.split('\n')) {
|
|
1217
1317
|
const eq = line.indexOf('=')
|
|
1218
1318
|
if (eq > 0) vars.set(line.slice(0, eq), line.slice(eq + 1))
|
|
1319
|
+
// A bare key (`[core] fileMode` with no `=`) is listed without one, and
|
|
1320
|
+
// git reads it as true.
|
|
1321
|
+
else if (eq === -1 && /^[a-z]+\./i.test(line)) vars.set(line, 'true')
|
|
1219
1322
|
}
|
|
1220
1323
|
return vars
|
|
1221
1324
|
}
|
|
1222
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* A boolean config value as git reads it: `true`/`yes`/`on`, `false`/
|
|
1328
|
+
* `no`/`off` and the empty value, or an integer (`k`/`m`/`g` suffix
|
|
1329
|
+
* allowed) that is true when nonzero. Only `false`/`no`/`off`/`0` were
|
|
1330
|
+
* taken, so `trustctime =` or `fileMode = 00` kept index OIDs trusted
|
|
1331
|
+
* while git judged by the weaker stat. Undefined when unset or not a
|
|
1332
|
+
* boolean git takes.
|
|
1333
|
+
*/
|
|
1334
|
+
function gitBool(v: string | undefined): boolean | undefined {
|
|
1335
|
+
if (v === undefined) return undefined
|
|
1336
|
+
const t = v.trim().toLowerCase()
|
|
1337
|
+
if (t === 'true' || t === 'yes' || t === 'on') return true
|
|
1338
|
+
if (t === 'false' || t === 'no' || t === 'off' || t === '') return false
|
|
1339
|
+
const n = /^([-+]?\d+)[kmg]?$/.exec(t)
|
|
1340
|
+
return n === null ? undefined : Number(n[1]) !== 0
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1223
1343
|
/**
|
|
1224
1344
|
* `true` when git ignores the executable bit (`core.fileMode=false`, the
|
|
1225
1345
|
* default on WSL's DrvFs and what `git init` writes on a filesystem without
|
|
@@ -1227,8 +1347,7 @@ function gitVarList(listing: string): Map<string, string> {
|
|
|
1227
1347
|
* says nothing about the worktree.
|
|
1228
1348
|
*/
|
|
1229
1349
|
function fileModeIgnored(gitVars: string): boolean {
|
|
1230
|
-
|
|
1231
|
-
return v === 'false' || v === 'no' || v === 'off' || v === '0'
|
|
1350
|
+
return gitBool(gitVarList(gitVars).get('core.filemode')) === false
|
|
1232
1351
|
}
|
|
1233
1352
|
|
|
1234
1353
|
/**
|
|
@@ -1265,21 +1384,14 @@ function restampModes(trusted: Map<string, string>, workspaceRoot: string): void
|
|
|
1265
1384
|
*/
|
|
1266
1385
|
export function gitStatWeakened(gitVars: string): boolean {
|
|
1267
1386
|
const vars = gitVarList(gitVars)
|
|
1268
|
-
const trustctime = vars.get('core.trustctime')?.trim().toLowerCase()
|
|
1269
1387
|
const checkStat = vars.get('core.checkstat')?.trim().toLowerCase()
|
|
1270
|
-
return (
|
|
1271
|
-
trustctime === 'false' ||
|
|
1272
|
-
trustctime === 'no' ||
|
|
1273
|
-
trustctime === 'off' ||
|
|
1274
|
-
trustctime === '0' ||
|
|
1275
|
-
checkStat === 'minimal'
|
|
1276
|
-
)
|
|
1388
|
+
return gitBool(vars.get('core.trustctime')) === false || checkStat === 'minimal'
|
|
1277
1389
|
}
|
|
1278
1390
|
|
|
1279
1391
|
/** `true` when git may rewrite bytes for EVERY auto-detected text file. */
|
|
1280
1392
|
export function autocrlfConverts(gitVars: string): boolean {
|
|
1281
|
-
const v = gitVarList(gitVars).get('core.autocrlf')
|
|
1282
|
-
return v === '
|
|
1393
|
+
const v = gitVarList(gitVars).get('core.autocrlf')
|
|
1394
|
+
return v?.trim().toLowerCase() === 'input' || gitBool(v) === true
|
|
1283
1395
|
}
|
|
1284
1396
|
|
|
1285
1397
|
/**
|
|
@@ -1433,6 +1545,8 @@ export interface GitEnumeration {
|
|
|
1433
1545
|
scopes: readonly string[]
|
|
1434
1546
|
/** Workspace-relative path → index OID, for files whose worktree state matched the index. */
|
|
1435
1547
|
trusted: Map<string, string>
|
|
1548
|
+
/** Tracked paths git does not look at on disk (assume-unchanged, skip-worktree), workspace-relative. */
|
|
1549
|
+
flagged: readonly string[]
|
|
1436
1550
|
/** Whether the worktree had uncommitted changes; null when `git status` failed. */
|
|
1437
1551
|
dirty: boolean | null
|
|
1438
1552
|
/** `git var -l`'s listing (merged config, then git's variables); null when it failed. */
|
|
@@ -1515,6 +1629,19 @@ function hasRacyEntry(index: Uint8Array, mtimeSec: number, oidBytes: number): bo
|
|
|
1515
1629
|
/** A private copy a run older than this may still have handed to `status`. */
|
|
1516
1630
|
const STALE_INDEX_MS = 60_000
|
|
1517
1631
|
|
|
1632
|
+
/** Whether `status` should read a refreshed copy of the index (`refreshedIndex`). */
|
|
1633
|
+
function racyIndex(
|
|
1634
|
+
read: { bytes: Uint8Array; st: Stats },
|
|
1635
|
+
objectFormat: 'sha1' | 'sha256',
|
|
1636
|
+
): boolean {
|
|
1637
|
+
if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return false
|
|
1638
|
+
return hasRacyEntry(
|
|
1639
|
+
read.bytes,
|
|
1640
|
+
Math.floor(read.st.mtimeMs / 1000),
|
|
1641
|
+
objectFormat === 'sha256' ? 32 : 20,
|
|
1642
|
+
)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1518
1645
|
/**
|
|
1519
1646
|
* The index `status` reads when the real one holds a racily clean entry: a
|
|
1520
1647
|
* copy refreshed once and kept beside it, named by the real index's bytes.
|
|
@@ -1527,18 +1654,13 @@ const STALE_INDEX_MS = 60_000
|
|
|
1527
1654
|
async function refreshedIndex(
|
|
1528
1655
|
indexFile: string,
|
|
1529
1656
|
read: { bytes: Uint8Array; hash: string; st: Stats },
|
|
1530
|
-
objectFormat: 'sha1' | 'sha256',
|
|
1531
1657
|
spawnGit: (
|
|
1532
1658
|
args: string[],
|
|
1533
1659
|
stdin?: string,
|
|
1534
1660
|
env?: Record<string, string>,
|
|
1535
1661
|
) => Promise<GitRun | null>,
|
|
1536
1662
|
): Promise<string | undefined> {
|
|
1537
|
-
if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return undefined
|
|
1538
1663
|
const { bytes, hash, st } = read
|
|
1539
|
-
if (!hasRacyEntry(bytes, Math.floor(st.mtimeMs / 1000), objectFormat === 'sha256' ? 32 : 20)) {
|
|
1540
|
-
return undefined
|
|
1541
|
-
}
|
|
1542
1664
|
const dir = path.dirname(indexFile)
|
|
1543
1665
|
const name = `vx-index-${hash}`
|
|
1544
1666
|
const copy = path.join(dir, name)
|
|
@@ -1640,15 +1762,6 @@ export async function startGitEnumeration(
|
|
|
1640
1762
|
}
|
|
1641
1763
|
}
|
|
1642
1764
|
const startedAtMs = Date.now()
|
|
1643
|
-
// Four spawns at most (the rev-parse is memoized per process), one
|
|
1644
|
-
// worktree walk. `ls-files -s -v` reads the INDEX only
|
|
1645
|
-
// (~9 ms on a 1000-project tree) and answers two questions at once: every
|
|
1646
|
-
// tracked path's OID and its cache-state flag. `status -uall` is the one
|
|
1647
|
-
// command that walks the worktree, and it answers two as well: which
|
|
1648
|
-
// tracked paths are dirty AND which files are untracked. Asking
|
|
1649
|
-
// `ls-files --others` for the untracked set walked the same tree a second
|
|
1650
|
-
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1651
|
-
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
|
|
1652
1765
|
// The gate for whether a clean filter can rewrite bytes between the
|
|
1653
1766
|
// index and the worktree: git's merged config (`core.autocrlf`) and,
|
|
1654
1767
|
// from git 2.42, the attributes files it reads outside the tree
|
|
@@ -1657,7 +1770,7 @@ export async function startGitEnumeration(
|
|
|
1657
1770
|
// 1.32 ms, min of 30), which could not name the default global file.
|
|
1658
1771
|
// Not foldable into the rev-parse below: rev-parse prints no config.
|
|
1659
1772
|
const varsRun = spawnGit(['var', '-l'])
|
|
1660
|
-
// Asked while
|
|
1773
|
+
// Asked while `var` runs, from the memo the file hasher reads
|
|
1661
1774
|
// too (`repoFacts`). `prefix` is the repo→workspace path (empty when the
|
|
1662
1775
|
// workspace root IS the git root): `ls-files` prints cwd(workspace)-
|
|
1663
1776
|
// relative paths but `status` prints repo-root-relative ones, so when the
|
|
@@ -1683,15 +1796,39 @@ export async function startGitEnumeration(
|
|
|
1683
1796
|
facts === null || facts.indexFile === ''
|
|
1684
1797
|
? undefined
|
|
1685
1798
|
: path.resolve(workspaceRoot, facts.indexFile)
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1799
|
+
// `ls-files` and `status` each read the index on their own. A write
|
|
1800
|
+
// between them (an unstage, a checkout) paired one index's OIDs with the
|
|
1801
|
+
// other's verdict: a `git reset` of a staged edit made the file clean to
|
|
1802
|
+
// `status` while `ls-files` had listed the staged blob, and that blob
|
|
1803
|
+
// keyed the committed bytes on disk. The index is stamped before the
|
|
1804
|
+
// listing and after the status; an index that moved trusts no OID.
|
|
1805
|
+
const indexStamp = (): string | undefined => {
|
|
1806
|
+
if (indexFile === undefined) return undefined
|
|
1807
|
+
const st = lstatSync(indexFile, { throwIfNoEntry: false })
|
|
1808
|
+
return st === undefined ? undefined : `${st.ino}:${st.ctimeMs}:${st.size}`
|
|
1809
|
+
}
|
|
1810
|
+
const stampBefore = indexStamp()
|
|
1811
|
+
// Four spawns at most (the rev-parse is memoized per process), one
|
|
1812
|
+
// worktree walk. `ls-files -s -v` reads the INDEX only
|
|
1813
|
+
// (~9 ms on a 1000-project tree) and answers two questions at once: every
|
|
1814
|
+
// tracked path's OID and its cache-state flag. `status -uall` is the one
|
|
1815
|
+
// command that walks the worktree, and it answers two as well: which
|
|
1816
|
+
// tracked paths are dirty AND which files are untracked. Asking
|
|
1817
|
+
// `ls-files --others` for the untracked set walked the same tree a second
|
|
1818
|
+
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1819
|
+
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
|
|
1820
|
+
// Read synchronously: `status` spawns once the read is in, and an async
|
|
1821
|
+
// read's answer waited for the main thread to yield, behind the workspace
|
|
1822
|
+
// config and discovery (~5 ms of a 1,200-task warm run's wait for git).
|
|
1823
|
+
const readIndex = ():
|
|
1824
|
+
| { bytes: Uint8Array; hash: string; st: Stats; key: string; stamp: string }
|
|
1825
|
+
| undefined => {
|
|
1689
1826
|
if (indexFile === undefined) return undefined
|
|
1690
1827
|
try {
|
|
1691
1828
|
// Stat before the read: a write between them leaves an mtime older
|
|
1692
1829
|
// than the bytes, which only makes more entries racy.
|
|
1693
1830
|
const st = lstatSync(indexFile)
|
|
1694
|
-
const bytes =
|
|
1831
|
+
const bytes = readFileSync(indexFile)
|
|
1695
1832
|
const hash = xxh3hex(bytes)
|
|
1696
1833
|
return {
|
|
1697
1834
|
bytes,
|
|
@@ -1704,38 +1841,33 @@ export async function startGitEnumeration(
|
|
|
1704
1841
|
return undefined
|
|
1705
1842
|
}
|
|
1706
1843
|
}
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
)
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
env,
|
|
1735
|
-
)
|
|
1736
|
-
}),
|
|
1737
|
-
varsRun,
|
|
1738
|
-
])
|
|
1844
|
+
const read = readIndex()
|
|
1845
|
+
// `--ignored=matching` names an ignored path without descending into an
|
|
1846
|
+
// ignored directory: git applies an ignored `.gitattributes` as it does
|
|
1847
|
+
// any other, and the filter gate below must see it (A-19).
|
|
1848
|
+
// `--no-renames`: a deletion paired as an unmerged path's rename source
|
|
1849
|
+
// prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
|
|
1850
|
+
const spawnStatus = (index: string | undefined) =>
|
|
1851
|
+
spawnGit(
|
|
1852
|
+
[
|
|
1853
|
+
'status',
|
|
1854
|
+
'--porcelain',
|
|
1855
|
+
'-z',
|
|
1856
|
+
'-uall',
|
|
1857
|
+
'--ignored=matching',
|
|
1858
|
+
'--no-renames',
|
|
1859
|
+
'--',
|
|
1860
|
+
...pathspecs,
|
|
1861
|
+
],
|
|
1862
|
+
undefined,
|
|
1863
|
+
index === undefined ? undefined : { GIT_INDEX_FILE: index },
|
|
1864
|
+
)
|
|
1865
|
+
// An index with no racy entry is read as it is: `status` spawns now.
|
|
1866
|
+
const statusRun =
|
|
1867
|
+
read === undefined || indexFile === undefined || !racyIndex(read, facts!.objectFormat)
|
|
1868
|
+
? spawnStatus(undefined)
|
|
1869
|
+
: refreshedIndex(indexFile, read, spawnGit).then(spawnStatus)
|
|
1870
|
+
const running = Promise.all([listing, statusRun, varsRun])
|
|
1739
1871
|
const gitPrefix = facts?.prefix ?? ''
|
|
1740
1872
|
const gitDir = facts?.commonDir ?? ''
|
|
1741
1873
|
const ls = await listing
|
|
@@ -1750,12 +1882,12 @@ export async function startGitEnumeration(
|
|
|
1750
1882
|
? spawnGit(CHECK_ATTR, [...parsedLs.oids.keys()].join('\0'))
|
|
1751
1883
|
: undefined
|
|
1752
1884
|
const [, status, vars] = await running
|
|
1885
|
+
const indexMoved = indexStamp() !== stampBefore
|
|
1753
1886
|
// The key stands for the index `ls-files` listed only when that index
|
|
1754
1887
|
// was written before the enumeration began (git writes it by rename, so
|
|
1755
1888
|
// the stamp moves with any write) and is still in place after it: a
|
|
1756
1889
|
// `git add` between the listing and the read paired one index's verdict
|
|
1757
1890
|
// with the other's entries. Otherwise this run asks for no verdict.
|
|
1758
|
-
const read = await indexKeyRead
|
|
1759
1891
|
let indexKey: string | undefined
|
|
1760
1892
|
if (
|
|
1761
1893
|
read !== undefined &&
|
|
@@ -1771,13 +1903,8 @@ export async function startGitEnumeration(
|
|
|
1771
1903
|
if (ls === null) {
|
|
1772
1904
|
throw gitSpawnRefusal(workspaceRoot)
|
|
1773
1905
|
}
|
|
1774
|
-
if (parsedLs === undefined)
|
|
1775
|
-
|
|
1776
|
-
throw new UserError(
|
|
1777
|
-
`vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
|
|
1778
|
-
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
1779
|
-
)
|
|
1780
|
-
}
|
|
1906
|
+
if (parsedLs === undefined)
|
|
1907
|
+
throw gitFailure(workspaceRoot, 'ls-files', ls.exitCode, ls.stderr.trim())
|
|
1781
1908
|
const { files: tracked, oids, flagged, gitlinks, undecodable } = parsedLs
|
|
1782
1909
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
1783
1910
|
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
@@ -1808,7 +1935,7 @@ export async function startGitEnumeration(
|
|
|
1808
1935
|
// this same status spawn so `run()` needs no second `git status`.
|
|
1809
1936
|
// null when the status spawn failed (non-repo / git error).
|
|
1810
1937
|
const worktreeDirty = dirty === null ? null : dirty.size > 0
|
|
1811
|
-
const trusted = dirty === null ? new Map<string, string>() : oids
|
|
1938
|
+
const trusted = dirty === null || indexMoved ? new Map<string, string>() : oids
|
|
1812
1939
|
if (dirty !== null) {
|
|
1813
1940
|
for (const rel of dirty) trusted.delete(rel)
|
|
1814
1941
|
}
|
|
@@ -1856,6 +1983,7 @@ export async function startGitEnumeration(
|
|
|
1856
1983
|
tracked: gitlinks.size === 0 ? tracked : tracked.concat([...gitlinks]),
|
|
1857
1984
|
scopes: pathspecs,
|
|
1858
1985
|
trusted,
|
|
1986
|
+
flagged: [...flagged],
|
|
1859
1987
|
dirty: worktreeDirty,
|
|
1860
1988
|
vars: vars !== null && vars.exitCode === 0 ? vars.stdout : null,
|
|
1861
1989
|
changed: dirty === null ? null : [...dirty, ...untracked],
|
|
@@ -1871,6 +1999,10 @@ export async function startGitEnumeration(
|
|
|
1871
1999
|
async blobs() {
|
|
1872
2000
|
const run = await spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs])
|
|
1873
2001
|
if (run === null || run.exitCode !== 0) return null
|
|
2002
|
+
// Sizes from an index written since the listing judge entries the
|
|
2003
|
+
// trusted OIDs did not come from: an add there rewrote a resized
|
|
2004
|
+
// entry to one that fits, and the listed OID stood (A-60).
|
|
2005
|
+
if (indexStamp() !== stampBefore) return null
|
|
1874
2006
|
const debug = splitLsDebug(run)
|
|
1875
2007
|
const indexed = new Map<string, { oid: string; size: number }>()
|
|
1876
2008
|
parseLsFilesOutput(debug.records, debug.undecodable, { sizes: debug.sizes, indexed })
|
package/src/cache/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ export {
|
|
|
40
40
|
export { ABSENT_INPUT, absentOr, CACHE_VERSION, foldHead } from './key-fold.js'
|
|
41
41
|
export type { FoldMemo } from './fold-memo.js'
|
|
42
42
|
export { measureFsClockLag, setFsClockLag } from './fs-clock.js'
|
|
43
|
-
export { filesBehind } from './linked-inputs.js'
|
|
43
|
+
export { behindLink, filesBehind } from './linked-inputs.js'
|
|
44
44
|
export {
|
|
45
45
|
addedInput,
|
|
46
46
|
type InputListing,
|
|
@@ -57,6 +57,7 @@ export {
|
|
|
57
57
|
resolveOutputs,
|
|
58
58
|
outputWalkTakes,
|
|
59
59
|
resolveWorkspaceOutputs,
|
|
60
|
+
resolveWorkspaceOutputTree,
|
|
60
61
|
sweepRestoreTemps,
|
|
61
62
|
sweepStaging,
|
|
62
63
|
asTrees,
|
|
@@ -91,7 +92,7 @@ export {
|
|
|
91
92
|
type RemoteCacheLayer,
|
|
92
93
|
type RemoteOpenContext,
|
|
93
94
|
} from './layered-cache.js'
|
|
94
|
-
export { networkConcurrency } from './remote-lane.js'
|
|
95
|
+
export { networkConcurrency, sayRemoteFault } from './remote-lane.js'
|
|
95
96
|
export type { ResolvedRemote } from './remote-lane.js'
|
|
96
97
|
export {
|
|
97
98
|
absentPluginCache,
|