Skip to content

Commit 28262ac

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 28262ac

7 files changed

Lines changed: 367 additions & 2 deletions

File tree

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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
AI_DEVKIT_GITIGNORE_END,
3+
AI_DEVKIT_GITIGNORE_START,
4+
buildManagedGitignoreBody,
5+
mergeAiDevkitGitignoreBlock,
6+
normalizeDocsDirForIgnore,
7+
writeGitignoreWithAiDevkitBlock
8+
} from '../../lib/gitignoreArtifacts';
9+
import * as fs from 'fs-extra';
10+
import * as path from 'path';
11+
12+
jest.mock('fs-extra');
13+
14+
describe('gitignoreArtifacts', () => {
15+
describe('normalizeDocsDirForIgnore', () => {
16+
it('trims and normalizes slashes', () => {
17+
expect(normalizeDocsDirForIgnore(' docs/ai ')).toBe('docs/ai');
18+
expect(normalizeDocsDirForIgnore('docs\\ai')).toBe('docs/ai');
19+
});
20+
21+
it('strips leading ./', () => {
22+
expect(normalizeDocsDirForIgnore('./docs/ai')).toBe('docs/ai');
23+
});
24+
25+
it('rejects empty, absolute, .., and empty segments', () => {
26+
expect(() => normalizeDocsDirForIgnore('')).toThrow();
27+
expect(() => normalizeDocsDirForIgnore(' ')).toThrow();
28+
expect(() => normalizeDocsDirForIgnore('/abs')).toThrow();
29+
expect(() => normalizeDocsDirForIgnore('docs/../x')).toThrow();
30+
expect(() => normalizeDocsDirForIgnore('docs//ai')).toThrow();
31+
});
32+
});
33+
34+
describe('buildManagedGitignoreBody', () => {
35+
it('includes config file and trailing slash on docs dir', () => {
36+
expect(buildManagedGitignoreBody('docs/ai')).toBe('.ai-devkit.json\ndocs/ai/\n');
37+
});
38+
});
39+
40+
describe('mergeAiDevkitGitignoreBlock', () => {
41+
const blockForDocsAi = `${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\ndocs/ai/\n${AI_DEVKIT_GITIGNORE_END}\n`;
42+
43+
it('creates block only when file empty', () => {
44+
expect(mergeAiDevkitGitignoreBlock('', 'docs/ai')).toBe(blockForDocsAi);
45+
});
46+
47+
it('appends block after existing user content', () => {
48+
const user = 'node_modules/\n';
49+
expect(mergeAiDevkitGitignoreBlock(user, 'docs/ai')).toBe(`node_modules/\n\n${blockForDocsAi}`);
50+
});
51+
52+
it('replaces managed block when docs path changes', () => {
53+
const before = `keep-me\n\n${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\nold/\n${AI_DEVKIT_GITIGNORE_END}\n\ntrailer\n`;
54+
const merged = mergeAiDevkitGitignoreBlock(before, 'docs/custom');
55+
expect(merged).toContain('keep-me');
56+
expect(merged).toContain('trailer');
57+
expect(merged).toContain('docs/custom/');
58+
expect(merged).not.toContain('old/');
59+
});
60+
61+
it('is idempotent when content unchanged', () => {
62+
const once = mergeAiDevkitGitignoreBlock('', 'docs/ai');
63+
const twice = mergeAiDevkitGitignoreBlock(once, 'docs/ai');
64+
expect(twice).toBe(once);
65+
});
66+
67+
it('repairs missing end marker by replacing from start', () => {
68+
const broken = `${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\ndocs/ai/\n`;
69+
const merged = mergeAiDevkitGitignoreBlock(broken, 'docs/ai');
70+
expect(merged).toContain(AI_DEVKIT_GITIGNORE_END);
71+
expect(merged.split(AI_DEVKIT_GITIGNORE_START).length - 1).toBe(1);
72+
});
73+
74+
it('preserves content outside the managed block', () => {
75+
const content = `# top\n\n${blockForDocsAi}# bottom\n`;
76+
const merged = mergeAiDevkitGitignoreBlock(content, 'docs/ai');
77+
expect(merged).toContain('# top');
78+
expect(merged).toContain('# bottom');
79+
});
80+
});
81+
82+
describe('writeGitignoreWithAiDevkitBlock', () => {
83+
const mockFs = fs as jest.Mocked<typeof fs>;
84+
85+
beforeEach(() => {
86+
jest.clearAllMocks();
87+
});
88+
89+
it('writes merged content when file missing', async () => {
90+
mockFs.pathExists.mockResolvedValue(false as never);
91+
mockFs.writeFile.mockResolvedValue(undefined as never);
92+
93+
await writeGitignoreWithAiDevkitBlock('/repo', 'docs/ai');
94+
95+
expect(mockFs.pathExists).toHaveBeenCalledWith(path.join('/repo', '.gitignore'));
96+
expect(mockFs.writeFile).toHaveBeenCalledTimes(1);
97+
const written = mockFs.writeFile.mock.calls[0][1] as string;
98+
expect(written).toContain('.ai-devkit.json');
99+
expect(written).toContain('docs/ai/');
100+
});
101+
102+
it('skips write when merge is identical', async () => {
103+
const body = buildManagedGitignoreBody('docs/ai');
104+
const unchanged = `${AI_DEVKIT_GITIGNORE_START}\n${body}${AI_DEVKIT_GITIGNORE_END}\n`;
105+
mockFs.pathExists.mockResolvedValue(true as never);
106+
mockFs.readFile.mockResolvedValue(unchanged as never);
107+
108+
await writeGitignoreWithAiDevkitBlock('/tmp', 'docs/ai');
109+
110+
expect(mockFs.writeFile).not.toHaveBeenCalled();
111+
});
112+
});
113+
});

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 and the AI docs directory to .gitignore (excluded from git)'
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 and your AI docs folder 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}/`);

packages/cli/src/lib/InitTemplate.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,18 @@ export interface InitTemplateConfig {
1717
environments?: EnvironmentCode[];
1818
phases?: Phase[];
1919
skills?: InitTemplateSkill[];
20+
/** When true, add .ai-devkit.json and docs dir to .gitignore after init. */
21+
gitignoreArtifacts?: boolean;
2022
}
2123

22-
const ALLOWED_TEMPLATE_FIELDS = new Set(['version', 'paths', 'environments', 'phases', 'skills']);
24+
const ALLOWED_TEMPLATE_FIELDS = new Set([
25+
'version',
26+
'paths',
27+
'environments',
28+
'phases',
29+
'skills',
30+
'gitignoreArtifacts'
31+
]);
2332

2433
function validationError(templatePath: string, message: string): Error {
2534
return new Error(`Invalid template at ${templatePath}: ${message}`);
@@ -138,6 +147,13 @@ function validateTemplate(raw: unknown, resolvedPath: string): InitTemplateConfi
138147
});
139148
}
140149

150+
if (candidate.gitignoreArtifacts !== undefined) {
151+
if (typeof candidate.gitignoreArtifacts !== 'boolean') {
152+
throw validationError(resolvedPath, '"gitignoreArtifacts" must be a boolean');
153+
}
154+
result.gitignoreArtifacts = candidate.gitignoreArtifacts;
155+
}
156+
141157
if (candidate.skills !== undefined) {
142158
if (!Array.isArray(candidate.skills)) {
143159
throw validationError(resolvedPath, '"skills" must be an array of skill objects');

0 commit comments

Comments
 (0)