Skip to content
GitHubRSS

src/workspace/affected.ts — git-relative project selection

Power --affected[=<base>] and the [<since>] filter form. Resolves the set of project names whose files changed between <since> and the current working tree.

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): boolean
  1. 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, naming A as the base to pass alone: rev-parse --verify refuses a range, and its “did not resolve” blamed refs that exist. .. is illegal in a ref name, so the check refuses no real ref.
  2. verifyRef(workspaceRoot, since) — git rev-parse --verify --quiet --end-of-options &lt;ref&gt;. Throws UserError if the ref doesn’t resolve locally.
  3. 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.
  4. Untracked files (git ls-files --others --exclude-standard) are unioned in — a brand-new source file is a change. vx-lock.json is filtered out, so re-running vx lock never selects everything.
  5. If pnpm-workspace.yaml or 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 a fingerprint plugin claims (@vzn/vx-lockfile) selects the projects whose dependency closure moved instead — lockfile-claim.md; one that appeared or went still selects everything.
  6. 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 — see config-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 matching cache.inputs.workspaceFiles glob — through the run path’s staged load, so a glob a project plugin gave a config-less package counts. The match runs the entries through asTrees, the same rule resolveWorkspaceFiles applies, because this answers the question the KEY answers: with the entries raw, ./shared/** and the literal shared folded a changed file into a project’s key while selecting nothing, so --affected skipped 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.md allows it), and asking only the unowned ones ran the owner alone (item 954). The --affected sugar 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 whose directDeps differ from today’s is selected: a deleted package (item 959) or a version / name that 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 root workspaces edit 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~1 if the symbolic-ref isn’t set.
  • If HEAD~1 does not resolve either (a CI checkout at fetch-depth: 1), a UserError asks to fetch history or name the base (--affected=origin/main).

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.

  • Doesn’t run git fetch. If the local clone is stale, affectedProjects operates on stale refs. CI scripts should fetch first.
  • Doesn’t honor .gitattributes or --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.
  • defaultAffectedBase returns origin/HEAD symref then HEAD~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.