From f8e8e742bd8689c74babfb40a1659b6a83f091ab Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 01:54:53 +0800 Subject: [PATCH 01/16] docs: add git init requirement design spec Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-28-git-init-requirement.md | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-git-init-requirement.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..79c2bf7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-git-init-requirement.md @@ -0,0 +1,321 @@ +# 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 }; + } + + engine._initialize(); // sets flag, creates main branch, HEAD, .git/ dir + return { output: 'Initialized empty Git repository', exitCode: 0 }; +} +``` + +`_initialize()` on GitEngine: + +- Sets `this.initialized = true` +- Creates `main` branch with empty target: `this.refs.branches.set('main', '')` +- Sets HEAD: `this.refs.head = { attached: true, target: 'main' }` +- Creates `.git/` directory marker: `this.vfs.createDir('.git')` + +## 2. Command Gating + +### Router (`src/shell/router.ts`) + +Before dispatching git commands, check `engine.isInitialized()`. Gate all git commands except `git init`. + +``` +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 +``` + +### Engine Execute (`src/engine/index.ts`) + +Add init check at top of `execute()` method. If command is not `init` and not initialized, return error. This provides defense-in-depth alongside router check. + +## 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`, `-l`, `-h` flags in any combination (`-lah`, `-la`, `-al`, `-a -l`, etc.). + +**`-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. + +**`-h` (human-readable):** Accepted, no-op. No file sizes to format. + +**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/router.ts` | Gate git commands behind init check | +| `src/shell/builtins.ts` | Enhance `ls` with `-a`, `-l`, `-h` 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 | From 17def9f6a7d2b3f21c9a298ff90d0a6a8060938c Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 01:57:41 +0800 Subject: [PATCH 02/16] =?UTF-8?q?docs:=20self-review=20spec=20=E2=80=94=20?= =?UTF-8?q?apply=20KISS=20&=20DRY=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate router gating (engine is single gate point), drop no-op -h flag, inline _initialize() into cmdInit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-28-git-init-requirement.md | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/specs/2026-05-28-git-init-requirement.md b/docs/superpowers/specs/2026-05-28-git-init-requirement.md index 79c2bf7..6969863 100644 --- a/docs/superpowers/specs/2026-05-28-git-init-requirement.md +++ b/docs/superpowers/specs/2026-05-28-git-init-requirement.md @@ -53,23 +53,21 @@ export function cmdInit(engine: GitEngine): CommandResult { return { output: 'Reinitialized existing Git repository', exitCode: 0 }; } - engine._initialize(); // sets flag, creates main branch, HEAD, .git/ dir + // 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 }; } ``` -`_initialize()` on GitEngine: - -- Sets `this.initialized = true` -- Creates `main` branch with empty target: `this.refs.branches.set('main', '')` -- Sets HEAD: `this.refs.head = { attached: true, target: 'main' }` -- Creates `.git/` directory marker: `this.vfs.createDir('.git')` - ## 2. Command Gating -### Router (`src/shell/router.ts`) +### Engine Execute (`src/engine/index.ts`) -Before dispatching git commands, check `engine.isInitialized()`. Gate all git commands except `git init`. +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 @@ -85,9 +83,7 @@ Error message for blocked commands: fatal: not a git repository (or any of the parent directories): .git ``` -### Engine Execute (`src/engine/index.ts`) - -Add init check at top of `execute()` method. If command is not `init` and not initialized, return error. This provides defense-in-depth alongside router check. +Builtins bypass engine entirely (routed by shell), so they work pre-init by default. ## 3. Prompt Change @@ -121,7 +117,7 @@ Behavior: ### New Behavior -Support `-a`, `-l`, `-h` flags in any combination (`-lah`, `-la`, `-al`, `-a -l`, etc.). +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. @@ -136,8 +132,6 @@ drwxr-xr-x src/ Permissions are decorative (always `drwxr-xr-x` for dirs, `-rw-r--r--` for files). No size/date columns — simulated filesystem has no metadata. -**`-h` (human-readable):** Accepted, no-op. No file sizes to format. - **Without `-l`:** Space-separated (current behavior), respecting `-a` filter. **Flag parsing:** Extract flags from args before interpreting remaining args as directory path. @@ -304,8 +298,7 @@ Update `beforeEach` in all engine test files. Add new test file `tests/engine/in | `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/router.ts` | Gate git commands behind init check | -| `src/shell/builtins.ts` | Enhance `ls` with `-a`, `-l`, `-h` flags, protect `.git/` from `rm` | +| `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 | From 864cb465349dc38f77ee27648e63452945c8bdf9 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 02:01:34 +0800 Subject: [PATCH 03/16] docs: add git init requirement implementation plan 8 tasks, TDD, dependency graph for parallel execution. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-28-git-init-requirement.md | 870 ++++++++++++++++++ 1 file changed, 870 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-git-init-requirement.md 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..a6b703f --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-git-init-requirement.md @@ -0,0 +1,870 @@ +# 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" +``` From 49dddf455425b1a96f4bd50e978e55469256cad7 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 02:07:41 +0800 Subject: [PATCH 04/16] feat: add git init command with initialized flag and command gating Co-Authored-By: Claude Opus 4.7 (1M context) --- src/engine/commands/init.ts | 13 +++++++++ src/engine/index.ts | 28 ++++++++++++++++++ src/engine/refs.ts | 4 +-- tests/engine/init.test.ts | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/engine/commands/init.ts create mode 100644 tests/engine/init.test.ts 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/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); + }); +}); From 1a72a95ae723ce7f732c837a23fb0c10998e23d5 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 02:13:02 +0800 Subject: [PATCH 05/16] feat: add -a and -l flags to ls builtin Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shell/builtins.ts | 26 ++++++++++++++++++++-- tests/shell/builtins.test.ts | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/shell/builtins.ts b/src/shell/builtins.ts index a845365..0d9e111 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 }; } diff --git a/tests/shell/builtins.test.ts b/tests/shell/builtins.test.ts index 5a3cd34..e14962b 100644 --- a/tests/shell/builtins.test.ts +++ b/tests/shell/builtins.test.ts @@ -38,6 +38,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', () => { From e049a309e6275546ddd042e99fec98add54af6be Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 02:14:05 +0800 Subject: [PATCH 06/16] docs: remove non-existent sim command from README and CLAUDE.md Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 | From 86833c95aa02e45a569d20226ceeb98a5f6d31dd Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 02:17:38 +0800 Subject: [PATCH 07/16] feat: prompt hides branch pre-init, autocomplete filters pre-init, guard .git/ deletion Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shell/builtins.ts | 3 +++ src/shell/complete.ts | 19 +++++++++++++++++++ src/shell/prompt.ts | 7 +++++++ tests/shell/builtins.test.ts | 7 +++++++ tests/shell/prompt.test.ts | 19 +++++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/src/shell/builtins.ts b/src/shell/builtins.ts index 0d9e111..1461a36 100644 --- a/src/shell/builtins.ts +++ b/src/shell/builtins.ts @@ -101,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/tests/shell/builtins.test.ts b/tests/shell/builtins.test.ts index e14962b..623b7fc 100644 --- a/tests/shell/builtins.test.ts +++ b/tests/shell/builtins.test.ts @@ -155,6 +155,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..5726199 100644 --- a/tests/shell/prompt.test.ts +++ b/tests/shell/prompt.test.ts @@ -104,6 +104,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'); From 5179e341ffd4b797df555e41edcf49e89fcac892 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 02:19:30 +0800 Subject: [PATCH 08/16] feat: serialize/deserialize initialized flag with backwards compat Co-Authored-By: Claude Sonnet 4.6 --- src/persistence/serializer.ts | 9 +++++++++ tests/persistence/serializer.test.ts | 30 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) 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/tests/persistence/serializer.test.ts b/tests/persistence/serializer.test.ts index dc1b29c..9386595 100644 --- a/tests/persistence/serializer.test.ts +++ b/tests/persistence/serializer.test.ts @@ -154,3 +154,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); + }); +}); From 4333f416a5664694188ba135566ed2fac106db6a Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 02:21:31 +0800 Subject: [PATCH 09/16] feat: phantom node on graph after git init with zero commits Co-Authored-By: Claude Opus 4.7 (1M context) --- src/graph/types.ts | 1 + src/ui/Graph.svelte | 86 ++++++++++++++++++++++++++------------ tests/graph/layout.test.ts | 1 + 3 files changed, 62 insertions(+), 26 deletions(-) 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/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 @@ -
+
+
{`
+   ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
+  ██╔════╝ ██║╚══██╔══╝██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝
+  ██║  ███╗██║   ██║   ██║   ██║█████╗  ██████╔╝███████╗█████╗
+  ██║   ██║██║   ██║   ╚██╗ ██╔╝██╔══╝  ██╔══██╗╚════██║██╔══╝
+  ╚██████╔╝██║   ██║    ╚████╔╝ ███████╗██║  ██║███████║███████╗
+   ╚═════╝ ╚═╝   ╚═╝     ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝`.trim()}
+
From 6a0854c777e1427b5328852896b2e35caa69d948 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 10:11:57 +0800 Subject: [PATCH 15/16] feat: ASCII art more prominent (60% opacity), add mobile centered variant Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ui/Layout.svelte | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui/Layout.svelte b/src/ui/Layout.svelte index 41dc69c..e5780a6 100644 --- a/src/ui/Layout.svelte +++ b/src/ui/Layout.svelte @@ -4,8 +4,18 @@
+
{`
+    class="absolute top-3 right-4 z-10 font-mono text-terminal-dim/60 text-xs leading-tight select-none pointer-events-none max-sm:hidden">{`
+   ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
+  ██╔════╝ ██║╚══██╔══╝██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝
+  ██║  ███╗██║   ██║   ██║   ██║█████╗  ██████╔╝███████╗█████╗
+  ██║   ██║██║   ██║   ╚██╗ ██╔╝██╔══╝  ██╔══██╗╚════██║██╔══╝
+  ╚██████╔╝██║   ██║    ╚████╔╝ ███████╗██║  ██║███████║███████╗
+   ╚═════╝ ╚═╝   ╚═╝     ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝`.trim()}
+ +
{`
    ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
   ██╔════╝ ██║╚══██╔══╝██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝
   ██║  ███╗██║   ██║   ██║   ██║█████╗  ██████╔╝███████╗█████╗

From c98cb438e91bea2eba9177d79469aae71ae2e3c9 Mon Sep 17 00:00:00 2001
From: opariffazman 
Date: Thu, 28 May 2026 10:16:02 +0800
Subject: [PATCH 16/16] fix: ASCII art first line alignment, bump opacity to
 80%

.trim() stripped leading spaces from first line. Removed trim,
preserved relative indentation across all 6 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/ui/Layout.svelte | 26 ++++++++++++--------------
 1 file changed, 12 insertions(+), 14 deletions(-)

diff --git a/src/ui/Layout.svelte b/src/ui/Layout.svelte
index e5780a6..4c4cbf5 100644
--- a/src/ui/Layout.svelte
+++ b/src/ui/Layout.svelte
@@ -6,22 +6,20 @@
 
{`
-   ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
-  ██╔════╝ ██║╚══██╔══╝██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝
-  ██║  ███╗██║   ██║   ██║   ██║█████╗  ██████╔╝███████╗█████╗
-  ██║   ██║██║   ██║   ╚██╗ ██╔╝██╔══╝  ██╔══██╗╚════██║██╔══╝
-  ╚██████╔╝██║   ██║    ╚████╔╝ ███████╗██║  ██║███████║███████╗
-   ╚═════╝ ╚═╝   ╚═╝     ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝`.trim()}
+ class="absolute top-3 right-4 z-10 font-mono text-terminal-dim/80 text-xs leading-tight select-none pointer-events-none max-sm:hidden">{` ██████╗ ██╗████████╗██╗ ██╗███████╗██████╗ ███████╗███████╗ +██╔════╝ ██║╚══██╔══╝██║ ██║██╔════╝██╔══██╗██╔════╝██╔════╝ +██║ ███╗██║ ██║ ██║ ██║█████╗ ██████╔╝███████╗█████╗ +██║ ██║██║ ██║ ╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║██╔══╝ +╚██████╔╝██║ ██║ ╚████╔╝ ███████╗██║ ██║███████║███████╗ + ╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝`}
{`
-   ██████╗ ██╗████████╗██╗   ██╗███████╗██████╗ ███████╗███████╗
-  ██╔════╝ ██║╚══██╔══╝██║   ██║██╔════╝██╔══██╗██╔════╝██╔════╝
-  ██║  ███╗██║   ██║   ██║   ██║█████╗  ██████╔╝███████╗█████╗
-  ██║   ██║██║   ██║   ╚██╗ ██╔╝██╔══╝  ██╔══██╗╚════██║██╔══╝
-  ╚██████╔╝██║   ██║    ╚████╔╝ ███████╗██║  ██║███████║███████╗
-   ╚═════╝ ╚═╝   ╚═╝     ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝`.trim()}
+ class="absolute top-2 left-1/2 -translate-x-1/2 z-10 font-mono text-terminal-dim/80 text-[5px] leading-tight select-none pointer-events-none sm:hidden">{` ██████╗ ██╗████████╗██╗ ██╗███████╗██████╗ ███████╗███████╗ +██╔════╝ ██║╚══██╔══╝██║ ██║██╔════╝██╔══██╗██╔════╝██╔════╝ +██║ ███╗██║ ██║ ██║ ██║█████╗ ██████╔╝███████╗█████╗ +██║ ██║██║ ██║ ╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║██╔══╝ +╚██████╔╝██║ ██║ ╚████╔╝ ███████╗██║ ██║███████║███████╗ + ╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝`}