fix(notfiles): save state on partial link failure; skip re-copy of unchanged files

Closes #8, closes #9
This commit is contained in:
Joseph O'Brien
2026-04-11 23:40:32 -04:00
parent 11d329c5a2
commit 3e7135a398
11 changed files with 156 additions and 44 deletions

View File

@@ -1,5 +1,15 @@
{ {
"hooks": { "hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu"
}
]
}
],
"PostToolUse": [ "PostToolUse": [
{ {
"matcher": "Edit|Write", "matcher": "Edit|Write",

Submodule .claude/worktrees/agent-a0b9834e added at 11d329c5a2

Submodule .claude/worktrees/agent-a27fe5f2 added at 11d329c5a2

Submodule .claude/worktrees/agent-a6b81d3b added at 11d329c5a2

Submodule .claude/worktrees/agent-af9e1ed8 added at 11d329c5a2

View File

@@ -95,7 +95,7 @@ pub fn link_package(
let target = target_base.join(relative); let target = target_base.join(relative);
let source_display = format!("{package}/{}", relative.display()); let source_display = format!("{package}/{}", relative.display());
// Check if already correctly linked // Check if already correctly linked / copied
if is_already_linked(&source, &target, method) { if is_already_linked(&source, &target, method) {
if opts.verbose { if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)"); println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
@@ -103,15 +103,26 @@ pub fn link_package(
continue; continue;
} }
// Helper: save partial state then return an error.
let save_and_return = |state: &mut State, e: NotfilesError| -> Result<(), NotfilesError> {
if !opts.dry_run {
let _ = state.save(dotfiles_dir);
}
Err(e)
};
// Conflict detection // Conflict detection
if target.exists() || target.symlink_metadata().is_ok() { if target.exists() || target.symlink_metadata().is_ok() {
if !opts.force { if !opts.force {
return Err(NotfilesError::Conflict { return save_and_return(
state,
NotfilesError::Conflict {
path: target.clone(), path: target.clone(),
reason: format!( reason: format!(
"already exists (use --force to overwrite); source: {source_display}" "already exists (use --force to overwrite); source: {source_display}"
), ),
}); },
);
} }
// Force mode: backup then remove // Force mode: backup then remove
if !opts.no_backup { if !opts.no_backup {
@@ -130,13 +141,18 @@ pub fn link_package(
backup.display() backup.display()
); );
} }
fs::rename(&target, &backup)?; if let Err(e) = fs::rename(&target, &backup) {
return save_and_return(state, e.into());
}
} }
} else if !opts.dry_run { } else if !opts.dry_run {
if target.is_dir() { let rm_result = if target.is_dir() {
fs::remove_dir_all(&target)?; fs::remove_dir_all(&target)
} else { } else {
fs::remove_file(&target)?; fs::remove_file(&target)
};
if let Err(e) = rm_result {
return save_and_return(state, e.into());
} }
} }
} }
@@ -147,8 +163,8 @@ pub fn link_package(
if opts.verbose { if opts.verbose {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display()); println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
} }
} else { } else if let Err(e) = fs::create_dir_all(parent) {
fs::create_dir_all(parent)?; return save_and_return(state, e.into());
} }
} }
@@ -164,16 +180,21 @@ pub fn link_package(
target.display() target.display()
); );
} else { } else {
match method { let link_result = match method {
Method::Symlink => { Method::Symlink => {
#[cfg(unix)] #[cfg(unix)]
std::os::unix::fs::symlink(&source, &target)?; {
std::os::unix::fs::symlink(&source, &target).map(|_| 0u64)
}
#[cfg(not(unix))] #[cfg(not(unix))]
fs::copy(&source, &target)?; {
fs::copy(&source, &target)
} }
Method::Copy => {
fs::copy(&source, &target)?;
} }
Method::Copy => fs::copy(&source, &target),
};
if let Err(e) = link_result {
return save_and_return(state, e.into());
} }
if opts.verbose { if opts.verbose {
println!( println!(
@@ -283,7 +304,15 @@ fn is_already_linked(source: &Path, target: &Path, method: Method) -> bool {
false false
} }
} }
Method::Copy => false, // Always re-copy Method::Copy => {
// Skip re-copy if target exists and has the same size and mtime as source.
match (fs::metadata(source), fs::metadata(target)) {
(Ok(sm), Ok(tm)) => {
sm.len() == tm.len() && sm.modified().ok() == tm.modified().ok()
}
_ => false,
}
}
} }
} }

View File

