@vzn/vx 0.0.646 → 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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
1
2
|
import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
|
|
2
3
|
import {
|
|
3
4
|
asTrees,
|
|
5
|
+
braceAlternatives,
|
|
6
|
+
GLOB_WILDCARDS,
|
|
4
7
|
checkIgnoreSync,
|
|
5
8
|
ignoreProbes,
|
|
6
9
|
isLiteralPattern,
|
|
7
10
|
literalPath,
|
|
8
11
|
listedSync,
|
|
12
|
+
nearest,
|
|
9
13
|
normalizeGlob,
|
|
10
14
|
relPosix,
|
|
11
15
|
splitNegations,
|
|
@@ -15,15 +19,14 @@ import {
|
|
|
15
19
|
UserError,
|
|
16
20
|
wholeIgnored,
|
|
17
21
|
wholeSubtreePrefixes,
|
|
18
|
-
} from '../util/index.js'
|
|
19
|
-
import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
|
|
20
|
-
import {
|
|
21
22
|
DependencySpecError,
|
|
22
23
|
compileTaskPattern,
|
|
23
24
|
isTaskPattern,
|
|
24
25
|
parseDependencySpec,
|
|
25
26
|
type DependencySpec,
|
|
26
|
-
} from '
|
|
27
|
+
} from '../util/index.js'
|
|
28
|
+
import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
|
|
29
|
+
import { isDefaultBuild } from './default-build.js'
|
|
27
30
|
import { foldedDeps, isGroupTask } from './folds.js'
|
|
28
31
|
import type { TaskOutcome } from './scheduler.js'
|
|
29
32
|
|
|
@@ -69,6 +72,11 @@ export interface TaskNode {
|
|
|
69
72
|
* Absent when no plugin declares the stage.
|
|
70
73
|
*/
|
|
71
74
|
keyParts?: ReadonlyArray<readonly [name: string, value: string]>
|
|
75
|
+
/**
|
|
76
|
+
* The platform this run runs it on, when it declares `exec.platforms`:
|
|
77
|
+
* set by placement, folded into its key.
|
|
78
|
+
*/
|
|
79
|
+
platform?: string
|
|
72
80
|
/**
|
|
73
81
|
* Ids of upstream tasks whose declared outputs this task's outputs
|
|
74
82
|
* overlap: this task ADDS to their trees (twenty's `build:individual`
|
|
@@ -102,9 +110,20 @@ export function taskId(project: string, task: string): string {
|
|
|
102
110
|
// graph is where every reader of a task id looks for it.
|
|
103
111
|
export { splitTaskId } from '../util/index.js'
|
|
104
112
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
/**
|
|
114
|
+
* A keyed group that is only its dependencies' own `name` (`^name`): the
|
|
115
|
+
* default `build` (orchestrator/projects.ts), or a config's of that shape.
|
|
116
|
+
* Only this shape walks last on a package cycle and is passed through.
|
|
117
|
+
* Any keyed group was: `ci` keyed on `lint` lost its edge and ran nothing,
|
|
118
|
+
* and `deploy` on `^build` walked `^deploy`.
|
|
119
|
+
*/
|
|
120
|
+
function isChainGroup(task: TaskConfig, name: string): boolean {
|
|
121
|
+
return (
|
|
122
|
+
task.exec === undefined &&
|
|
123
|
+
task.inputs !== undefined &&
|
|
124
|
+
task.dependsOn?.length === 1 &&
|
|
125
|
+
task.dependsOn[0] === `^${name}`
|
|
126
|
+
)
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
/**
|
|
@@ -283,11 +302,17 @@ export interface BuildGraphOptions {
|
|
|
283
302
|
workspaceRoot?: string
|
|
284
303
|
/** The workspace's `run.rules`; each is on unless set to `false`. */
|
|
285
304
|
rules?: GraphRules | undefined
|
|
305
|
+
/**
|
|
306
|
+
* By project name, every project rooted inside its dir (absolute dirs).
|
|
307
|
+
* Given with `workspaceRoot` and unlocked (`vx lock`), an `inputs.files`
|
|
308
|
+
* entry rooted inside one is refused (`refuseNestedInputs`).
|
|
309
|
+
*/
|
|
310
|
+
nestedProjects?: ReadonlyMap<string, ReadonlyArray<{ name: string; dir: string }>>
|
|
286
311
|
}
|
|
287
312
|
|
|
288
313
|
/**
|
|
289
314
|
* `run.rules` as the graph applies them. `locked`: a run, which never
|
|
290
|
-
* checks them; `vx lock` and `vx lock --check` do (
|
|
315
|
+
* checks them; `vx lock` and `vx lock --check` do (2026-10-14).
|
|
291
316
|
*/
|
|
292
317
|
export type GraphRules = WorkspaceRules & { readonly locked?: boolean }
|
|
293
318
|
|
|
@@ -298,9 +323,14 @@ export type GraphRules = WorkspaceRules & { readonly locked?: boolean }
|
|
|
298
323
|
* legal without a holder — a preset spreads `^build` over projects whose
|
|
299
324
|
* dependencies lack it.
|
|
300
325
|
*/
|
|
301
|
-
export function undeclaredDepsError(
|
|
326
|
+
export function undeclaredDepsError(
|
|
327
|
+
taskId: string,
|
|
328
|
+
name: string,
|
|
329
|
+
declared: Iterable<string>,
|
|
330
|
+
): UserError {
|
|
331
|
+
const meant = nearest(name, declared)
|
|
302
332
|
return new UserError(
|
|
303
|
-
`${taskId}: dependsOn ^${name} but no project in the workspace declares ${name}`,
|
|
333
|
+
`${taskId}: dependsOn ^${name} but no project in the workspace declares ${name}${meant === undefined ? '' : `. Did you mean ^${meant}?`}`,
|
|
304
334
|
'VX_E_DEPENDS_ON',
|
|
305
335
|
)
|
|
306
336
|
}
|
|
@@ -335,7 +365,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
335
365
|
|
|
336
366
|
function add(node: TaskNode): void {
|
|
337
367
|
nodes.set(node.id, node)
|
|
338
|
-
if (
|
|
368
|
+
if (isChainGroup(node.config, node.taskName) && onCycle(node.projectName)) deferred.push(node)
|
|
339
369
|
else if ((node.config.dependsOn?.length ?? 0) > 0) {
|
|
340
370
|
stack.push({ node, entry: 0, added: false, pending: null, next: 0 })
|
|
341
371
|
}
|
|
@@ -401,7 +431,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
401
431
|
return declared.has(name)
|
|
402
432
|
}
|
|
403
433
|
|
|
404
|
-
// Package cycles, for the default `build` (`
|
|
434
|
+
// Package cycles, for the default `build` (`isChainGroup`): each project
|
|
405
435
|
// on one maps to its strongly connected component, found on the first ask
|
|
406
436
|
// (iterative Tarjan over `directDeps`, the order the `^name` walk reads).
|
|
407
437
|
const component = new Map<string, number>()
|
|
@@ -466,7 +496,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
466
496
|
if (visit(frame, target, name, false)) {
|
|
467
497
|
held = true
|
|
468
498
|
const task = declaredTask(projects.get(target)!.config, name)!
|
|
469
|
-
if (
|
|
499
|
+
if (isChainGroup(task, name) && onCycle(target)) {
|
|
470
500
|
frontier.push(...packageGraph.directDeps(target))
|
|
471
501
|
}
|
|
472
502
|
} else frontier.push(...packageGraph.directDeps(target))
|
|
@@ -474,13 +504,14 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
474
504
|
return held
|
|
475
505
|
}
|
|
476
506
|
|
|
477
|
-
// An edge by name (`build`, `pkg#build`)
|
|
478
|
-
// cycle goes on past it as a `^build` walk
|
|
479
|
-
// of the builds on its cycle, so `a#test`
|
|
480
|
-
// on the default build folded none of `b`
|
|
507
|
+
// An edge by name (`build`, `pkg#build`) or pattern (`bui*`, `^bui*`) to
|
|
508
|
+
// a default build on a package cycle goes on past it as a `^build` walk
|
|
509
|
+
// does: that build folds nothing of the builds on its cycle, so `a#test`
|
|
510
|
+
// on `build` with `a` ↔ `b` both on the default build folded none of `b`
|
|
511
|
+
// and hit after `b` changed.
|
|
481
512
|
function goPast(frame: Frame, project: string, name: string, visited: Set<string>): void {
|
|
482
513
|
const task = declaredTask(projects.get(project)!.config, name)!
|
|
483
|
-
if (
|
|
514
|
+
if (isChainGroup(task, name) && onCycle(project)) walkHolders(frame, name, visited, project)
|
|
484
515
|
}
|
|
485
516
|
|
|
486
517
|
// Resolves one `dependsOn` entry of `frame`'s task into its edges.
|
|
@@ -489,7 +520,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
489
520
|
const { id, projectName, taskName } = node
|
|
490
521
|
let spec: DependencySpec
|
|
491
522
|
try {
|
|
492
|
-
spec = parseDependencySpec(raw)
|
|
523
|
+
spec = parseDependencySpec(raw, 'dependsOn')
|
|
493
524
|
} catch (err) {
|
|
494
525
|
if (err instanceof DependencySpecError) {
|
|
495
526
|
throw new UserError(`${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
|
|
@@ -530,13 +561,20 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
530
561
|
for (const name of Object.keys(projects.get(projectName)!.config.tasks ?? {})) {
|
|
531
562
|
if (name === taskName || !re.test(name)) continue
|
|
532
563
|
visit(frame, projectName, name, false)
|
|
564
|
+
goPast(frame, projectName, name, new Set([projectName]))
|
|
533
565
|
}
|
|
534
566
|
} else {
|
|
535
567
|
// Missing target is a hard error — the user typed a name that
|
|
536
568
|
// doesn't resolve in this project.
|
|
537
569
|
if (!visit(frame, projectName, spec.task, false)) {
|
|
570
|
+
const meant = nearest(
|
|
571
|
+
spec.task,
|
|
572
|
+
Object.keys(projects.get(projectName)!.config.tasks ?? {}).filter(
|
|
573
|
+
(n) => n !== taskName,
|
|
574
|
+
),
|
|
575
|
+
)
|
|
538
576
|
throw new UserError(
|
|
539
|
-
`${id}: dependsOn ${taskId(projectName, spec.task)} but no such task is declared`,
|
|
577
|
+
`${id}: dependsOn ${taskId(projectName, spec.task)} but no such task is declared${meant === undefined ? '' : `. Did you mean ${meant}?`}`,
|
|
540
578
|
'VX_E_DEPENDS_ON',
|
|
541
579
|
)
|
|
542
580
|
}
|
|
@@ -576,7 +614,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
576
614
|
// task behind it would fold no upstream either.
|
|
577
615
|
if (!packageGraph.edgeSource) {
|
|
578
616
|
throw new UserError(
|
|
579
|
-
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
|
|
617
|
+
`${id}: ${isDefaultBuild(node.config) ? `${projectName} declares no build, so it is the default build, whose ` : ''}^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
|
|
580
618
|
'VX_E_DEPENDS_ON',
|
|
581
619
|
)
|
|
582
620
|
}
|
|
@@ -584,7 +622,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
584
622
|
if (!isTaskPattern(spec.task)) {
|
|
585
623
|
const held = walkHolders(frame, spec.task, visited, projectName)
|
|
586
624
|
if (!held && !declaredAnywhere(projectName, spec.task)) {
|
|
587
|
-
if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task)
|
|
625
|
+
if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task, declared ?? [])
|
|
588
626
|
undeclaredDeps(id, spec.task)
|
|
589
627
|
}
|
|
590
628
|
return
|
|
@@ -600,7 +638,10 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
600
638
|
re.test(n),
|
|
601
639
|
)
|
|
602
640
|
if (names.length > 0) {
|
|
603
|
-
for (const name of names)
|
|
641
|
+
for (const name of names) {
|
|
642
|
+
visit(frame, target, name, false)
|
|
643
|
+
goPast(frame, target, name, new Set([projectName, target]))
|
|
644
|
+
}
|
|
604
645
|
} else {
|
|
605
646
|
frontier.push(...packageGraph.directDeps(target))
|
|
606
647
|
}
|
|
@@ -684,13 +725,13 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
684
725
|
}
|
|
685
726
|
const to = taskId(target, name)
|
|
686
727
|
if (sameCycle(x.projectName, target)) {
|
|
687
|
-
if (!
|
|
728
|
+
if (!isChainGroup(task, name) && !nodes.has(to)) {
|
|
688
729
|
visit(null, target, name, false)
|
|
689
730
|
drain()
|
|
690
731
|
expanded = true
|
|
691
732
|
reaching = null
|
|
692
733
|
}
|
|
693
|
-
if (
|
|
734
|
+
if (isChainGroup(task, name) || (reaching ??= reachingTo(nodes, x.id)).has(to)) {
|
|
694
735
|
frontier.push(...packageGraph.directDeps(target))
|
|
695
736
|
continue
|
|
696
737
|
}
|
|
@@ -700,7 +741,8 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
700
741
|
visit(frame, target, name, false)
|
|
701
742
|
drain()
|
|
702
743
|
if (nodes.size !== size) reaching = null
|
|
703
|
-
if (
|
|
744
|
+
if (isChainGroup(task, name) && onCycle(target))
|
|
745
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
704
746
|
}
|
|
705
747
|
x.deps = [...new Set(x.deps)].sort()
|
|
706
748
|
}
|
|
@@ -718,9 +760,50 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
718
760
|
}
|
|
719
761
|
|
|
720
762
|
checkGraph(nodes, options.workspaceRoot, options.rules)
|
|
763
|
+
if (options.nestedProjects !== undefined && options.rules?.locked !== true)
|
|
764
|
+
refuseNestedInputs(nodes, options.nestedProjects, options.workspaceRoot)
|
|
721
765
|
return nodes
|
|
722
766
|
}
|
|
723
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Refuse an `inputs.files` entry rooted inside a project nested in the
|
|
770
|
+
* task's own: every file it can match belongs to that project, which a
|
|
771
|
+
* project's globs never reach, so the entry keys nothing and an edit there
|
|
772
|
+
* hits (a `dist/**` over a build output holding its own config was dropped
|
|
773
|
+
* from the key in silence). Judged by each brace alternative's fixed
|
|
774
|
+
* prefix: `**` or a sibling reaches past the nested dir and is fine.
|
|
775
|
+
*/
|
|
776
|
+
function refuseNestedInputs(
|
|
777
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
778
|
+
nested: ReadonlyMap<string, ReadonlyArray<{ name: string; dir: string }>>,
|
|
779
|
+
workspaceRoot: string | undefined,
|
|
780
|
+
): void {
|
|
781
|
+
for (const n of nodes.values()) {
|
|
782
|
+
const inner = nested.get(n.projectName)
|
|
783
|
+
if (inner === undefined || inner.length === 0 || isGroupTask(n)) continue
|
|
784
|
+
for (const g of positives(n.config.inputs?.files)) {
|
|
785
|
+
for (const alt of braceAlternatives(g)) {
|
|
786
|
+
const prefix = staticPrefix(alt)
|
|
787
|
+
if (prefix === '.' || prefix === '') continue
|
|
788
|
+
const abs = path.join(n.projectDir, prefix)
|
|
789
|
+
const hit = inner.find((p) => abs === p.dir || abs.startsWith(p.dir + path.sep))
|
|
790
|
+
if (hit === undefined) continue
|
|
791
|
+
const rel =
|
|
792
|
+
workspaceRoot === undefined
|
|
793
|
+
? hit.name
|
|
794
|
+
: relPosix(workspaceRoot, path.join(n.projectDir, alt.replace(/^\.\//, '')))
|
|
795
|
+
throw new UserError(
|
|
796
|
+
`${n.id} reads ${JSON.stringify(g)} in inputs.files, which lies inside the project ` +
|
|
797
|
+
`${hit.name}: a project's globs never reach another project's files, so it keys ` +
|
|
798
|
+
`nothing. Depend on a ${hit.name} task, or name the files in inputs.workspaceFiles ` +
|
|
799
|
+
`(${JSON.stringify(rel)}).`,
|
|
800
|
+
'VX_E_NESTED_INPUT',
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
724
807
|
/** The ids of the tasks that reach `target` along `deps`, itself included. */
|
|
725
808
|
function reachingTo(nodes: Map<string, TaskNode>, target: string): Set<string> {
|
|
726
809
|
const dependants = new Map<string, string[]>()
|
|
@@ -913,6 +996,16 @@ export function excludeDependencies(
|
|
|
913
996
|
* 445). One rule, one place: the copy is gone.
|
|
914
997
|
*/
|
|
915
998
|
export function outputsOverlap(rawA: string, rawB: string): boolean {
|
|
999
|
+
// Each brace alternative on its own: the checks below read a brace as
|
|
1000
|
+
// one opaque segment, so `build/{a,b}.js` met `build/*.js`, and
|
|
1001
|
+
// `dist/{esm,cjs}/**` met `**/*.js`, nowhere (fuzz, 2026-10-25).
|
|
1002
|
+
const as = braceAlternatives(rawA)
|
|
1003
|
+
const bs = braceAlternatives(rawB)
|
|
1004
|
+
if (as.length === 1 && bs.length === 1) return overlapOne(rawA, rawB)
|
|
1005
|
+
return as.some((a) => bs.some((b) => overlapOne(a, b)))
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function overlapOne(rawA: string, rawB: string): boolean {
|
|
916
1009
|
// A built path proves overlap only between globs as written: a literal's
|
|
917
1010
|
// `/**` twin reads it as a directory, and `dist/types.d.ts` met
|
|
918
1011
|
// `dist/**/*.js` at `dist/types.d.ts/x.js`, a file no one can write.
|
|
@@ -966,7 +1059,7 @@ function witnessed(a: string, b: string): boolean {
|
|
|
966
1059
|
}
|
|
967
1060
|
// Each glob filled in whole, every wildcard a name and `**` one level or
|
|
968
1061
|
// none: `**/dist/**` beside `**/*` meets at `x/dist/x`, a path neither
|
|
969
|
-
// last segment builds (solid,
|
|
1062
|
+
// last segment builds (solid, 2026-10-15).
|
|
970
1063
|
for (const glob of [a, b]) {
|
|
971
1064
|
const segs = glob.split('/')
|
|
972
1065
|
if (segs.some((s) => /[[\]{}()!+@]/.test(s))) continue
|
|
@@ -977,7 +1070,97 @@ function witnessed(a: string, b: string): boolean {
|
|
|
977
1070
|
if (candidate !== '' && ga.match(candidate) && gb.match(candidate)) return true
|
|
978
1071
|
}
|
|
979
1072
|
}
|
|
980
|
-
|
|
1073
|
+
const met = unified(a.split('/'), b.split('/'))
|
|
1074
|
+
return met !== null && met !== '' && ga.match(met) && gb.match(met)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* A path both globs take, built segment by segment, each a name both
|
|
1079
|
+
* segments take, `**` matching a segment of the other or none: `out/**`
|
|
1080
|
+
* beside every `.d.ts` in a `types` directory one level down meets at
|
|
1081
|
+
* `out/types/x.d.ts`, and `*` beside `[ab]` at `a`, paths neither glob
|
|
1082
|
+
* filled alone builds (fuzz, 2026-10-25). Null when none is found; a find
|
|
1083
|
+
* is checked whole after.
|
|
1084
|
+
*/
|
|
1085
|
+
function unified(sa: readonly string[], sb: readonly string[]): string | null {
|
|
1086
|
+
// A `!` opening a class is the class's; anywhere else, an extglob.
|
|
1087
|
+
if ([...sa, ...sb].some((x) => /[{}()!+@]/.test(x.replaceAll('[!', '[')))) return null
|
|
1088
|
+
const memo = new Map<string, string | null>()
|
|
1089
|
+
const join = (name: string, rest: string): string => (rest === '' ? name : `${name}/${rest}`)
|
|
1090
|
+
const walk = (i: number, j: number): string | null => {
|
|
1091
|
+
const key = `${i},${j}`
|
|
1092
|
+
if (memo.has(key)) return memo.get(key)!
|
|
1093
|
+
memo.set(key, null)
|
|
1094
|
+
let out: string | null = null
|
|
1095
|
+
if (i === sa.length && j === sb.length) out = ''
|
|
1096
|
+
else if (sa[i] === '**' || sb[j] === '**') {
|
|
1097
|
+
// A last `**` takes one segment at least: `out/**` is not `out`.
|
|
1098
|
+
const lastA = sa[i] === '**' && i === sa.length - 1
|
|
1099
|
+
const lastB = sb[j] === '**' && j === sb.length - 1
|
|
1100
|
+
const [wide, x, y, other] = sa[i] === '**' ? [sa, i, j, sb] : [sb, j, i, sa]
|
|
1101
|
+
if (lastA && lastB) out = 'x'
|
|
1102
|
+
if (out === null && sa[i] === '**' && !lastA) out = walk(i + 1, j)
|
|
1103
|
+
if (out === null && sb[j] === '**' && !lastB) out = walk(i, j + 1)
|
|
1104
|
+
if (out === null && y < other.length && other[y] !== '**') {
|
|
1105
|
+
const name = segmentMeet('*', other[y]!)
|
|
1106
|
+
// Having taken one, it may take more or stop.
|
|
1107
|
+
const rest =
|
|
1108
|
+
name === null
|
|
1109
|
+
? null
|
|
1110
|
+
: wide === sa
|
|
1111
|
+
? (walk(x, y + 1) ?? walk(x + 1, y + 1))
|
|
1112
|
+
: (walk(y + 1, x) ?? walk(y + 1, x + 1))
|
|
1113
|
+
if (name !== null && rest !== null) out = join(name, rest)
|
|
1114
|
+
}
|
|
1115
|
+
} else if (i < sa.length && j < sb.length) {
|
|
1116
|
+
const name = segmentMeet(sa[i]!, sb[j]!)
|
|
1117
|
+
const rest = name === null ? null : walk(i + 1, j + 1)
|
|
1118
|
+
if (name !== null && rest !== null) out = join(name, rest)
|
|
1119
|
+
}
|
|
1120
|
+
memo.set(key, out)
|
|
1121
|
+
return out
|
|
1122
|
+
}
|
|
1123
|
+
return walk(0, 0)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** A name both segments take, from each filled in and the one's head joined to the other's tail. */
|
|
1127
|
+
function segmentMeet(x: string, y: string): string | null {
|
|
1128
|
+
const fill = (seg: string, star: string): string =>
|
|
1129
|
+
seg
|
|
1130
|
+
.replace(/\*+/g, star)
|
|
1131
|
+
.replace(/\?/g, 'x')
|
|
1132
|
+
// A negated class takes a name it does not list: `[!a]` is not `a`.
|
|
1133
|
+
.replace(/\[([!^]?)(.)([^\]]*)\]/g, (_, not: string, first: string, more: string) =>
|
|
1134
|
+
not === ''
|
|
1135
|
+
? first
|
|
1136
|
+
: (['x', 'y', 'z', '0', '_'].find((c) => !`${first}${more}`.includes(c)) ?? first),
|
|
1137
|
+
)
|
|
1138
|
+
const head = (seg: string): string => {
|
|
1139
|
+
const at = seg.search(GLOB_WILDCARDS)
|
|
1140
|
+
return at === -1 ? seg : seg.slice(0, at)
|
|
1141
|
+
}
|
|
1142
|
+
const tail = (seg: string): string => {
|
|
1143
|
+
let at = seg.length
|
|
1144
|
+
while (at > 0 && !GLOB_WILDCARDS.test(seg[at - 1]!)) at--
|
|
1145
|
+
return seg.slice(at)
|
|
1146
|
+
}
|
|
1147
|
+
const gx = new Bun.Glob(x)
|
|
1148
|
+
const gy = new Bun.Glob(y)
|
|
1149
|
+
for (const c of [
|
|
1150
|
+
fill(x, 'x'),
|
|
1151
|
+
fill(y, 'x'),
|
|
1152
|
+
fill(x, ''),
|
|
1153
|
+
fill(y, ''),
|
|
1154
|
+
head(x) + tail(y),
|
|
1155
|
+
head(y) + tail(x),
|
|
1156
|
+
// Each name a class lists, the other's class filled: `[ab]` beside `[!a]` meets at `b`.
|
|
1157
|
+
...[x, y].flatMap((seg) => {
|
|
1158
|
+
const listed = /\[([^!^\]][^\]]*)\]/.exec(seg)?.[1] ?? ''
|
|
1159
|
+
return listed.split('').map((ch) => fill(seg.replace(/\[[^!^\]][^\]]*\]/, ch), 'x'))
|
|
1160
|
+
}),
|
|
1161
|
+
])
|
|
1162
|
+
if (c !== '' && gx.match(c) && gy.match(c)) return c
|
|
1163
|
+
return null
|
|
981
1164
|
}
|
|
982
1165
|
|
|
983
1166
|
/**
|
|
@@ -988,7 +1171,16 @@ function witnessed(a: string, b: string): boolean {
|
|
|
988
1171
|
* back its own stale copy of the narrow one's output under a green run
|
|
989
1172
|
* (item 941).
|
|
990
1173
|
*/
|
|
1174
|
+
const WHOLE_ROOT = new Set(['**', '**/*'])
|
|
1175
|
+
|
|
991
1176
|
function covers(tree: string, glob: string): boolean {
|
|
1177
|
+
// The root's own tree has no directory to name: `**` beside a `!` that
|
|
1178
|
+
// spared the config cleaned every source under it, and the refusal of
|
|
1179
|
+
// a task's own sources as outputs never fired.
|
|
1180
|
+
if (WHOLE_ROOT.has(normalizeGlob(tree))) {
|
|
1181
|
+
const g = normalizeGlob(glob)
|
|
1182
|
+
return !g.startsWith('!') && !g.startsWith('/') && !g.split('/').includes('..')
|
|
1183
|
+
}
|
|
992
1184
|
const dir = wholeSubtreePrefixes([tree])?.[0]
|
|
993
1185
|
if (dir === undefined) return false
|
|
994
1186
|
const prefix = staticPrefix(glob)
|
|
@@ -1317,7 +1509,8 @@ function overlapCandidates<T>(
|
|
|
1317
1509
|
for (let i = 0; i < tasks.length; i++) {
|
|
1318
1510
|
const s = sideOf?.(tasks[i]!) === true ? 1 : 0
|
|
1319
1511
|
side.push(s)
|
|
1320
|
-
|
|
1512
|
+
// Each brace alternative, as `outputsOverlap` reads them.
|
|
1513
|
+
for (const tree of asTrees((globsOf(tasks[i]!) ?? []).flatMap(braceAlternatives))) {
|
|
1321
1514
|
const lit = literalPath(tree)
|
|
1322
1515
|
if (lit !== null) {
|
|
1323
1516
|
literals.push([i, lit])
|
|
@@ -1567,10 +1760,10 @@ interface Side {
|
|
|
1567
1760
|
|
|
1568
1761
|
/**
|
|
1569
1762
|
* Refuse a task whose input globs can match another task's declared
|
|
1570
|
-
* outputs (X-54; no opt-out,
|
|
1763
|
+
* outputs (X-54; no opt-out, 2026-10-16). Such a key reads bytes a
|
|
1571
1764
|
* producer writes this run, so it is not known until the producer ran;
|
|
1572
1765
|
* refused, every key reads only what no task writes and can be derived
|
|
1573
|
-
* before anything runs (
|
|
1766
|
+
* before anything runs (2026-10-07: "hash ahead of time, not
|
|
1574
1767
|
* waterfall"). A dependency's own key already cascades through
|
|
1575
1768
|
* `dependsOn`, so the bytes it wrote add nothing a key needs; the one
|
|
1576
1769
|
* exception is `refuseOverlaps`'s unfolded dependency.
|
|
@@ -1706,9 +1899,9 @@ type Found = Map<string, Overlap[]>
|
|
|
1706
1899
|
* Which overlaps stand. A reader may read the outputs of a task in its
|
|
1707
1900
|
* `dependsOn` closure whose key its own does not fold (it sets
|
|
1708
1901
|
* `inputs.tasks` and leaves the producer out, also through the tasks it
|
|
1709
|
-
* folds): its key waits for the producer and hashes the bytes
|
|
1710
|
-
* 2026-10-16). Otherwise an output git ignores whole is no overlap
|
|
1711
|
-
* 2026-10-15), a key reading only what git lists, unless the reader's
|
|
1902
|
+
* folds): its key waits for the producer and hashes the bytes
|
|
1903
|
+
* (2026-10-16). Otherwise an output git ignores whole is no overlap
|
|
1904
|
+
* (2026-10-15), a key reading only what git lists, unless the reader's
|
|
1712
1905
|
* glob starts in an ignored path (`dist/**`): naming it is asking for it.
|
|
1713
1906
|
* Git is asked once, for every output and prefix together; one it cannot
|
|
1714
1907
|
* speak for is refused. The answer holds for the lock; a run's stability
|
|
@@ -1858,7 +2051,7 @@ function readsOutputs(
|
|
|
1858
2051
|
new UserError(
|
|
1859
2052
|
`${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in inputs.${field}, which matches ` +
|
|
1860
2053
|
`${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
|
|
1861
|
-
`the outputs of a task it does not depend on, or whose key it already
|
|
2054
|
+
`the outputs of a task it does not depend on, or whose key it already includes. ` +
|
|
1862
2055
|
`Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
|
|
1863
2056
|
`inputs.${field}, or depend on ${writer.node.id} and leave it out of ` +
|
|
1864
2057
|
`${reader.node.id}'s inputs.tasks, so its key hashes those bytes instead.`,
|
package/src/handoff.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// A global vx hands off to the project's own (
|
|
1
|
+
// A global vx hands off to the project's own (2026-10-13), found
|
|
2
2
|
// from the workspace root, the one lookup every verb shares. Here for
|
|
3
3
|
// the compiled binary installed by hand; an npm install's launcher
|
|
4
4
|
// (npm-launcher.sh) walks the same way and `exec`s, which Bun cannot. The
|
|
5
5
|
// walk runs before anything else loads, through `Bun.file(…).exists()`:
|
|
6
6
|
// importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
|
|
7
7
|
|
|
8
|
-
/** `vx upgrade -g`: moves only the global vx, so it never hands off (
|
|
8
|
+
/** `vx upgrade -g`: moves only the global vx, so it never hands off (2026-10-16). */
|
|
9
9
|
export function upgradesOnlyItself(argv: readonly string[]): boolean {
|
|
10
10
|
return argv[0] === 'upgrade' && argv.some((a) => a === '-g' || a === '--global')
|
|
11
11
|
}
|
|
@@ -24,7 +24,8 @@ export async function localVx(
|
|
|
24
24
|
compiled: boolean,
|
|
25
25
|
uid = process.getuid?.(),
|
|
26
26
|
): Promise<string | null> {
|
|
27
|
-
|
|
27
|
+
// Empty is unset, as the launcher's `[ -z "${VX_HANDED_OFF-}" ]` reads it.
|
|
28
|
+
if (process.env['VX_HANDED_OFF'] || !compiled) return null
|
|
28
29
|
if (execPath.includes('/node_modules/')) return null
|
|
29
30
|
const file = await workspaceFile(cwd)
|
|
30
31
|
if (file === null) return null
|
|
@@ -44,7 +45,7 @@ export async function localVx(
|
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* The workspace config's paths under the root, in lookup order: one marks
|
|
47
|
-
* the workspace root, the directory holding its `.vx` (
|
|
48
|
+
* the workspace root, the directory holding its `.vx` (2026-10-14).
|
|
48
49
|
*/
|
|
49
50
|
export const WORKSPACE_CONFIG_FILENAMES = [
|
|
50
51
|
'.vx/workspace.ts',
|
|
@@ -67,7 +68,7 @@ const files = new Map<string, Promise<string | null>>()
|
|
|
67
68
|
/**
|
|
68
69
|
* The workspace config that marks the root for `dir`: the first
|
|
69
70
|
* `.vx/workspace.*` from `dir` up to the git top-level ({@link gitTop}),
|
|
70
|
-
* inclusive, or to `/` outside git (
|
|
71
|
+
* inclusive, or to `/` outside git (2026-10-13: "Workspace file is
|
|
71
72
|
* the ultimate" and "never go out of git root"). Null when there is none.
|
|
72
73
|
* Learned once per directory per run: the hand-off and the workspace root
|
|
73
74
|
* share it (principle 9).
|
|
@@ -87,7 +88,7 @@ export function workspaceFileWritten(): void {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
async function findWorkspaceFile(start: string): Promise<string | null> {
|
|
90
|
-
|
|
91
|
+
let top = await gitTop(start)
|
|
91
92
|
const passed: string[] = []
|
|
92
93
|
let found: string | null = null
|
|
93
94
|
walk: for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
|
|
@@ -99,15 +100,79 @@ async function findWorkspaceFile(start: string): Promise<string | null> {
|
|
|
99
100
|
break walk
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
|
-
if (d === ''
|
|
103
|
+
if (d === '') break
|
|
104
|
+
if (d === top) {
|
|
105
|
+
// A submodule whose `.git` is a directory (added from a clone, not
|
|
106
|
+
// absorbed) is a top-level by its files: git alone knows the
|
|
107
|
+
// superproject tracks it. Asked only once the walk found nothing.
|
|
108
|
+
const above = await superproject(d)
|
|
109
|
+
if (above === null) break
|
|
110
|
+
top = await gitTop(above)
|
|
111
|
+
}
|
|
103
112
|
}
|
|
113
|
+
if (found === null && top !== null && top !== '/') found = await outerWorkspaceFile(top)
|
|
104
114
|
// Every directory walked has the same answer: the loader asks for the root's.
|
|
105
115
|
for (const d of passed) if (!files.has(d)) files.set(d, Promise.resolve(found))
|
|
106
116
|
return found
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The workspace config of the repository `top` is embedded in (an untracked
|
|
121
|
+
* clone with its own `.git` directory), when that repository's git does not
|
|
122
|
+
* ignore it: `vx lock` lists such a repository's configs as the outer
|
|
123
|
+
* workspace's, and a run from inside one stopped at its `.git`, "no
|
|
124
|
+
* .vx/workspace.ts". A submodule's walk goes on already ({@link gitTop}).
|
|
125
|
+
* Asked only when the walk found nothing, so a run pays nothing for it.
|
|
126
|
+
*/
|
|
127
|
+
async function outerWorkspaceFile(top: string): Promise<string | null> {
|
|
128
|
+
// A worktree's `.git` is a file: another checkout, its own top-level.
|
|
129
|
+
if (!(await Bun.file(`${top}/.git/HEAD`).exists())) return null
|
|
130
|
+
const parent = top.slice(0, top.lastIndexOf('/')) || '/'
|
|
131
|
+
const outer = await gitTop(parent)
|
|
132
|
+
if (outer === null) return null
|
|
133
|
+
const file = await workspaceFile(parent)
|
|
134
|
+
if (file === null) return null
|
|
135
|
+
try {
|
|
136
|
+
// Plain `git`, as handoff imports nothing (the hand-off runs first).
|
|
137
|
+
// Exit 1: not ignored, so the outer listing holds it; 0 ignored; 128 an error.
|
|
138
|
+
// `./`: check-ignore reads `:x` as pathspec magic and refuses `:(literal)`.
|
|
139
|
+
const rel = top.slice(outer.length + (outer === '/' ? 0 : 1))
|
|
140
|
+
const p = Bun.spawnSync({
|
|
141
|
+
cmd: ['git', 'check-ignore', '-q', '--', `./${rel}`],
|
|
142
|
+
cwd: outer,
|
|
143
|
+
stdin: 'ignore',
|
|
144
|
+
stdout: 'ignore',
|
|
145
|
+
stderr: 'ignore',
|
|
146
|
+
})
|
|
147
|
+
return p.exitCode === 1 ? file : null
|
|
148
|
+
} catch {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
109
153
|
const tops = new Map<string, Promise<string | null>>()
|
|
110
154
|
|
|
155
|
+
/** The working tree of the repository that tracks `top` as a submodule, or null. */
|
|
156
|
+
async function superproject(top: string): Promise<string | null> {
|
|
157
|
+
const { executablePath, gitEnv } = await import('./util/index.js')
|
|
158
|
+
try {
|
|
159
|
+
const proc = Bun.spawn({
|
|
160
|
+
cmd: [executablePath('git'), 'rev-parse', '--show-superproject-working-tree'],
|
|
161
|
+
cwd: top,
|
|
162
|
+
env: gitEnv(),
|
|
163
|
+
stdin: 'ignore',
|
|
164
|
+
stdout: 'pipe',
|
|
165
|
+
stderr: 'ignore',
|
|
166
|
+
})
|
|
167
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
168
|
+
const dir = out.trim()
|
|
169
|
+
return exit === 0 && dir.length > 0 ? dir : null
|
|
170
|
+
} catch {
|
|
171
|
+
// No git: the files are all there is to go by.
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
111
176
|
/**
|
|
112
177
|
* The git top-level holding `dir`, or null outside git: the nearest `.git`
|
|
113
178
|
* up from it, a directory in a clone, a file in a worktree. A submodule's
|
|
@@ -132,6 +197,11 @@ async function findGitTop(start: string): Promise<string | null> {
|
|
|
132
197
|
if (await file.exists()) {
|
|
133
198
|
const gitdir = await file.text().catch(() => '')
|
|
134
199
|
if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
|
|
200
|
+
// A submodule's own worktree points there too, and is a top-level:
|
|
201
|
+
// git writes `commondir` in a worktree's gitdir alone.
|
|
202
|
+
const to = gitdir.replace(/^gitdir:\s*/, '').trim()
|
|
203
|
+
if (await Bun.file(`${to.startsWith('/') ? '' : `${d}/`}${to}/commondir`).exists())
|
|
204
|
+
return d || '/'
|
|
135
205
|
}
|
|
136
206
|
if (d === '') return null
|
|
137
207
|
}
|
|
@@ -149,6 +219,10 @@ export function vxPath(): string {
|
|
|
149
219
|
* second would read as "stop now" (npm-launcher.cjs, the same rule).
|
|
150
220
|
*/
|
|
151
221
|
export async function handOff(bin: string, args: readonly string[]): Promise<never> {
|
|
222
|
+
// This vx waits out the whole run beside the tasks, and `run()`, which
|
|
223
|
+
// hides the env on every verb, is never reached here. Imported here:
|
|
224
|
+
// a start that hands off nothing pays nothing.
|
|
225
|
+
await (await import('./util/index.js')).hideEnvFromTasks()
|
|
152
226
|
const child = Bun.spawn([bin, ...args], {
|
|
153
227
|
stdio: ['inherit', 'inherit', 'inherit'],
|
|
154
228
|
env: { ...process.env, VX_HANDED_OFF: process.execPath },
|
|
@@ -159,13 +233,27 @@ export async function handOff(bin: string, args: readonly string[]): Promise<nev
|
|
|
159
233
|
})
|
|
160
234
|
}
|
|
161
235
|
const code = await child.exited
|
|
236
|
+
const signal = child.signalCode
|
|
237
|
+
if (signal === null) process.exit(code)
|
|
238
|
+
// Die of a stop signal the local vx died of, as vx itself does: a shell
|
|
239
|
+
// running vx in a script stops on Ctrl-C only when its child died of
|
|
240
|
+
// SIGINT, and an exit 130 ran the script's next line. The code below is
|
|
241
|
+
// the rest, and the fallback should the signal not land.
|
|
242
|
+
if (signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP') {
|
|
243
|
+
process.removeAllListeners(signal)
|
|
244
|
+
process.kill(process.pid, signal)
|
|
245
|
+
}
|
|
246
|
+
if (typeof signal === 'number') process.exit(128 + signal)
|
|
162
247
|
const { constants } = await import('node:os')
|
|
163
|
-
process.exit(
|
|
248
|
+
process.exit(128 + (constants.signals[signal] ?? 1))
|
|
164
249
|
}
|
|
165
250
|
|
|
166
251
|
function inForeground(): boolean {
|
|
167
252
|
try {
|
|
168
|
-
|
|
253
|
+
// The handed-off vx's tasks run meanwhile and could read its env: PATH alone (`helperEnv`).
|
|
254
|
+
const stat = Bun.spawnSync(['ps', '-o', 'pgid=,tpgid=', '-p', String(process.pid)], {
|
|
255
|
+
env: { PATH: process.env['PATH'] ?? '' },
|
|
256
|
+
})
|
|
169
257
|
const [pgid, tpgid] = stat.stdout.toString().trim().split(/\s+/)
|
|
170
258
|
return pgid !== undefined && pgid === tpgid
|
|
171
259
|
} catch {
|