Skip to content

Make upgrade tooling more stable#19846

Merged
RobinMalfait merged 20 commits intomainfrom
fix/issue-18972
Mar 24, 2026
Merged

Make upgrade tooling more stable#19846
RobinMalfait merged 20 commits intomainfrom
fix/issue-18972

Conversation

@RobinMalfait
Copy link
Copy Markdown
Member

@RobinMalfait RobinMalfait commented Mar 24, 2026

This PR is an attempt to make the upgrade tooling more stable.

TL;DR

  1. When migrating from Tailwind CSS v3 → Tailwind CSS v4, only migrate files listed in the config.content instead of relying on v4's auto content detection feature
  2. Skip writing files that have not been changed
  3. Write changed files in a safe way: first write to a temporary file, then rename the file atomically
  4. Never migrate files that are git ignored, even if they are listed in the config.content file
  5. Always ignore .env and .env.* files when scanning for files. Most people will have this in their .gitignore file, but if not, then this is a fallback mechanism.

Looking at the #18972 issue, it looks like some people are running into weird situations where some of the contents is just gone.

I have never been able to reproduce this on my own devices and in my own projects unfortunately. But there is definitely something happening that's not right that people are running into.

Therefore, this PR is an attempt to fix what I think might be wrong, but I'm not 100% sure if these fixes are enough, or if something else is still happening here.

This builds on top of the #19779 PR which has some small fixes, but is incomplete to make this work.

What's happening

Looking at some of the comments, it looks like a few things are happening such as:

  1. The upgrade tool is emptying out my files — it looks like these are only happening if you ctrl+c while the process is taking a while. It could be that a lot of files are being checked and therefore the tooling looks like its stuck.
  2. The upgrade tool is looking at files it shouldn't look at — in Tailwind CSS v4 we have this concept of the auto-content detection. This means that we will look at any plain text file that is not git ignored.

Fixes

Emptying out files

The files being emptied looks like it's because how fs.writeFile behaves by default. It opens the file handle with the w flag, which will first truncate the file before writing the new contents. This is not a single atomic operation, so a killed process in the middle will cause invalid state.

When we migrate your template files, everything is happening in promises to migrate things at the same time. When a lot of files are being scanned, truncating might have happened already before we write the new content. Since we migrate a bunch of files in parallel, a ctrl+c could cause data loss in multiple files.

To mitigate this, I switched to an alternative way of writing files.

  1. First, we do some quick checks where if the contents didn't change we just bail out immediately. Files that don't include Tailwind CSS classes won't change, and therefore we don't need to override these files with the same contents.

  2. When the migrated contents is empty, we bail out as well. I'm 100% sure that this is not the spot where the "emptying out" happens, I still believe it happens in the writeFile itself, but added it just in case.

  3. Next, I introduced a safe write, where we first write to a temporary file in the same folder. We could write it to /tmp, but then we can't guarantee that we are on the same file system.

    If we ctrl+c at this stage, then the worst case scenario is that you have additional temporary files in your project, but your original files are still there.

    Once that file was written, we will use the atomic fs.rename. This should be atomic as long as we are on the same file system, so either the rename didn't happen yet, or it completed.

I added an integration test for this, but I had to change the writeFile implementation slightly. In the test, we will truncate the file first, after that we will write the new contents. This is so that we have enough time to kill the current process and allows us to verify that we didn't clear out the file. Again, this is a hacky way of testing this, just because I can't reproduce this issue myself, let alone reproduce it reliable in a CI environment.

Note: we are also using realpath to make sure that we are updating the real file. Otherwise, if we were dealing with a symlinked file, we would override the symlink with a "hard" copy instead.

Touching files that should not be touched

During the migration, we rely on the Tailwind CSS v4 auto detection logic which means that it will scan any plain text file that is not git ignored. Therefore changes to php files could happen because in theory they could contain Tailwind CSS classes.

To solve this, when migrating from Tailwind CSS v3 to Tailwind CSS v4, we will only take the sources into account that were listed in the config.content array. Since this was a requirement in Tailwind CSS v3, it should be safe to rely on this array.

Additionally, this will make sure that we are dealing with way fewer files to migrate as well.

On top of that, files that match the patterns in the content array that are git ignored will also be skipped. This is to prevent that we mutate files in node_modules for example.

In one of the comments I read that .env files were emptied out. In most cases people will have these files gitignored but I explicitly added .env and .env.* as files to never ever touch by default when scanning.

Last but not least, this also updates the output a little bit of the upgrade tool in case we skip content files (because of git ignore) and if we changed a file.

