Skip to content

Commit ba00508

Browse files
committed
refactor(create-cli): handle gitignore in tree
1 parent 3138bba commit ba00508

File tree

3 files changed

+149
-85
lines changed

3 files changed

+149
-85
lines changed
Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,40 @@
1-
import { writeFile } from 'node:fs/promises';
2-
import path from 'node:path';
3-
import { fileExists, getGitRoot, readTextFile } from '@code-pushup/utils';
4-
import type { FileChange } from './types.js';
1+
import type { Tree } from './types.js';
52

63
const GITIGNORE_FILENAME = '.gitignore';
74
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`;
87

9-
export async function resolveGitignore(): Promise<FileChange | null> {
10-
const gitRoot = await getGitRoot();
11-
const gitignorePath = path.join(gitRoot, GITIGNORE_FILENAME);
8+
export async function resolveGitignore(tree: Tree): Promise<void> {
9+
const content = await tree.read(GITIGNORE_FILENAME);
10+
const updated = resolveGitignoreContent(content);
1211

13-
const section = `# Code PushUp reports\n${REPORTS_DIR}\n`;
14-
15-
const hasGitignore = await fileExists(gitignorePath);
16-
if (!hasGitignore) {
17-
return { type: 'CREATE', path: GITIGNORE_FILENAME, content: section };
12+
if (updated != null) {
13+
await tree.write(GITIGNORE_FILENAME, updated);
1814
}
15+
}
1916

20-
const currentContent = await readTextFile(gitignorePath);
21-
if (currentContent.includes(REPORTS_DIR)) {
17+
function resolveGitignoreContent(content: string | null): string | null {
18+
if (content == null) {
19+
return REPORTS_SECTION;
20+
}
21+
if (gitignoreContainsEntry(content)) {
2222
return null;
2323
}
24-
25-
const separator = currentContent.endsWith('\n\n')
24+
const separator = content.endsWith('\n\n')
2625
? ''
27-
: currentContent.endsWith('\n')
26+
: content.endsWith('\n')
2827
? '\n'
2928
: '\n\n';
3029

31-
return {
32-
type: 'UPDATE',
33-
path: GITIGNORE_FILENAME,
34-
content: `${currentContent}${separator}${section}`,
35-
};
30+
return `${content}${separator}${REPORTS_SECTION}`;
3631
}
3732

38-
export async function updateGitignore(
39-
change: FileChange | null,
40-
): Promise<void> {
41-
if (change == null) {
42-
return;
43-
}
44-
const gitRoot = await getGitRoot();
45-
await writeFile(path.join(gitRoot, change.path), change.content);
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+
});
4640
}
Lines changed: 108 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,147 @@
11
import { vol } from 'memfs';
22
import { readFile } from 'node:fs/promises';
33
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
4-
import { resolveGitignore, updateGitignore } from './gitignore.js';
4+
import { resolveGitignore } from './gitignore.js';
5+
import { createTree } from './virtual-fs.js';
56

67
describe('resolveGitignore', () => {
7-
it('should return CREATE change with comment when no .gitignore exists', async () => {
8+
it('should create .gitignore with comment when it does not exist', async () => {
89
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
10+
const tree = createTree(MEMFS_VOLUME);
911

10-
await expect(resolveGitignore()).resolves.toStrictEqual({
11-
type: 'CREATE',
12-
path: '.gitignore',
13-
content: '# Code PushUp reports\n.code-pushup\n',
14-
});
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+
]);
1521
});
1622

17-
it('should return UPDATE change with blank line separator for existing .gitignore', async () => {
23+
it('should update .gitignore with blank line separator when it already exists', async () => {
1824
vol.fromJSON({ '.gitignore': 'node_modules\n' }, MEMFS_VOLUME);
25+
const tree = createTree(MEMFS_VOLUME);
26+
27+
await resolveGitignore(tree);
1928

20-
await expect(resolveGitignore()).resolves.toStrictEqual({
21-
type: 'UPDATE',
22-
path: '.gitignore',
23-
content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n',
24-
});
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+
]);
2536
});
2637

2738
it('should preserve existing blank line before appending', async () => {
2839
vol.fromJSON({ '.gitignore': 'node_modules\n\n' }, MEMFS_VOLUME);
40+
const tree = createTree(MEMFS_VOLUME);
2941

30-
await expect(resolveGitignore()).resolves.toStrictEqual({
31-
type: 'UPDATE',
32-
path: '.gitignore',
33-
content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n',
34-
});
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+
]);
3551
});
3652

