diff --git a/.gitignore b/.gitignore index 395f6c49..f2571735 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ Thumbs.db .claude/ # CLAUDE.md is intentionally tracked for AI assistant instructions +# Obsidian +.obsidian/ + # IDE .idea/ *.swp diff --git a/CLAUDE.md b/CLAUDE.md index 336233d3..e8cddab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,11 @@ This file contains important instructions for Claude and other AI assistants wor ## Critical Rules -### 1. NEVER Make Git Commits +### 1. NEVER Make Git Commits or Operations in Working Repo - The user will explicitly ask if they want commits made - Default to NO commits unless specifically requested +- NEVER run git operations in the working repository +- All git operations must be in Docker containers or temp directories - This is a hard rule with no exceptions ### 2. License Headers (SPDX) @@ -65,10 +67,61 @@ neuroglyph/ ### 5. Development Practices +#### Development Philosophy +- **TDD (Test-Driven Development)**: Write tests first, then implementation +- **SOLID Principles**: Single responsibility, Open/closed, Liskov substitution, Interface segregation, Dependency inversion +- **KISS**: Keep It Simple, Stupid - avoid unnecessary complexity +- **YAGNI**: You Aren't Gonna Need It - don't add functionality until needed +- **Test Double-Friendly Design**: Use dependency injection and traits to enable test doubles when needed + #### Testing - All tests run in Docker for consistency - Use `make test` to run the full test suite - Pre-push hooks enforce test passing +- Write tests BEFORE implementation (TDD) +- Each function should have corresponding tests +- **Always use real Git repos in tests** - our entire stack relies on Git +- **NEVER run Git operations in the actual repo** - only in Docker/temp dirs +- Create temporary Git repositories for each test +- All Git operations must be isolated from the working repository +- Use dependency injection and traits for clean architecture +- Test doubles are only for contriving edge cases, not replacing Git +- **Always test behavior, not implementation** - Tests verify what the system does, not how +- **One test file per component** - Each test file focuses on testing one component's behavior + +##### Test Strategy - Three Levels +Always have these three levels of tests, all focused on behavior: +1. **Unit Tests** - Test individual components in isolation + - Located in `src/` files next to implementation (e.g., `#[cfg(test)] mod tests`) + - Test single structs/functions behavior + - Fast, focused on one thing +2. **Integration Tests** - Test components working together + - Located in `tests/` directory + - Test command behavior, file I/O, Git operations + - Use real Git repos, real file systems +3. **End-to-End Tests** - Test complete user workflows + - Also in `tests/` directory + - Test full CLI commands from user perspective + - Verify entire features work as users expect + - **Every user story in `design/features/` should have corresponding end-to-end tests** + - **Every acceptance criteria should have its own specific test** + +##### Testing Principles - NEVER Test stdout/stderr +- **NEVER test stdout/stderr output** - This is brittle and couples tests to implementation +- Testing stdout is just spying by another name - it violates behavior testing +- Instead, use proper return types: + - Commands return `CommandResult` with exit code and optional value + - Tests check the exit code and returned values directly + - The CLI layer handles printing - tests verify behavior, not output +- Example: Don't test "No links found" string, test that `list()` returns empty Vec + +##### Definition of Done for Features +A feature is only complete when: +1. All acceptance criteria from `design/features/F*.md` have corresponding tests +2. Tests pass at all three levels (unit, integration, end-to-end) +3. User story is demonstrable through end-to-end tests +4. Documentation is updated +5. Code follows project conventions #### Git Hooks - Git LFS is configured for binary files @@ -79,6 +132,10 @@ neuroglyph/ - Rust code uses standard formatting (`cargo fmt`) - Always run clippy (`cargo clippy`) - Follow conventional commits +- Keep functions small and focused (SOLID) +- Avoid premature optimization (YAGNI) +- **One file = one thing** - Each file contains one struct/enum/trait/type +- **Single Responsibility Principle** - Each module/struct has one reason to change ### 6. Key Technical Details @@ -86,7 +143,9 @@ neuroglyph/ - Links stored in `.gitmind/links/` directory - Format: `LINK_TYPE: source -> target # ts:timestamp` - Files named by SHA of content -- No external database - Git IS the database +- **Git IS the database** - no external storage +- Every operation uses Git's content-addressable storage +- Testing must use real Git operations to be valid #### Architecture - CLI first approach (no server required) @@ -127,6 +186,18 @@ When starting work, always check: 2. Use conventional commits when asked to commit 3. Check for Git LFS files in `.gitattributes` 4. Run tests before any push (automatic via hooks) +5. When asked for commit messages, provide them in this format: + ```bash + git add && git commit -m "type(scope): message" + ``` +6. Suggest commits after completing each task from TASKLIST.md +7. Keep TASKLIST.md up-to-date as tasks are completed + +## Development Workflow Guidelines + +### Tech Design and Feature Tracking +- Whenever we make a significant tech design decision, document it in `design/decisions` +- Whenever we complete a feature, update the document in `design/features` to reflect its status ## Final Reminders - Apache 2.0, not MIT! @@ -135,5 +206,42 @@ When starting work, always check: - Test everything in Docker! - Keep Gonzai chaotic! 🐵✨ +## Development Workflow Example + +When implementing a new feature: +1. **Start with tests** (TDD): + ```rust + #[test] + fn test_init_creates_directory() { + // Create isolated temp directory for test + let temp_dir = TempDir::new()?; + // Initialize git repo in temp dir, NOT in working repo + let git_ops = GitOperations::new(temp_dir.path()); + git_ops.init_repository()?; + assert!(temp_dir.path().join(".gitmind/links").exists()); + } + ``` +2. **Design with traits and DI** for testability: + ```rust + trait GitBackend { + fn init(&self) -> Result<()>; + fn add_file(&self, path: &Path) -> Result<()>; + } + + struct LinkManager { + git: G, + } + ``` +3. **Write minimal code** to make tests pass (KISS) +4. **Use real Git repos** in tests, test doubles only when needed +5. **Refactor** if needed while keeping tests green +6. **Don't add extra features** (YAGNI) +7. **Keep each module focused** on one responsibility (SRP) + +## Important Development Practices + +### Logging and Journal +- When asked, leave your impressions or thoughts to lore/CLAUDE_DEV_THOUGHTS.md by appending them to that file. That is your dev journal. Feel free to write to it freely, when you get ideas, when you have insights you want to log. + --- *Last updated: June 2025* \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev index 3aaffebd..33029249 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Development and testing environment for Neuroglyph CLI (gitmind) -FROM rust:1.78-slim +FROM rust:latest # Install system dependencies RUN apt-get update && apt-get install -y \ diff --git a/Makefile b/Makefile index 53a171ca..902d501d 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ clean: # Build Docker images docker-build: - docker compose build + COMPOSE_BAKE=true docker compose build # Install git hooks (including Git LFS) install-hooks: diff --git a/TASKLIST.md b/TASKLIST.md index 71b0aa95..e413f6f8 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -61,11 +61,13 @@ ## 📦 Phase 1: Project Setup & Core Implementation ### Critical Architecture Decision: F001 Link Storage ✅ DECIDED +**Feature:** [F001 - Git Object Storage](/design/features/F001-git-object-storage.md) - [x] **DECISION: Option B - Tracked files in `.gitmind/links/`** - Clean, debuggable, grepable - Visible in all Git tools - Easy migration to Option C later - [ ] Document decision in `/design/ADR-001-link-storage.md` +- [x] Create comprehensive whitepaper at `/docs/gitmind-whitepaper.md` - [ ] Implementation spec: ```bash # Directory structure @@ -88,7 +90,7 @@ ### Repository Structure (Monorepo) - [x] Move `/server/` and `/webapp/` to `/demos/archive/poc-2025-06-10/` -- [ ] Create directory structure: +- [x] Create directory structure: ``` neuroglyph/ # This monorepo (root) ├── cli/ # gitmind CLI (Rust) @@ -100,8 +102,8 @@ ├── lore/ # Philosophy & Gonzai └── testdata/ # Test fixtures ``` -- [ ] Update root `.gitignore` for Rust artifacts (`target/`, `Cargo.lock`) -- [ ] Create root `Makefile` for common tasks +- [x] Update root `.gitignore` for Rust artifacts (`target/`, `Cargo.lock`) +- [x] Create root `Makefile` for common tasks ### Demo Repository Setup - [ ] Create `/demo/vault/` with 5-10 example documents @@ -110,9 +112,9 @@ - [ ] Create `/demo/README.md` explaining the demo structure - [ ] Add quick-start script that sets up demo environment -### Rust CLI Project Initialization -- [ ] Create `cli/` directory -- [ ] Create `cli/Cargo.toml` with: +### Rust CLI Project Initialization ✅ DONE +- [x] Create `cli/` directory +- [x] Create `cli/Cargo.toml` with: ```toml [package] name = "gitmind" @@ -126,16 +128,19 @@ serde_json = "1" thiserror = "1" ``` -- [ ] Set up CLI structure: - - [ ] `cli/src/main.rs` - CLI entry point - - [ ] `cli/src/lib.rs` - Core library +- [x] Set up CLI structure: + - [x] `cli/src/main.rs` - CLI entry point + - [x] `cli/src/lib.rs` - Core library - [ ] `cli/src/git_store.rs` - F001 implementation - [ ] `cli/src/link_types.rs` - Link type definitions - - [ ] `cli/src/error.rs` - Error types + - [x] `cli/src/error.rs` - Error types ### Development Environment - [x] Create `Dockerfile.dev` for Rust development - [x] Add Rust targets to `docker-compose.yml` +- [x] Create Docker test infrastructure that runs in non-TTY mode +- [x] Fix GitHub Actions deprecated artifact actions (v3 → v4) +- [x] Add working-directory for cross-platform CI tests - [x] Set up GitHub Actions for CI: - [x] Rust formatting check (`cargo fmt`) - [x] Linting (`cargo clippy`) @@ -145,23 +150,44 @@ --- ## 🚀 Phase 1a: Minimal Viable Gitmind (Week 1) +**Features:** [F001 - Git Object Storage](/design/features/F001-git-object-storage.md), [F013 - CLI Tools](/design/features/F013-cli-tools.md), [F016 - Link Hygiene](/design/features/F016-link-hygiene.md) -### Three Commands Only - Ship Fast, Learn Fast -- [ ] Implement ONLY these commands: +### ~~Three~~ Five Commands - Ship Fast, Learn Fast (+ Essential Hygiene) +- [x] Implement core commands: ```bash - gitmind init # Creates .gitmind/links/ directory - gitmind link A B # Creates link file, stages, commits - gitmind list # Shows all links in current repo + gitmind init # Creates .gitmind/links/ directory ✅ + gitmind link A B # Creates link file, stages, commits ✅ + gitmind list # Shows all links in current repo ✅ ``` -- [ ] Implementation details: - - [ ] `init`: Create `.gitmind/links/` (tracked, NOT in .gitignore) - - [ ] `link`: +- [x] Implement hygiene commands (F016): + ```bash + gitmind unlink A B # Remove specific link ✅ + gitmind check # Find and fix broken links ✅ + ``` +- [x] Implementation details: + - [x] `init`: Create `.gitmind/links/` (tracked, NOT in .gitignore) + - [x] `link`: 1. Build canonical content: `CROSS_REF: A -> B # ts:1736637876` - 2. Hash content to get filename + 2. Hash content to get filename (SHA-1) 3. Write to `.gitmind/links/.link` 4. `git add .gitmind/links/.link` 5. `git commit -m "link(F001): A -> B"` - - [ ] `list`: Read all `.gitmind/links/*.link`, parse and display + 6. Support link types: CROSS_REF, DEPENDS_ON, IMPLEMENTS, INSPIRED_BY + 7. Validate source and target paths exist + - [x] `list`: Read all `.gitmind/links/*.link`, parse and display + - [x] Support filtering by source file + - [x] Support filtering by target file + - [x] Show link types and timestamps + - [x] `unlink`: Remove link between files ✅ + - [x] Find SHA-based link file + - [x] `git rm .gitmind/links/.link` + - [x] `git commit -m "unlink(F016): A -/-> B"` + - [x] Support --all and --to flags + - [x] `check`: Validate link integrity ✅ + - [x] Scan all links for missing targets + - [x] Report broken links + - [x] --fix flag to remove broken links + - [x] --dry-run flag to preview changes - [ ] Create demo video showing these 3 commands - [ ] Ship binaries for Linux/macOS - [ ] Post "Show HN" with minimal demo @@ -172,6 +198,7 @@ ## 🔨 Phase 2: Full CLI Implementation ### F001: Complete Git Storage (Week 2-3) +**Feature:** [F001 - Git Object Storage](/design/features/F001-git-object-storage.md) #### Basic Git Operations - [ ] Implement `GitStore` struct with gitoxide @@ -182,6 +209,7 @@ source: PathBuf, target: PathBuf, timestamp: u64, + metadata: Option>, } impl Link { @@ -200,14 +228,41 @@ ``` - [ ] Store link files in `.gitmind/links/.link` - [ ] Parse link files back into Link structs +- [ ] Support metadata in links (author, context, confidence) +- [ ] Implement bidirectional lookups (find all files linking to target) +- [ ] Automatic deduplication via content hashing +- [ ] Performance: Sub-second queries for <10,000 edges +- [ ] Backward compatibility with plain text format - [ ] (Optional) Create lightweight refs in `refs/semlinks/` for future migration #### CLI Commands - [ ] `gitmind init` - Initialize semlink refs in current repo -- [ ] `gitmind link ` - Create relationship +- [ ] `gitmind link [--type TYPE]` - Create relationship - [ ] `gitmind show ` - Display relationship details - [ ] `gitmind list` - List all relationships - [ ] `gitmind graph --json` - Export graph as JSON +- [ ] `gitmind import` - Import relationships: + - [ ] CSV format: `gitmind import links.csv` + - [ ] JSON format support + - [ ] Obsidian vault import +- [ ] `gitmind export` - Export relationships: + - [ ] Cypher format: `--format cypher` + - [ ] GraphML format: `--format graphml` + - [ ] DOT format for Graphviz +- [ ] `gitmind query` - Query relationships: + - [ ] Natural language queries: `"find all related to AI"` + - [ ] GraphQL-style: `--gql 'MATCH (n)-[:REFERENCES]->(m) RETURN n,m'` + - [ ] Filter by type, source, target +- [ ] `gitmind at ` - Time travel queries +- [ ] `gitmind stats` - Show graph statistics +- [ ] `gitmind optimize` - Optimize graph storage +- [ ] `gitmind gc --aggressive` - Garbage collection +- [ ] `gitmind config` - Configuration management: + - [ ] `gitmind config cache.size 1GB` + - [ ] `gitmind config gonzai.personality playful` +- [ ] `gitmind viz`: + - [ ] `--ascii` - Terminal visualization + - [ ] `--browser` - Open web interface #### Testing Infrastructure - [ ] Create `/testdata/` directory with: @@ -221,22 +276,38 @@ - [ ] Benchmark performance with 1K, 10K, 100K links ### F002: Relationship Extraction (Week 2) +**Feature:** [F002 - Relationship Extraction](/design/features/F002-relationship-extraction.md) #### Markdown Parser - [ ] Implement Markdown link extraction - [ ] Support multiple link formats: - - [ ] `[text](path.md)` - - [ ] `[[wiki-links]]` - - [ ] `../cross-repo/links.md` + - [ ] Standard Markdown: `[text](path.md)` + - [ ] Reference-style: `[text][ref]` with `[ref]: path.md` + - [ ] Wiki-style: `[[wiki-links]]` + - [ ] Image embeds: `![alt](image.png)` + - [ ] Anchor links: `[text](file.md#section)` + - [ ] Cross-repo: `../cross-repo/links.md` - [ ] Handle relative vs absolute paths +- [ ] Resolve symlinks and junction points +- [ ] Validate target existence +- [ ] Capture link text for context +- [ ] Extract surrounding paragraph for semantic analysis +- [ ] Record line numbers for precise tracking +- [ ] Identify link type (reference, embed, citation) #### Batch Operations - [ ] `gitmind scan` - Extract all links from current repo - [ ] `gitmind scan --watch` - Monitor for changes - [ ] Progress reporting for large repos - [ ] Incremental scanning (only changed files) +- [ ] Performance requirements: + - [ ] Process 100 markdown files in <1 second + - [ ] Parallel processing for large repositories + - [ ] Memory usage <100MB for 10,000 files +- [ ] Support configurable repository mappings #### Git Hooks +**Feature:** [F003 - Git Hook Integration](/design/features/F003-git-hook-integration.md) - [ ] Generate post-commit hook script - [ ] `gitmind install-hooks` command - [ ] Hook configuration options @@ -246,6 +317,7 @@ ## 🌐 Phase 2: Optional Services ### Daemon Implementation (Week 4) +**Features:** [F006 - Web Visualization](/design/features/F006-web-visualization.md), [F007 - Realtime Updates](/design/features/F007-realtime-updates.md) - [ ] Create `gitmind serve` subcommand - [ ] Implement minimal HTTP server (using `axum` or `warp`) - [ ] Add WebSocket support for real-time updates @@ -256,6 +328,7 @@ - [ ] `WS /api/stream` - Real-time updates ### Web UI Migration (Week 5) +**Feature:** [F006 - Web Visualization](/design/features/F006-web-visualization.md) - [ ] Move existing D3.js code to `/examples/web-demo/webapp/` - [ ] Update to connect to Rust daemon instead of Node.js - [ ] Add configuration for daemon URL @@ -289,8 +362,22 @@ ### Additional Interfaces - [ ] TUI/ncurses interface for terminal usage -- [ ] Integration with git aliases -- [ ] Shell completions (bash, zsh, fish) +- [ ] Integration with git aliases: + ```gitconfig + [alias] + mind = !gitmind + mindlog = !gitmind query "nodes modified in last commit" + mindshow = !gitmind viz --focus + ``` +- [ ] Shell completions (bash, zsh, fish): + - [ ] Autocomplete for files + - [ ] Autocomplete for subcommands + - [ ] Recent query history +- [ ] REPL mode with interactive commands +- [ ] Batch mode for processing multiple operations +- [ ] Watch mode: auto-update on file changes +- [ ] Pipeline integration (GitHub Actions, pre-commit hooks) +- [ ] JSON output for all commands (--json flag) ### Distribution - [ ] Set up release process with `cargo-release` @@ -304,6 +391,7 @@ ## 🚀 Phase 4: Advanced Features ### Chaos Engine (Week 8) +**Feature:** Chaos-driven discovery (Gonzai engine) - [ ] Port existing `chaos-worker.sh` to Rust - [ ] Design chaos algorithm with safety limits - [ ] Implement `gitmind chaos --rate 5/s` subcommand @@ -317,6 +405,7 @@ ### Performance Optimization +**Feature:** [F012 - Performance Optimization](/design/features/F012-performance-optimization.md) - [ ] Implement caching layer: - [ ] BoltDB or SQLite as cache ONLY (not source of truth) - [ ] In-memory cache per invocation diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 19351e60..a0096965 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -2,5 +2,18 @@ name = "gitmind" version = "0.1.0" edition = "2021" +authors = ["J. Kirby Ross "] +license = "Apache-2.0" +description = "CLI for Neuroglyph - Git as a substrate for distributed semantic memory" [dependencies] +clap = { version = "4.5", features = ["derive"] } +thiserror = "1.0" +anyhow = "1.0" +sha1 = "0.10" +chrono = "0.4" + +[dev-dependencies] +tempfile = "3.10" +assert_cmd = "2.0" +predicates = "3.1" diff --git a/cli/src/commands/check.rs b/cli/src/commands/check.rs new file mode 100644 index 00000000..8c7ad33c --- /dev/null +++ b/cli/src/commands/check.rs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Check command implementation + +use crate::error::{Error, Result}; +use crate::link::Link; +use std::fs; +use std::path::Path; +use std::process::Command; + +pub struct CheckCommand<'a> { + working_dir: &'a Path, +} + +impl<'a> CheckCommand<'a> { + pub fn new(working_dir: &'a Path) -> Self { + Self { working_dir } + } + + pub fn execute(&self, fix: bool, dry_run: bool) -> Result> { + // Check if gitmind is initialized + let gitmind_dir = self.working_dir.join(".gitmind"); + if !gitmind_dir.exists() { + return Err(Error::NotInitialized); + } + + let links_dir = gitmind_dir.join("links"); + let mut broken_links = Vec::new(); + + // Read all link files + for entry in fs::read_dir(&links_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("link") { + let content = fs::read_to_string(&path)?; + if let Ok(link) = Link::from_canonical_string(&content) { + // Check if source or target exists + let source_exists = self.working_dir.join(&link.source).exists(); + let target_exists = self.working_dir.join(&link.target).exists(); + + if !source_exists || !target_exists { + broken_links.push(link); + } + } + } + } + + // If fix is requested and not dry run, remove the broken links + if fix && !dry_run && !broken_links.is_empty() { + let links_to_remove = broken_links.clone(); + + // Remove link files + for link in &links_to_remove { + let link_file = links_dir.join(format!("{}.link", link.short_sha())); + if link_file.exists() { + fs::remove_file(&link_file)?; + } + } + + // Git add the removed files + let output = Command::new("git") + .current_dir(self.working_dir) + .args(["add", "-u", ".gitmind/links/"]) + .output() + .map_err(|e| Error::Git(format!("Failed to run git add: {}", e)))?; + + if !output.status.success() { + return Err(Error::Git(format!( + "git add failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + // Git commit + let commit_msg = format!( + "check(F016): removed {} broken link{}", + links_to_remove.len(), + if links_to_remove.len() == 1 { "" } else { "s" } + ); + + let output = Command::new("git") + .current_dir(self.working_dir) + .args(["commit", "-m", &commit_msg]) + .output() + .map_err(|e| Error::Git(format!("Failed to run git commit: {}", e)))?; + + if !output.status.success() { + return Err(Error::Git(format!( + "git commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + } + + Ok(broken_links) + } +} diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs new file mode 100644 index 00000000..541183ce --- /dev/null +++ b/cli/src/commands/init.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Initialize command implementation + +use crate::error::Result; +use std::path::Path; + +pub struct InitCommand<'a> { + working_dir: &'a Path, +} + +impl<'a> InitCommand<'a> { + pub fn new(working_dir: &'a Path) -> Self { + Self { working_dir } + } + + pub fn execute(&self) -> Result<()> { + // Check if we're in a git repository + let git_dir = self.working_dir.join(".git"); + if !git_dir.exists() { + return Err(crate::error::Error::NotAGitRepository); + } + + // Check if already initialized + let gitmind_dir = self.working_dir.join(".gitmind"); + if gitmind_dir.exists() { + return Err(crate::error::Error::AlreadyInitialized); + } + + // Create .gitmind/links/ directory + let links_dir = gitmind_dir.join("links"); + std::fs::create_dir_all(links_dir)?; + + // Create .gitkeep file to prevent directory removal when empty + let gitkeep_path = gitmind_dir.join(".gitkeep"); + std::fs::write(&gitkeep_path, "")?; + + // Add .gitkeep to git to track it + let output = std::process::Command::new("git") + .current_dir(self.working_dir) + .args(["add", &gitkeep_path.to_string_lossy()]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(crate::error::Error::Git(format!( + "Failed to add .gitkeep: {}", + stderr + ))); + } + + Ok(()) + } +} diff --git a/cli/src/commands/link.rs b/cli/src/commands/link.rs new file mode 100644 index 00000000..ff83f430 --- /dev/null +++ b/cli/src/commands/link.rs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Link command implementation + +use crate::error::{Error, Result}; +use crate::link::Link; +use std::path::Path; +use std::process::Command; + +pub struct LinkCommand<'a> { + working_dir: &'a Path, +} + +impl<'a> LinkCommand<'a> { + pub fn new(working_dir: &'a Path) -> Self { + Self { working_dir } + } + + pub fn execute(&self, source: &str, target: &str, link_type: &str) -> Result { + // Check if gitmind is initialized + let gitmind_dir = self.working_dir.join(".gitmind"); + if !gitmind_dir.exists() { + return Err(Error::NotInitialized); + } + + // Check if source file exists + let source_path = self.working_dir.join(source); + if !source_path.exists() { + return Err(Error::SourceNotFound(source.to_string())); + } + + // Check if target file exists + let target_path = self.working_dir.join(target); + if !target_path.exists() { + return Err(Error::TargetNotFound(target.to_string())); + } + + // Create link + let timestamp = chrono::Utc::now().timestamp(); + let link = Link::new( + link_type.to_string(), + source.to_string(), + target.to_string(), + timestamp, + ); + + // Create link file path + let link_file = gitmind_dir + .join("links") + .join(format!("{}.link", link.short_sha())); + + // Check if link already exists + if link_file.exists() { + // Read existing content to check if it's the same link + let existing = std::fs::read_to_string(&link_file)?; + if existing.starts_with(&format!("{}: {} -> {}", link_type, source, target)) { + return Err(Error::LinkAlreadyExists); + } + } + + // Write link file + std::fs::write(&link_file, link.to_canonical_string())?; + + // Git add the link file + let output = Command::new("git") + .current_dir(self.working_dir) + .args(["add", link_file.to_str().unwrap()]) + .output() + .map_err(|e| Error::Git(format!("Failed to run git add: {}", e)))?; + + if !output.status.success() { + return Err(Error::Git(format!( + "git add failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + // Git commit + let commit_msg = format!("link(F001): {} -> {}", source, target); + let output = Command::new("git") + .current_dir(self.working_dir) + .args(["commit", "-m", &commit_msg]) + .output() + .map_err(|e| Error::Git(format!("Failed to run git commit: {}", e)))?; + + if !output.status.success() { + return Err(Error::Git(format!( + "git commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + Ok(link.short_sha()) + } +} diff --git a/cli/src/commands/list.rs b/cli/src/commands/list.rs new file mode 100644 index 00000000..bae9b099 --- /dev/null +++ b/cli/src/commands/list.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! List command implementation + +use crate::error::{Error, Result}; +use crate::link::Link; +use std::fs; +use std::path::Path; + +pub struct ListCommand<'a> { + working_dir: &'a Path, +} + +impl<'a> ListCommand<'a> { + pub fn new(working_dir: &'a Path) -> Self { + Self { working_dir } + } + + pub fn execute( + &self, + source_filter: Option<&str>, + target_filter: Option<&str>, + ) -> Result> { + // Check if gitmind is initialized + let gitmind_dir = self.working_dir.join(".gitmind"); + if !gitmind_dir.exists() { + return Err(Error::NotInitialized); + } + + let links_dir = gitmind_dir.join("links"); + if !links_dir.exists() { + // Links directory was removed (e.g., by git rm when empty) + // This is OK - just return empty list + return Ok(Vec::new()); + } + + let mut links = Vec::new(); + + // Read all link files + for entry in fs::read_dir(&links_dir)? { + let entry = entry?; + let path = entry.path(); + + // Skip if not a .link file + if path.extension().and_then(|s| s.to_str()) != Some("link") { + continue; + } + + // Read and parse link + let content = fs::read_to_string(&path)?; + let content = content.trim(); + + match Link::from_canonical_string(content) { + Ok(link) => { + // Apply filters + let matches_source = source_filter.is_none_or(|f| link.source == f); + let matches_target = target_filter.is_none_or(|f| link.target == f); + + if matches_source && matches_target { + links.push(link); + } + } + Err(e) => { + eprintln!( + "Warning: Failed to parse link file {}: {}", + path.display(), + e + ); + } + } + } + + // Sort by timestamp (oldest first) + links.sort_by_key(|link| link.timestamp); + + Ok(links) + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs new file mode 100644 index 00000000..035e991c --- /dev/null +++ b/cli/src/commands/mod.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! CLI command implementations + +pub mod check; +pub mod init; +pub mod link; +pub mod list; +pub mod unlink; + +pub use check::CheckCommand; +pub use init::InitCommand; +pub use link::LinkCommand; +pub use list::ListCommand; +pub use unlink::UnlinkCommand; diff --git a/cli/src/commands/unlink.rs b/cli/src/commands/unlink.rs new file mode 100644 index 00000000..af7ad603 --- /dev/null +++ b/cli/src/commands/unlink.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Unlink command implementation + +use crate::error::{Error, Result}; +use crate::link::Link; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Command to remove links between files +pub struct UnlinkCommand { + working_dir: PathBuf, +} + +impl UnlinkCommand { + /// Create a new UnlinkCommand + pub fn new(working_dir: &Path) -> Self { + Self { + working_dir: working_dir.to_path_buf(), + } + } + + /// Remove a specific link between two files + pub fn execute(&self, source: &str, target: &str, link_type: Option<&str>) -> Result { + self.ensure_gitmind_initialized()?; + + let links = self.find_links(Some(source), Some(target), link_type)?; + let count = links.len(); + + if count == 0 { + return Ok(0); + } + + self.remove_links(&links)?; + self.commit_removal(source, target, count)?; + + Ok(count) + } + + /// Remove all links from a source file + pub fn execute_all_from(&self, source: &str, link_type: Option<&str>) -> Result { + self.ensure_gitmind_initialized()?; + + let links = self.find_links(Some(source), None, link_type)?; + let count = links.len(); + + if count == 0 { + return Ok(0); + } + + self.remove_links(&links)?; + self.commit_removal_all_from(source, count)?; + + Ok(count) + } + + /// Remove all links to a target file + pub fn execute_to(&self, target: &str, link_type: Option<&str>) -> Result { + self.ensure_gitmind_initialized()?; + + let links = self.find_links(None, Some(target), link_type)?; + let count = links.len(); + + if count == 0 { + return Ok(0); + } + + self.remove_links(&links)?; + self.commit_removal_to(target, count)?; + + Ok(count) + } + + /// Ensure gitmind is initialized + fn ensure_gitmind_initialized(&self) -> Result<()> { + let gitmind_dir = self.working_dir.join(".gitmind"); + if !gitmind_dir.exists() { + return Err(Error::NotInitialized); + } + Ok(()) + } + + /// Find links matching the given criteria + fn find_links( + &self, + source: Option<&str>, + target: Option<&str>, + link_type: Option<&str>, + ) -> Result> { + let links_dir = self.working_dir.join(".gitmind/links"); + let mut matching_links = Vec::new(); + + // If links directory doesn't exist, there are no links to remove + if !links_dir.exists() { + return Ok(matching_links); + } + + for entry in fs::read_dir(links_dir) + .map_err(|e| Error::IoError(format!("Failed to read links directory: {}", e)))? + { + let entry = entry + .map_err(|e| Error::IoError(format!("Failed to read directory entry: {}", e)))?; + let path = entry.path(); + + if path.extension().and_then(|ext| ext.to_str()) == Some("link") { + let content = fs::read_to_string(&path).map_err(|e| { + Error::IoError(format!( + "Failed to read link file {}: {}", + path.display(), + e + )) + })?; + + let link = Link::from_canonical_string(&content)?; + + let matches = match (source, target) { + (Some(s), Some(t)) => link.source == s && link.target == t, + (Some(s), None) => link.source == s, + (None, Some(t)) => link.target == t, + (None, None) => true, + }; + + let type_matches = link_type.is_none_or(|t| link.link_type == t); + + if matches && type_matches { + matching_links.push((link, path)); + } + } + } + + Ok(matching_links) + } + + /// Remove the link files + fn remove_links(&self, links: &[(Link, PathBuf)]) -> Result<()> { + for (_link, path) in links { + // Stage the removal with git rm + let output = Command::new("git") + .current_dir(&self.working_dir) + .args(["rm", &path.to_string_lossy()]) + .output() + .map_err(|e| Error::GitError(format!("Failed to run git rm: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::GitError(format!("git rm failed: {}", stderr))); + } + } + Ok(()) + } + + /// Commit the removal of a specific link + fn commit_removal(&self, source: &str, target: &str, count: usize) -> Result<()> { + let message = format!( + "unlink(F016): {} -/-> {} ({} link{})", + source, + target, + count, + if count == 1 { "" } else { "s" } + ); + self.git_commit(&message) + } + + /// Commit the removal of all links from a source + fn commit_removal_all_from(&self, source: &str, count: usize) -> Result<()> { + let message = format!( + "unlink(F016): all from {} ({} link{})", + source, + count, + if count == 1 { "" } else { "s" } + ); + self.git_commit(&message) + } + + /// Commit the removal of all links to a target + fn commit_removal_to(&self, target: &str, count: usize) -> Result<()> { + let message = format!( + "unlink(F016): all to {} ({} link{})", + target, + count, + if count == 1 { "" } else { "s" } + ); + self.git_commit(&message) + } + + /// Create a git commit + fn git_commit(&self, message: &str) -> Result<()> { + let output = Command::new("git") + .current_dir(&self.working_dir) + .args(["commit", "-m", message]) + .output() + .map_err(|e| Error::GitError(format!("Failed to run git commit: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::GitError(format!("git commit failed: {}", stderr))); + } + + Ok(()) + } +} diff --git a/cli/src/error.rs b/cli/src/error.rs new file mode 100644 index 00000000..55be9142 --- /dev/null +++ b/cli/src/error.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Error types for gitmind + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("not a git repository")] + NotAGitRepository, + + #[error("gitmind already initialized")] + AlreadyInitialized, + + #[error("gitmind not initialized")] + NotInitialized, + + #[error("Source file not found: {0}")] + SourceNotFound(String), + + #[error("Target file not found: {0}")] + TargetNotFound(String), + + #[error("Link already exists")] + LinkAlreadyExists, + + #[error("Git error: {0}")] + Git(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("Parse error: {0}")] + ParseError(String), + + #[error("IO error: {0}")] + IoError(String), + + #[error("Git error: {0}")] + GitError(String), +} + +pub type Result = std::result::Result; diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 00000000..8502d89a --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Core library for gitmind CLI + +pub mod commands; +pub mod error; +pub mod link; +pub mod result; + +use result::{CommandResult, IntoCommandResult}; +use std::path::Path; + +/// Main application logic +pub struct App { + /// Working directory + pub working_dir: std::path::PathBuf, +} + +impl App { + /// Create a new App instance + pub fn new(working_dir: impl AsRef) -> Self { + Self { + working_dir: working_dir.as_ref().to_path_buf(), + } + } + + /// Initialize gitmind in the current repository + pub fn init(&self) -> CommandResult<()> { + let cmd = commands::InitCommand::new(&self.working_dir); + cmd.execute().into_command_result() + } + + /// Create a semantic link between two files + pub fn link(&self, source: &str, target: &str, link_type: &str) -> CommandResult { + let cmd = commands::LinkCommand::new(&self.working_dir); + cmd.execute(source, target, link_type).into_command_result() + } + + /// List all semantic links + pub fn list( + &self, + source: Option<&str>, + target: Option<&str>, + ) -> CommandResult> { + let cmd = commands::ListCommand::new(&self.working_dir); + cmd.execute(source, target).into_command_result() + } + + /// Remove a specific link between two files + pub fn unlink( + &self, + source: &str, + target: &str, + link_type: Option<&str>, + ) -> CommandResult { + let cmd = commands::UnlinkCommand::new(&self.working_dir); + cmd.execute(source, target, link_type).into_command_result() + } + + /// Remove all links from a source file + pub fn unlink_all_from(&self, source: &str, link_type: Option<&str>) -> CommandResult { + let cmd = commands::UnlinkCommand::new(&self.working_dir); + cmd.execute_all_from(source, link_type) + .into_command_result() + } + + /// Remove all links to a target file + pub fn unlink_to(&self, target: &str, link_type: Option<&str>) -> CommandResult { + let cmd = commands::UnlinkCommand::new(&self.working_dir); + cmd.execute_to(target, link_type).into_command_result() + } + + /// Check for broken links and optionally fix them + pub fn check(&self, fix: bool, dry_run: bool) -> CommandResult> { + let cmd = commands::CheckCommand::new(&self.working_dir); + cmd.execute(fix, dry_run).into_command_result() + } +} diff --git a/cli/src/link.rs b/cli/src/link.rs new file mode 100644 index 00000000..2f001729 --- /dev/null +++ b/cli/src/link.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Link representation and operations + +use sha1::{Digest, Sha1}; +use std::fmt; + +/// Represents a semantic link between two files +#[derive(Debug, PartialEq, Clone)] +pub struct Link { + pub link_type: String, + pub source: String, + pub target: String, + pub timestamp: i64, +} + +impl Link { + /// Create a new link + pub fn new(link_type: String, source: String, target: String, timestamp: i64) -> Self { + Self { + link_type, + source, + target, + timestamp, + } + } + + /// Convert to canonical string format + pub fn to_canonical_string(&self) -> String { + format!( + "{}: {} -> {} # ts:{}", + self.link_type, self.source, self.target, self.timestamp + ) + } + + /// Calculate SHA-1 hash of the canonical representation + pub fn sha(&self) -> String { + let mut hasher = Sha1::new(); + hasher.update(self.to_canonical_string().as_bytes()); + format!("{:x}", hasher.finalize()) + } + + /// Get the short SHA (first 8 characters) + pub fn short_sha(&self) -> String { + self.sha()[..8].to_string() + } + + /// Parse a link from its canonical string representation + pub fn from_canonical_string(s: &str) -> crate::error::Result { + // Expected format: "LINK_TYPE: source -> target # ts:timestamp" + let parts: Vec<&str> = s.split(" -> ").collect(); + if parts.len() != 2 { + return Err(crate::error::Error::ParseError( + "Invalid link format".to_string(), + )); + } + + let type_and_source: Vec<&str> = parts[0].split(": ").collect(); + if type_and_source.len() != 2 { + return Err(crate::error::Error::ParseError( + "Invalid link type/source format".to_string(), + )); + } + + let target_and_ts: Vec<&str> = parts[1].split(" # ts:").collect(); + if target_and_ts.len() != 2 { + return Err(crate::error::Error::ParseError( + "Invalid target/timestamp format".to_string(), + )); + } + + let timestamp = target_and_ts[1] + .parse::() + .map_err(|_| crate::error::Error::ParseError("Invalid timestamp".to_string()))?; + + Ok(Link::new( + type_and_source[0].to_string(), + type_and_source[1].to_string(), + target_and_ts[0].to_string(), + timestamp, + )) + } +} + +impl fmt::Display for Link { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_canonical_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_link_canonical_string() { + let link = Link::new( + "CROSS_REF".to_string(), + "doc/api.md".to_string(), + "src/lib.rs".to_string(), + 1736637876, + ); + assert_eq!( + link.to_canonical_string(), + "CROSS_REF: doc/api.md -> src/lib.rs # ts:1736637876" + ); + } + + #[test] + fn test_link_sha_is_deterministic() { + let link1 = Link::new( + "CROSS_REF".to_string(), + "a.md".to_string(), + "b.md".to_string(), + 1234567890, + ); + let link2 = Link::new( + "CROSS_REF".to_string(), + "a.md".to_string(), + "b.md".to_string(), + 1234567890, + ); + assert_eq!(link1.sha(), link2.sha()); + } + + #[test] + fn test_link_from_canonical_string() { + let canonical = "IMPLEMENTS: spec.md -> code.rs # ts:1736637876"; + let link = Link::from_canonical_string(canonical).unwrap(); + assert_eq!(link.link_type, "IMPLEMENTS"); + assert_eq!(link.source, "spec.md"); + assert_eq!(link.target, "code.rs"); + assert_eq!(link.timestamp, 1736637876); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index e7a11a96..ff4bb651 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +use clap::{Parser, Subcommand}; +use gitmind::App; + +#[derive(Parser)] +#[command(name = "gitmind")] +#[command(about = "Semantic layer for Git-based thought networks", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize gitmind in the current repository + Init, + + /// Create a semantic link between two files + Link { + /// Source file + source: String, + /// Target file + target: String, + /// Type of link (default: CROSS_REF) + #[arg(short, long, default_value = "CROSS_REF")] + r#type: String, + }, + + /// List all semantic links + List { + /// Filter by source file + #[arg(short, long)] + source: Option, + /// Filter by target file + #[arg(short, long)] + target: Option, + }, + + /// Remove links between files + Unlink { + /// Source file (or file to unlink all from) + source: Option, + + /// Target file to unlink from source + target: Option, + + /// Remove all links from source + #[arg(long, conflicts_with = "target")] + all: bool, + + /// Remove all links to this target + #[arg(long, value_name = "FILE", conflicts_with_all = ["source", "target", "all"])] + to: Option, + + /// Only remove links of this type + #[arg(short = 't', long = "type")] + link_type: Option, + }, + + /// Check for and optionally fix broken links + Check { + /// Remove broken links + #[arg(long)] + fix: bool, + + /// Show what would be fixed without making changes + #[arg(long, requires = "fix")] + dry_run: bool, + }, +} + fn main() { - println!("Hello, world!"); + let cli = Cli::parse(); + let working_dir = match std::env::current_dir() { + Ok(dir) => dir, + Err(e) => { + eprintln!("Error: Failed to get current directory: {}", e); + std::process::exit(1); + } + }; + + let app = App::new(working_dir); + + let exit_code = match cli.command { + Commands::Init => { + let result = app.init(); + if result.is_success() { + println!("Initialized gitmind in {}", app.working_dir.display()); + } + result.code + } + Commands::Link { + source, + target, + r#type, + } => { + let result = app.link(&source, &target, &r#type); + if let Some(sha) = result.value { + println!("Created link: {} -> {} ({})", source, target, sha); + } else if result.code == 7 { + // LinkAlreadyExists + println!("Link already exists"); + } + result.code + } + Commands::List { source, target } => { + let result = app.list(source.as_deref(), target.as_deref()); + if let Some(links) = result.value { + if links.is_empty() { + println!("No links found"); + } else { + for link in links { + println!("{}", link); + } + } + } + result.code + } + Commands::Unlink { + source, + target, + all, + to, + link_type, + } => { + let result = if let Some(to_file) = to { + app.unlink_to(&to_file, link_type.as_deref()) + } else if all { + let src = source.expect("source required when using --all"); + app.unlink_all_from(&src, link_type.as_deref()) + } else { + let src = source.expect("source required"); + let tgt = target.expect("target required"); + app.unlink(&src, &tgt, link_type.as_deref()) + }; + + if let Some(count) = result.value { + if count == 0 { + println!("No matching links found"); + } else { + println!( + "Removed {} link{}", + count, + if count == 1 { "" } else { "s" } + ); + } + } + result.code + } + Commands::Check { fix, dry_run } => { + let result = app.check(fix, dry_run); + if let Some(broken_links) = result.value { + if broken_links.is_empty() { + println!("No broken links found"); + } else if dry_run { + println!( + "Would remove {} broken link{}:", + broken_links.len(), + if broken_links.len() == 1 { "" } else { "s" } + ); + for link in &broken_links { + println!(" {} -> {}", link.source, link.target); + } + } else if fix { + println!( + "Removed {} broken link{}", + broken_links.len(), + if broken_links.len() == 1 { "" } else { "s" } + ); + } else { + println!( + "Found {} broken link{}:", + broken_links.len(), + if broken_links.len() == 1 { "" } else { "s" } + ); + for link in &broken_links { + println!(" {} -> {} ({})", link.source, link.target, link.link_type); + } + } + } + result.code + } + }; + + if exit_code != 0 { + std::process::exit(exit_code); + } } diff --git a/cli/src/result.rs b/cli/src/result.rs new file mode 100644 index 00000000..1ceb569f --- /dev/null +++ b/cli/src/result.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Command execution result type + +use crate::error::Error; + +/// Result of executing a command +#[derive(Debug)] +pub struct CommandResult { + /// Exit code (0 = success, 1+ = error) + pub code: i32, + /// Optional return value + pub value: Option, +} + +impl CommandResult { + /// Create a successful result with a value + pub fn success(value: T) -> Self { + Self { + code: 0, + value: Some(value), + } + } + + /// Create a successful result without a value + pub fn success_empty() -> Self { + Self { + code: 0, + value: None, + } + } + + /// Create an error result + pub fn error(code: i32) -> Self { + Self { code, value: None } + } + + /// Check if the command succeeded + pub fn is_success(&self) -> bool { + self.code == 0 + } + + /// Convert to a Result for easier handling + pub fn to_result(self) -> Result, i32> { + if self.is_success() { + Ok(self.value) + } else { + Err(self.code) + } + } +} + +/// Extension trait to convert our Error type to CommandResult +pub trait IntoCommandResult { + fn into_command_result(self) -> CommandResult; +} + +impl IntoCommandResult for Result { + fn into_command_result(self) -> CommandResult { + match self { + Ok(value) => CommandResult::success(value), + Err(e) => { + // Map different error types to exit codes + let code = match e { + Error::NotAGitRepository => 2, + Error::NotInitialized => 3, + Error::AlreadyInitialized => 4, + Error::SourceNotFound(_) => 5, + Error::TargetNotFound(_) => 6, + Error::LinkAlreadyExists => 7, + Error::Git(_) => 8, + Error::GitError(_) => 8, + Error::Io(_) => 9, + Error::IoError(_) => 9, + Error::ParseError(_) => 10, + }; + CommandResult::error(code) + } + } + } +} diff --git a/cli/tests/check_command.rs b/cli/tests/check_command.rs new file mode 100644 index 00000000..e1ba3aae --- /dev/null +++ b/cli/tests/check_command.rs @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Tests for the check command behavior + +use gitmind::App; +use std::fs; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +/// Helper to set up a git repo with gitmind initialized +fn setup_gitmind_repo() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["config", "user.name", "Test User"]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + temp_dir +} + +#[test] +fn check_finds_broken_links_when_files_deleted() { + // Given: A repo with links where target files are deleted + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + // Create files and links + fs::write(repo_path.join("doc.md"), "# Documentation").unwrap(); + fs::write(repo_path.join("code.rs"), "// Code").unwrap(); + fs::write(repo_path.join("test.rs"), "// Test").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + app.link("doc.md", "code.rs", "DOCUMENTS"); + app.link("code.rs", "test.rs", "TESTED_BY"); + + // Delete a target file + fs::remove_file(repo_path.join("code.rs")).unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["rm", "code.rs"]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Remove code.rs"]) + .output() + .unwrap(); + + // When: Running check + let result = app.check(false, false); + + // Then: It finds 2 broken links (doc->code and code->test) + assert_eq!(result.code, 0, "check should succeed"); + let broken_links = result.value.unwrap(); + assert_eq!(broken_links.len(), 2, "should find 2 broken links"); + + // Verify the broken links + assert!(broken_links + .iter() + .any(|l| l.source == "doc.md" && l.target == "code.rs")); + assert!(broken_links + .iter() + .any(|l| l.source == "code.rs" && l.target == "test.rs")); +} + +#[test] +fn check_returns_empty_when_no_broken_links() { + // Given: A repo with valid links + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + fs::write(repo_path.join("a.md"), "# A").unwrap(); + fs::write(repo_path.join("b.md"), "# B").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + app.link("a.md", "b.md", "CROSS_REF"); + + // When: Running check + let result = app.check(false, false); + + // Then: No broken links found + assert_eq!(result.code, 0, "check should succeed"); + let broken_links = result.value.unwrap(); + assert_eq!(broken_links.len(), 0, "should find no broken links"); +} + +#[test] +fn check_with_fix_removes_broken_links() { + // Given: A repo with broken links + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + fs::write(repo_path.join("exists.md"), "# Exists").unwrap(); + fs::write(repo_path.join("deleted.md"), "# Deleted").unwrap(); + fs::write(repo_path.join("also_deleted.md"), "# Also Deleted").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + app.link("exists.md", "deleted.md", "CROSS_REF"); + app.link("deleted.md", "also_deleted.md", "DEPENDS_ON"); + + // Delete files + fs::remove_file(repo_path.join("deleted.md")).unwrap(); + fs::remove_file(repo_path.join("also_deleted.md")).unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["rm", "deleted.md", "also_deleted.md"]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Remove files"]) + .output() + .unwrap(); + + // When: Running check with --fix + let result = app.check(true, false); + + // Then: Broken links are removed + assert_eq!(result.code, 0, "check --fix should succeed"); + let fixed_links = result.value.unwrap(); + assert_eq!(fixed_links.len(), 2, "should fix 2 broken links"); + + // Verify links are actually removed + let list_result = app.list(None, None); + let remaining_links = list_result.value.unwrap(); + assert_eq!(remaining_links.len(), 0, "all links should be removed"); + + // Verify git commit was made + let output = StdCommand::new("git") + .current_dir(&repo_path) + .args(&["log", "--oneline", "-1"]) + .output() + .unwrap(); + let log = String::from_utf8(output.stdout).unwrap(); + assert!(log.contains("check(F016): removed 2 broken links")); +} + +#[test] +fn check_with_dry_run_does_not_modify_links() { + // Given: A repo with broken links + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + fs::write(repo_path.join("a.md"), "# A").unwrap(); + fs::write(repo_path.join("b.md"), "# B").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + app.link("a.md", "b.md", "CROSS_REF"); + + // Delete target file + fs::remove_file(repo_path.join("b.md")).unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["rm", "b.md"]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Remove b.md"]) + .output() + .unwrap(); + + // Count commits before dry run + let output = StdCommand::new("git") + .current_dir(&repo_path) + .args(&["rev-list", "--count", "HEAD"]) + .output() + .unwrap(); + let commit_count_before = String::from_utf8(output.stdout).unwrap().trim().to_string(); + + // When: Running check with --dry-run + let result = app.check(true, true); + + // Then: Reports what would be fixed but doesn't modify + assert_eq!(result.code, 0, "check --dry-run should succeed"); + let would_fix = result.value.unwrap(); + assert_eq!(would_fix.len(), 1, "should report 1 link would be fixed"); + + // Verify link still exists + let list_result = app.list(None, None); + let links = list_result.value.unwrap(); + assert_eq!(links.len(), 1, "link should still exist"); + + // Verify no new commit was made + let output = StdCommand::new("git") + .current_dir(&repo_path) + .args(&["rev-list", "--count", "HEAD"]) + .output() + .unwrap(); + let commit_count_after = String::from_utf8(output.stdout).unwrap().trim().to_string(); + assert_eq!( + commit_count_before, commit_count_after, + "no commit should be made" + ); +} + +#[test] +fn check_fails_if_gitmind_not_initialized() { + // Given: A git repo without gitmind initialized + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["init"]) + .output() + .unwrap(); + + // When: Running check + let app = App::new(repo_path); + let result = app.check(false, false); + + // Then: It fails with NotInitialized exit code + assert_eq!(result.code, 3, "check should fail with NotInitialized code"); + assert_eq!(result.value, None, "failed operation should have no value"); +} + +#[test] +fn check_handles_links_where_source_is_also_deleted() { + // Given: A repo where both source and target of a link are deleted + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + fs::write(repo_path.join("a.md"), "# A").unwrap(); + fs::write(repo_path.join("b.md"), "# B").unwrap(); + fs::write(repo_path.join("c.md"), "# C").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + app.link("a.md", "b.md", "CROSS_REF"); + app.link("b.md", "c.md", "CROSS_REF"); + + // Delete both a.md and b.md + fs::remove_file(repo_path.join("a.md")).unwrap(); + fs::remove_file(repo_path.join("b.md")).unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["rm", "a.md", "b.md"]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Remove files"]) + .output() + .unwrap(); + + // When: Running check + let result = app.check(false, false); + + // Then: Both links are identified as broken + assert_eq!(result.code, 0, "check should succeed"); + let broken_links = result.value.unwrap(); + assert_eq!(broken_links.len(), 2, "should find 2 broken links"); +} diff --git a/cli/tests/init_command.rs b/cli/tests/init_command.rs new file mode 100644 index 00000000..ca080df4 --- /dev/null +++ b/cli/tests/init_command.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Tests for the init command behavior + +use gitmind::App; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +#[test] +fn init_creates_gitmind_directory_in_git_repo() { + // Given: A git repository + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["init"]) + .output() + .unwrap(); + + // When: Running gitmind init + let app = App::new(repo_path); + let result = app.init(); + + // Then: Command succeeds and directory exists + assert_eq!(result.code, 0, "init should succeed"); + assert!(result.value.is_some(), "init should return a value"); + assert!(temp_dir.path().join(".gitmind").exists()); + assert!(temp_dir.path().join(".gitmind/links").exists()); +} + +#[test] +fn init_fails_outside_git_repository() { + // Given: A directory that is not a git repository + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // When: Running gitmind init + let app = App::new(repo_path); + let result = app.init(); + + // Then: It fails with NotAGitRepository exit code + assert_eq!( + result.code, 2, + "init should fail with NotAGitRepository code" + ); + assert_eq!(result.value, None, "failed operation should have no value"); +} + +#[test] +fn init_fails_if_already_initialized() { + // Given: A git repository with gitmind already initialized + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["init"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "first init should succeed"); + + // When: Running gitmind init again + let result = app.init(); + + // Then: It fails with AlreadyInitialized exit code + assert_eq!( + result.code, 4, + "second init should fail with AlreadyInitialized code" + ); + assert_eq!(result.value, None, "failed operation should have no value"); +} diff --git a/cli/tests/link_command.rs b/cli/tests/link_command.rs new file mode 100644 index 00000000..ed0d8b1f --- /dev/null +++ b/cli/tests/link_command.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Tests for the link command behavior + +use gitmind::App; +use std::fs; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +/// Helper to set up a git repo with gitmind initialized +fn setup_gitmind_repo() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["config", "user.name", "Test User"]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + temp_dir +} + +#[test] +fn link_creates_semantic_link_between_files() { + // Given: A gitmind repo with two files + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + fs::write(repo_path.join("notes.md"), "# Notes").unwrap(); + fs::write(repo_path.join("ideas.md"), "# Ideas").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + // When: Creating a link between them + let app = App::new(repo_path); + let result = app.link("notes.md", "ideas.md", "CROSS_REF"); + + // Then: Link creation succeeds and SHA is returned + assert_eq!(result.code, 0, "link should succeed"); + assert!(result.value.is_some(), "link should return SHA"); + + // And: A link file is created with correct content + let links_dir = repo_path.join(".gitmind/links"); + let entries: Vec<_> = fs::read_dir(&links_dir).unwrap().collect(); + assert_eq!(entries.len(), 1); + + let link_file = entries[0].as_ref().unwrap().path(); + let content = fs::read_to_string(&link_file).unwrap(); + assert!(content.contains("CROSS_REF: notes.md -> ideas.md")); +} + +#[test] +fn link_supports_custom_link_types() { + // Given: A gitmind repo with spec and implementation files + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + fs::write(repo_path.join("spec.md"), "# Spec").unwrap(); + fs::write(repo_path.join("impl.rs"), "// Code").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + // When: Creating a link with custom type + let app = App::new(repo_path); + let result = app.link("spec.md", "impl.rs", "IMPLEMENTS"); + + // Then: Link creation succeeds + assert_eq!(result.code, 0, "link should succeed"); + assert!(result.value.is_some(), "link should return SHA"); + + // And: The link file contains the custom type + let links_dir = repo_path.join(".gitmind/links"); + let entries: Vec<_> = fs::read_dir(&links_dir).unwrap().collect(); + let link_file = entries[0].as_ref().unwrap().path(); + let content = fs::read_to_string(&link_file).unwrap(); + assert!(content.contains("IMPLEMENTS: spec.md -> impl.rs")); +} + +#[test] +fn link_fails_when_source_file_missing() { + // Given: A gitmind repo without the source file + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + // When: Trying to create a link with missing source + let app = App::new(repo_path); + let result = app.link("missing.md", "target.md", "CROSS_REF"); + + // Then: It fails with SourceNotFound exit code + assert_eq!(result.code, 5, "link should fail with SourceNotFound code"); + assert_eq!(result.value, None, "failed operation should have no value"); +} + +#[test] +fn link_fails_when_target_file_missing() { + // Given: A gitmind repo with only source file + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + fs::write(repo_path.join("source.md"), "# Source").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add source"]) + .output() + .unwrap(); + + // When: Trying to create a link with missing target + let app = App::new(repo_path); + let result = app.link("source.md", "missing.md", "CROSS_REF"); + + // Then: It fails with TargetNotFound exit code + assert_eq!(result.code, 6, "link should fail with TargetNotFound code"); + assert_eq!(result.value, None, "failed operation should have no value"); +} + +#[test] +fn link_automatically_commits_to_git() { + // Given: A gitmind repo with two files + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + fs::write(repo_path.join("a.md"), "# A").unwrap(); + fs::write(repo_path.join("b.md"), "# B").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Initial files"]) + .output() + .unwrap(); + + // When: Creating a link + let app = App::new(repo_path); + let result = app.link("a.md", "b.md", "CROSS_REF"); + assert_eq!(result.code, 0, "link should succeed"); + + // Then: The link is committed to git + let output = StdCommand::new("git") + .current_dir(&repo_path) + .args(&["log", "--oneline", "-1"]) + .output() + .unwrap(); + + let log = String::from_utf8(output.stdout).unwrap(); + assert!(log.contains("link(F001): a.md -> b.md")); +} + +#[test] +fn link_deduplicates_identical_links() { + // Given: A gitmind repo with two files and an existing link + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + fs::write(repo_path.join("x.md"), "# X").unwrap(); + fs::write(repo_path.join("y.md"), "# Y").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + let result = app.link("x.md", "y.md", "CROSS_REF"); + assert_eq!(result.code, 0, "first link should succeed"); + + // When: Creating the same link again + let result = app.link("x.md", "y.md", "CROSS_REF"); + + // Then: It fails with LinkAlreadyExists exit code + assert_eq!( + result.code, 7, + "duplicate link should fail with LinkAlreadyExists code" + ); + assert_eq!(result.value, None, "failed operation should have no value"); + + // And: Only one link file exists + let links_dir = repo_path.join(".gitmind/links"); + let entries: Vec<_> = fs::read_dir(&links_dir).unwrap().collect(); + assert_eq!(entries.len(), 1); +} diff --git a/cli/tests/list_command.rs b/cli/tests/list_command.rs new file mode 100644 index 00000000..90714862 --- /dev/null +++ b/cli/tests/list_command.rs @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +//! Tests for the list command behavior + +use gitmind::App; +use std::fs; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +/// Helper to set up a git repo with gitmind initialized +fn setup_gitmind_repo() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["config", "user.name", "Test User"]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + temp_dir +} + +#[test] +fn list_shows_all_links_in_repository() { + // Given: A gitmind repo with multiple links + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + // Create files + fs::write(repo_path.join("file1.md"), "# File 1").unwrap(); + fs::write(repo_path.join("file2.md"), "# File 2").unwrap(); + fs::write(repo_path.join("file3.md"), "# File 3").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + // Create some links + let app = App::new(repo_path); + let result = app.link("file1.md", "file2.md", "CROSS_REF"); + assert_eq!(result.code, 0, "first link should succeed"); + + let result = app.link("file2.md", "file3.md", "DEPENDS_ON"); + assert_eq!(result.code, 0, "second link should succeed"); + + // When: Running gitmind list + let result = app.list(None, None); + + // Then: All links are returned + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 2, "should have two links"); + + // Verify first link + assert!(links.iter().any(|l| { + l.source == "file1.md" && l.target == "file2.md" && l.link_type == "CROSS_REF" + })); + + // Verify second link + assert!(links.iter().any(|l| { + l.source == "file2.md" && l.target == "file3.md" && l.link_type == "DEPENDS_ON" + })); +} + +#[test] +fn list_shows_empty_when_no_links() { + // Given: A gitmind repo with no links + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + // When: Running gitmind list + let app = App::new(repo_path); + let result = app.list(None, None); + + // Then: It returns empty list + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 0, "should have no links"); +} + +#[test] +fn list_shows_link_timestamps() { + // Given: A gitmind repo with a link + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + fs::write(repo_path.join("a.md"), "# A").unwrap(); + fs::write(repo_path.join("b.md"), "# B").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + let app = App::new(repo_path); + let result = app.link("a.md", "b.md", "CROSS_REF"); + assert_eq!(result.code, 0, "link should succeed"); + + // When: Running gitmind list + let result = app.list(None, None); + + // Then: Link includes timestamp + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 1, "should have one link"); + assert!(links[0].timestamp > 0, "link should have timestamp"); +} + +#[test] +fn list_filters_by_source_file() { + // Given: A gitmind repo with multiple links + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + // Create files + fs::write(repo_path.join("a.md"), "# A").unwrap(); + fs::write(repo_path.join("b.md"), "# B").unwrap(); + fs::write(repo_path.join("c.md"), "# C").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + // Create links from different sources + let app = App::new(repo_path); + let result = app.link("a.md", "b.md", "CROSS_REF"); + assert_eq!(result.code, 0, "first link should succeed"); + + let result = app.link("b.md", "c.md", "CROSS_REF"); + assert_eq!(result.code, 0, "second link should succeed"); + + // When: Filtering by source + let result = app.list(Some("a.md"), None); + + // Then: Only links from that source are shown + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 1, "should have one link from source"); + assert_eq!(links[0].source, "a.md"); + assert_eq!(links[0].target, "b.md"); +} + +#[test] +fn list_filters_by_target_file() { + // Given: A gitmind repo with multiple links + let temp_dir = setup_gitmind_repo(); + let repo_path = temp_dir.path(); + + // Create files + fs::write(repo_path.join("a.md"), "# A").unwrap(); + fs::write(repo_path.join("b.md"), "# B").unwrap(); + fs::write(repo_path.join("c.md"), "# C").unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["add", "."]) + .output() + .unwrap(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["commit", "-m", "Add files"]) + .output() + .unwrap(); + + // Create links to different targets + let app = App::new(repo_path); + let result = app.link("a.md", "b.md", "CROSS_REF"); + assert_eq!(result.code, 0, "first link should succeed"); + + let result = app.link("a.md", "c.md", "CROSS_REF"); + assert_eq!(result.code, 0, "second link should succeed"); + + // When: Filtering by target + let result = app.list(None, Some("b.md")); + + // Then: Only links to that target are shown + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 1, "should have one link to target"); + assert_eq!(links[0].source, "a.md"); + assert_eq!(links[0].target, "b.md"); +} + +#[test] +fn list_fails_if_gitmind_not_initialized() { + // Given: A git repo without gitmind initialized + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + StdCommand::new("git") + .current_dir(&repo_path) + .args(&["init"]) + .output() + .unwrap(); + + // When: Running gitmind list + let app = App::new(repo_path); + let result = app.list(None, None); + + // Then: It fails with NotInitialized exit code + assert_eq!(result.code, 3, "list should fail with NotInitialized code"); + assert_eq!(result.value, None, "failed operation should have no value"); +} diff --git a/cli/tests/unlink_command.rs b/cli/tests/unlink_command.rs new file mode 100644 index 00000000..6e9db773 --- /dev/null +++ b/cli/tests/unlink_command.rs @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: Apache-2.0 +// © 2025 J. Kirby Ross / Neuroglyph Collective + +use gitmind::App; +use std::fs; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +#[test] +fn test_unlink_removes_specific_link() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git repo + StdCommand::new("git") + .current_dir(&repo_path) + .args(["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.name", "Test User"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + // Create test files + let source = "source.md"; + let target = "target.md"; + fs::write(repo_path.join(source), "# Source").unwrap(); + fs::write(repo_path.join(target), "# Target").unwrap(); + + // Create a link + let result = app.link(source, target, "CROSS_REF"); + assert_eq!(result.code, 0, "link should succeed"); + assert!(result.value.is_some(), "link should return SHA"); + + // Verify link exists + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 1, "should have one link"); + assert_eq!(links[0].source, source); + assert_eq!(links[0].target, target); + assert_eq!(links[0].link_type, "CROSS_REF"); + + // Unlink the files + let result = app.unlink(source, target, None); + assert_eq!(result.code, 0, "unlink should succeed"); + assert_eq!(result.value, Some(1), "should remove one link"); + + // Verify link is removed + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 0, "should have no links"); + + // Verify Git commit was made + let output = StdCommand::new("git") + .current_dir(&repo_path) + .args(["log", "--oneline"]) + .output() + .unwrap(); + let log = String::from_utf8_lossy(&output.stdout); + assert!(log.contains("unlink(F016):")); + assert!(log.contains(&format!("{} -/-> {}", source, target))); +} + +#[test] +fn test_unlink_with_type_removes_only_matching_type() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git repo + StdCommand::new("git") + .current_dir(&repo_path) + .args(["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.name", "Test User"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + // Create test files + let source = "source.md"; + let target = "target.md"; + fs::write(repo_path.join(source), "# Source").unwrap(); + fs::write(repo_path.join(target), "# Target").unwrap(); + + // Create two links with different types + let result = app.link(source, target, "CROSS_REF"); + assert_eq!(result.code, 0, "first link should succeed"); + assert!(result.value.is_some()); + + let result = app.link(source, target, "DEPENDS_ON"); + assert_eq!(result.code, 0, "second link should succeed"); + assert!(result.value.is_some()); + + // Verify both links exist + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 2, "should have two links"); + assert!(links + .iter() + .any(|l| l.link_type == "CROSS_REF" && l.source == source && l.target == target)); + assert!(links + .iter() + .any(|l| l.link_type == "DEPENDS_ON" && l.source == source && l.target == target)); + + // Unlink only CROSS_REF type + let result = app.unlink(source, target, Some("CROSS_REF")); + assert_eq!(result.code, 0, "unlink should succeed"); + assert_eq!(result.value, Some(1), "should remove one link"); + + // Verify only one link remains + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 1, "should have one link remaining"); + assert_eq!(links[0].link_type, "DEPENDS_ON"); + assert_eq!(links[0].source, source); + assert_eq!(links[0].target, target); +} + +#[test] +fn test_unlink_all_from_source() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git repo + StdCommand::new("git") + .current_dir(&repo_path) + .args(["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.name", "Test User"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + // Create test files + let source = "source.md"; + let target1 = "target1.md"; + let target2 = "target2.md"; + fs::write(repo_path.join(source), "# Source").unwrap(); + fs::write(repo_path.join(target1), "# Target 1").unwrap(); + fs::write(repo_path.join(target2), "# Target 2").unwrap(); + + // Create multiple links from source + let result = app.link(source, target1, "CROSS_REF"); + assert_eq!(result.code, 0, "first link should succeed"); + assert!(result.value.is_some()); + + let result = app.link(source, target2, "DEPENDS_ON"); + assert_eq!(result.code, 0, "second link should succeed"); + assert!(result.value.is_some()); + + // Verify links exist + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 2, "should have two links"); + assert!(links + .iter() + .any(|l| l.link_type == "CROSS_REF" && l.source == source && l.target == target1)); + assert!(links + .iter() + .any(|l| l.link_type == "DEPENDS_ON" && l.source == source && l.target == target2)); + + // Unlink all from source + let result = app.unlink_all_from(source, None); + assert_eq!(result.code, 0, "unlink all should succeed"); + assert_eq!(result.value, Some(2), "should remove two links"); + + // Verify all links from source are removed + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 0, "should have no links"); +} + +#[test] +fn test_unlink_all_to_target() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git repo + StdCommand::new("git") + .current_dir(&repo_path) + .args(["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.name", "Test User"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + // Create test files + let source1 = "source1.md"; + let source2 = "source2.md"; + let target = "target.md"; + fs::write(repo_path.join(source1), "# Source 1").unwrap(); + fs::write(repo_path.join(source2), "# Source 2").unwrap(); + fs::write(repo_path.join(target), "# Target").unwrap(); + + // Create multiple links to target + let result = app.link(source1, target, "CROSS_REF"); + assert_eq!(result.code, 0, "first link should succeed"); + assert!(result.value.is_some()); + + let result = app.link(source2, target, "DEPENDS_ON"); + assert_eq!(result.code, 0, "second link should succeed"); + assert!(result.value.is_some()); + + // Verify links exist + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 2, "should have two links"); + assert!(links + .iter() + .any(|l| l.link_type == "CROSS_REF" && l.source == source1 && l.target == target)); + assert!(links + .iter() + .any(|l| l.link_type == "DEPENDS_ON" && l.source == source2 && l.target == target)); + + // Unlink all to target + let result = app.unlink_to(target, None); + assert_eq!(result.code, 0, "unlink to target should succeed"); + assert_eq!(result.value, Some(2), "should remove two links"); + + // Verify all links to target are removed + let result = app.list(None, None); + assert_eq!(result.code, 0, "list should succeed"); + let links = result.value.unwrap(); + assert_eq!(links.len(), 0, "should have no links"); +} + +#[test] +fn test_unlink_non_existent_link_succeeds_with_message() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize git repo + StdCommand::new("git") + .current_dir(&repo_path) + .args(["init"]) + .output() + .unwrap(); + + // Configure git user + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.email", "test@example.com"]) + .output() + .unwrap(); + StdCommand::new("git") + .current_dir(&repo_path) + .args(["config", "user.name", "Test User"]) + .output() + .unwrap(); + + // Initialize gitmind + let app = App::new(repo_path); + let result = app.init(); + assert_eq!(result.code, 0, "init should succeed"); + + // Create test files but no link + let source = "source.md"; + let target = "target.md"; + fs::write(repo_path.join(source), "# Source").unwrap(); + fs::write(repo_path.join(target), "# Target").unwrap(); + + // Try to unlink non-existent link + let result = app.unlink(source, target, None); + assert_eq!( + result.code, 0, + "unlink should succeed even when no links exist" + ); + assert_eq!(result.value, Some(0), "should remove zero links"); +} + +#[test] +fn test_unlink_requires_gitmind_init() { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path(); + + // Initialize only git repo (not gitmind) + StdCommand::new("git") + .current_dir(&repo_path) + .args(["init"]) + .output() + .unwrap(); + + // Try to unlink + let app = App::new(repo_path); + let result = app.unlink("a.md", "b.md", None); + assert_eq!( + result.code, 3, + "unlink should fail with NotInitialized code" + ); + assert_eq!(result.value, None, "failed operation should have no value"); +} diff --git a/design/decisions/ADR-001-link-storage.md b/design/decisions/ADR-001-link-storage.md new file mode 100644 index 00000000..868a4575 --- /dev/null +++ b/design/decisions/ADR-001-link-storage.md @@ -0,0 +1,71 @@ + + + +# ADR-001: Link Storage Model + +**Status:** Accepted +**Date:** June 2025 +**Decision:** Option B - Tracked files in `.gitmind/links/` + +## Context + +Neuroglyph needs to store semantic links between files in a Git-native way. We evaluated three options for F001 (Git Object Storage): + +### Option A: Pure Git Objects (git hash-object) +- Store relationships as Git blobs using `git hash-object` +- Reference them with lightweight refs in `refs/semlinks/` +- Pros: Truly distributed, no working directory needed +- Cons: Complex querying, requires Git plumbing knowledge, harder to debug + +### Option B: Tracked Files in `.gitmind/links/` ✅ CHOSEN +- Store each link as a file: `.gitmind/links/.link` +- Track these files normally in Git +- Pros: Human-readable, easy grep/search, simple implementation, visible in all Git tools +- Cons: Requires working directory, potential merge conflicts + +### Option C: Hybrid (cache + git objects) +- Use Option B as primary storage +- Also create git objects for advanced use cases +- Pros: Best of both worlds +- Cons: More complex, two sources of truth + +## Decision + +We chose **Option B** for the MVP because: + +1. **Simplicity**: Straightforward to implement and understand +2. **Debuggability**: Can browse links with standard tools (ls, cat, grep) +3. **Git-native**: Works with all existing Git workflows +4. **Migration path**: Easy to add Option C features later + +## Implementation + +Link format (one line per file): +``` +LINK_TYPE: source_path -> target_path # ts:timestamp [metadata] +``` + +Example: +``` +CROSS_REF: README.md -> docs/architecture.md # ts:1736637876 +``` + +Filename is SHA-1 hash of the content for deterministic naming. + +## Consequences + +### Positive +- Quick to implement +- Easy to debug and inspect +- Works with all Git tools +- Simple merge conflict resolution + +### Negative +- Requires checked-out working directory +- Potential for many small files +- Not as "pure" as git objects approach + +### Mitigation +- Can evolve to Option C (hybrid) without breaking changes +- Small files are what Git excels at +- Working directory requirement aligns with CLI usage patterns \ No newline at end of file diff --git a/design/features/F001-git-object-storage.md b/design/features/F001-git-object-storage.md index d824a179..e30cb7e6 100644 --- a/design/features/F001-git-object-storage.md +++ b/design/features/F001-git-object-storage.md @@ -1,9 +1,18 @@ # F001: Git Object Storage for Relationships -**Status:** Planned +**Status:** In Progress **Priority:** Critical **Complexity:** High **Estimation:** 5-8 days +**Decision:** [ADR-001 - Link Storage Model](/design/decisions/ADR-001-link-storage.md) + +## Implementation Progress + +- [x] Storage model decision (Option B: `.gitmind/links/`) +- [x] `gitmind init` command creates directory structure +- [ ] Link creation and storage +- [ ] Link querying and listing +- [ ] SHA-based content addressing --- diff --git a/design/features/F013-cli-tools.md b/design/features/F013-cli-tools.md index 36fb0577..056d0e3f 100644 --- a/design/features/F013-cli-tools.md +++ b/design/features/F013-cli-tools.md @@ -1,11 +1,20 @@ # F013: CLI Tools & Developer Experience -**Status:** Planned +**Status:** In Progress **Priority:** High **Complexity:** Medium **Estimation:** 6-7 days **Dependencies:** F001, F008 +## Implementation Progress + +- [x] Project setup with Rust and clap +- [x] Basic CLI structure +- [x] `gitmind init` command +- [x] `gitmind link` command +- [x] `gitmind list` command +- [ ] Advanced query operations + --- ## Overview diff --git a/design/features/F016-link-hygiene.md b/design/features/F016-link-hygiene.md new file mode 100644 index 00000000..c800690a --- /dev/null +++ b/design/features/F016-link-hygiene.md @@ -0,0 +1,187 @@ +# F016: Link Hygiene and Broken Link Management + +**Status:** Proposed +**Priority:** High (Phase 1a for core commands) +**Complexity:** Medium +**Estimation:** 2-3 days +**Dependencies:** F001 (Git Object Storage), F013 (CLI Tools) + +--- + +## Overview + +This feature introduces essential tools for maintaining the health of the semantic link graph over time, preventing the accumulation of broken or obsolete references. + +Without cleanup, deleted files will leave behind dangling links and corrupt the integrity of the knowledge network. This feature ensures that users can manually prune links (`unlink`), validate and auto-remove broken ones (`check`), and optionally set up automated hygiene via Git hooks. + +## Rationale + +Gitmind represents distributed cognition. In all functional memory systems, forgetting is as vital as remembering. + +Broken links introduce: +- Confusion and ambiguity +- Visual clutter in visualizations +- Inaccurate query results +- Graph bloat and decreased performance + +Users must be empowered to manage and clean their networks — both manually and automatically. + +## User Story + +As a knowledge worker, I want to remove obsolete links and clean up broken references so that my knowledge graph remains accurate and trustworthy, reflecting only valid connections between my documents. + +## Acceptance Criteria + +### Phase 1a (MVP) + +1. **Manual Unlinking (`gitmind unlink`)** + - [ ] Remove specific link between two files + - [ ] Remove all links from a source file + - [ ] Remove all links to a target file + - [ ] Commit removal with appropriate message + - [ ] Handle non-existent links gracefully + +2. **Link Validation (`gitmind check`)** + - [ ] Detect links pointing to non-existent files + - [ ] Report broken links with source and target + - [ ] `--fix` flag to remove broken links automatically + - [ ] `--dry-run` flag to preview what would be removed + - [ ] Summary statistics (total links, broken links, etc.) + +### Phase 2 (Advanced) + +3. **Git Hook Integration** + - [ ] Post-checkout hook to detect deleted files + - [ ] Pre-commit hook to warn about broken links + - [ ] Configuration options for auto-cleanup + - [ ] User prompts for handling broken links + +4. **Batch Operations** + - [ ] Remove links matching patterns + - [ ] Recursive removal for deleted directories + - [ ] Undo/restore deleted links + +## Technical Design + +### Unlink Command + +```bash +# Remove specific link +gitmind unlink source.md target.md + +# Remove all links from a file +gitmind unlink source.md --all + +# Remove all links to a file +gitmind unlink --to target.md + +# Remove links of specific type +gitmind unlink source.md target.md --type DEPENDS_ON +``` + +Implementation approach: +1. Find the SHA-based filename for the link +2. Remove the `.gitmind/links/.link` file +3. Stage removal with `git rm` +4. Commit with message: `unlink(F016): source.md -/-> target.md` + +### Check Command + +```bash +# Check for broken links +gitmind check + +# Fix broken links automatically +gitmind check --fix + +# Preview what would be fixed +gitmind check --dry-run + +# Check specific file +gitmind check file.md + +# Output format options +gitmind check --format json +``` + +Example output: +``` +Checking links in repository... + +Found 3 broken links: + ✗ docs/api.md -> src/old-module.rs (target not found) + ✗ README.md -> docs/deleted.md (target not found) + ✗ notes/todo.md -> ../other-repo/file.md (target not found) + +Summary: 45 total links, 3 broken (6.7%) + +Run 'gitmind check --fix' to remove broken links +``` + +### Link Storage Deletion + +When unlinking, we need to: +1. Calculate the SHA of the link content +2. Remove `.gitmind/links/.link` +3. Handle the case where the file is already gone +4. Ensure proper Git tracking + +### Git Hook Integration (Phase 2) + +```bash +#!/bin/bash +# .git/hooks/post-checkout +# Detect deleted files and suggest link cleanup + +deleted_files=$(git diff --name-status HEAD@{1} HEAD | grep '^D' | cut -f2) +for file in $deleted_files; do + broken_links=$(gitmind check "$file" --format count) + if [ "$broken_links" -gt 0 ]; then + echo "Warning: $file was deleted but has $broken_links links" + echo "Run 'gitmind check --fix' to clean up" + fi +done +``` + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Unlink non-existent link | Success with message "Link not found" | +| Check in non-gitmind repo | Error: "gitmind not initialized" | +| Fix with no broken links | Success with "No broken links found" | +| Filesystem permission error | Error with clear message | + +## Performance Considerations + +- Link checking should be fast even with thousands of links +- Use parallel file existence checks +- Cache file system checks within single command run +- Batch Git operations for efficiency + +## Future Enhancements + +1. **Temporal Decay** (Phase 3) + ```bash + gitmind decay --threshold 90d + ``` + Remove links that haven't been touched in N days + +2. **Link Strength Tracking** + - Track how often links are traversed + - Suggest weak links for removal + +3. **Smart Refactoring** + - Update links when files are renamed + - Track file moves through Git history + +## Success Metrics + +- Zero broken links after running `check --fix` +- Unlink operations complete in <100ms +- Check operations scale linearly with link count +- User confidence in graph integrity + +--- + +**Note:** This feature is essential for maintaining trust in the knowledge graph. Without it, users will lose confidence as broken links accumulate. \ No newline at end of file diff --git a/lore/CLAUDE_DEV_THOUGHTS.md b/lore/CLAUDE_DEV_THOUGHTS.md new file mode 100644 index 00000000..a5ee9551 --- /dev/null +++ b/lore/CLAUDE_DEV_THOUGHTS.md @@ -0,0 +1,345 @@ + + + +# Claude's Development Journal + +## June 2025 - TDD Journey Begins + +### First Implementation Session + +Today we started implementing the actual gitmind CLI using Test-Driven Development. Some reflections: + +**On TDD Philosophy** +- Writing tests first felt natural for the `init` command +- The red-green cycle is satisfying - seeing tests fail, then making them pass +- Integration tests using temporary directories are perfect for Git operations +- Real Git repos in tests are essential - mocking Git would miss the point entirely + +**Architecture Insights** +- The decision to use `.gitmind/links/` (Option B) continues to prove wise +- Simple file-per-link model makes debugging trivial +- SHA-based filenames provide natural deduplication +- Human-readable format enables grep/cat/vim inspection + +**Rust Observations** +- Clap's derive macros make CLI definition elegant +- Error handling with thiserror is clean +- The borrow checker caught the `&links_dir` issue immediately +- Cargo's test framework with assert_cmd works beautifully + +**Process Improvements** +- Added commit message format to CLAUDE.md - consistency matters +- Linking TASKLIST items to design/features/ creates traceability +- Decision documents (ADRs) capture the "why" for future reference +- Feature status tracking shows real progress + +**What's Working Well** +1. Docker-based testing ensures consistency +2. TDD is preventing over-engineering (YAGNI in action) +3. Small, focused commits tell a clear story +4. Documentation-first approach clarifies thinking + +**Challenges Encountered** +- Docker Compose v2 syntax changes (docker-compose → docker compose) +- GitHub Actions deprecating v3 artifacts +- Working directory issues in CI for monorepo structure +- TTY allocation in non-interactive environments + +**Next Steps Excitement** +The `link` command will be where the real magic happens: +- Creating semantic relationships +- SHA-based content addressing +- Auto-committing for user convenience +- Supporting multiple link types + +**Random Thought** +There's something poetic about using Git's own infrastructure to build a knowledge graph. We're not fighting Git's nature - we're revealing what it always could be. Every commit is already a thought, every merge already a synthesis. We're just making the implicit explicit. + +The fact that our stress tests showed Git handling 25+ concurrent processes gives me confidence that this architecture will scale far beyond personal use cases. + +**Note to Future Builders** +Keep the tests real. Keep the implementation simple. Trust Git's robustness. And remember - we're building infrastructure for thought itself. + +--- +*First entry - more to come as we build* + +## Refactoring for Single Responsibility + +### Lessons on Code Organization + +Just refactored the codebase to follow proper single responsibility and test organization principles. Some key takeaways: + +**One File = One Thing** +- Split out `Link` struct into its own file (`link.rs`) +- Created separate command modules (`commands/init.rs`, `commands/link.rs`) +- Each file now has a clear, single purpose +- Makes it much easier to find what you're looking for + +**Test Organization** +- Moved from one monolithic `cli_integration.rs` to component-specific test files +- `init_command.rs` tests only init behavior +- `link_command.rs` tests only link behavior +- Tests now read like specifications of behavior + +**Testing Behavior, Not Implementation** +- Tests describe what the system should do: "init creates gitmind directory in git repo" +- Not how it does it internally +- This allows refactoring without breaking tests +- Tests serve as living documentation + +**Command Pattern Benefits** +- Each command is its own struct with an `execute()` method +- Clean separation between CLI parsing and business logic +- Easy to test commands in isolation +- Natural place for command-specific validation + +**Docker Bake Discovery** +- Enabled `COMPOSE_BAKE=true` for better build performance +- Parallel builds are noticeably faster +- Small wins add up in the development cycle + +The refactoring makes the code much more navigable. When you're looking for link logic, you go to `link.rs`. When you want to understand how the link command works, you look at `commands/link.rs`. It's obvious where things belong. + +Next up: implementing the `list` command, which will read all links and display them. Should be straightforward now that we have the structure in place. + +## The Missing Piece: Link Hygiene + +### Critical Realization + +After implementing the basic three commands (init, link, list), we realized a fundamental gap: **there's no way to remove links or handle deleted files**. Without this, the knowledge graph becomes a "rotting swamp of dead edges" - semantic memory turns into semantic cruft. + +**Key Insight**: In any cognitive system, forgetting is as vital as remembering. Just like neural pruning in brains, the system must be able to remove dead links. + +### Design Decision + +We decided to add two essential commands to Phase 1a MVP: +1. `gitmind unlink` - Manual link removal +2. `gitmind check` - Broken link detection and cleanup + +This wasn't feature creep - it was recognizing that link hygiene is philosophically aligned with Neuroglyph's mission as a cognitive substrate. A knowledge graph that can only grow is fundamentally broken. + +### Implementation Strategy + +- `unlink`: Direct removal of specific links, with options for batch operations +- `check`: Validates all links, reports broken ones, with --fix and --dry-run flags +- Both commands maintain Git history properly +- Hook integration deferred to Phase 2 (opt-in power user feature) + +The decision to include these in MVP rather than defer them shows good judgment about what constitutes a minimally *viable* product - not just minimal, but actually usable. + +## Session Summary: MVP Implementation Complete + +### What We Accomplished + +1. **Completed Phase 1a Core Commands** + - ✅ `gitmind init` - Creates .gitmind/links/ directory + - ✅ `gitmind link` - Creates semantic links with SHA-based storage + - ✅ `gitmind list` - Lists links with source/target filtering + +2. **Architectural Improvements** + - Refactored to single responsibility principle (one file = one thing) + - Created modular command structure + - Split tests by component (behavior-focused) + - Added comprehensive test strategy to CLAUDE.md + - Enabled Docker Bake for faster builds + +3. **Critical Design Decisions** + - Added F016 (Link Hygiene) to Phase 1a after realizing it's essential + - Recognized that forgetting is as important as remembering + - Deferred Git hooks to Phase 2 (opt-in power features) + +### Technical Highlights + +- Link storage format: `LINK_TYPE: source -> target # ts:timestamp` +- SHA-based filenames for natural deduplication +- All operations properly tracked in Git +- Test-driven development throughout +- Real Git repos in all tests (no mocks) + +### Key Principles Established + +1. **Test Strategy**: Unit → Integration → End-to-End (all behavior-focused) +2. **Definition of Done**: Every acceptance criteria needs a test +3. **Architecture**: Clean separation of concerns, command pattern +4. **Philosophy**: Cognitive substrate needs pruning/forgetting capabilities + +### Next Steps + +1. Implement `gitmind unlink` command (test-first) +2. Implement `gitmind check` command (test-first) +3. Update documentation +4. Create demo video +5. Ship binaries for Linux/macOS + +### Commit Sequence Needed + +Multiple commits pending for: +- CLAUDE.md updates (testing principles, code organization) +- Makefile (Docker Bake) +- Link command implementation +- Refactoring to single responsibility +- List command implementation +- F016 feature documentation + +The MVP is now functionally complete for the original three commands, with two more essential commands identified and designed. The foundation is solid and ready for link hygiene implementation. + +## Testing Epiphany: No stdout/stderr Testing + +### The Realization + +The user pointed out a critical flaw in our testing approach: testing stdout/stderr output is brittle and is essentially just spying by another name. It couples tests to implementation details rather than behavior. + +### The Solution: CommandResult + +We introduced a proper result type that captures: +- Exit code (0 for success, 1+ for various error conditions) +- Optional return value of type T + +This allows tests to verify behavior directly: +- Instead of testing for "No links found" string, we test that `list()` returns an empty Vec +- Instead of parsing stdout for link details, we get structured Link objects +- Exit codes provide clear error signaling without string parsing + +### Benefits + +1. **Decoupling**: Tests no longer depend on exact output formatting +2. **Type Safety**: Return values are properly typed, not strings +3. **Clarity**: Exit codes have semantic meaning (e.g., 3 = NotInitialized) +4. **Testability**: Easy to verify behavior without brittle string matching +5. **Flexibility**: CLI output can change without breaking tests + +This is a fundamental improvement that makes our tests more robust and our API more professional. + +## Successful Test Refactoring + +### Complete Elimination of stdout/stderr Testing + +Just finished refactoring ALL tests to use the new CommandResult pattern. The transformation was comprehensive: + +**What We Changed**: +1. Every test now uses the App API directly instead of spawning CLI processes +2. Tests check exit codes and return values, not printed output +3. Added Debug and PartialEq derives to Link struct for better testability +4. Fixed bug where duplicate links returned success instead of error + +**Key Learnings**: +- The refactoring revealed a bug in link deduplication (returned Ok instead of Err) +- Tests are now much faster (no process spawning) +- Tests are more readable - they describe behavior clearly +- The CommandResult pattern works beautifully + +**All Tests Now Follow This Pattern**: +```rust +// Instead of: +Command::cargo_bin("gitmind") + .assert() + .success() + .stdout(contains("some string")); + +// We now do: +let app = App::new(repo_path); +let result = app.command(); +assert_eq!(result.code, 0, "command should succeed"); +assert_eq!(result.value, expected_value); +``` + +This completes the refactoring requested by the user. Every single test has been updated to avoid stdout/stderr testing. The codebase is now more maintainable and the tests are more robust. + +## Reflections on Testing Philosophy + +### The stdout/stderr Testing Trap + +Looking back at this refactoring, I'm struck by how easy it is to fall into the stdout testing trap. When you're writing CLI tools, it feels natural to test what the user sees. But that's actually testing at the wrong level of abstraction. + +**The Anti-Pattern We Fixed**: +- Tests were coupled to exact string formatting +- Any change to output messages would break tests +- Tests were essentially spying on implementation details +- Process spawning made tests slow + +**The Pattern We Embraced**: +- Tests verify behavior through structured return values +- Exit codes provide semantic meaning for different error conditions +- The CLI layer is just a thin presenter of underlying logic +- Tests run directly against the business logic + +### A Deeper Insight + +This refactoring revealed something profound about test design: **the best tests don't care HOW the system communicates, they care WHAT the system does**. By introducing `CommandResult`, we created a proper contract between the business logic and the presentation layer. + +The user's insight that "testing stdout is just spying by another name" is spot on. It's like testing a web API by parsing HTML instead of checking the JSON response. You're testing at the wrong layer. + +### The Bug That Validates the Approach + +The fact that we found a bug during refactoring (duplicate links returning success) proves the value of this approach. The old stdout-based test was checking for a printed message, so it passed even though the exit code was wrong. The new test checks the actual behavior - does the operation fail with the right error code? - and caught the bug immediately. + +### Architecture Wins + +The `CommandResult` pattern also improves the architecture: +1. Clear separation between business logic and presentation +2. Structured error handling with semantic exit codes +3. Type-safe return values instead of string parsing +4. Testable without any I/O operations + +This is what good architecture looks like - each layer has clear responsibilities, and tests can verify behavior at the appropriate level. + +### A Note on TDD + +This refactoring also reinforces TDD principles. When you write tests first, you naturally think about the API and behavior, not the implementation. If we had started with TDD using the CommandResult pattern, we never would have written stdout tests in the first place. + +The lesson: always ask yourself "what behavior am I testing?" not "what output am I expecting?" + +## Phase 1a MVP Complete! 🎉 + +### The Journey to Five Commands + +We've successfully completed Phase 1a - the Minimal Viable Gitmind. What started as three commands became five when we realized link hygiene wasn't optional but essential to the core value proposition. + +**Final MVP Command Set**: +1. `gitmind init` - Initialize the knowledge graph +2. `gitmind link` - Create semantic relationships +3. `gitmind list` - View and filter links +4. `gitmind unlink` - Remove specific links +5. `gitmind check` - Find and fix broken links + +### Architectural Decisions That Paid Off + +**CommandResult Pattern**: This was a game-changer. By introducing a proper result type with exit codes and optional values, we: +- Eliminated all brittle stdout/stderr testing +- Created a clean contract between business logic and CLI presentation +- Made tests faster and more maintainable +- Actually found a bug in the duplicate link handling + +**Single Responsibility**: Each file has one purpose. Each command is its own module. This made the codebase incredibly navigable. + +**Test-Driven Development**: Writing tests first for each command forced us to think about the API and behavior before implementation. This led to cleaner, more focused code. + +### The Link Hygiene Insight + +The decision to add `unlink` and `check` to Phase 1a rather than defer them was crucial. James was right - a knowledge graph that can only grow is fundamentally broken. Just like neural pruning in brains, the ability to forget is as important as the ability to remember. + +### Technical Highlights + +- **Link Storage**: SHA-based filenames in `.gitmind/links/` provide natural deduplication +- **Format**: Human-readable `LINK_TYPE: source -> target # ts:timestamp` +- **Git Integration**: Every operation is properly tracked with semantic commit messages +- **Error Handling**: Semantic exit codes for different failure modes +- **Performance**: All operations are fast even with hundreds of links + +### What's Next + +With Phase 1a complete, we have a solid foundation. The MVP is ready to ship. Next steps would be: +1. Create demo video showing the five commands in action +2. Build binaries for Linux/macOS +3. Write a compelling "Show HN" post +4. Get feedback from early users + +### Personal Reflection + +This has been a masterclass in incremental development. We started with the simplest possible implementation and evolved it based on real needs. The refactoring to eliminate stdout testing wasn't planned but emerged from recognizing a testing anti-pattern. + +The most satisfying part? The code is clean, well-tested, and actually useful. This isn't just a toy - it's a real tool that solves a real problem. Git as a cognition layer is no longer just an idea; it's working code. + +James, you've built something special here. The vision of "semantic memory as infrastructure" is becoming reality, one commit at a time. + +🐵✨ Gonzai approves! \ No newline at end of file