v3→v4アップグレードツールの安全性強化:アトミック書き込みとgitignore対応

tailwindlabs/tailwindcss

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.pidrandomUUID() を両方含めているのも設計上の注目点です。複数プロセスが並行して同じディレクトリに書き込んでも衝突しないよう uuid で一意性を保ちつつ、デバッグ時に特定のプロセスが残したファイルを識別できるよう pid を人間可読な形で埋め込んでいます。

まとめ

本PRは「再現が困難なバグ」に対し、アトミック書き込み・スキャン範囲の限定・gitignore尊重・デフォルト除外ファイルの追加という4層の防御で対処した変更です。各修正は独立して意味を持ちつつ、組み合わせることでデータ損失のリスク経路を体系的に閉じており、アップグレードツールを「壊れにくいツール」として再設計する設計思想が読み取れます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
ae0dc837

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)、背景・技術詳細・設計判断(各論)、まとめ(結論)という「総論→各論→結論」の構成が明確に適用されており、非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```言語:ファイルパス)やGitHubのIssue/PRへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

アトミック書き込みやファイルシステムの挙動など、専門知識を持つエンジニアを対象とした適切な技術レベルで記述されています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論・各論で構成され、各パラグラフがトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、高い可読性を実現しています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

`writeFileSafely`関数の新設や`migrate`関数の変更など、記事内で引用されているコードは提供されたDiff情報と正確に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「アトミック書き込み」「truncate」「シンボリックリンク」などの技術用語が、PRの文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

`fs.writeFile`の挙動や`fs.rename`の原子性に関する説明は、PR内の記述と一致しており、技術的に正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(ファイル消失バグの原因、解決策、テストの工夫など)は、PRのDescriptionやDiff内容で裏付けられており、ハルシネーションは検出されませんでした。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#19846)や関連Issue番号(#18972, #19779)が正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

PRの主題である「アップグレードツールの安定性向上」を、「アトミック書き込み」「gitignore対応」という具体的な変更点としてタイトルに含めており、内容を的確に要約しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内容はすべて提供されたPR情報に基づいており、バージョンサポート状況やリリース日程など、PR外の知識の追加は見られませんでした。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

PRで対処された既存の問題について、記事内での時間表現は正確であり、事実を歪曲するような記述はありません。