feat(diff): fix diff bug in apply

This commit is contained in:
Ray Sinurat 2026-02-07 01:16:16 -06:00
parent f23a9b2653
commit bee2ceff00
2 changed files with 166 additions and 78 deletions

View file

@ -251,85 +251,155 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
} }
} }
println!("\nHow to resolve conflicts?"); if dry_run {
println!(" [s] Use source (overwrite target)"); println!("\nRun without --dry-run to resolve conflicts.");
println!(" [t] Keep target (skip these files)"); } else {
println!(" [i] Interactive (ask for each)"); println!("\nHow to resolve conflicts?");
println!(" [a] Abort"); println!(" [s] Use source (overwrite target)");
print!("\nChoice [s/t/i/a]: "); println!(" [t] Keep target (skip these files)");
io::stdout().flush()?; println!(" [i] Interactive (ask for each)");
println!(" [a] Abort");
print!("\nChoice [s/t/i/a]: ");
io::stdout().flush()?;
let mut input = String::new(); let mut input = String::new();
io::stdin().read_line(&mut input)?; io::stdin().read_line(&mut input)?;
match input.trim().to_lowercase().as_str() { match input.trim().to_lowercase().as_str() {
"s" => { "s" => {
for (idx, _) in conflicts { for (idx, _) in conflicts {
deploy_set.insert(idx); deploy_set.insert(idx);
}
} }
} "t" => {
"t" => { println!("Skipping conflicted files.");
println!("Skipping conflicted files."); }
} "i" => {
"i" => { for (idx, status) in conflicts {
for (idx, status) in conflicts { let dotfile = &result.dotfiles[idx];
let dotfile = &result.dotfiles[idx]; let status_str = match status {
let status_str = match status { SyncStatus::TargetChanged => "target changed",
SyncStatus::TargetChanged => "target changed", SyncStatus::Conflict => "both changed",
SyncStatus::Conflict => "both changed", _ => "conflict",
_ => "conflict", };
}; println!(
println!( "\n[{}] {} -> {}",
"\n[{}] {} -> {}", status_str,
status_str, dotfile.source.display(),
dotfile.source.display(), dotfile.target.display()
dotfile.target.display() );
); println!(
println!( " [s] Use source [t] Keep target [d] Show diff [m] Merge in editor"
" [s] Use source [t] Keep target [d] Show diff [m] Merge in editor" );
); print!(" Choice [s/t/d/m]: ");
print!(" Choice [s/t/d/m]: "); io::stdout().flush()?;
io::stdout().flush()?;
let mut choice = String::new(); let mut choice = String::new();
io::stdin().read_line(&mut choice)?; io::stdin().read_line(&mut choice)?;
match choice.trim().to_lowercase().as_str() { match choice.trim().to_lowercase().as_str() {
"s" => { "s" => {
deploy_set.insert(idx);
}
"d" => {
let full_source = source_dir.join(&dotfile.source);
show_diff(&full_source, &dotfile.target);
print!(" Use source? [y/n]: ");
io::stdout().flush()?;
let mut confirm = String::new();
io::stdin().read_line(&mut confirm)?;
if confirm.trim().to_lowercase() == "y" {
deploy_set.insert(idx); deploy_set.insert(idx);
} }
} "d" => {
"m" => { let full_source = source_dir.join(&dotfile.source);
let full_source = source_dir.join(&dotfile.source); if full_source.is_dir() {
if merge_in_editor(&full_source, &dotfile.target)? { // Show diffs for individual conflicted files
// Source was updated with merged content, deploy it let relevant: Vec<_> = file_conflicts
deploy_set.insert(idx); .iter()
} else { .filter(|(file_path, _, _, _)| {
println!(" Merge cancelled, keeping target."); file_path.starts_with(&dotfile.target)
})
.collect();
if relevant.is_empty() {
println!(" (no file-level diffs to show)");
} else {
for (file_path, src, tgt, _) in &relevant {
let relative = file_path
.strip_prefix(&dotfile.target)
.unwrap_or(file_path);
println!("\n diff: {}", relative.display());
show_diff(src, tgt);
}
}
print!(
" Use source for all {} conflicted file{}? [y/n]: ",
relevant.len(),
if relevant.len() == 1 { "" } else { "s" }
);
} else {
show_diff(&full_source, &dotfile.target);
print!(" Use source? [y/n]: ");
}
io::stdout().flush()?;
let mut confirm = String::new();
io::stdin().read_line(&mut confirm)?;
if confirm.trim().to_lowercase() == "y" {
deploy_set.insert(idx);
}
}
"m" => {
let full_source = source_dir.join(&dotfile.source);
if full_source.is_dir() {
// Merge individual conflicted files
let relevant: Vec<_> = file_conflicts
.iter()
.filter(|(file_path, _, _, _)| {
file_path.starts_with(&dotfile.target)
})
.collect();
if relevant.is_empty() {
println!(" (no file-level conflicts to merge)");
} else {
let mut all_merged = true;
for (file_path, src, tgt, _) in &relevant {
let relative = file_path
.strip_prefix(&dotfile.target)
.unwrap_or(file_path);
println!("\n Merging {}...", relative.display());
if !merge_in_editor(src, tgt)? {
println!(
" Merge cancelled for {}.",
relative.display()
);
all_merged = false;
}
}
if all_merged {
deploy_set.insert(idx);
} else {
print!(
" Some files not merged. Deploy anyway? [y/n]: "
);
io::stdout().flush()?;
let mut confirm = String::new();
io::stdin().read_line(&mut confirm)?;
if confirm.trim().to_lowercase() == "y" {
deploy_set.insert(idx);
}
}
}
} else if merge_in_editor(&full_source, &dotfile.target)? {
// Source was updated with merged content, deploy it
deploy_set.insert(idx);
} else {
println!(" Merge cancelled, keeping target.");
}
}
_ => {
println!(" Keeping target.");
} }
}
_ => {
println!(" Keeping target.");
} }
} }
} }
_ => {
println!("Aborted.");
return Ok(());
}
} }
_ => { } // else (not dry_run)
println!("Aborted.");
return Ok(());
}
}
} }
let dry_prefix = if dry_run { "[dry-run] " } else { "" }; let dry_prefix = if dry_run { "[dry-run] " } else { "" };
@ -583,19 +653,33 @@ fn prompt_uninstall(package: &str) -> anyhow::Result<bool> {
fn show_diff(source: &PathBuf, target: &PathBuf) { fn show_diff(source: &PathBuf, target: &PathBuf) {
use std::process::Command; use std::process::Command;
if source.is_file() && target.is_file() { if !source.is_file() {
let output = Command::new("diff") println!(" (source does not exist: {})", source.display());
.arg("--color=always") return;
.arg("-u") }
.arg(target) if !target.is_file() {
.arg(source) println!(" (target does not exist: {})", target.display());
.output(); return;
}
if let Ok(output) = output { match Command::new("diff")
println!("{}", String::from_utf8_lossy(&output.stdout)); .arg("--color=always")
.arg("-u")
.arg(target)
.arg(source)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
println!(" (no content differences)");
} else {
println!("{}", stdout);
}
}
Err(e) => {
println!(" (failed to run diff: {})", e);
} }
} else {
println!(" (diff not available for directories)");
} }
} }

View file

@ -233,6 +233,10 @@ impl StateStore {
(false, false) => SyncStatus::Synced, (false, false) => SyncStatus::Synced,
(true, false) => SyncStatus::SourceChanged, (true, false) => SyncStatus::SourceChanged,
(false, true) => SyncStatus::TargetChanged, (false, true) => SyncStatus::TargetChanged,
(true, true) if current_source_hash == current_target_hash => {
// Both changed but converged to the same content — not a real conflict
SyncStatus::SourceChanged
}
(true, true) => SyncStatus::Conflict, (true, true) => SyncStatus::Conflict,
}; };