specdiff 0.16.5

Show test outline changes on a branch
Documentation
use crate::vcs::Vcs;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

pub struct GitVcs {
    repo: git2::Repository,
}

impl GitVcs {
    pub fn open(path: &Path) -> Result<Self> {
        let repo = git2::Repository::discover(path)
            .with_context(|| format!("no git repository found at {}", path.display()))?;
        Ok(Self { repo })
    }

    fn resolve_rev(&self, rev: &str) -> Result<git2::Oid> {
        let obj = self.repo.revparse_single(rev)
            .with_context(|| format!("cannot resolve revision '{rev}'"))?;
        Ok(obj.peel_to_commit()
            .with_context(|| format!("'{rev}' does not point to a commit"))?
            .id())
    }

    fn tree_for_commit(&self, oid: git2::Oid) -> Result<git2::Tree<'_>> {
        let commit = self.repo.find_commit(oid)?;
        Ok(commit.tree()?)
    }

    fn blob_content(&self, tree: &git2::Tree<'_>, path: &Path) -> Result<String> {
        let entry = tree.get_path(path)
            .with_context(|| format!("file {} not found in tree", path.display()))?;
        let blob = self.repo.find_blob(entry.id())
            .with_context(|| format!("cannot read blob for {}", path.display()))?;
        let content = std::str::from_utf8(blob.content())
            .with_context(|| format!("{} is not valid UTF-8", path.display()))?;
        Ok(content.to_string())
    }
}

impl Vcs for GitVcs {
    fn changed_files(&self, base: &str, head: &str) -> Result<Vec<PathBuf>> {
        let base_oid = self.resolve_rev(base)?;
        let head_oid = self.resolve_rev(head)?;

        let base_tree = self.tree_for_commit(base_oid)?;
        let head_tree = self.tree_for_commit(head_oid)?;

        let diff = self.repo.diff_tree_to_tree(
            Some(&base_tree),
            Some(&head_tree),
            None,
        )?;

        let mut files = Vec::new();
        diff.foreach(
            &mut |delta, _| {
                if let Some(path) = delta.new_file().path().or_else(|| delta.old_file().path()) {
                    files.push(path.to_path_buf());
                }
                true
            },
            None,
            None,
            None,
        )?;

        files.sort();
        files.dedup();
        Ok(files)
    }

    fn file_at_revision(&self, path: &Path, rev: &str) -> Result<String> {
        if rev == "WORKDIR" {
            let workdir = self.repo.workdir()
                .context("bare repository has no working directory")?;
            let full_path = workdir.join(path);
            return std::fs::read_to_string(&full_path)
                .with_context(|| format!("cannot read {}", full_path.display()));
        }

        let oid = self.resolve_rev(rev)?;
        let tree = self.tree_for_commit(oid)?;
        self.blob_content(&tree, path)
    }

    fn merge_base(&self, a: &str, b: &str) -> Result<String> {
        let a_oid = self.resolve_rev(a)?;
        let b_oid = self.resolve_rev(b)?;
        let base = self.repo.merge_base(a_oid, b_oid)
            .with_context(|| format!("no merge base between '{a}' and '{b}'"))?;
        Ok(base.to_string())
    }

    fn current_branch(&self) -> Result<Option<String>> {
        let head = self.repo.head().context("HEAD is unborn")?;
        if head.is_branch() {
            return Ok(head.shorthand().map(|n| n.to_string()));
        }
        Ok(None)
    }

