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
+ }
+}