stax 0.96.3

Fast stacked Git branches and PRs
Documentation
use anyhow::{Context, Result};
use git2::{Repository, RepositoryInitOptions, Signature};
use std::fs;
use std::path::Path;

pub(crate) fn init_test_repo(path: &Path) -> Result<()> {
    let mut options = RepositoryInitOptions::new();
    options.initial_head("main");
    let repo = Repository::init_opts(path, &options)
        .with_context(|| format!("initialize test repository at {}", path.display()))?;

    let mut config = repo.config().context("open test repository config")?;
    config.set_str("user.name", "Test User")?;
    config.set_str("user.email", "test@test.com")?;
    config.set_bool("commit.gpgSign", false)?;

    fs::write(path.join("README.md"), "# Test Repo\n").context("write test repository README")?;
    let mut index = repo.index().context("open test repository index")?;
    index.add_path(Path::new("README.md"))?;
    index.write()?;
    let tree_id = index.write_tree()?;
    let tree = repo.find_tree(tree_id)?;
    let signature = Signature::now("Test User", "test@test.com")?;
    repo.commit(
        Some("HEAD"),
        &signature,
        &signature,
        "Initial commit",
        &tree,
        &[],
    )?;
    Ok(())
}

pub(crate) fn commit_all(path: &Path, message: &str) -> Result<git2::Oid> {
    let repo = Repository::open(path)
        .with_context(|| format!("open test repository at {}", path.display()))?;
    let parent = repo.head()?.peel_to_commit()?;
    let mut index = repo.index()?;
    let mut nested_worktrees = Vec::new();
    let worktrees = repo.worktrees()?;
    for name in worktrees.iter() {
        let Some(name) = name? else {
            continue;
        };
        let worktree = repo.find_worktree(name)?;
        if let Ok(relative_path) = worktree.path().strip_prefix(path) {
            nested_worktrees.push(relative_path.to_path_buf());
        }
    }
    index.update_all(["*"], None)?;
    let mut status_options = git2::StatusOptions::new();
    status_options
        .include_untracked(true)
        .recurse_untracked_dirs(true)
        .exclude_submodules(true);
    let statuses = repo.statuses(Some(&mut status_options))?;
    for entry in statuses.iter() {
        if !entry.status().contains(git2::Status::WT_NEW) {
            continue;
        }
        let relative_path = Path::new(entry.path()?);
        if nested_worktrees
            .iter()
            .any(|worktree| relative_path.starts_with(worktree))
        {
            continue;
        }
        let metadata = fs::symlink_metadata(path.join(relative_path))?;
        if metadata.is_file() || metadata.file_type().is_symlink() {
            index.add_path(relative_path)?;
        }
    }
    index.write()?;
    let tree_id = index.write_tree()?;
    let tree = repo.find_tree(tree_id)?;
    if tree.id() == parent.tree_id() {
        anyhow::bail!("no fixture changes to commit");
    }
    let signature = Signature::now("Test User", "test@test.com")?;
    Ok(repo.commit(
        Some("HEAD"),
        &signature,
        &signature,
        message,
        &tree,
        &[&parent],
    )?)
}

#[cfg(test)]
mod tests {
    use super::init_test_repo;
    use tempfile::{NamedTempFile, tempdir};

    #[test]
    fn initializes_clean_main_repo_with_deterministic_identity() {
        let dir = tempdir().expect("temp dir");
        init_test_repo(dir.path()).expect("initialize fixture");

        let repo = git2::Repository::open(dir.path()).expect("open fixture");
        assert_eq!(repo.head().unwrap().shorthand().unwrap(), "main");
        assert_eq!(
            repo.head()
                .unwrap()
                .peel_to_commit()
                .unwrap()
                .message()
                .unwrap(),
            "Initial commit"
        );
        assert!(repo.statuses(None).unwrap().is_empty());

        let config = repo.config().unwrap();
        assert_eq!(config.get_string("user.name").unwrap(), "Test User");
        assert_eq!(config.get_string("user.email").unwrap(), "test@test.com");
    }

    #[test]
    fn reports_repository_path_when_initialization_fails() {
        let file = NamedTempFile::new().expect("temp file");
        let error = init_test_repo(file.path()).unwrap_err().to_string();
        assert!(error.contains("initialize test repository"));
        assert!(error.contains(&file.path().display().to_string()));
    }

    #[test]
    fn commits_added_modified_and_deleted_files() {
        let dir = tempdir().expect("temp dir");
        init_test_repo(dir.path()).expect("initialize fixture");
        std::fs::write(dir.path().join("deleted.txt"), "delete me\n").unwrap();
        super::commit_all(dir.path(), "add deletion candidate").unwrap();

        std::fs::write(dir.path().join("added.txt"), "added\n").unwrap();
        std::fs::write(dir.path().join("README.md"), "changed\n").unwrap();
        std::fs::remove_file(dir.path().join("deleted.txt")).unwrap();

        let oid = super::commit_all(dir.path(), "fixture update").expect("commit fixture");
        let repo = git2::Repository::open(dir.path()).unwrap();
        let commit = repo.find_commit(oid).unwrap();
        assert_eq!(commit.message().unwrap(), "fixture update");
        let tree = commit.tree().unwrap();
        assert!(tree.get_name("added.txt").is_some());
        assert!(tree.get_name("deleted.txt").is_none());
        let readme = tree.get_name("README.md").unwrap();
        assert_eq!(repo.find_blob(readme.id()).unwrap().content(), b"changed\n");
        assert!(repo.statuses(None).unwrap().is_empty());
    }

    #[test]
    fn commit_all_rejects_an_empty_change() {
        let dir = tempdir().expect("temp dir");
        init_test_repo(dir.path()).expect("initialize fixture");
        let error = super::commit_all(dir.path(), "empty")
            .unwrap_err()
            .to_string();
        assert!(error.contains("no fixture changes to commit"));
    }

    #[test]
    fn commit_all_ignores_nested_linked_worktrees() {
        let dir = tempdir().expect("temp dir");
        init_test_repo(dir.path()).expect("initialize fixture");
        let repo = git2::Repository::open(dir.path()).unwrap();
        let nested_worktree = dir.path().join("nested-worktree");
        repo.worktree("nested-worktree", &nested_worktree, None)
            .expect("create nested linked worktree");
        std::fs::write(dir.path().join("main.txt"), "main change\n").unwrap();

        super::commit_all(dir.path(), "main update").expect("commit main worktree change");
        assert_eq!(
            repo.status_file(std::path::Path::new("main.txt")).unwrap(),
            git2::Status::CURRENT
        );
        assert!(
            repo.head()
                .unwrap()
                .peel_to_tree()
                .unwrap()
                .get_name("main.txt")
                .is_some()
        );
    }
}