Skip to content

Commit cbf25c7

Browse files
authored
feat(create-cli): add gitignore setup step (#1252)
1 parent 226ceb9 commit cbf25c7

4 files changed

Lines changed: 267 additions & 14 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Tree } from './types.js';
2+
3+
const GITIGNORE_FILENAME = '.gitignore';
4+
const REPORTS_DIR = '.code-pushup';
5+
const REPORTS_DIR_ENTRIES = new Set([REPORTS_DIR, `**/${REPORTS_DIR}`]);
6+
const REPORTS_SECTION = `# Code PushUp reports\n${REPORTS_DIR}\n`;
7+
8+
export async function resolveGitignore(tree: Tree): Promise<void> {
9+
const content = await tree.read(GITIGNORE_FILENAME);
10+
const updated = resolveGitignoreContent(content);
11+
12+
if (updated != null) {
13+
await tree.write(GITIGNORE_FILENAME, updated);
14+
}
15+
}
16+
17+
function resolveGitignoreContent(content: string | null): string | null {
18+
if (content == null) {
19+
return REPORTS_SECTION;
20+
}
21+
if (gitignoreContainsEntry(content)) {
22+
return null;
23+
}
24+
const separator = content.endsWith('\n\n')
25+
? ''
26+
: content.endsWith('\n')
27+
? '\n'
28+
: '\n\n';
29+
30+
return `${content}${separator}${REPORTS_SECTION}`;
31+
}
32+
33+
function gitignoreContainsEntry(content: string): boolean {
34+
return content.split('\n').some(raw => {
35+
const line = raw.trim();
36+
return (
37+
line !== '' && !line.startsWith('#') && REPORTS_DIR_ENTRIES.has(line)
38+
);
39+
});
40+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { vol } from 'memfs';
2+
import { readFile } from 'node:fs/promises';
3+
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
4+
import { resolveGitignore } from './gitignore.js';
5+
import { createTree } from './virtual-fs.js';
6+
7+
describe('resolveGitignore', () => {
8+
it('should create .gitignore with comment when it does not exist', async () => {
9+
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
10+
const tree = createTree(MEMFS_VOLUME);
11+
12+
await resolveGitignore(tree);
13+
14+
expect(tree.listChanges()).toStrictEqual([
15+
{
16+
type: 'CREATE',
17+
path: '.gitignore',
18+
content: '# Code PushUp reports\n.code-pushup\n',
19+
},
20+
]);
21+
});
22+
23+
it('should update .gitignore with blank line separator when it already exists', async () => {
24+
vol.fromJSON({ '.gitignore': 'node_modules\n' }, MEMFS_VOLUME);
25+
const tree = createTree(MEMFS_VOLUME);
26+
27+
await resolveGitignore(tree);
28+
29+
expect(tree.listChanges()).toStrictEqual([
30+
{
31+
type: 'UPDATE',
32+
path: '.gitignore',
33+
content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n',
34+
},
35+
]);
36+
});
37+
38+
it('should preserve existing blank line before appending', async () => {
39+
vol.fromJSON({ '.gitignore': 'node_modules\n\n' }, MEMFS_VOLUME);
40+
const tree = createTree(MEMFS_VOLUME);
41+
42+
await resolveGitignore(tree);
43+
44+
expect(tree.listChanges()).toStrictEqual([
45+
{
46+
type: 'UPDATE',
47+
path: '.gitignore',
48+
content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n',
49+
},
50+
]);
51+
});
52+
53+
it('should add double newline separator when .gitignore has no trailing newline', async () => {
54+
vol.fromJSON({ '.gitignore': 'node_modules' }, MEMFS_VOLUME);
55+
const tree = createTree(MEMFS_VOLUME);
56+
57+
await resolveGitignore(tree);
58+
59+
expect(tree.listChanges()).toStrictEqual([
60+
{
61+
type: 'UPDATE',
62+
path: '.gitignore',
63+
content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n',
64+
},
65+
]);
66+
});
67+
68+
it('should skip if entry already in .gitignore', async () => {
69+
vol.fromJSON({ '.gitignore': '.code-pushup\n' }, MEMFS_VOLUME);
70+
const tree = createTree(MEMFS_VOLUME);
71+
72+
await resolveGitignore(tree);
73+
74+
expect(tree.listChanges()).toStrictEqual([]);
75+
});
76+
77+
it('should skip if **/.code-pushup entry already in .gitignore', async () => {
78+
vol.fromJSON({ '.gitignore': '**/.code-pushup\n' }, MEMFS_VOLUME);
79+
const tree = createTree(MEMFS_VOLUME);
80+
81+
await resolveGitignore(tree);
82+
83+
expect(tree.listChanges()).toStrictEqual([]);
84+
});
85+
86+
it('should skip if entry exists among comments and other entries', async () => {
87+
vol.fromJSON(
88+
{ '.gitignore': '# build output\ndist\n\n# reports\n.code-pushup\n' },
89+
MEMFS_VOLUME,
90+
);
91+
const tree = createTree(MEMFS_VOLUME);
92+
93+
await resolveGitignore(tree);
94+
95+
expect(tree.listChanges()).toStrictEqual([]);
96+
});
97+
98+
it('should skip if entry has leading and trailing whitespace', async () => {
99+
vol.fromJSON({ '.gitignore': ' .code-pushup \n' }, MEMFS_VOLUME);
100+
const tree = createTree(MEMFS_VOLUME);
101+
102+
await resolveGitignore(tree);
103+
104+
expect(tree.listChanges()).toStrictEqual([]);
105+
});
106+
107+
it('should not match commented-out entry', async () => {
108+
vol.fromJSON({ '.gitignore': '# .code-pushup\n' }, MEMFS_VOLUME);
109+
const tree = createTree(MEMFS_VOLUME);
110+
111+
await resolveGitignore(tree);
112+
113+
expect(tree.listChanges()).toStrictEqual([
114+
{
115+
type: 'UPDATE',
116+
path: '.gitignore',
117+
content: '# .code-pushup\n\n# Code PushUp reports\n.code-pushup\n',
118+
},
119+
]);
120+
});
121+
});
122+
123+
describe('resolveGitignore - flush', () => {
124+
it('should write .gitignore file to disk on flush', async () => {
125+
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
126+
const tree = createTree(MEMFS_VOLUME);
127+
128+
await resolveGitignore(tree);
129+
await tree.flush();
130+
131+
await expect(readFile(`${MEMFS_VOLUME}/.gitignore`, 'utf8')).resolves.toBe(
132+
'# Code PushUp reports\n.code-pushup\n',
133+
);
134+
});
135+
136+
it('should skip writing when entry already exists', async () => {
137+
vol.fromJSON({ '.gitignore': '.code-pushup\n' }, MEMFS_VOLUME);
138+
const tree = createTree(MEMFS_VOLUME);
139+
140+
await resolveGitignore(tree);
141+
await tree.flush();
142+
143+
await expect(readFile(`${MEMFS_VOLUME}/.gitignore`, 'utf8')).resolves.toBe(
144+
'.code-pushup\n',
145+
);
146+
});
147+
});

packages/create-cli/src/lib/setup/wizard.int.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { readFile, writeFile } from 'node:fs/promises';
22
import path from 'node:path';
33
import { cleanTestFolder } from '@code-pushup/test-utils';
4+
import { getGitRoot } from '@code-pushup/utils';
45
import type { PluginSetupBinding } from './types.js';
56
import { runSetupWizard } from './wizard.js';
67

8+
vi.mock('@code-pushup/utils', async () => {
9+
const actual = await vi.importActual('@code-pushup/utils');
10+
return {
11+
...actual,
12+
getGitRoot: vi.fn(),
13+
};
14+
});
15+
716
const TEST_BINDINGS: PluginSetupBinding[] = [
817
{
918
slug: 'alpha',
@@ -51,6 +60,7 @@ describe('runSetupWizard', () => {
5160

5261
beforeEach(async () => {
5362
await cleanTestFolder(outputDir);
63+
vi.mocked(getGitRoot).mockResolvedValue(path.resolve(outputDir));
5464
});
5565

5666
it('should write a valid ts config file with provided bindings', async () => {
@@ -167,4 +177,47 @@ describe('runSetupWizard', () => {
167177
"
168178
`);
169179
});
180+
181+
it('should create .gitignore with .code-pushup entry', async () => {
182+
await runSetupWizard(TEST_BINDINGS, {
183+
yes: true,
184+
'config-format': 'ts',
185+
'target-dir': outputDir,
186+
});
187+
188+
await expect(
189+
readFile(path.join(outputDir, '.gitignore'), 'utf8'),
190+
).resolves.toBe('# Code PushUp reports\n.code-pushup\n');
191+
});
192+
193+
it('should append .code-pushup to existing .gitignore', async () => {
194+
await writeFile(path.join(outputDir, '.gitignore'), 'node_modules\n');
195+
196+
await runSetupWizard(TEST_BINDINGS, {
197+
yes: true,
198+
'config-format': 'ts',
199+
'target-dir': outputDir,
200+
});
201+
202+
await expect(
203+
readFile(path.join(outputDir, '.gitignore'), 'utf8'),
204+
).resolves.toBe('node_modules\n\n# Code PushUp reports\n.code-pushup\n');
205+
});
206+
207+
it('should not modify .gitignore if .code-pushup already present', async () => {
208+
await writeFile(
209+
path.join(outputDir, '.gitignore'),
210+
'node_modules\n.code-pushup\n',
211+
);
212+
213+
await runSetupWizard(TEST_BINDINGS, {
214+
yes: true,
215+
'config-format': 'ts',
216+
'target-dir': outputDir,
217+
});
218+
219+
await expect(
220+
readFile(path.join(outputDir, '.gitignore'), 'utf8'),
221+
).resolves.toBe('node_modules\n.code-pushup\n');
222+
});
170223
});

packages/create-cli/src/lib/setup/wizard.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
import { asyncSequential, formatAsciiTable, logger } from '@code-pushup/utils';
1+
import {
2+
asyncSequential,
3+
formatAsciiTable,
4+
getGitRoot,
5+
logger,
6+
} from '@code-pushup/utils';
27
import { generateConfigSource } from './codegen.js';
38
import {
49
promptConfigFormat,
510
readPackageJson,
611
resolveConfigFilename,
712
} from './config-format.js';
13+
import { resolveGitignore } from './gitignore.js';
814
import { promptPluginOptions } from './prompts.js';
915
import type {
1016
CliArgs,
@@ -14,7 +20,12 @@ import type {
1420
} from './types.js';
1521
import { createTree } from './virtual-fs.js';
1622

17-
/** Runs the interactive setup wizard that generates a Code PushUp config file. */
23+
/**
24+
* Runs the interactive setup wizard that generates a Code PushUp config file.
25+
*
26+
* All file changes are buffered in a virtual tree rooted at the git root,
27+
* then flushed to disk in one step (or skipped on `--dry-run`).
28+
*/
1829
export async function runSetupWizard(
1930
bindings: PluginSetupBinding[],
2031
cliArgs: CliArgs,
@@ -32,24 +43,26 @@ export async function runSetupWizard(
3243
resolveBinding(binding, cliArgs),
3344
);
3445

35-
const tree = createTree(targetDir);
46+
const gitRoot = await getGitRoot();
47+
const tree = createTree(gitRoot);
3648
await tree.write(filename, generateConfigSource(pluginResults, format));
49+
await resolveGitignore(tree);
3750

38-
const changes = tree.listChanges();
51+
logChanges(tree.listChanges());
3952

4053
if (cliArgs['dry-run']) {
41-
logChanges(changes);
4254
logger.info('Dry run — no files written.');
43-
} else {
44-
await tree.flush();
45-
logChanges(changes);
46-
logger.info('Setup complete.');
47-
logger.newline();
48-
logNextSteps([
49-
['npx code-pushup', 'Collect your first report'],
50-
['https://github.com/code-pushup/cli#readme', 'Documentation'],
51-
]);
55+
return;
5256
}
57+
58+
await tree.flush();
59+
60+
logger.info('Setup complete.');
61+
logger.newline();
62+
logNextSteps([
63+
['npx code-pushup', 'Collect your first report'],
64+
['https://github.com/code-pushup/cli#readme', 'Documentation'],
65+
]);
5366
}
5467

5568
async function resolveBinding(

0 commit comments

Comments
 (0)