Skip to content

fix: Invalidate only when Git ignore sources change - #13632

Merged
anthonyshew merged 2 commits into
vercel:mainfrom
smasato:fix/watch-invalidate-only-on-ignore-change
Aug 4, 2026
Merged

fix: Invalidate only when Git ignore sources change#13632
anthonyshew merged 2 commits into
vercel:mainfrom
smasato:fix/watch-invalidate-only-on-ignore-change

Conversation

@smasato

@smasato smasato commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Description

turbo watch treats every write to .git/config as a change to the repository's ignore state. The invalidation is unconditional, so writing a config key that has nothing to do with ignore rules flushes all watched globs and force-executes every task in the graph.

.git/config is watched because it can change core.excludesFile, and that coverage is correct. What is missing is a comparison: the watcher never checks whether the write actually changed anything it reads out of the config. This PR defers the decision to the refresh that the same event already selects, so a config write invalidates consumers only when the resolved set of ignore sources differs.

Reproduction

mkdir -p repro/packages/a repro/packages/b && cd repro

cat > package.json <<'JSON'
{
  "name": "repro-root",
  "private": true,
  "workspaces": ["packages/*"],
  "packageManager": "npm@11.16.0",
  "devDependencies": { "turbo": "2.10.8-canary.4" }
}
JSON

cat > turbo.json <<'JSON'
{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": { "dev": { "cache": false, "persistent": true } }
}
JSON

for p in a b; do
  cat > "packages/$p/package.json" <<JSON
{ "name": "pkg-$p", "version": "0.0.0", "scripts": { "dev": "node -e \"console.log('pkg-$p dev started at '+new Date().toISOString()); setInterval(()=>{},1e9)\"" } }
JSON
  echo "console.log('$p');" > "packages/$p/index.js"
done

printf 'node_modules\n.turbo\n' > .gitignore
git init -q . && git add -A && git commit -qm init
npm install

npx turbo watch dev --ui=stream

With turbo watch dev running, from another shell in the same repository:

git config foo.bar 1

Expected behavior

foo.bar does not affect Git's ignore rules, so nothing about the watched file set changed and no task re-runs.

Actual behavior

Every write to .git/config invalidates all globs and force-executes every task:

 WARNING  encountered filewatching error, flushing all globs: Git index or exclude state changed
 ERROR  file event error: NotifyError { error: Error { kind: Generic("Git index or exclude state changed"), paths: [] }, invalidation: true }
pkg-a:dev: cache bypass, force executing f28ac356e48a93e6
pkg-b:dev: cache bypass, force executing b99eb5e3ec09a157
pkg-a:dev: pkg-a dev started at 2026-08-01T06:09:43.928Z
pkg-b:dev: pkg-b dev started at 2026-08-01T06:09:43.927Z

Two controls were run in the same session:

  • Appending a line to the tracked file packages/a/index.js: no task restarts. dev is persistent and not interruptible, so this is the expected behavior — and it shows that an unrelated config write restarts tasks that a real source change deliberately does not.
  • git config core.excludesFile <path>: invalidation and restart. This is correct; the ignore rules did change.

Measured on 2.10.7 and 2.10.8-canary.4; five out of five unrelated git config writes restarted both persistent tasks in each run.

Cause

.git/config and .git/config.worktree are registered as Git control paths, with a comment noting that they are watched because they can change core.excludesFile:

fn control_paths(&self, root: &Path) -> HashSet<PathBuf> {
let mut paths = HashSet::new();
paths.extend(self.index.iter().cloned());
paths.extend(self.info_exclude.iter().cloned());
paths.extend(self.global_exclude.iter().cloned());
// These files can change core.excludesFile. They are also useful
// controls in linked worktrees, where the administrative directory is
// outside the worktree.
paths.extend(git_path(root, "config"));
paths.extend(git_path(root, "config.worktree"));
paths.extend(
directories_between(&self.worktree_root, root)
.into_iter()
.map(|directory| directory.join(".gitignore")),
);
paths
}

