a73x

tests/legacy_patch_test.rs

Ref:   Size: 12.6 KiB   History

//! What happens to a repository still holding a pre-release *ref layout*.
//!
//! Two layouts, and the line between them is the whole point of this file.
//!
//! The bare `refs/collab/patches/<id>` ref — the ref that *is* the event DAG,
//! from before revision refs needed a subtree — **is read**. Issue `e5096ffc`
//! stopped reading it on a survey that missed the user's own clones; issue
//! `05b18df6` found 11 of them still sitting in `~/code/rad/waystty`, with the
//! same shape on its remote. A repository must not become unreachable because
//! every copy of it is old, and that is the case a clone of an unmaintained
//! project is *always* in. So the reader takes the shape as it finds it, and
//! `sync` reconciles into the ref that is already there rather than trying to
//! create a directory where a ref lives.
//!
//! The interim `<id>/r/<n>` numbering — a *revision* ref named by ordinal
//! rather than by the OID it pins — is still refused. It is not a DAG, so
//! reading it means nothing; it was written by a draft that never left this
//! machine; and the audit behind `05b18df6` scanned every collab ref in every
//! repository under `~/code/rad` and found none. Nothing holds it, so nothing
//! is stranded by refusing it.
//!
//! What both halves share is the failure mode being guarded against: not
//! breakage but *silence*. A reader that merely stopped recognising a name
//! would report a repository full of patches as empty, which is
//! indistinguishable from a clean clone. Whatever the reader does with a shape,
//! it has to say so.
//!
//! Field-level shapes — `patch.create` with no commit, `patch.review` with no
//! revision, `patch.merge` with no commit, `patch.revise` — live in
//! `legacy_data_shapes_test.rs`.

mod common;

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

/// Create a patch on a fresh branch holding one commit. Returns its full id.
fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> String {
    repo.git(&["checkout", "-b", branch]);
    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));
    for name in repo
        .git(&["for-each-ref", "--format=%(refname)", "refs/collab/"])
        .lines()
    {
        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);
}

/// Write a patch in the pre-migration layout: one bare `refs/collab/patches/<id>`
/// ref that *is* the event DAG.
///
/// By hand, because no version of the tool that can still be built writes this
/// shape — and a repository somewhere may still hold it, which is the case
/// under test.
fn legacy_bare_patch(repo: &TestRepo) -> 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": "Written by an older version",
            "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
}

/// Every message about a layout the reader genuinely cannot use has to do four
/// things: name the ref, say the layout is not read, point at the command that
/// lists them, and offer a remedy that works when *every* copy is old.
///
/// The fourth was the one the strip got wrong. It advised "fetch the patch from
/// a remote holding the current layout, or delete the stale ref if it exists
/// nowhere else" — two remedies, neither of which fits the ordinary case, which
/// is a project whose copies are all the same age. Advice that cannot be
/// followed is worse than none: it reads as help and ends in a wall.
fn assert_actionable(stderr: &str, ref_name: &str) {
    assert!(
        stderr.contains(ref_name),
        "the message must name the offending ref, got: {}",
        stderr
    );
    assert!(
        stderr.contains("git-collab refs"),
        "the message must point at the diagnostic command, got: {}",
        stderr
    );
    let lowered = stderr.to_lowercase();
    assert!(
        lowered.contains("layout") || lowered.contains("older version"),
        "the message must say what is wrong with the ref, got: {}",
        stderr
    );
    assert!(
        !lowered.contains("holding the current layout"),
        "the message must not advise fetching a newer copy — when every copy is \
         old there is none, and that is the common case. Got: {}",
        stderr
    );
    assert!(
        lowered.contains("no version of git-collab wrote")
            || lowered.contains("update-ref")
            || lowered.contains("rename"),
        "the message must give a remedy that works when every copy is old, got: {}",
        stderr
    );
}

// ===========================================================================
// The bare `<id>` layout is read
// ===========================================================================

#[test]
fn a_bare_patch_ref_is_read_as_the_patch_it_is() {
    // 11 of these are sitting in `~/code/rad/waystty` right now, and its remote
    // holds the same. Refusing them made the repository unreachable from every
    // copy that exists.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_bare_patch(&repo);

    let out = repo.run_ok(&["patch", "list", "--all"]);
    assert!(
        out.contains(&id[..8]),
        "a patch in the bare layout must be listed, got:\n{}",
        out
    );
    assert!(
        out.contains("Written by an older version"),
        "and it must carry its own title, got:\n{}",
        out
    );
}

#[test]
fn a_bare_patch_ref_can_be_shown() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_bare_patch(&repo);

    let out = repo.run_ok(&["patch", "show", &id[..8]]);
    assert!(
        out.contains("Written by an older version"),
        "patch show must resolve a bare-layout patch, got:\n{}",
        out
    );
}

