diff --git a/code/code-schematics/src/schematic/project/project.factory.ts b/code/code-schematics/src/schematic/project/project.factory.ts index 6b88969e6..83c200b68 100644 --- a/code/code-schematics/src/schematic/project/project.factory.ts +++ b/code/code-schematics/src/schematic/project/project.factory.ts @@ -1,4 +1,6 @@ import type { Rule } from '@angular-devkit/schematics' +import type { SchematicContext } from '@angular-devkit/schematics' +import type { Tree } from '@angular-devkit/schematics' import { MergeStrategy } from '@angular-devkit/schematics' import { chain } from '@angular-devkit/schematics' @@ -7,10 +9,57 @@ import { mergeWith } from '@angular-devkit/schematics' import { updateTsConfigRule } from '../rules/index.js' import { generateCommonSource } from '../sources/index.js' import { generateProjectSpecificSource } from '../sources/index.js' +import { mergeGitIgnoreContent } from '../utils/index.js' -export const main = (options: Record): Rule => - chain([ +const GITIGNORE_PATH = '.gitignore' + +const captureGitIgnoreContentRule = (state: { content?: string }): Rule => + (host: Tree): Tree => { + const gitIgnoreBuffer = host.read(GITIGNORE_PATH) + + if (!gitIgnoreBuffer) { + return host + } + + state.content = gitIgnoreBuffer.toString('utf-8') + + return host + } + +const mergeGitIgnoreContentRule = (state: { content?: string }): Rule => + (host: Tree, context: SchematicContext): Tree => { + if (state.content === undefined) { + return host + } + + const gitIgnoreBuffer = host.read(GITIGNORE_PATH) + + if (!gitIgnoreBuffer) { + return host + } + + const templateContent = gitIgnoreBuffer.toString('utf-8') + const mergedContent = mergeGitIgnoreContent({ + existingContent: state.content, + templateContent, + }) + + if (mergedContent !== templateContent) { + context.logger.info('Merging template .gitignore with project-specific entries') + host.overwrite(GITIGNORE_PATH, mergedContent) + } + + return host + } + +export const main = (options: Record): Rule => { + const state: { content?: string } = {} + + return chain([ + captureGitIgnoreContentRule(state), updateTsConfigRule, mergeWith(generateCommonSource(options), MergeStrategy.Overwrite), mergeWith(generateProjectSpecificSource(options), MergeStrategy.Overwrite), + mergeGitIgnoreContentRule(state), ]) +} diff --git a/code/code-schematics/src/schematic/utils/index.ts b/code/code-schematics/src/schematic/utils/index.ts index 95a7f0955..9ad958db2 100644 --- a/code/code-schematics/src/schematic/utils/index.ts +++ b/code/code-schematics/src/schematic/utils/index.ts @@ -1,4 +1,5 @@ export * from './gitignore.utils.js' +export * from './merge-gitignore-content.utils.js' export * from './tsconfig.utils.js' export * from './json.utils.js' export * from './yaml.utils.js' diff --git a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.test.ts b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.test.ts new file mode 100644 index 000000000..0b4fa4c0e --- /dev/null +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.test.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { mergeGitIgnoreContent } from './merge-gitignore-content.utils.js' + +test('should preserve project-specific entries while keeping template section deterministic', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + const existingContent = ['node_modules', '.idea/', '.yarn/install-state.gz', 'coverage/'].join( + '\n' + ) + + const actual = mergeGitIgnoreContent({ + existingContent, + templateContent, + }) + + assert.equal( + actual, + [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '.idea/', + 'coverage/', + '# raijin:end project-specific gitignore', + ].join('\n') + ) +}) + +test('should be idempotent for already merged gitignore content', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + const mergedContent = [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '.idea/', + '# raijin:end project-specific gitignore', + ].join('\n') + + const actual = mergeGitIgnoreContent({ + existingContent: mergedContent, + templateContent, + }) + + assert.equal(actual, mergedContent) +}) + +test('should not duplicate template entries from project content', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + const existingContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + + const actual = mergeGitIgnoreContent({ + existingContent, + templateContent, + }) + + assert.equal(actual, templateContent) +}) + +test('should not create project-specific block for blank existing content', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + + const actual = mergeGitIgnoreContent({ + existingContent: '\n', + templateContent, + }) + + assert.equal(actual, templateContent) +}) + +test('should not keep removed template rules when managed block exists', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz'].join('\n') + const existingContent = [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '.idea/', + '# raijin:end project-specific gitignore', + ].join('\n') + + const actual = mergeGitIgnoreContent({ + existingContent, + templateContent, + }) + + assert.equal( + actual, + [ + 'node_modules', + '.yarn/install-state.gz', + '', + '# raijin:begin project-specific gitignore', + 'dist/', + '.idea/', + '# raijin:end project-specific gitignore', + ].join('\n') + ) +}) + +test('should normalize CRLF input before comparison', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + const existingContent = ['node_modules', '.yarn/install-state.gz', 'dist/', '.idea/'].join('\r\n') + + const actual = mergeGitIgnoreContent({ + existingContent, + templateContent, + }) + + assert.equal( + actual, + [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '.idea/', + '# raijin:end project-specific gitignore', + ].join('\n') + ) +}) + +test('should preserve project-specific lines added outside managed block', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + const existingContent = [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '.custom-above-block/', + '# raijin:begin project-specific gitignore', + '.idea/', + '# raijin:end project-specific gitignore', + '.custom-below-block/', + ].join('\n') + + const actual = mergeGitIgnoreContent({ + existingContent, + templateContent, + }) + + assert.equal( + actual, + [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '.custom-above-block/', + '.idea/', + '.custom-below-block/', + '# raijin:end project-specific gitignore', + ].join('\n') + ) +}) + +test('should preserve project-specific lines in template area before managed block', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + const existingContent = [ + '.custom-top-line/', + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '.idea/', + '# raijin:end project-specific gitignore', + ].join('\n') + + const actual = mergeGitIgnoreContent({ + existingContent, + templateContent, + }) + + assert.equal( + actual, + [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '.custom-top-line/', + '.idea/', + '# raijin:end project-specific gitignore', + ].join('\n') + ) +}) + +test('should preserve project-specific negation order around managed block', () => { + const templateContent = ['node_modules', '.yarn/install-state.gz', 'dist/'].join('\n') + const existingContent = [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '*.log', + '# raijin:begin project-specific gitignore', + '!important.log', + '# raijin:end project-specific gitignore', + ].join('\n') + + const actual = mergeGitIgnoreContent({ + existingContent, + templateContent, + }) + + assert.equal( + actual, + [ + 'node_modules', + '.yarn/install-state.gz', + 'dist/', + '', + '# raijin:begin project-specific gitignore', + '*.log', + '!important.log', + '# raijin:end project-specific gitignore', + ].join('\n') + ) +}) diff --git a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts new file mode 100644 index 000000000..da0fe1174 --- /dev/null +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -0,0 +1,72 @@ +type MergeGitIgnoreContentOptions = { + existingContent: string + templateContent: string +} + +const PROJECT_SPECIFIC_START_MARKER = '# raijin:begin project-specific gitignore' +const PROJECT_SPECIFIC_END_MARKER = '# raijin:end project-specific gitignore' + +const normalizeContent = (content: string): string => content.replace(/\r\n/g, '\n') + +const getNormalizedLines = (content: string): Array => normalizeContent(content).split('\n') + +const trimTrailingEmptyLines = (lines: Array): Array => { + const normalizedLines = [...lines] + + while (normalizedLines.length > 0 && normalizedLines[normalizedLines.length - 1] === '') { + normalizedLines.pop() + } + + return normalizedLines +} + +const isProjectSpecificLine = (line: string, templateLineSet: Set): boolean => + line !== '' && + !templateLineSet.has(line) && + line !== PROJECT_SPECIFIC_START_MARKER && + line !== PROJECT_SPECIFIC_END_MARKER + +const getProjectSpecificLines = ( + existingLines: Array, + templateLineSet: Set +): Array => { + const startIndex = existingLines.indexOf(PROJECT_SPECIFIC_START_MARKER) + const endIndex = existingLines.indexOf(PROJECT_SPECIFIC_END_MARKER) + + if (startIndex !== -1 && endIndex > startIndex) { + return Array.from( + new Set( + existingLines.filter((line) => isProjectSpecificLine(line, templateLineSet)) + ) + ) + } + + return existingLines.filter((line) => isProjectSpecificLine(line, templateLineSet)) +} + +export const mergeGitIgnoreContent = ({ + existingContent, + templateContent, +}: MergeGitIgnoreContentOptions): string => { + const templateLines = getNormalizedLines(templateContent) + const templateLineSet = new Set(templateLines) + const existingLines = getNormalizedLines(existingContent) + + const projectSpecificLines = getProjectSpecificLines(existingLines, templateLineSet) + + if (projectSpecificLines.length === 0) { + return trimTrailingEmptyLines(templateLines).join('\n') + } + + const mergedLines = trimTrailingEmptyLines(templateLines) + + if (mergedLines.length > 0) { + mergedLines.push('') + } + + mergedLines.push(PROJECT_SPECIFIC_START_MARKER) + mergedLines.push(...projectSpecificLines) + mergedLines.push(PROJECT_SPECIFIC_END_MARKER) + + return mergedLines.join('\n') +}