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
);
}