Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/cli/src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const mockConfigManager: any = {
exists: jest.fn(),
read: jest.fn(),
create: jest.fn(),
update: jest.fn(),
setEnvironments: jest.fn(),
addPhase: jest.fn()
};
Expand Down Expand Up @@ -77,23 +78,37 @@ jest.mock('../../lib/InitTemplate', () => ({
loadInitTemplate: (...args: unknown[]) => mockLoadInitTemplate(...args)
}));

jest.mock('../../lib/gitignoreArtifacts', () => ({
writeGitignoreWithAiDevkitBlock: jest.fn(async () => {
/* noop */
})
}));

jest.mock('../../util/terminal-ui', () => ({
ui: mockUi
}));

import { initCommand } from '../../commands/init';
import { writeGitignoreWithAiDevkitBlock } from '../../lib/gitignoreArtifacts';

const mockWriteGitignore = writeGitignoreWithAiDevkitBlock as jest.MockedFunction<
typeof writeGitignoreWithAiDevkitBlock
>;

describe('init command template mode', () => {
beforeEach(() => {
jest.clearAllMocks();
process.exitCode = undefined;
mockWriteGitignore.mockClear();
mockWriteGitignore.mockResolvedValue(undefined);

mockExecSync.mockReturnValue(undefined);
mockPrompt.mockResolvedValue({});

mockConfigManager.exists.mockResolvedValue(false);
mockConfigManager.read.mockResolvedValue(null);
mockConfigManager.create.mockResolvedValue({ environments: [], phases: [] });
mockConfigManager.update.mockResolvedValue(undefined);
mockConfigManager.setEnvironments.mockResolvedValue(undefined);
mockConfigManager.addPhase.mockResolvedValue(undefined);

Expand Down Expand Up @@ -195,4 +210,50 @@ describe('init command template mode', () => {
expect(process.exitCode).toBe(1);
expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled();
});

it('updates .gitignore when template sets gitignoreArtifacts true', async () => {
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/my/repo');
mockLoadInitTemplate.mockResolvedValue({
environments: ['codex'],
phases: ['requirements'],
gitignoreArtifacts: true,
paths: { docs: 'custom-ai-docs' }
});

await initCommand({ template: './init.yaml' });

expect(mockWriteGitignore).toHaveBeenCalledTimes(1);
expect(mockWriteGitignore).toHaveBeenCalledWith('/my/repo', 'custom-ai-docs');
expect(mockUi.success).toHaveBeenCalledWith(
'Updated .gitignore to exclude AI DevKit artifacts (not shared via git).'
);

cwdSpy.mockRestore();
});

it('does not update .gitignore when template sets gitignoreArtifacts false', async () => {
mockLoadInitTemplate.mockResolvedValue({
environments: ['codex'],
phases: ['requirements'],
gitignoreArtifacts: false
});

await initCommand({ template: './init.yaml' });

expect(mockWriteGitignore).not.toHaveBeenCalled();
});

it('updates .gitignore when --gitignore-artifacts is passed', async () => {
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/my/repo');
mockLoadInitTemplate.mockResolvedValue({
environments: ['codex'],
phases: ['requirements']
});

await initCommand({ template: './init.yaml', gitignoreArtifacts: true });

expect(mockWriteGitignore).toHaveBeenCalledWith('/my/repo', 'docs/ai');

cwdSpy.mockRestore();
});
});
26 changes: 26 additions & 0 deletions packages/cli/src/__tests__/lib/InitTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ paths:
);
});

it('loads gitignoreArtifacts boolean from YAML', async () => {
mockFs.pathExists.mockResolvedValue(true as never);
mockFs.readFile.mockResolvedValue(`
environments:
- codex
phases:
- requirements
gitignoreArtifacts: true
` as never);

const result = await loadInitTemplate('/tmp/init.yaml');

expect(result.gitignoreArtifacts).toBe(true);
});

it('throws when gitignoreArtifacts is not boolean', async () => {
mockFs.pathExists.mockResolvedValue(true as never);
mockFs.readFile.mockResolvedValue(`
gitignoreArtifacts: "yes"
` as never);

await expect(loadInitTemplate('/tmp/init.yaml')).rejects.toThrow(
'"gitignoreArtifacts" must be a boolean'
);
});

