diff --git a/CHANGELOG.md b/CHANGELOG.md index c766419c2534..da992e5ba7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Canonicalization: collapse `overflow-{x,y}-*` into `overflow-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842)) - Canonicalization: collapse `overscroll-{x,y}-*` into `overscroll-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842)) - Read from `--placeholder-color` instead of `--background-color` for `placeholder-*` utilities ([#19843](https://github.com/tailwindlabs/tailwindcss/pull/19843)) +- Upgrade: Ensure files are not emptied out when killing the upgrade process while it's running ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) +- Upgrade: Use `config.content` when migrating from Tailwind CSS v3 to Tailwind CSS v4 ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) +- Upgrade: Never migrate files that are ignored by git ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) +- Add `.env` and `.env.*` to default ignored content files ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846)) ## [4.2.2] - 2026-03-18 diff --git a/crates/oxide/src/scanner/fixtures/ignored-files.txt b/crates/oxide/src/scanner/fixtures/ignored-files.txt index d2d231ec7b0d..1e8f1029f9ac 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-files.txt +++ b/crates/oxide/src/scanner/fixtures/ignored-files.txt @@ -2,3 +2,5 @@ package-lock.json pnpm-lock.yaml bun.lockb .gitignore +.env +.env.* diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 46b657694ca3..cf58f5912c43 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git' -import { candidate, css, html, js, json, test, ts, yaml } from '../utils' +import { candidate, css, html, js, json, test, ts, txt, yaml } from '../utils' test( 'error when no CSS file with @tailwind is used', @@ -171,6 +171,56 @@ test( }, ) +test( + 'only migrates files matched by `config.content` when upgrading from v3 to v4', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^3", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.js': js` + /** @type {import('tailwindcss').Config} */ + module.exports = { + content: ['./src/**/*.html'], + } + `, + 'src/index.html': html` +
+ `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + 'templates/email.php': html` +
+ `, + 'notes/unrelated.txt': `order-[0] bg-[--my-red]`, + }, + }, + async ({ exec, fs, expect }) => { + await exec('npx @tailwindcss/upgrade') + + expect(await fs.dumpFiles('./**/*.{html,php,txt}')).toMatchInlineSnapshot(` + " + --- notes/unrelated.txt --- + order-[0] bg-[--my-red] + + --- src/index.html --- +
+ + --- templates/email.php --- +
+ " + `) + }, +) + test( `upgrades a v3 project with prefixes to v4`, { @@ -2972,6 +3022,198 @@ test( }, ) +test( + 'v4 ignores .env files during template migration', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^4", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/app.css': css`@import 'tailwindcss';`, + 'src/index.html': html` +
+ `, + 'src/.env': `TW_TEST_CLASS=order-[0]`, + 'src/.env.production': `TW_TEST_CLASS=order-[0]`, + }, + }, + async ({ exec, fs, expect }) => { + await exec('npx @tailwindcss/upgrade') + + expect(await fs.dumpFiles('./src/**/{*,.env,.env.*}')).toMatchInlineSnapshot(` + " + --- ./src/index.html --- +
+ + --- ./src/.env --- + TW_TEST_CLASS=order-[0] + + --- ./src/.env.production --- + TW_TEST_CLASS=order-[0] + + --- ./src/app.css --- + @import 'tailwindcss'; + " + `) + }, +) + +test( + 'v4 linked configs respect `content` and still ignore gitignored files', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^4", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.js': js` + /** @type {import('tailwindcss').Config} */ + module.exports = { + content: ['./src/*.html', './src/*.less'], + } + `, + 'src/app.css': css` + @import 'tailwindcss'; + @config '../tailwind.config.js'; + `, + + // Ignore all .html files + 'src/.gitignore': txt` + *.html + `, + + // HTML files are in .gitignore, even though they are explicitly mentioned + // in the `content` array. Still ignore them + 'src/do-not-migrate-me.html': html` +
+ `, + + // Should be picked up by auto-content detection + 'templates/migrate-me.php': html` +
+ `, + + // Does not get picked up by auto content detection (because it's a less + // file), but was explicitly listed in the `content` array. + // + // A bit of a hacky way, I admit, but it allows us to differentiate + // between git ignored files, auto content detection and explicitly listed + // files. + 'src/migrate-me.less': html` +
+ `, + }, + }, + async ({ exec, fs, expect }) => { + await exec('npx @tailwindcss/upgrade') + + expect(await fs.dumpFiles('./{src,templates}/**/*')).toMatchInlineSnapshot(` + " + --- ./src/app.css --- + @import 'tailwindcss'; + @config '../tailwind.config.js'; + + --- ./src/do-not-migrate-me.html --- +
+ + --- ./src/migrate-me.less --- +
+ + --- ./templates/migrate-me.php --- +
+ " + `) + }, +) + +test( + 'interrupting template migration does not truncate files', + { + timeout: 180_000, + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^4", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/app.css': css` @import 'tailwindcss'; `, + 'src/index.html': html` +
+ `, + 'hook.cjs': js` + let fs = require('node:fs/promises') + let path = require('node:path') + let originalWriteFile = fs.writeFile.bind(fs) + + fs.writeFile = async (file, contents, ...rest) => { + // Mimic a bad write + await originalWriteFile(file, '') // As-if we truncated first + console.error('__TRUNCATED_TARGET__') + await new Promise((r) => setTimeout(r, 50)) // Wait 50ms to allow us to kill the process + await originalWriteFile(file, contents, ...rest) // Write the actual contents + } + `, + 'src/keep.php': ` + 'this file should never be truncated', + ]; + `, + }, + }, + async ({ spawn, fs, expect }) => { + let repeatedCandidates = Array.from( + { length: 250 }, + () => '
', + ).join('\n') + + for (let i = 0; i < 100; i++) { + await fs.write( + `src/templates/template-${i}.php`, + ` ${i}];\n`, + ) + } + + let originalKeepFile = await fs.read('src/keep.php') + let originalTemplate = await fs.read('src/templates/template-0.php') + + let process = await spawn('npx @tailwindcss/upgrade --force', { + env: { + NODE_OPTIONS: '--require=./hook.cjs', + }, + }) + + // We're only interested once we start migrating the templates + await process.onStderr((message) => message.includes('Migrating templates')) + + // Wait for the trigger that we are mid-write + await process.onStderr((message) => message === '__TRUNCATED_TARGET__') + + // Kill the process + await process.dispose() + + expect(await fs.read('src/keep.php')).toBe(originalKeepFile) + expect(await fs.read('src/templates/template-0.php')).toBe(originalTemplate) + + for (let [file, contents] of await fs.glob('src/**/*.{html,php,css}')) { + expect(contents.trim(), `${file} should not be empty after interruption`).not.toBe('') + } + }, +) + test( 'upgrades can run in a pnpm workspace', { diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 450d8254c97b..1167eba27c7a 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { describe } from 'vitest' -import { css, html, json, test, ts } from '../utils' +import { css, html, json, test, ts, txt } from '../utils' test( `upgrade JS config files with flat theme values, darkMode, and content fields`, @@ -322,6 +322,105 @@ test( }, ) +test( + 'skips gitignored template files even when they are explicitly referenced in `content`', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^3", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + '.gitignore': txt` + node_modules + src/ignored + `, + 'tailwind.config.ts': ts` + import { type Config } from 'tailwindcss' + + module.exports = { + content: [ + './src/migrate-me.html', + './src/ignored/do-not-migrate-me.html', + './node_modules/my-external-lib/template.html', + ], + theme: {}, + plugins: [], + } satisfies Config + `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + 'src/migrate-me.html': html` +
+ `, + 'src/ignored/do-not-migrate-me.html': html` +
+ `, + 'node_modules/my-external-lib/template.html': html` +
+ `, + }, + }, + async ({ exec, fs, expect }) => { + await exec('npx @tailwindcss/upgrade') + + expect( + await fs.dumpFiles( + '{src/**/*.{css,html},node_modules/my-external-lib/template.html,.gitignore}', + ), + ).toMatchInlineSnapshot(` + " + --- .gitignore --- + node_modules + src/ignored + + --- src/input.css --- + @import 'tailwindcss'; + + @source './ignored/do-not-migrate-me.html'; + @source '../node_modules/my-external-lib/template.html'; + + /* + The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. + */ + @layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + } + + --- src/migrate-me.html --- +
+ + --- node_modules/my-external-lib/template.html --- +
+ + --- src/ignored/do-not-migrate-me.html --- +
+ " + `) + }, +) + test( 'upgrades JS config files with plugins', { diff --git a/integrations/utils.ts b/integrations/utils.ts index f9638b8572d7..8e4a1c496f34 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -16,7 +16,7 @@ const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((na ) interface SpawnedProcess { - dispose: () => void + dispose: () => Promise flush: () => void onStdout: (predicate: (message: string) => boolean) => Promise onStderr: (predicate: (message: string) => boolean) => Promise @@ -194,6 +194,7 @@ export function test( return disposePromise } disposables.push(dispose) + function onExit() { resolveDisposal?.() } diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts index 512f3df59612..e16f52287105 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-postcss.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { pkg } from '../../utils/packages' import { highlight, info, relative, success, warn } from '../../utils/renderer' +import { writeFileSafely } from '../../utils/write-file-safely' // Migrates simple PostCSS setups. This is to cover non-dynamic config files // similar to the ones we have all over our docs: @@ -50,7 +51,7 @@ export async function migratePostCSSConfig(base: string) { ranMigration = true if (result) { - await fs.writeFile( + await writeFileSafely( packageJsonPath, JSON.stringify({ ...packageJson, postcss: result?.json }, null, 2), ) @@ -76,7 +77,7 @@ export async function migratePostCSSConfig(base: string) { ranMigration = true if (result) { - await fs.writeFile(jsonConfigPath, JSON.stringify(result.json, null, 2)) + await writeFileSafely(jsonConfigPath, JSON.stringify(result.json, null, 2)) didMigrate = true didAddPostcssClient = result.didAddPostcssClient @@ -206,7 +207,7 @@ async function migratePostCSSJSConfig(configPath: string): Promise<{ newLines.push(line) } } - await fs.writeFile(configPath, newLines.join('\n')) + await writeFileSafely(configPath, newLines.join('\n')) return { didAddPostcssClient, didRemoveAutoprefixer, didRemovePostCSSImport } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 2b39094d45ba..976c63a83984 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -9,6 +9,7 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' +import { writeFileSafely } from '../../utils/write-file-safely' import { extractRawCandidates } from './candidates' import { isSafeMigration } from './is-safe-migration' import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection' @@ -119,12 +120,18 @@ export default async function migrateContents( return spliceChangesIntoString(contents, changes) } -export async function migrate(designSystem: DesignSystem, userConfig: Config | null, file: string) { +export async function migrate( + designSystem: DesignSystem, + userConfig: Config | null, + file: string, +): Promise { let fullPath = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file) let contents = await fs.readFile(fullPath, 'utf-8') - await fs.writeFile( - fullPath, - await migrateContents(designSystem, userConfig, contents, extname(file)), - ) + let migrated = await migrateContents(designSystem, userConfig, contents, extname(file)) + if (migrated === contents) return false // Nothing changed + if (migrated.trim() === '') return false // Emptied out, something went horribly wrong + + await writeFileSafely(fullPath, migrated) + return true } diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 2cfbab371f5f..ae54332f357e 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { Scanner } from '@tailwindcss/oxide' -import { globby } from 'globby' +import { globby, isGitIgnored } from 'globby' import fs from 'node:fs/promises' import path from 'node:path' import pc from 'picocolors' @@ -23,6 +23,7 @@ import { isRepoDirty } from './utils/git' import { pkg } from './utils/packages' import { eprintln, error, header, highlight, info, relative, success } from './utils/renderer' import * as version from './utils/version' +import { writeFileSafely } from './utils/write-file-safely' const options = { '--config': { type: 'string', description: 'Path to the configuration file', alias: '-c' }, @@ -42,6 +43,7 @@ if (flags['--help']) { async function run() { let base = process.cwd() + let isIgnored = await isGitIgnored({ cwd: base }) eprintln(header()) eprintln() @@ -95,6 +97,7 @@ async function run() { info('Searching for CSS files in the current directory and its subdirectories…') files = await globby(['**/*.css'], { + cwd: base, absolute: true, gitignore: true, // gitignore: true will first search for all .gitignore including node_modules folders, this makes the initial search much faster @@ -250,7 +253,7 @@ async function run() { for (let sheet of stylesheets) { if (!sheet.file) continue - await fs.writeFile(sheet.file, sheet.root.toString()) + await writeFileSafely(sheet.file, sheet.root.toString()) if (sheet.isTailwindRoot) { success(`Migrated stylesheet: ${highlight(relative(sheet.file, base))}`, { prefix: '↳ ' }) @@ -293,6 +296,8 @@ async function run() { let designSystem = await sheet.designSystem() if (!designSystem) continue + let config = configBySheet.get(sheet) + // Figure out the source files to migrate let sources = (() => { // Disable auto source detection @@ -300,43 +305,121 @@ async function run() { return [] } - // No root specified, use the base directory + // No root specified if (compiler.root === null) { + // When coming from Tailwind CSS v3, we have to use the + // `config.sources` (which came from `config.content` originally) + if (version.isMajor(3)) { + if (config?.sources) { + return config.sources.map((source) => ({ ...source, negated: false })) + } + + // When we don't have any sources, then we have to fallback to no + // sources at all. We cannot fallback to the `**/*` pattern. + return [] + } + + // When we are upgrading a Tailwind CSS v4 and up version, we use + // the default `**/*` pattern. All custom `@source` directives will + // be attached later as sources. return [{ base, pattern: '**/*', negated: false }] } // Use the specified root return [{ ...compiler.root, negated: false }] })().concat(compiler.sources) - - let config = configBySheet.get(sheet) let scanner = new Scanner({ sources }) let filesToMigrate = [] + + let ignoredPaths = new Set() + for (let file of scanner.files) { + file = await fs.realpath(file).catch(() => file) // Ensure we are dealing with the real path, not symlinks if (file.endsWith('.css')) continue + + // When a file is git ignored, then we don't want to migrate it even + // if it was listed in the `config.content` array or part of any + // `@source` directives. + // + // We can make this an option later to explicitly allow this, but + // this should be the default. This guarantees that: + // + // 1. Files coming from node_modules aren't touched + // 2. Generated files aren't changed (the source should update, not the target) + // 3. You can see all the changes that happened + try { + if (isIgnored(file)) { + let culprit = file + + // To prevent print all ignored files, we can also walk up the + // parent tree and log those instead _if_ they are: + // + // 1. Also git ignored + // 2. Are not going outside of the current repo + let parent = path.dirname(file) + do { + try { + if (isIgnored(parent)) { + culprit = parent + } + } catch { + // Escaping the current repo + break + } + + parent = path.dirname(parent) + } while (parent) + + if (ignoredPaths.has(culprit)) continue // Already logged, skip + ignoredPaths.add(culprit) + + if (culprit === file) { + info(`Git ignored, skipping: ${highlight(relative(culprit, base))}`, { + prefix: '↳ ', + }) + } else { + info(`Git ignored folder, skipping: ${highlight(relative(culprit, base))}`, { + prefix: '↳ ', + }) + } + + continue + } + } catch (err) { + info(`Outside repository, skipping: ${highlight(relative(file, base))}`, { + prefix: '↳ ', + }) + // Skip this file when we run into errors. E.g.: when the current + // file is not part of the current git repo it will throw an error. + continue + } + if (seenFiles.has(file)) continue seenFiles.add(file) filesToMigrate.push(file) } // Migrate each file + let changes = 0 await Promise.allSettled( - filesToMigrate.map((file) => - migrateTemplate(designSystem, config?.userConfig ?? null, file), - ), + filesToMigrate.map(async (file) => { + let changed = await migrateTemplate(designSystem, config?.userConfig ?? null, file) + if (changed) { + changes++ + info(`Migrated ${highlight(relative(file, base))}`, { prefix: '↳ ' }) + } + }), ) if (config?.configFilePath) { success( - `Migrated templates for configuration file: ${highlight(relative(config.configFilePath, base))}`, + `Migrated templates for configuration file: ${highlight(relative(config.configFilePath, base))} (${changes} file${changes === 1 ? '' : 's'} changed)`, { prefix: '↳ ' }, ) } else { success( - `Migrated templates for: ${highlight(relative(sheet.file ?? '', base))}`, - { - prefix: '↳ ', - }, + `Migrated templates for: ${highlight(relative(sheet.file ?? '', base))} (${changes} file${changes === 1 ? '' : 's'} changed)`, + { prefix: '↳ ' }, ) } } diff --git a/packages/@tailwindcss-upgrade/src/utils/write-file-safely.ts b/packages/@tailwindcss-upgrade/src/utils/write-file-safely.ts new file mode 100644 index 000000000000..2ff99f9e7b2c --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/write-file-safely.ts @@ -0,0 +1,49 @@ +import { randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' + +export async function writeFileSafely(file: string, contents: string) { + // Resolve symlinks so the rename replaces the actual target file rather than + // replacing the symlink itself with a regular file. + let realFile = await fs.realpath(file).catch(() => file) + + // Start by creating a new file in the current directory that is guaranteed to + // be unique (via `uuid`). We can embed the `process.id` in case we need to + // debug things later. + // + // While we can write this to a more global `/tmp` folder, I want to be 100% + // sure that we are on the same file system (same drive) so the rename + // operation is atomic. Once the file is written, we will rename the file. If + // this fails, the old file is still intact, if it works we have an updated + // file. + // + // If this still causes problems (but it will slow things down): + // 1. We could make sure that we inherit the file permissions + // 2. Use an explicit fsync to force a flush to disk + let temporaryFile = path.join( + path.dirname(realFile), + `.${path.basename(realFile)}.tailwind-upgrade.${process.pid}.${randomUUID()}.tmp`, + ) + + // Write file uses the `w` flag by default, which is defined as: + // > Open file for writing. The file is created (if it does not exist) or truncated (if it exists). + // > https://nodejs.org/api/fs.html#file-system-flags + // + // Which means that if this function is actively running, and you cancel the + // process at the wrong time, then the truncated files are present. Since all + // these migrations happen in parallel, multiple files are open and available + // to be written to, it could mean in multiple truncated files. + // + // Writing to a temp file first means that if the process is cancelled at this + // point, that the old original file is still correct. + // + // The rename part should be atomic (especially because we guarantee it to be + // on the same file system) so this either succeeds or doesn't happen. + try { + await fs.writeFile(temporaryFile, contents, 'utf8') + await fs.rename(temporaryFile, realFile) + } catch (error) { + await fs.unlink(temporaryFile).catch(() => {}) + throw error + } +}