@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
package/src/cache/inputs.ts
CHANGED
|
@@ -17,12 +17,24 @@
|
|
|
17
17
|
// be a git work tree; non-git environments are not supported.
|
|
18
18
|
|
|
19
19
|
import path from 'node:path'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
chmodSync,
|
|
22
|
+
type Dirent,
|
|
23
|
+
lstatSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
realpathSync,
|
|
26
|
+
rmdirSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
statSync,
|
|
29
|
+
} from 'node:fs'
|
|
21
30
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
31
|
import type { InputsConfig, TaskConfig } from '../config.js'
|
|
23
32
|
import {
|
|
33
|
+
appendTail,
|
|
34
|
+
createTail,
|
|
24
35
|
literalPath,
|
|
25
36
|
anyTaskGlob,
|
|
37
|
+
tailText,
|
|
26
38
|
asTrees,
|
|
27
39
|
isExecutableMissing,
|
|
28
40
|
isInstalledPath,
|
|
@@ -37,6 +49,7 @@ import {
|
|
|
37
49
|
slashBraceExpansions,
|
|
38
50
|
braceAlternatives,
|
|
39
51
|
ESSENTIAL_ENV,
|
|
52
|
+
POST_EXIT_DRAIN_MS,
|
|
40
53
|
splitNegations,
|
|
41
54
|
staticPrefix,
|
|
42
55
|
stripTrailingSlash,
|
|
@@ -45,7 +58,7 @@ import {
|
|
|
45
58
|
writableUnder,
|
|
46
59
|
withoutWorkspaceBins,
|
|
47
60
|
} from '../util/index.js'
|
|
48
|
-
import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
|
|
61
|
+
import { GitFilesCache, listingEnv, newFilesIgnoredIn, runGitLsFiles } from './git-inputs.js'
|
|
49
62
|
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
50
63
|
|
|
51
64
|
// The git side lives in git-inputs.ts; its whole public surface is
|
|
@@ -106,13 +119,18 @@ const ALWAYS_IGNORE = [
|
|
|
106
119
|
'.vx/dependencies.lock.json',
|
|
107
120
|
// Where restores stage (archive.ts): vx's own, never an input.
|
|
108
121
|
'.vx/tmp/**',
|
|
122
|
+
// The temps the locks and review copies are written to and renamed from
|
|
123
|
+
// (`writeAllOrNone`, `writeNoFollow`): a killed write leaves one.
|
|
124
|
+
'.vx/projects.lock.json.tmp-*',
|
|
125
|
+
'.vx/dependencies.lock.json.tmp-*',
|
|
126
|
+
'vx.lock.json.tmp-*',
|
|
109
127
|
]
|
|
110
128
|
|
|
111
129
|
/**
|
|
112
130
|
* `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
|
|
113
131
|
* those globs cross into other projects, and each one's `vx.lock.json` is vx's.
|
|
114
132
|
*/
|
|
115
|
-
const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
|
|
133
|
+
const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json', '**/vx.lock.json.tmp-*']
|
|
116
134
|
|
|
117
135
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
118
136
|
|
|
@@ -214,7 +232,18 @@ export type ProjectFilesCache = Map<
|
|
|
214
232
|
* relative to it) and everything `resolveFiles` keys its memo on.
|
|
215
233
|
* Undefined when only resolving the files can say.
|
|
216
234
|
*/
|
|
217
|
-
export function filesToken(
|
|
235
|
+
export function filesToken(
|
|
236
|
+
args: Pick<
|
|
237
|
+
ResolveInputsArgs,
|
|
238
|
+
| 'projectDir'
|
|
239
|
+
| 'workspaceRoot'
|
|
240
|
+
| 'inputs'
|
|
241
|
+
| 'ownOutputs'
|
|
242
|
+
| 'ownWorkspaceOutputs'
|
|
243
|
+
| 'nestedProjectDirs'
|
|
244
|
+
| 'gitFilesCache'
|
|
245
|
+
>,
|
|
246
|
+
): string | undefined {
|
|
218
247
|
if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
|
|
219
248
|
const plan = filesPlan(args.inputs?.files, args.ownOutputs)
|
|
220
249
|
const cache = args.gitFilesCache
|
|
@@ -299,6 +328,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
299
328
|
args.runtimeScope === undefined
|
|
300
329
|
? `${args.projectDir}\0`
|
|
301
330
|
: `${args.projectDir}\0${args.runtimeScope}\0`,
|
|
331
|
+
'runtime',
|
|
302
332
|
),
|
|
303
333
|
resolveRuntimeValues(
|
|
304
334
|
wsRuntimeDecl,
|
|
@@ -307,6 +337,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
307
337
|
args.workspaceRoot,
|
|
308
338
|
args.workspaceRuntimeCache,
|
|
309
339
|
args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
|
|
340
|
+
'workspaceRuntime',
|
|
310
341
|
),
|
|
311
342
|
])
|
|
312
343
|
return {
|
|
@@ -579,6 +610,7 @@ export function answerRuntime(
|
|
|
579
610
|
[path.join(workspaceRoot, 'node_modules', '.bin')],
|
|
580
611
|
workspaceRoot,
|
|
581
612
|
undefined,
|
|
613
|
+
'workspaceRuntime',
|
|
582
614
|
)
|
|
583
615
|
: runRuntimeCommand(
|
|
584
616
|
command,
|
|
@@ -586,6 +618,7 @@ export function answerRuntime(
|
|
|
586
618
|
projectBinDirs(projectDir, workspaceRoot),
|
|
587
619
|
workspaceRoot,
|
|
588
620
|
undefined,
|
|
621
|
+
'runtime',
|
|
589
622
|
)
|
|
590
623
|
}
|
|
591
624
|
|
|
@@ -593,7 +626,21 @@ function resolveEnvValues(
|
|
|
593
626
|
names: readonly string[],
|
|
594
627
|
source: NodeJS.ProcessEnv,
|
|
595
628
|
): Array<[string, string | undefined]> {
|
|
596
|
-
return [...names].sort().map((name) =>
|
|
629
|
+
return [...names].sort().map((name) => {
|
|
630
|
+
const value = source[name]
|
|
631
|
+
// Bun decodes the environment lossily and offers no raw bytes on every
|
|
632
|
+
// platform (Linux's /proc/self/environ misses what vx sets itself), so
|
|
633
|
+
// Latin-1 é and è both read as U+FFFD and replayed each other's build.
|
|
634
|
+
// A value that truly holds U+FFFD is refused with them: the two read
|
|
635
|
+
// alike. The value is never shown; it may be a secret.
|
|
636
|
+
if (value?.includes('�') === true) {
|
|
637
|
+
throw new UserError(
|
|
638
|
+
`inputs.env ${name} holds U+FFFD: its value is not UTF-8, or holds that character, ` +
|
|
639
|
+
`and a lossy decode keys every invalid byte alike. Set it as UTF-8.`,
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
return [name, value]
|
|
643
|
+
})
|
|
597
644
|
}
|
|
598
645
|
|
|
599
646
|
/**
|
|
@@ -620,7 +667,9 @@ async function runRuntimeCommand(
|
|
|
620
667
|
binDirs: readonly string[],
|
|
621
668
|
workspaceRoot: string,
|
|
622
669
|
owner: RuntimeMemo | undefined,
|
|
670
|
+
field: RuntimeField,
|
|
623
671
|
): Promise<string> {
|
|
672
|
+
const probe = `inputs.${field} probe`
|
|
624
673
|
// As a task's (exec/env.ts): no member's bin inherited from `npm run`.
|
|
625
674
|
const ambient = withoutWorkspaceBins(process.env['PATH'] ?? '', workspaceRoot)
|
|
626
675
|
// As the task's PATH does (exec/env.ts): a dir holding the delimiter
|
|
@@ -648,10 +697,10 @@ async function runRuntimeCommand(
|
|
|
648
697
|
// the shell, not the command (item 244).
|
|
649
698
|
if (isExecutableMissing(err)) {
|
|
650
699
|
throw new UserError(
|
|
651
|
-
|
|
700
|
+
`${probe} could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
|
|
652
701
|
)
|
|
653
702
|
}
|
|
654
|
-
throw new UserError(
|
|
703
|
+
throw new UserError(`${probe} failed to spawn: ${command} (cwd: ${cwd})`)
|
|
655
704
|
}
|
|
656
705
|
liveProbes.add(proc)
|
|
657
706
|
let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
|
|
@@ -662,22 +711,41 @@ async function runRuntimeCommand(
|
|
|
662
711
|
}
|
|
663
712
|
killProbesOnExit()
|
|
664
713
|
let stdout, stderr, exitCode
|
|
714
|
+
const ac = new AbortController()
|
|
665
715
|
try {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
proc.exited,
|
|
716
|
+
const streams = Promise.all([
|
|
717
|
+
probeBytes(proc.stdout, ac.signal),
|
|
718
|
+
probeBytes(proc.stderr, ac.signal),
|
|
670
719
|
])
|
|
720
|
+
exitCode = await proc.exited
|
|
721
|
+
// A child the shell left holding the pipes (`sleep 6 & echo x`) kept
|
|
722
|
+
// them open past the shell's exit, and the run waited for the child. As
|
|
723
|
+
// a task's (exec/runner.ts): a short drain, then the reads stop and the
|
|
724
|
+
// group goes. What was read is what the shell printed, so the key folds
|
|
725
|
+
// it; what the child prints later is no part of the answer.
|
|
726
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
727
|
+
const cut = new Promise<void>((resolve) => {
|
|
728
|
+
timer = setTimeout(resolve, POST_EXIT_DRAIN_MS)
|
|
729
|
+
})
|
|
730
|
+
await Promise.race([streams, cut])
|
|
731
|
+
clearTimeout(timer)
|
|
732
|
+
ac.abort()
|
|
733
|
+
killGroup(proc.pid)
|
|
734
|
+
;[stdout, stderr] = await streams
|
|
671
735
|
} finally {
|
|
672
736
|
liveProbes.delete(proc)
|
|
673
737
|
mine?.delete(proc)
|
|
674
738
|
}
|
|
675
739
|
if (exitCode !== 0) {
|
|
676
740
|
const lossy = new TextDecoder()
|
|
677
|
-
|
|
741
|
+
// Capped as a persistent task's log is: a chatty probe put megabytes
|
|
742
|
+
// into one error line. The tail is kept; the failure is usually last.
|
|
743
|
+
const tail = createTail()
|
|
744
|
+
appendTail(tail, `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim())
|
|
745
|
+
const shown = tailText(tail)
|
|
746
|
+
const lost = tail.dropped === 0 ? '' : `… ${tail.dropped} earlier characters dropped\n`
|
|
678
747
|
throw new UserError(
|
|
679
|
-
|
|
680
|
-
(shown ? `\n${shown}` : ''),
|
|
748
|
+
`${probe} exited ${exitCode}: ${command} (cwd: ${cwd})` + (shown ? `\n${lost}${shown}` : ''),
|
|
681
749
|
)
|
|
682
750
|
}
|
|
683
751
|
let out: string
|
|
@@ -689,7 +757,7 @@ async function runRuntimeCommand(
|
|
|
689
757
|
// A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
|
|
690
758
|
// folded the same output and replayed each other's build.
|
|
691
759
|
throw new UserError(
|
|
692
|
-
|
|
760
|
+
`${probe} printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
|
|
693
761
|
`Pipe it through a hash or od.`,
|
|
694
762
|
)
|
|
695
763
|
}
|
|
@@ -722,6 +790,8 @@ const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
|
|
|
722
790
|
let probeExitHooked = false
|
|
723
791
|
|
|
724
792
|
type RuntimeMemo = Map<string, Promise<string>>
|
|
793
|
+
/** The field a probe is declared under, as its errors name it. */
|
|
794
|
+
type RuntimeField = 'runtime' | 'workspaceRuntime'
|
|
725
795
|
/** The probes each run started, by the run's memo: one run's stop is not another's. */
|
|
726
796
|
const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
|
|
727
797
|
|
|
@@ -736,24 +806,51 @@ const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
|
|
|
736
806
|
export function stopRuntimeProbes(...memos: readonly RuntimeMemo[]): void {
|
|
737
807
|
for (const memo of memos) {
|
|
738
808
|
for (const p of probesOf.get(memo) ?? []) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
809
|
+
killGroup(p.pid)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/** SIGKILL a probe's group, its own since the spawn (`detached`). */
|
|
814
|
+
function killGroup(pid: number): void {
|
|
815
|
+
try {
|
|
816
|
+
process.kill(-pid, 'SIGKILL')
|
|
817
|
+
} catch {
|
|
818
|
+
// the group is gone
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* A probe's stream, read to its end or until `signal`: then the pending
|
|
824
|
+
* read is cancelled and what was read is returned. `new Response().bytes()`
|
|
825
|
+
* offers no way out while a leftover child holds the pipe.
|
|
826
|
+
*/
|
|
827
|
+
async function probeBytes(
|
|
828
|
+
stream: ReadableStream<Uint8Array>,
|
|
829
|
+
signal: AbortSignal,
|
|
830
|
+
): Promise<Uint8Array> {
|
|
831
|
+
const reader = stream.getReader()
|
|
832
|
+
const onAbort = (): void => void reader.cancel().catch(() => {})
|
|
833
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
834
|
+
const parts: Uint8Array[] = []
|
|
835
|
+
try {
|
|
836
|
+
while (true) {
|
|
837
|
+
const { value, done } = await reader.read()
|
|
838
|
+
if (done) break
|
|
839
|
+
parts.push(value)
|
|
744
840
|
}
|
|
841
|
+
} finally {
|
|
842
|
+
signal.removeEventListener('abort', onAbort)
|
|
843
|
+
reader.releaseLock()
|
|
745
844
|
}
|
|
845
|
+
return Buffer.concat(parts)
|
|
746
846
|
}
|
|
847
|
+
|
|
747
848
|
function killProbesOnExit(): void {
|
|
748
849
|
if (probeExitHooked) return
|
|
749
850
|
probeExitHooked = true
|
|
750
851
|
process.on('exit', () => {
|
|
751
852
|
for (const p of liveProbes) {
|
|
752
|
-
|
|
753
|
-
process.kill(-p.pid, 'SIGKILL')
|
|
754
|
-
} catch {
|
|
755
|
-
// the group is gone
|
|
756
|
-
}
|
|
853
|
+
killGroup(p.pid)
|
|
757
854
|
}
|
|
758
855
|
})
|
|
759
856
|
}
|
|
@@ -774,6 +871,7 @@ async function resolveRuntimeValues(
|
|
|
774
871
|
workspaceRoot: string,
|
|
775
872
|
memo: Map<string, Promise<string>> | undefined,
|
|
776
873
|
memoKeyPrefix: string,
|
|
874
|
+
field: RuntimeField,
|
|
777
875
|
): Promise<Array<[string, string]>> {
|
|
778
876
|
if (commands.length === 0) return []
|
|
779
877
|
const unique = [...new Set(commands)].sort()
|
|
@@ -782,7 +880,7 @@ async function resolveRuntimeValues(
|
|
|
782
880
|
const key = `${memoKeyPrefix}${cmd}`
|
|
783
881
|
let p = memo?.get(key)
|
|
784
882
|
if (p === undefined) {
|
|
785
|
-
p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
|
|
883
|
+
p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo, field)
|
|
786
884
|
memo?.set(key, p)
|
|
787
885
|
}
|
|
788
886
|
return [cmd, await p] as [string, string]
|
|
@@ -800,6 +898,13 @@ function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspac
|
|
|
800
898
|
return outputNever(outputs, scope).map(globFor)
|
|
801
899
|
}
|
|
802
900
|
|
|
901
|
+
/** A task's `inputs` and the directories they anchor at, for `refuseLinkedIntoInputs`. */
|
|
902
|
+
export interface OwnInputs {
|
|
903
|
+
projectDir: string
|
|
904
|
+
workspaceRoot: string
|
|
905
|
+
declared: InputsConfig | undefined
|
|
906
|
+
}
|
|
907
|
+
|
|
803
908
|
/**
|
|
804
909
|
* Resolve declared output globs (project-relative) to actual produced files.
|
|
805
910
|
* An output directory linked out of the project is refused by name (X-88):
|
|
@@ -809,6 +914,7 @@ export async function resolveOutputs(args: {
|
|
|
809
914
|
projectDir: string
|
|
810
915
|
outputs: readonly string[]
|
|
811
916
|
nestedProjectDirs: string[]
|
|
917
|
+
inputs?: OwnInputs | undefined
|
|
812
918
|
}): Promise<string[]> {
|
|
813
919
|
return (await scanOutputs(args)).files
|
|
814
920
|
}
|
|
@@ -837,21 +943,66 @@ export async function resolveOutputTree(args: {
|
|
|
837
943
|
projectDir: string
|
|
838
944
|
outputs: readonly string[]
|
|
839
945
|
nestedProjectDirs: string[]
|
|
840
|
-
|
|
946
|
+
/** The task's own inputs: an output directory linked into them is refused. */
|
|
947
|
+
inputs?: OwnInputs | undefined
|
|
948
|
+
/**
|
|
949
|
+
* Also list the FIFOs, sockets and devices the globs name: a save refuses
|
|
950
|
+
* them, a clean removes them. `'later'` leaves `special` empty and hands
|
|
951
|
+
* `findSpecial` instead, for a walk that needs them only if it restores.
|
|
952
|
+
*/
|
|
953
|
+
special?: boolean | 'later'
|
|
954
|
+
/** The walk feeds a clean or a restore (`refuseUndecodableOutput`). */
|
|
955
|
+
clearing?: boolean
|
|
956
|
+
}): Promise<OutputTree> {
|
|
841
957
|
const emptyDirs = new Set<string>()
|
|
842
|
-
const
|
|
843
|
-
|
|
958
|
+
const probe: SpecialProbe | undefined =
|
|
959
|
+
args.special === undefined || args.special === false ? undefined : {}
|
|
960
|
+
const { files } = await scanOutputs(args, emptyDirs, probe)
|
|
961
|
+
return treeOf(args.projectDir, files, emptyDirs, args.special, probe)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** What `resolveOutputTree` and its workspace twin return. */
|
|
965
|
+
type OutputTree = {
|
|
966
|
+
files: string[]
|
|
967
|
+
emptyDirs: string[]
|
|
968
|
+
special: string[]
|
|
969
|
+
findSpecial?: () => string[]
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function treeOf(
|
|
973
|
+
root: string,
|
|
974
|
+
files: string[],
|
|
975
|
+
emptyDirs: Set<string>,
|
|
976
|
+
asked: boolean | 'later' | undefined,
|
|
977
|
+
probe: SpecialProbe | undefined,
|
|
978
|
+
): OutputTree {
|
|
979
|
+
const findSpecial = (): string[] =>
|
|
980
|
+
probe?.find === undefined ? [] : containedIn(root, probe.find()).sort()
|
|
981
|
+
const tree: OutputTree = {
|
|
982
|
+
files,
|
|
983
|
+
emptyDirs: containedIn(root, [...emptyDirs]).sort(),
|
|
984
|
+
special: asked === true ? findSpecial() : [],
|
|
985
|
+
}
|
|
986
|
+
if (asked === 'later') tree.findSpecial = findSpecial
|
|
987
|
+
return tree
|
|
844
988
|
}
|
|
845
989
|
|
|
846
990
|
async function scanOutputs(
|
|
847
|
-
args: {
|
|
991
|
+
args: {
|
|
992
|
+
projectDir: string
|
|
993
|
+
outputs: readonly string[]
|
|
994
|
+
nestedProjectDirs: string[]
|
|
995
|
+
inputs?: OwnInputs | undefined
|
|
996
|
+
clearing?: boolean
|
|
997
|
+
},
|
|
848
998
|
emptyDirs?: Set<string>,
|
|
999
|
+
special?: SpecialProbe,
|
|
849
1000
|
): Promise<{ files: string[] }> {
|
|
850
1001
|
const { positive, negative } = splitNegations(args.outputs)
|
|
851
1002
|
if (positive.length === 0) return { files: [] }
|
|
852
1003
|
const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
|
|
853
1004
|
const trees = asTrees(positive)
|
|
854
|
-
refuseDirLinkedOut(args.projectDir, trees, 'project')
|
|
1005
|
+
const absent = refuseDirLinkedOut(args.projectDir, trees, 'project', args.inputs)
|
|
855
1006
|
const scanned = [
|
|
856
1007
|
...(await scanUnion(
|
|
857
1008
|
trees,
|
|
@@ -859,6 +1010,9 @@ async function scanOutputs(
|
|
|
859
1010
|
args.projectDir,
|
|
860
1011
|
inNestedProject(args.projectDir, args.nestedProjectDirs),
|
|
861
1012
|
emptyDirs,
|
|
1013
|
+
special,
|
|
1014
|
+
absent,
|
|
1015
|
+
args.clearing,
|
|
862
1016
|
)),
|
|
863
1017
|
]
|
|
864
1018
|
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
@@ -892,14 +1046,22 @@ export function committedFiles(
|
|
|
892
1046
|
* below it is an output, kept as the link (`archive.ts`). One into `.git`
|
|
893
1047
|
* or `.vx` is refused in either scope: for the root project and for
|
|
894
1048
|
* workspace outputs those are inside, and the clean deleted the
|
|
895
|
-
* repository through `dist -> .git`. One out of
|
|
896
|
-
*
|
|
1049
|
+
* repository through `dist -> .git`. One out of the root is refused in
|
|
1050
|
+
* either scope too: a workspace output's every file was dropped as outside
|
|
1051
|
+
* and an empty entry saved, while the project twin refused the task (M-65).
|
|
1052
|
+
* So is one holding an entry of the task's own `inputs`, in either scope:
|
|
1053
|
+
* X-55 read through the link, as `dist -> src` under `src/**` had the
|
|
1054
|
+
* clean delete every untracked source.
|
|
897
1055
|
*/
|
|
898
1056
|
function refuseDirLinkedOut(
|
|
899
1057
|
root: string,
|
|
900
1058
|
trees: readonly string[],
|
|
901
1059
|
scope: 'project' | 'workspace',
|
|
902
|
-
|
|
1060
|
+
inputs?: OwnInputs,
|
|
1061
|
+
): Set<string> {
|
|
1062
|
+
// The heads found absent: the scan that follows skips them without
|
|
1063
|
+
// asking again (a restore after a clean asks of every one).
|
|
1064
|
+
const absent = new Set<string>()
|
|
903
1065
|
let realRoot: string | undefined
|
|
904
1066
|
for (const tree of trees) {
|
|
905
1067
|
let at = root
|
|
@@ -913,7 +1075,10 @@ function refuseDirLinkedOut(
|
|
|
913
1075
|
} catch {
|
|
914
1076
|
break
|
|
915
1077
|
}
|
|
916
|
-
if (st === undefined)
|
|
1078
|
+
if (st === undefined) {
|
|
1079
|
+
absent.add(at)
|
|
1080
|
+
break
|
|
1081
|
+
}
|
|
917
1082
|
if (!st.isSymbolicLink()) continue
|
|
918
1083
|
let real: string
|
|
919
1084
|
try {
|
|
@@ -924,10 +1089,9 @@ function refuseDirLinkedOut(
|
|
|
924
1089
|
}
|
|
925
1090
|
realRoot ??= realOr(root)
|
|
926
1091
|
if (!isInside(realRoot, real)) {
|
|
927
|
-
if (scope === 'workspace') break
|
|
928
1092
|
throw new UserError(
|
|
929
1093
|
`${at} is a symbolic link to ${real}, outside ${root} — vx never cleans, saves ` +
|
|
930
|
-
|
|
1094
|
+
`or restores declared outputs through a link that leaves the ${scope}. Remove the ` +
|
|
931
1095
|
'link and re-run, or stop declaring outputs under it.',
|
|
932
1096
|
)
|
|
933
1097
|
}
|
|
@@ -938,8 +1102,57 @@ function refuseDirLinkedOut(
|
|
|
938
1102
|
'link and re-run, or stop declaring outputs under it.',
|
|
939
1103
|
)
|
|
940
1104
|
}
|
|
1105
|
+
if (inputs !== undefined) refuseLinkedIntoInputs(at, real, inputs)
|
|
941
1106
|
}
|
|
942
1107
|
}
|
|
1108
|
+
return absent
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Throws when an entry of `inputs` can match a path under the real
|
|
1113
|
+
* directory `real`: `files` under the project, `workspaceFiles` under the
|
|
1114
|
+
* workspace root. A link to an ancestor of the anchor holds every entry.
|
|
1115
|
+
* Negations are not subtracted: a link onto files the task reads is wrong
|
|
1116
|
+
* even where one excludes them.
|
|
1117
|
+
*/
|
|
1118
|
+
function refuseLinkedIntoInputs(at: string, real: string, inputs: OwnInputs): void {
|
|
1119
|
+
const holds = (anchor: string, globs: readonly string[] | undefined): boolean => {
|
|
1120
|
+
const { positive } = splitNegations(globs ?? [])
|
|
1121
|
+
if (positive.length === 0) return false
|
|
1122
|
+
const realAnchor = realOr(anchor)
|
|
1123
|
+
const rel = relPosix(realAnchor, real)
|
|
1124
|
+
if (rel === '..' || rel.startsWith('../')) return isInside(real, realAnchor)
|
|
1125
|
+
const dir = rel === '' ? [] : rel.split('/')
|
|
1126
|
+
return asTrees(positive).some((g) => reachesBelow(g.split('/'), dir))
|
|
1127
|
+
}
|
|
1128
|
+
if (
|
|
1129
|
+
!holds(inputs.projectDir, inputs.declared?.files) &&
|
|
1130
|
+
!holds(inputs.workspaceRoot, inputs.declared?.workspaceFiles)
|
|
1131
|
+
)
|
|
1132
|
+
return
|
|
1133
|
+
throw new UserError(
|
|
1134
|
+
`${at} is a symbolic link into ${real}, which the task's own inputs read — vx removes a ` +
|
|
1135
|
+
`task's outputs before it runs, so the task would delete its own sources. Remove the ` +
|
|
1136
|
+
'link and re-run, or stop declaring outputs under it.',
|
|
1137
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1138
|
+
)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Whether the glob split into `segments` can match a path below the
|
|
1143
|
+
* directory `dir` (split too): each of `dir`'s names matches its segment
|
|
1144
|
+
* and the glob goes on past them, or a `**` comes first. `**\/*.ts` and
|
|
1145
|
+
* `src/**` reach below `src/lib`; `src/**` and `*.json` do not reach below
|
|
1146
|
+
* `build-out`.
|
|
1147
|
+
*/
|
|
1148
|
+
function reachesBelow(segments: readonly string[], dir: readonly string[]): boolean {
|
|
1149
|
+
for (let i = 0; i < dir.length; i++) {
|
|
1150
|
+
const seg = segments[i]
|
|
1151
|
+
if (seg === undefined) return false
|
|
1152
|
+
if (seg === '**') return true
|
|
1153
|
+
if (!globFor(seg).match(dir[i]!)) return false
|
|
1154
|
+
}
|
|
1155
|
+
return segments.length > dir.length
|
|
943
1156
|
}
|
|
944
1157
|
|
|
945
1158
|
/**
|
|
@@ -1088,6 +1301,7 @@ export async function cleanOutputs(args: {
|
|
|
1088
1301
|
projectDir: string
|
|
1089
1302
|
outputs: readonly string[]
|
|
1090
1303
|
nestedProjectDirs: string[]
|
|
1304
|
+
inputs?: OwnInputs | undefined
|
|
1091
1305
|
committed?: (abs: string) => boolean
|
|
1092
1306
|
/**
|
|
1093
1307
|
* What `resolveOutputs` returned for these same arguments, when the
|
|
@@ -1103,17 +1317,18 @@ export async function cleanOutputs(args: {
|
|
|
1103
1317
|
)
|
|
1104
1318
|
sweepRestoreTemps(args.projectDir, args.outputs, committed)
|
|
1105
1319
|
// An empty directory under the outputs is one too (a save keeps it): one
|
|
1106
|
-
// an older build left would otherwise be saved by this run.
|
|
1320
|
+
// an older build left would otherwise be saved by this run. So is a FIFO,
|
|
1321
|
+
// socket or device: a task writing a FIFO's path blocks on it forever.
|
|
1107
1322
|
const tree =
|
|
1108
1323
|
args.resolved === undefined
|
|
1109
|
-
? await resolveOutputTree(args)
|
|
1110
|
-
: { files: args.resolved, emptyDirs: [] }
|
|
1324
|
+
? await resolveOutputTree({ ...args, special: true, clearing: true })
|
|
1325
|
+
: { files: args.resolved, emptyDirs: [], special: [] }
|
|
1111
1326
|
const matched = tree.files
|
|
1112
1327
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
1113
1328
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
1114
1329
|
// A symlink is unlinked, never followed.
|
|
1115
1330
|
const removed = await removeAll(
|
|
1116
|
-
matched.filter((f) => !committed(f)),
|
|
1331
|
+
[...matched, ...tree.special].filter((f) => !committed(f)),
|
|
1117
1332
|
args.projectDir,
|
|
1118
1333
|
)
|
|
1119
1334
|
await pruneEmptiedDirs(
|
|
@@ -1254,18 +1469,42 @@ export async function stampOutputs(args: {
|
|
|
1254
1469
|
projectDir: string
|
|
1255
1470
|
outputs: readonly string[]
|
|
1256
1471
|
nestedProjectDirs: string[]
|
|
1472
|
+
inputs?: OwnInputs | undefined
|
|
1257
1473
|
}): Promise<Map<string, OutputStamp>> {
|
|
1258
1474
|
return stampFiles(await resolveOutputs(args))
|
|
1259
1475
|
}
|
|
1260
1476
|
|
|
1261
|
-
/** `stampOutputs` for root-anchored `
|
|
1477
|
+
/** `stampOutputs` for root-anchored `outputs.workspaceFiles` (A-43). */
|
|
1262
1478
|
export async function stampWorkspaceOutputs(args: {
|
|
1263
1479
|
workspaceRoot: string
|
|
1264
1480
|
outputs: readonly string[]
|
|
1481
|
+
inputs?: OwnInputs | undefined
|
|
1265
1482
|
}): Promise<Map<string, OutputStamp>> {
|
|
1266
1483
|
return stampFiles(await resolveWorkspaceOutputs(args))
|
|
1267
1484
|
}
|
|
1268
1485
|
|
|
1486
|
+
/**
|
|
1487
|
+
* The stamps of the committed files among `files`, the outputs a clean
|
|
1488
|
+
* found. No clean deletes one, so a failed attempt's change to it stays
|
|
1489
|
+
* for the retry: `committedMoved` tells.
|
|
1490
|
+
*/
|
|
1491
|
+
export function stampCommitted(
|
|
1492
|
+
files: readonly string[],
|
|
1493
|
+
committed: (abs: string) => boolean,
|
|
1494
|
+
): Map<string, OutputStamp> {
|
|
1495
|
+
return stampFiles(files.filter(committed))
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** Whether a committed file among `files` was added, removed or touched since `before` (`stampCommitted`). */
|
|
1499
|
+
export function committedMoved(
|
|
1500
|
+
files: readonly string[],
|
|
1501
|
+
committed: (abs: string) => boolean,
|
|
1502
|
+
before: ReadonlyMap<string, OutputStamp>,
|
|
1503
|
+
): boolean {
|
|
1504
|
+
const changed = changedSince(files.filter(committed), before)
|
|
1505
|
+
return changed === undefined || changed.length > 0
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1269
1508
|
function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
|
|
1270
1509
|
const out = new Map<string, OutputStamp>()
|
|
1271
1510
|
for (const f of files) {
|
|
@@ -1292,15 +1531,20 @@ function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
|
|
|
1292
1531
|
* file back, so no entry reproduces that run and none is saved.
|
|
1293
1532
|
*/
|
|
1294
1533
|
export async function ownOutputsSince(
|
|
1295
|
-
args: {
|
|
1534
|
+
args: {
|
|
1535
|
+
projectDir: string
|
|
1536
|
+
outputs: readonly string[]
|
|
1537
|
+
nestedProjectDirs: string[]
|
|
1538
|
+
inputs?: OwnInputs | undefined
|
|
1539
|
+
},
|
|
1296
1540
|
before: ReadonlyMap<string, OutputStamp>,
|
|
1297
1541
|
): Promise<string[] | undefined> {
|
|
1298
1542
|
return changedSince(await resolveOutputs(args), before)
|
|
1299
1543
|
}
|
|
1300
1544
|
|
|
1301
|
-
/** `ownOutputsSince` for root-anchored `
|
|
1545
|
+
/** `ownOutputsSince` for root-anchored `outputs.workspaceFiles` (A-43). */
|
|
1302
1546
|
export async function ownWorkspaceOutputsSince(
|
|
1303
|
-
args: { workspaceRoot: string; outputs: readonly string[] },
|
|
1547
|
+
args: { workspaceRoot: string; outputs: readonly string[]; inputs?: OwnInputs | undefined },
|
|
1304
1548
|
before: ReadonlyMap<string, OutputStamp>,
|
|
1305
1549
|
): Promise<string[] | undefined> {
|
|
1306
1550
|
return changedSince(await resolveWorkspaceOutputs(args), before)
|
|
@@ -1348,9 +1592,18 @@ const SYNC_CLEAN_MAX = 128
|
|
|
1348
1592
|
* wrote, a read-only checkout) is the environment's failure, not vx's:
|
|
1349
1593
|
* the scheduler prints any other error as an "internal error", which
|
|
1350
1594
|
* sends the reader to file a bug against a permission bit.
|
|
1595
|
+
*
|
|
1596
|
+
* A directory of this user's that withholds its own write (a task's
|
|
1597
|
+
* `chmod 555 dist/ro`, which a hit restores too) gets owner write and
|
|
1598
|
+
* search for the remove and its mode back after, as a restore's write
|
|
1599
|
+
* into it does (archive.ts `reopen`): else that task could never run or
|
|
1600
|
+
* restore again until someone chmodded the tree by hand. The prune after
|
|
1601
|
+
* reopens the same way for the subdirectories it removes (`pruneEmptiedDirs`).
|
|
1351
1602
|
*/
|
|
1352
1603
|
async function removeAll(all: readonly string[], root: string): Promise<string[]> {
|
|
1353
1604
|
const files = insideRoot(all, root)
|
|
1605
|
+
const reopened: Reopened = new Map()
|
|
1606
|
+
const reopen = (dir: string): boolean => reopenDir(dir, reopened)
|
|
1354
1607
|
const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
|
|
1355
1608
|
const rel = relPosix(root, f)
|
|
1356
1609
|
return new UserError(
|
|
@@ -1359,24 +1612,64 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
|
|
|
1359
1612
|
`by this user, or stop declaring it as an output`,
|
|
1360
1613
|
)
|
|
1361
1614
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1615
|
+
const denied = (err: unknown, f: string): boolean =>
|
|
1616
|
+
(err as NodeJS.ErrnoException).code === 'EACCES' && reopen(path.dirname(f))
|
|
1617
|
+
try {
|
|
1618
|
+
if (files.length <= SYNC_CLEAN_MAX) {
|
|
1619
|
+
for (const f of files) {
|
|
1620
|
+
try {
|
|
1621
|
+
rmSync(f, { force: true })
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
if (!denied(err, f)) throw refused(f, err as NodeJS.ErrnoException)
|
|
1624
|
+
try {
|
|
1625
|
+
rmSync(f, { force: true })
|
|
1626
|
+
} catch (again) {
|
|
1627
|
+
throw refused(f, again as NodeJS.ErrnoException)
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1368
1630
|
}
|
|
1631
|
+
return files
|
|
1369
1632
|
}
|
|
1633
|
+
await Promise.all(
|
|
1634
|
+
files.map((f) =>
|
|
1635
|
+
rm(f, { force: true })
|
|
1636
|
+
.catch((err: NodeJS.ErrnoException) => {
|
|
1637
|
+
if (!denied(err, f)) throw err
|
|
1638
|
+
return rm(f, { force: true })
|
|
1639
|
+
})
|
|
1640
|
+
.catch((err: NodeJS.ErrnoException) => {
|
|
1641
|
+
throw refused(f, err)
|
|
1642
|
+
}),
|
|
1643
|
+
),
|
|
1644
|
+
)
|
|
1370
1645
|
return files
|
|
1646
|
+
} finally {
|
|
1647
|
+
giveBack(reopened)
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/** Directories given owner write and search for a remove, with the mode each had. */
|
|
1652
|
+
type Reopened = Map<string, number>
|
|
1653
|
+
|
|
1654
|
+
/** Whether `dir` is now open to a remove: this user's, and its missing owner bits given. */
|
|
1655
|
+
function reopenDir(dir: string, reopened: Reopened): boolean {
|
|
1656
|
+
if (reopened.has(dir)) return true
|
|
1657
|
+
const st = lstatSync(dir, { throwIfNoEntry: false })
|
|
1658
|
+
if (st?.isDirectory() !== true || st.uid !== process.getuid?.()) return false
|
|
1659
|
+
if ((st.mode & 0o300) === 0o300) return false
|
|
1660
|
+
reopened.set(dir, st.mode & 0o7777)
|
|
1661
|
+
chmodSync(dir, (st.mode & 0o7777) | 0o300)
|
|
1662
|
+
return true
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function giveBack(reopened: Reopened): void {
|
|
1666
|
+
for (const [dir, mode] of reopened) {
|
|
1667
|
+
try {
|
|
1668
|
+
chmodSync(dir, mode)
|
|
1669
|
+
} catch {
|
|
1670
|
+
// gone since (the prune removed it): nothing to give back
|
|
1671
|
+
}
|
|
1371
1672
|
}
|
|
1372
|
-
await Promise.all(
|
|
1373
|
-
files.map((f) =>
|
|
1374
|
-
rm(f, { force: true }).catch((err: NodeJS.ErrnoException) => {
|
|
1375
|
-
throw refused(f, err)
|
|
1376
|
-
}),
|
|
1377
|
-
),
|
|
1378
|
-
)
|
|
1379
|
-
return files
|
|
1380
1673
|
}
|
|
1381
1674
|
|
|
1382
1675
|
/**
|
|
@@ -1444,33 +1737,53 @@ async function pruneEmptiedDirs(
|
|
|
1444
1737
|
// the memo is correct and 60 ms, because every child re-attempts the
|
|
1445
1738
|
// shared parent. This is correct at 44 ms.
|
|
1446
1739
|
let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1740
|
+
// A subdirectory of a read-only directory of this user's (`dist/x/s` under
|
|
1741
|
+
// a `chmod 555 dist/x`) is removed as `removeAll` removes a file there.
|
|
1742
|
+
const reopened: Reopened = new Map()
|
|
1743
|
+
const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
|
|
1744
|
+
const denied = (err: NodeJS.ErrnoException, dir: string): boolean =>
|
|
1745
|
+
err.code === 'EACCES' && reopenDir(path.dirname(dir), reopened)
|
|
1746
|
+
try {
|
|
1747
|
+
while (level.size > 0) {
|
|
1748
|
+
const parents = new Set<string>()
|
|
1749
|
+
const dirs = [...level].filter(
|
|
1750
|
+
(dir) =>
|
|
1751
|
+
dir !== rootResolved &&
|
|
1752
|
+
dir.startsWith(rootResolved + path.sep) &&
|
|
1753
|
+
(scope === undefined || inScope(dir, scope)) &&
|
|
1754
|
+
keep?.has(dir) !== true,
|
|
1755
|
+
)
|
|
1756
|
+
if (dirs.length <= SYNC_CLEAN_MAX) {
|
|
1757
|
+
for (const dir of dirs) {
|
|
1758
|
+
try {
|
|
1759
|
+
try {
|
|
1760
|
+
rmdirSync(dir)
|
|
1761
|
+
} catch (err) {
|
|
1762
|
+
if (!denied(err as NodeJS.ErrnoException, dir)) throw err
|
|
1763
|
+
rmdirSync(dir)
|
|
1764
|
+
}
|
|
1765
|
+
parents.add(path.dirname(dir))
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
if (gone(err as NodeJS.ErrnoException)) parents.add(path.dirname(dir))
|
|
1768
|
+
}
|
|
1464
1769
|
}
|
|
1770
|
+
} else {
|
|
1771
|
+
await Promise.all(
|
|
1772
|
+
dirs.map(async (dir) => {
|
|
1773
|
+
const ok = await rmdir(dir)
|
|
1774
|
+
.catch((err: NodeJS.ErrnoException) => {
|
|
1775
|
+
if (!denied(err, dir)) throw err
|
|
1776
|
+
return rmdir(dir)
|
|
1777
|
+
})
|
|
1778
|
+
.then(() => true, gone)
|
|
1779
|
+
if (ok) parents.add(path.dirname(dir))
|
|
1780
|
+
}),
|
|
1781
|
+
)
|
|
1465
1782
|
}
|
|
1466
|
-
|
|
1467
|
-
await Promise.all(
|
|
1468
|
-
dirs.map(async (dir) => {
|
|
1469
|
-
if (await rmdir(dir).then(() => true, gone)) parents.add(path.dirname(dir))
|
|
1470
|
-
}),
|
|
1471
|
-
)
|
|
1783
|
+
level = parents
|
|
1472
1784
|
}
|
|
1473
|
-
|
|
1785
|
+
} finally {
|
|
1786
|
+
giveBack(reopened)
|
|
1474
1787
|
}
|
|
1475
1788
|
}
|
|
1476
1789
|
|
|
@@ -1525,16 +1838,28 @@ export async function resolveWorkspaceOutputs(
|
|
|
1525
1838
|
args: {
|
|
1526
1839
|
workspaceRoot: string
|
|
1527
1840
|
outputs: readonly string[]
|
|
1841
|
+
inputs?: OwnInputs | undefined
|
|
1842
|
+
clearing?: boolean
|
|
1528
1843
|
},
|
|
1529
1844
|
emptyDirs?: Set<string>,
|
|
1845
|
+
special?: SpecialProbe,
|
|
1530
1846
|
): Promise<string[]> {
|
|
1531
1847
|
const { positive, negative } = splitNegations(args.outputs)
|
|
1532
1848
|
if (positive.length === 0) return []
|
|
1533
1849
|
const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
|
|
1534
1850
|
const trees = asTrees(positive)
|
|
1535
|
-
refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace')
|
|
1851
|
+
const absent = refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace', args.inputs)
|
|
1536
1852
|
const scanned = [
|
|
1537
|
-
...(await scanUnion(
|
|
1853
|
+
...(await scanUnion(
|
|
1854
|
+
trees,
|
|
1855
|
+
excludeGlobs,
|
|
1856
|
+
args.workspaceRoot,
|
|
1857
|
+
undefined,
|
|
1858
|
+
emptyDirs,
|
|
1859
|
+
special,
|
|
1860
|
+
absent,
|
|
1861
|
+
args.clearing,
|
|
1862
|
+
)),
|
|
1538
1863
|
]
|
|
1539
1864
|
// Same containment as the project twin, anchored one level out. These globs
|
|
1540
1865
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
@@ -1547,10 +1872,17 @@ export async function resolveWorkspaceOutputs(
|
|
|
1547
1872
|
export async function resolveWorkspaceOutputTree(args: {
|
|
1548
1873
|
workspaceRoot: string
|
|
1549
1874
|
outputs: readonly string[]
|
|
1550
|
-
|
|
1875
|
+
/** As `resolveOutputTree`'s. */
|
|
1876
|
+
inputs?: OwnInputs | undefined
|
|
1877
|
+
/** As `resolveOutputTree`'s. */
|
|
1878
|
+
special?: boolean | 'later'
|
|
1879
|
+
clearing?: boolean
|
|
1880
|
+
}): Promise<OutputTree> {
|
|
1551
1881
|
const emptyDirs = new Set<string>()
|
|
1552
|
-
const
|
|
1553
|
-
|
|
1882
|
+
const probe: SpecialProbe | undefined =
|
|
1883
|
+
args.special === undefined || args.special === false ? undefined : {}
|
|
1884
|
+
const files = await resolveWorkspaceOutputs(args, emptyDirs, probe)
|
|
1885
|
+
return treeOf(args.workspaceRoot, files, emptyDirs, args.special, probe)
|
|
1554
1886
|
}
|
|
1555
1887
|
|
|
1556
1888
|
/**
|
|
@@ -1564,6 +1896,7 @@ export async function resolveWorkspaceOutputTree(args: {
|
|
|
1564
1896
|
export async function cleanWorkspaceOutputs(args: {
|
|
1565
1897
|
workspaceRoot: string
|
|
1566
1898
|
outputs: readonly string[]
|
|
1899
|
+
inputs?: OwnInputs | undefined
|
|
1567
1900
|
committed?: (abs: string) => boolean
|
|
1568
1901
|
/** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
|
|
1569
1902
|
resolved?: readonly string[] | undefined
|
|
@@ -1575,13 +1908,13 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1575
1908
|
sweepRestoreTemps(args.workspaceRoot, args.outputs, committed)
|
|
1576
1909
|
const tree =
|
|
1577
1910
|
args.resolved === undefined
|
|
1578
|
-
? await resolveWorkspaceOutputTree(args)
|
|
1579
|
-
: { files: args.resolved, emptyDirs: [] }
|
|
1911
|
+
? await resolveWorkspaceOutputTree({ ...args, special: true, clearing: true })
|
|
1912
|
+
: { files: args.resolved, emptyDirs: [], special: [] }
|
|
1580
1913
|
const matched = tree.files
|
|
1581
1914
|
await pruneEmptiedDirs(
|
|
1582
1915
|
args.workspaceRoot,
|
|
1583
1916
|
await removeAll(
|
|
1584
|
-
matched.filter((f) => !committed(f)),
|
|
1917
|
+
[...matched, ...tree.special].filter((f) => !committed(f)),
|
|
1585
1918
|
args.workspaceRoot,
|
|
1586
1919
|
),
|
|
1587
1920
|
pruneScope(args.workspaceRoot, args.outputs),
|
|
@@ -1619,16 +1952,61 @@ function unanswered(
|
|
|
1619
1952
|
files: readonly string[],
|
|
1620
1953
|
newIgnored?: (lit: string) => boolean,
|
|
1621
1954
|
): Set<string> {
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1955
|
+
return new Set(
|
|
1956
|
+
reachingIgnored(
|
|
1957
|
+
literals,
|
|
1958
|
+
(lit) => {
|
|
1959
|
+
const under = `${lit}/`
|
|
1960
|
+
return (
|
|
1961
|
+
files[lowerBound(files, lit)] === lit ||
|
|
1962
|
+
files[lowerBound(files, under)]?.startsWith(under) === true ||
|
|
1963
|
+
files.some((rel) => rel === lit || rel.startsWith(under))
|
|
1964
|
+
)
|
|
1965
|
+
},
|
|
1966
|
+
newIgnored,
|
|
1967
|
+
),
|
|
1968
|
+
)
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* The rule a named entry reaches git-ignored files by: git lists nothing at
|
|
1973
|
+
* or under it, or ignores a new file directly in it (a kept `gen/.gitkeep`).
|
|
1974
|
+
* `src` of `src/**` over a listed `src` reaches none: an ignored `src/dev.log`
|
|
1975
|
+
* is no input.
|
|
1976
|
+
*/
|
|
1977
|
+
function reachingIgnored(
|
|
1978
|
+
named: readonly string[],
|
|
1979
|
+
listed: (entry: string) => boolean,
|
|
1980
|
+
newIgnored?: (entry: string) => boolean,
|
|
1981
|
+
): string[] {
|
|
1982
|
+
return named.filter((n) => !listed(n) || newIgnored?.(n) === true)
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* The entries of `named` (absolute, from {@link namedOutright}) a
|
|
1987
|
+
* git-ignored input can be at or under, by the rule a key reads them
|
|
1988
|
+
* ({@link ignoredReach}): `listed` holds what git lists, absolute, with every
|
|
1989
|
+
* directory above a listed file. Watch asks it, so an ignored file the key
|
|
1990
|
+
* never reads starts no cycle.
|
|
1991
|
+
*/
|
|
1992
|
+
export function namedIgnoredReach(
|
|
1993
|
+
root: string,
|
|
1994
|
+
named: readonly string[],
|
|
1995
|
+
listed: ReadonlySet<string>,
|
|
1996
|
+
): string[] {
|
|
1997
|
+
const held = named.filter((n) => listed.has(n))
|
|
1998
|
+
const fresh =
|
|
1999
|
+
held.length === 0
|
|
2000
|
+
? new Set<string>()
|
|
2001
|
+
: newFilesIgnoredIn(
|
|
2002
|
+
root,
|
|
2003
|
+
held.map((n) => relPosix(root, n)),
|
|
2004
|
+
)
|
|
2005
|
+
return reachingIgnored(
|
|
2006
|
+
named,
|
|
2007
|
+
(n) => listed.has(n),
|
|
2008
|
+
(n) => fresh.has(relPosix(root, n)),
|
|
2009
|
+
)
|
|
1632
2010
|
}
|
|
1633
2011
|
|
|
1634
2012
|
/** The first index whose path is not below `key`. */
|
|
@@ -1907,9 +2285,43 @@ function throughLinks(
|
|
|
1907
2285
|
* read through it, and an edit there was a hit (2026-10-28). Such a path
|
|
1908
2286
|
* that is a link to a directory is an input (`dirLinkInput`). Generous on
|
|
1909
2287
|
* purpose: a wrong yes costs one lookup, and folds a linked directory.
|
|
2288
|
+
*
|
|
2289
|
+
* Asked of every path no glob takes, so the prefixes are a map walked by
|
|
2290
|
+
* the path's own `/`s: a scan of every prefix per path was 8,000 globs ×
|
|
2291
|
+
* 200,000 files, minutes per key.
|
|
1910
2292
|
*/
|
|
1911
|
-
function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
|
|
1912
|
-
|
|
2293
|
+
export function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
|
|
2294
|
+
// A prefix ends in `/` (or is empty), so the ones a path starts with are
|
|
2295
|
+
// its slices that end at one of its own `/`s.
|
|
2296
|
+
const byPrefix = new Map<string, { deep: boolean; depth: number }>()
|
|
2297
|
+
for (const r of holdReach(positive)) {
|
|
2298
|
+
const seen = byPrefix.get(r.prefix)
|
|
2299
|
+
if (seen === undefined) byPrefix.set(r.prefix, { deep: r.deep, depth: r.depth })
|
|
2300
|
+
else {
|
|
2301
|
+
seen.deep ||= r.deep
|
|
2302
|
+
seen.depth = Math.max(seen.depth, r.depth)
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
if (byPrefix.size === 0) return () => false
|
|
2306
|
+
return (rel) => {
|
|
2307
|
+
let slashes = 0
|
|
2308
|
+
for (let i = 0; i < rel.length; i++) if (rel.charCodeAt(i) === 47) slashes++
|
|
2309
|
+
const depth = slashes + 1
|
|
2310
|
+
let r = rel.length > 0 ? byPrefix.get('') : undefined
|
|
2311
|
+
if (r !== undefined && (r.deep || depth < r.depth)) return true
|
|
2312
|
+
for (let i = rel.indexOf('/'); i !== -1 && i < rel.length - 1; i = rel.indexOf('/', i + 1)) {
|
|
2313
|
+
r = byPrefix.get(rel.slice(0, i + 1))
|
|
2314
|
+
if (r !== undefined && (r.deep || depth < r.depth)) return true
|
|
2315
|
+
}
|
|
2316
|
+
return false
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/** Each positive alternative's fixed prefix (`''` or ending in `/`), whether it has a `**`, and its depth. */
|
|
2321
|
+
export function holdReach(
|
|
2322
|
+
positive: readonly string[],
|
|
2323
|
+
): { prefix: string; deep: boolean; depth: number }[] {
|
|
2324
|
+
return asTrees(positive.flatMap(braceAlternatives)).map((g) => {
|
|
1913
2325
|
const prefix = staticPrefix(g)
|
|
1914
2326
|
return {
|
|
1915
2327
|
prefix: prefix === '.' ? '' : `${prefix}/`,
|
|
@@ -1917,14 +2329,6 @@ function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
|
|
|
1917
2329
|
depth: g.split('/').length,
|
|
1918
2330
|
}
|
|
1919
2331
|
})
|
|
1920
|
-
return (rel) => {
|
|
1921
|
-
let depth: number | undefined
|
|
1922
|
-
for (const r of reach) {
|
|
1923
|
-
if (!rel.startsWith(r.prefix) || rel.length === r.prefix.length) continue
|
|
1924
|
-
if (r.deep || (depth ??= rel.split('/').length) < r.depth) return true
|
|
1925
|
-
}
|
|
1926
|
-
return false
|
|
1927
|
-
}
|
|
1928
2332
|
}
|
|
1929
2333
|
|
|
1930
2334
|
/**
|
|
@@ -1946,8 +2350,8 @@ function dirLinkInput(abs: string, oids: ReadonlyMap<string, string> | undefined
|
|
|
1946
2350
|
* Whether a changed path is one of a task's declared file inputs:
|
|
1947
2351
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1948
2352
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1949
|
-
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path
|
|
1950
|
-
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (2026-10-04).
|
|
2353
|
+
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a tracked path
|
|
2354
|
+
* under the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (2026-10-04).
|
|
1951
2355
|
*/
|
|
1952
2356
|
export function declaresInput(
|
|
1953
2357
|
task: TaskConfig,
|
|
@@ -1958,22 +2362,41 @@ export function declaresInput(
|
|
|
1958
2362
|
* below it keys what is behind it, as `mayHoldMatch` says at key time.
|
|
1959
2363
|
*/
|
|
1960
2364
|
dirLink = false,
|
|
2365
|
+
/** Git does not track the path, so under the task's own outputs it is no input. */
|
|
2366
|
+
untracked = false,
|
|
2367
|
+
/**
|
|
2368
|
+
* The task's project directory, workspace-relative: a `workspaceFiles`
|
|
2369
|
+
* glob reaches its `outputs.files` too, which the key leaves out as it
|
|
2370
|
+
* does `outputs.workspaceFiles` (`resolveWorkspaceFiles`).
|
|
2371
|
+
*/
|
|
2372
|
+
projectDir?: string,
|
|
1961
2373
|
): boolean {
|
|
1962
2374
|
const inputs = task.inputs
|
|
1963
2375
|
if (inputs === undefined) return false
|
|
1964
2376
|
if (projectRel !== null) {
|
|
1965
|
-
//
|
|
1966
|
-
// (`
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2377
|
+
// Under the task's own outputs the key folds only what git tracks
|
|
2378
|
+
// (`committedFiles`); an untracked build output is not one.
|
|
2379
|
+
const plan = filesPlan(inputs.files, task.outputs?.files ?? [])
|
|
2380
|
+
if (plan !== null && (inPlan(plan, projectRel) || (!untracked && ownOnly(plan, projectRel)))) {
|
|
2381
|
+
return true
|
|
2382
|
+
}
|
|
1970
2383
|
if (plan !== null && dirLink && plan.mayHold(projectRel) && !plan.isExcluded(projectRel)) {
|
|
1971
2384
|
return true
|
|
1972
2385
|
}
|
|
1973
2386
|
}
|
|
1974
2387
|
const ws = inputs.workspaceFiles
|
|
1975
2388
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
1976
|
-
|
|
2389
|
+
if (!workspaceMatcher(ws)(workspaceRel, dirLink)) return false
|
|
2390
|
+
// As for `files`: under its own outputs the key folds only a tracked path.
|
|
2391
|
+
return !untracked || !ownWorkspaceOutput(task, workspaceRel, projectDir)
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/** Asked only of an untracked path a `workspaceFiles` glob matched: rare, so not memoised. */
|
|
2395
|
+
function ownWorkspaceOutput(task: TaskConfig, rel: string, projectDir?: string): boolean {
|
|
2396
|
+
const outputs = task.outputs
|
|
2397
|
+
if (outputMatcher(outputs?.workspaceFiles ?? [])(rel)) return true
|
|
2398
|
+
if (projectDir === undefined) return false
|
|
2399
|
+
return underProject(projectDir, outputMatcher(outputs?.files ?? []))(rel)
|
|
1977
2400
|
}
|
|
1978
2401
|
|
|
1979
2402
|
/**
|
|
@@ -1995,8 +2418,7 @@ export function workspaceFilesReachInto(task: TaskConfig, dir: string): boolean
|
|
|
1995
2418
|
*/
|
|
1996
2419
|
export function namedOutright(task: TaskConfig): { files: string[]; workspaceFiles: string[] } {
|
|
1997
2420
|
const inputs = task.inputs
|
|
1998
|
-
const plan =
|
|
1999
|
-
inputs === undefined ? null : filesPlan(inputs.files, task.cache?.outputs.files ?? [])
|
|
2421
|
+
const plan = inputs === undefined ? null : filesPlan(inputs.files, task.outputs?.files ?? [])
|
|
2000
2422
|
const ws = inputs?.workspaceFiles ?? []
|
|
2001
2423
|
return {
|
|
2002
2424
|
files: plan === null ? [] : outright(plan),
|
|
@@ -2131,22 +2553,24 @@ async function resolveFiles(
|
|
|
2131
2553
|
const verdicts = plan.verdicts
|
|
2132
2554
|
const committed = committedFiles(args.gitFilesCache, args.projectDir)
|
|
2133
2555
|
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
2134
|
-
|
|
2556
|
+
const paths = base === undefined ? undefined : args.gitFilesCache?.absPaths(gitFiles)
|
|
2557
|
+
for (let i = 0; i < gitFiles.length; i++) {
|
|
2558
|
+
const rel = gitFiles[i]!
|
|
2135
2559
|
let input = verdicts.get(rel)
|
|
2136
2560
|
if (input === undefined) {
|
|
2137
2561
|
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
2138
2562
|
verdicts.set(rel, input)
|
|
2139
2563
|
}
|
|
2140
2564
|
if (nested(rel) || ownWsOutput(rel)) continue
|
|
2141
|
-
const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
|
|
2565
|
+
const abs = base === undefined ? path.resolve(args.projectDir, rel) : (paths?.[i] ?? base + rel)
|
|
2142
2566
|
if (!input && !ownOnly(plan, rel)) {
|
|
2143
2567
|
if (plan.mayHold(rel) && !isExcluded(rel) && !ownOutput(rel) && dirLinkInput(abs, oids)) {
|
|
2144
|
-
candidates.push(abs)
|
|
2568
|
+
candidates.push(paths === undefined ? abs : (paths[i] = abs))
|
|
2145
2569
|
}
|
|
2146
2570
|
continue
|
|
2147
2571
|
}
|
|
2148
2572
|
if (!input && !committed(abs)) continue
|
|
2149
|
-
candidates.push(abs)
|
|
2573
|
+
candidates.push(paths === undefined ? abs : (paths[i] = abs))
|
|
2150
2574
|
}
|
|
2151
2575
|
const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
|
|
2152
2576
|
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
@@ -2237,13 +2661,17 @@ function listingFor(
|
|
|
2237
2661
|
* yields names no file and the output dropped out of the artifact without
|
|
2238
2662
|
* a word: a hit then restored a tree without it (turborepo#9345's output
|
|
2239
2663
|
* half). A `\ufffd` that does not stat is that case or a file that left
|
|
2240
|
-
* mid-scan; `undecodableOnDisk` tells them apart.
|
|
2664
|
+
* mid-scan; `undecodableOnDisk` tells them apart. `clearing`: the walk
|
|
2665
|
+
* feeds a clean or a restore, which nothing saves.
|
|
2241
2666
|
*/
|
|
2242
|
-
function refuseUndecodableOutput(abs: string): void {
|
|
2667
|
+
function refuseUndecodableOutput(abs: string, clearing: boolean): void {
|
|
2243
2668
|
if (!undecodableOnDisk(abs)) return
|
|
2669
|
+
const why = clearing
|
|
2670
|
+
? 'vx cannot clear a file by it before the task runs or a hit restores'
|
|
2671
|
+
: 'vx cannot save a file by it'
|
|
2244
2672
|
throw new UserError(
|
|
2245
|
-
`
|
|
2246
|
-
`\ufffd), and
|
|
2673
|
+
`outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
|
|
2674
|
+
`\ufffd), and ${why}. Rename it, or keep it out of the outputs.`,
|
|
2247
2675
|
)
|
|
2248
2676
|
}
|
|
2249
2677
|
|
|
@@ -2456,9 +2884,18 @@ async function scanUnion(
|
|
|
2456
2884
|
cwd: string,
|
|
2457
2885
|
nested: (rel: string) => boolean = () => false,
|
|
2458
2886
|
emptyDirs?: Set<string>,
|
|
2887
|
+
special?: SpecialProbe,
|
|
2888
|
+
absent?: ReadonlySet<string>,
|
|
2889
|
+
clearing = false,
|
|
2459
2890
|
): Promise<Set<string>> {
|
|
2460
2891
|
const matches = new Set<string>()
|
|
2461
2892
|
const links: string[] = []
|
|
2893
|
+
// Bun's scan skips a FIFO, socket or device outright, so `special` is
|
|
2894
|
+
// found by listing the directories the walk entered: its roots, the
|
|
2895
|
+
// directories it took and the parents of what it took. Derived only when
|
|
2896
|
+
// asked, so a walk that may never need them pays a set add per directory.
|
|
2897
|
+
const roots = special === undefined ? undefined : new Set<string>()
|
|
2898
|
+
const dirs = special === undefined ? undefined : new Set<string>()
|
|
2462
2899
|
const take = (rel: string): void => {
|
|
2463
2900
|
if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
|
|
2464
2901
|
const abs = path.resolve(cwd, rel)
|
|
@@ -2466,15 +2903,26 @@ async function scanUnion(
|
|
|
2466
2903
|
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) {
|
|
2467
2904
|
matches.add(abs)
|
|
2468
2905
|
if (st.isSymbolicLink()) links.push(abs)
|
|
2469
|
-
} else if (
|
|
2470
|
-
|
|
2471
|
-
|
|
2906
|
+
} else if (st?.isDirectory() === true) {
|
|
2907
|
+
dirs?.add(abs)
|
|
2908
|
+
if (emptyDirs !== undefined && readdirSync(abs).length === 0) emptyDirs.add(abs)
|
|
2909
|
+
} else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs, clearing)
|
|
2472
2910
|
}
|
|
2473
2911
|
const opts = { cwd, onlyFiles: false, followSymlinks: false, dot: true }
|
|
2474
2912
|
// `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
|
|
2475
2913
|
// `/`: `{dist,lib/esm}/**` saved an empty artifact, and a hit restored
|
|
2476
2914
|
// nothing over a cleaned tree (A-10). Expanded here, as discovery does.
|
|
2477
2915
|
const patterns = positive.flatMap(slashBraceExpansions)
|
|
2916
|
+
if (special !== undefined) {
|
|
2917
|
+
special.find = () => {
|
|
2918
|
+
const walked = new Set(roots)
|
|
2919
|
+
for (const f of [...matches, ...links]) walked.add(path.dirname(f))
|
|
2920
|
+
for (const d of dirs!) walked.add(d).add(path.dirname(d))
|
|
2921
|
+
const into = new Set<string>()
|
|
2922
|
+
findSpecial(walked, cwd, anyTaskGlob(patterns), excludeGlobs, nested, into)
|
|
2923
|
+
return [...into]
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2478
2926
|
// A leading `**` walks the whole project whatever else is listed, so the
|
|
2479
2927
|
// list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
|
|
2480
2928
|
// ~2 ms of a warm hit on solid.
|
|
@@ -2490,9 +2938,10 @@ async function scanUnion(
|
|
|
2490
2938
|
return matches
|
|
2491
2939
|
}
|
|
2492
2940
|
for (const pattern of patterns) {
|
|
2493
|
-
if (absentPrefix(cwd, pattern)) continue
|
|
2941
|
+
if (absentPrefix(cwd, pattern, absent)) continue
|
|
2494
2942
|
const from = scanRoot(cwd, pattern)
|
|
2495
2943
|
if (from === null) continue
|
|
2944
|
+
roots?.add(from === undefined ? cwd : from.dir)
|
|
2496
2945
|
if (from === undefined) for (const rel of globFor(pattern).scanSync(opts)) take(rel)
|
|
2497
2946
|
else
|
|
2498
2947
|
for (const rel of globFor(from.rest).scanSync({ ...opts, cwd: from.dir }))
|
|
@@ -2513,6 +2962,36 @@ async function scanUnion(
|
|
|
2513
2962
|
return matches
|
|
2514
2963
|
}
|
|
2515
2964
|
|
|
2965
|
+
/** Filled by a walk asked for it: what lists the FIFOs, sockets and devices its globs name. */
|
|
2966
|
+
interface SpecialProbe {
|
|
2967
|
+
find?: () => string[]
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
/** The FIFOs, sockets and devices in `dirs` that the output globs name. */
|
|
2971
|
+
function findSpecial(
|
|
2972
|
+
dirs: ReadonlySet<string>,
|
|
2973
|
+
cwd: string,
|
|
2974
|
+
any: (rel: string) => boolean,
|
|
2975
|
+
excludeGlobs: readonly Bun.Glob[],
|
|
2976
|
+
nested: (rel: string) => boolean,
|
|
2977
|
+
into: Set<string>,
|
|
2978
|
+
): void {
|
|
2979
|
+
for (const dir of dirs) {
|
|
2980
|
+
let entries: Dirent[]
|
|
2981
|
+
try {
|
|
2982
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
2983
|
+
} catch {
|
|
2984
|
+
continue
|
|
2985
|
+
}
|
|
2986
|
+
for (const e of entries) {
|
|
2987
|
+
if (e.isFile() || e.isDirectory() || e.isSymbolicLink()) continue
|
|
2988
|
+
const abs = path.join(dir, e.name)
|
|
2989
|
+
const rel = path.relative(cwd, abs).split(path.sep).join('/')
|
|
2990
|
+
if (any(rel) && !nested(rel) && !excludeGlobs.some((g) => g.match(rel))) into.add(abs)
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2516
2995
|
/** `pattern`'s static directory passes through a symlink under `cwd`. */
|
|
2517
2996
|
function headLinked(cwd: string, pattern: string): boolean {
|
|
2518
2997
|
const prefix = staticPrefix(pattern)
|
|
@@ -2562,11 +3041,13 @@ function scanRoot(
|
|
|
2562
3041
|
* cost ~58 µs where the lstat costs a few. Anything this cannot read
|
|
2563
3042
|
* plainly (no prefix, an escape, an absolute path, a refused stat) scans.
|
|
2564
3043
|
*/
|
|
2565
|
-
function absentPrefix(cwd: string, pattern: string): boolean {
|
|
3044
|
+
function absentPrefix(cwd: string, pattern: string, known?: ReadonlySet<string>): boolean {
|
|
2566
3045
|
const prefix = staticPrefix(pattern)
|
|
2567
3046
|
if (prefix === '.' || prefix.includes('\\') || path.isAbsolute(prefix)) return false
|
|
3047
|
+
const at = path.join(cwd, prefix)
|
|
3048
|
+
if (known?.has(at) === true) return true
|
|
2568
3049
|
try {
|
|
2569
|
-
return lstatSync(
|
|
3050
|
+
return lstatSync(at, { throwIfNoEntry: false }) === undefined
|
|
2570
3051
|
} catch {
|
|
2571
3052
|
return false
|
|
2572
3053
|
}
|