a73x

tests/refs_test.rs

Ref:   Size: 18.1 KiB   History

//! `git-collab refs`: enumerate this clone's collab refs, and say what each is.
//!
//! The layout nests — `refs/collab/patches/<id>/events`, `<id>/rev/<oid>` — and
//! `git for-each-ref 'refs/collab/*'` silently reports nothing, because
//! for-each-ref's globs do not cross `/` while git2's `references_glob`, which
//! this tool uses internally, does. An operator should not have to know which
//! side of that boundary they are on to look at their own repository.
//!
//! The load-bearing property, beyond listing: this is a read. A repository
//! holding a pre-migration shape must still hold it afterwards, or the one
//! question the command exists to answer — *does this repo hold legacy
//! shapes?* — answers itself by destroying the evidence.

mod common;

use std::path::{Path, PathBuf};
use std::process::{Command, Output};

use common::{write_raw_event, TestRepo};
use serde_json::json;
use tempfile::TempDir;

/// Every ref under `refs/collab/`, as git itself sees it (no glob — that is the
/// whole point of the issue this command answers).
fn collab_refs(repo: &TestRepo) -> Vec<String> {
    let mut refs: Vec<String> = repo
        .git(&["for-each-ref", "--format=%(refname)", "refs/collab/"])
        .lines()
        .map(str::to_string)
        .collect();
    refs.sort();
    refs
}

/// Expand a short patch id to the full 40-char id via the ref listing.
fn full_patch_id(repo: &TestRepo, short: &str) -> String {
    for name in collab_refs(repo) {
        if let Some(rest) = name.strip_prefix("refs/collab/patches/") {
            let id = rest.split('/').next().unwrap_or_default();
            if id.starts_with(short) {
                return id.to_string();
            }
        }
    }
    panic!("no patch ref matching {}", short);
}

/// Create a patch on a fresh branch holding one commit. Returns (full id, tip).
fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> (String, String) {
    repo.git(&["checkout", "-b", branch]);
    let tip = repo.commit_file(file, "v1", &format!("add {}", file));
    let out = repo.run_ok(&["patch", "create", "-t", branch, "-B", branch]);
    let short = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap_or_else(|| panic!("unexpected create output: {}", out))
        .to_string();
    (full_patch_id(repo, &short), tip)
}

/// Write a patch in the pre-migration layout: one bare `refs/collab/patches/<id>`
/// ref that *is* the event DAG. Returns the full id.
///
/// By hand, because no version of the tool that can still be built writes this
/// shape — and a repository somewhere holds it, which is the case under test.
fn legacy_bare_patch(repo: &TestRepo, title: &str) -> String {
    let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
    let head = git_repo.head().unwrap().target().unwrap();
    let tree = git_repo.find_commit(head).unwrap().tree().unwrap().id();
    let root = write_raw_event(
        &git_repo,
        None,
        json!({
            "type": "patch.create",
            "title": title,
            "body": "",
            "base_ref": "main",
            "branch": "old",
            "commit": head.to_string(),
            "tree": tree.to_string(),
        }),
        1,
    );
    let id = root.to_string();
    git_repo
        .reference(
            &format!("refs/collab/patches/{}", id),
            root,
            false,
            "old layout",
        )
        .unwrap();
    id
}

/// The one output line naming `needle`, or a panic naming what was there.
fn line_for<'a>(output: &'a str, needle: &str) -> &'a str {
    output
        .lines()
        .find(|l| l.contains(needle))
        .unwrap_or_else(|| panic!("no line naming {}, got:\n{}", needle, output))
}

/// A repository holding one of each shape that matters: a current patch with a
/// pinned revision, an issue, and a bare pre-migration patch ref plus an
/// interim numbered revision ref.
///
/// The superseded shapes are written after every CLI call, and the ordering is
/// still load-bearing — for the opposite reason it used to be. It was to stop
/// the layout migration converting them out from under the test; the migration
/// is gone (issue `e5096ffc`), and now every command except `refs` refuses to
/// run at all once one of these exists.
fn mixed_layout_repo() -> (TestRepo, String, String, String) {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let (modern, tip) = patch_on_branch(&repo, "feat", "a.txt");
    repo.run_ok(&["issue", "open", "-t", "An issue"]);

    let legacy = legacy_bare_patch(&repo, "Written by an older version");

    // The interim shape: a revision named by number rather than by the OID it
    // pins. It can only exist beside an events ref, so it hangs off the current
    // patch.
    repo.git(&[
        "update-ref",
        &format!("refs/collab/patches/{}/r/1", modern),
        &tip,
    ]);

    (repo, modern, legacy, tip)
}

