@vzn/vx 0.0.595 → 0.0.597

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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. package/src/workspace/workspace.ts +53 -24
package/src/cli/last.ts CHANGED
@@ -60,7 +60,10 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
60
60
  if (spaced) i++
61
61
  const n = Number(lv)
62
62
  if (!Number.isInteger(n) || n < 1 || n > 500) {
63
- return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
63
+ return {
64
+ ...out,
65
+ error: `--list must be a whole number from 1 to 500 (got ${lv})${seeHelp('last')}`,
66
+ }
64
67
  }
65
68
  out.list = n
66
69
  continue
@@ -77,7 +80,7 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
77
80
  }
78
81
  const cd = parseCacheDirFlag(args, i)
79
82
  if (cd !== null) {
80
- if ('error' in cd) return { ...out, error: cd.error }
83
+ if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('last')}` }
81
84
  out.cacheDir = cd.cacheDir
82
85
  i = cd.next
83
86
  continue
@@ -96,11 +99,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
96
99
  if (out.runId !== undefined && out.list !== undefined) {
97
100
  return {
98
101
  ...out,
99
- error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
102
+ error: `a run id and --list do not combine: replay ${out.runId}, or list runs${seeHelp('last')}`,
100
103
  }
101
104
  }
102
105
  if (out.runId !== undefined && out.failed === true) {
103
- return { ...out, error: `a run id and --failed do not combine: replay ${out.runId}` }
106
+ return {
107
+ ...out,
108
+ error: `a run id and --failed do not combine: replay ${out.runId}${seeHelp('last')}`,
109
+ }
104
110
  }
105
111
  return out
106
112
  }
package/src/cli/lock.ts CHANGED
@@ -83,9 +83,11 @@ function bareNote(bare: number): string {
83
83
 
84
84
  async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
85
85
  const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
86
- const projects: Record<string, LockfileEntry> = {}
87
- // `listProjects` sorts by name — stable lockfile diffs for free.
88
- for (let i = 0; i < metas.length; i++) projects[metas[i]!.name] = entries[i]!
86
+ // `listProjects` sorts by name — stable lockfile diffs for free. Defined,
87
+ // not assigned: a project named `__proto__` set the map's prototype (D-152).
88
+ const projects: Record<string, LockfileEntry> = Object.fromEntries(
89
+ metas.map((m, i) => [m.name, entries[i]!]),
90
+ )
89
91
  const leaks = secretsIn(projects)
90
92
  if (leaks.length > 0) {
91
93
  process.stderr.write(
@@ -1,7 +1,7 @@
1
1
  // Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
2
2
 
3
3
  import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
4
- import { formatDuration } from '../orchestrator/index.js'
4
+ import { formatElapsed } from '../util/index.js'
5
5
 
6
6
  /**
7
7
  * Human-readable preview. One line per real task (groups hidden, same
@@ -37,7 +37,7 @@ export function formatPlanText(plan: RunPlan): string {
37
37
  const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
38
38
  const shortHash = t.hash ? t.hash.slice(0, 8) : ''
39
39
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
40
- const eta = executes && t.p50Ms !== undefined ? ` ~${formatDuration(t.p50Ms)}` : ''
40
+ const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
41
41
  // Placement, by EXECUTOR NAME rather than a local/remote word: the
42
42
  // summary line below already spends "local" and "remote" on the cache
43
43
  // tier, and a task placed on a named executor is what the reader can
@@ -94,8 +94,8 @@ export function formatPlanText(plan: RunPlan): string {
94
94
  const p = plan.predicted
95
95
  const executes = miss + nocache
96
96
  if (p !== undefined && executes > 0 && executes > p.unknownCount) {
97
- const parts = [`predicted: ~${formatDuration(p.wallMs)} wall`]
98
- parts.push(`~${formatDuration(p.workMs)} total execution`)
97
+ const parts = [`predicted: ~${formatElapsed(p.wallMs)} wall`]
98
+ parts.push(`~${formatElapsed(p.workMs)} total execution`)
99
99
  if (p.unknownCount > 0) {
100
100
  parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
101
101
  }
package/src/cli/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
+ import { findWorkspaceRoot } from '../workspace/index.js'
4
5
  import { translateForeign } from './foreign-flags.js'
5
6
  import { flagHint, seeHelp } from './help.js'
6
7
  import {
@@ -12,8 +13,13 @@ import {
12
13
  type RunOptions,
13
14
  type RunResult,
14
15
  } from '../orchestrator/index.js'
15
- import type { ContinueMode } from '../graph/index.js'
16
- import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
16
+ import type { ContinueMode, TaskOutcome } from '../graph/index.js'
17
+ import {
18
+ type CachePolicy,
19
+ FULL_CACHE_POLICY,
20
+ gitRefusal,
21
+ parseCachePolicy,
22
+ } from '../cache/index.js'
17
23
  import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
18
24
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
19
25
  import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
@@ -175,7 +181,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
175
181
  if (n === null)
176
182
  return {
177
183
  ...out,
178
- error: `invalid concurrency: ${v} (a positive integer, or a share of the cores such as 50%)`,
184
+ error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
179
185
  }
180
186
  out.concurrency = n
181
187
  } else if (a === '--all') {
@@ -261,14 +267,17 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
261
267
  out.download = v
262
268
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
263
269
  const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
264
- if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a value` }
270
+ if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
265
271
  // Unlike every other value flag, a cache dir is an arbitrary
