Skip to content

Commit 24b56d9

Browse files
committed
fix(init): add .gitkeep files to empty directories
After running openspec init, the specs/, changes/, and changes/archive/ directories are empty. Since git does not track empty directories, these folders are lost when the repository is cloned, causing openspec list to recommend re-initialization. Added .gitkeep file creation to createDirectoryStructure() for both normal and extend modes, ensuring empty directories are preserved in version control. Fixes #269
1 parent afdca0d commit 24b56d9

2 files changed

Lines changed: 52 additions & 14 deletions

File tree

src/core/init.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -453,40 +453,47 @@ export class InitCommand {
453453
// ═══════════════════════════════════════════════════════════
454454

455455
private async createDirectoryStructure(openspecPath: string, extendMode: boolean): Promise<void> {
456+
const directories = [
457+
openspecPath,
458+
path.join(openspecPath, 'specs'),
459+
path.join(openspecPath, 'changes'),
460+
path.join(openspecPath, 'changes', 'archive'),
461+
];
462+
456463
if (extendMode) {
457464
// In extend mode, just ensure directories exist without spinner
458-
const directories = [
459-
openspecPath,
460-
path.join(openspecPath, 'specs'),
461-
path.join(openspecPath, 'changes'),
462-
path.join(openspecPath, 'changes', 'archive'),
463-
];
464-
465465
for (const dir of directories) {
466466
await FileSystemUtils.createDirectory(dir);
467467
}
468+
await this.writeGitkeepFiles(openspecPath);
468469
return;
469470
}
470471

471472
const spinner = this.startSpinner('Creating OpenSpec structure...');
472473

473-
const directories = [
474-
openspecPath,
475-
path.join(openspecPath, 'specs'),
476-
path.join(openspecPath, 'changes'),
477-
path.join(openspecPath, 'changes', 'archive'),
478-
];
479-
480474
for (const dir of directories) {
481475
await FileSystemUtils.createDirectory(dir);
482476
}
483477

478+
await this.writeGitkeepFiles(openspecPath);
479+
484480
spinner.stopAndPersist({
485481
symbol: PALETTE.white('▌'),
486482
text: PALETTE.white('OpenSpec structure created'),
487483
});
488484
}
489485

486+
private async writeGitkeepFiles(openspecPath: string): Promise<void> {
487+
const emptyDirs = [
488+
path.join(openspecPath, 'specs'),
489+
path.join(openspecPath, 'changes'),
490+
path.join(openspecPath, 'changes', 'archive'),
491+
];
492+
for (const dir of emptyDirs) {
493+
await FileSystemUtils.writeFile(path.join(dir, '.gitkeep'), '');
494+
}
495+
}
496+
490497
// ═══════════════════════════════════════════════════════════
491498
// SKILL & COMMAND GENERATION
492499
// ═══════════════════════════════════════════════════════════

test/core/init.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,37 @@ describe('InitCommand', () => {
6565
expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);
6666
});
6767

68+
it('should create .gitkeep files in empty directories', async () => {
69+
const initCommand = new InitCommand({ tools: 'claude', force: true });
70+
71+
await initCommand.execute(testDir);
72+
73+
const openspecPath = path.join(testDir, 'openspec');
74+
expect(await fileExists(path.join(openspecPath, 'specs', '.gitkeep'))).toBe(true);
75+
expect(await fileExists(path.join(openspecPath, 'changes', '.gitkeep'))).toBe(true);
76+
expect(await fileExists(path.join(openspecPath, 'changes', 'archive', '.gitkeep'))).toBe(true);
77+
});
78+
79+
it('should create .gitkeep files in extend mode', async () => {
80+
const initCommand1 = new InitCommand({ tools: 'claude', force: true });
81+
await initCommand1.execute(testDir);
82+
83+
const openspecPath = path.join(testDir, 'openspec');
84+
85+
// Remove .gitkeep files to simulate a cloned repo without them
86+
await fs.unlink(path.join(openspecPath, 'specs', '.gitkeep'));
87+
await fs.unlink(path.join(openspecPath, 'changes', '.gitkeep'));
88+
await fs.unlink(path.join(openspecPath, 'changes', 'archive', '.gitkeep'));
89+
90+
// Re-run init (triggers extend mode since openspec dir already exists)
91+
const initCommand2 = new InitCommand({ tools: 'claude', force: true });
92+
await initCommand2.execute(testDir);
93+
94+
expect(await fileExists(path.join(openspecPath, 'specs', '.gitkeep'))).toBe(true);
95+
expect(await fileExists(path.join(openspecPath, 'changes', '.gitkeep'))).toBe(true);
96+
expect(await fileExists(path.join(openspecPath, 'changes', 'archive', '.gitkeep'))).toBe(true);
97+
});
98+
6899
it('should create config.yaml with default schema', async () => {
69100
const initCommand = new InitCommand({ tools: 'claude', force: true });
70101

0 commit comments

Comments
 (0)