ccd-cli 1.0.0-beta.2

Bootstrap and validate Continuous Context Development repositories
// Git path helpers are shared across commands, bootstrap flows, and tests, so
// some helpers only go live in specific call paths.

use std::path::{Path, PathBuf};
use std::process::Command;

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

pub fn ccd_dir(repo_root: &Path) -> Result<PathBuf> {
    git_path(repo_root, "ccd")
}

pub fn git_path(repo_root: &Path, suffix: &str) -> Result<PathBuf> {
    debug!(args = ?["rev-parse", "--git-path", suffix], dir = %repo_root.display(), "spawning git");
    let output = Command::new("git")
        .args(["rev-parse", "--git-path", suffix])
        .current_dir(repo_root)
        .output()
        .with_context(|| {
            format!(
                "failed to run `git rev-parse --git-path {suffix}` in {}",
                repo_root.display()
            )
        })?;
    debug!(success = output.status.success(), "git completed");

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let detail = stderr.trim();
        if detail.is_empty() {
            bail!(
                "`git rev-parse --git-path {suffix}` failed in {}",
                repo_root.display()
            );
        }

        bail!(
            "`git rev-parse --git-path {suffix}` failed in {}: {detail}",
            repo_root.display()
        );
    }

    let stdout = String::from_utf8(output.stdout)
        .with_context(|| format!("git returned a non-utf8 path for `{suffix}`"))?;
    let resolved = stdout.trim();
    if resolved.is_empty() {
        bail!(
            "`git rev-parse --git-path {suffix}` returned an empty path in {}",
            repo_root.display()
        );
    }

    let path = PathBuf::from(resolved);
    if path.is_absolute() {
        Ok(path)
    } else {
        Ok(repo_root.join(path))
    }
}

pub fn is_git_work_tree(path: &Path) -> bool {
    debug!(args = ?["rev-parse", "--is-inside-work-tree"], dir = %path.display(), "spawning git");
    let result = Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .current_dir(path)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    debug!(success = result, "git completed");
    result
}

pub fn worktree_add(
    repo_root: &Path,
    worktree_path: &Path,
    branch: &str,
    start_point: Option<&str>,
) -> Result<()> {
    let mut command = Command::new("git");
    command
        .arg("worktree")
        .arg("add")
        .arg("-b")
        .arg(branch)
        .arg(worktree_path)
        .current_dir(repo_root);
    if let Some(start_point) = start_point {
        command.arg(start_point);
    }

    debug!(args = ?["worktree", "add", "-b", branch], dir = %repo_root.display(), "spawning git");
    let output = command.output().with_context(|| {
        format!(
            "failed to run `git worktree add -b {branch} {}` in {}",
            worktree_path.display(),
            repo_root.display()
        )
    })?;
    debug!(success = output.status.success(), "git completed");

    if output.status.success() {
        return Ok(());
    }

    let stderr = String::from_utf8_lossy(&output.stderr);
    let detail = stderr.trim();
    if detail.is_empty() {
        bail!(
            "`git worktree add -b {branch} {}` failed in {}",
            worktree_path.display(),
            repo_root.display()
        );
    }

    bail!(
        "`git worktree add -b {branch} {}` failed in {}: {detail}",
        worktree_path.display(),
        repo_root.display()
    )
}

#[cfg(test)]
mod tests {
    use std::process::Command;

    use tempfile::tempdir;

    use super::*;

    #[test]
    fn ccd_dir_uses_git_rev_parse_output() {
        let temp = tempdir().expect("tempdir");
        init_git_repo(temp.path());

        let path = ccd_dir(temp.path()).expect("git path");

        assert_eq!(path, temp.path().join(".git/ccd"));
    }

    #[test]
    fn ccd_dir_fails_closed_outside_git_repo() {
        let temp = tempdir().expect("tempdir");

        let error = ccd_dir(temp.path()).expect_err("git path should fail");

        assert!(error.to_string().contains("git rev-parse --git-path ccd"));
    }

    fn init_git_repo(path: &Path) {
        Command::new("git")
            .args(["init", "-b", "main"])
            .current_dir(path)
            .status()
            .expect("git init");
    }
}