Skip to content

Commit 5e2758a

Browse files
author
cuong
committed
feat(cli): optional .gitignore for AI DevKit artifacts on init
Add managed .gitignore block for .ai-devkit.json and the docs dir, with --gitignore-artifacts, init template gitignoreArtifacts, and interactive prompt (TTY, default off). Made-with: Cursor
1 parent 96a9529 commit 5e2758a

File tree

7 files changed

+453
-2
lines changed

7 files changed

+453
-2
lines changed

packages/cli/src/__tests__/commands/init.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const mockConfigManager: any = {
44
exists: jest.fn(),
55
read: jest.fn(),
66
create: jest.fn(),
7+
update: jest.fn(),
78
setEnvironments: jest.fn(),
89
addPhase: jest.fn()
910
};
@@ -77,23 +78,37 @@ jest.mock('../../lib/InitTemplate', () => ({
7778
loadInitTemplate: (...args: unknown[]) => mockLoadInitTemplate(...args)
7879
}));
7980

81+
jest.mock('../../lib/gitignoreArtifacts', () => ({
82+
writeGitignoreWithAiDevkitBlock: jest.fn(async () => {
83+
/* noop */
84+
})
85+
}));
86+
8087
jest.mock('../../util/terminal-ui', () => ({
8188
ui: mockUi
8289
}));
8390

8491
import { initCommand } from '../../commands/init';
92+
import { writeGitignoreWithAiDevkitBlock } from '../../lib/gitignoreArtifacts';
93+
94+
const mockWriteGitignore = writeGitignoreWithAiDevkitBlock as jest.MockedFunction<
95+
typeof writeGitignoreWithAiDevkitBlock
96+
>;
8597

8698
describe('init command template mode', () => {
8799
beforeEach(() => {
88100
jest.clearAllMocks();
89101
process.exitCode = undefined;
102+
mockWriteGitignore.mockClear();
103+
mockWriteGitignore.mockResolvedValue(undefined);
90104

91105
mockExecSync.mockReturnValue(undefined);
92106
mockPrompt.mockResolvedValue({});
93107

94108
mockConfigManager.exists.mockResolvedValue(false);
95109
mockConfigManager.read.mockResolvedValue(null);
96110
mockConfigManager.create.mockResolvedValue({ environments: [], phases: [] });
111+
mockConfigManager.update.mockResolvedValue(undefined);
97112
mockConfigManager.setEnvironments.mockResolvedValue(undefined);
98113
mockConfigManager.addPhase.mockResolvedValue(undefined);
99114

@@ -195,4 +210,50 @@ describe('init command template mode', () => {
195210
expect(process.exitCode).toBe(1);
196211
expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled();
197212
});
213+
214+
it('updates .gitignore when template sets gitignoreArtifacts true', async () => {
215+
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/my/repo');
216+
mockLoadInitTemplate.mockResolvedValue({
217+
environments: ['codex'],
218+
phases: ['requirements'],
219+
gitignoreArtifacts: true,
220+
paths: { docs: 'custom-ai-docs' }
221+
});
222+
223+
await initCommand({ template: './init.yaml' });
224+
225+
expect(mockWriteGitignore).toHaveBeenCalledTimes(1);
226+
expect(mockWriteGitignore).toHaveBeenCalledWith('/my/repo', 'custom-ai-docs');
227+
expect(mockUi.success).toHaveBeenCalledWith(
228+
'Updated .gitignore to exclude AI DevKit artifacts (not shared via git).'
229+
);
230+
231+
cwdSpy.mockRestore();
232+
});
233+
234+
it('does not update .gitignore when template sets gitignoreArtifacts false', async () => {
235+
mockLoadInitTemplate.mockResolvedValue({
236+
environments: ['codex'],
237+
phases: ['requirements'],
238+
gitignoreArtifacts: false
239+
});
240+
241+
await initCommand({ template: './init.yaml' });
242+
243+
expect(mockWriteGitignore).not.toHaveBeenCalled();
244+
});
245+
246+
it('updates .gitignore when --gitignore-artifacts is passed', async () => {
247+
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/my/repo');
248+
mockLoadInitTemplate.mockResolvedValue({
249+
environments: ['codex'],
250+
phases: ['requirements']
251+
});
252+
253+
await initCommand({ template: './init.yaml', gitignoreArtifacts: true });
254+
255+
expect(mockWriteGitignore).toHaveBeenCalledWith('/my/repo', 'docs/ai');
256+
257+
cwdSpy.mockRestore();
258+
});
198259
});

packages/cli/src/__tests__/lib/InitTemplate.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,32 @@ paths:
126126
);
127127
});
128128