// ===========================================================================
// Enumeration
// ===========================================================================

#[test]
fn refs_reports_both_patch_layouts() {
    let (repo, modern, legacy, tip) = mixed_layout_repo();
    let out = repo.run_ok(&["refs"]);

    let events = line_for(&out, &format!("refs/collab/patches/{}/events", modern));
    assert!(
        events.contains("patch events") && !events.contains("legacy"),
        "the current layout must not be reported as legacy: {:?}",
        events
    );

    let bare = line_for(&out, &format!("refs/collab/patches/{}", legacy));
    assert!(
        bare.contains("legacy"),
        "a bare <id> ref is the pre-migration layout and must say so: {:?}",
        bare
    );

    let rev = line_for(&out, &format!("refs/collab/patches/{}/rev/{}", modern, tip));
    assert!(
        rev.contains("revision") && !rev.contains("legacy"),
        "a rev/<oid> ref pins a revision: {:?}",
        rev
    );

    let numbered = line_for(&out, &format!("refs/collab/patches/{}/r/1", modern));
    assert!(
        numbered.contains("legacy"),
        "a numbered r/<n> ref is the interim layout: {:?}",
        numbered
    );

    let issue_ref = collab_refs(&repo)
        .into_iter()
        .find(|r| r.starts_with("refs/collab/issues/"))
        .expect("the issue must have a ref");
    assert!(
        line_for(&out, &issue_ref).contains("issue"),
        "issues are collab refs too"
    );
}

#[test]
fn refs_summarises_the_legacy_shapes_it_found() {
    let (repo, _modern, _legacy, _tip) = mixed_layout_repo();
    let out = repo.run_ok(&["refs"]);

    // The summary is the whole answer to "does this repo hold legacy shapes?",
    // which is what blocks stripping the compatibility code.
    let summary = line_for(&out, "legacy");
    assert!(
        out.lines().any(|l| l.contains("2") && l.contains("legacy")),
        "expected a summary counting both legacy refs, got {:?}\nfull:\n{}",
        summary,
        out
    );
}

#[test]
fn refs_on_a_clean_repo_says_so() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run_ok(&["refs"]);
    assert!(
        out.to_lowercase().contains("no collab refs"),
        "an empty namespace must be stated, not printed as silence: {:?}",
        out
    );
}

// ===========================================================================
// JSON
// ===========================================================================

#[test]
fn refs_json_carries_full_ids() {
    let (repo, modern, legacy, tip) = mixed_layout_repo();
    let out = repo.run_ok(&["refs", "--json"]);
    let v: serde_json::Value = serde_json::from_str(&out)
        .unwrap_or_else(|e| panic!("--json must emit one JSON value ({}): {}", e, out));

    let refs = v["refs"].as_array().expect("refs array");
    let find = |name: &str| -> serde_json::Value {
        refs.iter()
            .find(|r| r["ref"] == name)
            .unwrap_or_else(|| panic!("no entry for {} in {}", name, out))
            .clone()
    };

    let events = find(&format!("refs/collab/patches/{}/events", modern));
    assert_eq!(events["kind"], "patch-events");
    assert_eq!(
        events["id"], modern,
        "--json carries full ids, never abbreviations"
    );
    assert_eq!(events["legacy"], false);
    assert_eq!(events["archived"], false);
    assert_eq!(
        events["target"].as_str().unwrap().len(),
        40,
        "the target is a full OID"
    );

    let bare = find(&format!("refs/collab/patches/{}", legacy));
    assert_eq!(bare["kind"], "patch-events-legacy");
    assert_eq!(bare["id"], legacy);
    assert_eq!(bare["legacy"], true);

    let rev = find(&format!("refs/collab/patches/{}/rev/{}", modern, tip));
    assert_eq!(rev["kind"], "patch-revision");
    assert_eq!(rev["id"], modern, "a revision ref belongs to its patch");
    assert_eq!(rev["target"], tip);
    assert_eq!(rev["legacy"], false);

    let numbered = find(&format!("refs/collab/patches/{}/r/1", modern));
    assert_eq!(numbered["kind"], "patch-revision-numbered");
    assert_eq!(numbered["id"], modern);
    assert_eq!(numbered["legacy"], true);

    assert_eq!(v["legacy"], 2, "two refs are in a superseded shape");
    assert_eq!(v["total"], refs.len(), "total counts what was listed");
    assert_eq!(v["counts"]["patch-events"], 1);
    assert_eq!(v["counts"]["patch-events-legacy"], 1);
}

