From 921dd55852632512925a96191774786545a939ea Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 03:04:10 +0300 Subject: [PATCH 1/8] fix(code-schematics): preserve project-specific gitignore entries --- .../src/schematic/project/project.factory.ts | 53 ++++++++++++++++++- .../src/schematic/utils/index.ts | 1 + .../merge-gitignore-content.utils.test.ts | 45 ++++++++++++++++ .../utils/merge-gitignore-content.utils.ts | 39 ++++++++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.test.ts create mode 100644 code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts 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..6b063c300 --- /dev/null +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.test.ts @@ -0,0 +1,45 @@ +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/', '', '.idea/', 'coverage/'].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/', '', '.idea/'].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) +}) 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..1c230dcfa --- /dev/null +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -0,0 +1,39 @@ +type MergeGitIgnoreContentOptions = { + existingContent: string + templateContent: string +} + +const trimTrailingEmptyLines = (lines: Array): Array => { + const normalizedLines = [...lines] + + while (normalizedLines.length > 0 && normalizedLines[normalizedLines.length - 1] === '') { + normalizedLines.pop() + } + + return normalizedLines +} + +export const mergeGitIgnoreContent = ({ + existingContent, + templateContent, +}: MergeGitIgnoreContentOptions): string => { + const templateLines = templateContent.split('\n') + const templateLineSet = new Set(templateLines) + const existingLines = existingContent.split('\n') + + const projectSpecificLines = existingLines.filter((line) => !templateLineSet.has(line)) + + if (projectSpecificLines.length === 0) { + return templateContent + } + + const mergedLines = trimTrailingEmptyLines(templateLines) + + if (mergedLines.length > 0) { + mergedLines.push('') + } + + mergedLines.push(...projectSpecificLines) + + return mergedLines.join('\n') +} From 17944f0e7d4aedb87294e4e09853eb1ea1df0288 Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 03:12:57 +0300 Subject: [PATCH 2/8] fix(code-schematics): handle managed and CRLF gitignore merge --- .../merge-gitignore-content.utils.test.ts | 74 ++++++++++++++++++- .../utils/merge-gitignore-content.utils.ts | 31 +++++++- 2 files changed, 99 insertions(+), 6 deletions(-) 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 index 6b063c300..c6b1a1f2e 100644 --- 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 @@ -16,13 +16,30 @@ test('should preserve project-specific entries while keeping template section de assert.equal( actual, - ['node_modules', '.yarn/install-state.gz', 'dist/', '', '.idea/', 'coverage/'].join('\n') + [ + '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/', '', '.idea/'].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, @@ -43,3 +60,56 @@ test('should not duplicate template entries from project content', () => { 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', + '.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') + ) +}) 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 index 1c230dcfa..e430deb93 100644 --- a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -3,6 +3,13 @@ type MergeGitIgnoreContentOptions = { 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] @@ -13,18 +20,32 @@ const trimTrailingEmptyLines = (lines: Array): Array => { return normalizedLines } +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 trimTrailingEmptyLines(existingLines.slice(startIndex + 1, endIndex)) + } + + return existingLines.filter((line) => !templateLineSet.has(line)) +} + export const mergeGitIgnoreContent = ({ existingContent, templateContent, }: MergeGitIgnoreContentOptions): string => { - const templateLines = templateContent.split('\n') + const templateLines = getNormalizedLines(templateContent) const templateLineSet = new Set(templateLines) - const existingLines = existingContent.split('\n') + const existingLines = getNormalizedLines(existingContent) - const projectSpecificLines = existingLines.filter((line) => !templateLineSet.has(line)) + const projectSpecificLines = getProjectSpecificLines(existingLines, templateLineSet) if (projectSpecificLines.length === 0) { - return templateContent + return trimTrailingEmptyLines(templateLines).join('\n') } const mergedLines = trimTrailingEmptyLines(templateLines) @@ -33,7 +54,9 @@ export const mergeGitIgnoreContent = ({ mergedLines.push('') } + mergedLines.push(PROJECT_SPECIFIC_START_MARKER) mergedLines.push(...projectSpecificLines) + mergedLines.push(PROJECT_SPECIFIC_END_MARKER) return mergedLines.join('\n') } From f1bb3ca6558ad0f148eb81a810c0321ff91e81a0 Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 03:32:30 +0300 Subject: [PATCH 3/8] fix(code-schematics): keep custom gitignore entries around managed block --- .../merge-gitignore-content.utils.test.ts | 35 +++++++++++++++++++ .../utils/merge-gitignore-content.utils.ts | 20 ++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) 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 index c6b1a1f2e..7ff8b2da3 100644 --- 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 @@ -113,3 +113,38 @@ test('should normalize CRLF input before comparison', () => { ].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', + '.idea/', + '.custom-above-block/', + '.custom-below-block/', + '# 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 index e430deb93..b9bf54fcb 100644 --- a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -28,7 +28,25 @@ const getProjectSpecificLines = ( const endIndex = existingLines.indexOf(PROJECT_SPECIFIC_END_MARKER) if (startIndex !== -1 && endIndex > startIndex) { - return trimTrailingEmptyLines(existingLines.slice(startIndex + 1, endIndex)) + const linesBeforeBlock = existingLines.slice(0, startIndex) + const linesAfterBlock = existingLines.slice(endIndex + 1) + + const blockSeparatorIndex = linesBeforeBlock.lastIndexOf('') + + const userLinesBeforeBlock = + blockSeparatorIndex >= 0 ? linesBeforeBlock.slice(blockSeparatorIndex + 1) : [] + + const outsideBlockProjectSpecificLines = [...userLinesBeforeBlock, ...linesAfterBlock].filter( + (line) => line !== '' && !templateLineSet.has(line) + ) + + const managedProjectSpecificLines = trimTrailingEmptyLines( + existingLines.slice(startIndex + 1, endIndex) + ) + + return Array.from( + new Set([...managedProjectSpecificLines, ...outsideBlockProjectSpecificLines]) + ) } return existingLines.filter((line) => !templateLineSet.has(line)) From 96546d7f5d2d770115c4549bbaa961c88da18a3a Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 03:45:09 +0300 Subject: [PATCH 4/8] fix(code-schematics): preserve custom lines before managed gitignore block --- .../merge-gitignore-content.utils.test.ts | 34 +++++++++++++++++++ .../utils/merge-gitignore-content.utils.ts | 14 ++++---- 2 files changed, 40 insertions(+), 8 deletions(-) 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 index 7ff8b2da3..eedaa06ae 100644 --- 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 @@ -85,6 +85,7 @@ test('should not keep removed template rules when managed block exists', () => { '.yarn/install-state.gz', '', '# raijin:begin project-specific gitignore', + 'dist/', '.idea/', '# raijin:end project-specific gitignore', ].join('\n') @@ -148,3 +149,36 @@ test('should preserve project-specific lines added outside managed block', () => ].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', + '.idea/', + '.custom-top-line/', + '# 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 index b9bf54fcb..469bc4aee 100644 --- a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -30,14 +30,12 @@ const getProjectSpecificLines = ( if (startIndex !== -1 && endIndex > startIndex) { const linesBeforeBlock = existingLines.slice(0, startIndex) const linesAfterBlock = existingLines.slice(endIndex + 1) - - const blockSeparatorIndex = linesBeforeBlock.lastIndexOf('') - - const userLinesBeforeBlock = - blockSeparatorIndex >= 0 ? linesBeforeBlock.slice(blockSeparatorIndex + 1) : [] - - const outsideBlockProjectSpecificLines = [...userLinesBeforeBlock, ...linesAfterBlock].filter( - (line) => line !== '' && !templateLineSet.has(line) + const outsideBlockProjectSpecificLines = [...linesBeforeBlock, ...linesAfterBlock].filter( + (line) => + line !== '' && + !templateLineSet.has(line) && + line !== PROJECT_SPECIFIC_START_MARKER && + line !== PROJECT_SPECIFIC_END_MARKER ) const managedProjectSpecificLines = trimTrailingEmptyLines( From fdbf6688651c32422b3dc3004ad8651499ba33be Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 03:58:22 +0300 Subject: [PATCH 5/8] fix(code-schematics): preserve gitignore project-specific rule order --- .../schematic/utils/merge-gitignore-content.utils.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) 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 index 469bc4aee..8fcc55158 100644 --- a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -28,23 +28,13 @@ const getProjectSpecificLines = ( const endIndex = existingLines.indexOf(PROJECT_SPECIFIC_END_MARKER) if (startIndex !== -1 && endIndex > startIndex) { - const linesBeforeBlock = existingLines.slice(0, startIndex) - const linesAfterBlock = existingLines.slice(endIndex + 1) - const outsideBlockProjectSpecificLines = [...linesBeforeBlock, ...linesAfterBlock].filter( + return existingLines.filter( (line) => line !== '' && !templateLineSet.has(line) && line !== PROJECT_SPECIFIC_START_MARKER && line !== PROJECT_SPECIFIC_END_MARKER ) - - const managedProjectSpecificLines = trimTrailingEmptyLines( - existingLines.slice(startIndex + 1, endIndex) - ) - - return Array.from( - new Set([...managedProjectSpecificLines, ...outsideBlockProjectSpecificLines]) - ) } return existingLines.filter((line) => !templateLineSet.has(line)) From 518546d0a00a1a897e35fcf1fe96b3402108fb5c Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 04:42:31 +0300 Subject: [PATCH 6/8] fix(code-schematics): preserve managed block priority in gitignore merge --- .../utils/merge-gitignore-content.utils.ts | 87 +++++++++++++++++-- 1 file changed, 80 insertions(+), 7 deletions(-) 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 index 8fcc55158..c3cb52df6 100644 --- a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -20,20 +20,89 @@ const trimTrailingEmptyLines = (lines: Array): Array => { return normalizedLines } +const isProjectSpecificLine = (line: string, templateLineSet: Set): boolean => + line !== '' && + !templateLineSet.has(line) && + line !== PROJECT_SPECIFIC_START_MARKER && + line !== PROJECT_SPECIFIC_END_MARKER + +const getTemplateBoundaryIndices = ( + lines: Array, + templateLines: Array +): { firstMatchedIndex: number; lastMatchedIndex: number } => { + let templateIndex = 0 + let firstMatchedIndex = -1 + let lastMatchedIndex = -1 + + for ( + let lineIndex = 0; + lineIndex < lines.length && templateIndex < templateLines.length; + lineIndex += 1 + ) { + if (lines[lineIndex] === templateLines[templateIndex]) { + if (firstMatchedIndex === -1) { + firstMatchedIndex = lineIndex + } + + lastMatchedIndex = lineIndex + templateIndex += 1 + } + } + + return { + firstMatchedIndex, + lastMatchedIndex, + } +} + const getProjectSpecificLines = ( existingLines: Array, + templateLines: 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 existingLines.filter( - (line) => - line !== '' && - !templateLineSet.has(line) && - line !== PROJECT_SPECIFIC_START_MARKER && - line !== PROJECT_SPECIFIC_END_MARKER + const linesBeforeBlock = existingLines.slice(0, startIndex) + const linesAfterBlock = existingLines.slice(endIndex + 1) + const managedProjectSpecificLines = existingLines + .slice(startIndex + 1, endIndex) + .filter((line) => isProjectSpecificLine(line, templateLineSet)) + const { firstMatchedIndex, lastMatchedIndex } = getTemplateBoundaryIndices( + linesBeforeBlock, + templateLines + ) + const blockSeparatorIndex = linesBeforeBlock.lastIndexOf('') + const removedTemplateTailLines: Array = [] + const outsideBlockProjectSpecificLines: Array = [] + + linesBeforeBlock.forEach((line, lineIndex) => { + if (!isProjectSpecificLine(line, templateLineSet)) { + return + } + + const isAfterSeparator = blockSeparatorIndex !== -1 && lineIndex > blockSeparatorIndex + + if (!isAfterSeparator && firstMatchedIndex !== -1 && lineIndex > lastMatchedIndex) { + removedTemplateTailLines.push(line) + + return + } + + outsideBlockProjectSpecificLines.push(line) + }) + + outsideBlockProjectSpecificLines.push( + ...linesAfterBlock.filter((line) => isProjectSpecificLine(line, templateLineSet)) + ) + + return Array.from( + new Set([ + ...removedTemplateTailLines, + ...managedProjectSpecificLines, + ...outsideBlockProjectSpecificLines, + ]) ) } @@ -48,7 +117,11 @@ export const mergeGitIgnoreContent = ({ const templateLineSet = new Set(templateLines) const existingLines = getNormalizedLines(existingContent) - const projectSpecificLines = getProjectSpecificLines(existingLines, templateLineSet) + const projectSpecificLines = getProjectSpecificLines( + existingLines, + templateLines, + templateLineSet + ) if (projectSpecificLines.length === 0) { return trimTrailingEmptyLines(templateLines).join('\n') From 63d4372b0589fcca49eaa506fa91ba50599a8658 Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 04:57:06 +0300 Subject: [PATCH 7/8] fix(code-schematics): keep gitignore project rule order --- .../merge-gitignore-content.utils.test.ts | 37 ++++++++- .../utils/merge-gitignore-content.utils.ts | 77 +------------------ 2 files changed, 39 insertions(+), 75 deletions(-) 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 index eedaa06ae..60ad1500b 100644 --- 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 @@ -142,8 +142,8 @@ test('should preserve project-specific lines added outside managed block', () => 'dist/', '', '# raijin:begin project-specific gitignore', - '.idea/', '.custom-above-block/', + '.idea/', '.custom-below-block/', '# raijin:end project-specific gitignore', ].join('\n') @@ -176,8 +176,41 @@ test('should preserve project-specific lines in template area before managed blo 'dist/', '', '# raijin:begin project-specific gitignore', - '.idea/', '.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 index c3cb52df6..9f512e762 100644 --- a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -26,83 +26,18 @@ const isProjectSpecificLine = (line: string, templateLineSet: Set): bool line !== PROJECT_SPECIFIC_START_MARKER && line !== PROJECT_SPECIFIC_END_MARKER -const getTemplateBoundaryIndices = ( - lines: Array, - templateLines: Array -): { firstMatchedIndex: number; lastMatchedIndex: number } => { - let templateIndex = 0 - let firstMatchedIndex = -1 - let lastMatchedIndex = -1 - - for ( - let lineIndex = 0; - lineIndex < lines.length && templateIndex < templateLines.length; - lineIndex += 1 - ) { - if (lines[lineIndex] === templateLines[templateIndex]) { - if (firstMatchedIndex === -1) { - firstMatchedIndex = lineIndex - } - - lastMatchedIndex = lineIndex - templateIndex += 1 - } - } - - return { - firstMatchedIndex, - lastMatchedIndex, - } -} - const getProjectSpecificLines = ( existingLines: Array, - templateLines: 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) { - const linesBeforeBlock = existingLines.slice(0, startIndex) - const linesAfterBlock = existingLines.slice(endIndex + 1) - const managedProjectSpecificLines = existingLines - .slice(startIndex + 1, endIndex) - .filter((line) => isProjectSpecificLine(line, templateLineSet)) - const { firstMatchedIndex, lastMatchedIndex } = getTemplateBoundaryIndices( - linesBeforeBlock, - templateLines - ) - const blockSeparatorIndex = linesBeforeBlock.lastIndexOf('') - const removedTemplateTailLines: Array = [] - const outsideBlockProjectSpecificLines: Array = [] - - linesBeforeBlock.forEach((line, lineIndex) => { - if (!isProjectSpecificLine(line, templateLineSet)) { - return - } - - const isAfterSeparator = blockSeparatorIndex !== -1 && lineIndex > blockSeparatorIndex - - if (!isAfterSeparator && firstMatchedIndex !== -1 && lineIndex > lastMatchedIndex) { - removedTemplateTailLines.push(line) - - return - } - - outsideBlockProjectSpecificLines.push(line) - }) - - outsideBlockProjectSpecificLines.push( - ...linesAfterBlock.filter((line) => isProjectSpecificLine(line, templateLineSet)) - ) - return Array.from( - new Set([ - ...removedTemplateTailLines, - ...managedProjectSpecificLines, - ...outsideBlockProjectSpecificLines, - ]) + new Set( + existingLines.filter((line) => isProjectSpecificLine(line, templateLineSet)) + ) ) } @@ -117,11 +52,7 @@ export const mergeGitIgnoreContent = ({ const templateLineSet = new Set(templateLines) const existingLines = getNormalizedLines(existingContent) - const projectSpecificLines = getProjectSpecificLines( - existingLines, - templateLines, - templateLineSet - ) + const projectSpecificLines = getProjectSpecificLines(existingLines, templateLineSet) if (projectSpecificLines.length === 0) { return trimTrailingEmptyLines(templateLines).join('\n') From a9d68ac02e0f2dd4c02ccd4a60d0cfdd73ae89c7 Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Wed, 29 Apr 2026 05:02:17 +0300 Subject: [PATCH 8/8] fix(code-schematics): skip blank gitignore project entries --- .../utils/merge-gitignore-content.utils.test.ts | 11 +++++++++++ .../schematic/utils/merge-gitignore-content.utils.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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 index 60ad1500b..0b4fa4c0e 100644 --- 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 @@ -61,6 +61,17 @@ test('should not duplicate template entries from project content', () => { 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 = [ 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 index 9f512e762..da0fe1174 100644 --- a/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts +++ b/code/code-schematics/src/schematic/utils/merge-gitignore-content.utils.ts @@ -41,7 +41,7 @@ const getProjectSpecificLines = ( ) } - return existingLines.filter((line) => !templateLineSet.has(line)) + return existingLines.filter((line) => isProjectSpecificLine(line, templateLineSet)) } export const mergeGitIgnoreContent = ({