invalidates_consumers returns true for every control path except the index, so any event on a config file is reported as requiring conservative invalidation:

/// Returns whether changing `path` requires conservative invalidation.
///
/// Ignore files inside the Turbo root are ordinary filesystem inputs: once
/// the snapshot is refreshed, consumers can apply their normal scoped
/// semantics to the event. An inherited ignore file cannot be represented
/// by an in-root event (and may change the relevance of the root itself),
/// so it must invalidate every consumer.
pub fn invalidates_consumers(&self, path: &Path) -> bool {
if self.is_control_path(path) {
let path = normalize_event_path(&self.root, &self.match_root, path);
return self
.index_path
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_ref()
!= Some(&path);
}
if !Self::is_gitignore(path) {
return false;
}
let path = normalize_event_path(&self.root, &self.match_root, path);
!path.starts_with(self.match_root.as_path())
}

Every route that classifies an event ORs that result into the invalidation decision, so the refresh cannot override it. WatchEventSender::send additionally short-circuits, and never refreshes at all:

pub fn send(
&self,
event: Result<Event, NotifyError>,
) -> Result<(), broadcast::error::SendError<Result<Event, NotifyError>>> {
let git_control_changed =
if let (Some(repository_ignore), Ok(event)) = (&self.repository_ignore, &event) {
let invalidates = event
.paths
.iter()
.any(|path| repository_ignore.invalidates_consumers(path));
let refresh = event
.paths
.iter()
.any(|path| repository_ignore.should_refresh(path));
invalidates || (refresh && repository_ignore.refresh())
} else {
false
};
if git_control_changed {
route_event(
&self.registry,
Err(NotifyError::invalidation(
"Git index or exclude state changed",
)),
);
return Ok(());
}

In other words, the fact that a write happened is taken as evidence that the ignore rules changed; what the config now resolves to is never compared against what it resolved to before.

Fix

The index is already handled this way: an index event refreshes the snapshot, and refresh reports whether the tracked set changed in a way that matters for ignore relevance. The config files fit the same shape — their entire contribution is the set of ignore sources they name — so this PR groups them with the index:

  • GitContext resolves the config paths once during discovery and exposes derived_controls: control paths whose contribution a refresh re-derives in full (the index, and the config files).
  • invalidates_consumers no longer invalidates for those paths; they are left to the refresh that should_refresh already selects them for.
  • refresh compares the resolved control paths before and after re-discovery. Changing, adding, or removing core.excludesFile changes that set. It also compares the effective worktree root so core.worktree changes invalidate even when the control-path set remains stable.\n- Refresh-derived state is published atomically under one lock, and refreshes are serialized before discovery so an older refresh cannot overwrite newer state.

Content changes to an exclude file (.git/info/exclude, the global excludes file) and to inherited .gitignore files are unaffected: those arrive as events on their own paths and still invalidate conservatively.

Tests

config_refresh_invalidates_only_when_ignore_sources_change in crates/turborepo-filewatch/src/repository_ignore.rs asserts that .git/config is a refresh trigger but not an invalidation trigger, that a write to an unrelated key reports no change, and that setting and then unsetting core.excludesFile both report a change and are reflected in is_relevant. Additional regressions verify that changing core.worktree invalidates consumers and that refresh serialization covers state discovery.

Validation

  • cargo test -p turborepo-filewatch — 74 passed, 0 failed

  • cargo clippy -p turborepo-filewatch --all-targets --all-features -- -D warnings

  • cargo fmt --all -- --check

  • The reproduction above, driven by a script that writes foo.bar five times, then edits a tracked source file, then sets core.excludesFile. Counted over the whole session:

    2.10.7 2.10.8-canary.4 this branch
    flushing all globs after the 5 unrelated git config writes 10 10 0
    task restarts after those writes 5 rounds (both tasks each) 5 rounds 0
    flushing all globs after git config core.excludesFile 2 2 1
    task restarts after core.excludesFile 1 round 1 round 1 round
    task restarts after editing a tracked source file 0 0 0

Impact

Tools that persist state in .git/config are increasingly common. While such a tool is running, turbo watch restarts every persistent task on every write, which makes watch mode unusable alongside a development server. The reproduction uses git config foo.bar 1 because the key is irrelevant — any writer of an ignore-unrelated key produces the same result.

Testing Instructions

  1. Build this branch and run the reproduction above with the built binary (turbo --skip-infer watch dev --ui=stream).
  2. From another shell in the same repository, run git config foo.bar 1 a few times. No flushing all globs warning appears and no task re-runs.
  3. Run git config core.excludesFile /tmp/globalignore. The invalidation and restart still happen.
  4. cargo test -p turborepo-filewatch --lib

Environment

CLI:
   Version: 2.10.8-canary.4
   Package manager: npm

Platform:
   Architecture: aarch64
   Operating system: macos (26.5.2)
   Available memory (MB): 20214
   Available CPU cores: 12

Environment:
   Node.js version: v24.18.1
   git version 2.55.0

🤖 Generated with Claude Code

@smasato
smasato requested review from a team and tknickman August 1, 2026 06:37
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

@smasato is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

@anthonyshew anthonyshew changed the title fix(watch): invalidate only when Git ignore sources change fix: Invalidate only when Git ignore sources change Aug 4, 2026

@anthonyshew anthonyshew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thanks for reporting this and making a PR. I made a slight refactor in the interest of maintainability/correctness. Looks good, let's get this merged!

smasato and others added 2 commits August 4, 2026 10:01
Writing to .git/config invalidated every filewatch consumer, so a config
key unrelated to ignore rules flushed all globs and force-executed every
task. Config files are watched because they can change core.excludesFile,
which a refresh already re-derives in full, so group them with the index:
defer the decision to that refresh and invalidate only when the resolved
set of ignore sources differs.
@anthonyshew
anthonyshew force-pushed the fix/watch-invalidate-only-on-ignore-change branch from d759c01 to 4f0936c Compare August 4, 2026 16:03
@anthonyshew
anthonyshew enabled auto-merge (squash) August 4, 2026 16:12
@anthonyshew
anthonyshew disabled auto-merge August 4, 2026 16:14
@anthonyshew
anthonyshew merged commit f2957a2 into vercel:main Aug 4, 2026
49 of 61 checks passed
@smasato
smasato deleted the fix/watch-invalidate-only-on-ignore-change branch August 4, 2026 21:59
@smasato

smasato commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@anthonyshew thanks!

anthonyshew pushed a commit that referenced this pull request Aug 5, 2026
## Release v2.10.9-canary.1

> [!CAUTION]
> Versioned docs aliasing FAILED. [View
logs](https://github.com/vercel/turborepo/actions/runs/31044275876)

### Changes

- refactor: Delete legacy external-resolution PackageInfo state (#13526)
(`fbe88d3`)
- chore: Release Turborepo 2.10.8-canary.4 (#13557) (`887d9b1`)
- refactor: Remove external declaration compatibility paths (#13527)
(`3c8448d`)
- refactor: Produce immutable native task and command knowledge (#13528)
(`18d0bc3`)
- refactor: Migrate native task registration and suggestions (#13529)
(`7b5ee63`)
- refactor: Migrate turbo-json native task synthesis (#13530)
(`2d004f0`)
- refactor: Migrate persistent and recursive task validation (#13531)
(`dd7519b`)
- refactor: Migrate native task definition precedence (#13532)
(`8792ce9`)
- refactor: Migrate engine native command planning (#13533) (`67e4e9e`)
- refactor: Migrate executor native command resolution (#13534)
(`1b8accc`)
- refactor: Migrate native task query, devtools, and LSP views (#13535)
(`2753b76`)
- refactor: Migrate command summaries and delete legacy task paths
(#13536) (`adbad50`)
- refactor: Produce immutable task-contract knowledge (#13537)
(`97bff31`)
- refactor: Migrate engine task-contract composition (#13538)
(`fab50c5`)
- refactor: Migrate hashing engines to task contracts (#13539)
(`76206f8`)
- refactor: Exclude JavaScript from toolchain task-I/O dispatch (#13540)
(`115e850`)
- refactor: Migrate change classification to immutable knowledge
(#13543) (`5a55e8d`)
- refactor: Separate prune rendering with golden coverage (#13546)
(`62a047a`)
- refactor: Delete JS format interpretation from prune orchestration
(#13554) (`f1b37f6`)
- refactor: Audit remaining JS knowledge consumer reads (#13556)
(`7f12c3f`)
- refactor: Migrate MFE dependency detection off PackageInfo (#13558)
(`4cb9d94`)
- refactor: Remove prune PackageInfo dependencies (#13562) (`1bb9233`)
- refactor: Remove residual runtime PackageInfo gates (#13564)
(`c8eaedc`)
- refactor: Migrate boundary diagnostics off PackageInfo (#13571)
(`f977db3`)
- docs: Audit Cargo package knowledge (#13572) (`53dd13e`)
- refactor: Complete Cargo relationship and resolution knowledge
(#13576) (`b29e30c`)
- refactor: Port Cargo task and contract knowledge (#13581) (`74446c5`)
- refactor: Port Cargo watch and prune knowledge (#13582) (`acd3be1`)
- refactor: Remove runtime toolchain dispatch (#13584) (`2c10201`)
- ci: Restore Cargo target for lockfile tests (#13588) (`e848ad1`)
- refactor: Replace toolchains with repository contributors (#13585)
(`5d06e95`)
- refactor: Remove Cargo contributor plumbing (#13586) (`6ea1daf`)
- refactor: Remove ToolchainId runtime dispatch (#13587) (`24fbd3e`)
- refactor: Route task behavior through contract domains (#13589)
(`da88240`)
- refactor: Route package consumers by manifest (#13591) (`bb12c72`)
- refactor: Route MFE eligibility by manifest (#13592) (`08c7a74`)
- refactor: Project manifest-derived repository facts (#13593)
(`64e2a7e`)
- refactor: Route residual task behavior by capability (#13595)
(`fe9b72d`)
- refactor: Route resolution through explicit domains (#13596)
(`be21801`)
- refactor: Route N-API package listing by manifest (#13597) (`8f44636`)
- docs: Vercel Remote Cache authentication with OIDC policies (#13140)
(`c84ed36`)
- refactor: Own resolution fingerprints in repository (#13598)
(`16708a9`)
- refactor: Fail closed on invalid relationships (#13599) (`1a237de`)
- perf: Reuse Cargo metadata discovery snapshot (#13600) (`2fa79c5`)
- refactor: Resolve MFE package ownership from graph (#13601)
(`0253836`)
- refactor: Remove retained package payloads (#13603) (`58d9660`)
- feat: Add native Cargo format task (#13606) (`ab15587`)
- refactor: Compose repository graphs for optional toolchains (#13608)
(`915e82b`)
- ci: Invalidate Cap'n Proto caches (#13616) (`5cf35ad`)
- feat: Discover uv workspaces (#13609) (`8715646`)
- feat: Run native uv tasks (#13610) (`4195e41`)
- feat: Hash uv lockfile closures (#13611) (`e14de24`)
- fix: Make Windows Cap'n Proto cache relocatable (#13621) (`00538d0`)
- feat: Watch uv workspace changes (#13612) (`b2e25d4`)
- feat: Prune uv workspaces (#13613) (`f8f288e`)
- fix: Fall back to polling on macOS (#13622) (`b3dc99b`)
- test: Add uv workspace integration coverage (#13602) (`dd87718`)
- chore: Release Turborepo 2.10.8 (#13626) (`adbfec7`)
- perf: Walk literal-prefix tree globs without wax compilation (#13522)
(`eb42f23`)
- fix: Accept semver ranges in devEngines.packageManager.version
(#13623) (`5297aa2`)
- docs: Explain affected package invalidation reasons (#13594)
(`c6fbc97`)
- perf(lockfiles): Borrow field-name scalars in the pnpm fast parser
(#13648) (`73e8d8c`)
- perf(repository): Avoid discarded alias allocation in Relationship
(#13650) (`b888891`)
- perf(lockfiles): Drop redundant human_name clone for pnpm v7/v9
(#13649) (`2effc86`)
- perf: Index workspace nodes by name in project_relationships (#13647)
(`0bf6973`)
- perf: Share resolution identity lists across identical workspace
closures (#13641) (`5107207`)
- docs: Fix duplicated word in runtime dependencies guide summary
(#13630) (`0664de8`)
- refactor: Remove turborepo-lsp dependency on turborepo-lib (#13631)
(`8ff1ad7`)
- perf: Index Bun nested lockfile entries by name for fallback
resolution (#13633) (`c0a8996`)
- perf: Memoize framework inference per package during task hashing
(#13634) (`21ea1d0`)
- perf: Avoid materializing transient declarations in
external_dependencies (#13646) (`a892a89`)
- perf: Enable shared closure DP for npm and yarn1 lockfiles (#13635)
(`04db9a8`)
- perf: Parse pnpm explicit-key entries in the lockfile fast path
(#13640) (`83ae3d9`)
- perf: Parallelize resolution fingerprint hashing (#13642) (`95f2297`)
- perf: Build resolution identity lists in parallel (#13643) (`58e4e8a`)
- perf: Intern resolution identities as Arc&lt;str&gt; across closures
(#13645) (`6af5423`)
- fix: Compose affected tasks with package filters (#13656) (`0b1f466`)
- docs: Explain worktree cache path isolation (#13657) (`9e2865e`)
- fix: Upgrade brace-expansion to 5.0.9 (#13658) (`e247a0e`)
- docs: Correct verified inaccuracies in the Turborepo Agent Skill
(#13644) (`c05ed3d`)
- chore: Update Next.js to 16.3.0 (#13659) (`a936402`)
- fix: don't use eprintln! in the panic hook (#13637) (`658fd54`)
- fix: Invalidate only when Git ignore sources change (#13632)
(`f2957a2`)
- docs: Update Geistdocs to 1.19.4 (#13680) (`3617c78`)
- docs: Exclude Turborepo from its own OSS products menu (#13681)
(`43ee46a`)
- docs: Use the geistdocs Turborepo logo in the navbar (#13682)
(`d43eec5`)
- docs: Update redirected vercel.com/nextjs.org links to current targets
(#13685) (`b23e283`)
- refactor: Generalize native command arguments (#13664) (`e797251`)
- refactor: Move native contracts to tasks (#13665) (`851857d`)
- docs: Fix loadTransformers reference in turbo-codemod README (#13683)
(`7b8144e`)
- refactor: Model native task execution explicitly (#13666) (`2634f3c`)
- feat: Compose aggregate native task dependencies (#13667) (`ef8b3f3`)
- fix: Respect aggregate task overrides (#13668) (`81f88f2`)
- test: Stabilize watch task inputs regression test (#13686) (`308ea6b`)
- feat: Parse Python quality tool declarations (#13669) (`b1d5dc5`)
- feat: Resolve Python quality plans (#13670) (`439b465`)
- refactor: Extract uv native task specs (#13671) (`e14f04e`)
- feat: Synthesize Python quality tasks (#13672) (`94708ad`)
- test: Cover Python quality task commands (#13673) (`0d43ff3`)
- feat: Hash Python quality task inputs (#13674) (`3584a5f`)
- test: Cover Python quality task graph (#13675) (`09bd548`)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
anthonyshew pushed a commit that referenced this pull request Aug 7, 2026
## Release v2.10.9

> [!CAUTION]
> Versioned docs aliasing FAILED. [View
logs](https://github.com/vercel/turborepo/actions/runs/31216733075)

### Changes

- chore: Release Turborepo 2.10.8 (#13626) (`adbfec7`)
- perf: Walk literal-prefix tree globs without wax compilation (#13522)
(`eb42f23`)
- fix: Accept semver ranges in devEngines.packageManager.version
(#13623) (`5297aa2`)
- docs: Explain affected package invalidation reasons (#13594)
(`c6fbc97`)
- perf(lockfiles): Borrow field-name scalars in the pnpm fast parser
(#13648) (`73e8d8c`)
- perf(repository): Avoid discarded alias allocation in Relationship
(#13650) (`b888891`)
- perf(lockfiles): Drop redundant human_name clone for pnpm v7/v9
(#13649) (`2effc86`)
- perf: Index workspace nodes by name in project_relationships (#13647)
(`0bf6973`)
- perf: Share resolution identity lists across identical workspace
closures (#13641) (`5107207`)
- docs: Fix duplicated word in runtime dependencies guide summary
(#13630) (`0664de8`)
- refactor: Remove turborepo-lsp dependency on turborepo-lib (#13631)
(`8ff1ad7`)
- perf: Index Bun nested lockfile entries by name for fallback
resolution (#13633) (`c0a8996`)
- perf: Memoize framework inference per package during task hashing
(#13634) (`21ea1d0`)
- perf: Avoid materializing transient declarations in
external_dependencies (#13646) (`a892a89`)
- perf: Enable shared closure DP for npm and yarn1 lockfiles (#13635)
(`04db9a8`)
- perf: Parse pnpm explicit-key entries in the lockfile fast path
(#13640) (`83ae3d9`)
- perf: Parallelize resolution fingerprint hashing (#13642) (`95f2297`)
- perf: Build resolution identity lists in parallel (#13643) (`58e4e8a`)
- perf: Intern resolution identities as Arc&lt;str&gt; across closures
(#13645) (`6af5423`)
- fix: Compose affected tasks with package filters (#13656) (`0b1f466`)
- docs: Explain worktree cache path isolation (#13657) (`9e2865e`)
- fix: Upgrade brace-expansion to 5.0.9 (#13658) (`e247a0e`)
- docs: Correct verified inaccuracies in the Turborepo Agent Skill
(#13644) (`c05ed3d`)
- chore: Update Next.js to 16.3.0 (#13659) (`a936402`)
- fix: don't use eprintln! in the panic hook (#13637) (`658fd54`)
- fix: Invalidate only when Git ignore sources change (#13632)
(`f2957a2`)
- docs: Update Geistdocs to 1.19.4 (#13680) (`3617c78`)
- docs: Exclude Turborepo from its own OSS products menu (#13681)
(`43ee46a`)
- docs: Use the geistdocs Turborepo logo in the navbar (#13682)
(`d43eec5`)
- docs: Update redirected vercel.com/nextjs.org links to current targets
(#13685) (`b23e283`)
- refactor: Generalize native command arguments (#13664) (`e797251`)
- refactor: Move native contracts to tasks (#13665) (`851857d`)
- docs: Fix loadTransformers reference in turbo-codemod README (#13683)
(`7b8144e`)
- refactor: Model native task execution explicitly (#13666) (`2634f3c`)
- feat: Compose aggregate native task dependencies (#13667) (`ef8b3f3`)
- fix: Respect aggregate task overrides (#13668) (`81f88f2`)
- test: Stabilize watch task inputs regression test (#13686) (`308ea6b`)
- feat: Parse Python quality tool declarations (#13669) (`b1d5dc5`)
- feat: Resolve Python quality plans (#13670) (`439b465`)
- refactor: Extract uv native task specs (#13671) (`e14f04e`)
- feat: Synthesize Python quality tasks (#13672) (`94708ad`)
- test: Cover Python quality task commands (#13673) (`0d43ff3`)
- feat: Hash Python quality task inputs (#13674) (`3584a5f`)
- test: Cover Python quality task graph (#13675) (`09bd548`)
- chore: Release Turborepo 2.10.9-canary.1 (#13687) (`c09a92f`)
- docs: Document dependency-driven Python tasks (#13676) (`a98e5cd`)
- fix: Prune Bun wildcard workspace dev dependencies (#13694)
(`efe4e1b`)
- fix: Prevent Windows process cleanup PID reuse (#13695) (`3b0e57f`)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants