tests/revision_refs_test.rs
Ref: Size: 27.3 KiB History
//! Patches carried as revision refs rather than branches.
//!
//! Each revision is pinned by a ref in the patch's own namespace:
//!
//! refs/collab/patches/<id>/events the event DAG
//! refs/collab/patches/<id>/rev/<oid> a revision's commit, pinned
//!
//! so a patch and every revision it ever had travel under the refspecs `sync`
//! already uses, and nothing depends on a `refs/heads/*` ref surviving.
//!
//! The refs are named by commit OID, so a name is never negotiated between
//! clones and never moves. Revision *numbering* lives in the DAG alone.
mod common;
use common::{write_raw_event, TestRepo};
use serde_json::json;
use tempfile::TempDir;
/// Every ref under the patch namespace, sorted, as `git for-each-ref` sees it.
fn patch_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
}
fn ref_target(repo: &TestRepo, name: &str) -> Option<String> {
let out = repo.git(&["for-each-ref", "--format=%(objectname)", name]);
let trimmed = out.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// The ref that pins `commit` for patch `id`.
fn rev_ref(id: &str, commit: &str) -> String {
format!("refs/collab/patches/{}/rev/{}", id, commit)
}
/// Every commit pinned for `id`, read back out of the ref names, sorted.
fn pinned_commits(repo: &TestRepo, id: &str) -> Vec<String> {
let prefix = format!("refs/collab/patches/{}/rev/", id);
let mut pinned: Vec<String> = patch_refs(repo)
.iter()
.filter_map(|r| r.strip_prefix(&prefix).map(str::to_string))
.collect();
pinned.sort();
pinned
}
fn show_json(repo: &TestRepo, id: &str) -> serde_json::Value {
let out = repo.run_ok(&["patch", "show", id, "--json"]);
serde_json::from_str(&out).unwrap()
}
/// Whether reachability detection fires for this patch.
///
/// It is a hint, not a status: a merge is recorded by `Action::PatchMerge`, and
/// detection only ever *suggests* — it cannot see a squash, so its silence
/// proves nothing, and it never writes. `patch show` renders the suggestion as
/// `[merged?]`, which is what these tests read.
fn looks_merged(repo: &TestRepo, id: &str) -> bool {
repo.run_ok(&["patch", "show", id]).contains("[merged?")
}
/// Create a patch on a fresh branch holding one commit. Returns (short id, full
/// id, tip commit).
fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> (String, 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();
let full = full_id(repo, &short);
(short, full, tip)
}
/// Expand a short patch id to the full 40-char id via the ref listing.
fn full_id(repo: &TestRepo, short: &str) -> String {
for name in patch_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);
}
// ===========================================================================
// Ref layout
// ===========================================================================
#[test]
fn patch_create_writes_an_events_ref_and_pins_the_first_revision() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (_short, id, tip) = patch_on_branch(&repo, "feat", "a.txt");
let refs = patch_refs(&repo);
assert!(
refs.contains(&format!("refs/collab/patches/{}/events", id)),
"expected an events ref, got {:?}",
refs
);
assert!(
refs.contains(&rev_ref(&id, &tip)),
"expected the created commit to be pinned, got {:?}",
refs
);
assert!(
!refs.contains(&format!("refs/collab/patches/{}", id)),
"the bare <id> ref must be gone — git cannot have it be both a ref and \
a directory: {:?}",
refs
);
}
#[test]
fn a_revision_ref_is_named_by_the_commit_it_pins() {
// This is what makes write-once structural rather than enforced: a ref
// whose name is its content has nothing to move to, so two clones can never
// disagree about it and a push can never be a non-fast-forward.
let repo = TestRepo::new("Alice", "alice@example.com");
let (_short, id, tip) = patch_on_branch(&repo, "feat", "a.txt");
assert_eq!(
ref_target(&repo, &rev_ref(&id, &tip)).as_deref(),
Some(tip.as_str()),
"the ref named for a commit must point at that commit"
);
assert_eq!(pinned_commits(&repo, &id), vec![tip]);
}
#[test]
fn revise_pins_the_new_commit_and_leaves_earlier_ones_alone() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, id, r1_commit) = patch_on_branch(&repo, "feat", "a.txt");
let r2_commit = repo.commit_file("b.txt", "v2", "second commit");
repo.run_ok(&["patch", "revise", &short, "-b", "addressed review"]);
let mut expected = vec![r1_commit, r2_commit];
expected.sort();
assert_eq!(
pinned_commits(&repo, &id),
expected,
"both revisions must stay pinned"
);
let json = show_json(&repo, &short);
assert_eq!(json["revisions"].as_array().unwrap().len(), 2);
}
#[test]
fn a_revision_ref_keeps_its_commit_alive_after_the_branch_is_rewritten() {
// The live defect: a rebase-and-force-push used to leave revision 1's
// commit reachable from no ref at all, so `git gc` was entitled to delete
// the objects that `patch log`, `patch diff --revision 1` and every
// revision-anchored inline comment point at.
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, id, r1_commit) = patch_on_branch(&repo, "feat", "a.txt");
// Rewrite the branch out from under the patch, exactly as a rebase does.
repo.git(&["commit", "--amend", "-m", "rewritten"]);
let rewritten = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
assert_ne!(rewritten, r1_commit);
let containing = repo.git(&[
"for-each-ref",
"--format=%(refname)",
"--contains",
&r1_commit,
"refs/heads/",
]);
assert!(
containing.trim().is_empty(),
"the rewrite should have detached r1's commit from every branch, got {}",
containing
);
assert_eq!(
ref_target(&repo, &rev_ref(&id, &r1_commit)).as_deref(),
Some(r1_commit.as_str()),
"the revision ref is what keeps the commit reachable"
);
// And it is still usable: a historical diff resolves the objects.
let diff = repo.run_ok(&["patch", "diff", &short, "--revision", "1"]);
assert!(diff.contains("a.txt"), "r1 diff lost its content: {}", diff);
}
#[test]
fn nested_revision_refs_are_visible_to_the_patch_glob() {
// The design's central claim is that the existing `refs/collab/patches/*`
// patterns carry revision refs unchanged. That only holds if the glob
// crosses `/`, which is what enumeration and `sync`'s push list rely on.
let tmp = TempDir::new().unwrap();
let repo = git2::Repository::init(tmp.path()).unwrap();
let sig = git2::Signature::now("A", "a@example.com").unwrap();
let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
let tree = repo.find_tree(tree_oid).unwrap();
let oid = repo
.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
.unwrap();
repo.reference("refs/collab/patches/abc/events", oid, false, "t")
.unwrap();
let nested = format!("refs/collab/patches/abc/rev/{}", oid);
repo.reference(&nested, oid, false, "t").unwrap();
let names: Vec<String> = repo
.references_glob("refs/collab/patches/*")
.unwrap()
.filter_map(|r| r.ok()?.name().map(str::to_string))
.collect();
assert!(
names.contains(&nested),
"refs/collab/patches/* must match nested refs, got {:?}",
names
);
}
// ===========================================================================
// Revision recording is explicit
// ===========================================================================
#[test]
fn commenting_does_not_manufacture_a_revision() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.commit_file("b.txt", "v2", "work the user has not submitted");
repo.run_ok(&["patch", "comment", &short, "-b", "a thought"]);
let json = show_json(&repo, &short);
assert_eq!(
json["revisions"].as_array().unwrap().len(),
1,
"only `patch revise` records a revision"
);
}
#[test]
fn reviewing_does_not_manufacture_a_revision() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.commit_file("b.txt", "v2", "work the reviewer cannot see");
repo.run_ok(&["patch", "review", &short, "-v", "comment", "-b", "noted"]);
let json = show_json(&repo, &short);
assert_eq!(json["revisions"].as_array().unwrap().len(), 1);
assert_eq!(json["reviews"][0]["revision"], 1);
}
#[test]
fn revise_reads_head_by_default_and_a_named_branch_on_request() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "wt-1", "a.txt");
// A second worktree-style branch, with a name the patch has never heard of.
repo.git(&["checkout", "-b", "wt-2"]);
let r2 = repo.commit_file("b.txt", "v2", "more work");
repo.git(&["checkout", "main"]);
repo.run_ok(&["patch", "revise", &short, "-B", "wt-2"]);
let json = show_json(&repo, &short);
let revisions = json["revisions"].as_array().unwrap();
assert_eq!(revisions.len(), 2);
assert_eq!(revisions[1]["commit"], r2);
}
#[test]
fn every_revision_in_the_dag_stays_pinned_across_many_revises() {
// The invariant the refs exist for: whatever the DAG says a revision is,
// its objects are reachable. With OID names there is no collision to
// resolve, so this holds by construction — assert it anyway, because it is
// the property, and the numbered scheme it replaced could not keep it.
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, id, first) = patch_on_branch(&repo, "feat", "a.txt");
let mut commits = vec![first];
for n in 2..=4 {
commits.push(repo.commit_file(&format!("v{}.txt", n), "x", "more work"));
repo.run_ok(&["patch", "revise", &short]);
}
let json = show_json(&repo, &short);
let revisions = json["revisions"].as_array().unwrap();
assert_eq!(revisions.len(), 4);
let pinned = pinned_commits(&repo, &id);
for rev in revisions {
let commit = rev["commit"].as_str().unwrap();
assert!(
pinned.contains(&commit.to_string()),
"revision {} ({}) is not pinned; pinned: {:?}",
rev["number"],
commit,
pinned
);
}
commits.sort();
assert_eq!(pinned, commits, "nothing beyond the DAG should be pinned");
}
// ===========================================================================
// Identity is declared, not derived from a branch name
// ===========================================================================
#[test]
fn a_second_patch_for_the_same_commit_is_rejected_by_id_not_by_branch_name() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
let err = repo.run_err(&["patch", "create", "-t", "again", "-B", "feat"]);
assert!(
err.contains(&short),
"the duplicate error should name the existing patch, got {}",
err
);
}
#[test]
fn two_branches_with_generated_names_get_their_own_patches() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (first, _id, _tip) = patch_on_branch(&repo, "worktree-agent-aaaa", "a.txt");
repo.git(&["checkout", "main"]);
let (second, _id2, _tip2) = patch_on_branch(&repo, "worktree-agent-bbbb", "b.txt");
assert_ne!(first, second);
let out = repo.run_ok(&["patch", "list"]);
assert!(out.contains(&first) && out.contains(&second), "{}", out);
}
// ===========================================================================
// The merge hint, anchored to the latest revision's base
//
// These exercise reachability detection, which no longer decides the status —
// it only raises `merged?`. The discrimination it has to get right is the same
// either way, so the assertions moved from the status to the hint.
// ===========================================================================
#[test]
fn merging_the_base_branch_forward_makes_the_patch_look_merged() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
assert!(looks_merged(&repo, &short));
assert_eq!(
show_json(&repo, &short)["status"],
"open",
"the hint is not the status"
);
}
#[test]
fn an_unmerged_patch_raises_no_hint() {
// Guards the failure mode where dropping `base_commit` leaves `base_moved`
// permanently false — or permanently true — and the hint silently stops
// telling the truth.
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.git(&["checkout", "main"]);
repo.commit_file("unrelated.txt", "x", "main moves on its own");
assert!(!looks_merged(&repo, &short));
assert_eq!(show_json(&repo, &short)["status"], "open");
}
#[test]
fn the_merge_hint_survives_deleting_the_source_branch() {
// The hint used to go through `refs/heads/<branch>` and no-op silently when
// it was absent, so deleting the branch after merging left the patch with
// no diagnostic at all.
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.git(&["branch", "-D", "feat"]);
assert!(looks_merged(&repo, &short));
}
#[test]
fn a_recorded_merge_survives_deleting_the_source_branch() {
// The hint above is a convenience. This is the fact: once recorded, merged
// state does not depend on the branch, or on the base branch, existing at
// all — which is the case that used to revert to Open the moment the state
// cache went cold.
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
repo.git(&["branch", "-D", "feat"]);
assert_eq!(show_json(&repo, &short)["status"], "merged");
}
#[test]
fn the_merge_hint_reads_the_latest_revisions_base_not_the_first() {
// After a rebase the patch's base moves. Detection must compare against
// where the *latest* revision stood, not where revision 1 did.
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.git(&["checkout", "main"]);
repo.commit_file("upstream.txt", "u", "upstream work");
repo.git(&["checkout", "feat"]);
repo.git(&["rebase", "main"]);
repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]);
assert!(!looks_merged(&repo, &short), "rebasing is not merging");
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
assert!(looks_merged(&repo, &short));
}
fn tree_of(repo: &TestRepo, commit: &str) -> String {
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let tree = git_repo
.find_commit(git2::Oid::from_str(commit).unwrap())
.unwrap()
.tree()
.unwrap()
.id()
.to_string();
tree
}
/// A patch whose first revision records a base and whose second does not: a
/// `PatchCreate` carrying `base_commit`, followed by a `PatchRevision` written
/// before revisions recorded a base of their own. Returns the full id.
///
/// This is the shape `PatchState::effective_base` exists for, and it is a
/// permanent one — the merge-base was never computed for that second revision
/// and nothing can recover it. Issue `e5096ffc` removed the pre-release
/// compatibility around it and deliberately kept this, so the refs below are
/// the *current* layout: what makes the fixture legacy is the missing `base`
/// key, not the shape of its refs.
fn patch_with_baseless_revision(
repo: &TestRepo,
r1: &str,
base: &str,
r2: &str,
title: &str,
) -> String {
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let root = write_raw_event(
&git_repo,
None,
json!({
"type": "patch.create",
"title": title,
"body": "",
"base_ref": "main",
"branch": "feat",
"commit": r1,
"tree": tree_of(repo, r1),
"base_commit": base,
}),
1,
);
let tip = write_raw_event(
&git_repo,
Some(root),
json!({
"type": "patch.revision",
"commit": r2,
"tree": tree_of(repo, r2),
}),
2,
);
let id = root.to_string();
git_repo
.reference(
&format!("refs/collab/patches/{}/events", id),
tip,
false,
"events",
)
.unwrap();
id
}
#[test]
fn a_patch_with_a_baseless_revision_merged_by_exact_fast_forward_is_detected() {
// The latest revision predates `base`, which is the normal shape of a
// migrated patch. In the exact fast-forward case — main fast-forwarded onto
// the patch head, so base tip == head — recomputing a merge-base yields the
// head itself, so the base looks like it never moved and the merge goes
// unnoticed. That is the commonest merge for a single-commit patch, so it
// has to come from the base that was actually recorded.
let repo = TestRepo::new("Alice", "alice@example.com");
let base = repo.git(&["rev-parse", "main"]).trim().to_string();
repo.git(&["checkout", "-b", "feat"]);
let r1 = repo.commit_file("a.txt", "v1", "the patch");
let r2 = repo.commit_file("b.txt", "v2", "revision 2");
repo.git(&["checkout", "main"]);
let id = patch_with_baseless_revision(&repo, &r1, &base, &r2, "Migrated fast-forward");
repo.git(&["merge", "--ff-only", "feat"]);
assert_eq!(
repo.git(&["rev-parse", "main"]).trim(),
r2,
"precondition: base tip and patch head are the same commit"
);
assert!(looks_merged(&repo, &id[..8]));
}
#[test]
fn a_patch_with_a_baseless_revision_that_was_not_merged_stays_open() {
let repo = TestRepo::new("Alice", "alice@example.com");
let base = repo.git(&["rev-parse", "main"]).trim().to_string();
repo.git(&["checkout", "-b", "feat"]);
let r1 = repo.commit_file("a.txt", "v1", "the patch");
let r2 = repo.commit_file("b.txt", "v2", "revision 2");
repo.git(&["checkout", "main"]);
let id = patch_with_baseless_revision(&repo, &r1, &base, &r2, "Migrated unmerged");
// main moves, but not onto the patch.
repo.commit_file("unrelated.txt", "x", "upstream work");
assert_eq!(show_json(&repo, &id[..8])["status"], "open");
}
#[test]
fn a_patch_created_on_the_base_branch_is_not_reported_merged() {
// The degenerate case `base_moved` exists for: the recorded base IS the
// patch's own head, so the head is trivially reachable from the base tip
// without anything having been merged. Guards against widening the
// unknown-base handling until it swallows this. It stays a hint either
// way, but a hint that fires on every patch is worse than none.
let repo = TestRepo::new("Alice", "alice@example.com");
let tip = repo.git(&["rev-parse", "main"]).trim().to_string();
repo.git(&["branch", "feat"]);
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let root = write_raw_event(
&git_repo,
None,
json!({
"type": "patch.create",
"title": "Created on the base branch",
"body": "",
"base_ref": "main",
"branch": "feat",
"commit": tip,
"tree": tree_of(&repo, &tip),
"base_commit": tip,
}),
1,
);
let id = root.to_string();
git_repo
.reference(
&format!("refs/collab/patches/{}/events", id),
root,
false,
"events",
)
.unwrap();
drop(git_repo);
assert!(!looks_merged(&repo, &id[..8]));
assert_eq!(show_json(&repo, &id[..8])["status"], "open");
}
#[test]
fn merge_detection_reads_the_base_of_the_revision_it_resolved_the_head_from() {
// `resolve_head` walks back past revisions whose objects are gone. The base
// has to come from the same revision, or the head is compared against a
// base it never stood on.
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
let head = repo.commit_file("a.txt", "v1", "the patch");
repo.git(&["checkout", "main"]);
let base = repo.git(&["rev-parse", "main"]).trim().to_string();
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let tree = git_repo
.find_commit(git2::Oid::from_str(&head).unwrap())
.unwrap()
.tree()
.unwrap()
.id()
.to_string();
let root = write_raw_event(
&git_repo,
None,
json!({
"type": "patch.create",
"title": "Head from r1, base must follow",
"body": "",
"base_ref": "main",
"branch": "feat",
"commit": head,
"tree": tree,
"base_commit": base,
}),
1,
);
// Revision 2 points at objects that are not in this repository, so
// `resolve_head` falls back to revision 1.
let tip = write_raw_event(
&git_repo,
Some(root),
json!({
"type": "patch.revision",
"commit": "2222222222222222222222222222222222222222",
"tree": tree,
"base": "3333333333333333333333333333333333333333",
}),
2,
);
let id = root.to_string();
git_repo
.reference(
&format!("refs/collab/patches/{}/events", id),
tip,
false,
"events",
)
.unwrap();
drop(git_repo);
repo.git(&["merge", "--ff-only", "feat"]);
assert!(
looks_merged(&repo, &id[..8]),
"head came from r1, so the base must come from r1 too"
);
}
#[test]
fn each_revision_records_the_base_it_was_written_against() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
let base1 = repo.git(&["rev-parse", "main"]).trim().to_string();
repo.git(&["checkout", "main"]);
repo.commit_file("upstream.txt", "u", "upstream work");
let base2 = repo.git(&["rev-parse", "main"]).trim().to_string();
repo.git(&["checkout", "feat"]);
repo.git(&["rebase", "main"]);
repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]);
let json = show_json(&repo, &short);
let revisions = json["revisions"].as_array().unwrap();
assert_eq!(revisions[0]["base"], base1);
assert_eq!(revisions[1]["base"], base2);
}
#[test]
fn a_revision_recorded_without_a_rebase_keeps_the_same_base() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
let base = repo.git(&["rev-parse", "main"]).trim().to_string();
repo.commit_file("b.txt", "v2", "more work, same base");
repo.run_ok(&["patch", "revise", &short]);
let json = show_json(&repo, &short);
let revisions = json["revisions"].as_array().unwrap();
assert_eq!(revisions[0]["base"], base);
assert_eq!(
revisions[1]["base"], base,
"no rebase happened, so the base must not move"
);
}
// ===========================================================================
// Revision-anchored review data outlives a rebase
// ===========================================================================
#[test]
fn an_inline_comment_on_revision_one_still_resolves_after_a_rebase() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
repo.run_ok(&[
"patch", "comment", &short, "-b", "here", "--file", "a.txt", "--line", "1",
]);
repo.git(&["checkout", "main"]);
repo.commit_file("upstream.txt", "u", "upstream work");
repo.git(&["checkout", "feat"]);
repo.git(&["rebase", "main"]);
repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]);
let json = show_json(&repo, &short);
assert_eq!(json["inline_comments"][0]["revision"], 1);
let diff = repo.run_ok(&["patch", "diff", &short, "--revision", "1"]);
assert!(
diff.contains("a.txt"),
"revision 1's objects must still resolve: {}",
diff
);
}
// ===========================================================================
// Ref lifecycle: the whole subtree moves
// ===========================================================================
#[test]
fn closing_a_patch_archives_every_revision_ref() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, id, r1) = patch_on_branch(&repo, "feat", "a.txt");
let r2 = repo.commit_file("b.txt", "v2", "second commit");
repo.run_ok(&["patch", "revise", &short]);
repo.run_ok(&["patch", "close", &short, "-r", "not now"]);
let refs = patch_refs(&repo);
assert!(
refs.iter()
.all(|r| !r.starts_with(&format!("refs/collab/patches/{}/", id))),
"nothing may be left in the active namespace: {:?}",
refs
);
for suffix in [
"events".to_string(),
format!("rev/{}", r1),
format!("rev/{}", r2),
] {
let name = format!("refs/collab/archive/patches/{}/{}", id, suffix);
assert!(refs.contains(&name), "missing {} in {:?}", name, refs);
}
assert_eq!(
ref_target(
&repo,
&format!("refs/collab/archive/patches/{}/rev/{}", id, r1)
)
.as_deref(),
Some(r1.as_str())
);
// And the closed patch is still reviewable.
let json = show_json(&repo, &short);
assert_eq!(json["status"], "closed");
assert_eq!(json["revisions"].as_array().unwrap().len(), 2);
}
#[test]
fn deleting_a_patch_removes_every_ref_in_its_namespace() {
let repo = TestRepo::new("Alice", "alice@example.com");
let (short, id, _r1) = patch_on_branch(&repo, "feat", "a.txt");
repo.commit_file("b.txt", "v2", "second commit");
repo.run_ok(&["patch", "revise", &short]);
repo.run_ok(&["patch", "delete", &short]);
let refs = patch_refs(&repo);
assert!(
refs.iter().all(|r| !r.contains(&id)),
"patch namespace not fully removed: {:?}",
refs
);
}