Skip to content
Merged
53 changes: 51 additions & 2 deletions code/code-schematics/src/schematic/project/project.factory.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string, string>): 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<string, string>): Rule => {
const state: { content?: string } = {}

return chain([
captureGitIgnoreContentRule(state),
updateTsConfigRule,
mergeWith(generateCommonSource(options), MergeStrategy.Overwrite),
mergeWith(generateProjectSpecificSource(options), MergeStrategy.Overwrite),
mergeGitIgnoreContentRule(state),
])
}
1 change: 1 addition & 0 deletions code/code-schematics/src/schematic/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
)
})
Original file line number Diff line number Diff line change
@@ -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<string> => normalizeContent(content).split('\n')

const trimTrailingEmptyLines = (lines: Array<string>): Array<string> => {
const normalizedLines = [...lines]

while (normalizedLines.length > 0 && normalizedLines[normalizedLines.length - 1] === '') {
normalizedLines.pop()
}

return normalizedLines
}

const isProjectSpecificLine = (line: string, templateLineSet: Set<string>): boolean =>
line !== '' &&
!templateLineSet.has(line) &&
line !== PROJECT_SPECIFIC_START_MARKER &&
line !== PROJECT_SPECIFIC_END_MARKER

const getProjectSpecificLines = (
existingLines: Array<string>,
templateLineSet: Set<string>
): Array<string> => {
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')
}
Loading