|
| 1 | +import { |
| 2 | + AI_DEVKIT_GITIGNORE_END, |
| 3 | + AI_DEVKIT_GITIGNORE_START, |
| 4 | + buildManagedGitignoreBody, |
| 5 | + collectInitCommandPathIgnorePatterns, |
| 6 | + commandPathToGitignoreDirPattern, |
| 7 | + mergeAiDevkitGitignoreBlock, |
| 8 | + normalizeDocsDirForIgnore, |
| 9 | + writeGitignoreWithAiDevkitBlock |
| 10 | +} from '../../lib/gitignoreArtifacts'; |
| 11 | +import * as fs from 'fs-extra'; |
| 12 | +import * as path from 'path'; |
| 13 | + |
| 14 | +jest.mock('fs-extra'); |
| 15 | + |
| 16 | +describe('gitignoreArtifacts', () => { |
| 17 | + describe('normalizeDocsDirForIgnore', () => { |
| 18 | + it('trims and normalizes slashes', () => { |
| 19 | + expect(normalizeDocsDirForIgnore(' docs/ai ')).toBe('docs/ai'); |
| 20 | + expect(normalizeDocsDirForIgnore('docs\\ai')).toBe('docs/ai'); |
| 21 | + }); |
| 22 | + |
| 23 | + it('strips leading ./', () => { |
| 24 | + expect(normalizeDocsDirForIgnore('./docs/ai')).toBe('docs/ai'); |
| 25 | + }); |
| 26 | + |
| 27 | + it('rejects empty, absolute, .., and empty segments', () => { |
| 28 | + expect(() => normalizeDocsDirForIgnore('')).toThrow(); |
| 29 | + expect(() => normalizeDocsDirForIgnore(' ')).toThrow(); |
| 30 | + expect(() => normalizeDocsDirForIgnore('/abs')).toThrow(); |
| 31 | + expect(() => normalizeDocsDirForIgnore('docs/../x')).toThrow(); |
| 32 | + expect(() => normalizeDocsDirForIgnore('docs//ai')).toThrow(); |
| 33 | + }); |
| 34 | + }); |
| 35 | + |
| 36 | + describe('commandPathToGitignoreDirPattern', () => { |
| 37 | + it('normalizes to a directory line with trailing slash', () => { |
| 38 | + expect(commandPathToGitignoreDirPattern('.cursor/commands')).toBe('.cursor/commands/'); |
| 39 | + expect(commandPathToGitignoreDirPattern('./.opencode/commands')).toBe('.opencode/commands/'); |
| 40 | + }); |
| 41 | + |
| 42 | + it('returns null for unsafe paths', () => { |
| 43 | + expect(commandPathToGitignoreDirPattern('')).toBeNull(); |
| 44 | + expect(commandPathToGitignoreDirPattern('docs/../x')).toBeNull(); |
| 45 | + expect(commandPathToGitignoreDirPattern('/abs/path')).toBeNull(); |
| 46 | + }); |
| 47 | + }); |
| 48 | + |
| 49 | + describe('collectInitCommandPathIgnorePatterns', () => { |
| 50 | + it('matches every environment commandPath and excludes skills / global paths', () => { |
| 51 | + const patterns = collectInitCommandPathIgnorePatterns(); |
| 52 | + expect(patterns).toEqual([ |
| 53 | + '.agent/workflows/', |
| 54 | + '.agents/commands/', |
| 55 | + '.claude/commands/', |
| 56 | + '.codex/commands/', |
| 57 | + '.cursor/commands/', |
| 58 | + '.gemini/commands/', |
| 59 | + '.github/prompts/', |
| 60 | + '.kilocode/commands/', |
| 61 | + '.opencode/commands/', |
| 62 | + '.roo/commands/', |
| 63 | + '.windsurf/commands/' |
| 64 | + ]); |
| 65 | + expect(patterns).not.toContain('.cursor/skills/'); |
| 66 | + expect(patterns).not.toContain('.opencode/skills/'); |
| 67 | + expect(patterns).not.toContain('.gemini/antigravity/'); |
| 68 | + expect(patterns).not.toContain('.codex/prompts/'); |
| 69 | + }); |
| 70 | + }); |
| 71 | + |
| 72 | + describe('buildManagedGitignoreBody', () => { |
| 73 | + it('includes config file, docs dir, and command directories only', () => { |
| 74 | + const body = buildManagedGitignoreBody('docs/ai'); |
| 75 | + expect(body).toMatch(/^\.ai-devkit\.json\ndocs\/ai\/\n/); |
| 76 | + expect(body).toContain('.cursor/commands/'); |
| 77 | + expect(body).toContain('.opencode/commands/'); |
| 78 | + expect(body).not.toContain('.cursor/skills'); |
| 79 | + }); |
| 80 | + }); |
| 81 | + |
| 82 | + describe('mergeAiDevkitGitignoreBlock', () => { |
| 83 | + const blockForDocsAi = `${AI_DEVKIT_GITIGNORE_START}\n${buildManagedGitignoreBody('docs/ai')}${AI_DEVKIT_GITIGNORE_END}\n`; |
| 84 | + |
| 85 | + it('creates block only when file empty', () => { |
| 86 | + expect(mergeAiDevkitGitignoreBlock('', 'docs/ai')).toBe(blockForDocsAi); |
| 87 | + }); |
| 88 | + |
| 89 | + it('appends block after existing user content', () => { |
| 90 | + const user = 'node_modules/\n'; |
| 91 | + expect(mergeAiDevkitGitignoreBlock(user, 'docs/ai')).toBe(`node_modules/\n\n${blockForDocsAi}`); |
| 92 | + }); |
| 93 | + |
| 94 | + it('replaces managed block when docs path changes', () => { |
| 95 | + const before = `keep-me\n\n${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\nold/\n${AI_DEVKIT_GITIGNORE_END}\n\ntrailer\n`; |
| 96 | + const merged = mergeAiDevkitGitignoreBlock(before, 'docs/custom'); |
| 97 | + expect(merged).toContain('keep-me'); |
| 98 | + expect(merged).toContain('trailer'); |
| 99 | + expect(merged).toContain('docs/custom/'); |
| 100 | + expect(merged).not.toContain('old/'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('is idempotent when content unchanged', () => { |
| 104 | + const once = mergeAiDevkitGitignoreBlock('', 'docs/ai'); |
| 105 | + const twice = mergeAiDevkitGitignoreBlock(once, 'docs/ai'); |
| 106 | + expect(twice).toBe(once); |
| 107 | + }); |
| 108 | + |
| 109 | + it('repairs missing end marker by replacing from start', () => { |
| 110 | + const broken = `${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\ndocs/ai/\n`; |
| 111 | + const merged = mergeAiDevkitGitignoreBlock(broken, 'docs/ai'); |
| 112 | + expect(merged).toContain(AI_DEVKIT_GITIGNORE_END); |
| 113 | + expect(merged.split(AI_DEVKIT_GITIGNORE_START).length - 1).toBe(1); |
| 114 | + }); |
| 115 | + |
| 116 | + it('preserves content outside the managed block', () => { |
| 117 | + const content = `# top\n\n${blockForDocsAi}# bottom\n`; |
| 118 | + const merged = mergeAiDevkitGitignoreBlock(content, 'docs/ai'); |
| 119 | + expect(merged).toContain('# top'); |
| 120 | + expect(merged).toContain('# bottom'); |
| 121 | + }); |
| 122 | + }); |
| 123 | + |
| 124 | + describe('writeGitignoreWithAiDevkitBlock', () => { |
| 125 | + const mockFs = fs as jest.Mocked<typeof fs>; |
| 126 | + |
| 127 | + beforeEach(() => { |
| 128 | + jest.clearAllMocks(); |
| 129 | + }); |
| 130 | + |
| 131 | + it('writes merged content when file missing', async () => { |
| 132 | + mockFs.pathExists.mockResolvedValue(false as never); |
| 133 | + mockFs.writeFile.mockResolvedValue(undefined as never); |
| 134 | + |
| 135 | + await writeGitignoreWithAiDevkitBlock('/repo', 'docs/ai'); |
| 136 | + |
| 137 | + expect(mockFs.pathExists).toHaveBeenCalledWith(path.join('/repo', '.gitignore')); |
| 138 | + expect(mockFs.writeFile).toHaveBeenCalledTimes(1); |
| 139 | + const written = mockFs.writeFile.mock.calls[0][1] as string; |
| 140 | + expect(written).toContain('.ai-devkit.json'); |
| 141 | + expect(written).toContain('docs/ai/'); |
| 142 | + }); |
| 143 | + |
| 144 | + it('skips write when merge is identical', async () => { |
| 145 | + const body = buildManagedGitignoreBody('docs/ai'); |
| 146 | + const unchanged = `${AI_DEVKIT_GITIGNORE_START}\n${body}${AI_DEVKIT_GITIGNORE_END}\n`; |
| 147 | + mockFs.pathExists.mockResolvedValue(true as never); |
| 148 | + mockFs.readFile.mockResolvedValue(unchanged as never); |
| 149 | + |
| 150 | + await writeGitignoreWithAiDevkitBlock('/tmp', 'docs/ai'); |
| 151 | + |
| 152 | + expect(mockFs.writeFile).not.toHaveBeenCalled(); |
| 153 | + }); |
| 154 | + }); |
| 155 | +}); |
0 commit comments