266
272
  // string — nothing about its shape rejects a swallowed flag. An
267
273
  // unquoted empty shell var (`--cache-dir $EMPTY --force`) would
268
274
  // otherwise create a directory literally named `--force` and drop
269
275
  // the flag. A path starting with `-` needs the `=` form.
270
276
  if (a === '--cache-dir' && v.startsWith('-')) {
271
- return { ...out, error: `--cache-dir requires a path, got flag: ${v}` }
277
+ return {
278
+ ...out,
279
+ error: `--cache-dir requires a path (got flag ${v}; a path that starts with - needs --cache-dir=${v})`,
280
+ }
272
281
  }
273
282
  out.cacheDir = v
274
283
  } else if (a === '--cache' || a?.startsWith('--cache=')) {
@@ -323,14 +332,15 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
323
332
  if (v === undefined)
324
333
  return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
325
334
  const n = parseDecimalInt(v)
326
- if (n === null) return { ...out, error: `invalid verbosity: ${v} (a non-negative integer)` }
335
+ if (n === null)
336
+ return { ...out, error: `--verbosity must be a non-negative integer (got ${v})` }
327
337
  out.verbosity = n
328
338
  } else if (a === '--dry') {
329
339
  out.dry = 'text'
330
340
  } else if (a?.startsWith('--dry=')) {
331
341
  const fmt = a.slice('--dry='.length)
332
342
  if (fmt !== 'text' && fmt !== 'json') {
333
- return { ...out, error: `invalid --dry value: ${fmt} (text or json)` }
343
+ return { ...out, error: `--dry must be text or json (got ${fmt})` }
334
344
  }
335
345
  out.dry = fmt
336
346
  } else if (a === '--graph') {
@@ -358,7 +368,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
358
368
  if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
359
369
  // Split on the FIRST `=` so values may contain `=` (e.g. a URL).
360
370
  const eq = raw.indexOf('=')
361
- if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
371
+ if (eq <= 0) return { ...out, error: `--tag must be k=v (got ${raw})` }
362
372
  out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
363
373
  } else if (a === '--report-file' || a?.startsWith('--report-file=')) {
364
374
  const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
@@ -367,7 +377,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
367
377
  // nothing about its shape rejects a swallowed flag. A path starting
368
378
  // with `-` needs the `=` form.
369
379
  if (a === '--report-file' && v.startsWith('-')) {
370
- return { ...out, error: `--report-file requires a path, got flag: ${v}` }
380
+ return {
381
+ ...out,
382
+ error: `--report-file requires a path (got flag ${v}; a path that starts with - needs --report-file=${v})`,
383
+ }
371
384
  }
372
385
  out.reportFile = v
373
386
  } else if (a === '--report') {
@@ -375,7 +388,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
375
388
  } else if (a?.startsWith('--report=')) {
376
389
  const fmt = a.slice('--report='.length)
377
390
  if (fmt !== 'markdown') {
378
- return { ...out, error: `invalid --report value: ${fmt} (only markdown)` }
391
+ return { ...out, error: `--report must be markdown (got ${fmt})` }
379
392
  }
380
393
  out.report = fmt
381
394
  } else if (a !== undefined && a.startsWith('-')) {
@@ -486,6 +499,7 @@ export async function resolveRunOptions(
486
499
  parsed: RunArgs,
487
500
  cwd: string,
488
501
  tasks: readonly string[],
502
+ verb: 'run' | 'watch' = 'run',
489
503
  ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
490
504
  for (const t of tasks) {
491
505
  const idx = t.indexOf('#')
@@ -557,8 +571,7 @@ export async function resolveRunOptions(
557
571
  const nx = await nxTargetHint(tasks, cwd)
558
572
  if (nx !== null) return { error: nx }
559
573
  return {
560
- error:
561
- 'not inside a project. Pass --all for every project, --filter <pattern> to filter, or run from within a project directory.',
574
+ error: `not inside a project: run from a project directory, or pass --all or --filter <pattern>${seeHelp(verb)}`,
562
575
  }
563
576
  }
564
577
  projects = [cwdProject.name]
@@ -620,6 +633,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
620
633
 
621
634
  const cwd = process.cwd()
622
635
  let tasks = [...parsed.tasks]
636
+ let picked: string | undefined
623
637
 
624
638
  // No positionals → interactive picker (TTY only). Yields a single
625
639
  // anchored pkg#task; the rest of the pipeline treats it like any
@@ -638,6 +652,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
638
652
  )
639
653
  return 1
640
654
  }
655
+ // Every run needs git: refused here, not after the user has chosen.
656
+ const refusal = gitRefusal(await findWorkspaceRoot(cwd))
657
+ if (refusal !== undefined) {
658
+ process.stderr.write(`${refusal.message}\n`)
659
+ return 1
660
+ }
641
661
  const load = {
642
662
  ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
643
663
  ...(parsed.frozen ? { frozen: true } : {}),
@@ -662,10 +682,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
662
682
  }
663
683
  only = new Set(selected.names)
664
684
  }
