docs: update HANDOFF.md — age-native redesign progress (tasks 1–7 complete)

This commit is contained in:
Joseph O'Brien
2026-04-01 20:58:09 -04:00
parent 545a45b3d3
commit b4cba74ea3
24 changed files with 1069 additions and 99 deletions

121
crates/nothooks/README.md Normal file
View File

@@ -0,0 +1,121 @@
# nothooks
Nushell hook runner for the notfiles workspace. Executes `.nu` scripts in two
distinct lifecycle phases — **dot** (always) and **setup** (once) — with
state persistence so setup hooks are not re-run across invocations.
## Concepts
### HookPhase
Each hook belongs to exactly one phase:
| Phase | When it runs | Typical use |
|-------|-------------|-------------|
| `dot` | Every time nothooks is invoked | Apply shell config, set env vars, refresh symlinks |
| `setup` | Once per machine (tracked in state) | Install packages, create dirs, first-time configuration |
### HookSpec
A hook is described by three fields (defined in `notcore`):
```toml
[[hooks]]
name = "install-starship"
script = "hooks/setup/install-starship.nu"
phase = "setup"
[[hooks]]
name = "set-default-shell"
script = "hooks/dot/shell.nu"
phase = "dot"
```
### State tracking
Setup hooks are tracked in `.nothooks-state.toml` in the state directory
(typically the dotfiles root). Once a setup hook completes successfully its
name is recorded and it will be **skipped** on all future runs.
```toml
# .nothooks-state.toml (auto-generated, do not edit manually)
completed_setup_hooks = ["install-starship", "configure-git"]
```
Dot hooks are **never** recorded in state — they run unconditionally on every
invocation.
### --force re-run
To re-run setup hooks (e.g. after a machine rebuild or to apply changes),
construct the runner with `HookRunner::with_force`. This bypasses the state
check so all setup hooks execute regardless of prior completion.
The notstrap CLI exposes this as `--force`.
## Hook script lifecycle
```
notstrap / nothooks invoked
├── dot phase
│ └── for each hook where phase == "dot"
│ └── nu <script> # always runs
└── setup phase
└── for each hook where phase == "setup"
├── state.is_done(name)?
│ ├── yes (and !force) → skip
│ └── no (or force) → nu <script>
│ └── success → state.mark_done(name)
└── report collected
```
## Writing a hook script
Hook scripts are plain Nushell (`.nu`) files. They receive no arguments and
communicate success/failure via exit code (0 = success, non-zero = failure).
```nushell
#!/usr/bin/env nu
# hooks/setup/install-starship.nu
# Installs starship prompt if not already present.
if (which starship | is-empty) {
print "Installing starship..."
sh -c "curl -sS https://starship.rs/install.sh | sh -s -- -y"
} else {
print "starship already installed, skipping"
}
```
```nushell
#!/usr/bin/env nu
# hooks/dot/xdg-dirs.nu
# Ensure XDG base directories exist on every login.
mkdir ~/.config ~/.cache ~/.local/share ~/.local/state
```
## Usage from Rust
```rust
use nothooks::{HookRunner, run_phase};
use notcore::{HookPhase, HookSpec};
use std::path::PathBuf;
let hooks: Vec<HookSpec> = /* load from notfiles.toml */;
let state_dir = PathBuf::from("/path/to/dotfiles");
// Normal run
let runner = HookRunner::new(state_dir.clone());
// Force re-run of setup hooks
let runner = HookRunner::with_force(state_dir.clone());
let dot_report = run_phase(&hooks, &HookPhase::Dot, &runner);
let setup_report = run_phase(&hooks, &HookPhase::Setup, &runner);
```
`run_phase` returns a `notcore::Report` containing a `StepStatus` (`Ok`,
`Skipped`, or `Failed(msg)`) for each hook in that phase.

View File

@@ -1,8 +1,8 @@
pub mod runner;
pub mod state;
pub use runner::HookRunner;
use notcore::{HookPhase, HookSpec, Report, StepStatus};
pub use runner::HookRunner;
#[derive(Debug, PartialEq)]
pub enum HookResult {
@@ -12,11 +12,7 @@ pub enum HookResult {
}
/// Run all hooks matching `phase` and collect into a `Report`.
pub fn run_phase(
hooks: &[HookSpec],
phase: &HookPhase,
runner: &HookRunner,
) -> Report {
pub fn run_phase(hooks: &[HookSpec], phase: &HookPhase, runner: &HookRunner) -> Report {
let mut report = Report::default();
for hook in hooks.iter().filter(|h| &h.phase == phase) {
let result = runner.run_hook(hook);

View File

@@ -1,7 +1,7 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use nothooks::{run_phase, HookRunner};
use notcore::{HookPhase, HookSpec};
use nothooks::{HookRunner, run_phase};
use std::path::PathBuf;
#[derive(Parser)]

View File

@@ -1,8 +1,8 @@
use std::path::PathBuf;
use std::process::Command;
use notcore::{HookPhase, HookSpec};
use crate::HookResult;
use crate::state::HookState;
use notcore::{HookPhase, HookSpec};
use std::path::PathBuf;
use std::process::Command;
pub struct HookRunner {
state_dir: PathBuf,
@@ -11,11 +11,17 @@ pub struct HookRunner {
impl HookRunner {
pub fn new(state_dir: PathBuf) -> Self {
Self { state_dir, force: false }
Self {
state_dir,
force: false,
}
}
pub fn with_force(state_dir: PathBuf) -> Self {
Self { state_dir, force: true }
Self {
state_dir,
force: true,
}
}
pub fn run_hook(&self, spec: &HookSpec) -> HookResult {
@@ -25,9 +31,8 @@ impl HookRunner {
return HookResult::Skipped;
}
let result = Command::new("nu")
.arg(&spec.script)
.status();
// TODO: support other shells
let result = Command::new("nu").arg(&spec.script).status();
match result {
Ok(status) if status.success() => {

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
const STATE_FILE: &str = ".nothooks-state.toml";

View File

@@ -1,7 +1,7 @@
use notcore::{HookPhase, HookSpec};
use nothooks::{HookResult, HookRunner};
use std::fs;
use tempfile::TempDir;
use nothooks::{HookResult, HookRunner};
use notcore::{HookPhase, HookSpec};
fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec {
let path = dir.path().join(format!("{name}.nu"));