git-bra 0.4.0

A Git worktree manager with project-aware configuration.
Documentation
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};

use anyhow::{Context, Result, anyhow, bail};

#[derive(Debug, Clone)]
pub struct WorktreeInfo {
    pub path: PathBuf,
    pub branch: Option<String>,
    pub is_current: bool,
}

pub(crate) fn absolute_git_dir(cwd: &Path) -> Result<PathBuf> {
    let output = git_capture(cwd, ["rev-parse", "--absolute-git-dir"])?;
    Ok(PathBuf::from(output.trim()))
}

pub fn repo_root(cwd: &Path) -> Result<PathBuf> {
    let output = git_capture(cwd, ["rev-parse", "--show-toplevel"])?;
    Ok(PathBuf::from(output.trim()))
}

pub fn current_branch(cwd: &Path) -> Result<String> {
    let output = git_capture(cwd, ["branch", "--show-current"])?;
    let branch = output.trim();
    if branch.is_empty() {
        bail!("repository is in detached HEAD state")
    }
    Ok(branch.to_owned())
}

pub fn origin_url(cwd: &Path) -> Result<String> {
    let output = git_capture(cwd, ["remote", "get-url", "origin"])?;
    Ok(output.trim().to_owned())
}

pub fn is_clean(cwd: &Path) -> Result<bool> {
    let output = git_capture(cwd, ["status", "--porcelain"])?;
    Ok(output.trim().is_empty())
}

pub fn fetch_origin(cwd: &Path) -> Result<()> {
    git_status(cwd, ["fetch", "origin"]).map(|_| ())
}

pub fn has_origin(cwd: &Path) -> Result<bool> {
    git_check(cwd, ["remote", "get-url", "origin"])
}

pub fn hard_reset_to_origin(cwd: &Path, branch: &str) -> Result<()> {
    git_status(cwd, ["reset", "--hard", &format!("origin/{branch}")]).map(|_| ())
}

pub fn list_worktrees(cwd: &Path) -> Result<Vec<WorktreeInfo>> {
    let output = git_capture(cwd, ["worktree", "list", "--porcelain"])?;
    let root = repo_root(cwd)?;
    let current = canonical_or_original(&root);
    let mut worktrees = Vec::new();
    let mut current_path: Option<PathBuf> = None;
    let mut current_branch_name: Option<String> = None;

    for line in output.lines() {
        if let Some(path) = line.strip_prefix("worktree ") {
            if let Some(path) = current_path.take() {
                worktrees.push(WorktreeInfo {
                    is_current: canonical_or_original(&path) == current,
                    path,
                    branch: current_branch_name.take(),
                });
            }
            current_path = Some(PathBuf::from(path));
            current_branch_name = None;
            continue;
        }

        if let Some(branch) = line.strip_prefix("branch refs/heads/") {
            current_branch_name = Some(branch.to_owned());
        }
    }

    if let Some(path) = current_path {
        worktrees.push(WorktreeInfo {
            is_current: canonical_or_original(&path) == current,
            path,
            branch: current_branch_name,
        });
    }

    Ok(worktrees)
}

pub fn local_branch_exists(cwd: &Path, branch: &str) -> Result<bool> {
    git_check(
        cwd,
        [
            "show-ref",
            "--verify",
            "--quiet",
            &format!("refs/heads/{branch}"),
        ],
    )
}

pub fn remote_branch_exists(cwd: &Path, branch: &str) -> Result<bool> {
    git_check(
        cwd,
        [
            "show-ref",
            "--verify",
            "--quiet",
            &format!("refs/remotes/origin/{branch}"),
        ],
    )
}

pub fn add_worktree(cwd: &Path, target: &Path, branch: &str) -> Result<()> {
    let target_str = target_to_str(target)?;

    if local_branch_exists(cwd, branch)? {
        git_status(cwd, ["worktree", "add", target_str, branch]).map(|_| ())
    } else if remote_branch_exists(cwd, branch)? {
        git_status(
            cwd,
            [
                "worktree",
                "add",
                "--track",
                "-b",
                branch,
                target_str,
                &format!("origin/{branch}"),
            ],
        )
        .map(|_| ())
    } else {
        git_status(cwd, ["worktree", "add", "-b", branch, target_str, "HEAD"]).map(|_| ())
    }
}

pub fn add_worktree_from(cwd: &Path, target: &Path, branch: &str, base: &str) -> Result<()> {
    let target_str = target_to_str(target)?;

    if local_branch_exists(cwd, branch)? {
        git_status(cwd, ["worktree", "add", target_str, branch]).map(|_| ())
    } else {
        git_status(cwd, ["worktree", "add", "-b", branch, target_str, base]).map(|_| ())
    }
}

pub fn remove_worktree(cwd: &Path, target: &Path) -> Result<()> {
    let target_str = target_to_str(target)?;
    git_status(cwd, ["worktree", "remove", "--force", target_str]).map(|_| ())
}

pub fn prune_worktrees(cwd: &Path) -> Result<()> {
    git_status(cwd, ["worktree", "prune"]).map(|_| ())
}

pub fn delete_local_branch(cwd: &Path, branch: &str) -> Result<()> {
    git_status(cwd, ["branch", "-d", branch]).map(|_| ())
}

pub fn merged_local_branches(cwd: &Path) -> Result<Vec<String>> {
    let output = git_capture(cwd, ["branch", "--format=%(refname:short)", "--merged"])?;
    Ok(output
        .lines()
        .map(str::trim)
        .filter(|branch| !branch.is_empty())
        .map(ToOwned::to_owned)
        .collect())
}

pub fn run_script(cwd: &Path, script: &Path, args: &[String]) -> Result<()> {
    let status = Command::new(script)
        .args(args)
        .current_dir(cwd)
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .with_context(|| format!("failed to execute script {}", script.display()))?;
    if !status.success() {
        bail!("script {} exited with status {status}", script.display())
    }
    Ok(())
}

pub fn run_script_text(cwd: &Path, text: &str, args: &[String], name: &str) -> Result<()> {
    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned());
    let mut command = Command::new(&shell);
    command
        .arg("-c")
        .arg(text)
        .arg(name)
        .args(args)
        .current_dir(cwd)
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());
    let status = command
        .status()
        .with_context(|| format!("failed to execute script '{name}' via {shell}"))?;
    if !status.success() {
        bail!("script '{name}' exited with status {status}")
    }
    Ok(())
}

fn git_capture<I, S>(cwd: &Path, args: I) -> Result<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let output = git_status(cwd, args)?;
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn git_check<I, S>(cwd: &Path, args: I) -> Result<bool>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let output = git_raw(cwd, args)?;
    Ok(output.status.success())
}

fn git_status<I, S>(cwd: &Path, args: I) -> Result<Output>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let output = git_raw(cwd, args)?;
    if output.status.success() {
        return Ok(output);
    }

    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
    let message = if !stderr.is_empty() { stderr } else { stdout };
    Err(anyhow!(message))
}

fn git_raw<I, S>(cwd: &Path, args: I) -> Result<Output>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let args_vec: Vec<String> = args
        .into_iter()
        .map(|arg| arg.as_ref().to_owned())
        .collect();
    Command::new("git")
        .args(&args_vec)
        .current_dir(cwd)
        .output()
        .with_context(|| format!("failed to run git {}", args_vec.join(" ")))
}

fn canonical_or_original(path: &Path) -> PathBuf {
    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

pub(crate) fn target_to_str(target: &Path) -> Result<&str> {
    target
        .to_str()
        .ok_or_else(|| anyhow!("worktree path is not valid UTF-8: {}", target.display()))
}