wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! The git guard, against real repositories. Every case here is one where
//! synchronising would mix two different bases together.

use super::*;
use super::super::model::Blocked;
use std::path::Path;

/// A repository with one commit on a named branch.
///
/// The first commit's content is the directory's own name, because a git
/// commit id is a hash of its content, author and time — two repositories
/// created in the same second from the same bytes have the SAME first commit,
/// and are then not two histories at all. Making them differ is what makes
/// "unrelated repositories" mean something in a fixture.
fn repository(dir: &Path, branch: &str) -> RepositoryFacts {
    std::fs::create_dir_all(dir).expect("mkdir");
    run(dir, &["init", "-q", "-b", branch]);
    run(dir, &["config", "user.name", "Test"]);
    run(dir, &["config", "user.email", "test@example.com"]);
    std::fs::write(dir.join("file.txt"), format!("{}\n", dir.display())).expect("write");
    run(dir, &["add", "."]);
    run(dir, &["commit", "-q", "-m", "one"]);
    facts(dir)
}

fn facts(dir: &Path) -> RepositoryFacts {
    RepositoryFacts::read(RepositoryId::root(), dir, &dir.join(".git")).expect("facts")
}

fn run(dir: &Path, args: &[&str]) {
    let output = crate::git_backend::git_command()
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
    assert!(
        output.status.success(),
        "git {args:?} in {}: {}",
        dir.display(),
        String::from_utf8_lossy(&output.stderr)
    );
}

/// Two clones of one repository, both on the same branch at the same commit.
fn pair() -> (tempfile::TempDir, RepositoryFacts, RepositoryFacts) {
    let temp = tempfile::tempdir().expect("tempdir");
    let origin = temp.path().join("origin");
    repository(&origin, "feature/peer");
    let desktop = temp.path().join("desktop");
    let laptop = temp.path().join("laptop");
    for clone in [&desktop, &laptop] {
        let output = crate::git_backend::git_command()
            .args(["clone", "-q", origin.to_str().expect("path"), clone.to_str().expect("path")])
            .output()
            .expect("clone");
        assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
        run(clone, &["config", "user.name", "Test"]);
        run(clone, &["config", "user.email", "test@example.com"]);
    }
    let here = facts(&desktop);
    let there = facts(&laptop);
    (temp, here, there)
}

/// TEST9944: the ordinary case — same repository, same branch, same commit,
/// nothing in progress — is eligible, and its epoch is stable.
#[test]
fn test9944_matching_trees_are_eligible() {
    let (_temp, here, there) = pair();
    let epoch = eligibility(&here, &there).expect("matching clones are eligible");

    assert_eq!(epoch.branch.as_deref(), Some("feature/peer"));
    assert_eq!(epoch.head, here.head);
    assert!(epoch.still_current(&here), "nothing moved, so the epoch stands");
    // The two clones agree on the repository's identity without either having
    // a remote URL in common with the other's spelling of it.
    assert_eq!(here.root_commit, there.root_commit);
    assert!(!here.root_commit.is_empty());
}

/// TEST9945: a different branch blocks, and says which is which.
#[test]
fn test9945_a_branch_mismatch_blocks() {
    let (temp, here, _there) = pair();
    let laptop = temp.path().join("laptop");
    run(&laptop, &["switch", "-q", "-c", "main"]);
    let there = facts(&laptop);

    match eligibility(&here, &there) {
        Err(Blocked::BranchMismatch { here, there }) => {
            assert_eq!(here, "feature/peer");
            assert_eq!(there, "main");
        }
        other => panic!("expected a branch mismatch, got {other:?}"),
    }
}

/// TEST9946: two detached HEADs at the same commit are NOT the same branch.
///
/// The trees are identical and the commits match, so a naive check would call
/// this compatible. It is not: nothing says the next commit on either side
/// lands anywhere the other can follow, and a detached HEAD is exactly where
/// work gets lost.
#[test]
fn test9946_detached_heads_are_never_the_same_branch() {
    let (temp, _here, _there) = pair();
    let desktop = temp.path().join("desktop");
    let laptop = temp.path().join("laptop");
    let head = facts(&desktop).head.expect("a commit");
    for clone in [&desktop, &laptop] {
        run(clone, &["checkout", "-q", &head]);
    }
    let here = facts(&desktop);
    let there = facts(&laptop);

    assert_eq!(here.head, there.head, "the fixture must have identical commits");
    assert!(here.branch.is_none() && there.branch.is_none());

    match eligibility(&here, &there) {
        Err(Blocked::DetachedHead { here, there }) => {
            assert!(here && there, "both sides are detached");
        }
        other => panic!("expected a detached-head block, got {other:?}"),
    }
}

/// TEST9947: the same branch at different commits blocks.
///
/// Same branch is not enough. The difference between the two trees would be
/// the work in progress AND every change between the two commits, and copying
/// files across that gap mixes the two together.
#[test]
fn test9947_a_head_mismatch_blocks() {
    let (temp, here, _there) = pair();
    let laptop = temp.path().join("laptop");
    std::fs::write(laptop.join("file.txt"), "two\n").expect("write");
    run(&laptop, &["commit", "-qam", "two"]);
    let there = facts(&laptop);

    match eligibility(&here, &there) {
        Err(Blocked::HeadMismatch { here: a, there: b }) => {
            assert_eq!(Some(a), here.head);
            assert_eq!(Some(b), there.head);
        }
        other => panic!("expected a head mismatch, got {other:?}"),
    }
}

