diff --git a/.ctx/HANDOFF.notfiles.notfiles.yaml b/.ctx/HANDOFF.notfiles.notfiles.yaml new file mode 100644 index 0000000..dd05b89 --- /dev/null +++ b/.ctx/HANDOFF.notfiles.notfiles.yaml @@ -0,0 +1,12 @@ +project: notfiles +id: notfile +updated: 2026-04-15 +branch: refactor/formalize-ports +state: + tests: unknown + build: unknown + notes: Handoff is now ephemeral; track active work via GitHub issues synced through doob. +items: [] +log: +- date: 2026-04-15 + summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work diff --git a/.gitignore b/.gitignore index 58df74b..c7535ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,21 @@ .envrc .worktrees/ .claude.local.md -docs/graph/ +docs/ librust_out.rlib .remember/ +!docs/superpowers/ + + +# handoff-begin +.ctx/* +!.ctx/HANDOFF.*.*.yaml +.ctx/HANDOFF.integration.notfiles.state.yaml +.ctx/HANDOFF.notcore.notfiles.state.yaml +.ctx/HANDOFF.notfiles.notfiles.state.yaml +.ctx/HANDOFF.notgraph.notfiles.state.yaml +.ctx/HANDOFF.nothooks.notfiles.state.yaml +.ctx/HANDOFF.notsecrets.notfiles.state.yaml +.ctx/HANDOFF.notstrap.notfiles.state.yaml +.ctx/.initialized +# handoff-end diff --git a/HANDOFF.md b/HANDOFF.md index 5d95728..4da5ec7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,111 +1,24 @@ # HANDOFF.md -State of the `notfiles` repo as of 2026-04-03. +Legacy note as of 2026-04-15. -## What Was Done +This repo no longer uses `HANDOFF.md` as a running history log. -### Session 1 (2026-03-31) +Active workflow: -Restructured from a flat `src/` layout into a Cargo workspace of 5 crates: +1. Sync GitHub issues into `doob` +2. Read short-lived handoff context from `.ctx/HANDOFF.*.*.yaml` +3. Update issue state in `doob` +4. Sync `doob` back to GitHub issues -- `notcore` — shared library (types, config, paths, errors) -- `notfiles` — dotfiles linker, migrated from `src/` -- `notsecrets` — age key retrieval (Bitwarden/file/prompt) + SOPS decrypt -- `nothooks` — Nushell hook runner with phase/state tracking -- `notstrap` — new-machine bootstrap orchestrator +Rules: -### Session 2 (2026-04-01) +- Handoff context should be brief and ephemeral +- Only open work should appear in handoff files +- Closed issues should be removed from handoff files after sync +- Historical status belongs in GitHub issues and git history, not here -Completed the age-native redesign of `notsecrets` — all 12 tasks done. +Current state: -**All tasks complete:** - -| Task | Status | Commit | -| ------------------------------------------------------------- | ------ | --------- | -| 1: Scaffold (Cargo.toml, error.rs, domain types, legacy shim) | ✅ | `34d9e3d` | -| 2: format.rs (age wire format parser/serializer) | ✅ | `2476daa` | -| 3: X25519 identity + recipient | ✅ | `6202c5d` | -| 4: Scrypt identity + recipient | ✅ | `b899198` | -| 5: SSH Ed25519 identity + recipient | ✅ | `566501c` | -| 6: SSH RSA identity + recipient | ✅ | `a0fe6f7` | -| 7: EncryptedIdentity (passphrase-protected identity file) | ✅ | `545a45b` | -| 8: Encryptor — multi-recipient age encryption | ✅ | `9863af6` | -| 9: Decryptor + wire EncryptedIdentity | ✅ | `e388d75` | -| 10+11: sources/ migration + resolve_identities() | ✅ | `f80ea02` | -| 12: notstrap migration, remove sops shell-out | ✅ | `58de007` | - -**Current state:** - -- 80 tests pass, 0 clippy warnings, `cargo check --workspace` clean -- `_legacy.rs` deleted; all deprecated shims removed -- `notsecrets` is now a self-contained age encryption/decryption library -- `notstrap` uses `resolve_identities` + `Decryptor` directly; no sops shell-out -- `EnvInjector` changed to `FnOnce`; `main.rs` sets `env_injector: None` -- Example at `crates/notsecrets/examples/age_smoke.rs` demonstrates round-trip encrypt/decrypt - -Also done this session (outside notfiles): - -- Fixed `~/.config/sops/age/keys.txt` — was `AGE-SECRET-KEY-1TESTKEY` placeholder, now real key from 1Password item `age-key-dotfiles` (UUID `6meypnchchq3tsb32mdnzxtlia`) -- `env.nu` now self-heals `keys.txt` from 1Password on fresh login if missing/placeholder -- Expanded `~/.secrets` to cover 18 keys (AWS, Docker, GitHub, Slack, ElevenLabs, Groq, Mistral, LangChain/Smith, SerpAPI, Twilio, Tavily, Context7) — all via `op://` UUID refs, resolved by `op inject` at shell startup -- Removed duplicate `op read` calls for OPENAI/ANTHROPIC from `env.nu` -- Cleaned up `~/.config/dev-bootstrap/secrets.env` — removed TAVILY/CONTEXT7 (now in `~/.secrets`), kept non-secret MCP config vars - -## Pending Issues - -### 1. Push to remote - -Several commits ahead of `gitea/main`. Push when ready. - -### 2. notstrap has an empty lib.rs - -`crates/notstrap/src/lib.rs` is empty — created as a stub. Either delete `[lib]` from `notstrap/Cargo.toml` or remove the file. - -### 3. notstrap config not documented - -`notstrap.toml` format is defined inline via serde structs but never written down. A sample config would help. - -### 4. Integration test coverage for nothooks assumes nu in PATH - -`nothooks` integration tests call `nu` directly. CI needs `nu` installed or tests need `#[ignore]` + feature flag. - -### 5. notstrap sops_file path footgun - -If `sops_file` in `notstrap.toml` is an absolute path, the `join` will silently override the base. Low priority. - -### 6. HuggingFace token not in ~/.secrets - -No API token found in 1Password — only login credentials. Add token manually if needed. - -### 7. notsecrets is not wire-compatible with standard age - -The Encryptor/Decryptor use single-block ChaCha20-Poly1305 rather than the STREAM construction. Files encrypted by `notsecrets` cannot be decrypted by `rage`/`age` CLI and vice versa. This is intentional per the spec but worth documenting. - -### Session 3 (2026-04-03) - -**notgraph** — crate dependency + module graph report landed and polished: - -- Heatmap coloring on crate nodes (fan-in intensity → blue gradient) -- Per-crate module graphs (Mermaid `flowchart TD`) with `__`-separated node IDs -- Cycle callouts with `cycle-entry` CSS class -- Fixed: hyphenated crate names (`my-crate`) were emitted as raw Mermaid IDs (invalid); added `mermaid_id` sanitizer (`-` → `_`) applied to all node IDs, edges, `style` directives, and cycle highlights -- 6 new integration tests covering: cyclic fixture, output file presence, hyphen sanitization, single-crate heatmap edge case, per-crate module graph rendering, cycle callout presence -- `notsecrets` refactor confirmed complete — no TODOs, no pending cleanup - -**notgraph planned extensions** (not yet implemented — prioritized list): - -| Priority | Section | Data source | Visualization | -| -------- | ----------------------- | --------------------------------------------------------- | -------------------------------------------- | -| 1 | Code coverage heatmap | `cargo llvm-cov --json` | Treemap or per-crate bar | -| 2 | Cyclomatic complexity | `rust-code-analysis-cli --metrics` | Histogram + top-offenders table | -| 3 | Lines of code breakdown | `tokei --output json` | Stacked bar (code/comments/blanks) | -| 4 | Unsafe code audit | `cargo geiger --output-format Json` | Table + traffic-light badges | -| 5 | Dep freshness + vulns | `cargo outdated --format json` + `cargo audit --json` | Semver-distance dots + advisory cards | -| 6 | Doc coverage | `cargo doc -Z unstable-options --show-coverage` (nightly) | Horizontal bars per crate | -| 7 | Commit churn heatmap | `git log --name-only` | Scatter: churn × complexity × LOC × coverage | -| 8 | API surface area | `cargo rustdoc --output-format json` | Bar by item kind per crate | -| 9 | Test-to-code ratio | `tokei` + `#[test]` count | Table + stacked bar | -| 10 | Dep weight/bloat | `cargo metadata` | Stacked bar (direct+transitive) + duplicates | - -Easiest to add with no extra tooling: LOC breakdown (tokei), test-to-code ratio, dep weight (cargo metadata always available). -Coverage + complexity require `cargo-llvm-cov` and `rust-code-analysis-cli` installed. +- No open handoff items are tracked locally +- Use GitHub issues plus `doob` as the task source of truth diff --git a/HANDOFF.notfiles.workspace.yaml b/HANDOFF.notfiles.workspace.yaml deleted file mode 100644 index c62bc1a..0000000 --- a/HANDOFF.notfiles.workspace.yaml +++ /dev/null @@ -1,77 +0,0 @@ -project: notfiles -id: notfile -updated: 2026-04-03 -branch: main -state: - tests: 103 passing - build: clean - notes: null -items: -- id: notfile-1 - doob_uuid: d1c0adc2-028a-4a12-be84-0698587f6f35 - name: public-ready-workflow-fix - priority: P1 - status: done - title: Investigate unstaged change in .gitea/workflows/public-ready.yml - description: | - `git status` shows `M .gitea/workflows/public-ready.yml` — a modification is uncommitted. Needs inspection to determine if it's intentional or leftover from a prior session. - files: - - .gitea/workflows/public-ready.yml - extra: - - date: 2026-04-03 - type: note - note: emoji → [OK] formatting change; committed in style commit - completed: 2026-04-03 -- id: notfile-2 - doob_uuid: b88a6135-3ecb-419e-aaac-22cb87595f90 - name: notgraph-integration-test-fix - priority: P1 - status: done - title: Investigate unstaged change in crates/notgraph/tests/integration.rs - description: | - `git status` shows ` M crates/notgraph/tests/integration.rs` — an unstaged modification. Needs inspection; may be a leftover tweak or a pending improvement. - files: - - crates/notgraph/tests/integration.rs - extra: - - date: 2026-04-03 - type: note - note: cargo fmt reformatted long emit::write_all calls; committed in style commit - completed: 2026-04-03 -- id: notfile-3 - doob_uuid: 615051dd-d22c-4d50-ad70-15497780726b - name: notgraph-plan-remaining-steps - priority: P2 - status: done - title: Review notgraph implementation plan for any incomplete tasks - description: | - Plan at docs/superpowers/plans/2026-04-01-notgraph.md may still have unchecked steps. Most functionality appears shipped (all pipeline stages, HTML/MD/JSON emit, Mermaid graphs, heatmap, cycle detection, CI integration). Verify checklist is fully marked done or remaining items are intentionally parked. - files: - - docs/superpowers/plans/2026-04-01-notgraph.md - extra: - - date: 2026-04-03 - type: note - note: All 43 checkboxes marked [x]; implementation confirmed complete via git log - completed: 2026-04-03 -log: -- date: 2026-04-03 - summary: Marked all 43 notgraph plan checkboxes done; all handoff items resolved -- date: 2026-04-03 - summary: 'Resolved both P1 items: formatting-only changes, committed style cleanup' - commits: - - style commit -- date: 2026-04-03 - summary: Created initial HANDOFF.yaml; notgraph crate fully implemented across 20+ commits -- date: 2026-04-01 - summary: 'Implemented notgraph: scaffold, all pipeline stages, emit, CLI wiring, integration tests, Mermaid fixes' - commits: - - 1e2f240 - - b4bb576 - - 82f84d5 - - 17a321c - - b5a2710 -- date: 2026-03-31 - summary: Implemented notsecrets age-native redesign, notstrap migration, integration test suite - commits: - - 58de007 - - f80ea02 - - e388d75 diff --git a/crates/notfiles/src/adapters/fs.rs b/crates/notfiles/src/adapters/fs.rs index 3e11ef2..feac68f 100644 --- a/crates/notfiles/src/adapters/fs.rs +++ b/crates/notfiles/src/adapters/fs.rs @@ -5,6 +5,10 @@ use std::path::{Path, PathBuf}; pub struct FileStoreImpl; impl FileStore for FileStoreImpl { + fn read(&self, path: &Path) -> Result, std::io::Error> { + std::fs::read(path) + } + fn read_to_string(&self, path: &Path) -> Result { std::fs::read_to_string(path) } @@ -25,6 +29,10 @@ impl FileStore for FileStoreImpl { std::fs::remove_dir_all(path) } + fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error> { + std::fs::remove_dir(path) + } + fn read_link(&self, path: &Path) -> Result { std::fs::read_link(path) } @@ -41,6 +49,12 @@ impl FileStore for FileStoreImpl { std::fs::create_dir_all(path) } + fn read_dir(&self, path: &Path) -> Result, std::io::Error> { + std::fs::read_dir(path)? + .map(|entry| entry.map(|entry| entry.path())) + .collect() + } + #[cfg(unix)] fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> { std::os::unix::fs::symlink(target, link) diff --git a/crates/notfiles/src/lib.rs b/crates/notfiles/src/lib.rs index 2fcceed..d3d46ca 100644 --- a/crates/notfiles/src/lib.rs +++ b/crates/notfiles/src/lib.rs @@ -11,14 +11,22 @@ use std::path::Path; pub use adapters::FileStoreImpl; pub use linker::{LinkOptions, State}; -pub use package::resolve_packages; +pub use package::{resolve_packages, resolve_packages_with_store}; pub use ports::FileStore; pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result { - let fs = &adapters::FileStoreImpl; + link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) +} + +pub fn link_with_store( + dotfiles_dir: &Path, + packages: &[String], + opts: &LinkOptions, + fs: &dyn FileStore, +) -> Result { let config = notcore::Config::load(dotfiles_dir)?; let mut state = linker::State::load(dotfiles_dir, fs)?; - let pkgs = resolve_packages(dotfiles_dir, packages)?; + let pkgs = resolve_packages_with_store(dotfiles_dir, packages, fs)?; for pkg in &pkgs { linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs)?; } @@ -27,7 +35,15 @@ pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Res } pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> { - let fs = &adapters::FileStoreImpl; + unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl) +} + +pub fn unlink_with_store( + dotfiles_dir: &Path, + packages: &[String], + opts: &LinkOptions, + fs: &dyn FileStore, +) -> Result<()> { let mut state = linker::State::load(dotfiles_dir, fs)?; let pkgs = if packages.is_empty() { state diff --git a/crates/notfiles/src/linker.rs b/crates/notfiles/src/linker.rs index e92d7e6..8decd7f 100644 --- a/crates/notfiles/src/linker.rs +++ b/crates/notfiles/src/linker.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use chrono::Utc; use serde::{Deserialize, Serialize}; -use crate::package::collect_files; +use crate::package::collect_files_with_store; use crate::ports::FileStore; use notcore::{Config, Method, NotfilesError, expand_tilde}; @@ -61,6 +61,11 @@ impl State { self.entries.retain(|e| e.package != package); } + pub fn remove_entry(&mut self, package: &str, source: &str, target: &str) { + self.entries + .retain(|e| !(e.package == package && e.source == source && e.target == target)); + } + pub fn add_entry(&mut self, entry: StateEntry) { // Remove existing entry for same source+target, then add new self.entries @@ -87,7 +92,7 @@ pub fn link_package( let package_dir = dotfiles_dir.join(package); let method = config.method_for(package); let target_base = expand_tilde(config.target_for(package))?; - let files = collect_files(&package_dir, config, package)?; + let files = collect_files_with_store(&package_dir, config, package, fs)?; if files.is_empty() { if opts.verbose { @@ -201,7 +206,7 @@ pub fn link_package( } } Method::Copy => { - let content = std::fs::read(&source)?; + let content = fs.read(&source)?; fs.write(&target, &content).map(|_| content.len() as u64) } }; @@ -261,13 +266,19 @@ pub fn unlink_package( return Ok(()); } + let mut removed_entries = Vec::new(); + for entry in &entries { let target = PathBuf::from(&entry.target); + let source = PathBuf::from(&entry.source); if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() { if opts.verbose { println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display()); } + if !opts.dry_run { + removed_entries.push((entry.source.clone(), entry.target.clone())); + } continue; } @@ -275,7 +286,6 @@ pub fn unlink_package( Method::Symlink => { // Verify it's a symlink pointing to our source if let Ok(link_target) = fs.read_link(&target) { - let source = PathBuf::from(&entry.source); if link_target != source { if opts.verbose { println!( @@ -292,9 +302,36 @@ pub fn unlink_package( continue; } } - Method::Copy => { - // For copies, trust the state file - } + Method::Copy => match (fs.read(&source), fs.read(&target)) { + (Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {} + (Ok(_), Ok(_)) => { + if opts.verbose { + println!( + " \x1b[33mskip\x1b[0m {} (copied file diverged from source)", + target.display() + ); + } + continue; + } + (Err(_), _) => { + if opts.verbose { + println!( + " \x1b[33mskip\x1b[0m {} (source missing for copied file)", + target.display() + ); + } + continue; + } + (_, Err(_)) => { + if opts.verbose { + println!( + " \x1b[33mskip\x1b[0m {} (cannot read copied target)", + target.display() + ); + } + continue; + } + }, } if opts.dry_run { @@ -311,11 +348,14 @@ pub fn unlink_package( // Clean up empty parent dirs cleanup_empty_parents(&target, fs); + removed_entries.push((entry.source.clone(), entry.target.clone())); } } if !opts.dry_run { - state.remove_package(package); + for (source, target) in removed_entries { + state.remove_entry(package, &source, &target); + } state.save(dotfiles_dir, fs)?; } @@ -331,15 +371,10 @@ fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn File false } } - Method::Copy => { - // Skip re-copy if target exists and has the same size and mtime as source. - match (fs.metadata(source), fs.metadata(target)) { - (Ok(sm), Ok(tm)) => { - sm.len() == tm.len() && sm.modified().ok() == tm.modified().ok() - } - _ => false, - } - } + Method::Copy => match (fs.read(source), fs.read(target)) { + (Ok(source_bytes), Ok(target_bytes)) => source_bytes == target_bytes, + _ => false, + }, } } @@ -349,18 +384,19 @@ fn backup_path(path: &Path) -> PathBuf { PathBuf::from(format!("{name}.notfiles-backup-{timestamp}")) } -fn cleanup_empty_parents(path: &Path, _fs: &dyn FileStore) { +fn cleanup_empty_parents(path: &Path, fs: &dyn FileStore) { let mut dir = path.parent(); while let Some(parent) = dir { // Stop at home dir or root if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") { break; } - if std::fs::read_dir(parent) - .map(|mut d| d.next().is_none()) + if fs + .read_dir(parent) + .map(|children| children.is_empty()) .unwrap_or(false) { - let _ = std::fs::remove_dir(parent); + let _ = fs.remove_dir(parent); dir = parent.parent(); } else { break; diff --git a/crates/notfiles/src/main.rs b/crates/notfiles/src/main.rs index 243cfb9..9d4e057 100644 --- a/crates/notfiles/src/main.rs +++ b/crates/notfiles/src/main.rs @@ -5,7 +5,7 @@ use std::fs; use notcore::Config; use notfiles::cli::{Cli, Command}; use notfiles::linker::{LinkOptions, State}; -use notfiles::package::resolve_packages; +use notfiles::package::resolve_packages_with_store; use notfiles::{adapters, linker, status}; fn main() -> Result<()> { @@ -26,7 +26,7 @@ fn main() -> Result<()> { let fs = &adapters::FileStoreImpl; let config = Config::load(&dotfiles_dir)?; let mut state = State::load(&dotfiles_dir, fs)?; - let pkgs = resolve_packages(&dotfiles_dir, &packages)?; + let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?; let opts = LinkOptions { force, no_backup, @@ -74,7 +74,7 @@ fn main() -> Result<()> { .collect::>() } else { // Validate requested packages exist in state - let _ = resolve_packages(&dotfiles_dir, &packages).or_else(|_| { + let _ = resolve_packages_with_store(&dotfiles_dir, &packages, fs).or_else(|_| { // Package dir might be gone but state entries exist — that's fine for unlink Ok::, anyhow::Error>(packages.clone()) }); @@ -111,10 +111,10 @@ fn main() -> Result<()> { let fs = &adapters::FileStoreImpl; let config = Config::load(&dotfiles_dir)?; let state = State::load(&dotfiles_dir, fs)?; - let pkgs = resolve_packages(&dotfiles_dir, &packages)?; + let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?; for pkg in &pkgs { - let entries = status::package_status(&dotfiles_dir, &config, &state, pkg); + let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs); status::print_status(pkg, &entries); } } diff --git a/crates/notfiles/src/package.rs b/crates/notfiles/src/package.rs index e2b981b..20ae72e 100644 --- a/crates/notfiles/src/package.rs +++ b/crates/notfiles/src/package.rs @@ -1,17 +1,29 @@ -use std::fs; use std::path::{Path, PathBuf}; +use crate::adapters::FileStoreImpl; use crate::ignore::IgnoreMatcher; +use crate::ports::FileStore; use notcore::{Config, NotfilesError}; /// Discover available packages (subdirectories of the dotfiles dir). pub fn discover_packages(dotfiles_dir: &Path) -> Result, NotfilesError> { + discover_packages_with_store(dotfiles_dir, &FileStoreImpl) +} + +pub fn discover_packages_with_store( + dotfiles_dir: &Path, + fs: &dyn FileStore, +) -> Result, NotfilesError> { let mut packages = Vec::new(); - for entry in fs::read_dir(dotfiles_dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); + // TODO(#19): Replace implicit top-level directory discovery with an explicit + // package contract so mixed-purpose repos do not accidentally link + // operational directories like scripts/ or docs/. + for path in fs.read_dir(dotfiles_dir)? { + if fs.is_dir(&path) { + let name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); // Skip hidden dirs and common non-package dirs if !name.starts_with('.') { packages.push(name); @@ -28,7 +40,15 @@ pub fn resolve_packages( dotfiles_dir: &Path, requested: &[String], ) -> Result, NotfilesError> { - let available = discover_packages(dotfiles_dir)?; + resolve_packages_with_store(dotfiles_dir, requested, &FileStoreImpl) +} + +pub fn resolve_packages_with_store( + dotfiles_dir: &Path, + requested: &[String], + fs: &dyn FileStore, +) -> Result, NotfilesError> { + let available = discover_packages_with_store(dotfiles_dir, fs)?; if requested.is_empty() { return Ok(available); } @@ -45,11 +65,20 @@ pub fn collect_files( package_dir: &Path, config: &Config, package_name: &str, +) -> Result, NotfilesError> { + collect_files_with_store(package_dir, config, package_name, &FileStoreImpl) +} + +pub fn collect_files_with_store( + package_dir: &Path, + config: &Config, + package_name: &str, + fs: &dyn FileStore, ) -> Result, NotfilesError> { let patterns = config.ignore_patterns_for(package_name); let matcher = IgnoreMatcher::new(&patterns)?; let mut files = Vec::new(); - walk_dir(package_dir, package_dir, &matcher, &mut files)?; + walk_dir(package_dir, package_dir, &matcher, &mut files, fs)?; files.sort(); Ok(files) } @@ -59,18 +88,17 @@ fn walk_dir( current: &Path, matcher: &IgnoreMatcher, files: &mut Vec, + fs: &dyn FileStore, ) -> Result<(), NotfilesError> { - for entry in fs::read_dir(current)? { - let entry = entry?; - let path = entry.path(); + for path in fs.read_dir(current)? { let relative = path.strip_prefix(base).unwrap().to_path_buf(); if matcher.is_ignored(&relative) { continue; } - if path.is_dir() { - walk_dir(base, &path, matcher, files)?; + if fs.is_dir(&path) { + walk_dir(base, &path, matcher, files, fs)?; } else { files.push(relative); } @@ -81,6 +109,7 @@ fn walk_dir( #[cfg(test)] mod tests { use super::*; + use std::fs; use tempfile::TempDir; #[test] diff --git a/crates/notfiles/src/ports.rs b/crates/notfiles/src/ports.rs index 90d9cca..7ca2c7f 100644 --- a/crates/notfiles/src/ports.rs +++ b/crates/notfiles/src/ports.rs @@ -5,6 +5,9 @@ use std::path::{Path, PathBuf}; /// This trait defines the interface for all file system interactions in the notfiles /// linker, enabling testing with mock implementations and potential future backends. pub trait FileStore { + /// Read entire file to bytes. + fn read(&self, path: &Path) -> Result, std::io::Error>; + /// Read entire file to string. fn read_to_string(&self, path: &Path) -> Result; @@ -20,6 +23,9 @@ pub trait FileStore { /// Recursively remove a directory and all its contents. fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error>; + /// Remove an empty directory. + fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error>; + /// Read the target of a symbolic link. fn read_link(&self, path: &Path) -> Result; @@ -32,6 +38,9 @@ pub trait FileStore { /// Create a directory and all missing parent directories. fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error>; + /// List direct children of a directory. + fn read_dir(&self, path: &Path) -> Result, std::io::Error>; + /// Create a symbolic link at `link` pointing to `target`. /// /// On Unix systems, this creates a symlink. On Windows with symlink support enabled, diff --git a/crates/notfiles/src/status.rs b/crates/notfiles/src/status.rs index 9fc525a..2327b82 100644 --- a/crates/notfiles/src/status.rs +++ b/crates/notfiles/src/status.rs @@ -1,8 +1,8 @@ -use std::fs; use std::path::{Path, PathBuf}; use crate::linker::State; -use crate::package::collect_files; +use crate::package::collect_files_with_store; +use crate::ports::FileStore; use notcore::{Config, Method, expand_tilde}; #[derive(Debug, PartialEq)] @@ -37,6 +37,7 @@ pub fn package_status( config: &Config, state: &State, package: &str, + fs: &dyn FileStore, ) -> Vec { let mut results = Vec::new(); let package_dir = dotfiles_dir.join(package); @@ -47,7 +48,7 @@ pub fn package_status( }; // Check files that should exist - if let Ok(files) = collect_files(&package_dir, config, package) { + if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) { for relative in &files { let source = package_dir.join(relative); let target = target_base.join(relative); @@ -55,25 +56,31 @@ pub fn package_status( let status = match method { Method::Symlink => { - if let Ok(link_target) = fs::read_link(&target) { + if let Ok(link_target) = fs.read_link(&target) { if link_target == source { FileStatus::Linked } else { FileStatus::Conflict } - } else if target.exists() { + } else if fs.exists(&target) { FileStatus::Conflict } else { FileStatus::Missing } } Method::Copy => { - let has_state = state.entries.iter().any(|e| { - e.package == package && e.target == target.to_string_lossy().as_ref() + let tracked = state.entries.iter().any(|e| { + e.package == package + && e.target == target.to_string_lossy().as_ref() + && e.source == source.to_string_lossy().as_ref() }); - if has_state && target.exists() { + let content_matches = match (fs.read(&source), fs.read(&target)) { + (Ok(source_bytes), Ok(target_bytes)) => source_bytes == target_bytes, + _ => false, + }; + if tracked && content_matches { FileStatus::Copied - } else if target.exists() { + } else if fs.exists(&target) { FileStatus::Conflict } else { FileStatus::Missing @@ -92,7 +99,7 @@ pub fn package_status( // Check for orphans: entries in state that no longer have a source file for entry in state.entries_for_package(package) { let source = PathBuf::from(&entry.source); - if !source.exists() { + if !fs.exists(&source) { let target = PathBuf::from(&entry.target); // Only add if we didn't already report this target if !results.iter().any(|r| r.target == target) { diff --git a/crates/notfiles/tests/integration.rs b/crates/notfiles/tests/integration.rs index af8915d..cd69f60 100644 --- a/crates/notfiles/tests/integration.rs +++ b/crates/notfiles/tests/integration.rs @@ -303,6 +303,89 @@ method = "copy" assert!(!ssh_config.exists()); } +#[test] +fn test_status_flags_diverged_copy_as_conflict() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + let ssh = dotfiles.join("ssh"); + fs::create_dir_all(ssh.join(".ssh")).unwrap(); + fs::write(ssh.join(".ssh/config"), "Host *\n AddKeysToAgent yes").unwrap(); + + let config = format!( + r#"[defaults] +target = "{}" +ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"] + +[packages.ssh] +method = "copy" +"#, + target.display() + ); + fs::write(dotfiles.join("notfiles.toml"), config).unwrap(); + + let (_, _, ok) = run(&dotfiles, &["link", "ssh"]); + assert!(ok); + + let ssh_config = target.join(".ssh/config"); + fs::write(&ssh_config, "Host github.com\n User joe\n").unwrap(); + + let (stdout, _, ok) = run(&dotfiles, &["status", "ssh"]); + assert!(ok); + assert!( + stdout.contains("conflict"), + "diverged copy should be reported as conflict, stdout={stdout}" + ); +} + +#[test] +fn test_unlink_preserves_diverged_copy_and_state() { + let tmp = TempDir::new().unwrap(); + setup_dotfiles(&tmp); + let dotfiles = tmp.path().join("dotfiles"); + let target = tmp.path().join("home"); + + let ssh = dotfiles.join("ssh"); + fs::create_dir_all(ssh.join(".ssh")).unwrap(); + fs::write(ssh.join(".ssh/config"), "Host *\n AddKeysToAgent yes").unwrap(); + + let config = format!( + r#"[defaults] +target = "{}" +ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"] + +[packages.ssh] +method = "copy" +"#, + target.display() + ); + fs::write(dotfiles.join("notfiles.toml"), config).unwrap(); + + let (_, _, ok) = run(&dotfiles, &["link", "ssh"]); + assert!(ok); + + let ssh_config = target.join(".ssh/config"); + fs::write(&ssh_config, "Host github.com\n User joe\n").unwrap(); + + let (stdout, stderr, ok) = run(&dotfiles, &["unlink", "ssh", "--verbose"]); + assert!( + ok, + "unlink should not fail: stdout={stdout} stderr={stderr}" + ); + assert!( + ssh_config.exists(), + "diverged copy should not be removed during unlink" + ); + + let state = fs::read_to_string(dotfiles.join(".notfiles-state.toml")).unwrap(); + assert!( + state.contains(".ssh/config"), + "state entry must be retained when unlink skips a diverged copy: {state}" + ); +} + #[test] fn test_dry_run() { let tmp = TempDir::new().unwrap(); diff --git a/crates/notgraph/tests/integration.rs b/crates/notgraph/tests/integration.rs index 7a26753..8c3b326 100644 --- a/crates/notgraph/tests/integration.rs +++ b/crates/notgraph/tests/integration.rs @@ -60,7 +60,7 @@ fn cyclic_fixture_module_graph_builds() { /// write_all produces report.html, report.md, and report.json in the output dir. #[test] fn write_all_creates_expected_files() { - use types::{CrateGraph, GraphStats, ModuleGraph}; + use types::{CrateGraph, ModuleGraph}; let out = TempDir::new().unwrap(); let crate_graph = CrateGraph { @@ -162,7 +162,7 @@ fn write_all_heatmap_does_not_panic_for_single_crate() { /// ModuleGraph entry in the HTML output. #[test] fn write_all_renders_per_crate_module_graphs() { - use types::{CrateGraph, ModuleGraph}; + use types::CrateGraph; let out = TempDir::new().unwrap(); let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).unwrap(); @@ -170,7 +170,7 @@ fn write_all_renders_per_crate_module_graphs() { nodes: vec!["clean".to_string()], edges: vec![], }; - let stats = analysis::analyse(&crate_graph, &[mg.clone()], 3); + let stats = analysis::analyse(&crate_graph, std::slice::from_ref(&mg), 3); let symbol_tables: Vec = vec![]; emit::write_all(out.path(), &crate_graph, &[mg], &stats, &symbol_tables).unwrap(); diff --git a/crates/nothooks/src/lib.rs b/crates/nothooks/src/lib.rs index 7dd807a..e87ac91 100644 --- a/crates/nothooks/src/lib.rs +++ b/crates/nothooks/src/lib.rs @@ -14,6 +14,10 @@ pub enum HookResult { /// Run all hooks matching `phase` and collect into a `Report`. /// /// State is loaded once and saved once per call — not once per hook. -pub fn run_phase(hooks: &[HookSpec], phase: &HookPhase, runner: &HookRunner) -> Report { +pub fn run_phase( + hooks: &[HookSpec], + phase: &HookPhase, + runner: &HookRunner, +) -> anyhow::Result { runner.run_phase(hooks, phase) } diff --git a/crates/nothooks/src/main.rs b/crates/nothooks/src/main.rs index 3e25532..98980f4 100644 --- a/crates/nothooks/src/main.rs +++ b/crates/nothooks/src/main.rs @@ -62,7 +62,7 @@ fn main() -> Result<()> { HookRunner::new(state_dir) }; - let report = run_phase(&file.hooks, &phase, &runner); + let report = run_phase(&file.hooks, &phase, &runner)?; report.print(); if report.has_failures() { diff --git a/crates/nothooks/src/runner.rs b/crates/nothooks/src/runner.rs index d8c0b47..47be894 100644 --- a/crates/nothooks/src/runner.rs +++ b/crates/nothooks/src/runner.rs @@ -1,7 +1,8 @@ use crate::HookResult; use crate::state::HookState; +use anyhow::{Context, Result}; use notcore::{HookPhase, HookSpec, Report, StepStatus}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; fn resolve_interpreter(spec: &HookSpec) -> Result { @@ -51,8 +52,9 @@ impl HookRunner { /// State is loaded once before iterating and saved once at the end (only if /// at least one Setup hook completed successfully), avoiding N file reads /// and writes per phase. - pub fn run_phase(&self, hooks: &[HookSpec], phase: &HookPhase) -> Report { - let mut state = HookState::load(&self.state_dir).unwrap_or_default(); + pub fn run_phase(&self, hooks: &[HookSpec], phase: &HookPhase) -> Result { + let mut state = HookState::load(&self.state_dir) + .with_context(|| format!("loading hook state from {}", self.state_dir.display()))?; let mut state_dirty = false; let mut report = Report::default(); @@ -67,10 +69,12 @@ impl HookRunner { } if state_dirty { - let _ = state.save(&self.state_dir); + state + .save(&self.state_dir) + .with_context(|| format!("saving hook state into {}", self.state_dir.display()))?; } - report + Ok(report) } /// Execute a single hook, using the caller-owned `state` and `dirty` flag @@ -89,11 +93,12 @@ impl HookRunner { Ok(i) => i, Err(msg) => return HookResult::Failed(msg), }; + let script = self.resolve_script_path(&spec.script); // SAFETY: `interp` is derived from the file extension or an explicit `interpreter` field // in HookSpec (both come from config, not untrusted shell input). `spec.script` is a // filesystem path from config. Both are passed as discrete args with no shell involved, // so argument injection is not possible. - let result = Command::new(&interp).arg(&spec.script).status(); + let result = Command::new(&interp).arg(&script).status(); match result { Ok(status) if status.success() => { @@ -112,13 +117,25 @@ impl HookRunner { /// /// Prefer [`HookRunner::run_phase`] when running multiple hooks to avoid /// repeated state I/O. - pub fn run_hook(&self, spec: &HookSpec) -> HookResult { - let mut state = HookState::load(&self.state_dir).unwrap_or_default(); + pub fn run_hook(&self, spec: &HookSpec) -> Result { + let mut state = HookState::load(&self.state_dir) + .with_context(|| format!("loading hook state from {}", self.state_dir.display()))?; let mut state_dirty = false; let result = self.run_hook_with_state(spec, &mut state, &mut state_dirty); if state_dirty { - let _ = state.save(&self.state_dir); + state + .save(&self.state_dir) + .with_context(|| format!("saving hook state into {}", self.state_dir.display()))?; + } + Ok(result) + } + + fn resolve_script_path(&self, script: &str) -> PathBuf { + let script = Path::new(script); + if script.is_absolute() { + script.to_path_buf() + } else { + self.state_dir.join(script) } - result } } diff --git a/crates/nothooks/src/state.rs b/crates/nothooks/src/state.rs index aecab78..6f8aba9 100644 --- a/crates/nothooks/src/state.rs +++ b/crates/nothooks/src/state.rs @@ -22,7 +22,9 @@ impl HookState { pub fn save(&self, dir: &Path) -> Result<()> { let path = dir.join(STATE_FILE); - std::fs::write(path, toml::to_string(self)?)?; + let tmp = dir.join(format!("{STATE_FILE}.tmp")); + std::fs::write(&tmp, toml::to_string(self)?)?; + std::fs::rename(&tmp, &path)?; Ok(()) } diff --git a/crates/nothooks/tests/integration.rs b/crates/nothooks/tests/integration.rs index ff11664..e1a18a4 100644 --- a/crates/nothooks/tests/integration.rs +++ b/crates/nothooks/tests/integration.rs @@ -19,7 +19,7 @@ fn test_hook_success() { let dir = TempDir::new().unwrap(); let spec = make_hook_script(&dir, "ok-hook", "echo hello"); let runner = HookRunner::new(dir.path().to_path_buf()); - let result = runner.run_hook(&spec); + let result = runner.run_hook(&spec).unwrap(); assert!(matches!(result, HookResult::Ok)); } @@ -28,7 +28,7 @@ fn test_hook_failure() { let dir = TempDir::new().unwrap(); let spec = make_hook_script(&dir, "fail-hook", "exit 1"); let runner = HookRunner::new(dir.path().to_path_buf()); - let result = runner.run_hook(&spec); + let result = runner.run_hook(&spec).unwrap(); assert!(matches!(result, HookResult::Failed(_))); } @@ -39,11 +39,11 @@ fn test_setup_hook_skipped_on_rerun() { spec.phase = notcore::HookPhase::Setup; let runner = HookRunner::new(dir.path().to_path_buf()); - let r1 = runner.run_hook(&spec); + let r1 = runner.run_hook(&spec).unwrap(); assert!(matches!(r1, HookResult::Ok)); // Second run — should be skipped - let r2 = runner.run_hook(&spec); + let r2 = runner.run_hook(&spec).unwrap(); assert!(matches!(r2, HookResult::Skipped)); } @@ -54,9 +54,29 @@ fn test_setup_hook_force_reruns() { spec.phase = notcore::HookPhase::Setup; let runner = HookRunner::new(dir.path().to_path_buf()); - runner.run_hook(&spec); + runner.run_hook(&spec).unwrap(); let runner2 = HookRunner::with_force(dir.path().to_path_buf()); - let r2 = runner2.run_hook(&spec); + let r2 = runner2.run_hook(&spec).unwrap(); assert!(matches!(r2, HookResult::Ok)); } + +#[test] +fn test_relative_script_path_resolves_from_state_dir() { + let dir = TempDir::new().unwrap(); + let scripts_dir = dir.path().join("scripts"); + fs::create_dir_all(&scripts_dir).unwrap(); + let script = scripts_dir.join("relative.sh"); + fs::write(&script, "echo relative-ok").unwrap(); + + let spec = HookSpec { + name: "relative".to_string(), + script: "scripts/relative.sh".to_string(), + phase: HookPhase::Dot, + interpreter: Some("sh".to_string()), + }; + + let runner = HookRunner::new(dir.path().to_path_buf()); + let result = runner.run_hook(&spec).unwrap(); + assert!(matches!(result, HookResult::Ok)); +} diff --git a/crates/notstrap/src/lib.rs b/crates/notstrap/src/lib.rs index 326ee91..f404a83 100644 --- a/crates/notstrap/src/lib.rs +++ b/crates/notstrap/src/lib.rs @@ -157,6 +157,7 @@ pub fn run(opts: BootstrapOptions) -> Result { } Err(e) => { report.add("link dotfiles", StepStatus::Failed(e.to_string())); + return Ok(report); } } @@ -171,7 +172,13 @@ pub fn run(opts: BootstrapOptions) -> Result { (HookPhase::Dot, "dot hooks"), (HookPhase::Setup, "setup hooks"), ] { - let phase_report = run_phase(&cfg.hooks, &phase, &runner); + let phase_report = match run_phase(&cfg.hooks, &phase, &runner) { + Ok(report) => report, + Err(e) => { + report.add(label, StepStatus::Failed(e.to_string())); + return Ok(report); + } + }; let failed = phase_report .steps .iter() @@ -183,6 +190,9 @@ pub fn run(opts: BootstrapOptions) -> Result { StepStatus::Ok }; report.add(label, summary); + if failed > 0 { + return Ok(report); + } } Ok(report) diff --git a/preflight.md b/preflight.md new file mode 100644 index 0000000..42f7b8a --- /dev/null +++ b/preflight.md @@ -0,0 +1,267 @@ +# preflight.rs — Quick Context Surfacer + +A `rust-script` preflight that surfaces repo context in one shot: shell detection, git state, +tracked-file tree, and HANDOFF document read/update. + +```rust +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1" +//! colored = "2" +//! chrono = { version = "0.4", features = ["clock"] } +//! ``` + +use anyhow::{Context, Result}; +use colored::Colorize; +use std::{ + env, fs, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn run(cmd: &str, args: &[&str]) -> Option { + Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +fn run_or(cmd: &str, args: &[&str], fallback: &str) -> String { + run(cmd, args).unwrap_or_else(|| fallback.to_string()) +} + +fn section(title: &str) { + println!("\n{}", format!("── {title} ").bold().cyan()); +} + +// --------------------------------------------------------------------------- +// Shell detection +// --------------------------------------------------------------------------- + +fn detect_shell() -> String { + // SHELL env is most reliable; fall back to $0 via ps + env::var("SHELL") + .ok() + .and_then(|s| Path::new(&s).file_name().map(|n| n.to_string_lossy().into_owned())) + .or_else(|| { + // Ask the parent process name via `ps` + let ppid = run("sh", &["-c", "echo $PPID"])?; + run("ps", &["-p", &ppid, "-o", "comm="]) + }) + .unwrap_or_else(|| "unknown".into()) +} + +// --------------------------------------------------------------------------- +// Git +// --------------------------------------------------------------------------- + +fn git_root() -> Option { + run("git", &["rev-parse", "--show-toplevel"]).map(PathBuf::from) +} + +fn git_branch() -> String { + run_or("git", &["branch", "--show-current"], "(detached)") +} + +fn git_status_short() -> String { + run_or("git", &["status", "--short"], "") +} + +fn git_log_oneline(n: usize) -> String { + run_or( + "git", + &["log", "--oneline", &format!("-{n}"), "--decorate"], + "(no commits)", + ) +} + +// --------------------------------------------------------------------------- +// Tracked-file tree (git ls-files, depth-limited) +// --------------------------------------------------------------------------- + +fn tracked_tree(root: &Path, max_depth: usize) -> String { + let files = run("git", &["ls-files"]).unwrap_or_default(); + let mut tree: Vec> = Vec::new(); + + for path in files.lines() { + let parts: Vec = path.split('/').map(String::from).collect(); + if parts.len() <= max_depth + 1 { + tree.push(parts); + } + } + + // Deduplicate and build a simple indented representation + let mut seen = std::collections::BTreeSet::new(); + let mut lines = Vec::new(); + + for parts in &tree { + for depth in 0..parts.len() { + let key = parts[..=depth].join("/"); + if seen.insert(key) { + let indent = " ".repeat(depth); + let is_leaf = depth == parts.len() - 1; + let label = if is_leaf { + parts[depth].normal().to_string() + } else { + parts[depth].bold().blue().to_string() + }; + lines.push(format!("{indent}{label}")); + } + } + } + + if lines.is_empty() { + "(no tracked files)".to_string() + } else { + lines.join("\n") + } +} + +// --------------------------------------------------------------------------- +// HANDOFF documents +// --------------------------------------------------------------------------- + +fn find_handoff_files(root: &Path) -> Vec { + let mut hits = Vec::new(); + if let Ok(rd) = fs::read_dir(root) { + for entry in rd.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("HANDOFF") { + hits.push(entry.path()); + } + } + } + hits.sort(); + hits +} + +fn read_handoff(path: &Path) -> Result { + fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display())) +} + +fn stamp_handoff(path: &Path) -> Result<()> { + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %z").to_string(); + let original = fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display()))?; + + // Insert or replace a `last_preflight:` line at the top of the YAML/MD doc + let stamp_line = format!("last_preflight: {now}"); + let updated = if let Some(pos) = original.find("last_preflight:") { + let end = original[pos..].find('\n').map(|i| pos + i + 1).unwrap_or(original.len()); + format!("{}{stamp_line}\n{}", &original[..pos], &original[end..]) + } else { + // Prepend before first non-comment, non-empty line + format!("{stamp_line}\n{original}") + }; + + if updated != original { + fs::write(path, &updated) + .with_context(|| format!("writing {}", path.display()))?; + println!(" {} updated timestamp", path.display().to_string().dimmed()); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +fn main() -> Result<()> { + let cwd = env::current_dir()?; + let root = git_root().unwrap_or_else(|| cwd.clone()); + + println!("{}", "╔══ preflight ══╗".bold().yellow()); + + // ── Environment ────────────────────────────────────────────────────────── + section("Environment"); + println!(" shell : {}", detect_shell().green()); + println!(" cwd : {}", cwd.display().to_string().green()); + println!(" git root : {}", root.display().to_string().dimmed()); + + // ── Git ────────────────────────────────────────────────────────────────── + section("Git — branch / status"); + println!(" branch : {}", git_branch().yellow()); + let status = git_status_short(); + if status.is_empty() { + println!(" status : {}", "clean".green()); + } else { + for line in status.lines() { + println!(" {line}"); + } + } + + section("Git — recent history"); + for line in git_log_oneline(7).lines() { + println!(" {line}"); + } + + // ── Tracked files ───────────────────────────────────────────────────── + section("Tracked files (depth ≤ 3)"); + println!("{}", tracked_tree(&root, 3)); + + // ── HANDOFF documents ──────────────────────────────────────────────── + section("HANDOFF documents"); + let handoffs = find_handoff_files(&root); + if handoffs.is_empty() { + println!(" (none found)"); + } else { + for path in &handoffs { + println!("\n {}", path.display().to_string().bold()); + match read_handoff(path) { + Ok(contents) => { + // Print first 40 lines to avoid flooding + for line in contents.lines().take(40) { + println!(" {line}"); + } + let total = contents.lines().count(); + if total > 40 { + println!(" {} … ({} more lines)", "↳".dimmed(), total - 40); + } + stamp_handoff(path)?; + } + Err(e) => println!(" {}", format!("error: {e}").red()), + } + } + } + + println!("\n{}", "╚══ done ══╝".bold().yellow()); + Ok(()) +} +``` + +## Usage + +```bash +# Make executable and run directly +chmod +x preflight.rs +./preflight.rs + +# Or invoke via rust-script explicitly +rust-script preflight.rs +``` + +## What it surfaces + +| Section | Details | +|---------|---------| +| Environment | Current shell (`$SHELL`), cwd, git root | +| Git status | Branch name, short status (clean or dirty file list) | +| Git history | Last 7 commits, one-line with decoration | +| Tracked file tree | `git ls-files` output rendered as an indented tree, depth ≤ 3 | +| HANDOFF docs | Full content (first 40 lines) of any `HANDOFF.*` file at repo root; stamps `last_preflight:` timestamp | + +## Dependencies + +- `rust-script` — `cargo install rust-script` or `mise use -g rust-script` +- `anyhow`, `colored`, `chrono` — fetched automatically by rust-script on first run diff --git a/scripts/preflight.rs b/scripts/preflight.rs new file mode 100644 index 0000000..cfed382 --- /dev/null +++ b/scripts/preflight.rs @@ -0,0 +1,298 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1" +//! colored = "2" +//! ``` +//! +//! Quick preflight context surfacer: +//! - current shell detection +//! - git status + recent history +//! - tracked-file tree (git ls-files) +//! - HANDOFF.* document summary +//! +//! Usage: ./scripts/preflight.rs [dir] + +use anyhow::{Context, Result}; +use colored::*; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn run(cmd: &str, args: &[&str], cwd: &Path) -> Result { + let out = Command::new(cmd) + .args(args) + .current_dir(cwd) + .output() + .with_context(|| format!("failed to run `{cmd}`"))?; + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +fn run_opt(cmd: &str, args: &[&str], cwd: &Path) -> String { + run(cmd, args, cwd).unwrap_or_default() +} + +// ── Shell detection ─────────────────────────────────────────────────────────── + +fn detect_shell() -> String { + // SHELL env is the login shell; $0 in a running session is more precise but + // not accessible from a child process, so we use SHELL + FISH_VERSION hint. + let shell_env = std::env::var("SHELL").unwrap_or_else(|_| "unknown".into()); + let name = Path::new(&shell_env) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Nushell doesn't set SHELL; detect via NU_VERSION if present. + if std::env::var("NU_VERSION").is_ok() || name == "nu" { + return "nushell".into(); + } + if std::env::var("FISH_VERSION").is_ok() { + return "fish".into(); + } + name +} + +// ── Git helpers ─────────────────────────────────────────────────────────────── + +fn git_root(cwd: &Path) -> Option { + let out = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .output() + .ok()?; + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Some(PathBuf::from(s)) + } else { + None + } +} + +fn git_branch(root: &Path) -> String { + run_opt("git", &["branch", "--show-current"], root) + .trim() + .to_string() +} + +fn git_status(root: &Path) -> String { + run_opt("git", &["status", "--short"], root) +} + +fn git_log(root: &Path) -> String { + run_opt( + "git", + &["log", "--oneline", "-7", "--decorate=short"], + root, + ) +} + +// ── Tracked-file tree ───────────────────────────────────────────────────────── + +/// Build a tree from `git ls-files` output and render it as a compact indented +/// string. Each directory node is printed once; files are leaves. +fn tracked_tree(root: &Path) -> String { + let raw = run_opt("git", &["ls-files"], root); + let paths: Vec<&str> = raw.lines().collect(); + + // Group by first path component for a two-level summary. + let mut dirs: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + let mut top_files: Vec<&str> = Vec::new(); + + for p in &paths { + if let Some(slash) = p.find('/') { + dirs.entry(&p[..slash]).or_default().push(&p[slash + 1..]); + } else { + top_files.push(p); + } + } + + let mut out = String::new(); + for f in &top_files { + out.push_str(&format!(" {f}\n")); + } + for (dir, files) in &dirs { + out.push_str(&format!(" {dir}/ ({} files)\n", files.len())); + // show up to 6 entries per dir to keep output compact + for f in files.iter().take(6) { + out.push_str(&format!(" {f}\n")); + } + if files.len() > 6 { + out.push_str(&format!(" … and {} more\n", files.len() - 6)); + } + } + out +} + +// ── HANDOFF surfacer ────────────────────────────────────────────────────────── + +fn surface_handoffs(root: &Path) -> Vec<(String, String)> { + let mut results = Vec::new(); + + // Collect HANDOFF.* from root and .ctx/ + let candidates: &[&str] = &["HANDOFF.md", ".ctx"]; + for c in candidates { + let p = root.join(c); + if p.is_dir() { + // Read all HANDOFF yaml files in .ctx/ + if let Ok(rd) = std::fs::read_dir(&p) { + let mut entries: Vec<_> = rd.flatten().collect(); + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("HANDOFF") && !name.ends_with(".state.yaml") { + let content = + std::fs::read_to_string(entry.path()).unwrap_or_default(); + results.push((format!(".ctx/{name}"), summarize_handoff(&content))); + } + } + } + } else if p.is_file() { + let content = std::fs::read_to_string(&p).unwrap_or_default(); + results.push((c.to_string(), summarize_handoff(&content))); + } + } + results +} + +/// Extract the most useful lines from a HANDOFF file (yaml or md). +fn summarize_handoff(content: &str) -> String { + // For YAML: grab project, branch, items, last log entry. + // For MD: grab the first 20 non-blank lines. + let lines: Vec<&str> = content.lines().collect(); + + if content.starts_with("project:") || content.contains("items:") { + // YAML path — grab key fields + let mut summary = Vec::new(); + let mut in_items = false; + let mut item_count = 0; + let mut last_log = String::new(); + let mut in_log = false; + + for line in &lines { + let t = line.trim(); + if t.starts_with("project:") || t.starts_with("branch:") || t.starts_with("updated:") { + summary.push(t.to_string()); + } + if t == "items: []" { + summary.push("items: (none)".into()); + } + if t.starts_with("- id:") || t.starts_with("- title:") { + in_items = true; + item_count += 1; + summary.push(format!(" item: {}", t.trim_start_matches("- "))); + } + if t == "log:" { + in_log = true; + } + if in_log && t.starts_with("summary:") { + last_log = t.trim_start_matches("summary:").trim().to_string(); + } + } + if item_count > 0 { + summary.push(format!("total items: {item_count}")); + } + if !last_log.is_empty() { + summary.push(format!("last log: {last_log}")); + } + summary.join("\n") + } else { + // MD path — first 20 non-blank lines + lines + .iter() + .filter(|l| !l.trim().is_empty()) + .take(20) + .copied() + .collect::>() + .join("\n") + } +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +fn section(title: &str) { + println!("\n{}", format!("── {title} ").bold().cyan()); +} + +fn main() -> Result<()> { + let cwd = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + println!("{}", "╔══════════════════════════════╗".bold()); + println!("{}", "║ preflight context surfacer ║".bold()); + println!("{}", "╚══════════════════════════════╝".bold()); + + // ── Shell ──────────────────────────────────────────────────────────────── + section("shell"); + println!(" {}", detect_shell().green()); + + // ── Git root ───────────────────────────────────────────────────────────── + section("workspace"); + let root = git_root(&cwd); + match &root { + Some(r) => { + let branch = git_branch(r); + println!(" root: {}", r.display().to_string().yellow()); + println!(" branch: {}", branch.green()); + } + None => { + println!(" {}", "not a git repository".red()); + } + } + + if let Some(root) = &root { + // ── Git status ─────────────────────────────────────────────────────── + section("git status"); + let status = git_status(root); + if status.trim().is_empty() { + println!(" {}", "clean".green()); + } else { + for line in status.lines() { + // color by status prefix + let colored = if line.starts_with('M') || line.starts_with("RM") { + line.yellow().to_string() + } else if line.starts_with('A') { + line.green().to_string() + } else if line.starts_with('D') || line.starts_with('R') { + line.red().to_string() + } else if line.starts_with('?') { + line.dimmed().to_string() + } else { + line.normal().to_string() + }; + println!(" {colored}"); + } + } + + // ── Git log ────────────────────────────────────────────────────────── + section("recent commits"); + for line in git_log(root).lines() { + println!(" {line}"); + } + + // ── File tree ──────────────────────────────────────────────────────── + section("tracked files"); + print!("{}", tracked_tree(root)); + + // ── HANDOFF ────────────────────────────────────────────────────────── + section("handoff"); + let handoffs = surface_handoffs(root); + if handoffs.is_empty() { + println!(" {}", "no HANDOFF files found".dimmed()); + } else { + for (name, summary) in &handoffs { + println!(" {}", name.bold().yellow()); + for line in summary.lines() { + println!(" {line}"); + } + println!(); + } + } + } + + println!("{}", "─────────────────────────────────".dimmed()); + Ok(()) +} diff --git a/tests/integration/tests/bootstrap.rs b/tests/integration/tests/bootstrap.rs index 810d461..7a6acf2 100644 --- a/tests/integration/tests/bootstrap.rs +++ b/tests/integration/tests/bootstrap.rs @@ -189,7 +189,7 @@ fn test_setup_hooks_skipped_on_rerun() { interpreter: None, }; let runner = HookRunner::new(d.to_path_buf()); - let phase_report = run_phase(&[hook_spec], &HookPhase::Setup, &runner); + let phase_report = run_phase(&[hook_spec], &HookPhase::Setup, &runner).unwrap(); let step = phase_report .steps .iter() @@ -244,3 +244,49 @@ fn test_bootstrap_fails_fast_on_bad_key() { "should not reach link dotfiles step after key failure" ); } + +/// A link conflict must stop bootstrap before any hook phase runs. +#[test] +fn test_bootstrap_stops_after_link_failure() { + let env = make_test_env(); + let d = env.dotfiles.path(); + let home = env.home.path(); + + fs::write(home.join(".zshrc"), "local override\n").unwrap(); + + let script = d.join("scripts/greet.nu"); + fs::write(&script, "print hello\n").unwrap(); + fs::write( + &env.config, + format!( + "[bootstrap]\n\ + dotfiles_repo = \"https://example.com/fake.git\"\n\ + dotfiles_dir = \"{dotfiles}\"\n\n\ + [[hooks]]\n\ + name = \"greet\"\n\ + script = \"{script}\"\n\ + phase = \"dot\"\n", + dotfiles = d.display(), + script = script.display(), + ), + ) + .unwrap(); + + let report = run(make_opts(&env, false)).unwrap(); + + let link_step = report + .steps + .iter() + .find(|step| step.name == "link dotfiles") + .expect("link dotfiles step should be present"); + assert!( + matches!(link_step.status, StepStatus::Failed(_)), + "link failure should be surfaced, got {:?}", + link_step.status + ); + assert!( + report.steps.iter().all(|step| step.name != "dot hooks"), + "bootstrap should stop before running hooks after link failure: {:?}", + report.steps + ); +} diff --git a/tests/integration/tests/cross_crate.rs b/tests/integration/tests/cross_crate.rs index c2c32ea..c8c6c71 100644 --- a/tests/integration/tests/cross_crate.rs +++ b/tests/integration/tests/cross_crate.rs @@ -39,7 +39,7 @@ fn test_nothooks_notsecrets_independent() { }; let runner = HookRunner::new(dir.path().to_path_buf()); - let result = runner.run_hook(&spec); + let result = runner.run_hook(&spec).expect("hook runner should not fail"); assert!( matches!(result, HookResult::Ok), "expected HookResult::Ok, got: {result:?}"