665
- const picked = await pickTask(cwd, {}, load, only)
666
- if (picked === 'interrupted') return 130
667
- if (!picked) return 1
668
- tasks = [`${picked.project}#${picked.task}`]
685
+ const chosen = await pickTask(cwd, {}, load, only)
686
+ if (chosen === 'interrupted') return 130
687
+ if (!chosen) return 1
688
+ picked = `${chosen.project}#${chosen.task}`
689
+ tasks = [picked]
669
690
  }
670
691
 
671
692
  const resolved = await resolveRunOptions(parsed, cwd, tasks)
@@ -680,8 +701,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
680
701
  const opts = resolved
681
702
  // The raw invocation, recorded on the `invocations` row so dashboards
682
703
  // show what was actually run. `args` is everything after `run`, quoted
683
- // so `vx last` replays one paste-able line.
684
- opts.command = ['vx', 'run', ...args.map(shellQuote)].join(' ')
704
+ // so `vx last` replays one paste-able line; a picked task is named in
705
+ // it, or the line would open the picker again.
706
+ opts.command = [
707
+ 'vx',
708
+ 'run',
709
+ ...(picked !== undefined ? [shellQuote(picked)] : []),
710
+ ...args.map(shellQuote),
711
+ ].join(' ')
685
712
 
686
713
  // Planning paths short-circuit execution. Both build the full task
687
714
  // graph + probe the cache; the difference is just the formatter.
@@ -726,6 +753,25 @@ export async function runCmd(args: readonly string[]): Promise<number> {
726
753
  return 0
727
754
  }
728
755
 
756
+ // The report prints above the footer: nothing prints below it (owner).
757
+ // Rendered once, however many sinks asked for it; a kept server's crash
758
+ // after the footer re-renders the file's copy (C-53).
759
+ const wantsReport = parsed.report === 'markdown' || parsed.reportFile !== undefined
760
+ let reported: { outcomes: readonly TaskOutcome[]; md: string } | undefined
761
+ if (wantsReport) {
762
+ opts.beforeFooter = (outcomes, ok) => {
763
+ reported = {
764
+ outcomes,
765
+ md: formatRunReportMarkdown({ ok, outcomes: outcomes.map(projectOutcome) }),
766
+ }
767
+ // `--report` writes stdout. The report ITSELF is machine-clean, but
768
+ // stdout is not vx's alone — the status logger writes there too, so
769
+ // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
770
+ // meter bar and `::group::` command above the table. `--report-file`
771
+ // is the redirect-free form.
772
+ return parsed.report === 'markdown' ? reported.md : ''
773
+ }
774
+ }
729
775
  // A run executes in THIS process, always. Where an individual task's
