Skip to content

Commit a854b35

Browse files
Prevent @tailwindcss/upgrade from rewriting files inside node_modules when run from a workspace subpackage (tailwindlabs#20329)
Fixes tailwindlabs#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 tailwindlabs#19726 / tailwindlabs#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>
1 parent e48c5e8 commit a854b35

5 files changed

Lines changed: 74 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- 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))
2222
- 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))
2323
- 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))
24+
- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20328](https://github.com/tailwindlabs/tailwindcss/issues/20328))
2425

2526
## [4.3.2] - 2026-06-26
2627

integrations/upgrade/index.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3371,6 +3371,61 @@ test(
33713371
},
33723372
)
33733373

3374+
// https://github.com/tailwindlabs/tailwindcss/issues/20328
3375+
test(
3376+
'ignored files should not be touched when upgrading from a nested directory',
3377+
{
3378+
fs: {
3379+
'pnpm-workspace.yaml': yaml`
3380+
#
3381+
packages:
3382+
- packages/*
3383+
`,
3384+
'package.json': json`
3385+
{
3386+
"name": "root",
3387+
"private": true
3388+
}
3389+
`,
3390+
'packages/css/package.json': json`
3391+
{
3392+
"name": "css-pkg",
3393+
"private": true,
3394+
"devDependencies": {
3395+
"tailwindcss": "^4",
3396+
"@tailwindcss/upgrade": "workspace:^"
3397+
}
3398+
}
3399+
`,
3400+
'packages/css/src/input.css': css`
3401+
@import 'tailwindcss/utilities.css' layer(utilities) source(none);
3402+
`,
3403+
'packages/css/src/index.html': html`<div class="flex text-red-500">Hi</div>`,
3404+
3405+
// Ensure files/folders ignored by a `.gitignore` in the root take effect
3406+
// when executing the upgrade tool from a sub-package.
3407+
'.gitignore': txt`
3408+
node_modules/
3409+
`,
3410+
},
3411+
},
3412+
async ({ root, exec, fs, expect }) => {
3413+
await exec('git init', { cwd: root })
3414+
3415+
await exec('pnpm exec upgrade --force', {
3416+
cwd: path.join(root, 'packages/css'),
3417+
})
3418+
3419+
expect(await fs.dumpFiles('packages/css/node_modules/tailwindcss/utilities.css'))
3420+
.toMatchInlineSnapshot(`
3421+
"
3422+
--- packages/css/node_modules/tailwindcss/utilities.css ---
3423+
@tailwind utilities;
3424+
"
3425+
`)
3426+
},
3427+
)
3428+
33743429
test(
33753430
'upgrade <style> blocks carefully',
33763431
{

packages/@tailwindcss-upgrade/src/codemods/css/analyze.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import postcss, { type Result } from 'postcss'
44
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
55
import { segment } from '../../../../tailwindcss/src/utils/segment'
66
import { Stylesheet, type StylesheetConnection } from '../../stylesheet'
7+
import { gitRoot } from '../../utils/git'
78
import { error, highlight, relative } from '../../utils/renderer'
89
import { resolveCssId } from '../../utils/resolve'
910

10-
export async function analyze(stylesheets: Stylesheet[]) {
11-
let isIgnored = await isGitIgnored()
11+
export async function analyze(stylesheets: Stylesheet[], { base }: { base: string }) {
12+
// Use the project's git root, instead of the `base` to ensure all
13+
// `.gitignore` rules are being used.
14+
let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base })
1215
let processingQueue: (() => Promise<Result>)[] = []
1316
let stylesheetsByFile = new DefaultMap<string, Stylesheet | null>((file) => {
1417
// We don't want to process ignored files (like node_modules)

packages/@tailwindcss-upgrade/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ async function run() {
125125

126126
// Analyze the stylesheets
127127
try {
128-
await analyzeStylesheets(stylesheets)
128+
await analyzeStylesheets(stylesheets, { base })
129129
} catch (e: any) {
130130
error(`${e?.message ?? e}`, { prefix: '↳ ' })
131131
}

packages/@tailwindcss-upgrade/src/utils/git.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import { execSync } from 'node:child_process'
22

3+
export function gitRoot(cwd?: string): string | null {
4+
try {
5+
return execSync('git rev-parse --show-toplevel', {
6+
encoding: 'utf-8',
7+
cwd,
8+
stdio: ['ignore', 'pipe', 'ignore'],
9+
}).trim()
10+
} catch {
11+
return null
12+
}
13+
}
14+
315
export function isRepoDirty(cwd?: string) {
416
try {
517
let stdout = execSync('git status --porcelain', { encoding: 'utf-8', cwd })

0 commit comments

Comments
 (0)