/// TEST9948: unrelated repositories never synchronise, however they are named.
///
/// Identity is the root commit, not the remote URL. Two unrelated projects
/// pushed to one URL, or one project on two hosts, must both come out right.
#[test]
fn test9948_unrelated_repositories_are_refused() {
    let temp = tempfile::tempdir().expect("tempdir");
    let one = temp.path().join("one");
    let two = temp.path().join("two");
    let here = repository(&one, "main");
    let there = repository(&two, "main");

    assert_ne!(
        here.root_commit, there.root_commit,
        "two histories must have two identities"
    );
    match eligibility(&here, &there) {
        Err(Blocked::RepositoryMismatch { .. }) => {}
        other => panic!("expected a repository mismatch, got {other:?}"),
    }
}

/// TEST9949: a git operation in progress blocks, and is detected from the
/// repository's own markers.
#[test]
fn test9949_an_operation_in_progress_blocks() {
    let (temp, here, there) = pair();
    let laptop = temp.path().join("laptop");
    // What a conflicted merge leaves behind. Written directly because the
    // state, not the way it was reached, is what the guard reads.
    std::fs::write(laptop.join(".git/MERGE_HEAD"), format!("{}\n", here.head.clone().unwrap()))
        .expect("write");
    let mid_merge = facts(&laptop);

    assert_eq!(mid_merge.git_state, GitState::Merge);
    match eligibility(&here, &mid_merge) {
        Err(Blocked::GitOperation { here, there }) => {
            assert!(!here && there, "only the peer is busy");
        }
        other => panic!("expected a git-operation block, got {other:?}"),
    }
    let _ = there;
}

/// TEST9950: the epoch moves when the repository does — which is what stops a
/// transfer prepared against one state from landing on another.
///
/// A checkout between the scan and the apply is not a rare race: it is what a
/// person does. The epoch has to notice a commit, a branch change and a git
/// operation starting — and must NOT notice things that leave the working
/// tree exactly as it was.
#[test]
fn test9950_the_epoch_moves_with_the_repository() {
    let (temp, here, _there) = pair();
    let desktop = temp.path().join("desktop");
    let epoch = here.epoch();
    assert!(epoch.still_current(&facts(&desktop)));

    // A commit moves it.
    std::fs::write(desktop.join("file.txt"), "changed\n").expect("write");
    run(&desktop, &["commit", "-qam", "two"]);
    assert!(
        !epoch.still_current(&facts(&desktop)),
        "a commit must invalidate a transfer prepared before it"
    );

    // So does a branch change, with the commit unchanged.
    let after_commit = facts(&desktop).epoch();
    run(&desktop, &["switch", "-q", "-c", "other"]);
    assert!(
        !after_commit.still_current(&facts(&desktop)),
        "a branch change must invalidate a transfer prepared before it"
    );

    // Staging does NOT move it, and must not. `git add` changes the index and
    // nothing else — the working tree is what continuity carries, and it is
    // untouched. Reading the index into the epoch made the guard fire on
    // non-events: `git status`, which any watcher runs, rewrites the index's
    // stat cache, and every transfer prepared against it was then refused,
    // retried and refused again while nothing crossed.
    let after_switch = facts(&desktop).epoch();
    std::fs::write(desktop.join("staged.txt"), "x\n").expect("write");
    std::thread::sleep(std::time::Duration::from_millis(10));
    run(&desktop, &["add", "staged.txt"]);
    assert!(
        after_switch.still_current(&facts(&desktop)),
        "staging a file is not the repository moving under a transfer"
    );

    // A rebase in progress does move it: git is mid-operation, and a checkout
    // it performs would land on top of whatever a transfer wrote.
    let before_rebase = facts(&desktop).epoch();
    std::fs::create_dir_all(desktop.join(".git/rebase-merge")).expect("mkdir");
    assert!(
        !before_rebase.still_current(&facts(&desktop)),
        "a git operation starting must invalidate a transfer prepared before it"
    );
}

/// TEST9951: a repository the peer does not have blocks, naming which side is
/// missing it.
#[test]
fn test9951_a_missing_repository_blocks_and_says_which_side() {
    let (_temp, here, _there) = pair();
    let absent = RepositoryFacts::absent(RepositoryId::parse("cartridges").expect("id"));

    match eligibility(&here, &absent) {
        Err(Blocked::SubmoduleMissing { here, there }) => {
            assert!(here && !there);
        }
        other => panic!("expected a missing-repository block, got {other:?}"),
    }
    match eligibility(&absent, &here) {
        Err(Blocked::SubmoduleMissing { here, there }) => {
            assert!(!here && there);
        }
        other => panic!("expected a missing-repository block, got {other:?}"),
    }
}