Skip to content

Commit dca3903

Browse files
committed
fix(migrate): limit automatic formatting to changed files
1 parent 04546ff commit dca3903

7 files changed

Lines changed: 167 additions & 13 deletions

File tree

docs/guide/migrate-rules.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged.
220220
After updating the manifests and package-manager configuration, migration
221221
reinstalls dependencies once to refresh the lockfile. If installation fails,
222222
migration reports the error and exits with a nonzero status. After a successful
223-
migration, it runs `vp fmt` unless the project still uses Prettier. A formatter
224-
failure is reported as a warning so the migration result and manual formatting
225-
command remain available.
223+
migration, it runs `vp fmt` on supported files changed in the Git worktree,
224+
leaving unrelated project files untouched. Non-Git projects retain full-project
225+
formatting. Formatting is skipped while the project still uses Prettier. A
226+
formatter failure is reported as a warning so the migration result and manual
227+
formatting command remain available.

packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
> vp migrate --no-interactive # migration should rewrite bare eslint but leave npx wrappers unchanged
1+
> vp migrate --no-interactive # migration should rewrite bare and bunx eslint but leave other wrappers unchanged
22
◇ Migrated . to Vite+
33
• Node <semver> pnpm <semver>
44
• 4 config updates applied
55
• ESLint rules migrated to Oxlint
66

