Refactor: Formalize ports and update related files
This commit is contained in:
12
.ctx/HANDOFF.notfiles.notfiles.yaml
Normal file
12
.ctx/HANDOFF.notfiles.notfiles.yaml
Normal file
@@ -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
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -2,6 +2,21 @@
|
|||||||
.envrc
|
.envrc
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.claude.local.md
|
.claude.local.md
|
||||||
docs/graph/
|
docs/
|
||||||
librust_out.rlib
|
librust_out.rlib
|
||||||
.remember/
|
.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
|
||||||
|
|||||||
117
HANDOFF.md
117
HANDOFF.md
@@ -1,111 +1,24 @@
|
|||||||
# HANDOFF.md
|
# 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)
|
Rules:
|
||||||
- `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
|
|
||||||
|
|
||||||
### 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:**
|
- No open handoff items are tracked locally
|
||||||
|
- Use GitHub issues plus `doob` as the task source of truth
|
||||||
| 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.
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -5,6 +5,10 @@ use std::path::{Path, PathBuf};
|
|||||||
pub struct FileStoreImpl;
|
pub struct FileStoreImpl;
|
||||||
|
|
||||||
impl FileStore for FileStoreImpl {
|
impl FileStore for FileStoreImpl {
|
||||||
|
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error> {
|
||||||
|
std::fs::read(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error> {
|
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error> {
|
||||||
std::fs::read_to_string(path)
|
std::fs::read_to_string(path)
|
||||||
}
|
}
|
||||||
@@ -25,6 +29,10 @@ impl FileStore for FileStoreImpl {
|
|||||||
std::fs::remove_dir_all(path)
|
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<PathBuf, std::io::Error> {
|
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error> {
|
||||||
std::fs::read_link(path)
|
std::fs::read_link(path)
|
||||||
}
|
}
|
||||||
@@ -41,6 +49,12 @@ impl FileStore for FileStoreImpl {
|
|||||||
std::fs::create_dir_all(path)
|
std::fs::create_dir_all(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
|
||||||
|
std::fs::read_dir(path)?
|
||||||
|
.map(|entry| entry.map(|entry| entry.path()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> {
|
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> {
|
||||||
std::os::unix::fs::symlink(target, link)
|
std::os::unix::fs::symlink(target, link)
|
||||||
|
|||||||
@@ -11,14 +11,22 @@ use std::path::Path;
|
|||||||
|
|
||||||
pub use adapters::FileStoreImpl;
|
pub use adapters::FileStoreImpl;
|
||||||
pub use linker::{LinkOptions, State};
|
pub use linker::{LinkOptions, State};
|
||||||
pub use package::resolve_packages;
|
pub use package::{resolve_packages, resolve_packages_with_store};
|
||||||
pub use ports::FileStore;
|
pub use ports::FileStore;
|
||||||
|
|
||||||
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
|
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
|
||||||
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<State> {
|
||||||
let config = notcore::Config::load(dotfiles_dir)?;
|
let config = notcore::Config::load(dotfiles_dir)?;
|
||||||
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
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 {
|
for pkg in &pkgs {
|
||||||
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs)?;
|
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<()> {
|
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 mut state = linker::State::load(dotfiles_dir, fs)?;
|
||||||
let pkgs = if packages.is_empty() {
|
let pkgs = if packages.is_empty() {
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::package::collect_files;
|
use crate::package::collect_files_with_store;
|
||||||
use crate::ports::FileStore;
|
use crate::ports::FileStore;
|
||||||
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
||||||
|
|
||||||
@@ -61,6 +61,11 @@ impl State {
|
|||||||
self.entries.retain(|e| e.package != package);
|
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) {
|
pub fn add_entry(&mut self, entry: StateEntry) {
|
||||||
// Remove existing entry for same source+target, then add new
|
// Remove existing entry for same source+target, then add new
|
||||||
self.entries
|
self.entries
|
||||||
@@ -87,7 +92,7 @@ pub fn link_package(
|
|||||||
let package_dir = dotfiles_dir.join(package);
|
let package_dir = dotfiles_dir.join(package);
|
||||||
let method = config.method_for(package);
|
let method = config.method_for(package);
|
||||||
let target_base = expand_tilde(config.target_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 files.is_empty() {
|
||||||
if opts.verbose {
|
if opts.verbose {
|
||||||
@@ -201,7 +206,7 @@ pub fn link_package(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => {
|
Method::Copy => {
|
||||||
let content = std::fs::read(&source)?;
|
let content = fs.read(&source)?;
|
||||||
fs.write(&target, &content).map(|_| content.len() as u64)
|
fs.write(&target, &content).map(|_| content.len() as u64)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -261,13 +266,19 @@ pub fn unlink_package(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut removed_entries = Vec::new();
|
||||||
|
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
let target = PathBuf::from(&entry.target);
|
let target = PathBuf::from(&entry.target);
|
||||||
|
let source = PathBuf::from(&entry.source);
|
||||||
|
|
||||||
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
|
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
|
||||||
if opts.verbose {
|
if opts.verbose {
|
||||||
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
|
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
|
||||||
}
|
}
|
||||||
|
if !opts.dry_run {
|
||||||
|
removed_entries.push((entry.source.clone(), entry.target.clone()));
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +286,6 @@ pub fn unlink_package(
|
|||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
// Verify it's a symlink pointing to our source
|
// Verify it's a symlink pointing to our source
|
||||||
if let Ok(link_target) = fs.read_link(&target) {
|
if let Ok(link_target) = fs.read_link(&target) {
|
||||||
let source = PathBuf::from(&entry.source);
|
|
||||||
if link_target != source {
|
if link_target != source {
|
||||||
if opts.verbose {
|
if opts.verbose {
|
||||||
println!(
|
println!(
|
||||||
@@ -292,9 +302,36 @@ pub fn unlink_package(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => {
|
Method::Copy => match (fs.read(&source), fs.read(&target)) {
|
||||||
// For copies, trust the state file
|
(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 {
|
if opts.dry_run {
|
||||||
@@ -311,11 +348,14 @@ pub fn unlink_package(
|
|||||||
|
|
||||||
// Clean up empty parent dirs
|
// Clean up empty parent dirs
|
||||||
cleanup_empty_parents(&target, fs);
|
cleanup_empty_parents(&target, fs);
|
||||||
|
removed_entries.push((entry.source.clone(), entry.target.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.dry_run {
|
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)?;
|
state.save(dotfiles_dir, fs)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,15 +371,10 @@ fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn File
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => {
|
Method::Copy => match (fs.read(source), fs.read(target)) {
|
||||||
// Skip re-copy if target exists and has the same size and mtime as source.
|
(Ok(source_bytes), Ok(target_bytes)) => source_bytes == target_bytes,
|
||||||
match (fs.metadata(source), fs.metadata(target)) {
|
_ => false,
|
||||||
(Ok(sm), Ok(tm)) => {
|
},
|
||||||
sm.len() == tm.len() && sm.modified().ok() == tm.modified().ok()
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,18 +384,19 @@ fn backup_path(path: &Path) -> PathBuf {
|
|||||||
PathBuf::from(format!("{name}.notfiles-backup-{timestamp}"))
|
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();
|
let mut dir = path.parent();
|
||||||
while let Some(parent) = dir {
|
while let Some(parent) = dir {
|
||||||
// Stop at home dir or root
|
// Stop at home dir or root
|
||||||
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
|
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if std::fs::read_dir(parent)
|
if fs
|
||||||
.map(|mut d| d.next().is_none())
|
.read_dir(parent)
|
||||||
|
.map(|children| children.is_empty())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
let _ = std::fs::remove_dir(parent);
|
let _ = fs.remove_dir(parent);
|
||||||
dir = parent.parent();
|
dir = parent.parent();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::fs;
|
|||||||
use notcore::Config;
|
use notcore::Config;
|
||||||
use notfiles::cli::{Cli, Command};
|
use notfiles::cli::{Cli, Command};
|
||||||
use notfiles::linker::{LinkOptions, State};
|
use notfiles::linker::{LinkOptions, State};
|
||||||
use notfiles::package::resolve_packages;
|
use notfiles::package::resolve_packages_with_store;
|
||||||
use notfiles::{adapters, linker, status};
|
use notfiles::{adapters, linker, status};
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -26,7 +26,7 @@ fn main() -> Result<()> {
|
|||||||
let fs = &adapters::FileStoreImpl;
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load(&dotfiles_dir)?;
|
||||||
let mut state = State::load(&dotfiles_dir, fs)?;
|
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 {
|
let opts = LinkOptions {
|
||||||
force,
|
force,
|
||||||
no_backup,
|
no_backup,
|
||||||
@@ -74,7 +74,7 @@ fn main() -> Result<()> {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
} else {
|
} else {
|
||||||
// Validate requested packages exist in state
|
// 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
|
// Package dir might be gone but state entries exist — that's fine for unlink
|
||||||
Ok::<Vec<String>, anyhow::Error>(packages.clone())
|
Ok::<Vec<String>, anyhow::Error>(packages.clone())
|
||||||
});
|
});
|
||||||
@@ -111,10 +111,10 @@ fn main() -> Result<()> {
|
|||||||
let fs = &adapters::FileStoreImpl;
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load(&dotfiles_dir)?;
|
||||||
let state = State::load(&dotfiles_dir, fs)?;
|
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 {
|
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);
|
status::print_status(pkg, &entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::adapters::FileStoreImpl;
|
||||||
use crate::ignore::IgnoreMatcher;
|
use crate::ignore::IgnoreMatcher;
|
||||||
|
use crate::ports::FileStore;
|
||||||
use notcore::{Config, NotfilesError};
|
use notcore::{Config, NotfilesError};
|
||||||
|
|
||||||
/// Discover available packages (subdirectories of the dotfiles dir).
|
/// Discover available packages (subdirectories of the dotfiles dir).
|
||||||
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_packages_with_store(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
let mut packages = Vec::new();
|
let mut packages = Vec::new();
|
||||||
for entry in fs::read_dir(dotfiles_dir)? {
|
// TODO(#19): Replace implicit top-level directory discovery with an explicit
|
||||||
let entry = entry?;
|
// package contract so mixed-purpose repos do not accidentally link
|
||||||
let path = entry.path();
|
// operational directories like scripts/ or docs/.
|
||||||
if path.is_dir() {
|
for path in fs.read_dir(dotfiles_dir)? {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
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
|
// Skip hidden dirs and common non-package dirs
|
||||||
if !name.starts_with('.') {
|
if !name.starts_with('.') {
|
||||||
packages.push(name);
|
packages.push(name);
|
||||||
@@ -28,7 +40,15 @@ pub fn resolve_packages(
|
|||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
requested: &[String],
|
requested: &[String],
|
||||||
) -> Result<Vec<String>, NotfilesError> {
|
) -> Result<Vec<String>, 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<Vec<String>, NotfilesError> {
|
||||||
|
let available = discover_packages_with_store(dotfiles_dir, fs)?;
|
||||||
if requested.is_empty() {
|
if requested.is_empty() {
|
||||||
return Ok(available);
|
return Ok(available);
|
||||||
}
|
}
|
||||||
@@ -45,11 +65,20 @@ pub fn collect_files(
|
|||||||
package_dir: &Path,
|
package_dir: &Path,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
package_name: &str,
|
package_name: &str,
|
||||||
|
) -> Result<Vec<PathBuf>, 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<Vec<PathBuf>, NotfilesError> {
|
) -> Result<Vec<PathBuf>, NotfilesError> {
|
||||||
let patterns = config.ignore_patterns_for(package_name);
|
let patterns = config.ignore_patterns_for(package_name);
|
||||||
let matcher = IgnoreMatcher::new(&patterns)?;
|
let matcher = IgnoreMatcher::new(&patterns)?;
|
||||||
let mut files = Vec::new();
|
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();
|
files.sort();
|
||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
@@ -59,18 +88,17 @@ fn walk_dir(
|
|||||||
current: &Path,
|
current: &Path,
|
||||||
matcher: &IgnoreMatcher,
|
matcher: &IgnoreMatcher,
|
||||||
files: &mut Vec<PathBuf>,
|
files: &mut Vec<PathBuf>,
|
||||||
|
fs: &dyn FileStore,
|
||||||
) -> Result<(), NotfilesError> {
|
) -> Result<(), NotfilesError> {
|
||||||
for entry in fs::read_dir(current)? {
|
for path in fs.read_dir(current)? {
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
let relative = path.strip_prefix(base).unwrap().to_path_buf();
|
let relative = path.strip_prefix(base).unwrap().to_path_buf();
|
||||||
|
|
||||||
if matcher.is_ignored(&relative) {
|
if matcher.is_ignored(&relative) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.is_dir() {
|
if fs.is_dir(&path) {
|
||||||
walk_dir(base, &path, matcher, files)?;
|
walk_dir(base, &path, matcher, files, fs)?;
|
||||||
} else {
|
} else {
|
||||||
files.push(relative);
|
files.push(relative);
|
||||||
}
|
}
|
||||||
@@ -81,6 +109,7 @@ fn walk_dir(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ use std::path::{Path, PathBuf};
|
|||||||
/// This trait defines the interface for all file system interactions in the notfiles
|
/// This trait defines the interface for all file system interactions in the notfiles
|
||||||
/// linker, enabling testing with mock implementations and potential future backends.
|
/// linker, enabling testing with mock implementations and potential future backends.
|
||||||
pub trait FileStore {
|
pub trait FileStore {
|
||||||
|
/// Read entire file to bytes.
|
||||||
|
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error>;
|
||||||
|
|
||||||
/// Read entire file to string.
|
/// Read entire file to string.
|
||||||
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error>;
|
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error>;
|
||||||
|
|
||||||
@@ -20,6 +23,9 @@ pub trait FileStore {
|
|||||||
/// Recursively remove a directory and all its contents.
|
/// Recursively remove a directory and all its contents.
|
||||||
fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error>;
|
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.
|
/// Read the target of a symbolic link.
|
||||||
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error>;
|
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error>;
|
||||||
|
|
||||||
@@ -32,6 +38,9 @@ pub trait FileStore {
|
|||||||
/// Create a directory and all missing parent directories.
|
/// Create a directory and all missing parent directories.
|
||||||
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error>;
|
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error>;
|
||||||
|
|
||||||
|
/// List direct children of a directory.
|
||||||
|
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error>;
|
||||||
|
|
||||||
/// Create a symbolic link at `link` pointing to `target`.
|
/// Create a symbolic link at `link` pointing to `target`.
|
||||||
///
|
///
|
||||||
/// On Unix systems, this creates a symlink. On Windows with symlink support enabled,
|
/// On Unix systems, this creates a symlink. On Windows with symlink support enabled,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::linker::State;
|
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};
|
use notcore::{Config, Method, expand_tilde};
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
@@ -37,6 +37,7 @@ pub fn package_status(
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
state: &State,
|
state: &State,
|
||||||
package: &str,
|
package: &str,
|
||||||
|
fs: &dyn FileStore,
|
||||||
) -> Vec<StatusEntry> {
|
) -> Vec<StatusEntry> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let package_dir = dotfiles_dir.join(package);
|
let package_dir = dotfiles_dir.join(package);
|
||||||
@@ -47,7 +48,7 @@ pub fn package_status(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check files that should exist
|
// 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 {
|
for relative in &files {
|
||||||
let source = package_dir.join(relative);
|
let source = package_dir.join(relative);
|
||||||
let target = target_base.join(relative);
|
let target = target_base.join(relative);
|
||||||
@@ -55,25 +56,31 @@ pub fn package_status(
|
|||||||
|
|
||||||
let status = match method {
|
let status = match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
if let Ok(link_target) = fs::read_link(&target) {
|
if let Ok(link_target) = fs.read_link(&target) {
|
||||||
if link_target == source {
|
if link_target == source {
|
||||||
FileStatus::Linked
|
FileStatus::Linked
|
||||||
} else {
|
} else {
|
||||||
FileStatus::Conflict
|
FileStatus::Conflict
|
||||||
}
|
}
|
||||||
} else if target.exists() {
|
} else if fs.exists(&target) {
|
||||||
FileStatus::Conflict
|
FileStatus::Conflict
|
||||||
} else {
|
} else {
|
||||||
FileStatus::Missing
|
FileStatus::Missing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => {
|
Method::Copy => {
|
||||||
let has_state = state.entries.iter().any(|e| {
|
let tracked = state.entries.iter().any(|e| {
|
||||||
e.package == package && e.target == target.to_string_lossy().as_ref()
|
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
|
FileStatus::Copied
|
||||||
} else if target.exists() {
|
} else if fs.exists(&target) {
|
||||||
FileStatus::Conflict
|
FileStatus::Conflict
|
||||||
} else {
|
} else {
|
||||||
FileStatus::Missing
|
FileStatus::Missing
|
||||||
@@ -92,7 +99,7 @@ pub fn package_status(
|
|||||||
// Check for orphans: entries in state that no longer have a source file
|
// Check for orphans: entries in state that no longer have a source file
|
||||||
for entry in state.entries_for_package(package) {
|
for entry in state.entries_for_package(package) {
|
||||||
let source = PathBuf::from(&entry.source);
|
let source = PathBuf::from(&entry.source);
|
||||||
if !source.exists() {
|
if !fs.exists(&source) {
|
||||||
let target = PathBuf::from(&entry.target);
|
let target = PathBuf::from(&entry.target);
|
||||||
// Only add if we didn't already report this target
|
// Only add if we didn't already report this target
|
||||||
if !results.iter().any(|r| r.target == target) {
|
if !results.iter().any(|r| r.target == target) {
|
||||||
|
|||||||
@@ -303,6 +303,89 @@ method = "copy"
|
|||||||
assert!(!ssh_config.exists());
|
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]
|
#[test]
|
||||||
fn test_dry_run() {
|
fn test_dry_run() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ fn cyclic_fixture_module_graph_builds() {
|
|||||||
/// write_all produces report.html, report.md, and report.json in the output dir.
|
/// write_all produces report.html, report.md, and report.json in the output dir.
|
||||||
#[test]
|
#[test]
|
||||||
fn write_all_creates_expected_files() {
|
fn write_all_creates_expected_files() {
|
||||||
use types::{CrateGraph, GraphStats, ModuleGraph};
|
use types::{CrateGraph, ModuleGraph};
|
||||||
|
|
||||||
let out = TempDir::new().unwrap();
|
let out = TempDir::new().unwrap();
|
||||||
let crate_graph = CrateGraph {
|
let crate_graph = CrateGraph {
|
||||||
@@ -162,7 +162,7 @@ fn write_all_heatmap_does_not_panic_for_single_crate() {
|
|||||||
/// ModuleGraph entry in the HTML output.
|
/// ModuleGraph entry in the HTML output.
|
||||||
#[test]
|
#[test]
|
||||||
fn write_all_renders_per_crate_module_graphs() {
|
fn write_all_renders_per_crate_module_graphs() {
|
||||||
use types::{CrateGraph, ModuleGraph};
|
use types::CrateGraph;
|
||||||
|
|
||||||
let out = TempDir::new().unwrap();
|
let out = TempDir::new().unwrap();
|
||||||
let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).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()],
|
nodes: vec!["clean".to_string()],
|
||||||
edges: vec![],
|
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<types::SymbolTable> = vec![];
|
let symbol_tables: Vec<types::SymbolTable> = vec![];
|
||||||
|
|
||||||
emit::write_all(out.path(), &crate_graph, &[mg], &stats, &symbol_tables).unwrap();
|
emit::write_all(out.path(), &crate_graph, &[mg], &stats, &symbol_tables).unwrap();
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub enum HookResult {
|
|||||||
/// Run all hooks matching `phase` and collect into a `Report`.
|
/// Run all hooks matching `phase` and collect into a `Report`.
|
||||||
///
|
///
|
||||||
/// State is loaded once and saved once per call — not once per hook.
|
/// 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<Report> {
|
||||||
runner.run_phase(hooks, phase)
|
runner.run_phase(hooks, phase)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ fn main() -> Result<()> {
|
|||||||
HookRunner::new(state_dir)
|
HookRunner::new(state_dir)
|
||||||
};
|
};
|
||||||
|
|
||||||
let report = run_phase(&file.hooks, &phase, &runner);
|
let report = run_phase(&file.hooks, &phase, &runner)?;
|
||||||
report.print();
|
report.print();
|
||||||
|
|
||||||
if report.has_failures() {
|
if report.has_failures() {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use crate::HookResult;
|
use crate::HookResult;
|
||||||
use crate::state::HookState;
|
use crate::state::HookState;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
use notcore::{HookPhase, HookSpec, Report, StepStatus};
|
use notcore::{HookPhase, HookSpec, Report, StepStatus};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn resolve_interpreter(spec: &HookSpec) -> Result<String, String> {
|
fn resolve_interpreter(spec: &HookSpec) -> Result<String, String> {
|
||||||
@@ -51,8 +52,9 @@ impl HookRunner {
|
|||||||
/// State is loaded once before iterating and saved once at the end (only if
|
/// 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
|
/// at least one Setup hook completed successfully), avoiding N file reads
|
||||||
/// and writes per phase.
|
/// and writes per phase.
|
||||||
pub fn run_phase(&self, hooks: &[HookSpec], phase: &HookPhase) -> Report {
|
pub fn run_phase(&self, hooks: &[HookSpec], phase: &HookPhase) -> Result<Report> {
|
||||||
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
|
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 state_dirty = false;
|
||||||
let mut report = Report::default();
|
let mut report = Report::default();
|
||||||
|
|
||||||
@@ -67,10 +69,12 @@ impl HookRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if 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()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
report
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a single hook, using the caller-owned `state` and `dirty` flag
|
/// Execute a single hook, using the caller-owned `state` and `dirty` flag
|
||||||
@@ -89,11 +93,12 @@ impl HookRunner {
|
|||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
Err(msg) => return HookResult::Failed(msg),
|
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
|
// 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
|
// 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,
|
// filesystem path from config. Both are passed as discrete args with no shell involved,
|
||||||
// so argument injection is not possible.
|
// 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 {
|
match result {
|
||||||
Ok(status) if status.success() => {
|
Ok(status) if status.success() => {
|
||||||
@@ -112,13 +117,25 @@ impl HookRunner {
|
|||||||
///
|
///
|
||||||
/// Prefer [`HookRunner::run_phase`] when running multiple hooks to avoid
|
/// Prefer [`HookRunner::run_phase`] when running multiple hooks to avoid
|
||||||
/// repeated state I/O.
|
/// repeated state I/O.
|
||||||
pub fn run_hook(&self, spec: &HookSpec) -> HookResult {
|
pub fn run_hook(&self, spec: &HookSpec) -> Result<HookResult> {
|
||||||
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
|
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 state_dirty = false;
|
||||||
let result = self.run_hook_with_state(spec, &mut state, &mut state_dirty);
|
let result = self.run_hook_with_state(spec, &mut state, &mut state_dirty);
|
||||||
if 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ impl HookState {
|
|||||||
|
|
||||||
pub fn save(&self, dir: &Path) -> Result<()> {
|
pub fn save(&self, dir: &Path) -> Result<()> {
|
||||||
let path = dir.join(STATE_FILE);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ fn test_hook_success() {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spec = make_hook_script(&dir, "ok-hook", "echo hello");
|
let spec = make_hook_script(&dir, "ok-hook", "echo hello");
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
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));
|
assert!(matches!(result, HookResult::Ok));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ fn test_hook_failure() {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spec = make_hook_script(&dir, "fail-hook", "exit 1");
|
let spec = make_hook_script(&dir, "fail-hook", "exit 1");
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
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(_)));
|
assert!(matches!(result, HookResult::Failed(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,11 +39,11 @@ fn test_setup_hook_skipped_on_rerun() {
|
|||||||
spec.phase = notcore::HookPhase::Setup;
|
spec.phase = notcore::HookPhase::Setup;
|
||||||
|
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
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));
|
assert!(matches!(r1, HookResult::Ok));
|
||||||
|
|
||||||
// Second run — should be skipped
|
// Second run — should be skipped
|
||||||
let r2 = runner.run_hook(&spec);
|
let r2 = runner.run_hook(&spec).unwrap();
|
||||||
assert!(matches!(r2, HookResult::Skipped));
|
assert!(matches!(r2, HookResult::Skipped));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +54,29 @@ fn test_setup_hook_force_reruns() {
|
|||||||
spec.phase = notcore::HookPhase::Setup;
|
spec.phase = notcore::HookPhase::Setup;
|
||||||
|
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
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 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));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
report.add("link dotfiles", StepStatus::Failed(e.to_string()));
|
report.add("link dotfiles", StepStatus::Failed(e.to_string()));
|
||||||
|
return Ok(report);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +172,13 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
|||||||
(HookPhase::Dot, "dot hooks"),
|
(HookPhase::Dot, "dot hooks"),
|
||||||
(HookPhase::Setup, "setup 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
|
let failed = phase_report
|
||||||
.steps
|
.steps
|
||||||
.iter()
|
.iter()
|
||||||
@@ -183,6 +190,9 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
|||||||
StepStatus::Ok
|
StepStatus::Ok
|
||||||
};
|
};
|
||||||
report.add(label, summary);
|
report.add(label, summary);
|
||||||
|
if failed > 0 {
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
|
|||||||
267
preflight.md
Normal file
267
preflight.md
Normal file
@@ -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<String> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<String>> = Vec::new();
|
||||||
|
|
||||||
|
for path in files.lines() {
|
||||||
|
let parts: Vec<String> = 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<PathBuf> {
|
||||||
|
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<String> {
|
||||||
|
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
|
||||||
298
scripts/preflight.rs
Normal file
298
scripts/preflight.rs
Normal file
@@ -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<String> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
@@ -189,7 +189,7 @@ fn test_setup_hooks_skipped_on_rerun() {
|
|||||||
interpreter: None,
|
interpreter: None,
|
||||||
};
|
};
|
||||||
let runner = HookRunner::new(d.to_path_buf());
|
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
|
let step = phase_report
|
||||||
.steps
|
.steps
|
||||||
.iter()
|
.iter()
|
||||||
@@ -244,3 +244,49 @@ fn test_bootstrap_fails_fast_on_bad_key() {
|
|||||||
"should not reach link dotfiles step after key failure"
|
"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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ fn test_nothooks_notsecrets_independent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
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!(
|
assert!(
|
||||||
matches!(result, HookResult::Ok),
|
matches!(result, HookResult::Ok),
|
||||||
"expected HookResult::Ok, got: {result:?}"
|
"expected HookResult::Ok, got: {result:?}"
|
||||||
|
|||||||
Reference in New Issue
Block a user