diff --git a/CLAUDE.md b/CLAUDE.md index 5e3e50e..17e0e6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ src/ ├── shell/ # Command routing + builtins │ ├── parser.ts # Input tokenization │ ├── router.ts # Git vs builtin dispatch -│ ├── builtins.ts # ls, cat, touch, rm, mv, clear, help, sim +│ ├── builtins.ts # ls, cat, touch, rm, mv, clear, help │ ├── history.ts # Command history │ ├── complete.ts # Tab autocomplete │ └── prompt.ts # Powerlevel10k-style prompt diff --git a/README.md b/README.md index 2c3e599..944efad 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Engine (pure TS) ← Shell (router) ← Renderer (Svelte 5) | Advanced | `git merge` `git rebase` `git reset` `git stash` `git tag` `git cherry-pick` `git revert` | | File | `git rm` `git mv` | -File builtins: `ls` `cat` `touch` `rm` `mv` `clear` `help` `sim change` +File builtins: `ls` `cat` `touch` `rm` `mv` `clear` `help` ## Features @@ -143,7 +143,7 @@ CI/CD: | Source lines | ~4,800 | | Test lines | ~3,500 | | Git commands | 17 | -| File builtins | 8 | +| File builtins | 7 | | Svelte components | 5 | | Total commits | 64 | diff --git a/docs/superpowers/plans/2026-05-28-git-init-requirement.md b/docs/superpowers/plans/2026-05-28-git-init-requirement.md new file mode 100644 index 0000000..08a7805 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-git-init-requirement.md @@ -0,0 +1,880 @@ +# Git Init Requirement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `git init` a required first command — no branch shown pre-init, `.git/` directory created on init, phantom graph node for visual feedback, `ls` enhanced with `-a`/`-l` flags, README command audit cleanup. + +**Architecture:** Add `initialized` flag to GitEngine. RefStore starts empty. `git init` sets flag, creates `main` branch, `.git/` dir. Engine `execute()` gates all non-init git commands. Shell, prompt, autocomplete, graph, and persistence all read `isInitialized()`. + +**Tech Stack:** TypeScript, Svelte 5, Vitest, d3-dag + +--- + +## Task Dependency Graph + +``` +Task 1 (engine core) ──┬──→ Task 3 (shell: prompt + autocomplete + rm guard) + ├──→ Task 4 (persistence) + ├──→ Task 5 (graph phantom node) + ├──→ Task 6 (welcome banner + store) + └──→ Task 7 (update existing tests) +Task 2 (ls flags) ── independent +Task 8 (README/CLAUDE.md) ── independent +``` + +**Parallelizable:** Tasks 1+2+8 can run simultaneously. Tasks 3+4+5+6 can run simultaneously after Task 1. Task 7 runs after Task 1. + +--- + +### Task 1: Engine Init Foundation + +**Files:** + +- Modify: `src/engine/refs.ts:17-21` (constructor) +- Modify: `src/engine/index.ts:115-122` (constructor), `src/engine/index.ts:153-254` (execute) +- Create: `src/engine/commands/init.ts` +- Create: `tests/engine/init.test.ts` + +- [ ] **Step 1: Write init tests** + +Create `tests/engine/init.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { GitEngine } from '$engine/index'; + +describe('git init', () => { + let engine: GitEngine; + + beforeEach(() => { + engine = new GitEngine(); + }); + + it('starts uninitialized', () => { + expect(engine.isInitialized()).toBe(false); + }); + + it('has no branches before init', () => { + expect(engine.allBranches().size).toBe(0); + }); + + it('initializes repository', () => { + const result = engine.execute('git init'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Initialized empty Git repository'); + expect(engine.isInitialized()).toBe(true); + }); + + it('creates main branch on init', () => { + engine.execute('git init'); + const head = engine.getHEAD(); + expect(head.attached).toBe(true); + expect(head.target).toBe('main'); + expect(engine.allBranches().has('main')).toBe(true); + }); + + it('creates .git/ directory on init', () => { + engine.execute('git init'); + expect(engine.getVFS().exists('.git/')).toBe(true); + }); + + it('returns reinitialized on double init', () => { + engine.execute('git init'); + const result = engine.execute('git init'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Reinitialized existing Git repository'); + }); + + it('gates git commands before init', () => { + engine.getVFS().createFile('readme.md', '# hello'); + const result = engine.execute('git add readme.md'); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('fatal: not a git repository'); + }); + + it('allows git commands after init', () => { + engine.execute('git init'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = engine.execute('git add readme.md'); + expect(result.exitCode).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test -- --reporter=verbose tests/engine/init.test.ts` +Expected: All 7 tests FAIL (isInitialized doesn't exist, git init not recognized). + +- [ ] **Step 3: Modify RefStore constructor to start empty** + +In `src/engine/refs.ts`, change the constructor (lines 17-21): + +```typescript +// Before +constructor() { + this.head = { attached: true, target: 'main' }; + this.branches = new Map([['main', '']]); + this.tags = new Map(); +} + +// After +constructor() { + this.head = { attached: false, target: '' }; + this.branches = new Map(); + this.tags = new Map(); +} +``` + +- [ ] **Step 4: Add initialized flag and isInitialized() to GitEngine** + +In `src/engine/index.ts`, add property after the existing private fields (around line 112): + +```typescript +private initialized = false; +``` + +Add public accessor after the constructor (after line 122): + +```typescript +isInitialized(): boolean { + return this.initialized; +} + +setInitialized(value: boolean): void { + this.initialized = value; +} +``` + +- [ ] **Step 5: Create cmdInit command** + +Create `src/engine/commands/init.ts`: + +```typescript +import type { GitEngine } from '../index'; +import type { CommandResult } from './types'; + +export function cmdInit(engine: GitEngine): CommandResult { + if (engine.isInitialized()) { + return { output: 'Reinitialized existing Git repository', exitCode: 0 }; + } + + engine.setInitialized(true); + engine.getVFS().createDir('.git'); + + return { output: 'Initialized empty Git repository', exitCode: 0 }; +} +``` + +Note: The `cmdInit` function also needs to set up the main branch and HEAD. But those are on `refs` which is private. Two options: (a) expose refs or (b) have the engine's execute dispatch handle the ref setup after cmdInit returns. Option (b) is cleaner — keep cmdInit focused and let the engine do ref setup in the `case 'init'` handler: + +In `src/engine/index.ts`, add the import and case in the `execute()` switch (before the `default` case, around line 246): + +```typescript +// Add import at top of file +import { cmdInit } from './commands/init'; + +// Add case in execute() switch +case 'init': + result = cmdInit(this); + if (result.exitCode === 0 && !this.refs.hasBranch('main')) { + this.refs.createBranch('main', ''); + this.refs.attachHEAD('main'); + } + break; +``` + +- [ ] **Step 6: Add init gate to execute()** + +In `src/engine/index.ts`, add the gate at the top of `execute()` (after parsing, before the switch — around line 156): + +```typescript +if (command !== 'init' && !this.initialized) { + return { + output: 'fatal: not a git repository (or any of the parent directories): .git', + exitCode: 1, + }; +} +``` + +- [ ] **Step 7: Run tests** + +Run: `npm run test -- --reporter=verbose tests/engine/init.test.ts` +Expected: All 7 tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/engine/commands/init.ts src/engine/index.ts src/engine/refs.ts tests/engine/init.test.ts +git commit -m "feat: add git init command with initialized flag and command gating" +``` + +--- + +### Task 2: `ls` Flag Enhancement + +**Files:** + +- Modify: `src/shell/builtins.ts:27-34` (ls case) +- Modify: `tests/shell/builtins.test.ts` (add ls flag tests) + +- [ ] **Step 1: Write ls flag tests** + +Add to `tests/shell/builtins.test.ts` inside the existing `describe('ls', ...)` block (after line 41): + +```typescript +it('hides dotfiles by default', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', []); + expect(result.output).not.toContain('.git'); + expect(result.output).toContain('readme.md'); +}); + +it('shows dotfiles with -a flag', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-a']); + expect(result.output).toContain('.git'); + expect(result.output).toContain('readme.md'); +}); + +it('shows long format with -l flag', () => { + engine.getVFS().createDir('src'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-l']); + expect(result.output).toContain('drwxr-xr-x'); + expect(result.output).toContain('-rw-r--r--'); + expect(result.output).toContain('src/'); + expect(result.output).toContain('readme.md'); +}); + +it('combines -la flags', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-la']); + expect(result.output).toContain('.git'); + expect(result.output).toContain('drwxr-xr-x'); + expect(result.output).toContain('-rw-r--r--'); +}); + +it('handles separate -l -a flags', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-l', '-a']); + expect(result.output).toContain('.git'); + expect(result.output).toContain('drwxr-xr-x'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test -- --reporter=verbose tests/shell/builtins.test.ts` +Expected: New tests FAIL (ls doesn't filter dotfiles or support flags). + +- [ ] **Step 3: Implement ls flag parsing** + +Replace the ls case in `src/shell/builtins.ts` (lines 27-34): + +```typescript +case 'ls': { + const flags = new Set(); + const paths: string[] = []; + for (const arg of args) { + if (arg.startsWith('-')) { + for (const ch of arg.slice(1)) flags.add(ch); + } else { + paths.push(arg); + } + } + const showAll = flags.has('a'); + const longFormat = flags.has('l'); + const dir = paths[0]; + + let entries = vfs.listDir(dir); + if (!showAll) { + entries = entries.filter((e) => !e.startsWith('.')); + } + if (entries.length === 0) { + return { output: '', exitCode: 0 }; + } + if (longFormat) { + const lines = entries.map((e) => { + const perm = e.endsWith('/') ? 'drwxr-xr-x' : '-rw-r--r--'; + return `${perm} ${e}`; + }); + return { output: lines.join('\n'), exitCode: 0 }; + } + return { output: entries.join(' '), exitCode: 0 }; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm run test -- --reporter=verbose tests/shell/builtins.test.ts` +Expected: All tests PASS (existing + new). + +- [ ] **Step 5: Commit** + +```bash +git add src/shell/builtins.ts tests/shell/builtins.test.ts +git commit -m "feat: add -a and -l flags to ls builtin" +``` + +--- + +### Task 3: Shell Updates (Prompt + Autocomplete + rm Guard) + +**Depends on:** Task 1 + +**Files:** + +- Modify: `src/shell/prompt.ts:8-55` +- Modify: `src/shell/complete.ts:4-28` +- Modify: `src/shell/builtins.ts:77-92` (rm case) +- Modify: `tests/shell/prompt.test.ts` +- Modify: `tests/shell/builtins.test.ts` + +- [ ] **Step 1: Write prompt test for uninitialized state** + +Add to `tests/shell/prompt.test.ts`, new describe block: + +```typescript +describe('uninitialized state', () => { + it('shows no branch segment when uninitialized', () => { + const engine = new GitEngine(); + const segments = generatePrompt(engine); + const text = segmentText(segments); + expect(text).not.toContain('main'); + expect(text).toContain('gitverse'); + expect(text).toContain('❯'); + }); + + it('shows branch after init', () => { + const engine = new GitEngine(); + engine.execute('git init'); + const segments = generatePrompt(engine); + const text = segmentText(segments); + expect(text).toContain('main'); + }); +}); +``` + +- [ ] **Step 2: Write rm guard test** + +Add to `tests/shell/builtins.test.ts` inside the `describe('rm', ...)` block: + +```typescript +it('refuses to delete .git directory', () => { + engine.execute('git init'); + const result = executeBuiltin(engine, 'rm', ['.git/']); + expect(result.exitCode).toBe(1); + expect(engine.getVFS().exists('.git/')).toBe(true); +}); +``` + +Note: This test needs `engine.execute('git init')` to create `.git/`. If Task 1 is already applied, the engine in builtins tests needs init too. Check if the builtins test `beforeEach` already calls `git init` — if not, add it for tests that need it, or call `engine.execute('git init')` inline in this test. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm run test -- --reporter=verbose tests/shell/prompt.test.ts tests/shell/builtins.test.ts` +Expected: New tests FAIL. + +- [ ] **Step 4: Update prompt for uninitialized state** + +In `src/shell/prompt.ts`, add early return at start of `generatePrompt` (after line 9): + +```typescript +export function generatePrompt(engine: GitEngine): PromptSegment[] { + if (!engine.isInitialized()) { + return [ + { text: 'gitverse ', color: 'dim' }, + { text: '❯ ', color: 'cyan' }, + ]; + } + + // ... rest of existing function unchanged +``` + +- [ ] **Step 5: Update autocomplete for uninitialized state** + +In `src/shell/complete.ts`, add `'init'` to `GIT_SUBCOMMANDS` array (line 4). Then in `getCompletions` function, add early filter when uninitialized. After the engine parameter is available, add: + +```typescript +// At top of getCompletions, after determining tokens +if (!engine.isInitialized()) { + const TOP_LEVEL_PREINIT = ['git', 'ls', 'cat', 'touch', 'rm', 'mv', 'clear', 'help']; + if (tokens.length <= 1) { + return TOP_LEVEL_PREINIT.filter((c) => c.startsWith(partial)).map((c) => c + ' '); + } + if (tokens[0] === 'git') { + return ['init'].filter((c) => c.startsWith(partial)).map((c) => `git ${c} `); + } + // File completions for builtins still work + if (FILE_SUBCOMMANDS.has(tokens[0])) { + return completeFilePaths(engine, tokens[0] + ' ', partial); + } + return []; +} +``` + +- [ ] **Step 6: Add .git/ deletion guard to rm builtin** + +In `src/shell/builtins.ts`, in the `rm` case (around line 77), add guard before the existing `!vfs.exists` check: + +```typescript +case 'rm': { + if (args.length === 0) return { output: 'usage: rm ', exitCode: 1 }; + const path = args[0]; + if (path === '.git' || path === '.git/') { + return { output: 'rm: cannot remove .git: protected directory', exitCode: 1 }; + } + if (!vfs.exists(path)) { + return { output: `rm: ${path}: No such file or directory`, exitCode: 1 }; + } + vfs.deleteFile(path); + return { output: '', exitCode: 0 }; +} +``` + +- [ ] **Step 7: Run tests** + +Run: `npm run test -- --reporter=verbose tests/shell/prompt.test.ts tests/shell/builtins.test.ts` +Expected: All tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/shell/prompt.ts src/shell/complete.ts src/shell/builtins.ts tests/shell/prompt.test.ts tests/shell/builtins.test.ts +git commit -m "feat: prompt hides branch pre-init, autocomplete filters pre-init, guard .git/ deletion" +``` + +--- + +### Task 4: Persistence + +**Depends on:** Task 1 + +**Files:** + +- Modify: `src/persistence/serializer.ts:44-49` (WireState), `src/persistence/serializer.ts:55-93` (serialize), `src/persistence/serializer.ts:99-129` (deserialize) +- Modify: `tests/persistence/serializer.test.ts` + +- [ ] **Step 1: Write persistence tests** + +Add to `tests/persistence/serializer.test.ts`: + +```typescript +describe('initialized state', () => { + it('round-trips initialized flag', () => { + const engine = new GitEngine(); + engine.execute('git init'); + const json = serialize(engine); + const restored = deserialize(json); + expect(restored.isInitialized()).toBe(true); + }); + + it('round-trips uninitialized state', () => { + const engine = new GitEngine(); + const json = serialize(engine); + const restored = deserialize(json); + expect(restored.isInitialized()).toBe(false); + }); + + it('defaults old saves without initialized field to true', () => { + const engine = new GitEngine(); + engine.execute('git init'); + engine.getVFS().createFile('readme.md', '# hello'); + engine.execute('git add readme.md'); + engine.execute('git commit -m "initial"'); + const json = serialize(engine); + const parsed = JSON.parse(json); + delete parsed.initialized; + const restored = deserialize(JSON.stringify(parsed)); + expect(restored.isInitialized()).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test -- --reporter=verbose tests/persistence/serializer.test.ts` +Expected: New tests FAIL (initialized not in WireState). + +- [ ] **Step 3: Add initialized to serialization** + +In `src/persistence/serializer.ts`, update `WireState` type (line 44): + +```typescript +type WireState = { + initialized: boolean; + vfs: [string, WireFileEntry][]; + objects: WireObjects; + refs: WireRefs; + index: [string, string][]; +}; +``` + +In `serialize()` function, add `initialized` to the output object (around line 56): + +```typescript +const state: WireState = { + initialized: engine.isInitialized(), + vfs: vfsEntries, + // ... rest unchanged +}; +``` + +In `deserialize()` function, restore the flag (after creating engine, around line 102): + +```typescript +export function deserialize(json: string): GitEngine { + const state = JSON.parse(json) as WireState; + const engine = new GitEngine(); + + // Backwards compat: old saves without initialized field default to true + if (state.initialized !== undefined) { + engine.setInitialized(state.initialized); + } else { + engine.setInitialized(true); + } + + // ... rest of existing restore logic unchanged +``` + +- [ ] **Step 4: Run tests** + +Run: `npm run test -- --reporter=verbose tests/persistence/serializer.test.ts` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/persistence/serializer.ts tests/persistence/serializer.test.ts +git commit -m "feat: serialize/deserialize initialized flag with backwards compat" +``` + +--- + +### Task 5: Graph Phantom Node + +**Depends on:** Task 1 + +**Files:** + +- Modify: `src/graph/types.ts:1-11` +- Modify: `src/ui/Graph.svelte` (node generation + rendering) +- Modify: `tests/graph/layout.test.ts` + +- [ ] **Step 1: Write phantom node test** + +Add to `tests/graph/layout.test.ts`: + +```typescript +describe('phantom node', () => { + it('is not included when uninitialized', () => { + const engine = new GitEngine(); + const commits = engine.allCommits(); + expect(commits).toHaveLength(0); + }); + + it('uses phantom type for zero-commit initialized state', () => { + const engine = new GitEngine(); + engine.execute('git init'); + const commits = engine.allCommits(); + expect(commits).toHaveLength(0); + // Phantom node generation happens in Graph.svelte, not layout + // This test verifies the engine state that triggers phantom node + expect(engine.isInitialized()).toBe(true); + expect(engine.allBranches().has('main')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify** + +Run: `npm run test -- --reporter=verbose tests/graph/layout.test.ts` +Expected: Tests pass (they're testing engine state, phantom rendering is in Svelte). + +- [ ] **Step 3: Add type to GraphNode** + +In `src/graph/types.ts`, add `type` field to `GraphNode` (line 1): + +```typescript +export type GraphNode = { + hash: string; + type: 'commit' | 'phantom'; + parents: string[]; + message: string; + branches: string[]; + tags?: string[]; + isHEAD: boolean; + lane: number; + x: number; + y: number; +}; +``` + +- [ ] **Step 4: Fix all GraphNode construction sites** + +In `src/ui/Graph.svelte`, where `inputNodes` are created from `allCommits` (around line 64-74), add `type: 'commit'` to each node: + +```typescript +const inputNodes: GraphNode[] = allCommits.map((c) => ({ + hash: c.hash, + type: 'commit' as const, + parents: c.parents, + // ... rest unchanged +})); +``` + +- [ ] **Step 5: Add phantom node generation in Graph.svelte** + +In `src/ui/Graph.svelte`, after building `inputNodes` from `allCommits` and before calling `computeLayout`, add phantom node logic: + +```typescript +// After inputNodes creation, before computeLayout call +if (inputNodes.length === 0 && $engine.isInitialized()) { + const phantomNode: GraphNode = { + hash: '', + type: 'phantom', + parents: [], + message: '', + branches: ['main'], + tags: [], + isHEAD: true, + lane: 0, + x: NODE_SPACING_X, + y: LANE_SPACING_Y, + }; + // Skip layout computation, render single node directly + nodes = [phantomNode]; + edges = []; +} else { + const layout = computeLayout(inputNodes); + nodes = layout.nodes; + edges = layout.edges; +} +``` + +- [ ] **Step 6: Render phantom node differently in SVG** + +In `src/ui/Graph.svelte`, in the SVG node rendering section (around line 244), add conditional styling: + +```svelte + node.type !== 'phantom' && selectNode(node)} +/> +``` + +For the hash text inside the node, hide it for phantom: + +```svelte +{#if node.type !== 'phantom'} + {node.hash.slice(0, 4)} +{/if} +``` + +- [ ] **Step 7: Fix layout.test.ts if needed** + +If `computeLayout` tests construct GraphNode objects, add `type: 'commit'` to them. Search for GraphNode literals in the test and add the field. + +- [ ] **Step 8: Commit** + +```bash +git add src/graph/types.ts src/ui/Graph.svelte tests/graph/layout.test.ts +git commit -m "feat: phantom node on graph after git init with zero commits" +``` + +--- + +### Task 6: Welcome Banner + Store + +**Depends on:** Task 1 + +**Files:** + +- Modify: `src/store/engine.ts:30-35` (createInitialLines) + +- [ ] **Step 1: Update welcome banner for uninitialized state** + +In `src/store/engine.ts`, update the `WELCOME_BANNER` and `WELCOME_SUBTITLE` constants and `createInitialLines` (around lines 10-35): + +```typescript +const WELCOME_BANNER = `┌─────────────────────────────────────┐ +│ Welcome to Gitverse │ +│ A browser-based git sandbox │ +│ │ +│ Type 'git init' to begin │ +│ Type 'help' for commands │ +└─────────────────────────────────────┘`; + +function createInitialLines(): TerminalLine[] { + return [{ id: ++lineIdCounter, output: WELCOME_BANNER, color: 'cyan' }]; +} +``` + +Remove the old `WELCOME_SUBTITLE` line if it exists — single banner is sufficient. + +- [ ] **Step 2: Verify dev server shows banner** + +Run: `npm run dev` +Open browser. Verify: + +- Welcome banner appears +- Prompt shows `gitverse ❯` (no branch) +- Type `git init` — prompt changes to show `main` branch +- Type `ls -la` — shows `.git/` directory + +- [ ] **Step 3: Commit** + +```bash +git add src/store/engine.ts +git commit -m "feat: welcome banner with git init hint for uninitialized state" +``` + +--- + +### Task 7: Update Existing Tests + +**Depends on:** Task 1 + +**Files:** + +- Modify: `tests/engine/core.test.ts` +- Modify: `tests/engine/branch-checkout.test.ts` +- Modify: `tests/engine/merge.test.ts` +- Modify: `tests/engine/rebase.test.ts` +- Modify: `tests/engine/reset-stash-tag.test.ts` +- Modify: `tests/engine/log-diff.test.ts` +- Modify: `tests/engine/vfs.test.ts` +- Modify: `tests/engine/objects.test.ts` +- Modify: `tests/engine/refs.test.ts` +- Modify: `tests/shell/builtins.test.ts` +- Modify: `tests/shell/router.test.ts` +- Modify: `tests/shell/prompt.test.ts` +- Modify: `tests/graph/layout.test.ts` +- Modify: `tests/persistence/serializer.test.ts` + +- [ ] **Step 1: Run full test suite to see what breaks** + +Run: `npm run test -- --reporter=verbose` +Note every failing test. Most will fail because the engine starts uninitialized and commands are gated. + +- [ ] **Step 2: Fix engine tests** + +For every `beforeEach` that creates a `new GitEngine()` and then runs git commands, add `engine.execute('git init')` after the constructor call. + +Pattern — in each test file's `beforeEach`: + +```typescript +beforeEach(() => { + engine = new GitEngine(); + engine.execute('git init'); // ADD THIS LINE +}); +``` + +Apply to: `core.test.ts`, `branch-checkout.test.ts`, `merge.test.ts`, `rebase.test.ts`, `reset-stash-tag.test.ts`, `log-diff.test.ts`. + +For helper functions like `engineWithCommit()` in these files, add `engine.execute('git init')` after `new GitEngine()`. + +For `vfs.test.ts`, `objects.test.ts`, `refs.test.ts` — these test lower-level modules directly and may not use `engine.execute()`. Check if they fail; if not, no changes needed. If `refs.test.ts` tests assume `main` branch exists at construction, update those tests to either create the branch manually or use `engine.execute('git init')`. + +- [ ] **Step 3: Fix shell tests** + +In `tests/shell/builtins.test.ts`, add `engine.execute('git init')` to `beforeEach` if tests use git commands. Tests that only use file builtins (ls, touch, rm, mv, cat) don't need it. + +In `tests/shell/router.test.ts`, add init where tests dispatch git commands. + +In `tests/shell/prompt.test.ts`, existing tests that check branch display need init. The new "uninitialized state" tests from Task 3 should NOT have init. + +- [ ] **Step 4: Fix graph and persistence tests** + +In `tests/graph/layout.test.ts`, add init to tests that construct engines and call git commands. + +In `tests/persistence/serializer.test.ts`, add init to existing serialization tests that create commits. + +- [ ] **Step 5: Run full test suite** + +Run: `npm run test -- --reporter=verbose` +Expected: ALL tests PASS. + +- [ ] **Step 6: Run typecheck and lint** + +Run: `npm run typecheck && npm run lint` +Expected: No errors. + +- [ ] **Step 7: Commit** + +```bash +git add tests/ +git commit -m "test: add git init to all existing test setups" +``` + +--- + +### Task 8: README / CLAUDE.md Cleanup + +**Independent — no dependencies** + +**Files:** + +- Modify: `README.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Remove `sim change` from README** + +In `README.md`, find the file builtins line: + +``` +File builtins: `ls` `cat` `touch` `rm` `mv` `clear` `help` `sim change` +``` + +Replace with: + +``` +File builtins: `ls` `cat` `touch` `rm` `mv` `clear` `help` +``` + +Update the Numbers table — builtins count from 8 to 7. + +- [ ] **Step 2: Remove `sim` from CLAUDE.md** + +In `CLAUDE.md`, find the builtins line in the shell section: + +``` +│ ├── builtins.ts # ls, cat, touch, rm, mv, clear, help, sim +``` + +Replace with: + +``` +│ ├── builtins.ts # ls, cat, touch, rm, mv, clear, help +``` + +- [ ] **Step 3: Run prettier** + +Run: `npm run format` + +- [ ] **Step 4: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "docs: remove non-existent sim command from README and CLAUDE.md" +``` diff --git a/docs/superpowers/specs/2026-05-28-git-init-requirement.md b/docs/superpowers/specs/2026-05-28-git-init-requirement.md new file mode 100644 index 0000000..5c9a670 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-git-init-requirement.md @@ -0,0 +1,322 @@ +# Git Init Requirement + Command Audit + +**Date:** 2026-05-28 +**Status:** Draft +**Scope:** Make `git init` a required first command, add `.git/` directory marker, enhance `ls` with flags, add phantom graph node, audit README commands. + +## Problem + +Engine auto-initializes with a `main` branch — `git init` is listed in README but doesn't exist as a command. This makes the sandbox less realistic. Users should experience the full git workflow starting from an empty directory. + +## Design Decisions + +| Decision | Choice | Rationale | +| ---------------------- | --------------------------------------------- | ------------------------------------------------ | +| Init mechanism | Engine-level `initialized` flag | Single source of truth, explicit, testable | +| Pre-init commands | File builtins + `git init` + `help` + `clear` | Sandbox-friendly — set up files before init | +| `.git/` representation | Directory marker in VFS | Visible with `ls -a`, can't explore contents | +| Graph on init | Hollow/dashed phantom node | Visual feedback that repo exists, no commits yet | +| Welcome UX | ASCII banner + empty prompt | Guides new users, branded first impression | +| `sim change` in README | Remove | Not implemented, out of scope | + +## 1. Engine State Change + +### GitEngine (`src/engine/index.ts`) + +Add `initialized: boolean = false` property. + +**Constructor:** No changes to VFS/ObjectStore/Index. RefStore constructor changes: + +```typescript +// Before +this.head = { attached: true, target: 'main' }; +this.branches = new Map([['main', '']]); + +// After +this.head = { attached: false, target: '' }; +this.branches = new Map(); +``` + +Add public accessor: + +```typescript +isInitialized(): boolean { return this.initialized; } +``` + +### `git init` Command (`src/engine/commands/init.ts`) + +New file. Handler: + +```typescript +export function cmdInit(engine: GitEngine): CommandResult { + if (engine.isInitialized()) { + return { output: 'Reinitialized existing Git repository', exitCode: 0 }; + } + + // Inline — single call site, no reuse + engine.setInitialized(true); + engine.refs.createBranch('main', ''); + engine.refs.setHEAD({ attached: true, target: 'main' }); + engine.vfs.createDir('.git'); + + return { output: 'Initialized empty Git repository', exitCode: 0 }; +} +``` + +## 2. Command Gating + +### Engine Execute (`src/engine/index.ts`) + +Single gating point — add init check at top of `execute()`. If command is not `init` and not initialized, return error. No router changes needed (KISS — one check, one place). + +``` +Allowed pre-init: git init, ls, cat, touch, rm, mv, clear, help +Blocked pre-init: git add, git commit, git status, git log, git diff, + git branch, git checkout, git switch, git merge, + git rebase, git reset, git stash, git tag, + git cherry-pick, git revert, git rm, git mv +``` + +Error message for blocked commands: + +``` +fatal: not a git repository (or any of the parent directories): .git +``` + +Builtins bypass engine entirely (routed by shell), so they work pre-init by default. + +## 3. Prompt Change + +### Prompt (`src/shell/prompt.ts`) + +When `!engine.isInitialized()`: + +- No branch icon segment +- No branch name segment +- No status count segments +- Just: `gitverse` (dim) + `❯ ` (cyan) + +When `engine.isInitialized()`: + +- Current behavior unchanged (branch icon, name, status counts) + +## 4. `.git/` Directory Marker + +`git init` calls `vfs.createDir('.git')`. This makes `.git/` appear in VFS. + +Behavior: + +- `ls -a` shows `.git/` (hidden by default without `-a`) +- `ls .git` returns empty (no children in VFS) +- `cat .git` returns error (it's a directory) +- `touch .git/foo` — blocked (VFS doesn't allow nested files inside `.git/`) +- `rm .git` — blocked (protected directory, not user-deletable) + +## 5. `ls` Flag Enhancement + +### Current Behavior + +`ls [dir]` returns space-separated entries. No flag support. + +### New Behavior + +Support `-a` and `-l` flags in any combination (`-la`, `-al`, `-a -l`, etc.). No `-h` — no file sizes to format (KISS). + +**`-a` (all):** Show hidden files/directories (names starting with `.`). Without `-a`, filter out dotfiles. + +**`-l` (long format):** One entry per line with type indicator: + +``` +drwxr-xr-x .git/ +drwxr-xr-x src/ +-rw-r--r-- readme.md +-rw-r--r-- index.js +``` + +Permissions are decorative (always `drwxr-xr-x` for dirs, `-rw-r--r--` for files). No size/date columns — simulated filesystem has no metadata. + +**Without `-l`:** Space-separated (current behavior), respecting `-a` filter. + +**Flag parsing:** Extract flags from args before interpreting remaining args as directory path. + +## 6. Welcome Banner + +On fresh load (uninitialized state), terminal shows: + +``` +┌─────────────────────────────────────┐ +│ Welcome to Gitverse │ +│ A browser-based git sandbox │ +│ │ +│ Type 'git init' to begin │ +│ Type 'help' for commands │ +└─────────────────────────────────────┘ +``` + +Rendered as initial terminal lines in `src/store/engine.ts` (`createInitialLines()`). Only shown when no persisted state exists. If loading from saved state, skip banner. + +## 7. Graph: Phantom Node + +### GraphNode Type (`src/graph/types.ts`) + +Add node type discriminator: + +```typescript +type GraphNode = { + id: string; + type: 'commit' | 'phantom'; + // ... existing fields +}; +``` + +### Graph Data Generation + +When initialized but zero commits: + +- Generate single phantom node: `{ id: 'phantom-main', type: 'phantom', ... }` +- Attach `main` branch label to it +- No parent edges + +When first commit is created: + +- Phantom node disappears, replaced by real commit node +- Standard graph behavior from here + +### Graph Rendering (`src/ui/Graph.svelte`) + +Phantom node styling: + +- Hollow circle (no fill, or transparent fill) +- Dashed stroke +- No commit hash displayed +- Branch label displayed normally +- Same size as regular commit nodes + +## 8. Persistence + +### Serialization (`src/persistence/serializer.ts`) + +Add `initialized` to `WireState`: + +```typescript +type WireState = { + initialized: boolean; + vfs: ...; + objects: ...; + refs: ...; + index: ...; +}; +``` + +**Backwards compatibility:** On deserialization, if `initialized` field is missing (old saves), default to `true`. Old saves have commits and branches, so treating them as initialized is correct. + +### Storage + +No changes to storage layer. `initialized` flag serializes/deserializes with engine state. + +## 9. Autocomplete + +### Pre-init (`src/shell/complete.ts`) + +When `!engine.isInitialized()`: + +- Top-level completions: `git`, `ls`, `cat`, `touch`, `rm`, `mv`, `clear`, `help` +- Git subcommand completions: only `init` +- No branch completions +- File completions still work (for file builtins) + +When initialized: + +- Current behavior + `init` added to git subcommand list + +## 10. README Command Audit + +### Commands verified as implemented: + +| Command | Status | Notes | +| ----------------- | ---------------- | ------------------------------- | +| `git init` | **To implement** | This spec | +| `git add` | Working | Supports `.` and specific paths | +| `git commit` | Working | Requires `-m "message"` | +| `git status` | Working | Shows three-area diff | +| `git log` | Working | Supports `--oneline` | +| `git diff` | Working | Supports `--staged`/`--cached` | +| `git branch` | Working | Supports `-d`, `-D` | +| `git checkout` | Working | Supports `-b` | +| `git switch` | Working | Alias of checkout | +| `git merge` | Working | Fast-forward + three-way | +| `git rebase` | Working | Replays commits | +| `git reset` | Working | `--soft`, `--mixed`, `--hard` | +| `git stash` | Working | `push`, `pop`, `list`, `drop` | +| `git tag` | Working | Lightweight tags | +| `git cherry-pick` | Working | Single commit | +| `git revert` | Working | Creates inverse commit | +| `git rm` | Working | Removes from VFS + index | +| `git mv` | Working | Moves in VFS + index | + +### Builtins verified: + +| Builtin | Status | Notes | +| ------------ | -------------------- | ---------------------------- | +| `ls` | Working, **enhance** | Add `-a`, `-l`, `-h` flags | +| `cat` | Working | No changes | +| `touch` | Working | Auto-generates content | +| `rm` | Working | No changes | +| `mv` | Working | No changes | +| `clear` | Working | No changes | +| `help` | Working | Update to include `git init` | +| `sim change` | **Not implemented** | Remove from README | + +### README changes needed: + +- Remove `sim change` from file builtins list +- `git init` already listed, no change needed +- Builtins count: 8 → 7 (drop `sim change`) + +## 11. Test Impact + +All existing engine tests start with `new GitEngine()` (uninitialized). Tests that run git commands need `git init` first. + +**Strategy:** Add helper function: + +```typescript +function initEngine(): GitEngine { + const engine = new GitEngine(); + engine.execute('git init'); + return engine; +} +``` + +Update `beforeEach` in all engine test files. Add new test file `tests/engine/init.test.ts` covering: + +- Fresh engine is uninitialized +- `git init` sets initialized flag +- `git init` creates `.git/` directory +- `git init` creates `main` branch +- Double `git init` returns "Reinitialized" +- Git commands fail pre-init with correct error +- File builtins work pre-init +- Prompt shows no branch pre-init +- Autocomplete only shows `init` pre-init + +## Files Changed + +| File | Change | +| ------------------------------- | --------------------------------------------------------------------------------------- | +| `src/engine/index.ts` | Add `initialized` flag, `_initialize()`, init check in `execute()`, dispatch `git init` | +| `src/engine/refs.ts` | Constructor starts empty (no branches, no HEAD target) | +| `src/engine/commands/init.ts` | **New file** — `cmdInit` handler | +| `src/shell/builtins.ts` | Enhance `ls` with `-a`, `-l` flags, protect `.git/` from `rm` | +| `src/shell/prompt.ts` | Handle uninitialized state (no branch segment) | +| `src/shell/complete.ts` | Filter completions pre-init | +| `src/graph/types.ts` | Add `'phantom'` to node type | +| `src/graph/layout.ts` | Handle phantom node generation | +| `src/ui/Graph.svelte` | Render phantom node (hollow, dashed) | +| `src/store/engine.ts` | Update welcome banner for uninitialized state | +| `src/persistence/serializer.ts` | Add `initialized` to wire format, backwards compat | +| `tests/engine/init.test.ts` | **New file** — init-specific tests | +| `tests/engine/*.test.ts` | Add `git init` to setup in all existing tests | +| `tests/shell/builtins.test.ts` | Add `ls` flag tests | +| `tests/shell/prompt.test.ts` | Add uninitialized prompt test | +| `README.md` | Remove `sim change`, update builtins count | +| `CLAUDE.md` | Remove `sim` from builtins list | diff --git a/package-lock.json b/package-lock.json index 1cffa5d..c5b0f45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitverse", - "version": "0.3.0", + "version": "0.4.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitverse", - "version": "0.3.0", + "version": "0.4.0-rc.0", "dependencies": { "d3-dag": "1.2.1", "d3-hierarchy": "3.1.2", diff --git a/package.json b/package.json index 1fddb6e..6bc95c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitverse", - "version": "0.3.0", + "version": "0.4.0-rc.0", "private": true, "type": "module", "scripts": { diff --git a/src/engine/commands/init.ts b/src/engine/commands/init.ts new file mode 100644 index 0000000..ca1e048 --- /dev/null +++ b/src/engine/commands/init.ts @@ -0,0 +1,13 @@ +import type { GitEngine } from '../index'; +import type { CommandResult } from './types'; + +export function cmdInit(engine: GitEngine): CommandResult { + if (engine.isInitialized()) { + return { output: 'Reinitialized existing Git repository', exitCode: 0 }; + } + + engine.setInitialized(true); + engine.getVFS().createDir('.git'); + + return { output: 'Initialized empty Git repository', exitCode: 0 }; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index a1dfd8a..6f0825a 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -21,6 +21,7 @@ import { cmdMv } from './commands/mv'; import { cmdRebase } from './commands/rebase'; import { cmdCherryPick } from './commands/cherry-pick'; import { cmdRevert } from './commands/revert'; +import { cmdInit } from './commands/init'; // --------------------------------------------------------------------------- // Command parsing @@ -111,6 +112,7 @@ export class GitEngine { private listeners: Set<() => void>; /** Stash stack (LIFO). Index 0 is the most recent entry. */ stashStack: StashEntry[]; + private initialized = false; constructor() { this.vfs = new VirtualFileSystem(); @@ -121,6 +123,18 @@ export class GitEngine { this.stashStack = []; } + // ------------------------------------------------------------------------- + // Initialization state + // ------------------------------------------------------------------------- + + isInitialized(): boolean { + return this.initialized; + } + + setInitialized(value: boolean): void { + this.initialized = value; + } + // ------------------------------------------------------------------------- // Public accessor for VFS (needed by tests and shell layer) // ------------------------------------------------------------------------- @@ -153,9 +167,23 @@ export class GitEngine { execute(input: string): CommandResult { const { command, args, opts } = parseGitCommand(input.trim()); + if (command !== 'init' && !this.initialized) { + return { + output: 'fatal: not a git repository (or any of the parent directories): .git', + exitCode: 1, + }; + } + let result: CommandResult; switch (command) { + case 'init': + result = cmdInit(this); + if (result.exitCode === 0 && !this.refs.hasBranch('main')) { + this.refs.createBranch('main', ''); + this.refs.attachHEAD('main'); + } + break; case 'add': result = cmdAdd(args, opts, this.vfs, this.objects, this.index, this.getCommittedTree()); break; diff --git a/src/engine/refs.ts b/src/engine/refs.ts index c379a86..8fc082a 100644 --- a/src/engine/refs.ts +++ b/src/engine/refs.ts @@ -15,8 +15,8 @@ export class RefStore { private tags: Map; // name -> commit hash constructor() { - this.head = { attached: true, target: 'main' }; - this.branches = new Map([['main', '']]); + this.head = { attached: false, target: '' }; + this.branches = new Map(); this.tags = new Map(); } diff --git a/src/graph/types.ts b/src/graph/types.ts index f90fac4..35fc40a 100644 --- a/src/graph/types.ts +++ b/src/graph/types.ts @@ -1,5 +1,6 @@ export type GraphNode = { hash: string; + type: 'commit' | 'phantom'; parents: string[]; message: string; branches: string[]; diff --git a/src/persistence/serializer.ts b/src/persistence/serializer.ts index 6d1f25c..e78f1f3 100644 --- a/src/persistence/serializer.ts +++ b/src/persistence/serializer.ts @@ -42,6 +42,7 @@ type WireRefs = { }; type WireState = { + initialized: boolean; vfs: [string, WireFileEntry][]; objects: WireObjects; refs: WireRefs; @@ -83,6 +84,7 @@ export function serialize(engine: GitEngine): string { const wireIndex: [string, string][] = [...engine._getIndex().entries()]; const state: WireState = { + initialized: engine.isInitialized(), vfs: wireVfs, objects: wireObjects, refs: wireRefs, @@ -101,6 +103,13 @@ export function deserialize(json: string): GitEngine { const engine = new GitEngine(); + // Backwards compat: old saves without initialized field default to true + if (state.initialized !== undefined) { + engine.setInitialized(state.initialized); + } else { + engine.setInitialized(true); + } + // Restore VFS const snapMap = new Map(); for (const [path, entry] of state.vfs) { diff --git a/src/shell/builtins.ts b/src/shell/builtins.ts index a845365..1461a36 100644 --- a/src/shell/builtins.ts +++ b/src/shell/builtins.ts @@ -25,11 +25,33 @@ export function executeBuiltin(engine: GitEngine, command: string, args: string[ switch (command) { case 'ls': { - const dir = args[0]; - const entries = vfs.listDir(dir); + const flags = new Set(); + const paths: string[] = []; + for (const arg of args) { + if (arg.startsWith('-')) { + for (const ch of arg.slice(1)) flags.add(ch); + } else { + paths.push(arg); + } + } + const showAll = flags.has('a'); + const longFormat = flags.has('l'); + const dir = paths[0]; + + let entries = vfs.listDir(dir); + if (!showAll) { + entries = entries.filter((e) => !e.startsWith('.')); + } if (entries.length === 0) { return { output: '', exitCode: 0 }; } + if (longFormat) { + const lines = entries.map((e) => { + const perm = e.endsWith('/') ? 'drwxr-xr-x' : '-rw-r--r--'; + return `${perm} ${e}`; + }); + return { output: lines.join('\n'), exitCode: 0 }; + } return { output: entries.join(' '), exitCode: 0 }; } @@ -79,6 +101,9 @@ export function executeBuiltin(engine: GitEngine, command: string, args: string[ return { output: 'rm: missing operand', exitCode: 1 }; } const path = args[0]; + if (path === '.git' || path === '.git/') { + return { output: 'rm: cannot remove .git: protected directory', exitCode: 1 }; + } if (!vfs.exists(path)) { return { output: `rm: cannot remove '${path}': No such file or directory`, exitCode: 1 }; } diff --git a/src/shell/complete.ts b/src/shell/complete.ts index e1d87d6..eee8db5 100644 --- a/src/shell/complete.ts +++ b/src/shell/complete.ts @@ -8,6 +8,7 @@ const GIT_SUBCOMMANDS = [ 'cherry-pick', 'commit', 'diff', + 'init', 'log', 'merge', 'mv', @@ -40,6 +41,24 @@ const FILE_SUBCOMMANDS = new Set(['add', 'rm', 'mv', 'diff', 'cat', 'touch']); */ export function getCompletions(input: string, engine: GitEngine): string[] { const trimmed = input; + const tokens = trimmed.split(' ').filter((t) => t.length > 0); + const partial = tokens.length > 0 ? tokens[tokens.length - 1] : ''; + + // --- pre-init filter: only allow builtins + git init --- + if (!engine.isInitialized()) { + const TOP_LEVEL_PREINIT = ['git', 'ls', 'cat', 'touch', 'rm', 'mv', 'clear', 'help']; + if (tokens.length <= 1) { + return TOP_LEVEL_PREINIT.filter((c) => c.startsWith(partial)).map((c) => c + ' '); + } + if (tokens[0] === 'git') { + const gitPartial = tokens[1] || ''; + return ['init'].filter((c) => c.startsWith(gitPartial)).map((c) => `git ${c} `); + } + if (FILE_SUBCOMMANDS.has(tokens[0])) { + return completeFilePaths(engine, tokens[0] + ' ', tokens[tokens.length - 1] || ''); + } + return []; + } // --- git completions --- if (trimmed === 'git') { diff --git a/src/shell/prompt.ts b/src/shell/prompt.ts index 184eac7..7490424 100644 --- a/src/shell/prompt.ts +++ b/src/shell/prompt.ts @@ -6,6 +6,13 @@ export type PromptSegment = { }; export function generatePrompt(engine: GitEngine): PromptSegment[] { + if (!engine.isInitialized()) { + return [ + { text: 'gitverse ', color: 'dim' }, + { text: '❯ ', color: 'cyan' }, + ]; + } + const head = engine.getHEAD(); const dirty = engine.isDirty(); const staged = engine.getStagedFiles(); diff --git a/src/store/engine.ts b/src/store/engine.ts index cb6b7d1..d477acc 100644 --- a/src/store/engine.ts +++ b/src/store/engine.ts @@ -18,20 +18,16 @@ export type TerminalLine = { let lineIdCounter = 0; -const WELCOME_BANNER = `██████╗ ██╗████████╗██╗ ██╗███████╗██████╗ ███████╗███████╗ -██╔════╝ ██║╚══██╔══╝██║ ██║██╔════╝██╔══██╗██╔════╝██╔════╝ -██║ ███╗██║ ██║ ██║ ██║█████╗ ██████╔╝███████╗█████╗ -██║ ██║██║ ██║ ╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║██╔══╝ -╚██████╔╝██║ ██║ ╚████╔╝ ███████╗██║ ██║███████║███████╗ - ╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝`; - -const WELCOME_SUBTITLE = "interactive git sandbox — type 'help' for commands"; +const WELCOME_BANNER = `┌─────────────────────────────────────┐ +│ Welcome to Gitverse │ +│ A browser-based git sandbox │ +│ │ +│ Type 'git init' to begin │ +│ Type 'help' for commands │ +└─────────────────────────────────────┘`; function createInitialLines(): TerminalLine[] { - return [ - { id: ++lineIdCounter, output: WELCOME_BANNER, color: 'cyan' }, - { id: ++lineIdCounter, output: WELCOME_SUBTITLE, color: 'dim' }, - ]; + return [{ id: ++lineIdCounter, output: WELCOME_BANNER, color: 'cyan' }]; } function createEngineStore() { diff --git a/src/ui/Graph.svelte b/src/ui/Graph.svelte index bcbe6a1..d7872d5 100644 --- a/src/ui/Graph.svelte +++ b/src/ui/Graph.svelte @@ -1,6 +1,6 @@ -
+
+ +
{` ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
+██╔════╝ ██║╚══██╔══╝██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝
+██║  ███╗██║   ██║   ██║   ██║█████╗  ██████╔╝███████╗█████╗
+██║   ██║██║   ██║   ╚██╗ ██╔╝██╔══╝  ██╔══██╗╚════██║██╔══╝
+╚██████╔╝██║   ██║    ╚████╔╝ ███████╗██║  ██║███████║███████╗
+ ╚═════╝ ╚═╝   ╚═╝     ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝`}
+ +
{` ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
+██╔════╝ ██║╚══██╔══╝██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝
+██║  ███╗██║   ██║   ██║   ██║█████╗  ██████╔╝███████╗█████╗
+██║   ██║██║   ██║   ╚██╗ ██╔╝██╔══╝  ██╔══██╗╚════██║██╔══╝
+╚██████╔╝██║   ██║    ╚████╔╝ ███████╗██║  ██║███████║███████╗
+ ╚═════╝ ╚═╝   ╚═╝     ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝`}
+
diff --git a/tests/engine/branch-checkout.test.ts b/tests/engine/branch-checkout.test.ts index 0496084..a95c714 100644 --- a/tests/engine/branch-checkout.test.ts +++ b/tests/engine/branch-checkout.test.ts @@ -8,6 +8,7 @@ import { GitEngine } from '$engine/index'; /** Create engine with one commit on main so HEAD resolves to a real commit. */ function engineWithCommit(): { engine: GitEngine; commitHash: string } { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('readme.md', '# hello'); engine.execute('git add readme.md'); engine.execute('git commit -m "initial"'); @@ -70,6 +71,7 @@ describe('git branch – create', () => { it('cannot create a branch without any commits', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git branch feature'); expect(result.exitCode).not.toBe(0); }); @@ -187,6 +189,7 @@ describe('git checkout – restores working directory', () => { // (single-parent linear history), we test that WD/index is restored to // the target branch's commit tree. const engine = new GitEngine(); + engine.execute('git init'); // Commit A on main: only file-a.txt engine.getVFS().createFile('file-a.txt', 'content-a'); @@ -213,6 +216,7 @@ describe('git checkout – restores working directory', () => { it('index is reset to match target commit tree on checkout', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('file-a.txt', 'content-a'); engine.execute('git add file-a.txt'); @@ -233,6 +237,7 @@ describe('git checkout – restores working directory', () => { it('files from target branch appear after checkout', () => { const engine = new GitEngine(); + engine.execute('git init'); // Commit on main engine.getVFS().createFile('main-file.txt', 'main'); diff --git a/tests/engine/core.test.ts b/tests/engine/core.test.ts index b7ddebf..d99f552 100644 --- a/tests/engine/core.test.ts +++ b/tests/engine/core.test.ts @@ -6,6 +6,7 @@ describe('GitEngine – initial state', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); it('starts on the main branch (attached HEAD)', () => { @@ -28,6 +29,7 @@ describe('GitEngine – git add & commit', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); it('stages a specific file and shows it in getStagedFiles', () => { @@ -118,6 +120,7 @@ describe('GitEngine – git status output', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); it('shows untracked files', () => { @@ -167,6 +170,7 @@ describe('GitEngine – file state helpers', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); it('getUntrackedFiles returns files not in index', () => { @@ -212,6 +216,7 @@ describe('GitEngine – file state helpers', () => { describe('GitEngine – subscribe notifications', () => { it('fires listener on add', () => { const engine = new GitEngine(); + engine.execute('git init'); const listener = vi.fn(); engine.subscribe(listener); engine.getVFS().createFile('a.txt', 'a'); @@ -221,6 +226,7 @@ describe('GitEngine – subscribe notifications', () => { it('fires listener on commit', () => { const engine = new GitEngine(); + engine.execute('git init'); const listener = vi.fn(); engine.subscribe(listener); engine.getVFS().createFile('a.txt', 'a'); @@ -232,6 +238,7 @@ describe('GitEngine – subscribe notifications', () => { it('unsubscribe stops notifications', () => { const engine = new GitEngine(); + engine.execute('git init'); const listener = vi.fn(); const unsub = engine.subscribe(listener); unsub(); @@ -244,12 +251,14 @@ describe('GitEngine – subscribe notifications', () => { describe('GitEngine – unknown command', () => { it('returns non-zero exit code for unknown command', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git foobar'); expect(result.exitCode).not.toBe(0); }); it('returns error message for unknown command', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git foobar'); expect(result.output).toMatch(/not a git command|unknown command/i); }); diff --git a/tests/engine/init.test.ts b/tests/engine/init.test.ts new file mode 100644 index 0000000..75112fe --- /dev/null +++ b/tests/engine/init.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GitEngine } from '$engine/index'; + +describe('GitEngine – git init', () => { + let engine: GitEngine; + + beforeEach(() => { + engine = new GitEngine(); + }); + + it('fresh engine is uninitialized', () => { + expect(engine.isInitialized()).toBe(false); + }); + + it('has no branches before init', () => { + expect(engine.allBranches().size).toBe(0); + }); + + it('git init initializes repository', () => { + const result = engine.execute('git init'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Initialized empty Git repository'); + expect(engine.isInitialized()).toBe(true); + }); + + it('creates main branch on init', () => { + engine.execute('git init'); + const head = engine.getHEAD(); + expect(head.attached).toBe(true); + expect(head.target).toBe('main'); + expect(engine.allBranches().has('main')).toBe(true); + }); + + it('creates .git/ directory on init', () => { + engine.execute('git init'); + expect(engine.getVFS().exists('.git/')).toBe(true); + }); + + it('returns "Reinitialized" on double init', () => { + engine.execute('git init'); + const result = engine.execute('git init'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Reinitialized'); + }); + + it('gates git commands before init', () => { + const result = engine.execute('git add .'); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('fatal: not a git repository'); + }); + + it('allows git commands after init', () => { + engine.execute('git init'); + engine.getVFS().createFile('test.txt', 'hello'); + const result = engine.execute('git add test.txt'); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/tests/engine/log-diff.test.ts b/tests/engine/log-diff.test.ts index 3d56366..0507d23 100644 --- a/tests/engine/log-diff.test.ts +++ b/tests/engine/log-diff.test.ts @@ -11,6 +11,7 @@ describe('git log – empty repository', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); it('returns a message when there are no commits', () => { @@ -31,6 +32,7 @@ describe('git log – full format (default)', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'alpha'); engine.execute('git add a.txt'); engine.execute('git commit -m "first commit"'); @@ -74,6 +76,7 @@ describe('git log – --oneline format', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'alpha'); engine.execute('git add a.txt'); engine.execute('git commit -m "first commit"'); @@ -119,6 +122,7 @@ describe('git log – branch decorations', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'alpha'); engine.execute('git add a.txt'); engine.execute('git commit -m "initial"'); @@ -203,6 +207,7 @@ describe('git diff – working directory vs index/committed', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); it('shows nothing when working tree is clean', () => { @@ -241,6 +246,7 @@ describe('git diff --staged / --cached', () => { beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); it('shows nothing when nothing is staged', () => { diff --git a/tests/engine/merge.test.ts b/tests/engine/merge.test.ts index 63f09bf..ebb5510 100644 --- a/tests/engine/merge.test.ts +++ b/tests/engine/merge.test.ts @@ -10,6 +10,7 @@ import { GitEngine } from '$engine/index'; */ function engineWithCommit(): GitEngine { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('readme.md', '# hello'); engine.execute('git add readme.md'); engine.execute('git commit -m "initial"'); @@ -93,6 +94,7 @@ describe('git merge – fast-forward', () => { describe('git merge – three-way', () => { it('creates a merge commit with two parents', () => { const engine = new GitEngine(); + engine.execute('git init'); // Commit A: base engine.getVFS().createFile('base.txt', 'base content'); @@ -127,6 +129,7 @@ describe('git merge – three-way', () => { it('merge commit message mentions the merged branch', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('base.txt', 'base'); engine.execute('git add base.txt'); @@ -152,6 +155,7 @@ describe('git merge – three-way', () => { it('merged tree includes files from both branches', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('shared.txt', 'shared'); engine.execute('git add shared.txt'); @@ -177,6 +181,7 @@ describe('git merge – three-way', () => { it('HEAD advances to merge commit after three-way merge', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'a'); engine.execute('git add a.txt'); @@ -205,6 +210,7 @@ describe('git merge – three-way', () => { it('index and VFS are clean after three-way merge', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'a'); engine.execute('git add a.txt'); diff --git a/tests/engine/rebase.test.ts b/tests/engine/rebase.test.ts index 9dca22f..bd60d06 100644 --- a/tests/engine/rebase.test.ts +++ b/tests/engine/rebase.test.ts @@ -7,6 +7,7 @@ import { GitEngine } from '$engine/index'; function engineWithBase(): GitEngine { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('base.txt', 'base content'); engine.execute('git add base.txt'); engine.execute('git commit -m "base"'); @@ -258,6 +259,7 @@ describe('git cherry-pick', () => { it('target commit entries override HEAD entries on collision', () => { const engine = new GitEngine(); + engine.execute('git init'); // Base with shared.txt = "original" engine.getVFS().createFile('shared.txt', 'original'); @@ -361,6 +363,7 @@ describe('git revert', () => { it('revert restores content changed by the target commit', () => { const engine = new GitEngine(); + engine.execute('git init'); // Initial: shared.txt = "original" engine.getVFS().createFile('shared.txt', 'original'); diff --git a/tests/engine/refs.test.ts b/tests/engine/refs.test.ts index 1dbaaec..237e8e2 100644 --- a/tests/engine/refs.test.ts +++ b/tests/engine/refs.test.ts @@ -10,23 +10,24 @@ describe('RefStore', () => { // --- HEAD --- - it('starts with HEAD attached to main', () => { + it('starts with HEAD detached and empty target', () => { const head = store.getHEAD(); - expect(head.attached).toBe(true); - expect(head.target).toBe('main'); + expect(head.attached).toBe(false); + expect(head.target).toBe(''); }); it('getHEAD returns a copy, not the internal reference', () => { const head = store.getHEAD(); - head.attached = false; + head.attached = true; head.target = 'tampered'; const head2 = store.getHEAD(); - expect(head2.attached).toBe(true); - expect(head2.target).toBe('main'); + expect(head2.attached).toBe(false); + expect(head2.target).toBe(''); }); it('resolveHEAD follows attached branch to its commit hash', () => { - store.updateBranch('main', 'abc123'); + store.createBranch('main', 'abc123'); + store.attachHEAD('main'); expect(store.resolveHEAD()).toBe('abc123'); }); @@ -71,7 +72,7 @@ describe('RefStore', () => { store.createBranch('alpha', 'aaa'); store.createBranch('beta', 'bbb'); const branches = store.listBranches(); - expect(branches).toEqual(['alpha', 'beta', 'main', 'zebra']); + expect(branches).toEqual(['alpha', 'beta', 'zebra']); }); it('updateBranch changes the target commit', () => { @@ -91,7 +92,8 @@ describe('RefStore', () => { }); it('deleteBranch throws when deleting current attached branch', () => { - // HEAD is attached to 'main' by default + store.createBranch('main', 'abc'); + store.attachHEAD('main'); expect(() => store.deleteBranch('main')).toThrow(); }); diff --git a/tests/engine/reset-stash-tag.test.ts b/tests/engine/reset-stash-tag.test.ts index b1f8c3c..d148205 100644 --- a/tests/engine/reset-stash-tag.test.ts +++ b/tests/engine/reset-stash-tag.test.ts @@ -18,6 +18,7 @@ function engineWithTwoCommits(): { c2Hash: string; } { const engine = new GitEngine(); + engine.execute('git init'); // C1 engine.getVFS().createFile('readme.md', '# hello'); @@ -166,6 +167,7 @@ describe('git reset --hard', () => { describe('git stash save + pop round-trip', () => { it('stash pop restores VFS to pre-stash state', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'committed'); engine.execute('git add a.txt'); engine.execute('git commit -m "base"'); @@ -190,6 +192,7 @@ describe('git stash save + pop round-trip', () => { it('stash pop restores index to pre-stash state', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'committed'); engine.execute('git add a.txt'); engine.execute('git commit -m "base"'); @@ -213,6 +216,7 @@ describe('git stash save + pop round-trip', () => { it('stash stack is empty after a single pop', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'x'); engine.execute('git add a.txt'); engine.execute('git commit -m "base"'); @@ -227,6 +231,7 @@ describe('git stash save + pop round-trip', () => { describe('git stash list', () => { it('lists stash entries in LIFO order', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'base'); engine.execute('git add a.txt'); engine.execute('git commit -m "base"'); @@ -251,6 +256,7 @@ describe('git stash list', () => { it('shows empty output when no stashes exist', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git stash list'); expect(result.exitCode).toBe(0); expect(result.output).toBe(''); @@ -260,6 +266,7 @@ describe('git stash list', () => { describe('git stash drop', () => { it('removes the most recent entry without applying', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'base'); engine.execute('git add a.txt'); engine.execute('git commit -m "base"'); @@ -279,6 +286,7 @@ describe('git stash drop', () => { it('errors when no stashes to drop', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git stash drop'); expect(result.exitCode).not.toBe(0); expect(result.output).toMatch(/no stash/i); @@ -292,6 +300,7 @@ describe('git stash drop', () => { describe('git tag – creation', () => { it('creates a tag pointing at HEAD', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'a'); engine.execute('git add a.txt'); engine.execute('git commit -m "initial"'); @@ -309,6 +318,7 @@ describe('git tag – creation', () => { it('creates a tag at a specific commit hash', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'a'); engine.execute('git add a.txt'); engine.execute('git commit -m "initial"'); @@ -329,6 +339,7 @@ describe('git tag – creation', () => { it('errors when tagging at a non-existent commit', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'a'); engine.execute('git add a.txt'); engine.execute('git commit -m "initial"'); @@ -342,6 +353,7 @@ describe('git tag – creation', () => { describe('git tag – list', () => { it('lists all tags alphabetically', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'a'); engine.execute('git add a.txt'); engine.execute('git commit -m "initial"'); @@ -362,6 +374,7 @@ describe('git tag – list', () => { it('shows empty output when no tags exist', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git tag'); expect(result.exitCode).toBe(0); expect(result.output).toBe(''); @@ -375,6 +388,7 @@ describe('git tag – list', () => { describe('git rm', () => { it('removes file from VFS', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('delete-me.txt', 'content'); engine.execute('git add delete-me.txt'); engine.execute('git commit -m "add file"'); @@ -386,6 +400,7 @@ describe('git rm', () => { it('removes file from staging index', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('tracked.txt', 'content'); engine.execute('git add tracked.txt'); engine.execute('git commit -m "add file"'); @@ -402,6 +417,7 @@ describe('git rm', () => { it('errors when file does not exist', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git rm nonexistent.txt'); expect(result.exitCode).not.toBe(0); expect(result.output).toMatch(/nonexistent\.txt/); @@ -415,6 +431,7 @@ describe('git rm', () => { describe('git mv', () => { it('renames file in VFS', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('old.txt', 'content'); engine.execute('git add old.txt'); engine.execute('git commit -m "add old"'); @@ -428,6 +445,7 @@ describe('git mv', () => { it('updates index: removes old entry, adds new entry', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('old.txt', 'hello'); engine.execute('git add old.txt'); @@ -443,6 +461,7 @@ describe('git mv', () => { it('errors when source file does not exist', () => { const engine = new GitEngine(); + engine.execute('git init'); const result = engine.execute('git mv ghost.txt other.txt'); expect(result.exitCode).not.toBe(0); expect(result.output).toMatch(/ghost\.txt/); @@ -450,6 +469,7 @@ describe('git mv', () => { it('preserves file content after move', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('src.txt', 'important data'); engine.execute('git add src.txt'); diff --git a/tests/graph/layout.test.ts b/tests/graph/layout.test.ts index dee1f90..f04aa64 100644 --- a/tests/graph/layout.test.ts +++ b/tests/graph/layout.test.ts @@ -6,6 +6,7 @@ import type { GraphNode } from '$graph/types'; function makeNode(hash: string, parents: string[], overrides: Partial = {}): GraphNode { return { hash, + type: 'commit' as const, parents, message: `commit ${hash}`, branches: [], diff --git a/tests/persistence/serializer.test.ts b/tests/persistence/serializer.test.ts index dc1b29c..63edc78 100644 --- a/tests/persistence/serializer.test.ts +++ b/tests/persistence/serializer.test.ts @@ -22,6 +22,7 @@ describe('serialize', () => { describe('round-trip: simple repo', () => { it('preserves commits and branches after round-trip', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('README.md', 'hello'); engine.execute('git add README.md'); engine.execute('git commit -m "initial commit"'); @@ -53,6 +54,7 @@ describe('round-trip: simple repo', () => { it('preserves multiple commits and parent links', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('a.txt', 'aaa'); engine.execute('git add a.txt'); engine.execute('git commit -m "first"'); @@ -77,6 +79,7 @@ describe('round-trip: simple repo', () => { it('preserves tags', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('f.txt', 'content'); engine.execute('git add f.txt'); engine.execute('git commit -m "tagged commit"'); @@ -106,6 +109,7 @@ describe('round-trip: simple repo', () => { describe('round-trip: staged files', () => { it('preserves index (staged files) after round-trip', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('staged.txt', 'staged content'); engine.execute('git add staged.txt'); // No commit — staged.txt is in the index but not committed @@ -120,6 +124,7 @@ describe('round-trip: staged files', () => { it('preserves staged content after round-trip', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('README.md', 'initial'); engine.execute('git add README.md'); engine.execute('git commit -m "init"'); @@ -140,6 +145,7 @@ describe('round-trip: staged files', () => { describe('round-trip: detached HEAD', () => { it('preserves detached HEAD state', () => { const engine = new GitEngine(); + engine.execute('git init'); engine.getVFS().createFile('x.txt', 'x'); engine.execute('git add x.txt'); engine.execute('git commit -m "c1"'); @@ -154,3 +160,33 @@ describe('round-trip: detached HEAD', () => { expect(head.target).toBe(headHash); }); }); + +describe('initialized state', () => { + it('round-trips initialized flag', () => { + const engine = new GitEngine(); + engine.execute('git init'); + const json = serialize(engine); + const restored = deserialize(json); + expect(restored.isInitialized()).toBe(true); + }); + + it('round-trips uninitialized state', () => { + const engine = new GitEngine(); + const json = serialize(engine); + const restored = deserialize(json); + expect(restored.isInitialized()).toBe(false); + }); + + it('defaults old saves without initialized field to true', () => { + const engine = new GitEngine(); + engine.execute('git init'); + engine.getVFS().createFile('readme.md', '# hello'); + engine.execute('git add readme.md'); + engine.execute('git commit -m "initial"'); + const json = serialize(engine); + const parsed = JSON.parse(json); + delete parsed.initialized; + const restored = deserialize(JSON.stringify(parsed)); + expect(restored.isInitialized()).toBe(true); + }); +}); diff --git a/tests/shell/builtins.test.ts b/tests/shell/builtins.test.ts index 5a3cd34..4f88c64 100644 --- a/tests/shell/builtins.test.ts +++ b/tests/shell/builtins.test.ts @@ -7,6 +7,7 @@ let engine: GitEngine; beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); describe('ls', () => { @@ -38,6 +39,49 @@ describe('ls', () => { expect(r.exitCode).toBe(0); expect(r.output).toBe(''); }); + + it('hides dotfiles by default', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', []); + expect(result.output).not.toContain('.git'); + expect(result.output).toContain('readme.md'); + }); + + it('shows dotfiles with -a flag', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-a']); + expect(result.output).toContain('.git'); + expect(result.output).toContain('readme.md'); + }); + + it('shows long format with -l flag', () => { + engine.getVFS().createDir('src'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-l']); + expect(result.output).toContain('drwxr-xr-x'); + expect(result.output).toContain('-rw-r--r--'); + expect(result.output).toContain('src/'); + expect(result.output).toContain('readme.md'); + }); + + it('combines -la flags', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-la']); + expect(result.output).toContain('.git'); + expect(result.output).toContain('drwxr-xr-x'); + expect(result.output).toContain('-rw-r--r--'); + }); + + it('handles separate -l -a flags', () => { + engine.getVFS().createDir('.git'); + engine.getVFS().createFile('readme.md', '# hello'); + const result = executeBuiltin(engine, 'ls', ['-l', '-a']); + expect(result.output).toContain('.git'); + expect(result.output).toContain('drwxr-xr-x'); + }); }); describe('cat', () => { @@ -112,6 +156,13 @@ describe('rm', () => { expect(r.exitCode).toBe(0); expect(engine.getVFS().exists('bye.txt')).toBe(false); }); + + it('refuses to delete .git directory', () => { + engine.getVFS().createDir('.git'); + const result = executeBuiltin(engine, 'rm', ['.git/']); + expect(result.exitCode).toBe(1); + expect(engine.getVFS().exists('.git/')).toBe(true); + }); }); describe('mv', () => { diff --git a/tests/shell/prompt.test.ts b/tests/shell/prompt.test.ts index 15803fc..5371c9d 100644 --- a/tests/shell/prompt.test.ts +++ b/tests/shell/prompt.test.ts @@ -14,6 +14,7 @@ function segmentByText(segments: PromptSegment[], text: string): PromptSegment | beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); }); describe('prompt — clean state', () => { @@ -104,6 +105,25 @@ describe('prompt — detached HEAD', () => { }); }); +describe('uninitialized state', () => { + it('shows no branch segment when uninitialized', () => { + const engine = new GitEngine(); + const segments = generatePrompt(engine); + const text = segmentText(segments); + expect(text).not.toContain('main'); + expect(text).toContain('gitverse'); + expect(text).toContain('❯'); + }); + + it('shows branch after init', () => { + const engine = new GitEngine(); + engine.execute('git init'); + const segments = generatePrompt(engine); + const text = segmentText(segments); + expect(text).toContain('main'); + }); +}); + describe('prompt — deleted files', () => { it('shows -N for deleted files in red', () => { engine.getVFS().createFile('a.txt', 'aaa'); diff --git a/tests/shell/router.test.ts b/tests/shell/router.test.ts index 7422d4a..ed41671 100644 --- a/tests/shell/router.test.ts +++ b/tests/shell/router.test.ts @@ -7,6 +7,7 @@ let router: ShellRouter; beforeEach(() => { engine = new GitEngine(); + engine.execute('git init'); router = new ShellRouter(engine); });