7-
> cat package.json # check eslint removed, bare eslint rewritten, npx/pnpm exec/bunx wrappers unchanged
7+
> cat package.json # check eslint removed, bare and bunx eslint rewritten, npx/pnpm exec unchanged
88
{
99
"name": "migration-eslint-npx-wrapper",
1010
"scripts": {
1111
"dev": "vp dev",
1212
"build": "vp build",
1313
"lint": "npx eslint .",
1414
"lint:fix": "pnpm exec eslint --fix .",
15-
"lint:bunx": "bunx eslint .",
15+
"lint:bunx": "bunx vp lint .",
1616
"lint:bare": "vp lint --fix .",
1717
"prepare": "vp config"
1818
},

packages/cli/snap-tests-global/migration-eslint-npx-wrapper/steps.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"commands": [
3-
"vp migrate --no-interactive # migration should rewrite bare eslint but leave npx wrappers unchanged",
4-
"cat package.json # check eslint removed, bare eslint rewritten, npx/pnpm exec/bunx wrappers unchanged",
3+
"vp migrate --no-interactive # migration should rewrite bare and bunx eslint but leave other wrappers unchanged",
4+
"cat package.json # check eslint removed, bare and bunx eslint rewritten, npx/pnpm exec unchanged",
55
"cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog",
66
"test ! -f eslint.config.mjs # check eslint config is removed"
77
]

packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
AGENTS.md
44
README.md
55
apps
6-
bunfig.toml
76
package.json
87
packages
98
tsconfig.json

packages/cli/src/migration/__tests__/format.spec.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import { execFileSync } from 'node:child_process';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
16
import { describe, expect, it, vi } from 'vitest';
27

3-
import { canFormatWithOxfmt, formatMigratedProject } from '../format.ts';
8+
import { canFormatWithOxfmt, collectChangedFormatPaths, formatMigratedProject } from '../format.ts';
49
import { createMigrationReport } from '../report.ts';
510

611
describe('formatMigratedProject', () => {
@@ -11,16 +16,31 @@ describe('formatMigratedProject', () => {
1116
status: 'formatted',
1217
});
1318
const report = createMigrationReport();
19+
const collectPaths = vi.fn().mockResolvedValue(['package.json', 'vite.config.ts']);
1420

15-
await expect(formatMigratedProject('/project', false, report, format)).resolves.toBe(true);
16-
expect(format).toHaveBeenCalledWith('/project', false, undefined, {
21+
await expect(
22+
formatMigratedProject('/project', false, report, format, collectPaths),
23+
).resolves.toBe(true);
24+
expect(format).toHaveBeenCalledWith('/project', false, ['package.json', 'vite.config.ts'], {
1725
silent: false,
1826
command: process.execPath,
1927
commandArgs: [...process.execArgv, process.argv[1]],
2028
});
2129
expect(report.warnings).toEqual([]);
2230
});
2331

32+
it('skips formatting when migration changed no supported files', async () => {
33+
const format = vi.fn();
34+
const report = createMigrationReport();
35+
const collectPaths = vi.fn().mockResolvedValue([]);
36+
37+
await expect(
38+
formatMigratedProject('/project', false, report, format, collectPaths),
39+
).resolves.toBe(true);
40+
expect(format).not.toHaveBeenCalled();
41+
expect(report.warnings).toEqual([]);
42+
});
43+
2444
it('reports a formatter nonzero exit without throwing', async () => {
2545
const format = vi.fn().mockResolvedValue({
2646
durationMs: 1,
@@ -46,6 +66,37 @@ describe('formatMigratedProject', () => {
4666
});
4767
});
4868

69+
describe('collectChangedFormatPaths', () => {
70+
it('collects supported changed Git paths without formatting unrelated files', async () => {
71+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migrate-format-'));
72+
try {
73+
execFileSync('git', ['init'], { cwd: projectRoot, stdio: 'ignore' });
74+
fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}\n');
75+
fs.writeFileSync(path.join(projectRoot, 'vite.config.ts'), 'export default {}\n');
76+
fs.writeFileSync(path.join(projectRoot, 'template.mdx'), '# untouched\n');
77+
fs.writeFileSync(path.join(projectRoot, 'bun.lock'), 'lockfileVersion = 1\n');
78+
execFileSync('git', ['add', 'package.json'], { cwd: projectRoot });
79+
fs.appendFileSync(path.join(projectRoot, 'package.json'), '\n');
80+
81+
await expect(collectChangedFormatPaths(projectRoot)).resolves.toEqual([
82+
'package.json',
83+
'vite.config.ts',
84+
]);
85+
} finally {
86+
fs.rmSync(projectRoot, { recursive: true, force: true });
87+
}
88+
});
89+
90+
it('falls back to full-project formatting outside Git', async () => {
91+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migrate-format-no-git-'));
92+
try {
93+
await expect(collectChangedFormatPaths(projectRoot)).resolves.toBeUndefined();
94+
} finally {
95+
fs.rmSync(projectRoot, { recursive: true, force: true });
96+
}
97+
});
98+
});
99+
49100
describe('canFormatWithOxfmt', () => {
50101
it('formats projects that do not use Prettier', () => {
51102
expect(canFormatWithOxfmt(false, false)).toBe(true);

packages/cli/src/migration/format.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { runCommandSilently } from '../utils/command.ts';
15
import { type CommandRunSummary, runViteFmt } from '../utils/prompts.ts';
26
import { addMigrationWarning, type MigrationReport } from './report.ts';
37

@@ -8,9 +12,94 @@ type FormatRunner = (
812
options?: { silent?: boolean; command?: string; commandArgs?: string[] },
913
) => Promise<CommandRunSummary>;
1014

15+
type FormatPathCollector = (cwd: string) => Promise<string[] | undefined>;
16+
1117
const FORMAT_FAILURE_MESSAGE =
1218
'Automatic formatting failed. Run `vp fmt` manually after migration.';
1319

20+
const FORMAT_EXTENSIONS = new Set([
21+
'.astro',
22+
'.cjs',
23+
'.css',
24+
'.cts',
25+
'.html',
26+
'.js',
27+
'.json',
28+
'.jsonc',
29+
'.jsx',
30+
'.less',
31+
'.md',
32+
'.mjs',
33+
'.mts',
34+
'.scss',
35+
'.svelte',
36+
'.toml',
37+
'.ts',
38+
'.tsx',
39+
'.vue',
40+
'.yaml',
41+
'.yml',
42+
]);
43+
44+
function parseNullDelimitedPaths(output: Buffer): string[] {
45+
return output.toString().split('\0').filter(Boolean);
46+
}
47+
48+
function isFormatCandidate(projectRoot: string, relativePath: string): boolean {
49+
const absolutePath = path.join(projectRoot, relativePath);
50+
return (
51+
fs.existsSync(absolutePath) &&
52+
fs.statSync(absolutePath).isFile() &&
53+
FORMAT_EXTENSIONS.has(path.extname(relativePath).toLowerCase())
54+
);
55+
}
56+
57+
/**
58+
* Limit automatic formatting to files changed in the current Git worktree.
59+
* This prevents migration from reformatting unrelated source trees while still
60+
* covering manifests, generated config, and rewritten imports.
61+
*
62+
* Return `undefined` outside a Git worktree so non-Git projects retain the
63+
* existing full-project formatting behavior.
64+
*/
65+
export async function collectChangedFormatPaths(
66+
projectRoot: string,
67+
): Promise<string[] | undefined> {
68+
try {
69+
const git = (args: string[]) =>
70+
runCommandSilently({ command: 'git', args, cwd: projectRoot, envs: process.env });
71+
const [unstaged, staged, untracked] = await Promise.all([
72+
git(['diff', '--name-only', '--relative', '-z', '--diff-filter=ACMRTUXB', '--', '.']),
73+
git([
74+
'diff',
75+
'--cached',
76+
'--name-only',
77+
'--relative',
78+
'-z',
79+
'--diff-filter=ACMRTUXB',
80+
'--',
81+
'.',
82+
]),
83+
git(['ls-files', '--others', '--exclude-standard', '-z', '--', '.']),
84+
]);
85+
if (unstaged.exitCode !== 0 || staged.exitCode !== 0 || untracked.exitCode !== 0) {
86+
return undefined;
87+
}
88+
89+
return [
90+
...new Set([
91+
...parseNullDelimitedPaths(unstaged.stdout),
92+
...parseNullDelimitedPaths(staged.stdout),
93+
...parseNullDelimitedPaths(untracked.stdout),
94+
]),
95+
]
96+
.filter((file) => isFormatCandidate(projectRoot, file))
97+
.toSorted();
98+
} catch {
99+
return undefined;
100+
}
101+
}
102+
14103
/**
15104
* Do not apply Oxfmt to a project that still uses Prettier. Their formatting
16105
* rules can conflict, especially when Prettier is enforced through ESLint.
@@ -33,10 +122,15 @@ export async function formatMigratedProject(
33122
interactive: boolean,
34123
report: MigrationReport,
35124
format: FormatRunner = runViteFmt,
125+
collectPaths: FormatPathCollector = collectChangedFormatPaths,
36126
): Promise<boolean> {
37127
try {
128+
const paths = await collectPaths(projectRoot);
129+
if (paths?.length === 0) {
130+
return true;
131+
}
38132
const cliEntry = process.argv[1];
39-
const result = await format(projectRoot, interactive, undefined, {
133+
const result = await format(projectRoot, interactive, paths, {
40134
silent: false,
41135
...(cliEntry
42136
? { command: process.execPath, commandArgs: [...process.execArgv, cliEntry] }

rfcs/migration-command.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,14 @@ migrations run. Nested launcher forms such as
581581
`portless --tailscale run bunx --bun vite` are also handled. Other package
582582
executors remain unchanged and can be addressed separately.
583583

584+
## Post-Migration Formatting
585+
586+
After a successful install, migration runs the formatter only on supported
587+
files changed in the Git worktree. This formats manifests, generated config,
588+
and rewritten source without reformatting unrelated files in a large project.
589+
Non-Git projects retain full-project formatting. Projects that still use
590+
Prettier are not formatted automatically.
591+
584592
## ESLint Migration
585593

586594
When an ESLint flat config (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and `eslint` dependency are detected, `vp migrate` offers to convert the ESLint configuration to oxlint using [`@oxlint/migrate`](https://www.npmjs.com/package/@oxlint/migrate).

0 commit comments

Comments
 (0)