@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
package/src/cache/inputs.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } fro
|
|
|
21
21
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
22
|
import type { InputsConfig, TaskConfig } from '../config.js'
|
|
23
23
|
import {
|
|
24
|
+
literalPath,
|
|
24
25
|
anyTaskGlob,
|
|
25
26
|
asTrees,
|
|
26
27
|
isExecutableMissing,
|
|
@@ -31,16 +32,20 @@ import {
|
|
|
31
32
|
outputNever,
|
|
32
33
|
realpathOf,
|
|
33
34
|
relPosix,
|
|
35
|
+
restorableOutput,
|
|
34
36
|
shellArgv,
|
|
35
37
|
slashBraceExpansions,
|
|
36
38
|
braceAlternatives,
|
|
39
|
+
ESSENTIAL_ENV,
|
|
37
40
|
splitNegations,
|
|
38
41
|
staticPrefix,
|
|
39
42
|
stripTrailingSlash,
|
|
40
43
|
taskGlob,
|
|
41
44
|
UserError,
|
|
45
|
+
writableUnder,
|
|
46
|
+
withoutWorkspaceBins,
|
|
42
47
|
} from '../util/index.js'
|
|
43
|
-
import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
|
|
48
|
+
import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
|
|
44
49
|
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
45
50
|
|
|
46
51
|
// The git side lives in git-inputs.ts; its whole public surface is
|
|
@@ -141,8 +146,8 @@ export interface ResolveInputsArgs {
|
|
|
141
146
|
* by `projectDir + '\0' + command`. Shared across a run's tasks so a
|
|
142
147
|
* project's command runs once even across build/test/lint and across
|
|
143
148
|
* the hash + sandbox-baseline resolveInputs calls. The key is sound
|
|
144
|
-
* ONLY because
|
|
145
|
-
* task's `exec.env` (`runRuntimeCommand`): two tasks with different
|
|
149
|
+
* ONLY because every probe runs in one environment, vx's essentials,
|
|
150
|
+
* never a task's `exec.env` (`runRuntimeCommand`): two tasks with different
|
|
146
151
|
* `define`s share one value. Threading the task env in would have to
|
|
147
152
|
* widen this key too, or task A's probe becomes task B's key component.
|
|
148
153
|
*/
|
|
@@ -286,6 +291,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
286
291
|
runtimeDecl,
|
|
287
292
|
args.projectDir,
|
|
288
293
|
projectBinDirs(args.projectDir, args.workspaceRoot),
|
|
294
|
+
args.workspaceRoot,
|
|
289
295
|
args.runtimeCache,
|
|
290
296
|
args.runtimeScope === undefined
|
|
291
297
|
? `${args.projectDir}\0`
|
|
@@ -295,6 +301,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
295
301
|
wsRuntimeDecl,
|
|
296
302
|
args.workspaceRoot,
|
|
297
303
|
[path.join(args.workspaceRoot, 'node_modules', '.bin')],
|
|
304
|
+
args.workspaceRoot,
|
|
298
305
|
args.workspaceRuntimeCache,
|
|
299
306
|
args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
|
|
300
307
|
),
|
|
@@ -363,6 +370,7 @@ function resolveWorkspaceFiles(args: {
|
|
|
363
370
|
// caller sees a different array and scans again.
|
|
364
371
|
let isExcluded: ((rel: string) => boolean) | undefined
|
|
365
372
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
373
|
+
let mayHold: ((rel: string) => boolean) | undefined
|
|
366
374
|
const excludedByDeclaration = (rel: string): boolean =>
|
|
367
375
|
(isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
368
376
|
// A committed file stays an input whatever output reaches it (`committedFiles`).
|
|
@@ -374,11 +382,14 @@ function resolveWorkspaceFiles(args: {
|
|
|
374
382
|
)
|
|
375
383
|
const listing: InputListing = {
|
|
376
384
|
root: args.workspaceRoot,
|
|
385
|
+
workspaceRoot: args.workspaceRoot,
|
|
377
386
|
listed: gitFiles,
|
|
378
387
|
isInput: (rel) =>
|
|
379
|
-
(isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
|
|
388
|
+
(isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive))))(rel) &&
|
|
380
389
|
!excludedByDeclaration(rel) &&
|
|
381
390
|
!own(rel),
|
|
391
|
+
linked: (rel) =>
|
|
392
|
+
(mayHold ??= mayHoldMatch(positive))(rel) && !excludedByDeclaration(rel) && !own(rel),
|
|
382
393
|
nested: () => false,
|
|
383
394
|
...reachOf(positive),
|
|
384
395
|
}
|
|
@@ -392,7 +403,7 @@ function resolveWorkspaceFiles(args: {
|
|
|
392
403
|
const hit = args.memo?.get(declared)
|
|
393
404
|
if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
|
|
394
405
|
if (base === undefined) {
|
|
395
|
-
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
406
|
+
isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
|
|
396
407
|
base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
|
|
397
408
|
args.memo?.set(declared, { snapshot: gitFiles, result: base })
|
|
398
409
|
}
|
|
@@ -409,12 +420,14 @@ function resolveWorkspaceFiles(args: {
|
|
|
409
420
|
if (reach.length === 0) return kept
|
|
410
421
|
// Outside the memo: an ignored file a task writes mid-run leaves the
|
|
411
422
|
// snapshot as it was.
|
|
412
|
-
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
423
|
+
isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
|
|
413
424
|
const accept = isPositive
|
|
414
425
|
const extra = ignoredInputs(
|
|
415
426
|
args.workspaceRoot,
|
|
416
427
|
reach,
|
|
417
428
|
(rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
|
|
429
|
+
listing.linked,
|
|
430
|
+
args.gitFilesCache?.vxDirs,
|
|
418
431
|
)
|
|
419
432
|
return extra.length === 0 ? kept : mergeSorted(kept, extra)
|
|
420
433
|
})
|
|
@@ -429,14 +442,18 @@ async function resolveWorkspaceFilesOver(
|
|
|
429
442
|
excluded: (rel: string) => boolean,
|
|
430
443
|
): Promise<string[]> {
|
|
431
444
|
const candidates: string[] = []
|
|
432
|
-
for (const rel of gitFiles) {
|
|
433
|
-
if (!isPositive(rel)) continue
|
|
434
|
-
if (excluded(rel)) continue
|
|
435
|
-
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
436
|
-
}
|
|
437
445
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
438
446
|
// tracked file necessarily exists on disk.
|
|
439
447
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
448
|
+
const mayHold = mayHoldMatch(positive)
|
|
449
|
+
for (const rel of gitFiles) {
|
|
450
|
+
if (isPositive(rel)) {
|
|
451
|
+
if (!excluded(rel)) candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
452
|
+
} else if (mayHold(rel) && !excluded(rel)) {
|
|
453
|
+
const abs = path.resolve(args.workspaceRoot, rel)
|
|
454
|
+
if (dirLinkInput(abs, oids)) candidates.push(abs)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
440
457
|
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
441
458
|
}
|
|
442
459
|
|
|
@@ -523,6 +540,16 @@ function isInputOnDisk(abs: string): boolean {
|
|
|
523
540
|
}
|
|
524
541
|
}
|
|
525
542
|
|
|
543
|
+
/** vx's values of `ESSENTIAL_ENV`: the one environment every probe gets. */
|
|
544
|
+
function probeEnv(): Record<string, string> {
|
|
545
|
+
const env: Record<string, string> = {}
|
|
546
|
+
for (const name of ESSENTIAL_ENV) {
|
|
547
|
+
const value = process.env[name]
|
|
548
|
+
if (value !== undefined) env[name] = value
|
|
549
|
+
}
|
|
550
|
+
return env
|
|
551
|
+
}
|
|
552
|
+
|
|
526
553
|
/** The task's `node_modules/.bin` directories, as `taskBinDirs` gives its PATH. */
|
|
527
554
|
function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
|
|
528
555
|
const own = path.join(projectDir, 'node_modules', '.bin')
|
|
@@ -530,6 +557,35 @@ function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
|
|
|
530
557
|
return own === root ? [own] : [own, root]
|
|
531
558
|
}
|
|
532
559
|
|
|
560
|
+
/**
|
|
561
|
+
* One runtime probe answered on this machine as a key folds it: an
|
|
562
|
+
* `inputs.runtime` command run in `projectDir`, or with `workspace` a
|
|
563
|
+
* `workspaceRuntime` one run in `workspaceRoot`. For an executor that
|
|
564
|
+
* checks a task's probes where it runs it (`TaskExecutor.runtime`).
|
|
565
|
+
*/
|
|
566
|
+
export function answerRuntime(
|
|
567
|
+
command: string,
|
|
568
|
+
projectDir: string,
|
|
569
|
+
workspaceRoot: string,
|
|
570
|
+
workspace = false,
|
|
571
|
+
): Promise<string> {
|
|
572
|
+
return workspace
|
|
573
|
+
? runRuntimeCommand(
|
|
574
|
+
command,
|
|
575
|
+
workspaceRoot,
|
|
576
|
+
[path.join(workspaceRoot, 'node_modules', '.bin')],
|
|
577
|
+
workspaceRoot,
|
|
578
|
+
undefined,
|
|
579
|
+
)
|
|
580
|
+
: runRuntimeCommand(
|
|
581
|
+
command,
|
|
582
|
+
projectDir,
|
|
583
|
+
projectBinDirs(projectDir, workspaceRoot),
|
|
584
|
+
workspaceRoot,
|
|
585
|
+
undefined,
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
|
|
533
589
|
function resolveEnvValues(
|
|
534
590
|
names: readonly string[],
|
|
535
591
|
source: NodeJS.ProcessEnv,
|
|
@@ -542,10 +598,13 @@ function resolveEnvValues(
|
|
|
542
598
|
* work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
|
|
543
599
|
* exit is a hard UserError naming the command (fail-loud, like git).
|
|
544
600
|
*
|
|
545
|
-
* The probe
|
|
546
|
-
* `
|
|
547
|
-
*
|
|
548
|
-
*
|
|
601
|
+
* The probe sees the essential environment every task sees
|
|
602
|
+
* (`ESSENTIAL_ENV`), from vx's own — never the task's `exec.env`, which
|
|
603
|
+
* describes the command, not the machine, and never the rest of vx's: a
|
|
604
|
+
* child is dumpable after its exec, so an unsandboxed task read every
|
|
605
|
+
* secret vx holds from the probe's `/proc/<pid>/environ`. One env for
|
|
606
|
+
* every probe is what lets the run-scoped memo key on (projectDir,
|
|
607
|
+
* command) alone; see `runtimeCache`.
|
|
549
608
|
*
|
|
550
609
|
* Its PATH does lead with the task's own `node_modules/.bin` directories
|
|
551
610
|
* (`binDirs`), as the task's does: `tsc --version` otherwise keyed the
|
|
@@ -556,9 +615,11 @@ async function runRuntimeCommand(
|
|
|
556
615
|
command: string,
|
|
557
616
|
cwd: string,
|
|
558
617
|
binDirs: readonly string[],
|
|
618
|
+
workspaceRoot: string,
|
|
559
619
|
owner: RuntimeMemo | undefined,
|
|
560
620
|
): Promise<string> {
|
|
561
|
-
|
|
621
|
+
// As a task's (exec/env.ts): no member's bin inherited from `npm run`.
|
|
622
|
+
const ambient = withoutWorkspaceBins(process.env['PATH'] ?? '', workspaceRoot)
|
|
562
623
|
// As the task's PATH does (exec/env.ts): a dir holding the delimiter
|
|
563
624
|
// splits into an entry relative to the probe's cwd.
|
|
564
625
|
const PATH = [...binDirs.filter((dir) => !dir.includes(path.delimiter)), ambient]
|
|
@@ -571,7 +632,7 @@ async function runRuntimeCommand(
|
|
|
571
632
|
// `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
|
|
572
633
|
proc = Bun.spawn(shellArgv(command), {
|
|
573
634
|
cwd,
|
|
574
|
-
env: { ...
|
|
635
|
+
env: { ...probeEnv(), PATH },
|
|
575
636
|
stdin: 'ignore',
|
|
576
637
|
stdout: 'pipe',
|
|
577
638
|
stderr: 'pipe',
|
|
@@ -707,6 +768,7 @@ async function resolveRuntimeValues(
|
|
|
707
768
|
commands: readonly string[],
|
|
708
769
|
cwd: string,
|
|
709
770
|
binDirs: readonly string[],
|
|
771
|
+
workspaceRoot: string,
|
|
710
772
|
memo: Map<string, Promise<string>> | undefined,
|
|
711
773
|
memoKeyPrefix: string,
|
|
712
774
|
): Promise<Array<[string, string]>> {
|
|
@@ -717,7 +779,7 @@ async function resolveRuntimeValues(
|
|
|
717
779
|
const key = `${memoKeyPrefix}${cmd}`
|
|
718
780
|
let p = memo?.get(key)
|
|
719
781
|
if (p === undefined) {
|
|
720
|
-
p = runRuntimeCommand(cmd, cwd, binDirs, memo)
|
|
782
|
+
p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
|
|
721
783
|
memo?.set(key, p)
|
|
722
784
|
}
|
|
723
785
|
return [cmd, await p] as [string, string]
|
|
@@ -748,6 +810,21 @@ export async function resolveOutputs(args: {
|
|
|
748
810
|
return (await scanOutputs(args)).files
|
|
749
811
|
}
|
|
750
812
|
|
|
813
|
+
/**
|
|
814
|
+
* Whether `resolveOutputs` takes the project-relative file `rel`, decided
|
|
815
|
+
* without the walk: a directory snapshot's file list is held to the
|
|
816
|
+
* entry's rows through it.
|
|
817
|
+
*/
|
|
818
|
+
export function outputWalkTakes(
|
|
819
|
+
projectDir: string,
|
|
820
|
+
outputs: readonly string[],
|
|
821
|
+
nestedProjectDirs: string[],
|
|
822
|
+
): (rel: string) => boolean {
|
|
823
|
+
const restorable = restorableOutput(outputs)
|
|
824
|
+
const nested = inNestedProject(projectDir, nestedProjectDirs)
|
|
825
|
+
return (rel) => restorable(rel) && !nested(rel)
|
|
826
|
+
}
|
|
827
|
+
|
|
751
828
|
/**
|
|
752
829
|
* `resolveOutputs`, plus the empty directories the globs match: a save
|
|
753
830
|
* keeps them, so a hit leaves the tree a miss left (Turbo and Nx restore
|
|
@@ -771,7 +848,7 @@ async function scanOutputs(
|
|
|
771
848
|
if (positive.length === 0) return { files: [] }
|
|
772
849
|
const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
|
|
773
850
|
const trees = asTrees(positive)
|
|
774
|
-
refuseDirLinkedOut(args.projectDir, trees)
|
|
851
|
+
refuseDirLinkedOut(args.projectDir, trees, 'project')
|
|
775
852
|
const scanned = [
|
|
776
853
|
...(await scanUnion(
|
|
777
854
|
trees,
|
|
@@ -788,12 +865,13 @@ async function scanOutputs(
|
|
|
788
865
|
// outside the project, so any future caller reaching it by another route (a
|
|
789
866
|
// programmatic embedder, a config source that skips the loader) is contained
|
|
790
867
|
// by construction.
|
|
791
|
-
|
|
868
|
+
const contained = containedIn(args.projectDir, scanned)
|
|
869
|
+
return { files: outsideNested(args.nestedProjectDirs, contained).sort() }
|
|
792
870
|
}
|
|
793
871
|
|
|
794
872
|
/**
|
|
795
873
|
* Whether git tracks a file under `dir`. No clean deletes a committed (or
|
|
796
|
-
* `git add`ed) file an output glob reaches (
|
|
874
|
+
* `git add`ed) file an output glob reaches (2026-10-14: "never
|
|
797
875
|
* delete committed file"); the task may rewrite it and a restore writes the
|
|
798
876
|
* bytes it left. It stays in the task's key, so those bytes follow from
|
|
799
877
|
* the key. Turbo and Nx never clean an output at all.
|
|
@@ -808,16 +886,24 @@ export function committedFiles(
|
|
|
808
886
|
/**
|
|
809
887
|
* The literal head of each output glob, walked: the scan follows a link
|
|
810
888
|
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
|
|
811
|
-
* below it is an output, kept as the link (`archive.ts`).
|
|
889
|
+
* below it is an output, kept as the link (`archive.ts`). One into `.git`
|
|
890
|
+
* or `.vx` is refused in either scope: for the root project and for
|
|
891
|
+
* workspace outputs those are inside, and the clean deleted the
|
|
892
|
+
* repository through `dist -> .git`. One out of a workspace output's root
|
|
893
|
+
* is left to `containedIn`.
|
|
812
894
|
*/
|
|
813
|
-
function refuseDirLinkedOut(
|
|
895
|
+
function refuseDirLinkedOut(
|
|
896
|
+
root: string,
|
|
897
|
+
trees: readonly string[],
|
|
898
|
+
scope: 'project' | 'workspace',
|
|
899
|
+
): void {
|
|
814
900
|
let realRoot: string | undefined
|
|
815
901
|
for (const tree of trees) {
|
|
816
|
-
let at =
|
|
902
|
+
let at = root
|
|
817
903
|
for (const part of staticPrefix(tree).split('/')) {
|
|
818
904
|
if (part === '' || part === '.') continue
|
|
819
905
|
at = path.join(at, part)
|
|
820
|
-
if (!isInside(
|
|
906
|
+
if (!isInside(root, at)) break
|
|
821
907
|
let st
|
|
822
908
|
try {
|
|
823
909
|
st = lstatSync(at, { throwIfNoEntry: false })
|
|
@@ -833,18 +919,41 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
|
|
|
833
919
|
} catch {
|
|
834
920
|
break
|
|
835
921
|
}
|
|
836
|
-
realRoot ??= realOr(
|
|
922
|
+
realRoot ??= realOr(root)
|
|
837
923
|
if (!isInside(realRoot, real)) {
|
|
924
|
+
if (scope === 'workspace') break
|
|
838
925
|
throw new UserError(
|
|
839
|
-
`${at} is a symbolic link to ${real}, outside ${
|
|
926
|
+
`${at} is a symbolic link to ${real}, outside ${root} — vx never cleans, saves ` +
|
|
840
927
|
'or restores declared outputs through a link that leaves the project. Remove the ' +
|
|
841
928
|
'link and re-run, or stop declaring outputs under it.',
|
|
842
929
|
)
|
|
843
930
|
}
|
|
931
|
+
if (inMetadata(real, realRoot)) {
|
|
932
|
+
throw new UserError(
|
|
933
|
+
`${at} is a symbolic link to ${real}, Git's or vx's metadata — vx never cleans, ` +
|
|
934
|
+
'saves or restores declared outputs through a link into .git or .vx. Remove the ' +
|
|
935
|
+
'link and re-run, or stop declaring outputs under it.',
|
|
936
|
+
)
|
|
937
|
+
}
|
|
844
938
|
}
|
|
845
939
|
}
|
|
846
940
|
}
|
|
847
941
|
|
|
942
|
+
/**
|
|
943
|
+
* Whether `real`, under `realRoot`, is in a `.git` there (`writableUnder`'s
|
|
944
|
+
* rule) or in its `.vx`. Asked only of where a link landed: a build's own
|
|
945
|
+
* `dist/.git/**` is an output (2026-10-17).
|
|
946
|
+
*/
|
|
947
|
+
function inMetadata(real: string, realRoot: string): boolean {
|
|
948
|
+
if (!writableUnder(real, realRoot)) return true
|
|
949
|
+
return (
|
|
950
|
+
real
|
|
951
|
+
.slice(realRoot.length + 1)
|
|
952
|
+
.split(path.sep, 1)[0]!
|
|
953
|
+
.toLowerCase() === '.vx'
|
|
954
|
+
)
|
|
955
|
+
}
|
|
956
|
+
|
|
848
957
|
function realOr(p: string): string {
|
|
849
958
|
try {
|
|
850
959
|
return realpathSync(p)
|
|
@@ -902,6 +1011,57 @@ function containedIn(root: string, paths: readonly string[]): string[] {
|
|
|
902
1011
|
return lexical.filter((_p, i) => contained.has(lexDirs[i]!))
|
|
903
1012
|
}
|
|
904
1013
|
|
|
1014
|
+
/**
|
|
1015
|
+
* `committed`, also asked by each path's real directory: the output walk
|
|
1016
|
+
* follows a link at a glob's head that stays inside `root` (X-88), so
|
|
1017
|
+
* `public/logo.txt` may be `static/logo.txt`, the only name git lists.
|
|
1018
|
+
* Asked by the lexical name alone, an untracked `public -> static` link
|
|
1019
|
+
* let the clean delete a committed file. One realpath per directory.
|
|
1020
|
+
*/
|
|
1021
|
+
function committedPastLinks(
|
|
1022
|
+
root: string,
|
|
1023
|
+
committed: (abs: string) => boolean,
|
|
1024
|
+
): (abs: string) => boolean {
|
|
1025
|
+
let realRoot: string | undefined
|
|
1026
|
+
const canonical = new Map<string, string | null>()
|
|
1027
|
+
return (abs) => {
|
|
1028
|
+
if (committed(abs)) return true
|
|
1029
|
+
const dir = path.dirname(abs)
|
|
1030
|
+
let canon = canonical.get(dir)
|
|
1031
|
+
if (canon === undefined) {
|
|
1032
|
+
const real = realOrNull(dir)
|
|
1033
|
+
realRoot ??= realOrNull(root) ?? root
|
|
1034
|
+
canon =
|
|
1035
|
+
real === null || !isInside(realRoot, real)
|
|
1036
|
+
? null
|
|
1037
|
+
: path.join(root, path.relative(realRoot, real))
|
|
1038
|
+
canonical.set(dir, canon)
|
|
1039
|
+
}
|
|
1040
|
+
return canon !== null && canon !== dir && committed(path.join(canon, path.basename(abs)))
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Drop the files whose real directory is in a nested project: a link at a
|
|
1046
|
+
* glob's head that stays in the project is followed (X-88), and the
|
|
1047
|
+
* lexical nested-project filter cannot see where it lands.
|
|
1048
|
+
*/
|
|
1049
|
+
function outsideNested(nestedDirs: readonly string[], files: string[]): string[] {
|
|
1050
|
+
if (nestedDirs.length === 0 || files.length === 0) return files
|
|
1051
|
+
const realNested = nestedDirs.map((d) => realOrNull(d) ?? d)
|
|
1052
|
+
const verdict = new Map<string, boolean>()
|
|
1053
|
+
return files.filter((f) => {
|
|
1054
|
+
const dir = path.dirname(f)
|
|
1055
|
+
let ok = verdict.get(dir)
|
|
1056
|
+
if (ok === undefined) {
|
|
1057
|
+
const real = realOrNull(dir) ?? dir
|
|
1058
|
+
ok = !realNested.some((n) => isInside(n, real))
|
|
1059
|
+
verdict.set(dir, ok)
|
|
1060
|
+
}
|
|
1061
|
+
return ok
|
|
1062
|
+
})
|
|
1063
|
+
}
|
|
1064
|
+
|
|
905
1065
|
/** Is `abs` the directory `dir` itself or something beneath it? */
|
|
906
1066
|
function isInside(dir: string, abs: string): boolean {
|
|
907
1067
|
if (abs === dir) return true
|
|
@@ -934,9 +1094,18 @@ export async function cleanOutputs(args: {
|
|
|
934
1094
|
*/
|
|
935
1095
|
resolved?: readonly string[] | undefined
|
|
936
1096
|
}): Promise<string[]> {
|
|
937
|
-
const committed =
|
|
938
|
-
|
|
939
|
-
|
|
1097
|
+
const committed = committedPastLinks(
|
|
1098
|
+
args.projectDir,
|
|
1099
|
+
args.committed ?? committedFiles(undefined, args.projectDir),
|
|
1100
|
+
)
|
|
1101
|
+
sweepRestoreTemps(args.projectDir, args.outputs, committed)
|
|
1102
|
+
// An empty directory under the outputs is one too (a save keeps it): one
|
|
1103
|
+
// an older build left would otherwise be saved by this run.
|
|
1104
|
+
const tree =
|
|
1105
|
+
args.resolved === undefined
|
|
1106
|
+
? await resolveOutputTree(args)
|
|
1107
|
+
: { files: args.resolved, emptyDirs: [] }
|
|
1108
|
+
const matched = tree.files
|
|
940
1109
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
941
1110
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
942
1111
|
// A symlink is unlinked, never followed.
|
|
@@ -944,7 +1113,13 @@ export async function cleanOutputs(args: {
|
|
|
944
1113
|
matched.filter((f) => !committed(f)),
|
|
945
1114
|
args.projectDir,
|
|
946
1115
|
)
|
|
947
|
-
await pruneEmptiedDirs(
|
|
1116
|
+
await pruneEmptiedDirs(
|
|
1117
|
+
args.projectDir,
|
|
1118
|
+
removed,
|
|
1119
|
+
pruneScope(args.projectDir, args.outputs),
|
|
1120
|
+
undefined,
|
|
1121
|
+
tree.emptyDirs,
|
|
1122
|
+
)
|
|
948
1123
|
// Project-relative posix paths the outputs reach, for the caller's
|
|
949
1124
|
// GitFilesCache.markOutputsChanged: a committed one kept here is the
|
|
950
1125
|
// task's to rewrite or remove, so its index OID no longer vouches for it.
|
|
@@ -961,13 +1136,22 @@ export async function cleanOutputs(args: {
|
|
|
961
1136
|
* whose writer is alive, this process included, is left: it may be
|
|
962
1137
|
* mid-restore.
|
|
963
1138
|
*/
|
|
964
|
-
export function sweepRestoreTemps(
|
|
1139
|
+
export function sweepRestoreTemps(
|
|
1140
|
+
base: string,
|
|
1141
|
+
outputs: readonly string[],
|
|
1142
|
+
committed: (abs: string) => boolean = () => false,
|
|
1143
|
+
): void {
|
|
965
1144
|
const dirs = new Set<string>()
|
|
966
1145
|
for (const g of outputs) {
|
|
967
1146
|
if (g.startsWith('!') || !isLiteralPattern(g)) continue
|
|
968
1147
|
dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
|
|
969
1148
|
}
|
|
1149
|
+
// A directory that resolves out of `base` is not the outputs': the link
|
|
1150
|
+
// refusal runs after this sweep, and a delete never follows a link out.
|
|
1151
|
+
let realBase: string | undefined
|
|
970
1152
|
for (const dir of dirs) {
|
|
1153
|
+
const real = realOrNull(dir)
|
|
1154
|
+
if (real === null || !isInside((realBase ??= realOrNull(base) ?? base), real)) continue
|
|
971
1155
|
let names: string[]
|
|
972
1156
|
try {
|
|
973
1157
|
names = readdirSync(dir)
|
|
@@ -979,6 +1163,8 @@ export function sweepRestoreTemps(base: string, outputs: readonly string[]): voi
|
|
|
979
1163
|
if (m === null) continue
|
|
980
1164
|
const pid = Number.parseInt(m[1]!, 36)
|
|
981
1165
|
if (pid === process.pid || writerAlive(pid)) continue
|
|
1166
|
+
// vx never deletes a committed file, whatever its name.
|
|
1167
|
+
if (committed(path.join(dir, name))) continue
|
|
982
1168
|
rmSync(path.join(dir, name), { force: true })
|
|
983
1169
|
}
|
|
984
1170
|
}
|
|
@@ -1029,6 +1215,8 @@ export async function cleanOutputPaths(args: {
|
|
|
1029
1215
|
committed?: (abs: string) => boolean
|
|
1030
1216
|
/** Directories the prune leaves standing even when emptied (absolute). */
|
|
1031
1217
|
keep?: ReadonlySet<string>
|
|
1218
|
+
/** Empty directories to remove too (project-relative). */
|
|
1219
|
+
emptyDirs?: readonly string[]
|
|
1032
1220
|
}): Promise<void> {
|
|
1033
1221
|
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
1034
1222
|
const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
|
|
@@ -1037,6 +1225,7 @@ export async function cleanOutputPaths(args: {
|
|
|
1037
1225
|
await removeAll(files, args.projectDir),
|
|
1038
1226
|
pruneScope(args.projectDir, args.outputs),
|
|
1039
1227
|
args.keep,
|
|
1228
|
+
args.emptyDirs?.map((r) => path.resolve(args.projectDir, r)),
|
|
1040
1229
|
)
|
|
1041
1230
|
}
|
|
1042
1231
|
|
|
@@ -1193,7 +1382,8 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
|
|
|
1193
1382
|
* that skipped `dist -> real-out` left the last run's files for the next
|
|
1194
1383
|
* entry to save (X-88). One that leaves the project is never deleted
|
|
1195
1384
|
* through (X-5); `cleanOutputPaths` takes recorded rows, not the resolver's
|
|
1196
|
-
* contained set, so the check is here too
|
|
1385
|
+
* contained set, so the check is here too, and so is the one into
|
|
1386
|
+
* `.git` or `.vx`.
|
|
1197
1387
|
*/
|
|
1198
1388
|
function insideRoot(files: readonly string[], root: string): string[] {
|
|
1199
1389
|
// Resolved only once a directory exists to compare: after a clean pruned
|
|
@@ -1208,7 +1398,14 @@ function insideRoot(files: readonly string[], root: string): string[] {
|
|
|
1208
1398
|
// A directory already gone has nothing to delete through; its path
|
|
1209
1399
|
// stays so the prune still reaches the parents it emptied.
|
|
1210
1400
|
const r = realOrNull(dir)
|
|
1211
|
-
|
|
1401
|
+
if (r === null) ok = true
|
|
1402
|
+
else {
|
|
1403
|
+
realRoot ??= realOrNull(root) ?? root
|
|
1404
|
+
// Where no link moved it, a `.git` is the build's own (inMetadata).
|
|
1405
|
+
ok =
|
|
1406
|
+
isInside(realRoot, r) &&
|
|
1407
|
+
(path.relative(realRoot, r) === path.relative(root, dir) || !inMetadata(r, realRoot))
|
|
1408
|
+
}
|
|
1212
1409
|
own.set(dir, ok)
|
|
1213
1410
|
}
|
|
1214
1411
|
return ok
|
|
@@ -1216,11 +1413,11 @@ function insideRoot(files: readonly string[], root: string): string[] {
|
|
|
1216
1413
|
}
|
|
1217
1414
|
|
|
1218
1415
|
/**
|
|
1219
|
-
* Remove the directories a clean emptied,
|
|
1416
|
+
* Remove the directories a clean emptied, and the `emptyDirs` it found
|
|
1417
|
+
* (an older build's, which a save would keep), bottom-up, never `root` itself.
|
|
1220
1418
|
* A restore writes files where the cached entry has them; a directory left
|
|
1221
1419
|
* standing where the entry holds a FILE of the same name (a task that once
|
|
1222
|
-
* wrote `dist/out/…` and now writes `dist/out`) blocks the rename
|
|
1223
|
-
* empty directory is not an output anyone declared. A directory that still
|
|
1420
|
+
* wrote `dist/out/…` and now writes `dist/out`) blocks the rename. A directory that still
|
|
1224
1421
|
* holds something — a stray the globs do not cover — stays, and the restore
|
|
1225
1422
|
* says so if it is in the way. With a `scope`, only a directory inside it
|
|
1226
1423
|
* goes (`pruneScope`).
|
|
@@ -1230,6 +1427,7 @@ async function pruneEmptiedDirs(
|
|
|
1230
1427
|
removed: readonly string[],
|
|
1231
1428
|
scope?: PruneScope,
|
|
1232
1429
|
keep?: ReadonlySet<string>,
|
|
1430
|
+
emptyDirs: readonly string[] = [],
|
|
1233
1431
|
): Promise<void> {
|
|
1234
1432
|
const rootResolved = path.resolve(root)
|
|
1235
1433
|
// LEVEL ORDER, not a walk-up per directory. A parent is attempted only
|
|
@@ -1242,7 +1440,7 @@ async function pruneEmptiedDirs(
|
|
|
1242
1440
|
// standing when a sibling's turn came first; the same walk-up without
|
|
1243
1441
|
// the memo is correct and 60 ms, because every child re-attempts the
|
|
1244
1442
|
// shared parent. This is correct at 44 ms.
|
|
1245
|
-
let level = new Set(removed.map((f) => path.dirname(f)))
|
|
1443
|
+
let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
|
|
1246
1444
|
while (level.size > 0) {
|
|
1247
1445
|
const parents = new Set<string>()
|
|
1248
1446
|
const dirs = [...level].filter(
|
|
@@ -1294,8 +1492,10 @@ function pruneScope(base: string, outputs: readonly string[]): PruneScope {
|
|
|
1294
1492
|
const scope: PruneScope = { below: [], atOrBelow: [] }
|
|
1295
1493
|
for (const g of outputs) {
|
|
1296
1494
|
if (g.startsWith('!')) continue
|
|
1297
|
-
|
|
1298
|
-
|
|
1495
|
+
// `literalPath`, as `asTrees` reads it: an escaped `out/\[x\]` is one path.
|
|
1496
|
+
const lit = literalPath(normalizeGlob(g))
|
|
1497
|
+
if (lit !== null) {
|
|
1498
|
+
scope.atOrBelow.push(path.resolve(base, stripTrailingSlash(lit)))
|
|
1299
1499
|
continue
|
|
1300
1500
|
}
|
|
1301
1501
|
const prefix = staticPrefix(g)
|
|
@@ -1318,14 +1518,21 @@ function inScope(dir: string, scope: PruneScope): boolean {
|
|
|
1318
1518
|
* with NO project-dir exclusion: workspace outputs are the documented
|
|
1319
1519
|
* boundary escape hatch.
|
|
1320
1520
|
*/
|
|
1321
|
-
export async function resolveWorkspaceOutputs(
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1521
|
+
export async function resolveWorkspaceOutputs(
|
|
1522
|
+
args: {
|
|
1523
|
+
workspaceRoot: string
|
|
1524
|
+
outputs: readonly string[]
|
|
1525
|
+
},
|
|
1526
|
+
emptyDirs?: Set<string>,
|
|
1527
|
+
): Promise<string[]> {
|
|
1325
1528
|
const { positive, negative } = splitNegations(args.outputs)
|
|
1326
1529
|
if (positive.length === 0) return []
|
|
1327
1530
|
const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
|
|
1328
|
-
const
|
|
1531
|
+
const trees = asTrees(positive)
|
|
1532
|
+
refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace')
|
|
1533
|
+
const scanned = [
|
|
1534
|
+
...(await scanUnion(trees, excludeGlobs, args.workspaceRoot, undefined, emptyDirs)),
|
|
1535
|
+
]
|
|
1329
1536
|
// Same containment as the project twin, anchored one level out. These globs
|
|
1330
1537
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
1331
1538
|
// escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
|
|
@@ -1333,6 +1540,16 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1333
1540
|
return containedIn(args.workspaceRoot, scanned).sort()
|
|
1334
1541
|
}
|
|
1335
1542
|
|
|
1543
|
+
/** `resolveOutputTree` for `outputs.workspaceFiles`: the files and the empty directories. */
|
|
1544
|
+
export async function resolveWorkspaceOutputTree(args: {
|
|
1545
|
+
workspaceRoot: string
|
|
1546
|
+
outputs: readonly string[]
|
|
1547
|
+
}): Promise<{ files: string[]; emptyDirs: string[] }> {
|
|
1548
|
+
const emptyDirs = new Set<string>()
|
|
1549
|
+
const files = await resolveWorkspaceOutputs(args, emptyDirs)
|
|
1550
|
+
return { files, emptyDirs: containedIn(args.workspaceRoot, [...emptyDirs]).sort() }
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1336
1553
|
/**
|
|
1337
1554
|
* `cleanOutputs` for the workspace-output namespace: wipe every file
|
|
1338
1555
|
* currently matching the declared root-relative globs. Same contract
|
|
@@ -1348,9 +1565,16 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1348
1565
|
/** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
|
|
1349
1566
|
resolved?: readonly string[] | undefined
|
|
1350
1567
|
}): Promise<string[]> {
|
|
1351
|
-
const committed =
|
|
1352
|
-
|
|
1353
|
-
|
|
1568
|
+
const committed = committedPastLinks(
|
|
1569
|
+
args.workspaceRoot,
|
|
1570
|
+
args.committed ?? committedFiles(undefined, args.workspaceRoot),
|
|
1571
|
+
)
|
|
1572
|
+
sweepRestoreTemps(args.workspaceRoot, args.outputs, committed)
|
|
1573
|
+
const tree =
|
|
1574
|
+
args.resolved === undefined
|
|
1575
|
+
? await resolveWorkspaceOutputTree(args)
|
|
1576
|
+
: { files: args.resolved, emptyDirs: [] }
|
|
1577
|
+
const matched = tree.files
|
|
1354
1578
|
await pruneEmptiedDirs(
|
|
1355
1579
|
args.workspaceRoot,
|
|
1356
1580
|
await removeAll(
|
|
@@ -1358,6 +1582,8 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1358
1582
|
args.workspaceRoot,
|
|
1359
1583
|
),
|
|
1360
1584
|
pruneScope(args.workspaceRoot, args.outputs),
|
|
1585
|
+
undefined,
|
|
1586
|
+
tree.emptyDirs,
|
|
1361
1587
|
)
|
|
1362
1588
|
return matched.map((f) => relPosix(args.workspaceRoot, f))
|
|
1363
1589
|
}
|
|
@@ -1418,7 +1644,7 @@ function lowerBound(files: readonly string[], key: string): number {
|
|
|
1418
1644
|
* The entries a declaration names outright that git answers nothing for: a
|
|
1419
1645
|
* literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
|
|
1420
1646
|
* path at or under it. Such a path is gitignored or absent, and naming it
|
|
1421
|
-
* is asking for it (
|
|
1647
|
+
* is asking for it (2026-10-16), so {@link ignoredInputs} reads it
|
|
1422
1648
|
* from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
|
|
1423
1649
|
* no ignored file: `node_modules` is never walked.
|
|
1424
1650
|
*/
|
|
@@ -1472,20 +1698,28 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
|
|
|
1472
1698
|
* The files on disk under the {@link ignoredReach} entries that `accept`
|
|
1473
1699
|
* keeps, sorted and absolute. A file is hashed from disk like any untracked
|
|
1474
1700
|
* one; nothing here is stored or restored. `node_modules` is no special
|
|
1475
|
-
* case (
|
|
1701
|
+
* case (2026-10-16): an ignored path named outright is an input,
|
|
1476
1702
|
* a generated client (`node_modules/.prisma/client/**`) too; skipping
|
|
1477
1703
|
* installs here folded such an entry to nothing, silently. lstat, not
|
|
1478
1704
|
* `exists()`: that answers false for a directory. Never memoized: a
|
|
1479
|
-
* dependency writes these mid-run.
|
|
1705
|
+
* dependency writes these mid-run. Nothing under `vxDirs` (the run's cache
|
|
1706
|
+
* directory) is read: an ignored prefix over a `cache.dir` there walked the
|
|
1707
|
+
* index each run writes, and the task never hit. The walk does not follow
|
|
1708
|
+
* links, so a link to a directory a glob may reach below (`linked`) is an
|
|
1709
|
+
* input itself, as a listed one is (`dirLinkInput`).
|
|
1480
1710
|
*/
|
|
1481
1711
|
function ignoredInputs(
|
|
1482
1712
|
root: string,
|
|
1483
1713
|
reach: readonly string[],
|
|
1484
1714
|
accept: (rel: string) => boolean,
|
|
1715
|
+
linked: (rel: string) => boolean,
|
|
1716
|
+
vxDirs: readonly string[] = [],
|
|
1485
1717
|
): string[] {
|
|
1486
1718
|
const found = new Set<string>()
|
|
1719
|
+
const vxOwn = (abs: string) => vxDirs.some((d) => abs === d || abs.startsWith(d + path.sep))
|
|
1487
1720
|
for (const entry of reach) {
|
|
1488
1721
|
const abs = path.resolve(root, entry)
|
|
1722
|
+
if (vxOwn(abs)) continue
|
|
1489
1723
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1490
1724
|
if (st === undefined) continue
|
|
1491
1725
|
if (!st.isDirectory()) {
|
|
@@ -1499,9 +1733,11 @@ function ignoredInputs(
|
|
|
1499
1733
|
dot: true,
|
|
1500
1734
|
})) {
|
|
1501
1735
|
const full = `${entry}/${rel.split(path.sep).join('/')}`
|
|
1502
|
-
if (!accept(full)) continue
|
|
1503
1736
|
const file = path.join(abs, rel)
|
|
1504
|
-
if (
|
|
1737
|
+
if (vxOwn(file)) continue
|
|
1738
|
+
if (accept(full)) {
|
|
1739
|
+
if (isInputOnDisk(file)) found.add(file)
|
|
1740
|
+
} else if (linked(full) && dirLinkInput(file, undefined)) found.add(file)
|
|
1505
1741
|
}
|
|
1506
1742
|
}
|
|
1507
1743
|
return [...found].sort()
|
|
@@ -1568,6 +1804,8 @@ interface FilesPlan {
|
|
|
1568
1804
|
ownOutput: (rel: string) => boolean
|
|
1569
1805
|
positiveGlobs: Bun.Glob[]
|
|
1570
1806
|
isPositive: (rel: string) => boolean
|
|
1807
|
+
/** Whether a positive glob may match a path under `rel` (`mayHoldMatch`). */
|
|
1808
|
+
mayHold: (rel: string) => boolean
|
|
1571
1809
|
/** The literal entries, each naming one path (see `unanswered`). */
|
|
1572
1810
|
literals: string[]
|
|
1573
1811
|
/** The directories a positive entry reaches; see `InputListing`. */
|
|
@@ -1580,9 +1818,9 @@ interface FilesPlan {
|
|
|
1580
1818
|
*/
|
|
1581
1819
|
verdicts: Map<string, boolean>
|
|
1582
1820
|
/**
|
|
1583
|
-
* Whether a path is
|
|
1584
|
-
* tracks
|
|
1585
|
-
*
|
|
1821
|
+
* Whether a path is under the task's own outputs, and no exclude takes it
|
|
1822
|
+
* back: one git tracks is an input (`committedFiles`) whether or not a
|
|
1823
|
+
* positive glob reaches it, since a hit writes the artifact's copy over it.
|
|
1586
1824
|
*/
|
|
1587
1825
|
ownOnly: Map<string, boolean>
|
|
1588
1826
|
}
|
|
@@ -1613,8 +1851,9 @@ function filesPlan(
|
|
|
1613
1851
|
else positive.push(entry)
|
|
1614
1852
|
}
|
|
1615
1853
|
}
|
|
1854
|
+
// Outputs alone still make a plan: a committed file under them is keyed.
|
|
1616
1855
|
const plan =
|
|
1617
|
-
positive.length === 0
|
|
1856
|
+
positive.length === 0 && splitNegations(ownOutputs).positive.length === 0
|
|
1618
1857
|
? null
|
|
1619
1858
|
: {
|
|
1620
1859
|
positive,
|
|
@@ -1624,7 +1863,8 @@ function filesPlan(
|
|
|
1624
1863
|
// stays an input: a tracked file under `dist` the build reads (A-44).
|
|
1625
1864
|
ownOutput: outputMatcher(ownOutputs),
|
|
1626
1865
|
positiveGlobs: asTrees(positive).map(globFor),
|
|
1627
|
-
isPositive: anyTaskGlob(asTrees(positive)),
|
|
1866
|
+
isPositive: throughLinks(positive, anyTaskGlob(asTrees(positive))),
|
|
1867
|
+
mayHold: mayHoldMatch(positive),
|
|
1628
1868
|
...reachOf(positive),
|
|
1629
1869
|
verdicts: new Map<string, boolean>(),
|
|
1630
1870
|
ownOnly: new Map<string, boolean>(),
|
|
@@ -1633,17 +1873,88 @@ function filesPlan(
|
|
|
1633
1873
|
return plan
|
|
1634
1874
|
}
|
|
1635
1875
|
|
|
1876
|
+
/**
|
|
1877
|
+
* `isPositive`, and each path a glob's static directory passes through:
|
|
1878
|
+
* git lists a linked directory as one path, the link, so `lib/**` over
|
|
1879
|
+
* `lib -> ../shared` matched nothing, and an edit behind it kept the key
|
|
1880
|
+
* the task's last bytes were saved under (2026-10-25). The link is then
|
|
1881
|
+
* an input, and its key includes every file behind it.
|
|
1882
|
+
*/
|
|
1883
|
+
function throughLinks(
|
|
1884
|
+
positive: readonly string[],
|
|
1885
|
+
isPositive: (rel: string) => boolean,
|
|
1886
|
+
): (rel: string) => boolean {
|
|
1887
|
+
const through = new Set<string>()
|
|
1888
|
+
// Each brace alternative by its own prefix: whole, `{lib,src}/**` has
|
|
1889
|
+
// none, and a file behind `lib -> ../shared` was no input.
|
|
1890
|
+
for (const g of positive.flatMap(braceAlternatives)) {
|
|
1891
|
+
const prefix = staticPrefix(g)
|
|
1892
|
+
if (prefix === '.' || prefix === '' || path.isAbsolute(prefix)) continue
|
|
1893
|
+
const parts = prefix.split('/')
|
|
1894
|
+
for (let i = 1; i <= parts.length; i++) through.add(parts.slice(0, i).join('/'))
|
|
1895
|
+
}
|
|
1896
|
+
return through.size === 0 ? isPositive : (rel) => isPositive(rel) || through.has(rel)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* Whether a positive glob may match a path under `rel`, by each
|
|
1901
|
+
* alternative's fixed prefix and depth (a `**` reaches any depth). A link
|
|
1902
|
+
* below a glob's fixed prefix is listed as one path too: `src/**\/*.txt`
|
|
1903
|
+
* over `src/v -> ../shared` matched the link's name, not what the task
|
|
1904
|
+
* read through it, and an edit there was a hit (2026-10-28). Such a path
|
|
1905
|
+
* that is a link to a directory is an input (`dirLinkInput`). Generous on
|
|
1906
|
+
* purpose: a wrong yes costs one lookup, and folds a linked directory.
|
|
1907
|
+
*/
|
|
1908
|
+
function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
|
|
1909
|
+
const reach = asTrees(positive.flatMap(braceAlternatives)).map((g) => {
|
|
1910
|
+
const prefix = staticPrefix(g)
|
|
1911
|
+
return {
|
|
1912
|
+
prefix: prefix === '.' ? '' : `${prefix}/`,
|
|
1913
|
+
deep: g.includes('**'),
|
|
1914
|
+
depth: g.split('/').length,
|
|
1915
|
+
}
|
|
1916
|
+
})
|
|
1917
|
+
return (rel) => {
|
|
1918
|
+
let depth: number | undefined
|
|
1919
|
+
for (const r of reach) {
|
|
1920
|
+
if (!rel.startsWith(r.prefix) || rel.length === r.prefix.length) continue
|
|
1921
|
+
if (r.deep || (depth ??= rel.split('/').length) < r.depth) return true
|
|
1922
|
+
}
|
|
1923
|
+
return false
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Whether `abs`, a path a glob may reach below, is a link to a directory.
|
|
1929
|
+
* A trusted index OID answers for a tracked file with no syscall: only a
|
|
1930
|
+
* link's (`120000:`) or an untracked path's is looked at.
|
|
1931
|
+
*/
|
|
1932
|
+
function dirLinkInput(abs: string, oids: ReadonlyMap<string, string> | undefined): boolean {
|
|
1933
|
+
const oid = oids?.get(abs)
|
|
1934
|
+
if (oid !== undefined && !oid.startsWith('120000:')) return false
|
|
1935
|
+
try {
|
|
1936
|
+
return lstatSync(abs).isSymbolicLink() && statSync(abs).isDirectory()
|
|
1937
|
+
} catch {
|
|
1938
|
+
return false
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1636
1942
|
/**
|
|
1637
1943
|
* Whether a changed path is one of a task's declared file inputs:
|
|
1638
1944
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1639
1945
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1640
1946
|
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
|
|
1641
|
-
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (
|
|
1947
|
+
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (2026-10-04).
|
|
1642
1948
|
*/
|
|
1643
1949
|
export function declaresInput(
|
|
1644
1950
|
task: TaskConfig,
|
|
1645
1951
|
projectRel: string | null,
|
|
1646
1952
|
workspaceRel: string | null,
|
|
1953
|
+
/**
|
|
1954
|
+
* The path is a link to a directory (or was one): a glob that may reach
|
|
1955
|
+
* below it keys what is behind it, as `mayHoldMatch` says at key time.
|
|
1956
|
+
*/
|
|
1957
|
+
dirLink = false,
|
|
1647
1958
|
): boolean {
|
|
1648
1959
|
const inputs = task.inputs
|
|
1649
1960
|
if (inputs === undefined) return false
|
|
@@ -1653,10 +1964,13 @@ export function declaresInput(
|
|
|
1653
1964
|
// lists: either way it may be an input, so it counts.
|
|
1654
1965
|
const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
|
|
1655
1966
|
if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
|
|
1967
|
+
if (plan !== null && dirLink && plan.mayHold(projectRel) && !plan.isExcluded(projectRel)) {
|
|
1968
|
+
return true
|
|
1969
|
+
}
|
|
1656
1970
|
}
|
|
1657
1971
|
const ws = inputs.workspaceFiles
|
|
1658
1972
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
1659
|
-
return workspaceMatcher(ws)(workspaceRel)
|
|
1973
|
+
return workspaceMatcher(ws)(workspaceRel, dirLink)
|
|
1660
1974
|
}
|
|
1661
1975
|
|
|
1662
1976
|
/**
|
|
@@ -1700,22 +2014,23 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
|
1700
2014
|
function ownOnly(plan: FilesPlan, rel: string): boolean {
|
|
1701
2015
|
let own = plan.ownOnly.get(rel)
|
|
1702
2016
|
if (own === undefined) {
|
|
1703
|
-
own = plan.ownOutput(rel) &&
|
|
2017
|
+
own = plan.ownOutput(rel) && !plan.isExcluded(rel)
|
|
1704
2018
|
plan.ownOnly.set(rel, own)
|
|
1705
2019
|
}
|
|
1706
2020
|
return own
|
|
1707
2021
|
}
|
|
1708
2022
|
|
|
1709
|
-
const workspaceMatchers = new Map<string, (rel: string) => boolean>()
|
|
2023
|
+
const workspaceMatchers = new Map<string, (rel: string, dirLink: boolean) => boolean>()
|
|
1710
2024
|
|
|
1711
|
-
function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
|
|
2025
|
+
function workspaceMatcher(decl: readonly string[]): (rel: string, dirLink: boolean) => boolean {
|
|
1712
2026
|
const key = JSON.stringify(decl)
|
|
1713
2027
|
let m = workspaceMatchers.get(key)
|
|
1714
2028
|
if (m !== undefined) return m
|
|
1715
2029
|
const { positive, negative } = splitNegations(decl)
|
|
1716
|
-
const isPositive = anyTaskGlob(asTrees(positive))
|
|
2030
|
+
const isPositive = throughLinks(positive, anyTaskGlob(asTrees(positive)))
|
|
2031
|
+
const mayHold = mayHoldMatch(positive)
|
|
1717
2032
|
const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
|
|
1718
|
-
m = (rel) => isPositive(rel) && !isExcluded(rel)
|
|
2033
|
+
m = (rel, dirLink) => (isPositive(rel) || (dirLink && mayHold(rel))) && !isExcluded(rel)
|
|
1719
2034
|
workspaceMatchers.set(key, m)
|
|
1720
2035
|
return m
|
|
1721
2036
|
}
|
|
@@ -1764,6 +2079,7 @@ async function resolveFiles(
|
|
|
1764
2079
|
plan,
|
|
1765
2080
|
nested,
|
|
1766
2081
|
ownWsOutput,
|
|
2082
|
+
args.gitFilesCache,
|
|
1767
2083
|
),
|
|
1768
2084
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1769
2085
|
}
|
|
@@ -1779,7 +2095,7 @@ async function resolveFiles(
|
|
|
1779
2095
|
// tree just changed); set() drops the project's OID slot and these
|
|
1780
2096
|
// files fall back to Cache.hashFile, which computes the identical
|
|
1781
2097
|
// blob OID from disk.
|
|
1782
|
-
const ls = runGitLsFiles(args.projectDir)
|
|
2098
|
+
const ls = runGitLsFiles(args.projectDir, listingEnv(args.projectDir, args.workspaceRoot))
|
|
1783
2099
|
gitFiles = ls.files
|
|
1784
2100
|
// set() also clears the project's pending-changed bookkeeping.
|
|
1785
2101
|
args.gitFilesCache?.set(args.projectDir, gitFiles)
|
|
@@ -1811,14 +2127,21 @@ async function resolveFiles(
|
|
|
1811
2127
|
const candidates: string[] = []
|
|
1812
2128
|
const verdicts = plan.verdicts
|
|
1813
2129
|
const committed = committedFiles(args.gitFilesCache, args.projectDir)
|
|
2130
|
+
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
1814
2131
|
for (const rel of gitFiles) {
|
|
1815
2132
|
let input = verdicts.get(rel)
|
|
1816
2133
|
if (input === undefined) {
|
|
1817
2134
|
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1818
2135
|
verdicts.set(rel, input)
|
|
1819
2136
|
}
|
|
1820
|
-
if (
|
|
2137
|
+
if (nested(rel) || ownWsOutput(rel)) continue
|
|
1821
2138
|
const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
|
|
2139
|
+
if (!input && !ownOnly(plan, rel)) {
|
|
2140
|
+
if (plan.mayHold(rel) && !isExcluded(rel) && !ownOutput(rel) && dirLinkInput(abs, oids)) {
|
|
2141
|
+
candidates.push(abs)
|
|
2142
|
+
}
|
|
2143
|
+
continue
|
|
2144
|
+
}
|
|
1822
2145
|
if (!input && !committed(abs)) continue
|
|
1823
2146
|
candidates.push(abs)
|
|
1824
2147
|
}
|
|
@@ -1830,7 +2153,6 @@ async function resolveFiles(
|
|
|
1830
2153
|
// per-file syscalls. Paths without an OID keep the probe:
|
|
1831
2154
|
// `git ls-files -s` can surface staged entries whose working-tree
|
|
1832
2155
|
// file is gone; the hasher would otherwise throw ENOENT.
|
|
1833
|
-
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
1834
2156
|
const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs))
|
|
1835
2157
|
// Git's slice comes sorted and a common prefix keeps it so: one pass
|
|
1836
2158
|
// proves it instead of a sort per task.
|
|
@@ -1842,7 +2164,15 @@ async function resolveFiles(
|
|
|
1842
2164
|
}
|
|
1843
2165
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
1844
2166
|
return {
|
|
1845
|
-
files: withIgnored(
|
|
2167
|
+
files: withIgnored(
|
|
2168
|
+
args.projectDir,
|
|
2169
|
+
resolved,
|
|
2170
|
+
reach,
|
|
2171
|
+
plan,
|
|
2172
|
+
nested,
|
|
2173
|
+
ownWsOutput,
|
|
2174
|
+
args.gitFilesCache,
|
|
2175
|
+
),
|
|
1846
2176
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1847
2177
|
}
|
|
1848
2178
|
}
|
|
@@ -1855,18 +2185,26 @@ function withIgnored(
|
|
|
1855
2185
|
plan: FilesPlan,
|
|
1856
2186
|
nested: (rel: string) => boolean,
|
|
1857
2187
|
ownWsOutput: (rel: string) => boolean,
|
|
2188
|
+
cache: GitFilesCache | undefined,
|
|
1858
2189
|
): string[] {
|
|
1859
2190
|
if (reach.length === 0) return [...resolved]
|
|
1860
2191
|
const extra = ignoredInputs(
|
|
1861
2192
|
projectDir,
|
|
1862
2193
|
reach,
|
|
1863
2194
|
(rel) => inPlan(plan, rel) && !nested(rel) && !ownWsOutput(rel),
|
|
2195
|
+
(rel) =>
|
|
2196
|
+
plan.mayHold(rel) &&
|
|
2197
|
+
!plan.isExcluded(rel) &&
|
|
2198
|
+
!plan.ownOutput(rel) &&
|
|
2199
|
+
!nested(rel) &&
|
|
2200
|
+
!ownWsOutput(rel),
|
|
2201
|
+
cache?.vxDirs,
|
|
1864
2202
|
)
|
|
1865
2203
|
return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
|
|
1866
2204
|
}
|
|
1867
2205
|
|
|
1868
2206
|
function listingFor(
|
|
1869
|
-
args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
|
|
2207
|
+
args: { projectDir: string; workspaceRoot: string; gitFilesCache?: GitFilesCache | undefined },
|
|
1870
2208
|
listed: readonly string[],
|
|
1871
2209
|
plan: FilesPlan,
|
|
1872
2210
|
nested: (rel: string) => boolean,
|
|
@@ -1876,12 +2214,15 @@ function listingFor(
|
|
|
1876
2214
|
let committed: ((abs: string) => boolean) | undefined
|
|
1877
2215
|
return {
|
|
1878
2216
|
root,
|
|
2217
|
+
workspaceRoot: args.workspaceRoot,
|
|
1879
2218
|
listed,
|
|
1880
2219
|
isInput: (rel) =>
|
|
1881
2220
|
(inPlan(plan, rel) ||
|
|
1882
2221
|
(ownOnly(plan, rel) &&
|
|
1883
2222
|
(committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
|
|
1884
2223
|
!ownWsOutput(rel),
|
|
2224
|
+
linked: (rel) =>
|
|
2225
|
+
plan.mayHold(rel) && !plan.isExcluded(rel) && !plan.ownOutput(rel) && !ownWsOutput(rel),
|
|
1885
2226
|
nested,
|
|
1886
2227
|
prefixes: plan.prefixes,
|
|
1887
2228
|
literals: plan.literals,
|
|
@@ -1943,9 +2284,16 @@ function undecodableOnDisk(abs: string): boolean {
|
|
|
1943
2284
|
*/
|
|
1944
2285
|
export interface InputListing {
|
|
1945
2286
|
root: string
|
|
2287
|
+
/** Where git's location variables point; `root` in a repository below it is listed without them. */
|
|
2288
|
+
workspaceRoot: string
|
|
1946
2289
|
/** Root-relative paths, as git listed them. */
|
|
1947
2290
|
listed: readonly string[]
|
|
1948
2291
|
isInput: (rel: string) => boolean
|
|
2292
|
+
/**
|
|
2293
|
+
* Whether a link to a directory at `rel` is an input: a glob may match
|
|
2294
|
+
* below it (`mayHoldMatch`), though its name fits none (`dirLinkInput`).
|
|
2295
|
+
*/
|
|
2296
|
+
linked: (rel: string) => boolean
|
|
1949
2297
|
nested: (rel: string) => boolean
|
|
1950
2298
|
/** Root-relative directories a positive entry reaches, `''` for the root. */
|
|
1951
2299
|
prefixes: readonly string[]
|
|
@@ -2051,7 +2399,10 @@ function addedTo(
|
|
|
2051
2399
|
if (known.has(rel) || e.name === 'node_modules' || l.nested(`${rel}/`)) continue
|
|
2052
2400
|
if (reaches(l.prefixes, rel)) candidates.push(rel)
|
|
2053
2401
|
} else if (
|
|
2054
|
-
l.isInput(rel)
|
|
2402
|
+
(l.isInput(rel) ||
|
|
2403
|
+
(e.isSymbolicLink() &&
|
|
2404
|
+
l.linked(rel) &&
|
|
2405
|
+
dirLinkInput(path.resolve(abs, e.name), undefined))) &&
|
|
2055
2406
|
!l.nested(rel) &&
|
|
2056
2407
|
!isInstalledPath(rel) &&
|
|
2057
2408
|
!keyFiles.has(path.resolve(abs, e.name))
|
|
@@ -2061,10 +2412,11 @@ function addedTo(
|
|
|
2061
2412
|
}
|
|
2062
2413
|
}
|
|
2063
2414
|
if (candidates.length === 0) return undefined
|
|
2064
|
-
for (const rel of runGitLsFiles(l.root).files) {
|
|
2415
|
+
for (const rel of runGitLsFiles(l.root, listingEnv(l.root, l.workspaceRoot)).files) {
|
|
2065
2416
|
if (!candidates.some((c) => c === '' || rel === c || rel.startsWith(`${c}/`))) continue
|
|
2066
|
-
if (isInstalledPath(rel) || l.nested(rel)
|
|
2417
|
+
if (isInstalledPath(rel) || l.nested(rel)) continue
|
|
2067
2418
|
const abs = path.resolve(l.root, rel)
|
|
2419
|
+
if (!l.isInput(rel) && !(l.linked(rel) && dirLinkInput(abs, undefined))) continue
|
|
2068
2420
|
if (!keyFiles.has(abs) && isInputOnDisk(abs)) return abs
|
|
2069
2421
|
}
|
|
2070
2422
|
return undefined
|
|
@@ -2103,25 +2455,103 @@ async function scanUnion(
|
|
|
2103
2455
|
emptyDirs?: Set<string>,
|
|
2104
2456
|
): Promise<Set<string>> {
|
|
2105
2457
|
const matches = new Set<string>()
|
|
2458
|
+
const links: string[] = []
|
|
2459
|
+
const take = (rel: string): void => {
|
|
2460
|
+
if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
|
|
2461
|
+
const abs = path.resolve(cwd, rel)
|
|
2462
|
+
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
2463
|
+
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) {
|
|
2464
|
+
matches.add(abs)
|
|
2465
|
+
if (st.isSymbolicLink()) links.push(abs)
|
|
2466
|
+
} else if (emptyDirs !== undefined && st?.isDirectory() === true) {
|
|
2467
|
+
if (readdirSync(abs).length === 0) emptyDirs.add(abs)
|
|
2468
|
+
} else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
|
|
2469
|
+
}
|
|
2470
|
+
const opts = { cwd, onlyFiles: false, followSymlinks: false, dot: true }
|
|
2106
2471
|
// `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
|
|
2107
2472
|
// `/`: `{dist,lib/esm}/**` saved an empty artifact, and a hit restored
|
|
2108
2473
|
// nothing over a cleaned tree (A-10). Expanded here, as discovery does.
|
|
2109
|
-
|
|
2474
|
+
const patterns = positive.flatMap(slashBraceExpansions)
|
|
2475
|
+
// A leading `**` walks the whole project whatever else is listed, so the
|
|
2476
|
+
// list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
|
|
2477
|
+
// ~2 ms of a warm hit on solid.
|
|
2478
|
+
// Not where a static head passes through a link: the one walk does not
|
|
2479
|
+
// follow links, and the per-pattern scan enters them (X-88).
|
|
2480
|
+
if (
|
|
2481
|
+
patterns.length > 1 &&
|
|
2482
|
+
patterns.some((p) => p.startsWith('**')) &&
|
|
2483
|
+
!patterns.some((p) => headLinked(cwd, p))
|
|
2484
|
+
) {
|
|
2485
|
+
const any = anyTaskGlob(patterns)
|
|
2486
|
+
for (const rel of WHOLE_TREE.scanSync(opts)) if (any(rel)) take(rel)
|
|
2487
|
+
return matches
|
|
2488
|
+
}
|
|
2489
|
+
for (const pattern of patterns) {
|
|
2110
2490
|
if (absentPrefix(cwd, pattern)) continue
|
|
2111
|
-
const
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
const
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2491
|
+
const from = scanRoot(cwd, pattern)
|
|
2492
|
+
if (from === null) continue
|
|
2493
|
+
if (from === undefined) for (const rel of globFor(pattern).scanSync(opts)) take(rel)
|
|
2494
|
+
else
|
|
2495
|
+
for (const rel of globFor(from.rest).scanSync({ ...opts, cwd: from.dir }))
|
|
2496
|
+
take(`${from.prefix}/${rel}`)
|
|
2497
|
+
}
|
|
2498
|
+
// A literal names the path and its tree (`dist`, `dist/**`), and a tree
|
|
2499
|
+
// whose head is a directory link is walked through it: the link and the
|
|
2500
|
+
// files under it were one artifact holding `dist` as a file and a
|
|
2501
|
+
// directory, refused, so the task never cached. The walk is the output.
|
|
2502
|
+
if (links.length > 0) {
|
|
2503
|
+
const sorted = [...matches].sort()
|
|
2504
|
+
for (const link of links) {
|
|
2505
|
+
const under = `${link}/`
|
|
2506
|
+
const at = sorted.findIndex((m) => m >= under)
|
|
2507
|
+
if (at !== -1 && sorted[at]!.startsWith(under)) matches.delete(link)
|
|
2120
2508
|
}
|
|
2121
2509
|
}
|
|
2122
2510
|
return matches
|
|
2123
2511
|
}
|
|
2124
2512
|
|
|
2513
|
+
/** `pattern`'s static directory passes through a symlink under `cwd`. */
|
|
2514
|
+
function headLinked(cwd: string, pattern: string): boolean {
|
|
2515
|
+
const prefix = staticPrefix(pattern)
|
|
2516
|
+
if (prefix === '.' || path.isAbsolute(prefix)) return false
|
|
2517
|
+
let at = cwd
|
|
2518
|
+
for (const part of prefix.split('/')) {
|
|
2519
|
+
at = path.join(at, part)
|
|
2520
|
+
const st = lstatSync(at, { throwIfNoEntry: false })
|
|
2521
|
+
if (st === undefined || !st.isDirectory()) return st?.isSymbolicLink() === true
|
|
2522
|
+
}
|
|
2523
|
+
return false
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/**
|
|
2527
|
+
* Where `pattern`'s scan can start: its static directory, with the rest of
|
|
2528
|
+
* the pattern to match there. `Bun.Glob` walks the whole of `cwd` whatever
|
|
2529
|
+
* the prefix: `out/d1/**` beside 9000 sibling dirs cost 2.3 ms a scan, twice
|
|
2530
|
+
* per miss (2026-10-25). Null when the prefix is a file, which nothing lies
|
|
2531
|
+
* under (a literal's `<file>/**` tree). Undefined (scan from `cwd`) for no
|
|
2532
|
+
* prefix, an escape, or a prefix that does not stat (a dangling link, a
|
|
2533
|
+
* cycle), whose refusals the walk from `cwd` names. A prefix through a
|
|
2534
|
+
* link is entered either way.
|
|
2535
|
+
*/
|
|
2536
|
+
function scanRoot(
|
|
2537
|
+
cwd: string,
|
|
2538
|
+
pattern: string,
|
|
2539
|
+
): { prefix: string; rest: string; dir: string } | null | undefined {
|
|
2540
|
+
const prefix = staticPrefix(pattern)
|
|
2541
|
+
if (prefix === '.' || prefix.includes('\\') || path.isAbsolute(prefix)) return undefined
|
|
2542
|
+
if (!pattern.startsWith(`${prefix}/`) || prefix.split('/').includes('..')) return undefined
|
|
2543
|
+
const dir = path.join(cwd, prefix)
|
|
2544
|
+
let st
|
|
2545
|
+
try {
|
|
2546
|
+
st = statSync(dir, { throwIfNoEntry: false })
|
|
2547
|
+
} catch {
|
|
2548
|
+
return undefined
|
|
2549
|
+
}
|
|
2550
|
+
if (st === undefined) return undefined
|
|
2551
|
+
if (!st.isDirectory()) return null
|
|
2552
|
+
return { prefix, rest: pattern.slice(prefix.length + 1), dir }
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2125
2555
|
/**
|
|
2126
2556
|
* True when `pattern`'s static directory is not on disk, so nothing can
|
|
2127
2557
|
* match under it. A restore into a tree without its outputs scans each
|
|
@@ -2139,6 +2569,8 @@ function absentPrefix(cwd: string, pattern: string): boolean {
|
|
|
2139
2569
|
}
|
|
2140
2570
|
}
|
|
2141
2571
|
|
|
2572
|
+
const WHOLE_TREE = new Bun.Glob('**')
|
|
2573
|
+
|
|
2142
2574
|
// Compiled once per pattern string for the life of the process: a `Bun.Glob`
|
|
2143
2575
|
// is immutable, and the same handful of declared globs are compiled again
|
|
2144
2576
|
// for every task otherwise (three sites, up to thousands of times per run).
|