diff --git a/.changeset/safe-init-directory-anchors.md b/.changeset/safe-init-directory-anchors.md new file mode 100644 index 0000000000..739f47875a --- /dev/null +++ b/.changeset/safe-init-directory-anchors.md @@ -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. diff --git a/src/core/init.ts b/src/core/init.ts index 2d098cb9e7..921e3b9a01 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -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, @@ -856,42 +857,44 @@ export class InitCommand { // ═══════════════════════════════════════════════════════════ private async createDirectoryStructure(openspecPath: string, extendMode: boolean): Promise { + 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 { + for (const relativeDir of ANCHORED_OPENSPEC_DIRS) { + await ensureDirectoryAnchor(path.dirname(openspecPath), relativeDir); + } + } + // ═══════════════════════════════════════════════════════════ // SKILL & COMMAND GENERATION // ═══════════════════════════════════════════════════════════ diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index d65882ee21..109c92ca58 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -271,17 +271,23 @@ async function ensureDefaultConfig( }); } -async function ensureDirectoryAnchor( +export async function ensureDirectoryAnchor( storeRoot: string, relativeDir: string, - ledger: CreatedPathLedgerEntry[] + ledger: CreatedPathLedgerEntry[] = [] ): Promise { 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, diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index 1db7e33a76..8f8d2f1be0 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -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'; @@ -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); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 3c20733671..4e2f9058e7 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -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); + }); + + 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 }); diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts index d2059f31e0..3f5185be30 100644 --- a/test/core/openspec-root.test.ts +++ b/test/core/openspec-root.test.ts @@ -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'; @@ -10,6 +11,10 @@ import { rollbackCreatedPaths, } from '../../src/core/index.js'; +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...await importOriginal(), +})); + describe('OpenSpec root helper', () => { let tempDir: string; @@ -18,6 +23,7 @@ describe('OpenSpec root helper', () => { }); afterEach(() => { + vi.restoreAllMocks(); fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -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);