src/workspace/affected.ts — git-relative project selection
Purpose
Section titled “Purpose”Power --affected[=<base>] and the [<since>] filter form. Resolves
the set of project names whose files changed between <since> and
the current working tree.
Public surface
Section titled “Public surface”export interface AffectedArgs { workspaceRoot: string since: string // required: ref / commit / branch projects: readonly ProjectMeta[] /** Which projects declare a `workspaceFiles` glob matching these paths. * Asked about every changed path, once something changed (item 954). */ workspaceGlobOwners?: (paths: readonly string[]) => Promise<Iterable<string>> /** The fingerprint files a plugin claims and its answer for a change to * one; resolved lazily, only when a diff touches a root file. */ fingerprintClaims?: () => Promise<FingerprintClaims> /** Cross-project `dependsOn` edges, project → projects its tasks name; * asked only when a package was renamed or removed (item 1085). */ taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>}
export function affectedProjects(args: AffectedArgs): Promise<Set<string>>
/** Default base when `--affected` has no value. */export function defaultAffectedBase(workspaceRoot: string): Promise<string>
/** Lockfiles a `fingerprint` plugin claims, so `--affected` can follow * a per-project dependency closure instead of the whole fingerprint. */export interface FingerprintClaims { readonly files: ReadonlySet<string> /** Project names a change to a claimed file affects; `undefined` = all. */ affected(change: { file: string before: Uint8Array | null after: Uint8Array | null }): Promise<ReadonlySet<string> | undefined>}
/** Does any `workspaceFiles` glob match this root-relative path? */export function workspaceGlobsMatch(globs: readonly string[], rel: string): boolean
/** Is `ref` the current HEAD? A base that is already HEAD selects nothing, * which is a clean exit rather than an empty run. */export function refIsHead(workspaceRoot: string, ref: string): booleanAlgorithm
Section titled “Algorithm”- The base is refused before any spawn when it is empty or starts
with
-: it reaches git as an argument (never a shell, so$(…)is opaque), but an option-like value is a real option —--output=<path>is an arbitrary file write from a CI-supplied string. Every git call in the module also ends its options (--end-of-options) before the ref, so a new caller cannot lose the guard by accident. A range (A..B,A...B) is refused there too, namingAas the base to pass alone:rev-parse --verifyrefuses a range, and its “did not resolve” blamed refs that exist...is illegal in a ref name, so the check refuses no real ref. verifyRef(workspaceRoot, since)—git rev-parse --verify --quiet --end-of-options <ref>. ThrowsUserErrorif the ref doesn’t resolve locally.git diff --name-only <merge-base(since, HEAD)>(<since>itself when there is no merge base) — emits the union of committed + staged- unstaged changes. Matches Turbo’s
[<since>]semantics.
- unstaged changes. Matches Turbo’s
- Untracked files (
git ls-files --others --exclude-standard) are unioned in — a brand-new source file is a change.vx-lock.jsonis filtered out, so re-runningvx locknever selects everything. - If
pnpm-workspace.yamlor a ROOT lockfile no plugin claims changed, every project is selected and the walk stops: those files are folded into the workspace fingerprint, so they re-key every task. A lockfile afingerprintplugin claims (@vzn/vx-lockfile) selects the projects whose dependency closure moved instead —lockfile-claim.md; one that appeared or went still selects everything. - Otherwise each changed path reaches a project through four
channels, and the union is returned:
- Containment. Walk the path’s ancestor dirs bottom-up until one is a project dir; the first hit is the DEEPEST containing project, so a nested project wins over its parent. A NEW nested project (its manifest absent or nameless at the base) also selects the project above it, whose inputs it took (D-1). (This replaced an earlier sort-by-directory-length-descending pass; the walk is O(files · depth) instead of O(files · projects).)
- Config imports. A project whose
vx.config.*transitively imports the changed file — seeconfig-imports.md. Resolved-config hashing folds those values into the key, so selection has to see them too. - Workspace globs. For every changed path,
workspaceGlobOwners(cli/select.ts) asks which projects declare a matchingcache.inputs.workspaceFilesglob — through the run path’s staged load, so a glob aprojectplugin gave a config-less package counts. The match runs the entries throughasTrees, the same ruleresolveWorkspaceFilesapplies, because this answers the question the KEY answers: with the entries raw,./shared/**and the literalsharedfolded a changed file into a project’s key while selecting nothing, so--affectedskipped a project its own key called stale (item 445). Every path, not only the ones no project owns: a glob may name a file inside another project (schema.mdallows it), and asking only the unowned ones ran the owner alone (item 954). The--affectedsugar has staged every config for its graph walk already; a bare[ref]filter pays that one load when something changed. - The base graph. When a manifest changed, the package graph is
built again over the changed manifests as the base had them (one
git cat-file --batch), and every project whosedirectDepsdiffer from today’s is selected: a deleted package (item 959) or aversion/namethat no longer satisfies a dependent’s range drops an edge its key folded, and today’s graph has no dependent to walk to (D-3). A rootworkspacesedit selects every project.
Selection is never hashed, so widening it changes no cache key: every channel here may over-select safely — but it may not UNDER-select, and a channel that reads a declaration differently from the key does exactly that. It does NOT follow that selection is complete — the config-import channel stops at project boundaries and documents what that misses.
defaultAffectedBase:
- Try
git symbolic-ref --short -q refs/remotes/origin/HEAD(e.g.origin/main). - Fall back to
HEAD~1if the symbolic-ref isn’t set. - If
HEAD~1does not resolve either (a CI checkout atfetch-depth: 1), aUserErrorasks to fetch history or name the base (--affected=origin/main).
Filter integration
Section titled “Filter integration”cli/select.ts:resolveFilters invokes affectedProjects once per
[<since>] filter, stuffs the result in
affectedByFilter: Map<ParsedFilter, Set<string>>, then calls
applyFilters({ filters, projects, graph, affectedByFilter }).
This separation keeps workspace/filter.ts pure (no FS / no git
spawns) — easy to test against in-memory fixtures.
What this does NOT do
Section titled “What this does NOT do”- Doesn’t run
git fetch. If the local clone is stale,affectedProjectsoperates on stale refs. CI scripts should fetch first. - Doesn’t honor
.gitattributesor--diff-filter. A whitespace-only commit still marks projects as affected. - Doesn’t intersect with
cache.inputs.files. A project is affected if any file changed under its dir — even if no cached task lists that file as an input. (We default to “be permissive”; Turbo behaves the same.)
tests/affected.test.ts:
- single file change → owning project selected.
- file in nested project → nested project wins over parent.
- file outside any project → no project selected.
- bad git ref → UserError with the ref name.
defaultAffectedBasereturnsorigin/HEADsymref thenHEAD~1.
Every git spawn goes through spawnGitSync / spawnGit: a git that is
not on PATH is util’s gitSpawnRefusal (one line, the install named),
never the ENOENT stack defaultAffectedBase showed a minimal image
(item 241). tests/no-git-on-path.test.ts.