it('throws when unknown field exists', async () => {
mockFs.pathExists.mockResolvedValue(true as never);
mockFs.readFile.mockResolvedValue(`
Expand Down
155 changes: 155 additions & 0 deletions packages/cli/src/__tests__/lib/gitignoreArtifacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {
AI_DEVKIT_GITIGNORE_END,
AI_DEVKIT_GITIGNORE_START,
buildManagedGitignoreBody,
collectInitCommandPathIgnorePatterns,
commandPathToGitignoreDirPattern,
mergeAiDevkitGitignoreBlock,
normalizeDocsDirForIgnore,
writeGitignoreWithAiDevkitBlock
} from '../../lib/gitignoreArtifacts';
import * as fs from 'fs-extra';
import * as path from 'path';

jest.mock('fs-extra');

describe('gitignoreArtifacts', () => {
describe('normalizeDocsDirForIgnore', () => {
it('trims and normalizes slashes', () => {
expect(normalizeDocsDirForIgnore(' docs/ai ')).toBe('docs/ai');
expect(normalizeDocsDirForIgnore('docs\\ai')).toBe('docs/ai');
});

it('strips leading ./', () => {
expect(normalizeDocsDirForIgnore('./docs/ai')).toBe('docs/ai');
});

it('rejects empty, absolute, .., and empty segments', () => {
expect(() => normalizeDocsDirForIgnore('')).toThrow();
expect(() => normalizeDocsDirForIgnore(' ')).toThrow();
expect(() => normalizeDocsDirForIgnore('/abs')).toThrow();
expect(() => normalizeDocsDirForIgnore('docs/../x')).toThrow();
expect(() => normalizeDocsDirForIgnore('docs//ai')).toThrow();
});
});

describe('commandPathToGitignoreDirPattern', () => {
it('normalizes to a directory line with trailing slash', () => {
expect(commandPathToGitignoreDirPattern('.cursor/commands')).toBe('.cursor/commands/');
expect(commandPathToGitignoreDirPattern('./.opencode/commands')).toBe('.opencode/commands/');
});

it('returns null for unsafe paths', () => {
expect(commandPathToGitignoreDirPattern('')).toBeNull();
expect(commandPathToGitignoreDirPattern('docs/../x')).toBeNull();
expect(commandPathToGitignoreDirPattern('/abs/path')).toBeNull();
});
});

describe('collectInitCommandPathIgnorePatterns', () => {
it('matches every environment commandPath and excludes skills / global paths', () => {
const patterns = collectInitCommandPathIgnorePatterns();
expect(patterns).toEqual([
'.agent/workflows/',
'.agents/commands/',
'.claude/commands/',
'.codex/commands/',
'.cursor/commands/',
'.gemini/commands/',
'.github/prompts/',
'.kilocode/commands/',
'.opencode/commands/',
'.roo/commands/',
'.windsurf/commands/'
]);
expect(patterns).not.toContain('.cursor/skills/');
expect(patterns).not.toContain('.opencode/skills/');
expect(patterns).not.toContain('.gemini/antigravity/');
expect(patterns).not.toContain('.codex/prompts/');
});
});

describe('buildManagedGitignoreBody', () => {
it('includes config file, docs dir, and command directories only', () => {
const body = buildManagedGitignoreBody('docs/ai');
expect(body).toMatch(/^\.ai-devkit\.json\ndocs\/ai\/\n/);
expect(body).toContain('.cursor/commands/');
expect(body).toContain('.opencode/commands/');
expect(body).not.toContain('.cursor/skills');
});
});

describe('mergeAiDevkitGitignoreBlock', () => {
const blockForDocsAi = `${AI_DEVKIT_GITIGNORE_START}\n${buildManagedGitignoreBody('docs/ai')}${AI_DEVKIT_GITIGNORE_END}\n`;

it('creates block only when file empty', () => {
expect(mergeAiDevkitGitignoreBlock('', 'docs/ai')).toBe(blockForDocsAi);
});

it('appends block after existing user content', () => {
const user = 'node_modules/\n';
expect(mergeAiDevkitGitignoreBlock(user, 'docs/ai')).toBe(`node_modules/\n\n${blockForDocsAi}`);
});

it('replaces managed block when docs path changes', () => {
const before = `keep-me\n\n${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\nold/\n${AI_DEVKIT_GITIGNORE_END}\n\ntrailer\n`;
const merged = mergeAiDevkitGitignoreBlock(before, 'docs/custom');
expect(merged).toContain('keep-me');
expect(merged).toContain('trailer');
expect(merged).toContain('docs/custom/');
expect(merged).not.toContain('old/');
});

it('is idempotent when content unchanged', () => {
const once = mergeAiDevkitGitignoreBlock('', 'docs/ai');
const twice = mergeAiDevkitGitignoreBlock(once, 'docs/ai');
expect(twice).toBe(once);
});

it('repairs missing end marker by replacing from start', () => {
const broken = `${AI_DEVKIT_GITIGNORE_START}\n.ai-devkit.json\ndocs/ai/\n`;
const merged = mergeAiDevkitGitignoreBlock(broken, 'docs/ai');
expect(merged).toContain(AI_DEVKIT_GITIGNORE_END);
expect(merged.split(AI_DEVKIT_GITIGNORE_START).length - 1).toBe(1);
});

it('preserves content outside the managed block', () => {
const content = `# top\n\n${blockForDocsAi}# bottom\n`;
const merged = mergeAiDevkitGitignoreBlock(content, 'docs/ai');
expect(merged).toContain('# top');
expect(merged).toContain('# bottom');
});
});