129+
it('loads gitignoreArtifacts boolean from YAML', async () => {
130+
mockFs.pathExists.mockResolvedValue(true as never);
131+
mockFs.readFile.mockResolvedValue(`
132+
environments:
133+
- codex
134+
phases:
135+
- requirements
136+
gitignoreArtifacts: true
137+
` as never);
138+
139+
const result = await loadInitTemplate('/tmp/init.yaml');
140+
141+
expect(result.gitignoreArtifacts).toBe(true);
142+
});
143+
144+
it('throws when gitignoreArtifacts is not boolean', async () => {
145+
mockFs.pathExists.mockResolvedValue(true as never);
146+
mockFs.readFile.mockResolvedValue(`
147+
gitignoreArtifacts: "yes"
148+
` as never);
149+
150+
await expect(loadInitTemplate('/tmp/init.yaml')).rejects.toThrow(
151+
'"gitignoreArtifacts" must be a boolean'
152+
);
153+
});
154+
129155
it('throws when unknown field exists', async () => {
130156
mockFs.pathExists.mockResolvedValue(true as never);
131157
mockFs.readFile.mockResolvedValue(`
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
});

packages/cli/src/cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ program
2727
.option('-p, --phases <phases>', 'Comma-separated list of phases to initialize')
2828
.option('-t, --template <path>', 'Initialize from template file (.yaml, .yml, .json)')
2929
.option('-d, --docs-dir <path>', 'Custom directory for AI documentation (default: docs/ai)')
30+
.option(
31+
'--gitignore-artifacts',
32+
'Add .ai-devkit.json, the AI docs directory, and ai-devkit command folders (per-tool commandPath) to .gitignore'
33+
)
3034
.action(initCommand);
3135

3236
program

packages/cli/src/commands/init.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { TemplateManager } from '../lib/TemplateManager';
55
import { EnvironmentSelector } from '../lib/EnvironmentSelector';
66
import { PhaseSelector } from '../lib/PhaseSelector';
77
import { SkillManager } from '../lib/SkillManager';
8-
import { loadInitTemplate, InitTemplateSkill } from '../lib/InitTemplate';
8+
import { loadInitTemplate, InitTemplateConfig, InitTemplateSkill } from '../lib/InitTemplate';
9+
import { writeGitignoreWithAiDevkitBlock } from '../lib/gitignoreArtifacts';
910
import { EnvironmentCode, PHASE_DISPLAY_NAMES, Phase, DEFAULT_DOCS_DIR } from '../types';
1011
import { isValidEnvironmentCode } from '../util/env';
1112
import { ui } from '../util/terminal-ui';
@@ -19,6 +20,18 @@ function isGitAvailable(): boolean {
1920
}
2021
}
2122

23+
function isInsideGitWorkTree(): boolean {
24+
if (!isGitAvailable()) {
25+
return false;
26+
}
27+
try {
28+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
29+
return true;
30+
} catch {
31+
return false;
32+
}
33+
}
34+
2235
function ensureGitRepository(): void {
2336
if (!isGitAvailable()) {
2437
ui.warning(
@@ -47,6 +60,35 @@ interface InitOptions {
4760
phases?: string;
4861
template?: string;
4962
docsDir?: string;
63+
gitignoreArtifacts?: boolean;
64+
}
65+
66+
async function resolveShouldGitignoreArtifacts(
67+
options: InitOptions,
68+
templateConfig: InitTemplateConfig | null
69+
): Promise<boolean> {
70+
if (options.gitignoreArtifacts === true) {
71+
return true;
72+
}
73+
if (templateConfig?.gitignoreArtifacts === true) {
74+
return true;
75+
}
76+
if (templateConfig?.gitignoreArtifacts === false) {
77+
return false;
78+
}
79+
if (process.stdin.isTTY) {
80+
const { addGitignore } = await inquirer.prompt([
81+
{
82+
type: 'confirm',
83+
name: 'addGitignore',
84+
message:
85+
'Add .ai-devkit.json, your AI docs folder, and installed slash-command folders (e.g. .cursor/commands, .opencode/commands) to .gitignore? They will not be shared when you push to git.',
86+
default: false
87+
}
88+
]);
89+
return Boolean(addGitignore);
90+
}
91+
return false;
5092
}
5193

5294
function normalizeEnvironmentOption(
@@ -300,6 +342,22 @@ export async function initCommand(options: InitOptions) {
300342
}
301343
}
302344

345+
const shouldGitignore = await resolveShouldGitignoreArtifacts(options, templateConfig);
346+
if (shouldGitignore) {
347+
if (!isInsideGitWorkTree()) {
348+
ui.warning('Not inside a git repository; skipped updating .gitignore for AI DevKit artifacts.');
349+
} else {
350+
try {
351+
await writeGitignoreWithAiDevkitBlock(process.cwd(), docsDir);
352+
ui.success('Updated .gitignore to exclude AI DevKit artifacts (not shared via git).');
353+
} catch (error) {
354+
const message = error instanceof Error ? error.message : String(error);
355+
ui.error(`Failed to update .gitignore: ${message}`);
356+
process.exitCode = 1;
357+
}
358+
}
359+
}
360+
303361
ui.text('AI DevKit initialized successfully!', { breakline: true });
304362
ui.info('Next steps:');
305363
ui.text(` • Review and customize templates in ${docsDir}/`);

0 commit comments

Comments
 (0)