From 3e7135a398b159a9ea1a3047441167e63848dac8 Mon Sep 17 00:00:00 2001
From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com>
Date: Sat, 11 Apr 2026 23:40:32 -0400
Subject: [PATCH] fix(notfiles): save state on partial link failure; skip
re-copy of unchanged files
Closes #8, closes #9
---
.claude/settings.json | 10 ++++
.claude/worktrees/agent-a0b9834e | 1 +
.claude/worktrees/agent-a27fe5f2 | 1 +
.claude/worktrees/agent-a6b81d3b | 1 +
.claude/worktrees/agent-af9e1ed8 | 1 +
crates/notfiles/src/linker.rs | 69 +++++++++++++++-------
crates/notgraph/src/emit.rs | 47 +++++++++++----
crates/notgraph/src/main.rs | 10 +++-
crates/notsecrets/src/sources/bitwarden.rs | 20 ++++---
crates/notstrap/src/lib.rs | 3 +-
scripts/preflight.nu | 37 ++++++++++++
11 files changed, 156 insertions(+), 44 deletions(-)
create mode 160000 .claude/worktrees/agent-a0b9834e
create mode 160000 .claude/worktrees/agent-a27fe5f2
create mode 160000 .claude/worktrees/agent-a6b81d3b
create mode 160000 .claude/worktrees/agent-af9e1ed8
create mode 100644 scripts/preflight.nu
diff --git a/.claude/settings.json b/.claude/settings.json
index 0a4eaef..64de6ec 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,5 +1,15 @@
{
"hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu"
+ }
+ ]
+ }
+ ],
"PostToolUse": [
{
"matcher": "Edit|Write",
diff --git a/.claude/worktrees/agent-a0b9834e b/.claude/worktrees/agent-a0b9834e
new file mode 160000
index 0000000..11d329c
--- /dev/null
+++ b/.claude/worktrees/agent-a0b9834e
@@ -0,0 +1 @@
+Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0
diff --git a/.claude/worktrees/agent-a27fe5f2 b/.claude/worktrees/agent-a27fe5f2
new file mode 160000
index 0000000..11d329c
--- /dev/null
+++ b/.claude/worktrees/agent-a27fe5f2
@@ -0,0 +1 @@
+Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0
diff --git a/.claude/worktrees/agent-a6b81d3b b/.claude/worktrees/agent-a6b81d3b
new file mode 160000
index 0000000..11d329c
--- /dev/null
+++ b/.claude/worktrees/agent-a6b81d3b
@@ -0,0 +1 @@
+Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0
diff --git a/.claude/worktrees/agent-af9e1ed8 b/.claude/worktrees/agent-af9e1ed8
new file mode 160000
index 0000000..11d329c
--- /dev/null
+++ b/.claude/worktrees/agent-af9e1ed8
@@ -0,0 +1 @@
+Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0
diff --git a/crates/notfiles/src/linker.rs b/crates/notfiles/src/linker.rs
index a08b6d1..2dc68ae 100644
--- a/crates/notfiles/src/linker.rs
+++ b/crates/notfiles/src/linker.rs
@@ -95,7 +95,7 @@ pub fn link_package(
let target = target_base.join(relative);
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 opts.verbose {
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
@@ -103,15 +103,26 @@ pub fn link_package(
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
if target.exists() || target.symlink_metadata().is_ok() {
if !opts.force {
- return Err(NotfilesError::Conflict {
- path: target.clone(),
- reason: format!(
- "already exists (use --force to overwrite); source: {source_display}"
- ),
- });
+ return save_and_return(
+ state,
+ NotfilesError::Conflict {
+ path: target.clone(),
+ reason: format!(
+ "already exists (use --force to overwrite); source: {source_display}"
+ ),
+ },
+ );
}
// Force mode: backup then remove
if !opts.no_backup {
@@ -130,13 +141,18 @@ pub fn link_package(
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 {
- if target.is_dir() {
- fs::remove_dir_all(&target)?;
+ let rm_result = if target.is_dir() {
+ fs::remove_dir_all(&target)
} 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 {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
- } else {
- fs::create_dir_all(parent)?;
+ } else if let Err(e) = fs::create_dir_all(parent) {
+ return save_and_return(state, e.into());
}
}
@@ -164,16 +180,21 @@ pub fn link_package(
target.display()
);
} else {
- match method {
+ let link_result = match method {
Method::Symlink => {
#[cfg(unix)]
- std::os::unix::fs::symlink(&source, &target)?;
+ {
+ std::os::unix::fs::symlink(&source, &target).map(|_| 0u64)
+ }
#[cfg(not(unix))]
- fs::copy(&source, &target)?;
- }
- Method::Copy => {
- fs::copy(&source, &target)?;
+ {
+ 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 {
println!(
@@ -283,7 +304,15 @@ fn is_already_linked(source: &Path, target: &Path, method: Method) -> bool {
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,
+ }
+ }
}
}
diff --git a/crates/notgraph/src/emit.rs b/crates/notgraph/src/emit.rs
index 75bc88d..3ec0446 100644
--- a/crates/notgraph/src/emit.rs
+++ b/crates/notgraph/src/emit.rs
@@ -172,19 +172,27 @@ fn write_html(
.map(|fs| fs.name.as_str())
.collect();
- let mut leaves: Vec<&str> = crate_graph.nodes.iter()
+ let mut leaves: Vec<&str> = crate_graph
+ .nodes
+ .iter()
.map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect();
- let mut roots: Vec<&str> = crate_graph.nodes.iter()
+ let mut roots: Vec<&str> = crate_graph
+ .nodes
+ .iter()
.map(|n| n.as_str())
.filter(|n| root_nodes.contains(n) && !leaf_nodes.contains(n))
.collect();
- let mut features: Vec<&str> = crate_graph.nodes.iter()
+ let mut features: Vec<&str> = crate_graph
+ .nodes
+ .iter()
.map(|n| n.as_str())
.filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect();
- let mut isolated: Vec<&str> = crate_graph.nodes.iter()
+ let mut isolated: Vec<&str> = crate_graph
+ .nodes
+ .iter()
.map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n))
.collect();
@@ -202,7 +210,11 @@ fn write_html(
let syms = sym_count.get(n).copied().unwrap_or(0);
format!(
"{}[\"{}
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() {
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");
}
if !features.is_empty() {
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");
}
if !roots.is_empty() {
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");
}
@@ -234,7 +252,8 @@ fn write_html(
let color = heatmap_color(fs.fan_in);
mermaid.push_str(&format!(
" 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
.hotspots
.iter()
- .map(|h| format!(
- "