stax 0.29.4

Fast stacked Git branches and PRs
Documentation
use crate::git::GitRepo;
use colored::Colorize;

pub(crate) struct RestackConflictContext<'a> {
    pub branch: &'a str,
    pub parent_branch: &'a str,
    pub completed_branches: &'a [String],
    pub remaining_branches: usize,
    pub continue_commands: &'a [&'a str],
}

pub(crate) fn print_restack_conflict(repo: &GitRepo, context: &RestackConflictContext<'_>) {
    let conflicted_files = repo.conflicted_files().unwrap_or_default();

    println!();
    println!("{}", "Restack stopped on conflict:".yellow());
    for line in render_restack_conflict_details(context, &conflicted_files) {
        println!("{}", line);
    }
    println!();
    println!("{}", "Resolve conflicts and run:".yellow());
    for command in context.continue_commands {
        println!("  {}", command.cyan());
    }
}

fn render_restack_conflict_details(
    context: &RestackConflictContext<'_>,
    conflicted_files: &[String],
) -> Vec<String> {
    let completed_count = context.completed_branches.len();

    let mut lines = vec![
        format!("  Stopped at: {}", context.branch),
        format!("  Parent: {}", context.parent_branch),
        format!(
            "  Progress: {} rebased before conflict, {} remaining in stack",
            branch_count_label(completed_count),
            branch_count_label(context.remaining_branches)
        ),
    ];

    if !context.completed_branches.is_empty() {
        lines.push(format!(
            "  Completed: {}",
            context.completed_branches.join(", ")
        ));
    }

    if conflicted_files.is_empty() {
        lines.push("  Conflicted files: (not detected; run `git status`)".to_string());
    } else {
        lines.push("  Conflicted files:".to_string());
        for path in conflicted_files {
            lines.push(format!("    {}", path));
        }
    }

    lines
}

fn branch_count_label(count: usize) -> String {
    format!(
        "{} {}",
        count,
        if count == 1 { "branch" } else { "branches" }
    )
}

#[cfg(test)]
mod tests {
    use super::{render_restack_conflict_details, RestackConflictContext};

    #[test]
    fn renders_progress_completed_branches_and_conflicted_files() {
        let completed = vec!["feature-a".to_string(), "feature-b".to_string()];
        let conflicted_files = vec!["src/lib.rs".to_string(), "README.md".to_string()];
        let context = RestackConflictContext {
            branch: "feature-c",
            parent_branch: "feature-b",
            completed_branches: &completed,
            remaining_branches: 1,
            continue_commands: &["stax continue"],
        };

        let lines = render_restack_conflict_details(&context, &conflicted_files);

        assert_eq!(lines[0], "  Stopped at: feature-c");
        assert_eq!(lines[1], "  Parent: feature-b");
        assert_eq!(
            lines[2],
            "  Progress: 2 branches rebased before conflict, 1 branch remaining in stack"
        );
        assert_eq!(lines[3], "  Completed: feature-a, feature-b");
        assert_eq!(lines[4], "  Conflicted files:");
        assert_eq!(lines[5], "    src/lib.rs");
        assert_eq!(lines[6], "    README.md");
    }

    #[test]
    fn renders_git_status_hint_when_conflicted_files_are_unknown() {
        let completed = Vec::new();
        let context = RestackConflictContext {
            branch: "feature-a",
            parent_branch: "main",
            completed_branches: &completed,
            remaining_branches: 0,
            continue_commands: &["stax continue"],
        };

        let lines = render_restack_conflict_details(&context, &[]);

        assert_eq!(lines[0], "  Stopped at: feature-a");
        assert_eq!(lines[1], "  Parent: main");
        assert_eq!(
            lines[2],
            "  Progress: 0 branches rebased before conflict, 0 branches remaining in stack"
        );
        assert_eq!(
            lines[3],
            "  Conflicted files: (not detected; run `git status`)"
        );
    }
}