@vzn/vx 0.0.652 → 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 +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- 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 +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- 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 +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- 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 +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- 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 +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- 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 +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- 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-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- 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 +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- 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 +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- 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 +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -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/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- 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 +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- 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 +17 -3
- 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 +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- 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 +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- 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 +46 -25
- 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
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import path from 'node:path'
|
|
20
20
|
import {
|
|
21
|
+
chmodSync,
|
|
21
22
|
type Dirent,
|
|
22
23
|
lstatSync,
|
|
23
24
|
readdirSync,
|
|
@@ -29,8 +30,11 @@ import {
|
|
|
29
30
|
import { rm, rmdir } from 'node:fs/promises'
|
|
30
31
|
import type { InputsConfig, TaskConfig } from '../config.js'
|
|
31
32
|
import {
|
|
33
|
+
appendTail,
|
|
34
|
+
createTail,
|
|
32
35
|
literalPath,
|
|
33
36
|
anyTaskGlob,
|
|
37
|
+
tailText,
|
|
34
38
|
asTrees,
|
|
35
39
|
isExecutableMissing,
|
|
36
40
|
isInstalledPath,
|
|
@@ -45,6 +49,7 @@ import {
|
|
|
45
49
|
slashBraceExpansions,
|
|
46
50
|
braceAlternatives,
|
|
47
51
|
ESSENTIAL_ENV,
|
|
52
|
+
POST_EXIT_DRAIN_MS,
|
|
48
53
|
splitNegations,
|
|
49
54
|
staticPrefix,
|
|
50
55
|
stripTrailingSlash,
|
|
@@ -53,7 +58,7 @@ import {
|
|
|
53
58
|
writableUnder,
|
|
54
59
|
withoutWorkspaceBins,
|
|
55
60
|
} from '../util/index.js'
|
|
56
|
-
import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
|
|
61
|
+
import { GitFilesCache, listingEnv, newFilesIgnoredIn, runGitLsFiles } from './git-inputs.js'
|
|
57
62
|
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
58
63
|
|
|
59
64
|
// The git side lives in git-inputs.ts; its whole public surface is
|
|
@@ -114,13 +119,18 @@ const ALWAYS_IGNORE = [
|
|
|
114
119
|
'.vx/dependencies.lock.json',
|
|
115
120
|
// Where restores stage (archive.ts): vx's own, never an input.
|
|
116
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-*',
|
|
117
127
|
]
|
|
118
128
|
|
|
119
129
|
/**
|
|
120
130
|
* `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
|
|
121
131
|
* those globs cross into other projects, and each one's `vx.lock.json` is vx's.
|
|
122
132
|
*/
|
|
123
|
-
const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
|
|
133
|
+
const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json', '**/vx.lock.json.tmp-*']
|
|
124
134
|
|
|
125
135
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
126
136
|
|
|
@@ -222,7 +232,18 @@ export type ProjectFilesCache = Map<
|
|
|
222
232
|
* relative to it) and everything `resolveFiles` keys its memo on.
|
|
223
233
|
* Undefined when only resolving the files can say.
|
|
224
234
|
*/
|
|
225
|
-
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 {
|
|
226
247
|
if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
|
|
227
248
|
const plan = filesPlan(args.inputs?.files, args.ownOutputs)
|
|
228
249
|
const cache = args.gitFilesCache
|
|
@@ -307,6 +328,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
307
328
|
args.runtimeScope === undefined
|
|
308
329
|
? `${args.projectDir}\0`
|
|
309
330
|
: `${args.projectDir}\0${args.runtimeScope}\0`,
|
|
331
|
+
'runtime',
|
|
310
332
|
),
|
|
311
333
|
resolveRuntimeValues(
|
|
312
334
|
wsRuntimeDecl,
|
|
@@ -315,6 +337,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
315
337
|
args.workspaceRoot,
|
|
316
338
|
args.workspaceRuntimeCache,
|
|
317
339
|
args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
|
|
340
|
+
'workspaceRuntime',
|
|
318
341
|
),
|
|
319
342
|
])
|
|
320
343
|
return {
|
|
@@ -587,6 +610,7 @@ export function answerRuntime(
|
|
|
587
610
|
[path.join(workspaceRoot, 'node_modules', '.bin')],
|
|
588
611
|
workspaceRoot,
|
|
589
612
|
undefined,
|
|
613
|
+
'workspaceRuntime',
|
|
590
614
|
)
|
|
591
615
|
: runRuntimeCommand(
|
|
592
616
|
command,
|
|
@@ -594,6 +618,7 @@ export function answerRuntime(
|
|
|
594
618
|
projectBinDirs(projectDir, workspaceRoot),
|
|
595
619
|
workspaceRoot,
|
|
596
620
|
undefined,
|
|
621
|
+
'runtime',
|
|
597
622
|
)
|
|
598
623
|
}
|
|
599
624
|
|
|
@@ -601,7 +626,21 @@ function resolveEnvValues(
|
|
|
601
626
|
names: readonly string[],
|
|
602
627
|
source: NodeJS.ProcessEnv,
|
|
603
628
|
): Array<[string, string | undefined]> {
|
|
604
|
-
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
|
+
})
|
|
605
644
|
}
|
|
606
645
|
|
|
607
646
|
/**
|
|
@@ -628,7 +667,9 @@ async function runRuntimeCommand(
|
|
|
628
667
|
binDirs: readonly string[],
|
|
629
668
|
workspaceRoot: string,
|
|
630
669
|
owner: RuntimeMemo | undefined,
|
|
670
|
+
field: RuntimeField,
|
|
631
671
|
): Promise<string> {
|
|
672
|
+
const probe = `inputs.${field} probe`
|
|
632
673
|
// As a task's (exec/env.ts): no member's bin inherited from `npm run`.
|
|
633
674
|
const ambient = withoutWorkspaceBins(process.env['PATH'] ?? '', workspaceRoot)
|
|
634
675
|
// As the task's PATH does (exec/env.ts): a dir holding the delimiter
|
|
@@ -656,10 +697,10 @@ async function runRuntimeCommand(
|
|
|
656
697
|
// the shell, not the command (item 244).
|
|
657
698
|
if (isExecutableMissing(err)) {
|
|
658
699
|
throw new UserError(
|
|
659
|
-
|
|
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.`,
|
|
660
701
|
)
|
|
661
702
|
}
|
|
662
|
-
throw new UserError(
|
|
703
|
+
throw new UserError(`${probe} failed to spawn: ${command} (cwd: ${cwd})`)
|
|
663
704
|
}
|
|
664
705
|
liveProbes.add(proc)
|
|
665
706
|
let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
|
|
@@ -670,22 +711,41 @@ async function runRuntimeCommand(
|
|
|
670
711
|
}
|
|
671
712
|
killProbesOnExit()
|
|
672
713
|
let stdout, stderr, exitCode
|
|
714
|
+
const ac = new AbortController()
|
|
673
715
|
try {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
proc.exited,
|
|
716
|
+
const streams = Promise.all([
|
|
717
|
+
probeBytes(proc.stdout, ac.signal),
|
|
718
|
+
probeBytes(proc.stderr, ac.signal),
|
|
678
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
|
|
679
735
|
} finally {
|
|
680
736
|
liveProbes.delete(proc)
|
|
681
737
|
mine?.delete(proc)
|
|
682
738
|
}
|
|
683
739
|
if (exitCode !== 0) {
|
|
684
740
|
const lossy = new TextDecoder()
|
|
685
|
-
|
|
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`
|
|
686
747
|
throw new UserError(
|
|
687
|
-
|
|
688
|
-
(shown ? `\n${shown}` : ''),
|
|
748
|
+
`${probe} exited ${exitCode}: ${command} (cwd: ${cwd})` + (shown ? `\n${lost}${shown}` : ''),
|
|
689
749
|
)
|
|
690
750
|
}
|
|
691
751
|
let out: string
|
|
@@ -697,7 +757,7 @@ async function runRuntimeCommand(
|
|
|
697
757
|
// A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
|
|
698
758
|
// folded the same output and replayed each other's build.
|
|
699
759
|
throw new UserError(
|
|
700
|
-
|
|
760
|
+
`${probe} printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
|
|
701
761
|
`Pipe it through a hash or od.`,
|
|
702
762
|
)
|
|
703
763
|
}
|
|
@@ -730,6 +790,8 @@ const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
|
|
|
730
790
|
let probeExitHooked = false
|
|
731
791
|
|
|
732
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'
|
|
733
795
|
/** The probes each run started, by the run's memo: one run's stop is not another's. */
|
|
734
796
|
const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
|
|
735
797
|
|
|
@@ -744,24 +806,51 @@ const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
|
|
|
744
806
|
export function stopRuntimeProbes(...memos: readonly RuntimeMemo[]): void {
|
|
745
807
|
for (const memo of memos) {
|
|
746
808
|
for (const p of probesOf.get(memo) ?? []) {
|
|
747
|
-
|
|
748
|
-
process.kill(-p.pid, 'SIGKILL')
|
|
749
|
-
} catch {
|
|
750
|
-
// the group is gone
|
|
751
|
-
}
|
|
809
|
+
killGroup(p.pid)
|
|
752
810
|
}
|
|
753
811
|
}
|
|
754
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)
|
|
840
|
+
}
|
|
841
|
+
} finally {
|
|
842
|
+
signal.removeEventListener('abort', onAbort)
|
|
843
|
+
reader.releaseLock()
|
|
844
|
+
}
|
|
845
|
+
return Buffer.concat(parts)
|
|
846
|
+
}
|
|
847
|
+
|
|
755
848
|
function killProbesOnExit(): void {
|
|
756
849
|
if (probeExitHooked) return
|
|
757
850
|
probeExitHooked = true
|
|
758
851
|
process.on('exit', () => {
|
|
759
852
|
for (const p of liveProbes) {
|
|
760
|
-
|
|
761
|
-
process.kill(-p.pid, 'SIGKILL')
|
|
762
|
-
} catch {
|
|
763
|
-
// the group is gone
|
|
764
|
-
}
|
|
853
|
+
killGroup(p.pid)
|
|
765
854
|
}
|
|
766
855
|
})
|
|
767
856
|
}
|
|
@@ -782,6 +871,7 @@ async function resolveRuntimeValues(
|
|
|
782
871
|
workspaceRoot: string,
|
|
783
872
|
memo: Map<string, Promise<string>> | undefined,
|
|
784
873
|
memoKeyPrefix: string,
|
|
874
|
+
field: RuntimeField,
|
|
785
875
|
): Promise<Array<[string, string]>> {
|
|
786
876
|
if (commands.length === 0) return []
|
|
787
877
|
const unique = [...new Set(commands)].sort()
|
|
@@ -790,7 +880,7 @@ async function resolveRuntimeValues(
|
|
|
790
880
|
const key = `${memoKeyPrefix}${cmd}`
|
|
791
881
|
let p = memo?.get(key)
|
|
792
882
|
if (p === undefined) {
|
|
793
|
-
p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
|
|
883
|
+
p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo, field)
|
|
794
884
|
memo?.set(key, p)
|
|
795
885
|
}
|
|
796
886
|
return [cmd, await p] as [string, string]
|
|
@@ -808,6 +898,13 @@ function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspac
|
|
|
808
898
|
return outputNever(outputs, scope).map(globFor)
|
|
809
899
|
}
|
|
810
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
|
+
|
|
811
908
|
/**
|
|
812
909
|
* Resolve declared output globs (project-relative) to actual produced files.
|
|
813
910
|
* An output directory linked out of the project is refused by name (X-88):
|
|
@@ -817,6 +914,7 @@ export async function resolveOutputs(args: {
|
|
|
817
914
|
projectDir: string
|
|
818
915
|
outputs: readonly string[]
|
|
819
916
|
nestedProjectDirs: string[]
|
|
917
|
+
inputs?: OwnInputs | undefined
|
|
820
918
|
}): Promise<string[]> {
|
|
821
919
|
return (await scanOutputs(args)).files
|
|
822
920
|
}
|
|
@@ -845,29 +943,66 @@ export async function resolveOutputTree(args: {
|
|
|
845
943
|
projectDir: string
|
|
846
944
|
outputs: readonly string[]
|
|
847
945
|
nestedProjectDirs: string[]
|
|
848
|
-
/**
|
|
849
|
-
|
|
850
|
-
|
|
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> {
|
|
851
957
|
const emptyDirs = new Set<string>()
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
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 = {
|
|
855
982
|
files,
|
|
856
|
-
emptyDirs: containedIn(
|
|
857
|
-
special:
|
|
983
|
+
emptyDirs: containedIn(root, [...emptyDirs]).sort(),
|
|
984
|
+
special: asked === true ? findSpecial() : [],
|
|
858
985
|
}
|
|
986
|
+
if (asked === 'later') tree.findSpecial = findSpecial
|
|
987
|
+
return tree
|
|
859
988
|
}
|
|
860
989
|
|
|
861
990
|
async function scanOutputs(
|
|
862
|
-
args: {
|
|
991
|
+
args: {
|
|
992
|
+
projectDir: string
|
|
993
|
+
outputs: readonly string[]
|
|
994
|
+
nestedProjectDirs: string[]
|
|
995
|
+
inputs?: OwnInputs | undefined
|
|
996
|
+
clearing?: boolean
|
|
997
|
+
},
|
|
863
998
|
emptyDirs?: Set<string>,
|
|
864
|
-
special?:
|
|
999
|
+
special?: SpecialProbe,
|
|
865
1000
|
): Promise<{ files: string[] }> {
|
|
866
1001
|
const { positive, negative } = splitNegations(args.outputs)
|
|
867
1002
|
if (positive.length === 0) return { files: [] }
|
|
868
1003
|
const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
|
|
869
1004
|
const trees = asTrees(positive)
|
|
870
|
-
refuseDirLinkedOut(args.projectDir, trees, 'project')
|
|
1005
|
+
const absent = refuseDirLinkedOut(args.projectDir, trees, 'project', args.inputs)
|
|
871
1006
|
const scanned = [
|
|
872
1007
|
...(await scanUnion(
|
|
873
1008
|
trees,
|
|
@@ -876,6 +1011,8 @@ async function scanOutputs(
|
|
|
876
1011
|
inNestedProject(args.projectDir, args.nestedProjectDirs),
|
|
877
1012
|
emptyDirs,
|
|
878
1013
|
special,
|
|
1014
|
+
absent,
|
|
1015
|
+
args.clearing,
|
|
879
1016
|
)),
|
|
880
1017
|
]
|
|
881
1018
|
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
@@ -909,14 +1046,22 @@ export function committedFiles(
|
|
|
909
1046
|
* below it is an output, kept as the link (`archive.ts`). One into `.git`
|
|
910
1047
|
* or `.vx` is refused in either scope: for the root project and for
|
|
911
1048
|
* workspace outputs those are inside, and the clean deleted the
|
|
912
|
-
* repository through `dist -> .git`. One out of
|
|
913
|
-
*
|
|
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.
|
|
914
1055
|
*/
|
|
915
1056
|
function refuseDirLinkedOut(
|
|
916
1057
|
root: string,
|
|
917
1058
|
trees: readonly string[],
|
|
918
1059
|
scope: 'project' | 'workspace',
|
|
919
|
-
|
|
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>()
|
|
920
1065
|
let realRoot: string | undefined
|
|
921
1066
|
for (const tree of trees) {
|
|
922
1067
|
let at = root
|
|
@@ -930,7 +1075,10 @@ function refuseDirLinkedOut(
|
|
|
930
1075
|
} catch {
|
|
931
1076
|
break
|
|
932
1077
|
}
|
|
933
|
-
if (st === undefined)
|
|
1078
|
+
if (st === undefined) {
|
|
1079
|
+
absent.add(at)
|
|
1080
|
+
break
|
|
1081
|
+
}
|
|
934
1082
|
if (!st.isSymbolicLink()) continue
|
|
935
1083
|
let real: string
|
|
936
1084
|
try {
|
|
@@ -941,10 +1089,9 @@ function refuseDirLinkedOut(
|
|
|
941
1089
|
}
|
|
942
1090
|
realRoot ??= realOr(root)
|
|
943
1091
|
if (!isInside(realRoot, real)) {
|
|
944
|
-
if (scope === 'workspace') break
|
|
945
1092
|
throw new UserError(
|
|
946
1093
|
`${at} is a symbolic link to ${real}, outside ${root} — vx never cleans, saves ` +
|
|
947
|
-
|
|
1094
|
+
`or restores declared outputs through a link that leaves the ${scope}. Remove the ` +
|
|
948
1095
|
'link and re-run, or stop declaring outputs under it.',
|
|
949
1096
|
)
|
|
950
1097
|
}
|
|
@@ -955,8 +1102,57 @@ function refuseDirLinkedOut(
|
|
|
955
1102
|
'link and re-run, or stop declaring outputs under it.',
|
|
956
1103
|
)
|
|
957
1104
|
}
|
|
1105
|
+
if (inputs !== undefined) refuseLinkedIntoInputs(at, real, inputs)
|
|
958
1106
|
}
|
|
959
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
|
|
960
1156
|
}
|
|
961
1157
|
|
|
962
1158
|
/**
|
|
@@ -1105,6 +1301,7 @@ export async function cleanOutputs(args: {
|
|
|
1105
1301
|
projectDir: string
|
|
1106
1302
|
outputs: readonly string[]
|
|
1107
1303
|
nestedProjectDirs: string[]
|
|
1304
|
+
inputs?: OwnInputs | undefined
|
|
1108
1305
|
committed?: (abs: string) => boolean
|
|
1109
1306
|
/**
|
|
1110
1307
|
* What `resolveOutputs` returned for these same arguments, when the
|
|
@@ -1120,17 +1317,18 @@ export async function cleanOutputs(args: {
|
|
|
1120
1317
|
)
|
|
1121
1318
|
sweepRestoreTemps(args.projectDir, args.outputs, committed)
|
|
1122
1319
|
// An empty directory under the outputs is one too (a save keeps it): one
|
|
1123
|
-
// 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.
|
|
1124
1322
|
const tree =
|
|
1125
1323
|
args.resolved === undefined
|
|
1126
|
-
? await resolveOutputTree(args)
|
|
1127
|
-
: { files: args.resolved, emptyDirs: [] }
|
|
1324
|
+
? await resolveOutputTree({ ...args, special: true, clearing: true })
|
|
1325
|
+
: { files: args.resolved, emptyDirs: [], special: [] }
|
|
1128
1326
|
const matched = tree.files
|
|
1129
1327
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
1130
1328
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
1131
1329
|
// A symlink is unlinked, never followed.
|
|
1132
1330
|
const removed = await removeAll(
|
|
1133
|
-
matched.filter((f) => !committed(f)),
|
|
1331
|
+
[...matched, ...tree.special].filter((f) => !committed(f)),
|
|
1134
1332
|
args.projectDir,
|
|
1135
1333
|
)
|
|
1136
1334
|
await pruneEmptiedDirs(
|
|
@@ -1271,6 +1469,7 @@ export async function stampOutputs(args: {
|
|
|
1271
1469
|
projectDir: string
|
|
1272
1470
|
outputs: readonly string[]
|
|
1273
1471
|
nestedProjectDirs: string[]
|
|
1472
|
+
inputs?: OwnInputs | undefined
|
|
1274
1473
|
}): Promise<Map<string, OutputStamp>> {
|
|
1275
1474
|
return stampFiles(await resolveOutputs(args))
|
|
1276
1475
|
}
|
|
@@ -1279,10 +1478,33 @@ export async function stampOutputs(args: {
|
|
|
1279
1478
|
export async function stampWorkspaceOutputs(args: {
|
|
1280
1479
|
workspaceRoot: string
|
|
1281
1480
|
outputs: readonly string[]
|
|
1481
|
+
inputs?: OwnInputs | undefined
|
|
1282
1482
|
}): Promise<Map<string, OutputStamp>> {
|
|
1283
1483
|
return stampFiles(await resolveWorkspaceOutputs(args))
|
|
1284
1484
|
}
|
|
1285
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
|
+
|
|
1286
1508
|
function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
|
|
1287
1509
|
const out = new Map<string, OutputStamp>()
|
|
1288
1510
|
for (const f of files) {
|
|
@@ -1309,7 +1531,12 @@ function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
|
|
|
1309
1531
|
* file back, so no entry reproduces that run and none is saved.
|
|
1310
1532
|
*/
|
|
1311
1533
|
export async function ownOutputsSince(
|
|
1312
|
-
args: {
|
|
1534
|
+
args: {
|
|
1535
|
+
projectDir: string
|
|
1536
|
+
outputs: readonly string[]
|
|
1537
|
+
nestedProjectDirs: string[]
|
|
1538
|
+
inputs?: OwnInputs | undefined
|
|
1539
|
+
},
|
|
1313
1540
|
before: ReadonlyMap<string, OutputStamp>,
|
|
1314
1541
|
): Promise<string[] | undefined> {
|
|
1315
1542
|
return changedSince(await resolveOutputs(args), before)
|
|
@@ -1317,7 +1544,7 @@ export async function ownOutputsSince(
|
|
|
1317
1544
|
|
|
1318
1545
|
/** `ownOutputsSince` for root-anchored `outputs.workspaceFiles` (A-43). */
|
|
1319
1546
|
export async function ownWorkspaceOutputsSince(
|
|
1320
|
-
args: { workspaceRoot: string; outputs: readonly string[] },
|
|
1547
|
+
args: { workspaceRoot: string; outputs: readonly string[]; inputs?: OwnInputs | undefined },
|
|
1321
1548
|
before: ReadonlyMap<string, OutputStamp>,
|
|
1322
1549
|
): Promise<string[] | undefined> {
|
|
1323
1550
|
return changedSince(await resolveWorkspaceOutputs(args), before)
|
|
@@ -1365,9 +1592,18 @@ const SYNC_CLEAN_MAX = 128
|
|
|
1365
1592
|
* wrote, a read-only checkout) is the environment's failure, not vx's:
|
|
1366
1593
|
* the scheduler prints any other error as an "internal error", which
|
|
1367
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`).
|
|
1368
1602
|
*/
|
|
1369
1603
|
async function removeAll(all: readonly string[], root: string): Promise<string[]> {
|
|
1370
1604
|
const files = insideRoot(all, root)
|
|
1605
|
+
const reopened: Reopened = new Map()
|
|
1606
|
+
const reopen = (dir: string): boolean => reopenDir(dir, reopened)
|
|
1371
1607
|
const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
|
|
1372
1608
|
const rel = relPosix(root, f)
|
|
1373
1609
|
return new UserError(
|
|
@@ -1376,24 +1612,64 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
|
|
|
1376
1612
|
`by this user, or stop declaring it as an output`,
|
|
1377
1613
|
)
|
|
1378
1614
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
+
}
|
|
1385
1630
|
}
|
|
1631
|
+
return files
|
|
1386
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
|
+
)
|
|
1387
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
|
+
}
|
|
1388
1672
|
}
|
|
1389
|
-
await Promise.all(
|
|
1390
|
-
files.map((f) =>
|
|
1391
|
-
rm(f, { force: true }).catch((err: NodeJS.ErrnoException) => {
|
|
1392
|
-
throw refused(f, err)
|
|
1393
|
-
}),
|
|
1394
|
-
),
|
|
1395
|
-
)
|
|
1396
|
-
return files
|
|
1397
1673
|
}
|
|
1398
1674
|
|
|
1399
1675
|
/**
|
|
@@ -1461,33 +1737,53 @@ async function pruneEmptiedDirs(
|
|
|
1461
1737
|
// the memo is correct and 60 ms, because every child re-attempts the
|
|
1462
1738
|
// shared parent. This is correct at 44 ms.
|
|
1463
1739
|
let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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
|
+
}
|
|
1481
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
|
+
)
|
|
1482
1782
|
}
|
|
1483
|
-
|
|
1484
|
-
await Promise.all(
|
|
1485
|
-
dirs.map(async (dir) => {
|
|
1486
|
-
if (await rmdir(dir).then(() => true, gone)) parents.add(path.dirname(dir))
|
|
1487
|
-
}),
|
|
1488
|
-
)
|
|
1783
|
+
level = parents
|
|
1489
1784
|
}
|
|
1490
|
-
|
|
1785
|
+
} finally {
|
|
1786
|
+
giveBack(reopened)
|
|
1491
1787
|
}
|
|
1492
1788
|
}
|
|
1493
1789
|
|
|
@@ -1542,17 +1838,28 @@ export async function resolveWorkspaceOutputs(
|
|
|
1542
1838
|
args: {
|
|
1543
1839
|
workspaceRoot: string
|
|
1544
1840
|
outputs: readonly string[]
|
|
1841
|
+
inputs?: OwnInputs | undefined
|
|
1842
|
+
clearing?: boolean
|
|
1545
1843
|
},
|
|
1546
1844
|
emptyDirs?: Set<string>,
|
|
1547
|
-
special?:
|
|
1845
|
+
special?: SpecialProbe,
|
|
1548
1846
|
): Promise<string[]> {
|
|
1549
1847
|
const { positive, negative } = splitNegations(args.outputs)
|
|
1550
1848
|
if (positive.length === 0) return []
|
|
1551
1849
|
const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
|
|
1552
1850
|
const trees = asTrees(positive)
|
|
1553
|
-
refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace')
|
|
1851
|
+
const absent = refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace', args.inputs)
|
|
1554
1852
|
const scanned = [
|
|
1555
|
-
...(await scanUnion(
|
|
1853
|
+
...(await scanUnion(
|
|
1854
|
+
trees,
|
|
1855
|
+
excludeGlobs,
|
|
1856
|
+
args.workspaceRoot,
|
|
1857
|
+
undefined,
|
|
1858
|
+
emptyDirs,
|
|
1859
|
+
special,
|
|
1860
|
+
absent,
|
|
1861
|
+
args.clearing,
|
|
1862
|
+
)),
|
|
1556
1863
|
]
|
|
1557
1864
|
// Same containment as the project twin, anchored one level out. These globs
|
|
1558
1865
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
@@ -1566,16 +1873,16 @@ export async function resolveWorkspaceOutputTree(args: {
|
|
|
1566
1873
|
workspaceRoot: string
|
|
1567
1874
|
outputs: readonly string[]
|
|
1568
1875
|
/** As `resolveOutputTree`'s. */
|
|
1569
|
-
|
|
1570
|
-
|
|
1876
|
+
inputs?: OwnInputs | undefined
|
|
1877
|
+
/** As `resolveOutputTree`'s. */
|
|
1878
|
+
special?: boolean | 'later'
|
|
1879
|
+
clearing?: boolean
|
|
1880
|
+
}): Promise<OutputTree> {
|
|
1571
1881
|
const emptyDirs = new Set<string>()
|
|
1572
|
-
const
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
emptyDirs: containedIn(args.workspaceRoot, [...emptyDirs]).sort(),
|
|
1577
|
-
special: special === undefined ? [] : containedIn(args.workspaceRoot, [...special]).sort(),
|
|
1578
|
-
}
|
|
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)
|
|
1579
1886
|
}
|
|
1580
1887
|
|
|
1581
1888
|
/**
|
|
@@ -1589,6 +1896,7 @@ export async function resolveWorkspaceOutputTree(args: {
|
|
|
1589
1896
|
export async function cleanWorkspaceOutputs(args: {
|
|
1590
1897
|
workspaceRoot: string
|
|
1591
1898
|
outputs: readonly string[]
|
|
1899
|
+
inputs?: OwnInputs | undefined
|
|
1592
1900
|
committed?: (abs: string) => boolean
|
|
1593
1901
|
/** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
|
|
1594
1902
|
resolved?: readonly string[] | undefined
|
|
@@ -1600,13 +1908,13 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1600
1908
|
sweepRestoreTemps(args.workspaceRoot, args.outputs, committed)
|
|
1601
1909
|
const tree =
|
|
1602
1910
|
args.resolved === undefined
|
|
1603
|
-
? await resolveWorkspaceOutputTree(args)
|
|
1604
|
-
: { files: args.resolved, emptyDirs: [] }
|
|
1911
|
+
? await resolveWorkspaceOutputTree({ ...args, special: true, clearing: true })
|
|
1912
|
+
: { files: args.resolved, emptyDirs: [], special: [] }
|
|
1605
1913
|
const matched = tree.files
|
|
1606
1914
|
await pruneEmptiedDirs(
|
|
1607
1915
|
args.workspaceRoot,
|
|
1608
1916
|
await removeAll(
|
|
1609
|
-
matched.filter((f) => !committed(f)),
|
|
1917
|
+
[...matched, ...tree.special].filter((f) => !committed(f)),
|
|
1610
1918
|
args.workspaceRoot,
|
|
1611
1919
|
),
|
|
1612
1920
|
pruneScope(args.workspaceRoot, args.outputs),
|
|
@@ -1644,16 +1952,61 @@ function unanswered(
|
|
|
1644
1952
|
files: readonly string[],
|
|
1645
1953
|
newIgnored?: (lit: string) => boolean,
|
|
1646
1954
|
): Set<string> {
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
+
)
|
|
1657
2010
|
}
|
|
1658
2011
|
|
|
1659
2012
|
/** The first index whose path is not below `key`. */
|
|
@@ -1932,9 +2285,43 @@ function throughLinks(
|
|
|
1932
2285
|
* read through it, and an edit there was a hit (2026-10-28). Such a path
|
|
1933
2286
|
* that is a link to a directory is an input (`dirLinkInput`). Generous on
|
|
1934
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.
|
|
1935
2292
|
*/
|
|
1936
|
-
function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
|
|
1937
|
-
|
|
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) => {
|
|
1938
2325
|
const prefix = staticPrefix(g)
|
|
1939
2326
|
return {
|
|
1940
2327
|
prefix: prefix === '.' ? '' : `${prefix}/`,
|
|
@@ -1942,14 +2329,6 @@ function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
|
|
|
1942
2329
|
depth: g.split('/').length,
|
|
1943
2330
|
}
|
|
1944
2331
|
})
|
|
1945
|
-
return (rel) => {
|
|
1946
|
-
let depth: number | undefined
|
|
1947
|
-
for (const r of reach) {
|
|
1948
|
-
if (!rel.startsWith(r.prefix) || rel.length === r.prefix.length) continue
|
|
1949
|
-
if (r.deep || (depth ??= rel.split('/').length) < r.depth) return true
|
|
1950
|
-
}
|
|
1951
|
-
return false
|
|
1952
|
-
}
|
|
1953
2332
|
}
|
|
1954
2333
|
|
|
1955
2334
|
/**
|
|
@@ -1971,8 +2350,8 @@ function dirLinkInput(abs: string, oids: ReadonlyMap<string, string> | undefined
|
|
|
1971
2350
|
* Whether a changed path is one of a task's declared file inputs:
|
|
1972
2351
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1973
2352
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1974
|
-
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path
|
|
1975
|
-
* 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).
|
|
1976
2355
|
*/
|
|
1977
2356
|
export function declaresInput(
|
|
1978
2357
|
task: TaskConfig,
|
|
@@ -1983,22 +2362,41 @@ export function declaresInput(
|
|
|
1983
2362
|
* below it keys what is behind it, as `mayHoldMatch` says at key time.
|
|
1984
2363
|
*/
|
|
1985
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,
|
|
1986
2373
|
): boolean {
|
|
1987
2374
|
const inputs = task.inputs
|
|
1988
2375
|
if (inputs === undefined) return false
|
|
1989
2376
|
if (projectRel !== null) {
|
|
1990
|
-
//
|
|
1991
|
-
// (`
|
|
1992
|
-
// lists: either way it may be an input, so it counts.
|
|
2377
|
+
// Under the task's own outputs the key folds only what git tracks
|
|
2378
|
+
// (`committedFiles`); an untracked build output is not one.
|
|
1993
2379
|
const plan = filesPlan(inputs.files, task.outputs?.files ?? [])
|
|
1994
|
-
if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel)))
|
|
2380
|
+
if (plan !== null && (inPlan(plan, projectRel) || (!untracked && ownOnly(plan, projectRel)))) {
|
|
2381
|
+
return true
|
|
2382
|
+
}
|
|
1995
2383
|
if (plan !== null && dirLink && plan.mayHold(projectRel) && !plan.isExcluded(projectRel)) {
|
|
1996
2384
|
return true
|
|
1997
2385
|
}
|
|
1998
2386
|
}
|
|
1999
2387
|
const ws = inputs.workspaceFiles
|
|
2000
2388
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
2001
|
-
|
|
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)
|
|
2002
2400
|
}
|
|
2003
2401
|
|
|
2004
2402
|
/**
|
|
@@ -2155,22 +2553,24 @@ async function resolveFiles(
|
|
|
2155
2553
|
const verdicts = plan.verdicts
|
|
2156
2554
|
const committed = committedFiles(args.gitFilesCache, args.projectDir)
|
|
2157
2555
|
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
2158
|
-
|
|
2556
|
+
const paths = base === undefined ? undefined : args.gitFilesCache?.absPaths(gitFiles)
|
|
2557
|
+
for (let i = 0; i < gitFiles.length; i++) {
|
|
2558
|
+
const rel = gitFiles[i]!
|
|
2159
2559
|
let input = verdicts.get(rel)
|
|
2160
2560
|
if (input === undefined) {
|
|
2161
2561
|
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
2162
2562
|
verdicts.set(rel, input)
|
|
2163
2563
|
}
|
|
2164
2564
|
if (nested(rel) || ownWsOutput(rel)) continue
|
|
2165
|
-
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)
|
|
2166
2566
|
if (!input && !ownOnly(plan, rel)) {
|
|
2167
2567
|
if (plan.mayHold(rel) && !isExcluded(rel) && !ownOutput(rel) && dirLinkInput(abs, oids)) {
|
|
2168
|
-
candidates.push(abs)
|
|
2568
|
+
candidates.push(paths === undefined ? abs : (paths[i] = abs))
|
|
2169
2569
|
}
|
|
2170
2570
|
continue
|
|
2171
2571
|
}
|
|
2172
2572
|
if (!input && !committed(abs)) continue
|
|
2173
|
-
candidates.push(abs)
|
|
2573
|
+
candidates.push(paths === undefined ? abs : (paths[i] = abs))
|
|
2174
2574
|
}
|
|
2175
2575
|
const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
|
|
2176
2576
|
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
@@ -2261,13 +2661,17 @@ function listingFor(
|
|
|
2261
2661
|
* yields names no file and the output dropped out of the artifact without
|
|
2262
2662
|
* a word: a hit then restored a tree without it (turborepo#9345's output
|
|
2263
2663
|
* half). A `\ufffd` that does not stat is that case or a file that left
|
|
2264
|
-
* 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.
|
|
2265
2666
|
*/
|
|
2266
|
-
function refuseUndecodableOutput(abs: string): void {
|
|
2667
|
+
function refuseUndecodableOutput(abs: string, clearing: boolean): void {
|
|
2267
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'
|
|
2268
2672
|
throw new UserError(
|
|
2269
2673
|
`outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
|
|
2270
|
-
`\ufffd), and
|
|
2674
|
+
`\ufffd), and ${why}. Rename it, or keep it out of the outputs.`,
|
|
2271
2675
|
)
|
|
2272
2676
|
}
|
|
2273
2677
|
|
|
@@ -2480,31 +2884,45 @@ async function scanUnion(
|
|
|
2480
2884
|
cwd: string,
|
|
2481
2885
|
nested: (rel: string) => boolean = () => false,
|
|
2482
2886
|
emptyDirs?: Set<string>,
|
|
2483
|
-
special?:
|
|
2887
|
+
special?: SpecialProbe,
|
|
2888
|
+
absent?: ReadonlySet<string>,
|
|
2889
|
+
clearing = false,
|
|
2484
2890
|
): Promise<Set<string>> {
|
|
2485
2891
|
const matches = new Set<string>()
|
|
2486
2892
|
const links: string[] = []
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2489
|
-
|
|
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>()
|
|
2490
2899
|
const take = (rel: string): void => {
|
|
2491
2900
|
if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
|
|
2492
2901
|
const abs = path.resolve(cwd, rel)
|
|
2493
|
-
walked?.add(path.dirname(abs))
|
|
2494
2902
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
2495
|
-
if (walked !== undefined && st?.isDirectory() === true) walked.add(abs)
|
|
2496
2903
|
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) {
|
|
2497
2904
|
matches.add(abs)
|
|
2498
2905
|
if (st.isSymbolicLink()) links.push(abs)
|
|
2499
|
-
} else if (
|
|
2500
|
-
|
|
2501
|
-
|
|
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)
|
|
2502
2910
|
}
|
|
2503
2911
|
const opts = { cwd, onlyFiles: false, followSymlinks: false, dot: true }
|
|
2504
2912
|
// `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
|
|
2505
2913
|
// `/`: `{dist,lib/esm}/**` saved an empty artifact, and a hit restored
|
|
2506
2914
|
// nothing over a cleaned tree (A-10). Expanded here, as discovery does.
|
|
2507
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
|
+
}
|
|
2508
2926
|
// A leading `**` walks the whole project whatever else is listed, so the
|
|
2509
2927
|
// list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
|
|
2510
2928
|
// ~2 ms of a warm hit on solid.
|
|
@@ -2517,22 +2935,18 @@ async function scanUnion(
|
|
|
2517
2935
|
) {
|
|
2518
2936
|
const any = anyTaskGlob(patterns)
|
|
2519
2937
|
for (const rel of WHOLE_TREE.scanSync(opts)) if (any(rel)) take(rel)
|
|
2520
|
-
if (special !== undefined) findSpecial(walked!, cwd, any, excludeGlobs, nested, special)
|
|
2521
2938
|
return matches
|
|
2522
2939
|
}
|
|
2523
2940
|
for (const pattern of patterns) {
|
|
2524
|
-
if (absentPrefix(cwd, pattern)) continue
|
|
2941
|
+
if (absentPrefix(cwd, pattern, absent)) continue
|
|
2525
2942
|
const from = scanRoot(cwd, pattern)
|
|
2526
2943
|
if (from === null) continue
|
|
2527
|
-
|
|
2944
|
+
roots?.add(from === undefined ? cwd : from.dir)
|
|
2528
2945
|
if (from === undefined) for (const rel of globFor(pattern).scanSync(opts)) take(rel)
|
|
2529
2946
|
else
|
|
2530
2947
|
for (const rel of globFor(from.rest).scanSync({ ...opts, cwd: from.dir }))
|
|
2531
2948
|
take(`${from.prefix}/${rel}`)
|
|
2532
2949
|
}
|
|
2533
|
-
if (special !== undefined) {
|
|
2534
|
-
findSpecial(walked!, cwd, anyTaskGlob(patterns), excludeGlobs, nested, special)
|
|
2535
|
-
}
|
|
2536
2950
|
// A literal names the path and its tree (`dist`, `dist/**`), and a tree
|
|
2537
2951
|
// whose head is a directory link is walked through it: the link and the
|
|
2538
2952
|
// files under it were one artifact holding `dist` as a file and a
|
|
@@ -2548,6 +2962,11 @@ async function scanUnion(
|
|
|
2548
2962
|
return matches
|
|
2549
2963
|
}
|
|
2550
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
|
+
|
|
2551
2970
|
/** The FIFOs, sockets and devices in `dirs` that the output globs name. */
|
|
2552
2971
|
function findSpecial(
|
|
2553
2972
|
dirs: ReadonlySet<string>,
|
|
@@ -2622,11 +3041,13 @@ function scanRoot(
|
|
|
2622
3041
|
* cost ~58 µs where the lstat costs a few. Anything this cannot read
|
|
2623
3042
|
* plainly (no prefix, an escape, an absolute path, a refused stat) scans.
|
|
2624
3043
|
*/
|
|
2625
|
-
function absentPrefix(cwd: string, pattern: string): boolean {
|
|
3044
|
+
function absentPrefix(cwd: string, pattern: string, known?: ReadonlySet<string>): boolean {
|
|
2626
3045
|
const prefix = staticPrefix(pattern)
|
|
2627
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
|
|
2628
3049
|
try {
|
|
2629
|
-
return lstatSync(
|
|
3050
|
+
return lstatSync(at, { throwIfNoEntry: false }) === undefined
|
|
2630
3051
|
} catch {
|
|
2631
3052
|
return false
|
|
2632
3053
|
}
|