Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/safe-init-directory-anchors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

Preserve empty OpenSpec directories in Git after initialization. Re-running init restores missing directory markers without overwriting existing files or following marker symlinks.
31 changes: 17 additions & 14 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
storePointerProblem,
} from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
import {
AI_TOOLS,
Expand Down Expand Up @@ -856,42 +857,44 @@ export class InitCommand {
// ═══════════════════════════════════════════════════════════

private async createDirectoryStructure(openspecPath: string, extendMode: boolean): Promise<void> {
const directories = [
openspecPath,
path.join(openspecPath, 'specs'),
path.join(openspecPath, 'changes'),
path.join(openspecPath, 'changes', 'archive'),
];

if (extendMode) {
// In extend mode, just ensure directories exist without spinner
const directories = [
openspecPath,
path.join(openspecPath, 'specs'),
path.join(openspecPath, 'changes'),
path.join(openspecPath, 'changes', 'archive'),
];

for (const dir of directories) {
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
await FileSystemUtils.createDirectory(dir);
}
await this.writeGitkeepFiles(openspecPath);
return;
}

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

const directories = [
openspecPath,
path.join(openspecPath, 'specs'),
path.join(openspecPath, 'changes'),
path.join(openspecPath, 'changes', 'archive'),
];

for (const dir of directories) {
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
await FileSystemUtils.createDirectory(dir);
}

await this.writeGitkeepFiles(openspecPath);

spinner.stopAndPersist({
symbol: PALETTE.white('▌'),
text: PALETTE.white('OpenSpec structure created'),
});
}

private async writeGitkeepFiles(openspecPath: string): Promise<void> {
for (const relativeDir of ANCHORED_OPENSPEC_DIRS) {
await ensureDirectoryAnchor(path.dirname(openspecPath), relativeDir);
}
}

// ═══════════════════════════════════════════════════════════
// SKILL & COMMAND GENERATION
// ═══════════════════════════════════════════════════════════
Expand Down
12 changes: 9 additions & 3 deletions src/core/openspec-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,17 +271,23 @@ async function ensureDefaultConfig(
});
}

