rft-cli 0.4.0

Zero-config Docker Compose isolation for git worktrees
use std::path::{Path, PathBuf};

use tokio::process::Command;

use crate::error::{Result, RftError};

#[derive(Debug, Clone)]
pub struct WorktreeInfo {
    pub path: PathBuf,
    pub branch: String,
    pub is_main: bool,
    pub index: usize,
}

pub async fn get_repo_root(cwd: &Path) -> Result<PathBuf> {
    let output = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .current_dir(cwd)
        .output()
        .await?;

    if !output.status.success() {
        return Err(RftError::NotAGitRepo);
    }

    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok(PathBuf::from(root))
}

pub fn get_repo_name(repo_root: &Path) -> String {
    repo_root
        .file_name()
        .map(|name| name.to_string_lossy().into_owned())
        .unwrap_or_else(|| "unknown".to_string())
}

pub async fn get_worktrees(
    repo_root: &Path,
    main_branch: Option<&str>,
) -> Result<Vec<WorktreeInfo>> {
    let output = Command::new("git")
        .args(["worktree", "list", "--porcelain"])
        .current_dir(repo_root)
        .output()
        .await?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        return Err(RftError::CommandFailed {
            cmd: "git worktree list --porcelain".to_string(),
            stderr,
        });
    }

    let raw = String::from_utf8_lossy(&output.stdout);
    parse_porcelain_output(&raw, main_branch)
}

pub async fn get_worktree_by_index(
    repo_root: &Path,
    index: usize,
    main_branch: Option<&str>,
) -> Result<WorktreeInfo> {
    let worktrees = get_worktrees(repo_root, main_branch).await?;
    worktrees
        .into_iter()
        .find(|wt| !wt.is_main && wt.index == index)
        .ok_or(RftError::WorktreeNotFound { index })
}

const MAIN_BRANCH_NAMES: &[&str] = &["main", "master"];

fn parse_porcelain_output(raw: &str, main_branch: Option<&str>) -> Result<Vec<WorktreeInfo>> {
    let mut worktrees = Vec::new();
    let blocks = raw.split("\n\n").filter(|block| !block.trim().is_empty());
    let mut non_main_counter = 0usize;

    for block in blocks {
        let mut path: Option<PathBuf> = None;
        let mut branch = String::from("detached");
        let mut is_bare = false;

        for line in block.lines() {
            if let Some(worktree_path) = line.strip_prefix("worktree ") {
                path = Some(PathBuf::from(worktree_path));
            } else if let Some(branch_ref) = line.strip_prefix("branch ") {
                branch = branch_ref
                    .strip_prefix("refs/heads/")
                    .unwrap_or(branch_ref)
                    .to_string();
            } else if line.trim() == "bare" {
                is_bare = true;
            }
        }

        if is_bare {
            continue;
        }

        let is_main = match main_branch {
            Some(name) => branch == name,
            None => MAIN_BRANCH_NAMES.contains(&branch.as_str()),
        };

        if let Some(path) = path {
            let index = if is_main {
                0
            } else {
                non_main_counter += 1;
                non_main_counter
            };

            worktrees.push(WorktreeInfo {
                path,
                branch,
                is_main,
                index,
            });
        }
    }

    Ok(worktrees)
}

#[cfg(test)]
mod tests {
    use super::*;

    const PORCELAIN_TWO_WORKTREES: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main

worktree /home/user/project-feature
HEAD 789def012abc
branch refs/heads/feature/login
";

    const PORCELAIN_WITH_DETACHED: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main

worktree /home/user/project-detached
HEAD 789def012abc
detached
";

    const PORCELAIN_SINGLE: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/main
";

    const PORCELAIN_BARE_REPO: &str = "\
worktree /home/user/project/.bare
bare

worktree /home/user/project/main
HEAD abc123def456
branch refs/heads/main

worktree /home/user/project/feature-auth
HEAD 789def012abc
branch refs/heads/feature/auth
";

