specdiff 0.19.0

Show test outline changes on a branch
Documentation
use crate::vcs::Vcs;
use anyhow::{Context, Result};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use std::time::Duration;
use vcs_runner::{
    is_transient_error, jj_merge_base, parse_diff_summary, parse_log_output, run_jj,
    run_jj_with_retry, run_jj_with_timeout, LOG_TEMPLATE, RunError,
};

const JJ_TIMEOUT: Duration = Duration::from_secs(30);

pub struct JjVcs {
    root: PathBuf,
}

impl JjVcs {
    pub fn new(root: PathBuf) -> Self {
        Self { root }
    }

    pub fn open(path: &Path) -> Result<Self> {
        let jj_dir = path.join(".jj");
        if !jj_dir.exists() {
            anyhow::bail!("no jj repository found at {}", path.display());
        }
        Ok(Self { root: path.to_path_buf() })
    }
}

impl Vcs for JjVcs {
    fn changed_files(&self, base: &str, head: &str) -> Result<Vec<PathBuf>> {
        let output = run_jj_with_retry(
            &self.root,
            &["diff", "--from", base, "--to", head, "--summary"],
            is_transient_error,
        )
        .with_context(|| format!("jj diff --from {base} --to {head} --summary"))?;

        Ok(parse_diff_summary(&output.stdout_lossy())
            .into_iter()
            .map(|change| change.path)
            .collect())
    }

    fn file_at_revision(&self, path: &Path, rev: &str) -> Result<String> {
        if rev == "WORKDIR" || rev == "@" {
            let full_path = self.root.join(path);
            return std::fs::read_to_string(&full_path)
                .with_context(|| format!("cannot read {}", full_path.display()));
        }

        let path_str = path.to_string_lossy();
        match run_jj(&self.root, &["file", "show", "-r", rev, &path_str]) {
            Ok(output) => Ok(output.stdout_lossy().into_owned()),
            Err(e @ RunError::NonZeroExit { .. }) => {
                Err(anyhow::anyhow!("file {} not found at {rev}: {e}", path.display()))
            }
            Err(e) => Err(e.into()),
        }
    }

    fn merge_base(&self, a: &str, b: &str) -> Result<String> {
        jj_merge_base(&self.root, a, b)
            .with_context(|| format!("jj merge_base {a} {b}"))?
            .ok_or_else(|| anyhow::anyhow!("no common ancestor between '{a}' and '{b}'"))
    }

    fn current_branch(&self) -> Result<Option<String>> {
        let output = run_jj_with_timeout(
            &self.root,
            &["log", "-r", "@", "--no-graph", "--template", LOG_TEMPLATE],
            JJ_TIMEOUT,
        )
        .context("jj log -r @")?;

        let result = parse_log_output(&output.stdout_lossy());
        Ok(result
            .entries
            .into_iter()
            .next()
            .and_then(|entry| entry.local_bookmarks.into_iter().next()))
    }

    fn files_matching(&self, pattern: &str) -> Result<Vec<PathBuf>> {
        let output = run_jj(&self.root, &["file", "list"]).context("jj file list")?;
        let pat = glob::Pattern::new(pattern)
            .with_context(|| format!("invalid glob pattern: {pattern}"))?;

        Ok(output
            .stdout_lossy()
            .lines()
            .filter(|line| pat.matches(line.trim()))
            .map(|line| PathBuf::from(line.trim()))
            .collect())
    }

    fn default_base_rev(&self) -> String {
        for candidate in [
            "main@origin",
            "master@origin",
            "main@upstream",
            "master@upstream",
            "main",
            "master",
        ] {
            if run_jj(&self.root, &["log", "-r", candidate, "--no-graph", "--limit", "1"])
                .is_ok()
            {
                return candidate.to_string();
            }
        }
        "trunk()".to_string()
    }

    fn files_at_revision(&self, paths: &[PathBuf], rev: &str) -> Vec<(PathBuf, Option<String>)> {
        if paths.len() < 4 {
            return paths
                .iter()
                .map(|p| (p.clone(), self.file_at_revision(p, rev).ok()))
                .collect();
        }

        let root = &self.root;
        paths
            .par_iter()
            .map(|p| {
                let path_str = p.to_string_lossy();
                let content = if rev == "WORKDIR" || rev == "@" {
                    std::fs::read_to_string(root.join(p)).ok()
                } else {
                    run_jj(root, &["file", "show", "-r", rev, &path_str])
                        .ok()
                        .map(|o| o.stdout_lossy().into_owned())
                };
                (p.clone(), content)
            })
            .collect()
    }

    fn default_head_rev(&self) -> &str {
        "@"
    }
}

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

    fn create_test_repo() -> TempDir {
        let dir = TempDir::new().expect("tempdir");
        let path = dir.path();

        Command::new("git").args(["init", "-b", "main"]).current_dir(path).output().expect("git init");
        Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().expect("git config");
        Command::new("git").args(["config", "user.name", "Test"]).current_dir(path).output().expect("git config");
        std::fs::write(path.join("README.md"), "hi\n").expect("write");
        Command::new("git").args(["add", "-A"]).current_dir(path).output().expect("git add");
        Command::new("git").args(["commit", "-m", "initial"]).current_dir(path).output().expect("git commit");

        Command::new("jj").args(["git", "init", "--colocate"]).current_dir(path).output().expect("jj init");

        dir
    }

    #[test]
    #[ignore = "requires jj CLI; run with cargo test -- --ignored"]
    fn default_base_rev_prefers_main_at_origin_over_local_main() {
        let dir = create_test_repo();

        Command::new("git")
            .args(["update-ref", "refs/remotes/origin/main", "HEAD"])
            .current_dir(dir.path())
            .output()
            .expect("fake origin/main");

        Command::new("jj")
            .args(["git", "import"])
            .current_dir(dir.path())
            .output()
            .expect("jj git import");

        let vcs = JjVcs::new(dir.path().to_path_buf());
        assert_eq!(vcs.default_base_rev(), "main@origin");
    }

    #[test]
    #[ignore = "requires jj CLI; run with cargo test -- --ignored"]
    fn default_base_rev_falls_back_to_local_main_when_no_remote() {
        let dir = create_test_repo();
        let vcs = JjVcs::new(dir.path().to_path_buf());
        assert_eq!(vcs.default_base_rev(), "main");
    }

    #[test]
    #[ignore = "requires jj CLI; run with cargo test -- --ignored"]
    fn default_base_rev_prefers_origin_over_upstream() {
        let dir = create_test_repo();

        Command::new("git")
            .args(["update-ref", "refs/remotes/origin/main", "HEAD"])
            .current_dir(dir.path())
            .output()
            .expect("fake origin/main");

        Command::new("git")
            .args(["update-ref", "refs/remotes/upstream/main", "HEAD"])
            .current_dir(dir.path())
            .output()
            .expect("fake upstream/main");

        Command::new("jj")
            .args(["git", "import"])
            .current_dir(dir.path())
            .output()
            .expect("jj git import");

        let vcs = JjVcs::new(dir.path().to_path_buf());
        assert_eq!(vcs.default_base_rev(), "main@origin");
    }
}