async function ensureDirectoryAnchor(
export async function ensureDirectoryAnchor(
storeRoot: string,
relativeDir: string,
ledger: CreatedPathLedgerEntry[]
ledger: CreatedPathLedgerEntry[] = []
): Promise<void> {
const directory = path.join(storeRoot, relativeDir);
if ((await fs.readdir(directory)).length > 0) return;

const relativePath = `${relativeDir}/${DIRECTORY_ANCHOR_FILE_NAME}`;
const absolutePath = path.join(directory, DIRECTORY_ANCHOR_FILE_NAME);
await fs.writeFile(absolutePath, '', 'utf-8');
try {
// A file or symlink may appear after readdir. Never replace or follow it.
await fs.writeFile(absolutePath, '', { encoding: 'utf-8', flag: 'wx' });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return;
throw error;
}
ledger.push({
relativePath: relativeArtifact(relativePath, 'file'),
absolutePath,
Expand Down
33 changes: 33 additions & 0 deletions test/cli-e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { afterAll, describe, it, expect } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'node:child_process';
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';
import { isolatedGitEnv } from '../helpers/store-git.js';
import { AI_TOOLS } from '../../src/core/config.js';
import { getGlobalDataDir, registerStore } from '../../src/core/index.js';
import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js';
Expand Down Expand Up @@ -39,6 +41,37 @@ afterAll(async () => {
});

describe('openspec CLI e2e basics', () => {
it('preserves initialized directories through a Git clone without listing anchors as work', async () => {
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-init-clone-'));
tempRoots.push(base);
const projectDir = path.join(base, 'project');
const cloneDir = path.join(base, 'clone');
await fs.mkdir(projectDir);
const env = {
...isolatedGitEnv(base),
XDG_CONFIG_HOME: path.join(base, 'config'),
XDG_DATA_HOME: path.join(base, 'data'),
};
const initialized = await runCLI(['init', '--tools', 'none'], { cwd: projectDir, env });
expect(initialized.exitCode).toBe(0);

const gitOptions = { cwd: projectDir, env: { ...process.env, ...env }, stdio: 'pipe' as const };
execFileSync('git', ['init'], gitOptions);
execFileSync('git', ['add', 'openspec'], gitOptions);
execFileSync('git', ['commit', '-m', 'Initialize OpenSpec'], gitOptions);
execFileSync('git', ['clone', '--no-local', projectDir, cloneDir], gitOptions);

expect(await fs.readdir(path.join(cloneDir, 'openspec', 'specs'))).toEqual(['.gitkeep']);
expect(await fs.readdir(path.join(cloneDir, 'openspec', 'changes'))).toEqual(['archive']);
expect(await fs.readdir(path.join(cloneDir, 'openspec', 'changes', 'archive'))).toEqual(['.gitkeep']);
const changes = await runCLI(['list', '--json'], { cwd: cloneDir, env });
expectJsonOnlyOutput(changes);
expect(JSON.parse(changes.stdout).changes).toEqual([]);
const specs = await runCLI(['list', '--specs'], { cwd: cloneDir, env });
expect(specs.exitCode).toBe(0);
expect(specs.stdout).toContain('No specs found.');
});

it('shows help output', async () => {
const result = await runCLI(['--help']);
expect(result.exitCode).toBe(0);
Expand Down
75 changes: 75 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,81 @@ describe('InitCommand', () => {
expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);
});

it('should create .gitkeep files in empty directories', async () => {
const initCommand = new InitCommand({ tools: 'claude', force: true });

await initCommand.execute(testDir);

const openspecPath = path.join(testDir, 'openspec');
expect(await fileExists(path.join(openspecPath, 'specs', '.gitkeep'))).toBe(true);
// The archive anchor also keeps its parent changes/ directory in Git.
expect(await fileExists(path.join(openspecPath, 'changes', '.gitkeep'))).toBe(false);
expect(await fileExists(path.join(openspecPath, 'changes', 'archive', '.gitkeep'))).toBe(true);
});

it('should restore missing directories and anchors in extend mode', async () => {
const initCommand1 = new InitCommand({ tools: 'claude', force: true });
await initCommand1.execute(testDir);

const openspecPath = path.join(testDir, 'openspec');

// Older projects may lose these empty directories when cloned.
await fs.rm(path.join(openspecPath, 'specs'), { recursive: true });
await fs.rm(path.join(openspecPath, 'changes'), { recursive: true });

// Re-run init (triggers extend mode since openspec dir already exists)
const initCommand2 = new InitCommand({ tools: 'claude', force: true });
await initCommand2.execute(testDir);

expect(await fileExists(path.join(openspecPath, 'specs', '.gitkeep'))).toBe(true);
expect(await fileExists(path.join(openspecPath, 'changes', '.gitkeep'))).toBe(false);
expect(await fileExists(path.join(openspecPath, 'changes', 'archive', '.gitkeep'))).toBe(true);
});
Comment on lines +71 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extend mode .gitkeep creation is not tested

The PR description states that .gitkeep files are written in both the normal init path and the extend mode path. However, this test only exercises the normal (first-run) code path — a fresh testDir means extendMode is false in createDirectoryStructure.

The extend mode branch (lines 469–478 of init.ts) has no test coverage. If the extend mode logic were broken or accidentally removed, no test would catch it.

A minimal extend-mode test would look like:

it('should create .gitkeep files in extend mode', async () => {
  const initCommand1 = new InitCommand({ tools: 'claude', force: true });
  await initCommand1.execute(testDir);

  // Simulate re-running init (extend mode: openspec dir already exists)
  const initCommand2 = new InitCommand({ tools: 'claude', force: true });
  await initCommand2.execute(testDir);

  const openspecPath = path.join(testDir, 'openspec');
  expect(await fileExists(path.join(openspecPath, 'specs', '.gitkeep'))).toBe(true);
  expect(await fileExists(path.join(openspecPath, 'changes', '.gitkeep'))).toBe(true);
  expect(await fileExists(path.join(openspecPath, 'changes', 'archive', '.gitkeep'))).toBe(true);
});


it('should preserve existing directory anchor contents when re-running init', async () => {
const marker = path.join(testDir, 'openspec', 'specs', '.gitkeep');
await fs.mkdir(path.dirname(marker), { recursive: true });
await fs.writeFile(marker, 'Keep this directory in Git.\n');

await new InitCommand({ tools: 'none', force: true }).execute(testDir);

expect(await fs.readFile(marker, 'utf-8')).toBe('Keep this directory in Git.\n');
});

it('should not add anchors to populated directories', async () => {
const specsPath = path.join(testDir, 'openspec', 'specs');
const archivePath = path.join(testDir, 'openspec', 'changes', 'archive');
await fs.mkdir(specsPath, { recursive: true });
await fs.mkdir(archivePath, { recursive: true });
await fs.writeFile(path.join(specsPath, '.custom'), 'keep me');
await fs.mkdir(path.join(archivePath, '2026-08-27-example'));

await new InitCommand({ tools: 'none', force: true }).execute(testDir);

expect(await fs.readdir(specsPath)).toEqual(['.custom']);
expect(await fs.readdir(archivePath)).toEqual(['2026-08-27-example']);
});

it.skipIf(process.platform === 'win32').each([false, true])(
'should leave anchor symlinks untouched (dangling: %s)',
async (dangling) => {
const target = path.join(configTempDir, 'outside-target');
if (!dangling) await fs.writeFile(target, 'do not overwrite');
const marker = path.join(testDir, 'openspec', 'specs', '.gitkeep');
await fs.mkdir(path.dirname(marker), { recursive: true });
await fs.symlink(target, marker);

await new InitCommand({ tools: 'none', force: true }).execute(testDir);

expect(await fs.readlink(marker)).toBe(target);
if (dangling) {
expect(await fileExists(target)).toBe(false);
} else {
expect(await fs.readFile(target, 'utf-8')).toBe('do not overwrite');
}
},
);

it('should create config.yaml with default schema', async () => {
const initCommand = new InitCommand({ tools: 'claude', force: true });

Expand Down
61 changes: 60 additions & 1 deletion test/core/openspec-root.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';

Expand All @@ -10,6 +11,10 @@ import {
rollbackCreatedPaths,
} from '../../src/core/index.js';

vi.mock('node:fs/promises', async (importOriginal) => ({
...await importOriginal<typeof import('node:fs/promises')>(),
}));

describe('OpenSpec root helper', () => {
let tempDir: string;

Expand All @@ -18,6 +23,7 @@ describe('OpenSpec root helper', () => {
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});

Expand Down Expand Up @@ -135,6 +141,59 @@ describe('OpenSpec root helper', () => {
);
});

it('records only new anchors and includes them in rollback', async () => {
const root = path.join(tempDir, 'store');
createHealthyRoot(root);

const result = await ensureOpenSpecRoot(root, { anchorEmptyDirectories: true });

expect(result.createdArtifacts).toEqual([
'openspec/specs/.gitkeep',
'openspec/changes/archive/.gitkeep',
]);
expect((await ensureOpenSpecRoot(root, { anchorEmptyDirectories: true })).createdPaths).toEqual([]);

await rollbackCreatedPaths(result.createdPaths);

expect(fs.readdirSync(path.join(root, 'openspec', 'specs'))).toEqual([]);
expect(fs.readdirSync(path.join(root, 'openspec', 'changes', 'archive'))).toEqual([]);
});

it.each(['file', 'directory', 'symlink'] as const)(
'preserves a competing %s created after checking an empty directory',
async (kind) => {
const root = path.join(tempDir, 'store');
createHealthyRoot(root);
const marker = path.join(root, 'openspec', 'specs', '.gitkeep');
const target = path.join(tempDir, 'outside-target');
fs.mkdirSync(target);
fs.writeFileSync(path.join(target, 'user.txt'), 'keep me');
vi.spyOn(fsPromises, 'readdir').mockImplementationOnce(async () => {
if (kind === 'file') fs.writeFileSync(marker, 'keep me');
if (kind === 'directory') fs.mkdirSync(marker);
if (kind === 'symlink') fs.symlinkSync(target, marker, process.platform === 'win32' ? 'junction' : 'dir');
return [];
});

const result = await ensureOpenSpecRoot(root, { anchorEmptyDirectories: true });

expect(result.createdArtifacts).toEqual(['openspec/changes/archive/.gitkeep']);
if (kind === 'file') expect(fs.readFileSync(marker, 'utf-8')).toBe('keep me');
if (kind === 'directory') expect(fs.lstatSync(marker).isDirectory()).toBe(true);
if (kind === 'symlink') expect(fs.lstatSync(marker).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(target, 'user.txt'), 'utf-8')).toBe('keep me');
},
);

it('propagates anchor write failures other than an existing path', async () => {
const root = path.join(tempDir, 'store');
createHealthyRoot(root);
const error = Object.assign(new Error('permission denied'), { code: 'EACCES' });
vi.spyOn(fsPromises, 'writeFile').mockRejectedValueOnce(error);

await expect(ensureOpenSpecRoot(root, { anchorEmptyDirectories: true })).rejects.toBe(error);
});

it('rolls back only ledger-created files and empty directories', async () => {
const root = path.join(tempDir, 'store');
const result = await ensureOpenSpecRoot(root);
Expand Down
Loading