suture-cli 1.0.0

A patch-based version control system with semantic merge for structured files
use crate::style::run_hook_if_exists;

pub(crate) async fn cmd_merge(
    source: &str,
    dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    use std::path::Path as StdPath;
    use suture_core::repository::ConflictInfo;

    let mut repo = suture_core::repository::Repository::open(std::path::Path::new("."))?;

    let (branch, head_id) = repo
        .head()
        .unwrap_or_else(|_| ("main".to_string(), suture_common::Hash::ZERO));
    let mut pre_extra = std::collections::HashMap::new();
    pre_extra.insert("SUTURE_BRANCH".to_string(), branch.clone());
    pre_extra.insert("SUTURE_HEAD".to_string(), head_id.to_hex());
    pre_extra.insert("SUTURE_MERGE_SOURCE".to_string(), source.to_string());

    if dry_run {
        println!(
            "DRY RUN: previewing merge of '{}' into current branch",
            source
        );
    } else {
        run_hook_if_exists(repo.root(), "pre-merge", pre_extra)?;
    }

    let result = if dry_run {
        // Use read-only preview — does not modify the repository
        repo.preview_merge(source)?
    } else {
        repo.execute_merge(source)?
    };

    if result.is_clean {
        if dry_run {
            if result.patches_applied > 0 {
                println!(
                    "Would apply {} patch(es) from '{}'",
                    result.patches_applied, source
                );
            } else {
                println!("Already up to date.");
            }
            println!("DRY RUN — no files were modified.");
            return Ok(());
        }

        if let Some(id) = result.merge_patch_id {
            println!("Merge successful: {}", id);
        }
        if result.patches_applied > 0 {
            println!(
                "Applied {} patch(es) from '{}'",
                result.patches_applied, source
            );
        }

        let (branch, head_id) = repo.head()?;
        let mut post_extra = std::collections::HashMap::new();
        post_extra.insert("SUTURE_BRANCH".to_string(), branch);
        post_extra.insert("SUTURE_HEAD".to_string(), head_id.to_hex());
        post_extra.insert("SUTURE_MERGE_SOURCE".to_string(), source.to_string());
        run_hook_if_exists(repo.root(), "post-merge", post_extra)?;

        return Ok(());
    }

    // Handle conflicts
    let conflicts = result.unresolved_conflicts;
    let mut remaining: Vec<ConflictInfo> = Vec::new();
    let mut resolved_count = 0usize;

    if dry_run {
        if result.patches_applied > 0 {
            println!(
                "Would apply {} patch(es) from '{}'",
                result.patches_applied, source
            );
        }

        // Preview semantic driver resolution
        let registry = crate::driver_registry::builtin_registry();
        for conflict in &conflicts {
            let path = StdPath::new(&conflict.path);
            if registry.get_for_path(path).is_ok() {
                resolved_count += 1;
            } else {
                remaining.push(conflict.clone());
            }
        }

        if resolved_count > 0 {
            println!(
                "Would resolve {} conflict(s) via semantic drivers",
                resolved_count
            );
        }
        if remaining.is_empty() && resolved_count > 0 {
            println!("All conflicts would be resolved via semantic drivers.");
        } else if !remaining.is_empty() {
            println!("{} conflict(s) would remain unresolved:", remaining.len());
            for conflict in &remaining {
                println!("  CONFLICT in '{}'", conflict.path);
            }
        }
        println!("DRY RUN — no files were modified.");
        return Ok(());
    }

    // Not dry-run — actually perform the merge with semantic driver resolution
    {
        let registry = crate::driver_registry::builtin_registry();

        for conflict in &conflicts {
            let path = StdPath::new(&conflict.path);
            let Ok(driver) = registry.get_for_path(path) else {
                remaining.push(conflict.clone());
                continue;
            };

            let base_content = conflict
                .base_content_hash
                .and_then(|h| repo.cas().get_blob(&h).ok())
                .map(|b| String::from_utf8_lossy(&b).to_string());
            let ours_content = conflict
                .our_content_hash
                .and_then(|h| repo.cas().get_blob(&h).ok())
                .map(|b| String::from_utf8_lossy(&b).to_string());
            let theirs_content = conflict
                .their_content_hash
                .and_then(|h| repo.cas().get_blob(&h).ok())
                .map(|b| String::from_utf8_lossy(&b).to_string());

            let base_str = base_content.as_deref().unwrap_or("");
            let Some(ours_str) = ours_content.as_deref() else {
                remaining.push(conflict.clone());
                continue;
            };
            let Some(theirs_str) = theirs_content.as_deref() else {
                remaining.push(conflict.clone());
                continue;
            };

            let Ok(merged) = driver.merge(base_str, ours_str, theirs_str) else {
                remaining.push(conflict.clone());
                continue;
            };
            let Some(content) = merged else {
                remaining.push(conflict.clone());
                continue;
            };

            if let Err(e) = std::fs::write(&conflict.path, &content) {
                eprintln!(
                    "Warning: could not write resolved file '{}': {e}",
                    conflict.path
                );
                remaining.push(conflict.clone());
                continue;
            }

            if let Err(e) = repo.add(&conflict.path) {
                eprintln!(
                    "Warning: could not stage resolved file '{}': {e}",
                    conflict.path
                );
                remaining.push(conflict.clone());
                continue;
            }

            let driver_name = driver.name().to_lowercase();
            println!("Resolved {} via {} driver", conflict.path, driver_name);
            resolved_count += 1;
        }
    }

    println!("Merge has {} conflict(s):", remaining.len());
    for conflict in &remaining {
        let our_content = conflict
            .our_content_hash
            .and_then(|h| repo.cas().get_blob(&h).ok())
            .map(|b| String::from_utf8_lossy(&b).to_string())
            .unwrap_or_default();
        let their_content = conflict
            .their_content_hash
            .and_then(|h| repo.cas().get_blob(&h).ok())
            .map(|b| String::from_utf8_lossy(&b).to_string())
            .unwrap_or_default();
        println!("  CONFLICT in '{}':", conflict.path);
        println!("    ours:\n{}", indent(&our_content, "      "));
        println!("    theirs:\n{}", indent(&their_content, "      "));
    }

    if remaining.is_empty() {
        println!(
            "All conflicts resolved. {} via semantic drivers.",
            resolved_count
        );
        println!("Run `suture commit` to finalize the merge.");
    } else {
        println!("Edit the file(s), then run `suture commit` to resolve");
        println!(
            "Hint: resolve conflicts, then run `suture commit`"
        );
    }

    Ok(())
}

fn indent(s: &str, prefix: &str) -> String {
    s.lines()
        .map(|line| format!("{}{}", prefix, line))
        .collect::<Vec<_>>()
        .join("\n")
}