#[test]
fn refs_json_on_a_clean_repo_is_an_empty_listing() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run_ok(&["refs", "--json"]);
    let v: serde_json::Value = serde_json::from_str(&out).unwrap();
    assert_eq!(v["refs"].as_array().unwrap().len(), 0);
    assert_eq!(v["total"], 0);
    assert_eq!(v["legacy"], 0);
}

// ===========================================================================
// Reading must not write
// ===========================================================================

// ===========================================================================
// The server side: the same enumeration over a roster
// ===========================================================================

fn git_in(dir: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .expect("failed to run git");
    assert!(
        output.status.success(),
        "git {:?} in {:?} failed: {}",
        args,
        dir,
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap()
}

/// A `repos_dir` with a `server.toml` beside it, and no server running: all
/// `git-collab-server refs` needs, since it only reads.
struct Roster {
    root: TempDir,
    repos_dir: PathBuf,
    config: PathBuf,
}

impl Roster {
    fn new() -> Self {
        let root = TempDir::new().unwrap();
        let repos_dir = root.path().join("repos");
        std::fs::create_dir_all(&repos_dir).unwrap();
        let authorized_keys = root.path().join("authorized_keys");
        std::fs::write(&authorized_keys, "").unwrap();
        let config = root.path().join("server.toml");
        std::fs::write(
            &config,
            format!(
                "repos_dir = {:?}\nauthorized_keys = {:?}\n",
                repos_dir, authorized_keys
            ),
        )
        .unwrap();
        Roster {
            root,
            repos_dir,
            config,
        }
    }

    /// A hosted bare repository holding one patch. Returns (path, full id).
    fn seed(&self, name: &str) -> (PathBuf, String) {
        let bare = self.repos_dir.join(format!("{name}.git"));
        git_in(
            self.root.path(),
            &["init", "-q", "--bare", "-b", "main", bare.to_str().unwrap()],
        );

        let work = TestRepo::new("Alice", "alice@example.com");
        work.patch_create("A patch");
        work.git(&["push", "-q", bare.to_str().unwrap(), "main:main"]);
        work.git(&[
            "push",
            "-q",
            bare.to_str().unwrap(),
            "refs/collab/*:refs/collab/*",
        ]);

        let refs = git_in(
            &bare,
            &["for-each-ref", "--format=%(refname)", "refs/collab/"],
        );
        let id = refs
            .lines()
            .find_map(|n| n.strip_prefix("refs/collab/patches/"))
            .and_then(|rest| rest.split('/').next())
            .expect("a patch ref")
            .to_string();
        (bare, id)
    }

    /// Put a hosted repository's patch back into the pre-migration layout.
    fn demote(&self, bare: &Path, id: &str) {
        let events = format!("refs/collab/patches/{}/events", id);
        let tip = git_in(bare, &["rev-parse", &events]).trim().to_string();
        let names = git_in(
            bare,
            &[
                "for-each-ref",
                "--format=%(refname)",
                &format!("refs/collab/patches/{}/", id),
            ],
        );
        for name in names.lines() {
            git_in(bare, &["update-ref", "-d", name]);
        }
        git_in(
            bare,
            &["update-ref", &format!("refs/collab/patches/{}", id), &tip],
        );
    }

    fn refs(&self, extra: &[&str]) -> Output {
        let mut args = vec!["refs", "--config", self.config.to_str().unwrap()];
        args.extend_from_slice(extra);
        Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
            .args(&args)
            .output()
            .expect("failed to run git-collab-server refs")
    }
}

/// Every collab ref in a bare repository, as sorted `<name> <oid>` lines.
fn snapshot(bare: &Path) -> String {
    let mut lines: Vec<String> = git_in(
        bare,
        &[
            "for-each-ref",
            "--format=%(refname) %(objectname)",
            "refs/collab/",
        ],
    )
    .lines()
    .map(str::to_string)
    .collect();
    lines.sort();
    lines.join("\n")
}

#[test]
fn server_refs_names_the_repositories_holding_a_legacy_layout() {
    let roster = Roster::new();
    let (old, old_id) = roster.seed("old");
    roster.demote(&old, &old_id);
    roster.seed("current");

    let output = roster.refs(&[]);
    assert!(output.status.success(), "a clean audit exits 0");
    let stdout = String::from_utf8(output.stdout).unwrap();

    // "older layout" rather than "superseded": the bare shape is read again
    // (issue `05b18df6`), so this survey says which repositories are old, not
    // which are broken. The operator uses it to plan a migration, and a word
    // that implies breakage would make that look urgent when it is not.
    assert!(
        line_for(&stdout, "old:").contains("older layout"),
        "the demoted repository must be called out: {}",
        stdout
    );
    assert!(
        !line_for(&stdout, "current:").contains("older layout"),
        "the current repository must not be: {}",
        stdout
    );
    assert!(
        stdout.contains("2 repositories scanned"),
        "expected a roster summary, got {}",
        stdout
    );
}

#[test]
fn server_refs_json_names_the_legacy_refs_in_full() {
    let roster = Roster::new();
    let (old, old_id) = roster.seed("old");
    roster.demote(&old, &old_id);
    roster.seed("current");

    let output = roster.refs(&["--json"]);
    let stdout = String::from_utf8(output.stdout).unwrap();
    let v: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("--json must emit one JSON value ({}): {}", e, stdout));

    assert_eq!(v["legacy"], 1, "one legacy ref across the roster");
    assert_eq!(v["unreadable"], 0);

    let repos = v["repositories"].as_array().unwrap();
    let old_entry = repos
        .iter()
        .find(|r| r["repo"] == "old")
        .expect("the demoted repository");
    assert_eq!(old_entry["legacy"], 1);
    assert_eq!(
        old_entry["legacy_refs"][0]["ref"],
        format!("refs/collab/patches/{}", old_id),
        "the ref an operator has to act on, named in full"
    );
    assert_eq!(old_entry["legacy_refs"][0]["id"], old_id);
    assert_eq!(old_entry["legacy_refs"][0]["kind"], "patch-events-legacy");

    let current = repos
        .iter()
        .find(|r| r["repo"] == "current")
        .expect("the current repository");
    assert_eq!(current["legacy"], 0);
    assert_eq!(current["legacy_refs"].as_array().unwrap().len(), 0);
}