    const PORCELAIN_MASTER_BRANCH: &str = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/master

worktree /home/user/project-feature
HEAD 789def012abc
branch refs/heads/feature/login
";

    #[test]
    fn parse_two_worktrees() {
        let worktrees = parse_porcelain_output(PORCELAIN_TWO_WORKTREES, None).unwrap();

        assert_eq!(worktrees.len(), 2);

        assert_eq!(worktrees[0].path, PathBuf::from("/home/user/project"));
        assert_eq!(worktrees[0].branch, "main");
        assert!(worktrees[0].is_main);
        assert_eq!(worktrees[0].index, 0);

        assert_eq!(
            worktrees[1].path,
            PathBuf::from("/home/user/project-feature")
        );
        assert_eq!(worktrees[1].branch, "feature/login");
        assert!(!worktrees[1].is_main);
        assert_eq!(
            worktrees[1].index, 1,
            "first non-main worktree should be 1-indexed"
        );
    }

    #[test]
    fn parse_detached_head() {
        let worktrees = parse_porcelain_output(PORCELAIN_WITH_DETACHED, None).unwrap();

        assert_eq!(worktrees.len(), 2);
        assert_eq!(worktrees[1].branch, "detached");
        assert!(!worktrees[1].is_main);
        assert_eq!(
            worktrees[1].index, 1,
            "detached non-main worktree is 1-indexed"
        );
    }

    #[test]
    fn parse_single_worktree() {
        let worktrees = parse_porcelain_output(PORCELAIN_SINGLE, None).unwrap();

        assert_eq!(worktrees.len(), 1);
        assert!(worktrees[0].is_main);
        assert_eq!(worktrees[0].branch, "main");
    }

    #[test]
    fn parse_empty_output() {
        let worktrees = parse_porcelain_output("", None).unwrap();
        assert!(worktrees.is_empty());
    }

    #[test]
    fn parse_bare_repo_skips_bare_entry() {
        let worktrees = parse_porcelain_output(PORCELAIN_BARE_REPO, None).unwrap();

        assert_eq!(worktrees.len(), 2, "bare entry should be skipped");
        assert_eq!(worktrees[0].branch, "main");
        assert!(worktrees[0].is_main);
        assert_eq!(worktrees[0].index, 0);

        assert_eq!(worktrees[1].branch, "feature/auth");
        assert!(!worktrees[1].is_main);
        assert_eq!(worktrees[1].index, 1);
    }

    #[test]
    fn parse_master_branch_is_main() {
        let worktrees = parse_porcelain_output(PORCELAIN_MASTER_BRANCH, None).unwrap();

        assert_eq!(worktrees.len(), 2);
        assert!(worktrees[0].is_main, "master should be detected as main");
        assert_eq!(worktrees[0].index, 0);
        assert!(!worktrees[1].is_main);
        assert_eq!(worktrees[1].index, 1);
    }

    #[test]
    fn custom_main_branch_from_config() {
        let porcelain = "\
worktree /home/user/project
HEAD abc123def456
branch refs/heads/develop

worktree /home/user/project-feature
HEAD 789def012abc
branch refs/heads/feature/login
";
        let worktrees = parse_porcelain_output(porcelain, Some("develop")).unwrap();

        assert_eq!(worktrees.len(), 2);
        assert!(
            worktrees[0].is_main,
            "develop should be main when configured"
        );
        assert_eq!(worktrees[0].index, 0);
        assert!(!worktrees[1].is_main);
    }

    #[test]
    fn custom_main_branch_does_not_match_default() {
        let worktrees = parse_porcelain_output(PORCELAIN_TWO_WORKTREES, Some("develop")).unwrap();

        assert!(
            !worktrees[0].is_main,
            "main branch should not match when config says develop"
        );
    }

    #[test]
    fn repo_name_from_path() {
        assert_eq!(
            get_repo_name(Path::new("/home/user/my-project")),
            "my-project"
        );
        assert_eq!(get_repo_name(Path::new("/")), "unknown");
    }
}