describe('writeGitignoreWithAiDevkitBlock', () => {
const mockFs = fs as jest.Mocked<typeof fs>;

beforeEach(() => {
jest.clearAllMocks();
});

it('writes merged content when file missing', async () => {
mockFs.pathExists.mockResolvedValue(false as never);
mockFs.writeFile.mockResolvedValue(undefined as never);

await writeGitignoreWithAiDevkitBlock('/repo', 'docs/ai');

expect(mockFs.pathExists).toHaveBeenCalledWith(path.join('/repo', '.gitignore'));
expect(mockFs.writeFile).toHaveBeenCalledTimes(1);
const written = mockFs.writeFile.mock.calls[0][1] as string;
expect(written).toContain('.ai-devkit.json');
expect(written).toContain('docs/ai/');
});

it('skips write when merge is identical', async () => {
const body = buildManagedGitignoreBody('docs/ai');
const unchanged = `${AI_DEVKIT_GITIGNORE_START}\n${body}${AI_DEVKIT_GITIGNORE_END}\n`;
mockFs.pathExists.mockResolvedValue(true as never);
mockFs.readFile.mockResolvedValue(unchanged as never);

await writeGitignoreWithAiDevkitBlock('/tmp', 'docs/ai');

expect(mockFs.writeFile).not.toHaveBeenCalled();
});
});
});
4 changes: 4 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ program
.option('-p, --phases <phases>', 'Comma-separated list of phases to initialize')
.option('-t, --template <path>', 'Initialize from template file (.yaml, .yml, .json)')
.option('-d, --docs-dir <path>', 'Custom directory for AI documentation (default: docs/ai)')
.option(
'--gitignore-artifacts',
'Add .ai-devkit.json, the AI docs directory, and ai-devkit command folders (per-tool commandPath) to .gitignore'
)
.action(initCommand);

program
Expand Down
60 changes: 59 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { TemplateManager } from '../lib/TemplateManager';
import { EnvironmentSelector } from '../lib/EnvironmentSelector';
import { PhaseSelector } from '../lib/PhaseSelector';
import { SkillManager } from '../lib/SkillManager';
import { loadInitTemplate, InitTemplateSkill } from '../lib/InitTemplate';
import { loadInitTemplate, InitTemplateConfig, InitTemplateSkill } from '../lib/InitTemplate';
import { writeGitignoreWithAiDevkitBlock } from '../lib/gitignoreArtifacts';
import { EnvironmentCode, PHASE_DISPLAY_NAMES, Phase, DEFAULT_DOCS_DIR } from '../types';
import { isValidEnvironmentCode } from '../util/env';
import { ui } from '../util/terminal-ui';
Expand All @@ -19,6 +20,18 @@ function isGitAvailable(): boolean {
}
}

function isInsideGitWorkTree(): boolean {
if (!isGitAvailable()) {
return false;
}
try {
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}

function ensureGitRepository(): void {
if (!isGitAvailable()) {
ui.warning(
Expand Down Expand Up @@ -47,6 +60,35 @@ interface InitOptions {
phases?: string;
template?: string;
docsDir?: string;
gitignoreArtifacts?: boolean;
}

async function resolveShouldGitignoreArtifacts(
options: InitOptions,
templateConfig: InitTemplateConfig | null
): Promise<boolean> {
if (options.gitignoreArtifacts === true) {
return true;
}
if (templateConfig?.gitignoreArtifacts === true) {
return true;
}
if (templateConfig?.gitignoreArtifacts === false) {
return false;
}
if (process.stdin.isTTY) {
const { addGitignore } = await inquirer.prompt([
{
type: 'confirm',
name: 'addGitignore',
message:
'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.',
default: false
}
]);
return Boolean(addGitignore);
}
return false;
}

function normalizeEnvironmentOption(
Expand Down Expand Up @@ -300,6 +342,22 @@ export async function initCommand(options: InitOptions) {
}
}

const shouldGitignore = await resolveShouldGitignoreArtifacts(options, templateConfig);
if (shouldGitignore) {
if (!isInsideGitWorkTree()) {
ui.warning('Not inside a git repository; skipped updating .gitignore for AI DevKit artifacts.');
} else {
try {
await writeGitignoreWithAiDevkitBlock(process.cwd(), docsDir);
ui.success('Updated .gitignore to exclude AI DevKit artifacts (not shared via git).');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ui.error(`Failed to update .gitignore: ${message}`);
process.exitCode = 1;
}
}
}

ui.text('AI DevKit initialized successfully!', { breakline: true });
ui.info('Next steps:');
ui.text(` • Review and customize templates in ${docsDir}/`);
Expand Down
Loading