3753
it('should add double newline separator when .gitignore has no trailing newline', async () => {
3854
vol.fromJSON({ '.gitignore': 'node_modules' }, MEMFS_VOLUME);
55+
const tree = createTree(MEMFS_VOLUME);
56+
57+
await resolveGitignore(tree);
3958

40-
await expect(resolveGitignore()).resolves.toStrictEqual({
41-
type: 'UPDATE',
42-
path: '.gitignore',
43-
content: 'node_modules\n\n# Code PushUp reports\n.code-pushup\n',
44-
});
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+
]);
4566
});
4667

47-
it('should return null if entry already in .gitignore', async () => {
68+
it('should skip if entry already in .gitignore', async () => {
4869
vol.fromJSON({ '.gitignore': '.code-pushup\n' }, MEMFS_VOLUME);
70+
const tree = createTree(MEMFS_VOLUME);
4971

50-
await expect(resolveGitignore()).resolves.toBeNull();
72+
await resolveGitignore(tree);
73+
74+
expect(tree.listChanges()).toStrictEqual([]);
5175
});
52-
});
5376

54-
describe('updateGitignore', () => {
55-
it('should skip writing when change is null', async () => {
56-
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
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);
57101

58-
await updateGitignore(null);
102+
await resolveGitignore(tree);
59103

60-
expect(vol.toJSON(MEMFS_VOLUME)).toStrictEqual({
61-
[`${MEMFS_VOLUME}/package.json`]: '{}',
62-
});
104+
expect(tree.listChanges()).toStrictEqual([]);
63105
});
64106

65-
it('should write .gitignore file to git root', async () => {
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 () => {
66125
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
126+
const tree = createTree(MEMFS_VOLUME);
67127

68-
await updateGitignore({
69-
type: 'CREATE',
70-
path: '.gitignore',
71-
content: '# Code PushUp reports\n.code-pushup\n',
72-
});
128+
await resolveGitignore(tree);
129+
await tree.flush();
73130

74131
await expect(readFile(`${MEMFS_VOLUME}/.gitignore`, 'utf8')).resolves.toBe(
75132
'# Code PushUp reports\n.code-pushup\n',
76133
);
77134
});
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+
});
78147
});

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

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +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';
8-
import { resolveGitignore, updateGitignore } from './gitignore.js';
13+
import { resolveGitignore } from './gitignore.js';
914
import { promptPluginOptions } from './prompts.js';
1015
import type {
1116
CliArgs,
@@ -15,7 +20,12 @@ import type {
1520
} from './types.js';
1621
import { createTree } from './virtual-fs.js';
1722

18-
/** 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+
*/
1929
export async function runSetupWizard(
2030
bindings: PluginSetupBinding[],
2131
cliArgs: CliArgs,
@@ -33,22 +43,19 @@ export async function runSetupWizard(
3343
resolveBinding(binding, cliArgs),
3444
);
3545

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

39-
const configChanges = tree.listChanges();
40-
const gitignoreChange = await resolveGitignore();
41-
const changes = collectChanges(configChanges, gitignoreChange);
42-
43-
logChanges(changes);
51+
logChanges(tree.listChanges());
4452

4553
if (cliArgs['dry-run']) {
4654
logger.info('Dry run — no files written.');
4755
return;
4856
}
4957

5058
await tree.flush();
51-
await updateGitignore(gitignoreChange);
5259

5360
logger.info('Setup complete.');
5461
logger.newline();
@@ -68,12 +75,6 @@ async function resolveBinding(
6875
return binding.generateConfig(answers);
6976
}
7077

71-
function collectChanges(
72-
...sources: (FileChange[] | FileChange | null)[]
73-
): FileChange[] {
74-
return sources.flat().filter((c): c is FileChange => c != null);
75-
}
76-
7778
function logChanges(changes: FileChange[]): void {
7879
changes.forEach(change => {
7980
logger.info(`${change.type} ${change.path}`);

0 commit comments

Comments
 (0)