diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index 4c18781..0bbb881 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -251,85 +251,155 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: } } - println!("\nHow to resolve conflicts?"); - println!(" [s] Use source (overwrite target)"); - println!(" [t] Keep target (skip these files)"); - println!(" [i] Interactive (ask for each)"); - println!(" [a] Abort"); - print!("\nChoice [s/t/i/a]: "); - io::stdout().flush()?; + if dry_run { + println!("\nRun without --dry-run to resolve conflicts."); + } else { + println!("\nHow to resolve conflicts?"); + println!(" [s] Use source (overwrite target)"); + println!(" [t] Keep target (skip these files)"); + println!(" [i] Interactive (ask for each)"); + println!(" [a] Abort"); + print!("\nChoice [s/t/i/a]: "); + io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; - match input.trim().to_lowercase().as_str() { - "s" => { - for (idx, _) in conflicts { - deploy_set.insert(idx); + match input.trim().to_lowercase().as_str() { + "s" => { + for (idx, _) in conflicts { + deploy_set.insert(idx); + } } - } - "t" => { - println!("Skipping conflicted files."); - } - "i" => { - for (idx, status) in conflicts { - let dotfile = &result.dotfiles[idx]; - let status_str = match status { - SyncStatus::TargetChanged => "target changed", - SyncStatus::Conflict => "both changed", - _ => "conflict", - }; - println!( - "\n[{}] {} -> {}", - status_str, - dotfile.source.display(), - dotfile.target.display() - ); - println!( - " [s] Use source [t] Keep target [d] Show diff [m] Merge in editor" - ); - print!(" Choice [s/t/d/m]: "); - io::stdout().flush()?; + "t" => { + println!("Skipping conflicted files."); + } + "i" => { + for (idx, status) in conflicts { + let dotfile = &result.dotfiles[idx]; + let status_str = match status { + SyncStatus::TargetChanged => "target changed", + SyncStatus::Conflict => "both changed", + _ => "conflict", + }; + println!( + "\n[{}] {} -> {}", + status_str, + dotfile.source.display(), + dotfile.target.display() + ); + println!( + " [s] Use source [t] Keep target [d] Show diff [m] Merge in editor" + ); + print!(" Choice [s/t/d/m]: "); + io::stdout().flush()?; - let mut choice = String::new(); - io::stdin().read_line(&mut choice)?; + let mut choice = String::new(); + io::stdin().read_line(&mut choice)?; - match choice.trim().to_lowercase().as_str() { - "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" { + match choice.trim().to_lowercase().as_str() { + "s" => { deploy_set.insert(idx); } - } - "m" => { - let full_source = source_dir.join(&dotfile.source); - 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."); + "d" => { + let full_source = source_dir.join(&dotfile.source); + if full_source.is_dir() { + // Show diffs for 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 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(()); + } } - _ => { - println!("Aborted."); - return Ok(()); - } - } + } // else (not dry_run) } let dry_prefix = if dry_run { "[dry-run] " } else { "" }; @@ -583,19 +653,33 @@ fn prompt_uninstall(package: &str) -> anyhow::Result { fn show_diff(source: &PathBuf, target: &PathBuf) { use std::process::Command; - if source.is_file() && target.is_file() { - let output = Command::new("diff") - .arg("--color=always") - .arg("-u") - .arg(target) - .arg(source) - .output(); + if !source.is_file() { + println!(" (source does not exist: {})", source.display()); + return; + } + if !target.is_file() { + println!(" (target does not exist: {})", target.display()); + return; + } - if let Ok(output) = output { - println!("{}", String::from_utf8_lossy(&output.stdout)); + match Command::new("diff") + .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)"); } } diff --git a/crates/doot-core/src/state/store.rs b/crates/doot-core/src/state/store.rs index 06b8a7c..06dc01a 100644 --- a/crates/doot-core/src/state/store.rs +++ b/crates/doot-core/src/state/store.rs @@ -233,6 +233,10 @@ impl StateStore { (false, false) => SyncStatus::Synced, (true, false) => SyncStatus::SourceChanged, (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, };