@@ -172,19 +172,27 @@ fn write_html(
.map(|fs| fs.name.as_str()) .map(|fs| fs.name.as_str())
.collect(); .collect();
let mut leaves: Vec<&str> = crate_graph.nodes.iter() let mut leaves: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && !root_nodes.contains(n)) .filter(|n| leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect(); .collect();
let mut roots: Vec<&str> = crate_graph.nodes.iter() let mut roots: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| root_nodes.contains(n) && !leaf_nodes.contains(n)) .filter(|n| root_nodes.contains(n) && !leaf_nodes.contains(n))
.collect(); .collect();
let mut features: Vec<&str> = crate_graph.nodes.iter() let mut features: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n)) .filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect(); .collect();
let mut isolated: Vec<&str> = crate_graph.nodes.iter() let mut isolated: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n)) .filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n))
.collect(); .collect();
@@ -202,7 +210,11 @@ fn write_html(
let syms = sym_count.get(n).copied().unwrap_or(0); let syms = sym_count.get(n).copied().unwrap_or(0);
format!( format!(
"{}[\"{}<br/>in:{} out:{} | {} pub\"]", "{}[\"{}<br/>in:{} out:{} | {} pub\"]",
mermaid_id(n), n, fi, fo, syms mermaid_id(n),
n,
fi,
fo,
syms
) )
}; };
@@ -211,17 +223,23 @@ fn write_html(
if !leaves.is_empty() { if !leaves.is_empty() {
mermaid.push_str(" subgraph Core\n direction TB\n"); mermaid.push_str(" subgraph Core\n direction TB\n");
for n in &leaves { mermaid.push_str(&format!(" {}\n", node_label(n))); } for n in &leaves {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
if !features.is_empty() { if !features.is_empty() {
mermaid.push_str(" subgraph Features\n direction TB\n"); mermaid.push_str(" subgraph Features\n direction TB\n");
for n in &features { mermaid.push_str(&format!(" {}\n", node_label(n))); } for n in &features {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
if !roots.is_empty() { if !roots.is_empty() {
mermaid.push_str(" subgraph Tools\n direction TB\n"); mermaid.push_str(" subgraph Tools\n direction TB\n");
for n in &roots { mermaid.push_str(&format!(" {}\n", node_label(n))); } for n in &roots {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
@@ -234,7 +252,8 @@ fn write_html(
let color = heatmap_color(fs.fan_in); let color = heatmap_color(fs.fan_in);
mermaid.push_str(&format!( mermaid.push_str(&format!(
" style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n", " style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n",
mermaid_id(&fs.name), color mermaid_id(&fs.name),
color
)); ));
} }
@@ -316,10 +335,12 @@ fn write_html(
let hotspot_rows: String = stats let hotspot_rows: String = stats
.hotspots .hotspots
.iter() .iter()
.map(|h| format!( .map(|h| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>", "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
h.name, h.kind, h.score h.name, h.kind, h.score
)) )
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");

View File

@@ -63,7 +63,13 @@ fn main() -> Result<()> {
workspace_root.join(&cli.output) workspace_root.join(&cli.output)
}; };
emit::write_all(&output_dir, &crate_g, &module_graphs, &stats, &symbol_tables) emit::write_all(
&output_dir,
&crate_g,
&module_graphs,
&stats,
&symbol_tables,
)
.context("failed to write reports")?; .context("failed to write reports")?;
println!("notgraph: reports written to {}", output_dir.display()); println!("notgraph: reports written to {}", output_dir.display());

View File

@@ -50,12 +50,16 @@ impl BitwardenSource {
source: anyhow::anyhow!("bw unlock spawn: {e}"), source: anyhow::anyhow!("bw unlock spawn: {e}"),
})?; })?;
if let Some(mut stdin) = child.stdin.take() { if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(password.as_bytes()).map_err(|e| AgeError::SourceError { stdin
.write_all(password.as_bytes())
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: self.name().to_string(),
source: anyhow::anyhow!("bw unlock stdin write: {e}"), source: anyhow::anyhow!("bw unlock stdin write: {e}"),
})?; })?;
} }
let output = child.wait_with_output().map_err(|e| AgeError::SourceError { let output = child
.wait_with_output()
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: self.name().to_string(),
source: anyhow::anyhow!("bw unlock wait: {e}"), source: anyhow::anyhow!("bw unlock wait: {e}"),
})?; })?;

View File

@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use notcore::{HookPhase, Report, StepStatus}; use notcore::{HookPhase, Report, StepStatus};
use notfiles::{LinkOptions, link}; use notfiles::{LinkOptions, link};
use nothooks::{HookRunner, run_phase}; use nothooks::{HookRunner, run_phase};
use notsecrets::identities::Identity;
use notsecrets::sources::IdentitySource; use notsecrets::sources::IdentitySource;
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities}; use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
use serde::Deserialize; use serde::Deserialize;
@@ -10,7 +11,7 @@ use std::path::{Path, PathBuf};
pub mod prereqs; pub mod prereqs;
pub mod repo; pub mod repo;
type EnvInjector = Box<dyn Fn(&Path) -> Result<String>>; type EnvInjector = Box<dyn Fn(&Path, Vec<Box<dyn Identity>>) -> Result<String>>;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct NotstrapConfig { pub struct NotstrapConfig {

37
scripts/preflight.nu Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env nu
# preflight.nu — notfiles environment validation
def check [label: string, pass: bool, detail: string = ""] {
if $pass {
print $"[ok] ($label)"
} else if $detail != "" {
print $"[fail] ($label)($detail)"
} else {
print $"[fail] ($label)"
}
$pass
}
print "=== notfiles preflight ==="
let results = [
(check "cargo on PATH" (which cargo | length) > 0),
(check "just on PATH" (which just | length) > 0),
(check "nu on PATH" (which nu | length) > 0 "nushell required for hook scripts"),
(check "age on PATH" (which age | length) > 0 "optional — needed for SOPS decryption"),
(check "sops on PATH" (which sops | length) > 0 "optional — needed for encrypted configs"),
(check "notfiles on PATH" (which notfiles | length) > 0 "run: cargo install --path crates/notfiles"),
(check "op on PATH" (which op | length) > 0),
(check "1Password authed" (do { op account list } | complete | get exit_code) == 0),
(check "git repo clean" (do { git status --porcelain } | complete | get stdout | str trim | is-empty)),
]
let failed = $results | where { |r| not $r } | length
let total = $results | length
print ""
if $failed == 0 {
print $"preflight passed ($total)/($total)"
} else {
print $"preflight ($total - $failed)/($total)($failed) check(s) failed"
}