image
  • "Git ignored folder, skipping: ./node_modules": this is because the content array looks like this while the node_modules are being ignored:
image
  • "Migrated ./resources/views/vendor/filament-panels/components/logo.blade.php": this is because I made a change to showcase this feature.

Fixes: #18972
Closes: #19779

Test plan

  1. Existing tests still pass
  2. Added a dedicated integration test to ensure that we only take config.content into account when migrating from Tailwind CSS v3 to Tailwind CSS v4 projects.
  3. Added a dedicated integration test to make sure that files listed in config.content that are also git ignored, will still be skipped.
  4. Added a dedicated integration test to ensure that when writeFile is cancelled mid-write that our old files are still present.
  5. Added a dedicated integration test to ensure that we ignore .env and .env.* files even if you didn't git ignore them.

[ci-all] To verify on Windows

SychO9 and others added 8 commits March 23, 2026 16:31
…skip writes when content is unchanged

When migrating from v3 to v4, the CSS entrypoint has no @source directive
yet, leaving compiler.root as null. The fallback to '**/*' caused the tool
to scan every file in the project (PHP migrations, config files, etc.) and
unconditionally write them all back to disk.

- Use the v3 JS config's content patterns (config.sources) as the scan
  scope when compiler.root is null, falling back to '**/*' only when no
  config sources are available
- Move config lookup before the sources IIFE to avoid a TDZ error
- Skip fs.writeFile when the migrated content is identical to the original
In Tailwind CSS v3, the `config.content` was required. So if we are sure
that we are coming from Tailwind CSS v3, then we can use that. This will
already be part of the `compiler.sources`, so we can bail early.

We don't need to return the `config.content` because it's already part
of `compiler.sources` resulting in duplicated sources and therefore more
work.
We always follow the `.gitignore` rules by default, and most people will
ignore `.env` files. But just in case they don't, we will explicitly
ignore these files.
This is just something I noticed during debugging. The `cwd` is already
set to `process.cwd()` and our `base` is also just `process.cwd()`. But
in the event that we change the `base` variable, then this will be up
to date.

This doesn't make a functional difference.
@RobinMalfait RobinMalfait requested a review from a team as a code owner March 24, 2026 12:05
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 73c498e6-75f2-4c9c-8be0-22e916ef60ae

📥 Commits

Reviewing files that changed from the base of the PR and between 60c05bc and efd123b.

📒 Files selected for processing (2)
  • integrations/upgrade/index.test.ts
  • integrations/utils.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • integrations/utils.ts

Walkthrough

