v3→v4アップグレードツールの安全性強化:アトミック書き込みとgitignore対応
Tailwind CSS v3→v4のアップグレードツールが、ファイルの消失バグと不要ファイルへの誤変更という2つの深刻な問題に対処しました。プロセス中断時のデータ損失を防ぐアトミック書き込みと、config.contentを起点とした対象ファイルの限定により、アップグレード操作の信頼性が大幅に向上します。
背景
#18972 は、npx @tailwindcss/upgrade 実行中に「プロジェクト内のすべてのファイルが消える」という報告が複数寄せられたIssueです。被害を受けたユーザーのケースでは、Migrating templates... のステップで処理が止まったように見えたため Ctrl+C で中断したところ、git上で数千ファイルが削除済みになっていたと報告されています。
原因は2点に絞られました。第一に、Node.jsの fs.writeFile がデフォルトで w フラグを使うため、書き込み開始前にファイルを切り捨て(truncate)してから内容を書き込む非アトミックな操作になっていたこと。第二に、アップグレードツールがTailwind CSS v4の自動コンテンツ検出ロジックを流用しており、gitignoreされていない平文ファイルすべてを処理対象にしていたことです。関連する #19779 でも同様の問題が指摘されていましたが、本PRはその対処を引き継ぎ完全な修正として統合しています。
技術的な変更
本PRの変更は「安全な書き込みの実装」と「処理対象ファイルの限定」の2軸で構成されています。
アトミックな書き込み:writeFileSafely
writeFileSafely 関数が packages/@tailwindcss-upgrade/src/utils/write-file-safely.ts として新設され、従来の fs.writeFile 呼び出しをすべて置き換えます。
export async function writeFileSafely(file: string, contents: string) {
let realFile = await fs.realpath(file).catch(() => file)
let temporaryFile = path.join(
path.dirname(realFile),
`.${path.basename(realFile)}.tailwind-upgrade.${process.pid}.${randomUUID()}.tmp`,
)
try {
await fs.writeFile(temporaryFile, contents, 'utf8')
await fs.rename(temporaryFile, realFile)
} catch (error) {
await fs.unlink(temporaryFile).catch(() => {})
throw error
}
}
一時ファイルは元ファイルと同じディレクトリに .<filename>.tailwind-upgrade.<pid>.<uuid>.tmp という名前で作成されます。/tmp ではなく同一ディレクトリに置くのは、fs.rename の原子性がマウントポイントをまたぐと保証されないためです。プロセスが途中で強制終了されても元ファイルは無傷のまま残り、最悪のケースでも一時ファイルが残るだけです。また、fs.realpath でシンボリックリンクを事前に解決することで、シンボリックリンク自体をハードコピーで上書きしてしまうミスも防いでいます。
migrate関数の変更前後
テンプレートファイルを処理する migrate 関数も、内容に変化がなければ書き込みをスキップするよう変更されました。
変更前:
export async function migrate(designSystem: DesignSystem, userConfig: Config | null, file: string) {
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)),
)
}
変更後:
export async function migrate(
designSystem: DesignSystem,
userConfig: Config | null,
file: string,
): Promise<boolean> {
let fullPath = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file)
let contents = await fs.readFile(fullPath, 'utf-8')
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
}
変更後は boolean を返すようになり、内容が変化しなかったファイルへの書き込みを省略します。また、変換後の内容が空文字列になるケース(異常状態)も明示的に弾いています。
処理対象ファイルの限定
v3→v4マイグレーション時、テンプレートの走査範囲をv4の自動検出ロジックではなく config.content の配列に限定するよう変更されました。v3プロジェクトでは config.content の指定が必須であるため、この配列を起点とすることが安全です。
さらに、config.content に列挙されたパスに一致するファイルであっても、gitignoreの対象になっているファイルはスキップされます。node_modules 配下のテンプレートが誤って書き換えられるケースを防ぐための措置です。index.ts には isGitIgnored(globbyの関数)が追加され、ファイルごとにgit ignoreの判定が行われます。
.env および .env.* ファイルについては、Rustで実装されたスキャナ(crates/oxide/src/scanner/fixtures/ignored-files.txt)のデフォルト除外リストに追加されました。gitignoreに記載していないプロジェクトでも、環境変数ファイルが処理対象になることはなくなります。
package-lock.json
pnpm-lock.yaml
bun.lockb
.gitignore
+.env
+.env.*
テストの追加
統合テストが4件追加されました。
-
config.contentに指定されたファイルのみが変換対象になることの検証 -
config.contentに列挙されていてもgitignoreされたファイルがスキップされることの検証 -
writeFileが途中でキャンセルされても元ファイルが保持されることの検証 -
.env/.env.*がgitignoreなしでも処理されないことの検証
設計判断
本PRの判断で特徴的なのは、問題の再現が困難であっても、原因として考えられる経路を可能な限り塞ぐ アプローチです。
PR本文に「自分のデバイスでは再現できなかった」と明示されているように、Ctrl+C のタイミングに依存するレースコンディションはCI上での安定した再現が難しい問題です。そのためテストでは、writeFile の実装を意図的に「切り捨て後に書き込む」順序に変えることで強制的にレースウィンドウを広げ、プロセスキルに対する耐性を検証するという工夫が施されています。
一時ファイルの命名に process.pid と randomUUID() を両方含めているのも設計上の注目点です。複数プロセスが並行して同じディレクトリに書き込んでも衝突しないよう uuid で一意性を保ちつつ、デバッグ時に特定のプロセスが残したファイルを識別できるよう pid を人間可読な形で埋め込んでいます。
まとめ
本PRは「再現が困難なバグ」に対し、アトミック書き込み・スキャン範囲の限定・gitignore尊重・デフォルト除外ファイルの追加という4層の防御で対処した変更です。各修正は独立して意味を持ちつつ、組み合わせることでデータ損失のリスク経路を体系的に閉じており、アップグレードツールを「壊れにくいツール」として再設計する設計思想が読み取れます。