ccd-cli 1.0.0-alpha.2

Bootstrap and validate Continuous Context Development repositories
#![allow(dead_code)]

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 head_short(repo_root: &Path) -> Result<Option<String>> {
    debug!(args = ?["rev-parse", "--short", "HEAD"], dir = %repo_root.display(), "spawning git");
    let output = Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .current_dir(repo_root)
        .output()
        .with_context(|| {
            format!(
                "failed to run `git rev-parse --short HEAD` in {}",
                repo_root.display()
            )
        })?;
    debug!(success = output.status.success(), "git completed");

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

    let stdout = String::from_utf8(output.stdout)
        .context("git returned a non-utf8 HEAD while resolving current commit")?;
    let head = stdout.trim();
    if head.is_empty() {
        Ok(None)
    } else {
        Ok(Some(head.to_owned()))
    }
}

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()
    )
}

/// Returns `Some("owner/repo")` if the `origin` remote points to a GitHub URL.
/// Returns `None` if the remote is missing, not GitHub, or git fails.
pub fn github_remote_owner_repo(repo_root: &Path) -> Option<String> {
    origin_remote_url(repo_root)
        .ok()
        .flatten()
        .and_then(|url| parse_github_owner_repo(&url))
}

pub fn origin_remote_url(repo_root: &Path) -> Result<Option<String>> {
    debug!(args = ?["remote", "get-url", "origin"], dir = %repo_root.display(), "spawning git");
    let output = Command::new("git")
        .args(["remote", "get-url", "origin"])
        .current_dir(repo_root)
        .output()
        .with_context(|| {
            format!(
                "failed to run `git remote get-url origin` in {}",
                repo_root.display()
            )
        })?;
    debug!(success = output.status.success(), "git completed");
    if !output.status.success() {
        return Ok(None);
    }

    let url = String::from_utf8(output.stdout)
        .context("git returned a non-utf8 origin remote while resolving GitHub repo")?;
    let url = url.trim();
    if url.is_empty() {
        Ok(None)
    } else {
        Ok(Some(url.to_owned()))
    }
}

pub fn parse_github_owner_repo(url: &str) -> Option<String> {
    // SSH: git@github.com:owner/repo.git
    if let Some(rest) = url.strip_prefix("git@github.com:") {
        return normalize_github_owner_repo(rest);
    }

    // SSH transport: ssh://git@github.com/owner/repo.git
    if let Some(rest) = url.strip_prefix("ssh://git@github.com/") {
        return normalize_github_owner_repo(rest);
    }

    // HTTPS: https://github.com/owner/repo.git
    if let Some(rest) = url
        .strip_prefix("https://github.com/")
        .or_else(|| url.strip_prefix("http://github.com/"))
    {
        return normalize_github_owner_repo(rest);
    }

    // Git transport: git://github.com/owner/repo.git
    if let Some(rest) = url.strip_prefix("git://github.com/") {
        return normalize_github_owner_repo(rest);
    }

    None
}

fn normalize_github_owner_repo(path: &str) -> Option<String> {
    let path = path.trim_matches('/');
    let path = path.strip_suffix(".git").unwrap_or(path);
    let mut parts = path.split('/');
    let owner = parts.next()?;
    let repo = parts.next()?;
    if owner.is_empty() || repo.is_empty() || parts.next().is_some() {
        return None;
    }

    Some(format!("{owner}/{repo}"))
}

#[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");
    }

    #[test]
    fn parse_github_ssh_url() {
        assert_eq!(
            parse_github_owner_repo("git@github.com:dusk-network/ccd.git"),
            Some("dusk-network/ccd".to_owned())
        );
    }

    #[test]
    fn parse_github_ssh_url_no_suffix() {
        assert_eq!(
            parse_github_owner_repo("git@github.com:owner/repo"),
            Some("owner/repo".to_owned())
        );
    }

    #[test]
    fn parse_github_https_url() {
        assert_eq!(
            parse_github_owner_repo("https://github.com/dusk-network/ccd.git"),
            Some("dusk-network/ccd".to_owned())
        );
    }

    #[test]
    fn parse_github_https_url_no_suffix() {
        assert_eq!(
            parse_github_owner_repo("https://github.com/owner/repo"),
            Some("owner/repo".to_owned())
        );
    }

    #[test]
    fn parse_github_ssh_transport_url() {
        assert_eq!(
            parse_github_owner_repo("ssh://git@github.com/owner/repo.git"),
            Some("owner/repo".to_owned())
        );
    }

    #[test]
    fn parse_github_url_rejects_ambiguous_paths() {
        assert_eq!(
            parse_github_owner_repo("https://github.com/owner/repo/issues"),
            None
        );
    }

    #[test]
    fn parse_non_github_url_returns_none() {
        assert_eq!(
            parse_github_owner_repo("git@gitlab.com:owner/repo.git"),
            None
        );
    }
}