Skip to content
vxvx
GitHubBlueskydev.toRSS

vx watch

vx watch runs the named task, then re-runs it on every filesystem change in the projects in scope. Press Ctrl+C to stop.

vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]

OPTIONS are vx run’s, less the ones Constraints names.

Terminal window
vx watch test # cwd project; re-test on changes
vx watch test --all # every project that declares `test`
vx watch lint --filter '@scope/*' # filtered scope
vx watch build -- --sourcemap # forwarded args carry through every cycle
  1. Initial run. Same code path as vx run — same scope resolution, same task graph, same cache behaviour. The line vx watch: initial run... precedes it.
  2. Watch loop. After the initial run finishes (or once it waits only on a server’s readiness, X-139), the directory of every project a cycle can run — the scope (the bare tasks’ scope plus each pkg#task’s own project; only those when every task is anchored) plus its transitive dependencies, the closure --filter 'app...' walks, cross-project dependsOn edges included — is watched recursively, less any project nested inside it, with a config or not (a root project’s key leaves a nested project’s files out, so an edit there is no cycle; X-42, X-57). The workspace root is watched (non-recursively) for a claimed file (a lockfile), for .vx/projects.lock.json and for an edit to .vx/workspace.* — the one root file that shapes a run (plugins, config stage, concurrency) without being any task’s input; the cycle after it re-evaluates the file. A .gitignore there or in a project’s parent directory is a cycle too: inputs are gitignore-aware, so it changes what keys read (X-136). A file a config imports by relative path from outside the watched projects (a shared preset) is watched too, and its edit is a cycle that re-reads the configs; so is one inside a project, which re-reads the set as the config’s own edit does (WD-14). A file the workspace config imports is loaded once per process, so its edit is named with the restart it needs rather than run stale (item 949). An import whose file is gone stays on the list by the name it would have, so its return is a cycle (X-133). The projects are the lock’s: a new .vx/projects.lock.json is the event that adds or drops one, and its directory is watched from before that cycle runs, so an edit made during it queues (X-138). A config added and not locked is no project yet. With no lock at start the first cycle fails saying so, and the watch waits for one. A project directory, or one a config imports from, removed and made again (a checkout that restores packages/) is heard from its nearest directory that exists and each one above it, and a watch whose directory was replaced is armed again (an OS watch holds the deleted one; watch-recreated-dirs.test.ts). There, a project directory that is still the same one starts no cycle: a task writing at its top level moved its mtime, which macOS reports to the parent (watch-project-dir-write.test.ts). Under --filter or --affected the scope is the one resolved at start, and a new project joins it only as a dependency of it. A task’s own declared outputs (cache.outputs.files, outputs.workspaceFiles; a plugin’s project stage counts, as in a run) never trigger a re-run — a cycle that writes dist/ is not an edit, nor is the dist directory itself coming and going (a literal entry covers its whole tree, as in the schema), unless some task reads the path as an input (an in-place formatter’s src/** output still leaves a build reading src/** watching it, item 946; a task’s own outputs are no input of it, so a turbo() task reading **/* does not re-run on its own dist/; only the tasks the run reaches through dependsOn are asked, so an unwatched lint reading **/* beside vx watch build asks nothing) — and neither do node_modules, .git or the cache directory. A write the task did NOT declare (a task with no cache block declares nothing) is caught by state, judged once the bytes have settled: a file whose bytes and mode did not change since the loop last saw it is not an edit (a chmod is one, as the key reads the executable bit), nor is a directory whose entries (names and sizes) did not, nor a path that stayed gone; a symlink is its target string and what it points to, as the key folds them, so a retarget is an edit, and so is an edit behind it: watch arms each link’s target, wherever it lies, and names the link; a path the loop has never judged is an edit only if it was modified after the watchers went live (macOS delivers the initial run’s own writes after the arm; the later of the path’s mtime and ctime says which side of it a path belongs to, so a file moved in with an old mtime by mv, cp -p or tar x still counts, and a symlink’s own times are read, never its target’s), and a path git did not list at the arm that is gone again (vim’s 4913 write probe, a lock file) is no edit; and nothing is judged while a cycle runs — its own writes are mid-flight, a dist deleted and not yet rebuilt is a state the tree will not keep — so paths that land mid-run are judged together one debounce window after it ends, an edit made meanwhile included. A task that writes into its own project, rm -rf dist && tsc included, costs one extra cycle instead of re-running forever — when the bytes it leaves are the same. A file it rewrites with DIFFERENT bytes every run (a pid file, a timestamped log) is either git-ignored — a git-ignored path never starts a cycle, since no cache key can see it (a user’s edit to one still does in a project with a task that has a command and no cache, which reads what it likes, item 947, outside any project nested in it; a write the last cycle made is not, read off the later of its mtime and ctime, so an output a cache hit restores with its saved mtime is the run’s, watch-restored-output.test.ts) — or declared an output, or the loop re-runs on it; after three such cycles in a row watch names the path and the remedy, once, and keeps going. A dev server the last cycle left running counts as that cycle for as long as it runs, so a log it rewrites in its project is named too, with .gitignore as the remedy (a persistent task declares no outputs; item 948). A file git tracks is never blamed on the server: three saves of a source file are the user’s (WD-7). When any project’s config declares inputs.workspaceFiles, the per-project watchers are swapped for ONE recursive root watcher (boundaries are off for those globs, so a root-relative glob can name a file anywhere). That watcher hears every write in the tree and keeps only the ones a key can see: a path inside a project’s directory, a fingerprint file at the root, or a match of a declared workspaceFiles glob. A log written at the root, a coverage/ or .turbo/ tree, an editor’s scratch file are dropped before the trigger — so vx watch … > build.log inside the repo settles instead of feeding itself a cycle per line of its own output. The opening line, watching N projects · M tasks (after the initial run’s rows), is printed only after every watcher has reported a probe file written under it (.vx-watch-probe, re-written on a short backoff until its event arrives, then removed): on macOS a directory watcher can return before its event stream is live, and an edit in that gap is silently lost — so the line is a promise, not a hope. A probe is never an edit, at any depth: another watcher’s, seen under a nested project, ran a cycle (and restarted a dev server) with no edit made (item 1016). A watcher whose probe is not heard within 2 s is replaced by a poller that checks every 250 ms, with the notice vx watch: <dir>: no OS watch events within 2000 ms; polling every 250 ms instead.
  3. On change. A cycle opens with a blank line and changed <project> <relpath>, plus (+N more) when more paths changed, then its task rows as vx run prints them, with no footer. The named path is the first changed one that still exists, so an editor’s temporary file renamed away names nothing; a deletion names the cycle when nothing else changed. The watching line is said once: a cycle that re-reads the watched set (a member came or went, the lock or a plugin changed) says nothing more; VX_DEBUG_WATCH=1 says each arm on stderr as vx watch: armed N projects. Errors and refusals go to stderr. The orchestrator is invoked again with the same options, save one: the --affected diff is read once, for the initial run. A later cycle runs the requested task in every project of the scope fixed at start, and the cache keys decide what executes (the startup diff, asked again, kept an edit made since out of every cycle). Events arriving while a run is in flight queue and drain after the current cycle. Re-runs are debounced ~150ms after the last event, and wait at most 1 s after the first, so a writer that never pauses holds no edit back.
  4. Exit. SIGINT (Ctrl+C) exits 0; once the cycle in flight is down, vx watch: stopped is the last line.

Always ignored (no re-trigger):

  • node_modules/, .git/, .vx/cache/ anywhere in the path.
  • A path git ignores (git check-ignore, asked once per debounce window, never per event), unless a task’s inputs name it outright (.env.local, dist/**): invisible to every other cache key — inputs are tracked + untracked-not-ignored — so a cycle it started could change nothing. A tracked file that matches a pattern is not ignored, by git’s own rule; outside a repository nothing is.
  • Files ending in ~ (editor swap files), and .vx/tmp/, where restores stage.
  • The run’s resolved cache directory, wherever it is. .vx/cache/ covers the default, but defineWorkspace({ cache: { dir } }) and --cache-dir can put it anywhere; watching it would let vx’s own cache writes trigger the next cycle, which writes again — a loop that never settles.

Everything else triggers a cycle. We deliberately don’t filter events against per-task inputs.files — the cache hash is the source of truth. A change to an irrelevant file produces a cache-hit run (typically tens of ms); the cost is much smaller than the engineering cost of a per-event glob match.

One recursive OS watcher (fs.watch) per project directory in scope, plus the workspace root for the fingerprint files. Each watcher must prove it delivers: a directory whose watcher reports nothing within 2 s of the probe is polled every 250 ms instead, with a one-line notice on stderr (no OS watch events within 2000 ms; polling every 250 ms instead). Where the watcher is known not to deliver — a sandbox without FSEvents access on macOS, a network mount, a container bind — VX_WATCH_POLL=1 polls from the start and skips the 2 s probe; the loop says so once (vx watch: polling every 250 ms (VX_WATCH_POLL)). A watcher the OS refuses for its watch limit (ENOSPC, EMFILE) is polled too, with a notice naming the limit to raise. The poller walks the same tree the event filter keeps: the always-ignored segments and the run’s declared output containers are never sampled. It samples each file’s later clock of mtime and ctime, so a replacement that carries the old file’s mtime (cp -p, rsync -a, a mv) is an edit to it as it is to the OS watcher.

A root file a plugin claims — a lockfile or install setting under @vzn/vx-lockfile (pnpm-lock.yaml, bun.lock, .npmrc, …), turbo.json under turbo() (item 961) — triggers a cycle, and so does .vx/projects.lock.json. Watch mode hears them because it watches the workspace root (non-recursively); a plugin added to the workspace config mid-watch counts from the cycle that loads it. The keys then decide which projects actually re-run: core keys no package file, so a lockfile edit re-keys only through its claimant (cache key derivation). Every cycle’s configs come from .vx/projects.lock.json, so a config edit alone changes no command, and a re-lock (vx lock) is a cycle that re-reads the watched set (item 971).

The following flags are rejected (parser exits 1 before the initial run):

  • --dry — it skips execution; nothing to watch.
  • --profile — would overwrite its target per cycle.
  • --verbosity <n> (n > 0) — formats ONE run’s result; a watch loop has no single run to report. (--verbosity 0 is accepted: it asks for what watch already prints.)

A requested persistent task (exec.persistent, a dev server) starts once and stays up across cycles; each cycle re-runs what it depends on, as the cache keys decide. So vx watch dev, with dev depending on ^build, rebuilds only the libraries an edit reaches on the way to the app and leaves the server running; the dev tool’s own reload picks up what was rebuilt. What the server writes keeps printing. It restarts only when its own task config (or the args forwarded to it) changed, or when it died: the old one is stopped first (the kill grace, then SIGKILL), so the two never hold one port. One that dies while watch idles is said (vx: app#dev exited with code 3); the next change starts it again. A cycle in which a task the server depends on fails stops it. An edit while a cycle waits only on a server that has not printed its readyWhen line stops that cycle and starts the next, so a fix reaches a server stuck before ready, in the initial run too. Stopping watch stops the server too. A one-shot task (vx watch build) re-runs itself and what it depends on.

  • 0 — clean Ctrl+C / SIGTERM exit. A cycle in flight is torn down first — its children, and the dev server held between cycles, get the signal watch received (a Ctrl-C as SIGINT; a SIGTERM or SIGHUP as SIGTERM), a 2 s grace, then SIGKILL — and the process leaves only once it has returned, so a CI cancellation never orphans a task. Also 0, with no watch, when --affected selects nothing: watch prints vx watch: nothing affected since <ref> and exits as vx run does.
  • 1 — parser error, missing scope, or a task name no project in scope declares: the initial run refuses it as vx run does, with the same Did you mean hint, and watch exits rather than re-run the refusal on every change.

A cycle that throws — a vx.config.* that does not parse — prints vx watch: cycle failed: <reason> and watch keeps watching, the initial run included, so saving the fix re-runs (item 1017).

Re-run cycles whose orchestrator returns { ok: false } do NOT exit the watch loop — a failed cycle just prints the framed FAILED block and waits for the next change. This matches turbo watch / nx watch.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.