From 3138bba16c26c9161a01f32dd87323524f72c548 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 25 Feb 2026 14:49:31 -0500 Subject: [PATCH 1/2] feat(create-cli): add gitignore setup step --- .../create-cli/src/lib/setup/gitignore.ts | 46 +++++++++++ .../src/lib/setup/gitignore.unit.test.ts | 78 +++++++++++++++++++ .../src/lib/setup/wizard.int.test.ts | 53 +++++++++++++ packages/create-cli/src/lib/setup/wizard.ts | 34 +++++--- 4 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 packages/create-cli/src/lib/setup/gitignore.ts create mode 100644 packages/create-cli/src/lib/setup/gitignore.unit.test.ts 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..1502ac6ea --- /dev/null +++ b/packages/create-cli/src/lib/setup/gitignore.ts @@ -0,0 +1,46 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileExists, getGitRoot, readTextFile } from '@code-pushup/utils'; +import type { FileChange } from './types.js'; + +const GITIGNORE_FILENAME = '.gitignore'; +const REPORTS_DIR = '.code-pushup'; + +export async function resolveGitignore(): Promise { + const gitRoot = await getGitRoot(); + const gitignorePath = path.join(gitRoot, GITIGNORE_FILENAME); + + const section = `# Code PushUp reports\n${REPORTS_DIR}\n`; + + const hasGitignore = await fileExists(gitignorePath); + if (!hasGitignore) { + return { type: 'CREATE', path: GITIGNORE_FILENAME, content: section }; + } + + const currentContent = await readTextFile(gitignorePath); + if (currentContent.includes(REPORTS_DIR)) { + return null; + } + + const separator = currentContent.endsWith('\n\n') + ? '' + : currentContent.endsWith('\n') + ? '\n' + : '\n\n'; + + return { + type: 'UPDATE', + path: GITIGNORE_FILENAME, + content: `${currentContent}${separator}${section}`, + }; +} + +export async function updateGitignore( + change: FileChange | null, +): Promise { + if (change == null) { + return; + } + const gitRoot = await getGitRoot(); + await writeFile(path.join(gitRoot, change.path), change.content); +} 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..b585c89b7 --- /dev/null +++ b/packages/create-cli/src/lib/setup/gitignore.unit.test.ts @@ -0,0 +1,78 @@ +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { resolveGitignore, updateGitignore } from './gitignore.js'; + +describe('resolveGitignore', () => { + it('should return CREATE change with comment when no .gitignore exists', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + + await expect(resolveGitignore()).resolves.toStrictEqual({ + type: 'CREATE', + path: '.gitignore', + content: '# Code PushUp reports\n.code-pushup\n', + }); + }); + + it('should return UPDATE change with blank line separator for existing .gitignore', async () => { + vol.fromJSON({ '.gitignore': 'node_modules\n' }, MEMFS_VOLUME); + + await expect(resolveGitignore()).resolves.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); + + await expect(resolveGitignore()).resolves.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); + + await expect(resolveGitignore()).resolves.toStrictEqual({ + type: 'UPDATE', + path: '.gitignore', + content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', + }); + }); + + it('should return null if entry already in .gitignore', async () => { + vol.fromJSON({ '.gitignore': '.code-pushup\n' }, MEMFS_VOLUME); + + await expect(resolveGitignore()).resolves.toBeNull(); + }); +}); + +describe('updateGitignore', () => { + it('should skip writing when change is null', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + + await updateGitignore(null); + + expect(vol.toJSON(MEMFS_VOLUME)).toStrictEqual({ + [`${MEMFS_VOLUME}/package.json`]: '{}', + }); + }); + + it('should write .gitignore file to git root', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + + await updateGitignore({ + type: 'CREATE', + path: '.gitignore', + content: '# Code PushUp reports\n.code-pushup\n', + }); + + await expect(readFile(`${MEMFS_VOLUME}/.gitignore`, 'utf8')).resolves.toBe( + '# Code PushUp reports\n.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..f5f4a8642 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -5,6 +5,7 @@ import { readPackageJson, resolveConfigFilename, } from './config-format.js'; +import { resolveGitignore, updateGitignore } from './gitignore.js'; import { promptPluginOptions } from './prompts.js'; import type { CliArgs, @@ -35,21 +36,26 @@ export async function runSetupWizard( const tree = createTree(targetDir); await tree.write(filename, generateConfigSource(pluginResults, format)); - const changes = tree.listChanges(); + const configChanges = tree.listChanges(); + const gitignoreChange = await resolveGitignore(); + const changes = collectChanges(configChanges, gitignoreChange); + + logChanges(changes); 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(); + await updateGitignore(gitignoreChange); + + 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( @@ -62,6 +68,12 @@ async function resolveBinding( return binding.generateConfig(answers); } +function collectChanges( + ...sources: (FileChange[] | FileChange | null)[] +): FileChange[] { + return sources.flat().filter((c): c is FileChange => c != null); +} + function logChanges(changes: FileChange[]): void { changes.forEach(change => { logger.info(`${change.type} ${change.path}`); From ba0050837a98326038f78963c4b2a8723d940eb6 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 26 Feb 2026 12:52:50 -0500 Subject: [PATCH 2/2] refactor(create-cli): handle gitignore in tree --- .../create-cli/src/lib/setup/gitignore.ts | 54 +++---- .../src/lib/setup/gitignore.unit.test.ts | 147 +++++++++++++----- packages/create-cli/src/lib/setup/wizard.ts | 33 ++-- 3 files changed, 149 insertions(+), 85 deletions(-) diff --git a/packages/create-cli/src/lib/setup/gitignore.ts b/packages/create-cli/src/lib/setup/gitignore.ts index 1502ac6ea..b52afd7cf 100644 --- a/packages/create-cli/src/lib/setup/gitignore.ts +++ b/packages/create-cli/src/lib/setup/gitignore.ts @@ -1,46 +1,40 @@ -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileExists, getGitRoot, readTextFile } from '@code-pushup/utils'; -import type { FileChange } from './types.js'; +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(): Promise { - const gitRoot = await getGitRoot(); - const gitignorePath = path.join(gitRoot, GITIGNORE_FILENAME); +export async function resolveGitignore(tree: Tree): Promise { + const content = await tree.read(GITIGNORE_FILENAME); + const updated = resolveGitignoreContent(content); - const section = `# Code PushUp reports\n${REPORTS_DIR}\n`; - - const hasGitignore = await fileExists(gitignorePath); - if (!hasGitignore) { - return { type: 'CREATE', path: GITIGNORE_FILENAME, content: section }; + if (updated != null) { + await tree.write(GITIGNORE_FILENAME, updated); } +} - const currentContent = await readTextFile(gitignorePath); - if (currentContent.includes(REPORTS_DIR)) { +function resolveGitignoreContent(content: string | null): string | null { + if (content == null) { + return REPORTS_SECTION; + } + if (gitignoreContainsEntry(content)) { return null; } - - const separator = currentContent.endsWith('\n\n') + const separator = content.endsWith('\n\n') ? '' - : currentContent.endsWith('\n') + : content.endsWith('\n') ? '\n' : '\n\n'; - return { - type: 'UPDATE', - path: GITIGNORE_FILENAME, - content: `${currentContent}${separator}${section}`, - }; + return `${content}${separator}${REPORTS_SECTION}`; } -export async function updateGitignore( - change: FileChange | null, -): Promise { - if (change == null) { - return; - } - const gitRoot = await getGitRoot(); - await writeFile(path.join(gitRoot, change.path), change.content); +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 index b585c89b7..16e544927 100644 --- a/packages/create-cli/src/lib/setup/gitignore.unit.test.ts +++ b/packages/create-cli/src/lib/setup/gitignore.unit.test.ts @@ -1,78 +1,147 @@ import { vol } from 'memfs'; import { readFile } from 'node:fs/promises'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import { resolveGitignore, updateGitignore } from './gitignore.js'; +import { resolveGitignore } from './gitignore.js'; +import { createTree } from './virtual-fs.js'; describe('resolveGitignore', () => { - it('should return CREATE change with comment when no .gitignore exists', async () => { + it('should create .gitignore with comment when it does not exist', async () => { vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); - await expect(resolveGitignore()).resolves.toStrictEqual({ - type: 'CREATE', - path: '.gitignore', - content: '# Code PushUp reports\n.code-pushup\n', - }); + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([ + { + type: 'CREATE', + path: '.gitignore', + content: '# Code PushUp reports\n.code-pushup\n', + }, + ]); }); - it('should return UPDATE change with blank line separator for existing .gitignore', async () => { + 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); - await expect(resolveGitignore()).resolves.toStrictEqual({ - type: 'UPDATE', - path: '.gitignore', - content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', - }); + 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 expect(resolveGitignore()).resolves.toStrictEqual({ - type: 'UPDATE', - path: '.gitignore', - content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', - }); + 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); - await expect(resolveGitignore()).resolves.toStrictEqual({ - type: 'UPDATE', - path: '.gitignore', - content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', - }); + expect(tree.listChanges()).toStrictEqual([ + { + type: 'UPDATE', + path: '.gitignore', + content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n', + }, + ]); }); - it('should return null if entry already in .gitignore', async () => { + it('should skip if entry already in .gitignore', async () => { vol.fromJSON({ '.gitignore': '.code-pushup\n' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); - await expect(resolveGitignore()).resolves.toBeNull(); + await resolveGitignore(tree); + + expect(tree.listChanges()).toStrictEqual([]); }); -}); -describe('updateGitignore', () => { - it('should skip writing when change is null', async () => { - vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + 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 updateGitignore(null); + await resolveGitignore(tree); - expect(vol.toJSON(MEMFS_VOLUME)).toStrictEqual({ - [`${MEMFS_VOLUME}/package.json`]: '{}', - }); + expect(tree.listChanges()).toStrictEqual([]); }); - it('should write .gitignore file to git root', async () => { + 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 updateGitignore({ - type: 'CREATE', - path: '.gitignore', - content: '# Code PushUp reports\n.code-pushup\n', - }); + 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.ts b/packages/create-cli/src/lib/setup/wizard.ts index f5f4a8642..4ed26ef72 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -1,11 +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, updateGitignore } from './gitignore.js'; +import { resolveGitignore } from './gitignore.js'; import { promptPluginOptions } from './prompts.js'; import type { CliArgs, @@ -15,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, @@ -33,14 +43,12 @@ 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 configChanges = tree.listChanges(); - const gitignoreChange = await resolveGitignore(); - const changes = collectChanges(configChanges, gitignoreChange); - - logChanges(changes); + logChanges(tree.listChanges()); if (cliArgs['dry-run']) { logger.info('Dry run — no files written.'); @@ -48,7 +56,6 @@ export async function runSetupWizard( } await tree.flush(); - await updateGitignore(gitignoreChange); logger.info('Setup complete.'); logger.newline(); @@ -68,12 +75,6 @@ async function resolveBinding( return binding.generateConfig(answers); } -function collectChanges( - ...sources: (FileChange[] | FileChange | null)[] -): FileChange[] { - return sources.flat().filter((c): c is FileChange => c != null); -} - function logChanges(changes: FileChange[]): void { changes.forEach(change => { logger.info(`${change.type} ${change.path}`);