diff --git a/packages/create-cli/src/lib/setup/gitignore.ts b/packages/create-cli/src/lib/setup/gitignore.ts new file mode 100644 index 000000000..b52afd7cf --- /dev/null +++ b/packages/create-cli/src/lib/setup/gitignore.ts @@ -0,0 +1,40 @@ +import type { Tree } from './types.js'; + +const GITIGNORE_FILENAME = '.gitignore'; +const REPORTS_DIR = '.code-pushup'; +const REPORTS_DIR_ENTRIES = new Set([REPORTS_DIR, `**/${REPORTS_DIR}`]); +const REPORTS_SECTION = `# Code PushUp reports\n${REPORTS_DIR}\n`; + +export async function resolveGitignore(tree: Tree): Promise { + const content = await tree.read(GITIGNORE_FILENAME); + const updated = resolveGitignoreContent(content); + + if (updated != null) { + await tree.write(GITIGNORE_FILENAME, updated); + } +} + +function resolveGitignoreContent(content: string | null): string | null { + if (content == null) { + return REPORTS_SECTION; + } + if (gitignoreContainsEntry(content)) { + return null; + } + const separator = content.endsWith('\n\n') + ? '' + : content.endsWith('\n') + ? '\n' + : '\n\n'; + + return `${content}${separator}${REPORTS_SECTION}`; +} + +function gitignoreContainsEntry(content: string): boolean { + return content.split('\n').some(raw => { + const line = raw.trim(); + return ( + line !== '' && !line.startsWith('#') && REPORTS_DIR_ENTRIES.has(line) + ); + }); +} diff --git a/packages/create-cli/src/lib/setup/gitignore.unit.test.ts b/packages/create-cli/src/lib/setup/gitignore.unit.test.ts new file mode 100644 index 000000000..16e544927 --- /dev/null +++ b/packages/create-cli/src/lib/setup/gitignore.unit.test.ts @@ -0,0 +1,147 @@ +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { resolveGitignore } from './gitignore.js'; +import { createTree } from './virtual-fs.js'; + +describe('resolveGitignore', () => { + it('should create .gitignore with comment when it does not exist', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([ + { + type: 'CREATE', + path: '.gitignore', + content: '# Code PushUp reports\n.code-pushup\n', + }, + ]); + }); + + it('should update .gitignore with blank line separator when it already exists', async () => { + vol.fromJSON({ '.gitignore': 'node_modules\n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([ + { + type: 'UPDATE', + path: '.gitignore', + content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', + }, + ]); + }); + + it('should preserve existing blank line before appending', async () => { + vol.fromJSON({ '.gitignore': 'node_modules\n\n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([ + { + type: 'UPDATE', + path: '.gitignore', + content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', + }, + ]); + }); + + it('should add double newline separator when .gitignore has no trailing newline', async () => { + vol.fromJSON({ '.gitignore': 'node_modules' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([ + { + type: 'UPDATE', + path: '.gitignore', + content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', + }, + ]); + }); + + it('should skip if entry already in .gitignore', async () => { + vol.fromJSON({ '.gitignore': '.code-pushup\n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([]); + }); + + it('should skip if **/.code-pushup entry already in .gitignore', async () => { + vol.fromJSON({ '.gitignore': '**/.code-pushup\n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([]); + }); + + it('should skip if entry exists among comments and other entries', async () => { + vol.fromJSON( + { '.gitignore': '# build output\ndist\n\n# reports\n.code-pushup\n' }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([]); + }); + + it('should skip if entry has leading and trailing whitespace', async () => { + vol.fromJSON({ '.gitignore': ' .code-pushup \n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([]); + }); + + it('should not match commented-out entry', async () => { + vol.fromJSON({ '.gitignore': '# .code-pushup\n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([ + { + type: 'UPDATE', + path: '.gitignore', + content: '# .code-pushup\n\n# Code PushUp reports\n.code-pushup\n', + }, + ]); + }); +}); + +describe('resolveGitignore - flush', () => { + it('should write .gitignore file to disk on flush', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + await tree.flush(); + + await expect(readFile(`${MEMFS_VOLUME}/.gitignore`, 'utf8')).resolves.toBe( + '# Code PushUp reports\n.code-pushup\n', + ); + }); + + it('should skip writing when entry already exists', async () => { + vol.fromJSON({ '.gitignore': '.code-pushup\n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveGitignore(tree); + await tree.flush(); + + await expect(readFile(`${MEMFS_VOLUME}/.gitignore`, 'utf8')).resolves.toBe( + '.code-pushup\n', + ); + }); +}); diff --git a/packages/create-cli/src/lib/setup/wizard.int.test.ts b/packages/create-cli/src/lib/setup/wizard.int.test.ts index 6d703dd34..0371cdf92 100644 --- a/packages/create-cli/src/lib/setup/wizard.int.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -1,9 +1,18 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { cleanTestFolder } from '@code-pushup/test-utils'; +import { getGitRoot } from '@code-pushup/utils'; import type { PluginSetupBinding } from './types.js'; import { runSetupWizard } from './wizard.js'; +vi.mock('@code-pushup/utils', async () => { + const actual = await vi.importActual('@code-pushup/utils'); + return { + ...actual, + getGitRoot: vi.fn(), + }; +}); + const TEST_BINDINGS: PluginSetupBinding[] = [ { slug: 'alpha', @@ -51,6 +60,7 @@ describe('runSetupWizard', () => { beforeEach(async () => { await cleanTestFolder(outputDir); + vi.mocked(getGitRoot).mockResolvedValue(path.resolve(outputDir)); }); it('should write a valid ts config file with provided bindings', async () => { @@ -167,4 +177,47 @@ describe('runSetupWizard', () => { " `); }); + + it('should create .gitignore with .code-pushup entry', async () => { + await runSetupWizard(TEST_BINDINGS, { + yes: true, + 'config-format': 'ts', + 'target-dir': outputDir, + }); + + await expect( + readFile(path.join(outputDir, '.gitignore'), 'utf8'), + ).resolves.toBe('# Code PushUp reports\n.code-pushup\n'); + }); + + it('should append .code-pushup to existing .gitignore', async () => { + await writeFile(path.join(outputDir, '.gitignore'), 'node_modules\n'); + + await runSetupWizard(TEST_BINDINGS, { + yes: true, + 'config-format': 'ts', + 'target-dir': outputDir, + }); + + await expect( + readFile(path.join(outputDir, '.gitignore'), 'utf8'), + ).resolves.toBe('node_modules\n\n# Code PushUp reports\n.code-pushup\n'); + }); + + it('should not modify .gitignore if .code-pushup already present', async () => { + await writeFile( + path.join(outputDir, '.gitignore'), + 'node_modules\n.code-pushup\n', + ); + + await runSetupWizard(TEST_BINDINGS, { + yes: true, + 'config-format': 'ts', + 'target-dir': outputDir, + }); + + await expect( + readFile(path.join(outputDir, '.gitignore'), 'utf8'), + ).resolves.toBe('node_modules\n.code-pushup\n'); + }); }); diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index 8764e1972..4ed26ef72 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -1,10 +1,16 @@ -import { asyncSequential, formatAsciiTable, logger } from '@code-pushup/utils'; +import { + asyncSequential, + formatAsciiTable, + getGitRoot, + logger, +} from '@code-pushup/utils'; import { generateConfigSource } from './codegen.js'; import { promptConfigFormat, readPackageJson, resolveConfigFilename, } from './config-format.js'; +import { resolveGitignore } from './gitignore.js'; import { promptPluginOptions } from './prompts.js'; import type { CliArgs, @@ -14,7 +20,12 @@ import type { } from './types.js'; import { createTree } from './virtual-fs.js'; -/** Runs the interactive setup wizard that generates a Code PushUp config file. */ +/** + * Runs the interactive setup wizard that generates a Code PushUp config file. + * + * All file changes are buffered in a virtual tree rooted at the git root, + * then flushed to disk in one step (or skipped on `--dry-run`). + */ export async function runSetupWizard( bindings: PluginSetupBinding[], cliArgs: CliArgs, @@ -32,24 +43,26 @@ export async function runSetupWizard( resolveBinding(binding, cliArgs), ); - const tree = createTree(targetDir); + const gitRoot = await getGitRoot(); + const tree = createTree(gitRoot); await tree.write(filename, generateConfigSource(pluginResults, format)); + await resolveGitignore(tree); - const changes = tree.listChanges(); + logChanges(tree.listChanges()); if (cliArgs['dry-run']) { - logChanges(changes); logger.info('Dry run — no files written.'); - } else { - await tree.flush(); - logChanges(changes); - logger.info('Setup complete.'); - logger.newline(); - logNextSteps([ - ['npx code-pushup', 'Collect your first report'], - ['https://github.com/code-pushup/cli#readme', 'Documentation'], - ]); + return; } + + await tree.flush(); + + logger.info('Setup complete.'); + logger.newline(); + logNextSteps([ + ['npx code-pushup', 'Collect your first report'], + ['https://github.com/code-pushup/cli#readme', 'Documentation'], + ]); } async function resolveBinding(