#[test]
fn a_bare_patch_ref_does_not_produce_an_empty_list() {
    // The failure mode that outlives every change of policy here: whatever the
    // reader decides to do with the shape, reporting a repository full of
    // patches as empty is never it.
    let repo = TestRepo::new("Alice", "alice@example.com");
    legacy_bare_patch(&repo);

    let output = repo.run(&["patch", "list", "--all"]);
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        output.status.success() && !stdout.contains("No patches found"),
        "a bare-layout patch must be listed, not reported as emptiness: {}\n{}",
        stdout,
        String::from_utf8_lossy(&output.stderr)
    );
}

// ===========================================================================
// The interim `<id>/r/<n>` numbering is still refused
// ===========================================================================

#[test]
fn a_numbered_revision_ref_is_refused_and_says_what_to_do() {
    // Still refused, and on evidence rather than on principle: the audit behind
    // issue `05b18df6` walked every collab ref in every repository under
    // `~/code/rad` and found zero. It is also not a DAG — reading it would
    // yield nothing — so unlike the bare layout, refusing it strands no one.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = patch_on_branch(&repo, "feat", "a.txt");
    let tip = repo.git(&["rev-parse", "HEAD"]).trim().to_string();

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

    let stderr = repo.run_err(&["patch", "list"]);
    assert_actionable(&stderr, &format!("refs/collab/patches/{}/r/1", id));
}

#[test]
fn refs_still_reports_the_layout_of_every_patch_ref() {
    // `git-collab refs` is the one command that must keep working here: it is
    // what the refusal message tells the operator to run, so if it failed the
    // same way the advice would be a loop. It classifies the bare layout too —
    // reading a shape is not the same as recommending it, and an operator
    // should be able to see which of their refs are old.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_bare_patch(&repo);

    let out = repo.run_ok(&["refs"]);
    let line = out
        .lines()
        .find(|l| l.contains(&format!("refs/collab/patches/{}", id)))
        .unwrap_or_else(|| panic!("refs must still list the bare ref, got:\n{}", out));
    assert!(
        line.contains("legacy"),
        "refs must still classify the older layout by name: {:?}",
        line
    );
}

#[test]
fn nothing_migrates_the_shape_out_from_under_the_operator() {
    // Reading a shape is not converting it. A read that quietly rewrote the
    // ref would make every other clone's copy diverge from this one on the next
    // sync, and would do it during a command the operator asked nothing of.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_bare_patch(&repo);
    let before = repo.git(&["rev-parse", &format!("refs/collab/patches/{}", id)]);

    let _ = repo.run(&["patch", "list", "--all"]);
    let _ = repo.run(&["refs"]);

    let after = repo.git(&["rev-parse", &format!("refs/collab/patches/{}", id)]);
    assert_eq!(
        before.trim(),
        after.trim(),
        "the older ref must survive being read"
    );
}

// ===========================================================================
// A head that cannot be resolved
// ===========================================================================

#[test]
fn a_patch_whose_revision_commit_is_missing_fails_clearly() {
    // Patches used to be addressed by branch, so a head that could not be
    // found from a recorded revision fell back to `refs/heads/<branch>`. That
    // fallback is gone: a patch is addressed by its own revision refs. What
    // must not happen is the old silent no-op — the caller has to be told the
    // objects are missing, and which patch is affected.
    let repo = TestRepo::new("Alice", "alice@example.com");
    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();

    // A recorded commit that is a well-formed OID but is not in the object
    // database — a revision whose objects were never fetched.
    let absent = "a".repeat(40);
    assert!(
        git_repo
            .find_commit(git2::Oid::from_str(&absent).unwrap())
            .is_err(),
        "the placeholder commit must not be in the object database"
    );

    let root = write_raw_event(
        &git_repo,
        None,
        json!({
            "type": "patch.create",
            "title": "Objects never fetched",
            "body": "",
            "base_ref": "main",
            "branch": "main",
            "commit": absent,
            "tree": tree.to_string(),
            "base_commit": head.to_string(),
        }),
        1,
    );
    let id = root.to_string();
    git_repo
        .reference(
            &format!("refs/collab/patches/{}/events", id),
            root,
            false,
            "events",
        )
        .unwrap();

    // `main` exists and is a perfectly good branch name on this patch. Under
    // the old fallback that alone would have silently resolved the head to
    // main's tip — a different commit than the patch ever recorded.
    let stderr = repo.run_err(&["patch", "show", &id[..8]]);
    let lowered = stderr.to_lowercase();
    assert!(
        lowered.contains("commit") || lowered.contains("revision"),
        "the failure must say the recorded revision commit is what is missing, got: {}",
        stderr
    );
    assert!(
        stderr.contains(&absent[..8]) || lowered.contains("missing") || lowered.contains("not "),
        "the failure must identify what could not be resolved, got: {}",
        stderr
    );
}