#[test]
fn server_refs_reports_a_repository_it_cannot_read_rather_than_skipping_it() {
    let roster = Roster::new();
    roster.seed("fine");
    // A directory the roster walk counts as a repository — it has a `HEAD` —
    // and git2 cannot open, having nothing else. Silently skipping it would let
    // the audit answer "nothing legacy" for a roster it never finished reading.
    let broken = roster.repos_dir.join("broken.git");
    std::fs::create_dir_all(&broken).unwrap();
    std::fs::write(broken.join("HEAD"), "ref: refs/heads/main\n").unwrap();

    let output = roster.refs(&[]);
    assert!(
        !output.status.success(),
        "an audit that could not read everything must not exit 0"
    );
    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(
        stdout.contains("broken") && stdout.contains("cannot be read"),
        "the unreadable repository must be named: {}",
        stdout
    );
}

#[test]
fn server_refs_writes_nothing_to_the_repositories_it_reads() {
    let roster = Roster::new();
    let (old, old_id) = roster.seed("old");
    roster.demote(&old, &old_id);
    let before = snapshot(&old);

    roster.refs(&[]);
    roster.refs(&["--json"]);

    assert_eq!(
        snapshot(&old),
        before,
        "reading a hosted repository must leave its refs byte-identical — \
         serving one that migrated on read was issue 5174338f"
    );
}

#[test]
fn refs_does_not_migrate_what_it_reports() {
    let (repo, modern, legacy, _tip) = mixed_layout_repo();
    let before = collab_refs(&repo);

    repo.run_ok(&["refs"]);
    repo.run_ok(&["refs", "--json"]);

    assert_eq!(
        collab_refs(&repo),
        before,
        "listing refs must not move, convert or add a single one — every other \
         command migrates on entry, and this is the one that must not"
    );
    assert!(
        before.contains(&format!("refs/collab/patches/{}", legacy)),
        "precondition: the bare ref was there to begin with"
    );
    assert!(
        before.contains(&format!("refs/collab/patches/{}/r/1", modern)),
        "precondition: the numbered ref was there to begin with"
    );
}