Skip to content

Commit 79a4c84

Browse files
authored
Merge pull request #618 from atls/fix/schematics-preserve-gitignore
fix(code-schematics): preserve project-specific gitignore entries
2 parents 6b73a7c + a9d68ac commit 79a4c84

4 files changed

Lines changed: 352 additions & 2 deletions

File tree

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Rule } from '@angular-devkit/schematics'
2+
import type { SchematicContext } from '@angular-devkit/schematics'
3+
import type { Tree } from '@angular-devkit/schematics'
24

35
import { MergeStrategy } from '@angular-devkit/schematics'
46
import { chain } from '@angular-devkit/schematics'
@@ -7,10 +9,57 @@ import { mergeWith } from '@angular-devkit/schematics'
79
import { updateTsConfigRule } from '../rules/index.js'
810
import { generateCommonSource } from '../sources/index.js'
911
import { generateProjectSpecificSource } from '../sources/index.js'
12+
import { mergeGitIgnoreContent } from '../utils/index.js'
1013

11-
export const main = (options: Record<string, string>): Rule =>
12-
chain([
14+
const GITIGNORE_PATH = '.gitignore'
15+
16+
const captureGitIgnoreContentRule = (state: { content?: string }): Rule =>
17+
(host: Tree): Tree => {
18+
const gitIgnoreBuffer = host.read(GITIGNORE_PATH)
19+
20+
if (!gitIgnoreBuffer) {
21+
return host
22+
}
23+
24+
state.content = gitIgnoreBuffer.toString('utf-8')
25+
26+
return host
27+
}
28+
29+
const mergeGitIgnoreContentRule = (state: { content?: string }): Rule =>
30+
(host: Tree, context: SchematicContext): Tree => {
31+
if (state.content === undefined) {
32+
return host
33+
}
34+
35+
const gitIgnoreBuffer = host.read(GITIGNORE_PATH)
36+
37+
if (!gitIgnoreBuffer) {
38+
return host
39+
}
40+
41+
const templateContent = gitIgnoreBuffer.toString('utf-8')
42+
const mergedContent = mergeGitIgnoreContent({
43+
existingContent: state.content,
44+
templateContent,
45+
})
46+
47+
if (mergedContent !== templateContent) {
48+
context.logger.info('Merging template .gitignore with project-specific entries')
49+
host.overwrite(GITIGNORE_PATH, mergedContent)
50+
}
51+
52+
return host
53+
}
54+
55+
export const main = (options: Record<string, string>): Rule => {
56+
const state: { content?: string } = {}
57+
58+
return chain([
59+
captureGitIgnoreContentRule(state),
1360
updateTsConfigRule,
1461
mergeWith(generateCommonSource(options), MergeStrategy.Overwrite),
1562
mergeWith(generateProjectSpecificSource(options), MergeStrategy.Overwrite),
63+
mergeGitIgnoreContentRule(state),
1664
])
65+
}

code/code-schematics/src/schematic/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './gitignore.utils.js'
2+
export * from './merge-gitignore-content.utils.js'
23
export * from './tsconfig.utils.js'
34
export * from './json.utils.js'
45
export * from './yaml.utils.js'
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import assert from 'node:assert/strict'
2+
import { test } from 'node:test'
3+
4+
import { mergeGitIgnoreContent } from './merge-gitignore-content.utils.js'
5+
6+
test('should preserve project-specific entries while keeping template section deterministic', () => {
7+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
8+
const existingContent = ['node_modules', '.idea/', '.yarn/install-state.gz', 'coverage/'].join(
9+
'\n'
10+
)
11+
12+
const actual = mergeGitIgnoreContent({
13+
existingContent,
14+
templateContent,
15+
})
16+
17+
assert.equal(
18+
actual,
19+
[
20+
'node_modules',
21+
'.yarn/install-state.gz',
22+
'dist/',
23+
'',
24+
'# raijin:begin project-specific gitignore',
25+
'.idea/',
26+
'coverage/',
27+
'# raijin:end project-specific gitignore',
28+
].join('\n')
29+
)
30+
})
31+
32+
test('should be idempotent for already merged gitignore content', () => {
33+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
34+
const mergedContent = [
35+
'node_modules',
36+
'.yarn/install-state.gz',
37+
'dist/',
38+
'',
39+
'# raijin:begin project-specific gitignore',
40+
'.idea/',
41+
'# raijin:end project-specific gitignore',
42+
].join('\n')
43+
44+
const actual = mergeGitIgnoreContent({
45+
existingContent: mergedContent,
46+
templateContent,
47+
})
48+
49+
assert.equal(actual, mergedContent)
50+
})
51+
52+
test('should not duplicate template entries from project content', () => {
53+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
54+
const existingContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
55+
56+
const actual = mergeGitIgnoreContent({
57+
existingContent,
58+
templateContent,
59+
})
60+
61+
assert.equal(actual, templateContent)
62+
})
63+
64+
test('should not create project-specific block for blank existing content', () => {
65+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
66+
67+
const actual = mergeGitIgnoreContent({
68+
existingContent: '\n',
69+
templateContent,
70+
})
71+
72+
assert.equal(actual, templateContent)
73+
})
74+
75+
test('should not keep removed template rules when managed block exists', () => {
76+
const templateContent = ['node_modules', '.yarn/install-state.gz'].join('\n')
77+
const existingContent = [
78+
'node_modules',
79+
'.yarn/install-state.gz',
80+
'dist/',
81+
'',
82+
'# raijin:begin project-specific gitignore',
83+
'.idea/',
84+
'# raijin:end project-specific gitignore',
85+
].join('\n')
86+
87+
const actual = mergeGitIgnoreContent({
88+
existingContent,
89+
templateContent,
90+
})
91+
92+
assert.equal(
93+
actual,
94+
[
95+
'node_modules',
96+
'.yarn/install-state.gz',
97+
'',
98+
'# raijin:begin project-specific gitignore',
99+
'dist/',
100+
'.idea/',
101+
'# raijin:end project-specific gitignore',
102+
].join('\n')
103+
)
104+
})
105+
106+
test('should normalize CRLF input before comparison', () => {
107+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
108+
const existingContent = ['node_modules', '.yarn/install-state.gz', 'dist/', '.idea/'].join('\r\n')
109+
110+
const actual = mergeGitIgnoreContent({
111+
existingContent,
112+
templateContent,
113+
})
114+
115+
assert.equal(
116+
actual,
117+
[
118+
'node_modules',
119+
'.yarn/install-state.gz',
120+
'dist/',
121+
'',
122+
'# raijin:begin project-specific gitignore',
123+
'.idea/',
124+
'# raijin:end project-specific gitignore',
125+
].join('\n')
126+
)
127+
})
128+
129+
test('should preserve project-specific lines added outside managed block', () => {
130+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
131+
const existingContent = [
132+
'node_modules',
133+
'.yarn/install-state.gz',
134+
'dist/',
135+
'',
136+
'.custom-above-block/',
137+
'# raijin:begin project-specific gitignore',
138+
'.idea/',
139+
'# raijin:end project-specific gitignore',
140+
'.custom-below-block/',
141+
].join('\n')
142+
143+
const actual = mergeGitIgnoreContent({
144+
existingContent,
145+
templateContent,
146+
})
147+
148+
assert.equal(
149+
actual,
150+
[
151+
'node_modules',
152+
'.yarn/install-state.gz',
153+
'dist/',
154+
'',
155+
'# raijin:begin project-specific gitignore',
156+
'.custom-above-block/',
157+
'.idea/',
158+
'.custom-below-block/',
159+
'# raijin:end project-specific gitignore',
160+
].join('\n')
161+
)
162+
})
163+
164+
test('should preserve project-specific lines in template area before managed block', () => {
165+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
166+
const existingContent = [
167+
'.custom-top-line/',
168+
'node_modules',
169+
'.yarn/install-state.gz',
170+
'dist/',
171+
'',
172+
'# raijin:begin project-specific gitignore',
173+
'.idea/',
174+
'# raijin:end project-specific gitignore',
175+
].join('\n')
176+
177+
const actual = mergeGitIgnoreContent({
178+
existingContent,
179+
templateContent,
180+
})
181+
182+
assert.equal(
183+
actual,
184+
[
185+
'node_modules',
186+
'.yarn/install-state.gz',
187+
'dist/',
188+
'',
189+
'# raijin:begin project-specific gitignore',
190+
'.custom-top-line/',
191+
'.idea/',
192+
'# raijin:end project-specific gitignore',
193+
].join('\n')
194+
)
195+
})
196+
197+
test('should preserve project-specific negation order around managed block', () => {
198+
const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n')
199+
const existingContent = [
200+
'node_modules',
201+
'.yarn/install-state.gz',
202+
'dist/',
203+
'',
204+
'*.log',
205+
'# raijin:begin project-specific gitignore',
206+
'!important.log',
207+
'# raijin:end project-specific gitignore',
208+
].join('\n')
209+
210+
const actual = mergeGitIgnoreContent({
211+
existingContent,
212+
templateContent,
213+
})
214+
215+
assert.equal(
216+
actual,
217+
[
218+
'node_modules',
219+
'.yarn/install-state.gz',
220+
'dist/',
221+
'',
222+
'# raijin:begin project-specific gitignore',
223+
'*.log',
224+
'!important.log',
225+
'# raijin:end project-specific gitignore',
226+
].join('\n')
227+
)
228+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
type MergeGitIgnoreContentOptions = {
2+
existingContent: string
3+
templateContent: string
4+
}
5+
6+
const PROJECT_SPECIFIC_START_MARKER = '# raijin:begin project-specific gitignore'
7+
const PROJECT_SPECIFIC_END_MARKER = '# raijin:end project-specific gitignore'
8+
9+
const normalizeContent = (content: string): string => content.replace(/\r\n/g, '\n')
10+
11+
const getNormalizedLines = (content: string): Array<string> => normalizeContent(content).split('\n')
12+
13+
const trimTrailingEmptyLines = (lines: Array<string>): Array<string> => {
14+
const normalizedLines = [...lines]
15+
16+
while (normalizedLines.length > 0 && normalizedLines[normalizedLines.length - 1] === '') {
17+
normalizedLines.pop()
18+
}
19+
20+
return normalizedLines
21+
}
22+
23+
const isProjectSpecificLine = (line: string, templateLineSet: Set<string>): boolean =>
24+
line !== '' &&
25+
!templateLineSet.has(line) &&
26+
line !== PROJECT_SPECIFIC_START_MARKER &&
27+
line !== PROJECT_SPECIFIC_END_MARKER
28+
29+
const getProjectSpecificLines = (
30+
existingLines: Array<string>,
31+
templateLineSet: Set<string>
32+
): Array<string> => {
33+
const startIndex = existingLines.indexOf(PROJECT_SPECIFIC_START_MARKER)
34+
const endIndex = existingLines.indexOf(PROJECT_SPECIFIC_END_MARKER)
35+
36+
if (startIndex !== -1 && endIndex > startIndex) {
37+
return Array.from(
38+
new Set(
39+
existingLines.filter((line) => isProjectSpecificLine(line, templateLineSet))
40+
)
41+
)
42+
}
43+
44+
return existingLines.filter((line) => isProjectSpecificLine(line, templateLineSet))
45+
}
46+
47+
export const mergeGitIgnoreContent = ({
48+
existingContent,
49+
templateContent,
50+
}: MergeGitIgnoreContentOptions): string => {
51+
const templateLines = getNormalizedLines(templateContent)
52+
const templateLineSet = new Set(templateLines)
53+
const existingLines = getNormalizedLines(existingContent)
54+
55+
const projectSpecificLines = getProjectSpecificLines(existingLines, templateLineSet)
56+
57+
if (projectSpecificLines.length === 0) {
58+
return trimTrailingEmptyLines(templateLines).join('\n')
59+
}
60+
61+
const mergedLines = trimTrailingEmptyLines(templateLines)
62+
63+
if (mergedLines.length > 0) {
64+
mergedLines.push('')
65+
}
66+
67+
mergedLines.push(PROJECT_SPECIFIC_START_MARKER)
68+
mergedLines.push(...projectSpecificLines)
69+
mergedLines.push(PROJECT_SPECIFIC_END_MARKER)
70+
71+
return mergedLines.join('\n')
72+
}

0 commit comments

Comments
 (0)