Prevent @tailwindcss/upgrade from rewriting files inside node_modules when run from a workspace subpackage (#20329)

Fixes #20328.

## What changed

When `@tailwindcss/upgrade` is invoked from a subpackage of a workspace
(e.g. `pnpm --filter …` or `cd packages/foo && pnpm exec upgrade`), it
traverses `../../node_modules/…` and applies its v3 → v4 migration
transform (`@tailwind utilities;` → `@import 'tailwindcss/utilities'
layer(utilities);`) to files inside the installed `tailwindcss` package
itself — a self-referential import that later detonates as an infinite
CSS-resolution loop and reads like a "cache corruption" bug (matches the
report in #19726 / #20328).

Root cause: `globby`'s `isGitIgnored` only walks `.gitignore` files at
or below its `cwd`. When invoked from a subpackage, the workspace-root
`.gitignore` (which almost always lists `node_modules`) is never
consulted, and `../../node_modules/…` paths come back as **not**
ignored.

The fix anchors `isGitIgnored` at the git repository root instead of the
subpackage:

- New helper: `gitRoot(cwd)` in `src/utils/git.ts` — thin wrapper over
`git rev-parse --show-toplevel`.
- `analyze(stylesheets, { base })` in `src/codemods/css/analyze.ts` now
calls `isGitIgnored({ cwd: gitRoot(base) ?? base })`. Falls back to the
previous behavior when not inside a git repository or when `git` is
unavailable.

## Test

Added an integration test that reproduces the exact scenario: a pnpm
workspace with a subpackage that imports `tailwindcss/utilities.css`,
upgrade run from the subpackage, then asserts
`packages/css/node_modules/tailwindcss/utilities.css` still holds the
pristine `@tailwind utilities;` directive.

Verified the test fails on `main` and passes with this change.

The 415 existing upgrade unit tests still pass. Two npm-related snapshot
failures in `upgrade-errors.test.ts` (`half-upgraded v3 project to v4
(bun|npm)`) pre-exist this change — they're about newer npm's `npm
notice run` output that isn't in the snapshots, unrelated to what this
PR touches.


---

Edit by @RobinMalfait: going to run on all [ci-all] so we can make sure
it works on Windows as well.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Benoît Rouleau 2026-07-14 12:39:24 -04:00 • committed by GitHub
parent e48c5e8047
commit a854b35bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 74 additions and 3 deletions

View file

@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `<length>` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319))
- Lazily load `@parcel/watcher` when using the `--watch` flag in `@tailwindcss/cli`, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/issues/20325))
- Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#19767](https://github.com/tailwindlabs/tailwindcss/issues/19767), [#19768](https://github.com/tailwindlabs/tailwindcss/issues/19768))
- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20328](https://github.com/tailwindlabs/tailwindcss/issues/20328))
## [4.3.2] - 2026-06-26

View file

@ -3371,6 +3371,61 @@ test(
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20328
test(
'ignored files should not be touched when upgrading from a nested directory',
{
fs: {
'pnpm-workspace.yaml': yaml`
#
packages:
- packages/*
`,
'package.json': json`
{
"name": "root",
"private": true
}
`,
'packages/css/package.json': json`
{
"name": "css-pkg",
"private": true,
"devDependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'packages/css/src/input.css': css`
@import 'tailwindcss/utilities.css' layer(utilities) source(none);
`,
'packages/css/src/index.html': html`<div class="flex text-red-500">Hi</div>`,
// Ensure files/folders ignored by a `.gitignore` in the root take effect
// when executing the upgrade tool from a sub-package.
'.gitignore': txt`
node_modules/
`,
},
},
async ({ root, exec, fs, expect }) => {
await exec('git init', { cwd: root })
await exec('pnpm exec upgrade --force', {
cwd: path.join(root, 'packages/css'),
})
expect(await fs.dumpFiles('packages/css/node_modules/tailwindcss/utilities.css'))
.toMatchInlineSnapshot(`
"
--- packages/css/node_modules/tailwindcss/utilities.css ---
@tailwind utilities;
"
`)
},
)
test(
'upgrade <style> blocks carefully',
{

View file

@ -4,11 +4,14 @@ import postcss, { type Result } from 'postcss'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { Stylesheet, type StylesheetConnection } from '../../stylesheet'
import { gitRoot } from '../../utils/git'
import { error, highlight, relative } from '../../utils/renderer'
import { resolveCssId } from '../../utils/resolve'
export async function analyze(stylesheets: Stylesheet[]) {
let isIgnored = await isGitIgnored()
export async function analyze(stylesheets: Stylesheet[], { base }: { base: string }) {
// Use the project's git root, instead of the `base` to ensure all
// `.gitignore` rules are being used.
let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base })
let processingQueue: (() => Promise<Result>)[] = []
let stylesheetsByFile = new DefaultMap<string, Stylesheet | null>((file) => {
// We don't want to process ignored files (like node_modules)

View file

@ -125,7 +125,7 @@ async function run() {
// Analyze the stylesheets
try {
await analyzeStylesheets(stylesheets)
await analyzeStylesheets(stylesheets, { base })
} catch (e: any) {
error(`${e?.message ?? e}`, { prefix: '↳ ' })
}

View file

@ -1,5 +1,17 @@
import { execSync } from 'node:child_process'
export function gitRoot(cwd?: string): string | null {
try {
return execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8',
cwd,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
} catch {
return null
}
}
export function isRepoDirty(cwd?: string) {
try {
let stdout = execSync('git status --porcelain', { encoding: 'utf-8', cwd })