Added writeFileSafely and replaced direct fs.writeFile calls with it. migrate(...) now returns Promise<boolean>, avoids writing when output is unchanged or empty, and callers count per-file template changes. Source scanning became git-aware via isGitIgnored and uses fs.realpath. CSS discovery now uses globby rooted at the project base. Tests gained multiple integration cases and the ignored-files fixture now includes .env and .env.*. SpawnedProcess.dispose was changed to be asynchronous.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Make upgrade tooling more stable' accurately captures the main objective of stabilizing the v3→v4 upgrade process and preventing file data loss issues.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing the problems, fixes, and test plan for the upgrade tooling stability improvements.
Linked Issues check ✅ Passed All code changes address the primary objectives: safe atomic writes (#19779, #18972), config.content-only scanning for v3→v4 (#19779), git-ignored file skipping (#18972), and .env file exclusion (#18972).
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issues: safe writes, content-based scanning, git-ignore handling, .env exclusion, return value changes, and corresponding test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/@tailwindcss-upgrade/src/index.ts (1)

251-255: ⚠️ Potential issue | 🟠 Major

Skip byte-identical stylesheet writes.

Line 247 already uses originals to skip no-op formatting, but Lines 251-255 still replace every discovered CSS file unconditionally. That keeps touching unchanged stylesheets on no-op runs.

🛠️ Suggested fix
     // Write all files to disk
     for (let sheet of stylesheets) {
       if (!sheet.file) continue
 
-      await writeFileSafely(sheet.file, sheet.root.toString())
+      let next = sheet.root.toString()
+      if (originals.get(sheet) === next) continue
+      await writeFileSafely(sheet.file, next)
 
       if (sheet.isTailwindRoot) {
         success(`Migrated stylesheet: ${highlight(relative(sheet.file, base))}`, { prefix: '↳ ' })
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/`@tailwindcss-upgrade/src/index.ts around lines 251 - 255, The loop
that writes stylesheets unconditionally (iterating stylesheets and calling
writeFileSafely for sheet.file with sheet.root.toString()) should skip writing
when the generated content is byte-identical to the original; use the existing
originals collection (referencing originals and stylesheets) to compare
originals.get(sheet.file) (or the stored original content) against
sheet.root.toString() and only call writeFileSafely(sheet.file, ...) when the
contents differ, preserving timestamps for no-op runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@integrations/upgrade/index.test.ts`:
- Around line 3148-3197: The fs.writeFile override uses
file.includes('src/templates') and a generic '__TRUNCATED_TARGET__' marker which
fails on Windows paths and makes the test assert a fixed template file; change
the check to normalize the incoming path (e.g., const normalized =
String(file).replace(/\\/g, '/'); if (!normalized.includes('src/templates'))
...) so backslashes are handled, and emit a file-specific marker (e.g.,
`__TRUNCATED_TARGET__:${path.basename(normalized)}`) when truncating; update the
process.onStderr listener to match the emitted marker (capture the filename from
stderr) and assert the preserved contents against the same specific file rather
than always template-0.php (adjust references to fs.writeFile override,
process.onStderr, and the originalTemplate/template variable usage accordingly).

In `@packages/`@tailwindcss-upgrade/src/utils/write-file-safely.ts:
- Around line 19-40: The current write flow writes to a temp file
(temporaryFile) then calls fs.rename(temporaryFile, file), which will replace a
symlink itself instead of updating its target; update write-file-safely to
detect symlinks before renaming: use fs.lstat(file) and if
lstat.isSymbolicLink() then resolve the link target (await fs.readlink(file) and
path.resolve(path.dirname(file), target)) and rename the temporaryFile into that
resolved target path instead of into file; if the file doesn't exist or isn't a
symlink, fall back to the existing fs.rename(temporaryFile, file) behavior, and
ensure error paths still clean up temporaryFile.

---

Outside diff comments:
In `@packages/`@tailwindcss-upgrade/src/index.ts:
- Around line 251-255: The loop that writes stylesheets unconditionally
(iterating stylesheets and calling writeFileSafely for sheet.file with
sheet.root.toString()) should skip writing when the generated content is
byte-identical to the original; use the existing originals collection
(referencing originals and stylesheets) to compare originals.get(sheet.file) (or
the stored original content) against sheet.root.toString() and only call
writeFileSafely(sheet.file, ...) when the contents differ, preserving timestamps
for no-op runs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8a238fd2-cc29-409b-98c8-db0b203b366e

📥 Commits

Reviewing files that changed from the base of the PR and between 2c1ef9e and 2e2e075.

📒 Files selected for processing (7)
  • crates/oxide/src/scanner/fixtures/ignored-files.txt
  • integrations/upgrade/index.test.ts
  • integrations/utils.ts
  • packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts
  • packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts
  • packages/@tailwindcss-upgrade/src/index.ts
  • packages/@tailwindcss-upgrade/src/utils/write-file-safely.ts

We needed to update this because we want to ignore the git ignored
files. But we also don't want to use auto content detection otherwise we
don't really test the explicit content array.

So with this relatively hacky way of tackling it, we _do_ scan files
that are not ignored and not picked up by auto content detection.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/@tailwindcss-upgrade/src/index.ts (2)

252-261: Consider skipping writes for unchanged stylesheets.

The writeFileSafely call writes all stylesheets regardless of whether they changed. While the safe-write utility prevents truncation on interruption, writing unchanged files is unnecessary I/O and could touch file timestamps without actual changes.

The PR objectives mention "skip writing if migrated contents are unchanged" - this appears to be implemented for templates (via migrateTemplate returning a boolean) but not for stylesheets.

♻️ Proposed fix to skip unchanged stylesheets
     // Write all files to disk
     for (let sheet of stylesheets) {
       if (!sheet.file) continue
+      
+      // Skip writing if the stylesheet hasn't changed
+      if (originals.get(sheet) === sheet.root.toString()) continue

       await writeFileSafely(sheet.file, sheet.root.toString())

       if (sheet.isTailwindRoot) {
         success(`Migrated stylesheet: ${highlight(relative(sheet.file, base))}`, { prefix: '↳ ' })
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/`@tailwindcss-upgrade/src/index.ts around lines 252 - 261, The loop
currently calls writeFileSafely unconditionally for each stylesheet (iterating
over stylesheets, using sheet.file and sheet.root.toString()), which can
unnecessarily update files; change it to first read the existing file contents
(if present) and compare with sheet.root.toString(), and only call
writeFileSafely when contents differ. Keep the existing checks for sheet.file
and sheet.isTailwindRoot and only emit the success(...) message (which uses
relative(sheet.file, base)) when a write actually occurs; this mirrors the
migrateTemplate boolean-return behavior by skipping writes and timestamp changes
for unchanged stylesheets.

359-371: Consider adding an explicit loop termination condition.

The do...while (parent) loop relies on isIgnored() throwing an error when escaping the repository to terminate. Since path.dirname() on a root path returns the same root path (e.g., path.dirname('/') === '/'), this could theoretically loop indefinitely if isIgnored() doesn't throw.

In practice, this likely works because isIgnored throws for paths outside the git repo, but an explicit termination condition would be more robust.

♻️ Proposed improvement for loop robustness
               let parent = path.dirname(file)
+              let previousParent = ''
               do {
                 try {
                   if (isIgnored(parent)) {
                     culprit = parent
                   }
                 } catch {
                   // Escaping the current repo
                   break
                 }

+                previousParent = parent
                 parent = path.dirname(parent)
-              } while (parent)
+              } while (parent && parent !== previousParent)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/`@tailwindcss-upgrade/src/index.ts around lines 359 - 371, The loop
that walks up parent directories (using parent and path.dirname) currently
relies on isIgnored() throwing to break out; change it to include an explicit
termination check so it cannot loop forever: inside the do/while that updates
parent, compare the newly computed parent to the previous value (or check for
path.dirname(parent) === parent / root) and break when they are equal or when
you reach the FS root, while still preserving the existing isIgnored() handling
and setting culprit when isIgnored(parent) returns true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/`@tailwindcss-upgrade/src/index.ts:
- Around line 336-339: Wrap the fs.realpath(file) call in a try/catch to avoid
unhandled exceptions when resolving symlinks (e.g., broken symlink or deleted
file); on error, skip this file and continue the loop (optionally log or collect
the error) instead of letting it bubble. Update the loop that iterates
scanner.files and the realpath usage so that any exception from fs.realpath is
caught before calling file.endsWith or isIgnored; keep the existing isIgnored
error handling separate. Ensure you reference the existing scanner.files
iteration and the fs.realpath and isIgnored calls when making the change.

---

Nitpick comments:
In `@packages/`@tailwindcss-upgrade/src/index.ts:
- Around line 252-261: The loop currently calls writeFileSafely unconditionally
for each stylesheet (iterating over stylesheets, using sheet.file and
sheet.root.toString()), which can unnecessarily update files; change it to first
read the existing file contents (if present) and compare with
sheet.root.toString(), and only call writeFileSafely when contents differ. Keep
the existing checks for sheet.file and sheet.isTailwindRoot and only emit the
success(...) message (which uses relative(sheet.file, base)) when a write
actually occurs; this mirrors the migrateTemplate boolean-return behavior by
skipping writes and timestamp changes for unchanged stylesheets.
- Around line 359-371: The loop that walks up parent directories (using parent
and path.dirname) currently relies on isIgnored() throwing to break out; change
it to include an explicit termination check so it cannot loop forever: inside
the do/while that updates parent, compare the newly computed parent to the
previous value (or check for path.dirname(parent) === parent / root) and break
when they are equal or when you reach the FS root, while still preserving the
existing isIgnored() handling and setting culprit when isIgnored(parent) returns
true.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 09de03ca-78e1-46ba-bf8d-2d2a2645955f

📥 Commits

Reviewing files that changed from the base of the PR and between 960dbec and ea623c0.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • integrations/upgrade/index.test.ts
  • integrations/upgrade/js-config.test.ts
  • packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts
  • packages/@tailwindcss-upgrade/src/index.ts
  • packages/@tailwindcss-upgrade/src/utils/write-file-safely.ts
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts
  • packages/@tailwindcss-upgrade/src/utils/write-file-safely.ts
  • integrations/upgrade/index.test.ts

@RobinMalfait RobinMalfait merged commit e4856c9 into main Mar 24, 2026
23 checks passed
@RobinMalfait RobinMalfait deleted the fix/issue-18972 branch March 24, 2026 16:24
@RobinMalfait
Copy link
Copy Markdown
Member Author

Update: I was finally able to reproduce it when running with node instead of bun and while re-trying a few dozen times just to get the timing right.

While watching whenever the git status changed to files I knew should not be touched, I tried to quickly ctrl+c and eventually got it.

I also added logging to ensure that the file in question didn't actually change (so no migration) but it was still written to disk, which was cancelled and ended up emptying the file.

image

Good news is, I can't reproduce this now anymore, at all, with the changes implemented in this PR.

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.

v4 upgrader removing the content of all files in my project

2 participants