travelagent-core 1.10.3

Core library for travelagent code review tool
Documentation
pub mod context;
pub mod diff;
pub mod repository;

use git2::Repository;
use std::path::{Path, PathBuf};

use crate::error::{Result, TrvError};
use crate::model::{DiffFile, DiffLine, FileStatus};

use super::traits::{CommitInfo, VcsBackend, VcsInfo, VcsType};

// Re-export commonly used functions
pub use context::{calculate_gap, fetch_context_lines};
pub use diff::{
    get_commit_range_diff, get_staged_diff, get_unstaged_diff, get_working_tree_diff,
    get_working_tree_with_commits_diff,
};

/// Walk up from `start` looking for a git repository root (a directory that
/// contains a `.git` entry or is itself a bare repo).
///
/// Returns the path to the git directory (i.e. the `.git` directory for
/// non-bare repos, or the repo root itself for bare repos).
fn find_git_dir(start: &Path) -> Option<PathBuf> {
    let mut current = Some(start);
    while let Some(dir) = current {
        let dot_git = dir.join(".git");
        if dot_git.exists() {
            return Some(dot_git);
        }
        // Bare repo heuristic: a directory that contains both `HEAD` and `objects`.
        if dir.join("HEAD").is_file() && dir.join("objects").is_dir() {
            return Some(dir.to_path_buf());
        }
        current = dir.parent();
    }
    None
}

/// Returns true if the given git directory uses reftable ref storage.
///
/// Detection is best-effort: we look for either a `reftable/` subdirectory or
/// an `extensions.refFormat = reftable` / `extensions.refStorage = reftable`
/// entry in the repo config.
fn is_reftable_git_dir(git_dir: &Path) -> bool {
    if git_dir.join("reftable").is_dir() {
        return true;
    }

    let config_path = git_dir.join("config");
    if let Ok(contents) = std::fs::read_to_string(&config_path) {
        for raw_line in contents.lines() {
            let line = raw_line.trim();
            if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
                continue;
            }
            let lower = line.to_ascii_lowercase();
            // Match e.g. `refformat = reftable` or `refstorage = reftable`
            // (case-insensitive; ignores whitespace around `=`).
            if let Some((key, value)) = lower.split_once('=') {
                let key = key.trim();
                let value = value.trim();
                if (key == "refformat" || key == "refstorage") && value == "reftable" {
                    return true;
                }
            }
        }
    }

    false
}

/// Classify a failed `Repository::discover`/`Repository::open` attempt.
///
/// If the nearest enclosing git directory uses reftable ref storage, returns
/// [`TrvError::ReftableRepository`]; otherwise returns [`TrvError::NotARepository`].
fn classify_discover_failure(start: &Path) -> TrvError {
    match find_git_dir(start) {
        Some(git_dir) if is_reftable_git_dir(&git_dir) => TrvError::ReftableRepository,
        _ => TrvError::NotARepository,
    }
}

/// Git backend implementation using git2 library
pub struct GitBackend {
    repo: Repository,
    info: VcsInfo,
}

impl GitBackend {
    /// Discover a git repository from the current directory
    pub fn discover() -> Result<Self> {
        let cwd = std::env::current_dir().map_err(|_| TrvError::NotARepository)?;
        let repo = Repository::discover(&cwd).map_err(|_| classify_discover_failure(&cwd))?;

        let root_path = repo
            .workdir()
            .ok_or(TrvError::NotARepository)?
            .to_path_buf();

        let head_commit = repo
            .head()
            .ok()
            .and_then(|h| h.peel_to_commit().ok())
            .map_or_else(|| "HEAD".to_string(), |c| c.id().to_string());

        let branch_name = repo.head().ok().and_then(|h| {
            if h.is_branch() {
                h.shorthand().map(std::string::ToString::to_string)
            } else {
                None
            }
        });

        let info = VcsInfo {
            root_path,
            head_commit,
            branch_name,
            vcs_type: VcsType::Git,
        };

        Ok(Self { repo, info })
    }
}