730
776
  // command runs is the `executor` capability's business (per task, with
731
777
  // the scheduler, cache, retries and telemetry unchanged above it); there
@@ -735,17 +781,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
735
781
  process.stderr.write(`vx run: ${summary.refused}\n`)
736
782
  return 1
737
783
  }
738
- const result: RunResult = { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
739
- // Report generation is post-run, gated on the flags — zero cost when
740
- // both are absent. Rendered once, however many sinks asked for it.
741
- if (parsed.report === 'markdown' || parsed.reportFile !== undefined) {
742
- const md = formatRunReportMarkdown(result)
743
- // `--report` writes stdout. The report ITSELF is machine-clean, but
744
- // stdout is not vx's alone — the status logger writes there too, so
745
- // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
746
- // meter bar and `::group::` command above the table. `--report-file`
747
- // is the redirect-free form.
748
- if (parsed.report === 'markdown') process.stdout.write(md)
784
+ if (wantsReport) {
785
+ const md =
786
+ reported?.outcomes === summary.outcomes
787
+ ? reported.md
788
+ : formatRunReportMarkdown({
789
+ ok: summary.ok,
790
+ outcomes: summary.outcomes.map(projectOutcome),
791
+ })
749
792
  if (parsed.reportFile !== undefined) {
750
793
  const target = path.resolve(cwd, parsed.reportFile)
751
794
  try {
@@ -768,5 +811,5 @@ export async function runCmd(args: readonly string[]): Promise<number> {
768
811
  }
769
812
  }
770
813
  }
771
- return result.ok ? 0 : 1
814
+ return summary.ok ? 0 : 1
772
815
  }
package/src/cli/select.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  import type { ProjectConfig } from '../config.js'
27
27
  import type { ProjectEntry } from '../workspace/index.js'
28
28
  import { parseDependencySpec } from '../graph/index.js'
29
- import { declaresInput } from '../cache/index.js'
29
+ import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
30
30
  import { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
31
31
  import {
32
32
  claimedAffected,
@@ -60,12 +60,15 @@ export async function workspaceGlobOwners(
60
60
  load: CliLoadOptions = {},
61
61
  stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
62
62
  loadCliProjects(root, projects, 'all', load),
63
+ /** The changed nested repositories (`AffectedChanges.nested`). */
64
+ nested: readonly string[] = [],
63
65
  ): Promise<string[]> {
64
66
  const declaresMatch = (config: ProjectConfig): boolean => {
65
67
  for (const task of Object.values(config.tasks ?? {})) {
66
68
  const cache = task.cache
67
69
  if (cache === undefined) continue
68
70
  if (changed.some((rel) => declaresInput(cache, null, rel))) return true
71
+ if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
69
72
  }
70
73
  return false
71
74
  }
@@ -319,8 +322,8 @@ export async function resolveFilters(
319
322
  workspaceRoot: root,
320
323
  since: f.gitSince,
321
324
  projects,
322
- workspaceGlobOwners: (changed: readonly string[]) =>
323
- workspaceGlobOwners(root, projects, changed, load, stagedOnce),
325
+ workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
326
+ workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
324
327
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
325
328
  taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
326
329
  ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
package/src/cli/show.ts CHANGED
@@ -41,7 +41,12 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
41
41
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
42
42
  else if (a === '--filter') {
43
43
  const v = args[++i]
44
- if (v === undefined) return { ...out, error: '--filter requires a value (a pattern)' }
44
+ // `--filter --format json` took `--format` as the pattern; no filter opens with `--`.
45
+ if (v === undefined || v === '' || v.startsWith('--'))
46
+ return {
47
+ ...out,
48
+ error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
49
+ }
45
50
  out.filters.push(v)
46
51
  } else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
47
52
  else if (a === '--affected') out.affected = ''
@@ -55,7 +60,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
55
60
  return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
56
61
  // An empty name is a part of every name: its "did you mean" listed the
57
62
  // whole workspace.
58
- else if (a === '') return { ...out, error: 'empty target (omit it to list every project)' }
63
+ else if (a === '')
64
+ return { ...out, error: `empty target (omit it to list every project)${seeHelp('show')}` }
59
65
  else out.target = a
60
66
 
61
67
  if (format !== undefined) {
@@ -200,7 +200,7 @@ export async function fetchRelease(tag: string | undefined): Promise<unknown> {
200
200
  }
201
201
  // A body that is not JSON (a captive portal's page, a proxy's error
202
202
  // page served with a 200) is the same refusal as a cut one.
203
- return await readOrRefuse(() => res.json() as Promise<unknown>, url, 'read the release')
203
+ return await readOrRefuse(() => res.json(), url, 'read the release')
204
204
  }
205
205
 
206
206
  /**
@@ -179,9 +179,14 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
179
179
  // ignored let a pid file in the same window start cycles again. The
180
180
  // refused path is skipped and the rest asked again; a refusal before
181
181
  // any record outside a work tree is git refusing them all.
182
- let rest = paths
182
+ // Git refuses a path beyond a symbolic link: its ignored pid file read
183
+ // as an edit and an uncached task re-ran forever. Asked at its real
184
+ // place, answered as given.
185
+ const real = paths.map(gitSpeller(workspaceRoot))
186
+ let from = 0
183
187
  let inWorkTree: boolean | undefined
184
- while (rest.length > 0) {
188
+ while (from < paths.length) {
189
+ const rest = real.slice(from)
185
190
  let proc: ReturnType<typeof Bun.spawnSync>
186
191
  try {
187
192
  proc = Bun.spawnSync({
@@ -201,8 +206,8 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
201
206
  const fields = new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')
202
207
  const records = Math.floor(fields.length / 4)
203
208
  for (let i = 0; i < records; i++) {
204
- const [source, , pattern, p] = fields.slice(i * 4, i * 4 + 4)
205
- if (source !== '' && !pattern!.startsWith('!')) ignored.add(p!)
209
+ const [source, , pattern] = fields.slice(i * 4, i * 4 + 4)
210
+ if (source !== '' && !pattern!.startsWith('!')) ignored.add(paths[from + i]!)
206
211
  }
207
212
  // 0: some ignored; 1: none. Anything else is git refusing.
208
213
  if (proc.exitCode === 0 || proc.exitCode === 1) return ignored
@@ -216,7 +221,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
216
221
  }).exitCode === 0
217
222
  if (!inWorkTree) return ignored
218
223
  }
219
- rest = rest.slice(records + 1)
224
+ from += records + 1
220
225
  }
221
226
  return ignored
222
227
  }
@@ -351,19 +356,59 @@ export function isWorkspaceConfigFile(name: string): boolean {
351
356
  */
352
357
  const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
353
358
 
359
+ /**
360
+ * Paths as git spells them under `root`: a project reached through a
361
+ * symlink below the root (`packages/x -> ../shared/x`) is watched at the
362
+ * link, and git names and answers for its files only at the real place.
363
+ * The root keeps its own spelling (macOS's `/var` is `/private/var`), the
364
+ * name its own (the file may be gone); a directory outside the root is
365
+ * kept as given. Directories are resolved once per speller.
366
+ */
367
+ export function gitSpeller(root: string): (p: string) => string {
368
+ const dirs = new Map<string, string>()
369
+ let realRoot: string | undefined
370
+ return (p) => {
371
+ const dir = path.dirname(p)
372
+ let at = dirs.get(dir)
373
+ if (at === undefined) {
374
+ at = dir
375
+ try {
376
+ realRoot ??= realPath(root)
377
+ const rel = path.relative(realRoot, realPath(dir))
378
+ if (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)) {
379
+ at = path.join(root, rel)
380
+ }
381
+ } catch {}
382
+ dirs.set(dir, at)
383
+ }
384
+ return path.join(at, path.basename(p))
385
+ }
386
+ }
387
+
354
388
  /**
355
389
  * The files under the workspace git lists, tracked and untracked, ignored
356
390
  * ones aside, and every directory above one (git lists no directory, and
357
391
  * a project moved away whole is one event on its directory): what existed
358
392
  * when watch armed, so a path born and gone since is told from a deletion
359
- * (watch-judge.ts). Undefined when git cannot answer: no inventory, every
360
- * gone path is a deletion as before.
393
+ * (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
394
+ * so a server cannot be blamed for one. Undefined when git cannot answer:
395
+ * no inventory, every gone path is a deletion as before.
361
396
  */
362
- export function gitFiles(workspaceRoot: string): Set<string> | undefined {
397
+ export function gitFiles(
398
+ workspaceRoot: string,
399
+ ): { listed: Set<string>; tracked: Set<string> } | undefined {
363
400
  let proc: ReturnType<typeof Bun.spawnSync>
364
401
  try {
365
402
  proc = Bun.spawnSync({
366
- cmd: [executablePath('git'), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
403
+ cmd: [
404
+ executablePath('git'),
405
+ 'ls-files',
406
+ '-z',
407
+ '-t',
408
+ '--cached',
409
+ '--others',
410
+ '--exclude-standard',
411
+ ],
367
412
  cwd: workspaceRoot,
368
413
  stdout: 'pipe',
369
414
  stderr: 'ignore',
@@ -373,13 +418,19 @@ export function gitFiles(workspaceRoot: string): Set<string> | undefined {
373
418
  }
374
419
  if (proc.exitCode !== 0) return undefined
375
420
  const files = new Set<string>()
376
- for (const p of new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')) {
377
- if (p.length === 0) continue
421
+ const tracked = new Set<string>()
422
+ for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
423
+ .decode(proc.stdout)
424
+ .split('\0')) {
425
+ if (entry.length === 0) continue
426
+ // `-t` prefixes a tag and a space; `?` is untracked.
427
+ const p = entry.slice(2)
378
428
  // An untracked nested repository is listed as `dir/`.
379
429
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
380
430
  files.add(abs)
431
+ if (entry[0] !== '?') tracked.add(abs)
381
432
  for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
382
433
  files.add(abs)
383
434
  }
384
- return files
435
+ return { listed: files, tracked }
385
436
  }
@@ -349,7 +349,9 @@ export function modifiedBefore(abs: string, t: number): boolean {
349
349
  // carry a file's OLD mtime onto the new one, and judged by mtime alone
350
350
  // such an edit was "before the arm" and never ran (item 945). No
351
351
  // process can set a ctime, and a rename or a write moves it.
352
- const st = fs.statSync(abs)
352
+ // The link's own times, not its target's: a link made after the arm
353
+ // to an old file is a new input, and followed it read as the target's.
354
+ const st = fs.lstatSync(abs)
353
355
  return Math.max(st.mtimeMs, st.ctimeMs) < t
354
356
  } catch {
355
357
  return false
@@ -398,6 +400,10 @@ export class WatcherPool {
398
400
  // network mount, a container bind — the attempt costs a denied syscall
399
401
  // and a two-second wait before the fallback takes over anyway.
400
402
  private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
403
+ // The stop closes the pool while a cycle may still be re-arming: an arm
404
+ // after it, or a proof that fails because it closed the watcher, would
405
+ // leave a live watcher or poller nothing ever closes.
406
+ private closed = false
401
407
 
402
408
  constructor(private readonly skip: (dir: string, rel: string) => boolean) {
403
409
  if (this.forcePoll)
@@ -411,6 +417,7 @@ export class WatcherPool {
411
417
  // By slot, not by handle: an OS watcher that never proves delivery is
412
418
  // swapped for a poller in place, and a drop must close what is there.
413
419
  arm(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
420
+ if (this.closed) return CLOSED
414
421
  const watchers = this.watchers
415
422
  const at = watchers.length
416
423
  const handle: WatchHandle = {
@@ -442,8 +449,8 @@ export class WatcherPool {
442
449
  armed.ready.then((ok) => {
443
450
  if (ok) return
444
451
  armed.watcher.close()
445
- // Dropped by a rearm before its proof settled: nothing to swap in.
446
- if (watchers[at] !== armed.watcher) return
452
+ // Dropped by a rearm or the stop before its proof settled: nothing to swap in.
453
+ if (this.closed || watchers[at] !== armed.watcher) return
447
454
  // The watcher never proved delivery, so it is not one: an FSEvents
448
455
  // stream the OS refused, a filesystem that reports nothing. Swap in
449
456
  // the poller rather than run a loop that silently never fires.
@@ -462,6 +469,7 @@ export class WatcherPool {
462
469
  }
463
470
 
464
471
  closeAll(): void {
472
+ this.closed = true
465
473
  for (const w of this.watchers) {
466
474
  try {
467
475
  w.close()
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import { xxh3 } from '../util/index.js'
8
- import { gitIgnored } from './watch-filter.js'
8
+ import { gitIgnored, gitSpeller } from './watch-filter.js'
9
9
  import { modifiedBefore } from './watch-fs.js'
10
10
 
11
11
  const ABSENT = -1n
@@ -63,6 +63,8 @@ export interface JudgeContext {
63
63
  fenced?(ownDir: string, abs: string): boolean
64
64
  /** The files git listed at the arm; absent when git could not answer. */
65
65
  existedAtArm?: ReadonlySet<string>
66
+ /** The files git tracked at the arm; absent when git could not answer. */
67
+ trackedAtArm?: ReadonlySet<string>
66
68
  }
67
69
 
68
70
  export class ChangeJudge {
@@ -124,14 +126,14 @@ export class ChangeJudge {
124
126
  // the moment between a judgement and that cycle's keys, and gone by the
125
127
  // next judgement, was read and its deletion re-runs nothing. Git lists
126
128
  // no ignored path, so one of those is a deletion as before.
127
- private sameState(abs: string, ignored = false): boolean {
129
+ private sameState(abs: string, ignored: boolean, spell: (p: string) => string): boolean {
128
130
  const state = settledState(abs)
129
131
  const prev = this.lastState.get(abs)
130
132
  this.lastState.set(abs, state)
131
133
  if (prev !== undefined) return prev === state
132
134
  if (state === ABSENT) {
133
135
  const listed = this.ctx.existedAtArm
134
- return !ignored && listed !== undefined && !listed.has(abs) && !this.inNestedRepo(abs)
136
+ return !ignored && listed !== undefined && !listed.has(spell(abs)) && !this.inNestedRepo(abs)
135
137
  }
136
138
  return modifiedBefore(abs, this.ctx.armedAt)
137
139
  }
@@ -146,13 +148,17 @@ export class ChangeJudge {
146
148
  return false
147
149
  }
148
150
 
149
- private writtenDuringLastCycle(abs: string, openWhileHeld = false): boolean {
151
+ /** `spell` (git's spelling, see `gitSpeller`) given: a held server's write counts outside a cycle too. */
152
+ private writtenDuringLastCycle(abs: string, spell?: (p: string) => string): boolean {
150
153
  // A server the last cycle left running is still that cycle's: its
151
154
  // writes land after the cycle ended, and with a closed window a dev
152
155
  // server that rewrites a log in its project restarted itself forever
153
156
  // with no word of it, 12 restarts in 8 s (item 948). The initial run's
154
- // server is one too, from the arm on.
155
- const open = openWhileHeld && this.ctx.held()
157
+ // server is one too, from the arm on. Not a tracked file: that is the
158
+ // user's edit, and three saves of one source file were told to
159
+ // .gitignore it as a server's write (WD-7).
160
+ const open =
161
+ spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
156
162
  if (this.lastCycle === undefined && !open) return false
157
163
  try {
158
164
  const m = fs.statSync(abs).mtimeMs
@@ -165,6 +171,7 @@ export class ChangeJudge {
165
171
 
166
172
  judge(): string | undefined {
167
173
  const ignored = gitIgnored(this.ctx.workspaceRoot, [...this.pending.keys()])
174
+ const spell = gitSpeller(this.ctx.workspaceRoot)
168
175
  let first: string | undefined
169
176
  let firstAbs: string | undefined
170
177
  // `sameState` before the `first` test, never after it: it is what
@@ -190,7 +197,7 @@ export class ChangeJudge {
190
197
  let gone: [label: string, abs: string] | undefined
191
198
  for (const [p, l] of this.pending) {
192
199
  if (ignored.has(p) && !editForUncached(p)) continue
193
- if (this.sameState(p, ignored.has(p)) || first !== undefined) continue
200
+ if (this.sameState(p, ignored.has(p), spell) || first !== undefined) continue
194
201
  if (!fs.existsSync(p)) {
195
202
  gone ??= [l, p]
196
203
  continue
@@ -201,7 +208,7 @@ export class ChangeJudge {
201
208
  if (first === undefined && gone !== undefined) [first, firstAbs] = gone
202
209
  this.pending.clear()
203
210
  const byServer = firstAbs !== undefined && this.ctx.held()
204
- if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, true)) {
211
+ if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
205
212
  this.streak.n = 0
206
213
  return first
207
214
  }