diff --git a/packages/upgrade/README.md b/packages/upgrade/README.md index 09db04aab13..93762b3a5ea 100644 --- a/packages/upgrade/README.md +++ b/packages/upgrade/README.md @@ -62,6 +62,7 @@ Fill out the prompts and the CLI will: - `--dir` — directory to scan (default: current working directory) - `--glob` — glob of files for codemods (default: `**/*.(js|jsx|ts|tsx|mjs|cjs)`) - `--ignore` — extra globs to ignore during scans (repeatable) +- `--skip-gitignore` — ignore `.gitignore` files and scan files they would normally exclude - `--release` — target release (e.g., `core-3`); otherwise auto-selected from installed versions - `--skip-upgrade` — skip installing/updating packages - `--skip-codemods` — skip codemod execution diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index 32dfd4e3fa2..b72127ad00c 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -38,6 +38,7 @@ "execa": "9.4.1", "gray-matter": "^4.0.3", "index-to-position": "^0.1.2", + "isbinaryfile": "^5.0.7", "jscodeshift": "^17.0.0", "marked": "^11.1.1", "meow": "^11.0.0", diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index 1a6f1a4f9ad..7005e40c0ed 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -71,6 +71,7 @@ describe('CLI Integration', () => { expect(result.stdout).toContain('--sdk'); expect(result.stdout).toContain('--dir'); expect(result.stdout).toContain('--dry-run'); + expect(result.stdout).toContain('--skip-gitignore'); expect(result.stdout).toContain('--skip-upgrade'); expect(result.stdout).toContain('--release'); expect(result.stdout).toContain('--canary'); diff --git a/packages/upgrade/src/__tests__/integration/runner.test.js b/packages/upgrade/src/__tests__/integration/runner.test.js index a7885e63773..86aeafc824e 100644 --- a/packages/upgrade/src/__tests__/integration/runner.test.js +++ b/packages/upgrade/src/__tests__/integration/runner.test.js @@ -1,5 +1,18 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(actual.execSync), + }; +}); + +import { execSync, spawnSync } from 'node:child_process'; + import { loadConfig } from '../../config.js'; import { runCodemods, runScans } from '../../runner.js'; import { createTempFixture } from '../helpers/create-fixture.js'; @@ -22,6 +35,7 @@ vi.mock('../../render.js', () => ({ promptText: vi.fn((msg, defaultValue) => defaultValue), renderCodemodResults: vi.fn(), renderText: vi.fn(), + renderDebug: vi.fn(), })); describe('runCodemods', () => { @@ -219,6 +233,132 @@ describe('runScans', () => { expect(results[0].instances).toEqual([]); }); + it('scans extensionless node scripts as text files', async () => { + const config = await loadConfig('nextjs', 6); + config.changes = [ + { + title: 'Test extensionless script', + matcher: /afterSignInUrl/g, + packages: ['*'], + category: 'breaking', + warning: false, + docsAnchor: 'test', + content: 'Test', + }, + ]; + + const content = '#!/usr/bin/env node\nafterSignInUrl\n'; + fs.writeFileSync(path.join(fixture.path, 'run'), content, 'utf8'); + + const results = await runScans(config, 'nextjs', { + dir: fixture.path, + ignore: [], + }); + + const expected = path.relative(process.cwd(), path.join(fixture.path, 'run')); + expect(results[0].instances.some(instance => instance.file === expected)).toBe(true); + }); + + it('skips binary files that are not covered by ignore globs', async () => { + const config = await loadConfig('nextjs', 6); + config.changes = config.changes.filter(change => change.matcher); + + const binaryPath = path.join(fixture.path, 'binary'); + fs.writeFileSync(binaryPath, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00])); + + const results = await runScans(config, 'nextjs', { dir: fixture.path, ignore: [] }); + + const allInstances = results.flatMap(result => result.instances); + const skipped = path.relative(process.cwd(), binaryPath); + expect(allInstances.every(instance => instance.file !== skipped)).toBe(true); + }); + + it('respects .gitignore files by default', async () => { + // Guard as this test assumes git is available + if (spawnSync('git', ['--version'], { stdio: 'ignore' }).status !== 0) return; + + const config = await loadConfig('nextjs', 6); + config.changes = config.changes.filter(change => change.matcher); + + const gitPath = fs.mkdtempSync(path.join(fixture.path, 'gitignore-')); + + expect(spawnSync('git', ['init', '-q'], { cwd: gitPath, stdio: 'ignore' }).status).toBe(0); + fs.writeFileSync(path.join(gitPath, '.gitignore'), 'run\n', 'utf8'); + fs.writeFileSync(path.join(gitPath, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8'); + + const results = await runScans(config, 'nextjs', { dir: gitPath, ignore: [] }); + + const skipped = path.relative(process.cwd(), path.join(gitPath, 'run')); + expect(results.flatMap(result => result.instances).every(instance => instance.file !== skipped)).toBe(true); + }); + + it('can skip using .gitignore files', async () => { + // Guard as this test assumes git is available + if (spawnSync('git', ['--version'], { stdio: 'ignore' }).status !== 0) return; + + const config = await loadConfig('nextjs', 6); + config.changes = config.changes.filter(change => change.matcher); + + const gitPath = fs.mkdtempSync(path.join(fixture.path, 'gitignore-')); + + expect(spawnSync('git', ['init', '-q'], { cwd: gitPath, stdio: 'ignore' }).status).toBe(0); + fs.writeFileSync(path.join(gitPath, '.gitignore'), 'run\n', 'utf8'); + fs.writeFileSync(path.join(gitPath, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8'); + + const results = await runScans(config, 'nextjs', { + dir: gitPath, + ignore: [], + skipGitignore: true, + }); + + const expected = path.relative(process.cwd(), path.join(gitPath, 'run')); + expect(results.flatMap(result => result.instances).some(instance => instance.file === expected)).toBe(true); + }); + + it('falls back to normal scanning when git is not installed', async () => { + const config = await loadConfig('nextjs', 6); + config.changes = config.changes.filter(change => change.matcher); + + vi.mocked(execSync).mockImplementationOnce(() => { + const error = new Error('spawnSync git ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + + fs.writeFileSync(path.join(fixture.path, '.gitignore'), 'run\n', 'utf8'); + fs.writeFileSync(path.join(fixture.path, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8'); + + const results = await runScans(config, 'nextjs', { + dir: fixture.path, + ignore: [], + }); + + const file = path.relative(process.cwd(), path.join(fixture.path, 'run')); + expect(results.flatMap(result => result.instances).some(instance => instance.file === file)).toBe(true); + }); + + it('falls back to normal scanning when the directory is not a git repo', async () => { + const config = await loadConfig('nextjs', 6); + config.changes = config.changes.filter(change => change.matcher); + + vi.mocked(execSync).mockImplementationOnce(() => { + const error = new Error('fatal: not a git repository'); + error.status = 128; + throw error; + }); + + fs.writeFileSync(path.join(fixture.path, '.gitignore'), 'run\n', 'utf8'); + fs.writeFileSync(path.join(fixture.path, 'run'), '#!/usr/bin/env node\nafterSignInUrl\n', 'utf8'); + + const results = await runScans(config, 'nextjs', { + dir: fixture.path, + ignore: [], + }); + + const file = path.relative(process.cwd(), path.join(fixture.path, 'run')); + expect(results.flatMap(result => result.instances).some(instance => instance.file === file)).toBe(true); + }); + it('includes both changes with and without matchers', async () => { const config = await loadConfig('nextjs', 6); // Add a change without a matcher and one with a matcher diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 7901129ed60..ba8804cfd26 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -46,6 +46,7 @@ const cli = meow( --dir Directory to scan (defaults to current directory) --glob Glob pattern for files to transform (defaults to **/*.{js,jsx,ts,tsx,mjs,cjs}) --ignore Directories/files to ignore (can be used multiple times) + --skip-gitignore Do not use .gitignore files to exclude scan results --skip-upgrade Skip the upgrade step --release Name of the release you're upgrading to (e.g. core-3) --canary Upgrade to the latest canary version instead of the stable release @@ -72,6 +73,7 @@ const cli = meow( dryRun: { type: 'boolean', default: false }, glob: { type: 'string', default: '**/*.(js|jsx|ts|tsx|mjs|cjs)' }, ignore: { type: 'string', isMultiple: true }, + skipGitignore: { type: 'boolean', default: false }, release: { type: 'string' }, sdk: { type: 'string' }, canary: { type: 'boolean', default: false }, @@ -90,6 +92,7 @@ async function main() { dryRun: cli.flags.dryRun, glob: cli.flags.glob, ignore: cli.flags.ignore, + skipGitignore: cli.flags.skipGitignore, release: cli.flags.release, skipCodemods: cli.flags.skipCodemods, skipUpgrade: cli.flags.skipUpgrade, diff --git a/packages/upgrade/src/render.js b/packages/upgrade/src/render.js index 923f6f9f74f..eb2f5be1f88 100644 --- a/packages/upgrade/src/render.js +++ b/packages/upgrade/src/render.js @@ -68,6 +68,12 @@ export function renderWarning(message) { console.log(chalk.yellow(`⚠️ ${message}`)); } +export function renderDebug(message) { + if (process.env.DEBUG) { + console.debug(chalk.dim(message)); + } +} + export function renderNewline() { console.log(''); } diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js index 6b2a4e0a871..a3959f72969 100644 --- a/packages/upgrade/src/runner.js +++ b/packages/upgrade/src/runner.js @@ -1,12 +1,14 @@ +import { execSync } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import chalk from 'chalk'; import indexToPosition from 'index-to-position'; -import { glob } from 'tinyglobby'; +import { isBinaryFile } from 'isbinaryfile'; +import { escapePath, glob } from 'tinyglobby'; import { getCodemodConfig, runCodemod } from './codemods/index.js'; -import { createSpinner, renderCodemodResults } from './render.js'; +import { createSpinner, renderCodemodResults, renderDebug } from './render.js'; const GLOBBY_IGNORE = [ 'node_modules/**', @@ -95,15 +97,15 @@ export async function runScans(config, sdk, options) { try { const cwd = path.resolve(options.dir); - const files = await glob('**/*', { - cwd, - absolute: true, - ignore: [...GLOBBY_IGNORE, ...(options.ignore || [])], - }); + const files = await getFilesToScan(cwd, options); for (let idx = 0; idx < files.length; idx++) { const file = files[idx]; - spinner.update(`Scanning ${path.basename(file)} (${idx + 1}/${files.length})`); + spinner.update(`Scanning ${path.relative(cwd, file)} (${idx + 1}/${files.length})`); + + if (await isBinaryFile(file)) { + continue; + } const content = await fs.readFile(file, 'utf8'); @@ -156,6 +158,34 @@ function loadMatchers(config, sdk) { }); } +async function getFilesToScan(cwd, options) { + // NOTE: tinyglobby recommends shelling out to git for .gitignore support instead of + // implementing it internally: + // - https://superchupu.dev/tinyglobby/migration#gitignore + // - https://github.com/SuperchupuDev/tinyglobby/issues/92 + function gitIgnored() { + try { + return execSync('git ls-files --others --ignored --exclude-standard --directory', { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + .split('\n') + .filter(Boolean) + .map(file => escapePath(file)); + } catch (error) { + renderDebug(`Skipping .gitignore support for scans in ${cwd}: ${error.message}`); + return []; + } + } + + return glob('**/*', { + cwd, + absolute: true, + ignore: [...GLOBBY_IGNORE, ...(options.ignore || []), ...(options.skipGitignore ? [] : gitIgnored())], + }); +} + function findMatches(content, matcher) { if (Array.isArray(matcher)) { return matcher.flatMap(m => Array.from(content.matchAll(m))); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 048e40933e8..83f863349f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1054,6 +1054,9 @@ importers: index-to-position: specifier: ^0.1.2 version: 0.1.2 + isbinaryfile: + specifier: ^5.0.7 + version: 5.0.7 jscodeshift: specifier: ^17.0.0 version: 17.3.0(@babel/preset-env@7.28.5(@babel/core@7.28.5)) @@ -9522,6 +9525,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + isbot@5.1.32: resolution: {integrity: sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ==} engines: {node: '>=18'} @@ -25399,6 +25406,8 @@ snapshots: isarray@2.0.5: {} + isbinaryfile@5.0.7: {} + isbot@5.1.32: {} isexe@2.0.0: {}