    fn files_matching(&self, pattern: &str) -> Result<Vec<PathBuf>> {
        let head = self.repo.head()?;
        let tree = head.peel_to_tree()?;

        let pat = glob::Pattern::new(pattern)
            .with_context(|| format!("invalid glob pattern: {pattern}"))?;

        let mut files = Vec::new();
        tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
            if let Some(name) = entry.name() {
                let full_path = if dir.is_empty() {
                    name.to_string()
                } else {
                    format!("{dir}{name}")
                };
                if pat.matches(&full_path) {
                    files.push(PathBuf::from(full_path));
                }
            }
            git2::TreeWalkResult::Ok
        })?;

        Ok(files)
    }

    fn default_base_rev(&self) -> String {
        for candidate in ["main", "master"] {
            if self.resolve_rev(candidate).is_ok() {
                return candidate.to_string();
            }
        }
        "HEAD~1".to_string()
    }

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

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

    fn create_test_repo() -> (TempDir, GitVcs) {
        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 email");

        Command::new("git")
            .args(["config", "user.name", "Test"])
            .current_dir(path)
            .output()
            .expect("git config name");

        std::fs::create_dir_all(path.join("spec/models")).expect("mkdir");
        std::fs::write(
            path.join("spec/models/user_spec.rb"),
            "RSpec.describe User do\n  it \"exists\" do\n  end\nend\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("git")
            .args(["checkout", "-b", "feature"])
            .current_dir(path)
            .output()
            .expect("git checkout");

        std::fs::write(
            path.join("spec/models/user_spec.rb"),
            "RSpec.describe User do\n  it \"exists\" do\n  end\n  it \"validates\" do\n  end\nend\n",
        ).expect("write modified");

        Command::new("git")
            .args(["add", "-A"])
            .current_dir(path)
            .output()
            .expect("git add");

        Command::new("git")
            .args(["commit", "-m", "add validation"])
            .current_dir(path)
            .output()
            .expect("git commit");

        let vcs = GitVcs::open(path).expect("open");
        (dir, vcs)
    }

    #[test]
    fn git_changed_files() {
        let (_dir, vcs) = create_test_repo();
        let files = vcs.changed_files("main", "feature").expect("changed_files");
        assert_eq!(files.len(), 1);
        assert_eq!(files[0], PathBuf::from("spec/models/user_spec.rb"));
    }

    #[test]
    fn git_file_at_revision() {
        let (_dir, vcs) = create_test_repo();
        let content = vcs.file_at_revision(Path::new("spec/models/user_spec.rb"), "main").expect("file_at_revision");
        assert!(content.contains("exists"));
        assert!(!content.contains("validates"));

        let content = vcs.file_at_revision(Path::new("spec/models/user_spec.rb"), "feature").expect("file_at_revision");
        assert!(content.contains("validates"));
    }

    #[test]
    fn git_merge_base() {
        let (_dir, vcs) = create_test_repo();
        let base = vcs.merge_base("main", "feature").expect("merge_base");
        assert!(!base.is_empty());
    }

    #[test]
    fn git_current_branch() {
        let (_dir, vcs) = create_test_repo();
        let branch = vcs.current_branch().expect("current_branch");
        assert_eq!(branch.as_deref(), Some("feature"));
    }

    #[test]
    fn git_file_at_nonexistent_revision_errors() {
        let (_dir, vcs) = create_test_repo();
        let result = vcs.file_at_revision(Path::new("spec/models/user_spec.rb"), "nonexistent");
        assert!(result.is_err());
    }

    #[test]
    fn git_file_not_in_tree_errors() {
        let (_dir, vcs) = create_test_repo();
        let result = vcs.file_at_revision(Path::new("nonexistent.rb"), "main");
        assert!(result.is_err());
    }

    #[test]
    fn git_no_changes_after_merge_base_advances() {
        let (dir, _) = create_test_repo();

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

        Command::new("git")
            .args(["merge", "feature", "--no-edit"])
            .current_dir(dir.path())
            .output()
            .expect("merge feature into main");

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

        let vcs = GitVcs::open(dir.path()).expect("reopen");
        let merge_base = vcs.merge_base("main", "feature").expect("merge_base");
        let files = vcs.changed_files(&merge_base, "feature").expect("changed_files");
        assert!(files.is_empty(), "after main catches up to feature, no files should be changed");
    }
}