impl VcsBackend for GitBackend {
    fn info(&self) -> &VcsInfo {
        &self.info
    }

    fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
        get_working_tree_diff(&self.repo)
    }

    fn get_staged_diff(&self) -> Result<Vec<DiffFile>> {
        get_staged_diff(&self.repo)
    }

    fn get_unstaged_diff(&self) -> Result<Vec<DiffFile>> {
        get_unstaged_diff(&self.repo)
    }

    fn fetch_context_lines(
        &self,
        file_path: &Path,
        file_status: FileStatus,
        start_line: u32,
        end_line: u32,
    ) -> Result<Vec<DiffLine>> {
        fetch_context_lines(&self.repo, file_path, file_status, start_line, end_line)
    }

    fn get_recent_commits(&self, offset: usize, limit: usize) -> Result<Vec<CommitInfo>> {
        repository::get_recent_commits(&self.repo, offset, limit)
    }

    fn resolve_revisions(&self, revisions: &str) -> Result<Vec<String>> {
        repository::resolve_revisions(&self.repo, revisions)
    }

    fn get_commit_range_diff(&self, commit_ids: &[String]) -> Result<Vec<DiffFile>> {
        get_commit_range_diff(&self.repo, commit_ids)
    }

    fn get_commits_info(&self, ids: &[String]) -> Result<Vec<CommitInfo>> {
        repository::get_commits_info(&self.repo, ids)
    }

    fn get_working_tree_with_commits_diff(&self, commit_ids: &[String]) -> Result<Vec<DiffFile>> {
        get_working_tree_with_commits_diff(&self.repo, commit_ids)
    }

    // Phase I1b: branch-management surface for Sparring Review mode.
    fn is_working_tree_dirty(&self) -> Result<bool> {
        // `statuses()` with default options covers staged, unstaged,
        // and untracked — exactly the set we want to refuse switching
        // out from under.
        let mut opts = git2::StatusOptions::new();
        opts.include_untracked(true).recurse_untracked_dirs(true);
        let statuses = self.repo.statuses(Some(&mut opts))?;
        Ok(!statuses.is_empty())
    }

    fn branch_exists(&self, name: &str) -> Result<bool> {
        match self.repo.find_branch(name, git2::BranchType::Local) {
            Ok(_) => Ok(true),
            Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(false),
            Err(e) => Err(e.into()),
        }
    }

    fn create_branch(&self, name: &str) -> Result<()> {
        // Refuse if a local branch with this name already exists.
        // `create_branch` from git2 takes a `force` flag; we pass
        // `false` so collisions surface as errors instead of clobbering.
        let head = self.repo.head()?;
        let commit = head.peel_to_commit()?;
        self.repo.branch(name, &commit, false)?;
        Ok(())
    }

    fn checkout_branch(&self, name: &str) -> Result<()> {
        // Resolve the branch's ref and do a safe checkout. `SAFE`
        // refuses to clobber local modifications — we already refuse
        // dirty trees at the `:spar` entry point, but defense in depth
        // doesn't hurt.
        let refname = format!("refs/heads/{name}");
        let obj = self.repo.revparse_single(&refname)?;
        let mut checkout = git2::build::CheckoutBuilder::new();
        checkout.safe();
        self.repo.checkout_tree(&obj, Some(&mut checkout))?;
        self.repo.set_head(&refname)?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn classify_discover_failure_detects_reftable_via_config() {
        // given: a temp dir containing `.git/config` with `extensions.refFormat = reftable`
        // and an empty `.git/reftable/` directory.
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let git_dir = temp_dir.path().join(".git");
        fs::create_dir_all(&git_dir).expect("failed to create .git dir");
        fs::create_dir_all(git_dir.join("reftable")).expect("failed to create reftable dir");
        fs::write(
            git_dir.join("config"),
            "[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefFormat = reftable\n",
        )
        .expect("failed to write config");

        // when
        let err = classify_discover_failure(temp_dir.path());

        // then
        assert!(
            matches!(err, TrvError::ReftableRepository),
            "expected ReftableRepository, got {err:?}"
        );
    }

    #[test]
    fn classify_discover_failure_detects_reftable_via_directory_only() {
        // given: a `.git` dir with a `reftable/` subdirectory but no config entry
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let git_dir = temp_dir.path().join(".git");
        fs::create_dir_all(git_dir.join("reftable")).expect("failed to create reftable dir");

        // when
        let err = classify_discover_failure(temp_dir.path());

        // then
        assert!(matches!(err, TrvError::ReftableRepository));
    }

    #[test]
    fn classify_discover_failure_returns_not_a_repository_when_no_git_dir() {
        // given: a bare temp dir with no `.git`
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");

        // when
        let err = classify_discover_failure(temp_dir.path());

        // then
        assert!(matches!(err, TrvError::NotARepository));
    }

    #[test]
    fn is_reftable_git_dir_ignores_unrelated_config_keys() {
        // given: a `.git/config` without any reftable markers
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let git_dir = temp_dir.path().join(".git");
        fs::create_dir_all(&git_dir).expect("failed to create .git dir");
        fs::write(
            git_dir.join("config"),
            "[core]\n\trepositoryformatversion = 0\n",
        )
        .expect("failed to write config");

        // when / then
        assert!(!is_reftable_git_dir(&git_dir));
    }

    // ── Phase I1b: branch-management surface ──

    fn build_empty_backend() -> (tempfile::TempDir, GitBackend) {
        use git2::{Repository, Signature};
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let repo = Repository::init(temp_dir.path()).expect("failed to init repo");

        // Need an initial commit so HEAD peels to a commit for
        // `create_branch` / `checkout_branch` to have something to
        // point at. Scope the index borrow so it drops before we
        // move `repo` into the backend.
        let sig = Signature::now("test", "test@test").expect("sig");
        let tree_id = {
            let mut index = repo.index().unwrap();
            index.write_tree().expect("write tree")
        };
        let tree = repo.find_tree(tree_id).expect("find tree");
        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
            .expect("initial commit");
        drop(tree);

        let info = VcsInfo {
            root_path: temp_dir.path().to_path_buf(),
            head_commit: "initial".to_string(),
            branch_name: Some("main".to_string()),
            vcs_type: VcsType::Git,
        };
        let backend = GitBackend { repo, info };
        (temp_dir, backend)
    }

    #[test]
    fn is_working_tree_dirty_false_on_clean_repo() {
        let (_dir, backend) = build_empty_backend();
        assert!(!backend.is_working_tree_dirty().unwrap());
    }

    #[test]
    fn is_working_tree_dirty_true_with_untracked_file() {
        let (dir, backend) = build_empty_backend();
        fs::write(dir.path().join("new.txt"), "hi\n").expect("write");
        assert!(backend.is_working_tree_dirty().unwrap());
    }

    #[test]
    fn branch_exists_returns_false_for_missing_branch() {
        let (_dir, backend) = build_empty_backend();
        assert!(!backend.branch_exists("sparring-tests/ghost").unwrap());
    }

    #[test]
    fn create_branch_then_exists_and_checkout_switches_head() {
        let (_dir, backend) = build_empty_backend();
        backend
            .create_branch("sparring-tests/foo")
            .expect("create branch");
        assert!(backend.branch_exists("sparring-tests/foo").unwrap());

        backend
            .checkout_branch("sparring-tests/foo")
            .expect("checkout");

        // HEAD now points at the sparring branch.
        let head = backend.repo.head().expect("head");
        assert_eq!(head.shorthand(), Some("sparring-tests/foo"));
    }

    #[test]
    fn create_branch_fails_when_branch_already_exists() {
        let (_dir, backend) = build_empty_backend();
        backend.create_branch("sparring-tests/foo").unwrap();
        let err = backend
            .create_branch("sparring-tests/foo")
            .expect_err("second create should fail");
        // git2 returns Git for duplicate refs — just verify it's an error.
        assert!(matches!(err, TrvError::Git(_)), "unexpected error: {err:?}");
    }
}