fix(notfiles): save state on partial link failure; skip re-copy of unchanged files
Closes #8, closes #9
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
1
.claude/worktrees/agent-a0b9834e
Submodule
1
.claude/worktrees/agent-a0b9834e
Submodule
Submodule .claude/worktrees/agent-a0b9834e added at 11d329c5a2
1
.claude/worktrees/agent-a27fe5f2
Submodule
1
.claude/worktrees/agent-a27fe5f2
Submodule
Submodule .claude/worktrees/agent-a27fe5f2 added at 11d329c5a2
1
.claude/worktrees/agent-a6b81d3b
Submodule
1
.claude/worktrees/agent-a6b81d3b
Submodule
Submodule .claude/worktrees/agent-a6b81d3b added at 11d329c5a2
1
.claude/worktrees/agent-af9e1ed8
Submodule
1
.claude/worktrees/agent-af9e1ed8
Submodule
Submodule .claude/worktrees/agent-af9e1ed8 added at 11d329c5a2
@@ -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(
|
||||||
path: target.clone(),
|
state,
|
||||||
reason: format!(
|
NotfilesError::Conflict {
|
||||||
"already exists (use --force to overwrite); source: {source_display}"
|
path: target.clone(),
|
||||||
),
|
reason: format!(
|
||||||
});
|
"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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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| {
|
||||||
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
|
format!(
|
||||||
h.name, h.kind, h.score
|
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||||
))
|
h.name, h.kind, h.score
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,14 @@ 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(
|
||||||
.context("failed to write reports")?;
|
&output_dir,
|
||||||
|
&crate_g,
|
||||||
|
&module_graphs,
|
||||||
|
&stats,
|
||||||
|
&symbol_tables,
|
||||||
|
)
|
||||||
|
.context("failed to write reports")?;
|
||||||
|
|
||||||
println!("notgraph: reports written to {}", output_dir.display());
|
println!("notgraph: reports written to {}", output_dir.display());
|
||||||
|
|
||||||
|
|||||||
@@ -50,15 +50,19 @@ 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
|
||||||
name: self.name().to_string(),
|
.write_all(password.as_bytes())
|
||||||
source: anyhow::anyhow!("bw unlock stdin write: {e}"),
|
.map_err(|e| AgeError::SourceError {
|
||||||
})?;
|
name: self.name().to_string(),
|
||||||
|
source: anyhow::anyhow!("bw unlock stdin write: {e}"),
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
let output = child.wait_with_output().map_err(|e| AgeError::SourceError {
|
let output = child
|
||||||
name: self.name().to_string(),
|
.wait_with_output()
|
||||||
source: anyhow::anyhow!("bw unlock wait: {e}"),
|
.map_err(|e| AgeError::SourceError {
|
||||||
})?;
|
name: self.name().to_string(),
|
||||||
|
source: anyhow::anyhow!("bw unlock wait: {e}"),
|
||||||
|
})?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(AgeError::SourceError {
|
return Err(AgeError::SourceError {
|
||||||
name: self.name().to_string(),
|
name: self.name().to_string(),
|
||||||
|
|||||||
@@ -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
37
scripts/preflight.nu
Normal 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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user