refactor(nothooks): make hook runner language-agnostic
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Failing after 6s
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Failing after 6s
Remove hardcoded nu interpreter. HookSpec gains an optional interpreter field; runner infers from file extension (.sh, .nu, .py, .rb, .zsh, .bash) when not set. Integration tests now use sh — no nu runtime dep on CI.
This commit is contained in:
15
.claude/settings.json
Normal file
15
.claude/settings.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash -c 'if [ -f Cargo.toml ]; then cargo check --quiet 2>&1 | head -20; fi'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
61
HANDOFF.md
61
HANDOFF.md
@@ -7,6 +7,7 @@ State of the `notfiles` repo as of 2026-04-03.
|
|||||||
### Session 1 (2026-03-31)
|
### Session 1 (2026-03-31)
|
||||||
|
|
||||||
Restructured from a flat `src/` layout into a Cargo workspace of 5 crates:
|
Restructured from a flat `src/` layout into a Cargo workspace of 5 crates:
|
||||||
|
|
||||||
- `notcore` — shared library (types, config, paths, errors)
|
- `notcore` — shared library (types, config, paths, errors)
|
||||||
- `notfiles` — dotfiles linker, migrated from `src/`
|
- `notfiles` — dotfiles linker, migrated from `src/`
|
||||||
- `notsecrets` — age key retrieval (Bitwarden/file/prompt) + SOPS decrypt
|
- `notsecrets` — age key retrieval (Bitwarden/file/prompt) + SOPS decrypt
|
||||||
@@ -19,21 +20,22 @@ Completed the age-native redesign of `notsecrets` — all 12 tasks done.
|
|||||||
|
|
||||||
**All tasks complete:**
|
**All tasks complete:**
|
||||||
|
|
||||||
| Task | Status | Commit |
|
| Task | Status | Commit |
|
||||||
|------|--------|--------|
|
| ------------------------------------------------------------- | ------ | --------- |
|
||||||
| 1: Scaffold (Cargo.toml, error.rs, domain types, legacy shim) | ✅ | `34d9e3d` |
|
| 1: Scaffold (Cargo.toml, error.rs, domain types, legacy shim) | ✅ | `34d9e3d` |
|
||||||
| 2: format.rs (age wire format parser/serializer) | ✅ | `2476daa` |
|
| 2: format.rs (age wire format parser/serializer) | ✅ | `2476daa` |
|
||||||
| 3: X25519 identity + recipient | ✅ | `6202c5d` |
|
| 3: X25519 identity + recipient | ✅ | `6202c5d` |
|
||||||
| 4: Scrypt identity + recipient | ✅ | `b899198` |
|
| 4: Scrypt identity + recipient | ✅ | `b899198` |
|
||||||
| 5: SSH Ed25519 identity + recipient | ✅ | `566501c` |
|
| 5: SSH Ed25519 identity + recipient | ✅ | `566501c` |
|
||||||
| 6: SSH RSA identity + recipient | ✅ | `a0fe6f7` |
|
| 6: SSH RSA identity + recipient | ✅ | `a0fe6f7` |
|
||||||
| 7: EncryptedIdentity (passphrase-protected identity file) | ✅ | `545a45b` |
|
| 7: EncryptedIdentity (passphrase-protected identity file) | ✅ | `545a45b` |
|
||||||
| 8: Encryptor — multi-recipient age encryption | ✅ | `9863af6` |
|
| 8: Encryptor — multi-recipient age encryption | ✅ | `9863af6` |
|
||||||
| 9: Decryptor + wire EncryptedIdentity | ✅ | `e388d75` |
|
| 9: Decryptor + wire EncryptedIdentity | ✅ | `e388d75` |
|
||||||
| 10+11: sources/ migration + resolve_identities() | ✅ | `f80ea02` |
|
| 10+11: sources/ migration + resolve_identities() | ✅ | `f80ea02` |
|
||||||
| 12: notstrap migration, remove sops shell-out | ✅ | `58de007` |
|
| 12: notstrap migration, remove sops shell-out | ✅ | `58de007` |
|
||||||
|
|
||||||
**Current state:**
|
**Current state:**
|
||||||
|
|
||||||
- 80 tests pass, 0 clippy warnings, `cargo check --workspace` clean
|
- 80 tests pass, 0 clippy warnings, `cargo check --workspace` clean
|
||||||
- `_legacy.rs` deleted; all deprecated shims removed
|
- `_legacy.rs` deleted; all deprecated shims removed
|
||||||
- `notsecrets` is now a self-contained age encryption/decryption library
|
- `notsecrets` is now a self-contained age encryption/decryption library
|
||||||
@@ -42,6 +44,7 @@ Completed the age-native redesign of `notsecrets` — all 12 tasks done.
|
|||||||
- Example at `crates/notsecrets/examples/age_smoke.rs` demonstrates round-trip encrypt/decrypt
|
- Example at `crates/notsecrets/examples/age_smoke.rs` demonstrates round-trip encrypt/decrypt
|
||||||
|
|
||||||
Also done this session (outside notfiles):
|
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`)
|
- 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
|
- `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
|
- 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
|
||||||
@@ -51,29 +54,37 @@ Also done this session (outside notfiles):
|
|||||||
## Pending Issues
|
## Pending Issues
|
||||||
|
|
||||||
### 1. Push to remote
|
### 1. Push to remote
|
||||||
|
|
||||||
Several commits ahead of `gitea/main`. Push when ready.
|
Several commits ahead of `gitea/main`. Push when ready.
|
||||||
|
|
||||||
### 2. notstrap has an empty lib.rs
|
### 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.
|
`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
|
### 3. notstrap config not documented
|
||||||
|
|
||||||
`notstrap.toml` format is defined inline via serde structs but never written down. A sample config would help.
|
`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
|
### 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.
|
`nothooks` integration tests call `nu` directly. CI needs `nu` installed or tests need `#[ignore]` + feature flag.
|
||||||
|
|
||||||
### 5. notstrap sops_file path footgun
|
### 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.
|
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
|
### 6. HuggingFace token not in ~/.secrets
|
||||||
|
|
||||||
No API token found in 1Password — only login credentials. Add token manually if needed.
|
No API token found in 1Password — only login credentials. Add token manually if needed.
|
||||||
|
|
||||||
### 7. notsecrets is not wire-compatible with standard age
|
### 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.
|
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)
|
### Session 3 (2026-04-03)
|
||||||
|
|
||||||
**notgraph** — crate dependency + module graph report landed and polished:
|
**notgraph** — crate dependency + module graph report landed and polished:
|
||||||
|
|
||||||
- Heatmap coloring on crate nodes (fan-in intensity → blue gradient)
|
- Heatmap coloring on crate nodes (fan-in intensity → blue gradient)
|
||||||
- Per-crate module graphs (Mermaid `flowchart TD`) with `__`-separated node IDs
|
- Per-crate module graphs (Mermaid `flowchart TD`) with `__`-separated node IDs
|
||||||
- Cycle callouts with `cycle-entry` CSS class
|
- Cycle callouts with `cycle-entry` CSS class
|
||||||
@@ -83,18 +94,18 @@ The Encryptor/Decryptor use single-block ChaCha20-Poly1305 rather than the STREA
|
|||||||
|
|
||||||
**notgraph planned extensions** (not yet implemented — prioritized list):
|
**notgraph planned extensions** (not yet implemented — prioritized list):
|
||||||
|
|
||||||
| Priority | Section | Data source | Visualization |
|
| Priority | Section | Data source | Visualization |
|
||||||
|----------|---------|-------------|---------------|
|
| -------- | ----------------------- | --------------------------------------------------------- | -------------------------------------------- |
|
||||||
| 1 | Code coverage heatmap | `cargo llvm-cov --json` | Treemap or per-crate bar |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 9 | Test-to-code ratio | `tokei` + `#[test]` count | Table + stacked bar |
|
||||||
| 10 | Dep weight/bloat | `cargo metadata` | Stacked bar (direct+transitive) + duplicates |
|
| 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).
|
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.
|
Coverage + complexity require `cargo-llvm-cov` and `rust-code-analysis-cli` installed.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ state:
|
|||||||
notes: null
|
notes: null
|
||||||
items:
|
items:
|
||||||
- id: notfile-1
|
- id: notfile-1
|
||||||
doob_uuid: 6d9780fe-d6d9-4316-90b5-6de44f06e96e
|
doob_uuid: d1c0adc2-028a-4a12-be84-0698587f6f35
|
||||||
name: public-ready-workflow-fix
|
name: public-ready-workflow-fix
|
||||||
priority: P1
|
priority: P1
|
||||||
status: done
|
status: done
|
||||||
@@ -23,7 +23,7 @@ items:
|
|||||||
note: emoji → [OK] formatting change; committed in style commit
|
note: emoji → [OK] formatting change; committed in style commit
|
||||||
completed: 2026-04-03
|
completed: 2026-04-03
|
||||||
- id: notfile-2
|
- id: notfile-2
|
||||||
doob_uuid: a7ff6a74-a664-4a0e-9a13-3e3884e5d492
|
doob_uuid: b88a6135-3ecb-419e-aaac-22cb87595f90
|
||||||
name: notgraph-integration-test-fix
|
name: notgraph-integration-test-fix
|
||||||
priority: P1
|
priority: P1
|
||||||
status: done
|
status: done
|
||||||
@@ -38,7 +38,7 @@ items:
|
|||||||
note: cargo fmt reformatted long emit::write_all calls; committed in style commit
|
note: cargo fmt reformatted long emit::write_all calls; committed in style commit
|
||||||
completed: 2026-04-03
|
completed: 2026-04-03
|
||||||
- id: notfile-3
|
- id: notfile-3
|
||||||
doob_uuid: 49346e3f-0119-4e1f-9234-c59f14499aea
|
doob_uuid: 615051dd-d22c-4d50-ad70-15497780726b
|
||||||
name: notgraph-plan-remaining-steps
|
name: notgraph-plan-remaining-steps
|
||||||
priority: P2
|
priority: P2
|
||||||
status: done
|
status: done
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub struct HookSpec {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub script: String,
|
pub script: String,
|
||||||
pub phase: HookPhase,
|
pub phase: HookPhase,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interpreter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ use notcore::{HookPhase, HookSpec};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn resolve_interpreter(spec: &HookSpec) -> Result<String, String> {
|
||||||
|
if let Some(interp) = &spec.interpreter {
|
||||||
|
return Ok(interp.clone());
|
||||||
|
}
|
||||||
|
let ext = std::path::Path::new(&spec.script)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
match ext {
|
||||||
|
"nu" => Ok("nu".into()),
|
||||||
|
"sh" => Ok("sh".into()),
|
||||||
|
"bash" => Ok("bash".into()),
|
||||||
|
"zsh" => Ok("zsh".into()),
|
||||||
|
"py" => Ok("python3".into()),
|
||||||
|
"rb" => Ok("ruby".into()),
|
||||||
|
_ => Err(format!(
|
||||||
|
"cannot infer interpreter: no interpreter set and unknown extension {:?}",
|
||||||
|
ext
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct HookRunner {
|
pub struct HookRunner {
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
force: bool,
|
force: bool,
|
||||||
@@ -31,8 +53,11 @@ impl HookRunner {
|
|||||||
return HookResult::Skipped;
|
return HookResult::Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: support other shells
|
let interp = match resolve_interpreter(spec) {
|
||||||
let result = Command::new("nu").arg(&spec.script).status();
|
Ok(i) => i,
|
||||||
|
Err(msg) => return HookResult::Failed(msg),
|
||||||
|
};
|
||||||
|
let result = Command::new(&interp).arg(&spec.script).status();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(status) if status.success() => {
|
Ok(status) if status.success() => {
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ use std::fs;
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec {
|
fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec {
|
||||||
let path = dir.path().join(format!("{name}.nu"));
|
let path = dir.path().join(format!("{name}.sh"));
|
||||||
fs::write(&path, content).unwrap();
|
fs::write(&path, content).unwrap();
|
||||||
HookSpec {
|
HookSpec {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
script: path.to_str().unwrap().to_string(),
|
script: path.to_str().unwrap().to_string(),
|
||||||
phase: HookPhase::Dot,
|
phase: HookPhase::Dot,
|
||||||
|
interpreter: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hook_success() {
|
fn test_hook_success() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spec = make_hook_script(&dir, "ok-hook", "print 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);
|
||||||
assert!(matches!(result, HookResult::Ok));
|
assert!(matches!(result, HookResult::Ok));
|
||||||
@@ -25,7 +26,7 @@ fn test_hook_success() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_hook_failure() {
|
fn test_hook_failure() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spec = make_hook_script(&dir, "fail-hook", "exit 1\n");
|
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);
|
||||||
assert!(matches!(result, HookResult::Failed(_)));
|
assert!(matches!(result, HookResult::Failed(_)));
|
||||||
@@ -34,7 +35,7 @@ fn test_hook_failure() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_setup_hook_skipped_on_rerun() {
|
fn test_setup_hook_skipped_on_rerun() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let mut spec = make_hook_script(&dir, "setup-hook", "print ran");
|
let mut spec = make_hook_script(&dir, "setup-hook", "echo ran");
|
||||||
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());
|
||||||
@@ -49,7 +50,7 @@ fn test_setup_hook_skipped_on_rerun() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_setup_hook_force_reruns() {
|
fn test_setup_hook_force_reruns() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let mut spec = make_hook_script(&dir, "force-hook", "print ran");
|
let mut spec = make_hook_script(&dir, "force-hook", "echo ran");
|
||||||
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());
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ fn test_setup_hooks_skipped_on_rerun() {
|
|||||||
name: HOOK_NAME.to_string(),
|
name: HOOK_NAME.to_string(),
|
||||||
script: script.to_str().unwrap().to_string(),
|
script: script.to_str().unwrap().to_string(),
|
||||||
phase: HookPhase::Setup,
|
phase: HookPhase::Setup,
|
||||||
|
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);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ fn test_nothooks_notsecrets_independent() {
|
|||||||
name: "chain".to_string(),
|
name: "chain".to_string(),
|
||||||
script: script.to_str().unwrap().to_string(),
|
script: script.to_str().unwrap().to_string(),
|
||||||
phase: HookPhase::Dot,
|
phase: HookPhase::Dot,
|
||||||
|
interpreter: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
let runner = HookRunner::new(dir.path().to_path_buf());
|
||||||
|
|||||||
Reference in New Issue
Block a user