tests/merge_recording_test.rs
Ref: Size: 45.4 KiB History
//! Merges are recorded as events, not derived on read.
//!
//! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
//!
//! Layer 3 (`patch merge`) and layer 2 (sync scanning `Patch:` trailers) are
//! covered here, along with the demotion of reachability detection to a hint
//! that never writes. Every test below writes the trailer by hand, which is
//! exactly what a user without the hook does — and what everyone does for
//! commits that predate it, since `patch create --stamp` is still out.
//! Layer 1, the `commit-msg` hook, is in `tests/commit_msg_hook_test.rs`.
mod common;
use std::process::Command;
use serde_json::Value;
use tempfile::TempDir;
use common::TestRepo;
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
/// A `TestRepo` with a local bare `origin` it can sync against. Never a real
/// network remote. The returned `TempDir` owns the bare repo — keep it alive.
fn repo_with_origin() -> (TestRepo, TempDir) {
let bare = TempDir::new().unwrap();
let status = Command::new("git")
.args(["init", "--bare", "-b", "main"])
.arg(bare.path())
.status()
.unwrap();
assert!(status.success());
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
repo.git(&["push", "-u", "origin", "main"]);
repo.run_ok(&["init"]);
(repo, bare)
}
fn show_json(repo: &TestRepo, id: &str) -> Value {
serde_json::from_str(&repo.run_ok(&["patch", "show", id, "--json"])).unwrap()
}
fn issue_json(repo: &TestRepo, id: &str) -> Value {
serde_json::from_str(&repo.run_ok(&["issue", "show", id, "--json"])).unwrap()
}
/// Create a branch with one commit whose message carries `Patch: <id>`, and a
/// patch for it. Returns the patch's short id. The trailer has to be written
/// before the patch exists to know the id, so this creates the patch first and
/// then amends the trailer in — which is what `patch create --stamp` (layer 1,
/// out of scope) would do for the user.
fn patch_with_trailer(repo: &TestRepo, branch: &str, file: &str) -> String {
repo.git(&["checkout", "-b", branch]);
repo.commit_file(file, "content", &format!("work on {}", branch));
let out = repo.run_ok(&["patch", "create", "-t", branch, "-B", branch]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
stamp_head(repo, &short);
repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
repo.git(&["checkout", "main"]);
short
}
/// Amend HEAD's message to carry a `Patch:` trailer.
fn stamp_head(repo: &TestRepo, patch_id: &str) {
let message = repo.git(&["log", "-1", "--format=%B"]);
let stamped = format!("{}\n\nPatch: {}\n", message.trim_end(), patch_id);
repo.git(&["commit", "--amend", "-m", &stamped]);
}
/// Squash-merge `branch` into the current branch, keeping the source commit's
/// message (and therefore its trailer) verbatim.
fn squash_merge_keeping_message(repo: &TestRepo, branch: &str) {
let message = repo.git(&["log", "-1", "--format=%B", branch]);
repo.git(&["merge", "--squash", branch]);
repo.git(&["commit", "-m", message.trim_end()]);
}
fn count_events(repo: &TestRepo, ref_name: &str) -> usize {
repo.git(&["rev-list", "--count", ref_name])
.trim()
.parse()
.unwrap()
}
fn patch_events_ref(repo: &TestRepo, short: &str) -> String {
let out = repo.git(&[
"for-each-ref",
"--format=%(refname)",
"refs/collab/patches/",
]);
out.lines()
.find(|r| r.contains(short) && r.ends_with("/events"))
.unwrap_or_else(|| panic!("no events ref for {} in {}", short, out))
.to_string()
}
/// The issue's ref wherever it currently lives — `close` archives it and
/// `reopen` moves it back, so neither namespace alone will do.
fn issue_ref_of(repo: &TestRepo, short: &str) -> String {
let out = repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]);
out.lines()
.find(|r| r.contains(short) && r.contains("issues/"))
.unwrap_or_else(|| panic!("no issue ref for {} in {}", short, out))
.to_string()
}
/// Like `patch_with_trailer`, but the patch declares the issue it fixes.
fn patch_fixing_issue(repo: &TestRepo, branch: &str, file: &str, issue: &str) -> String {
repo.git(&["checkout", "-b", branch]);
repo.commit_file(file, "content", &format!("work on {}", branch));
let out = repo.run_ok(&[
"patch", "create", "-t", branch, "-B", branch, "--fixes", issue,
]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
stamp_head(repo, &short);
repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
repo.git(&["checkout", "main"]);
short
}
/// Run `git` in another working tree, under `env_from`'s isolated HOME so the
/// signing key and git config are the test's, not the developer's.
fn git_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String {
let mut command = Command::new("git");
env_from.apply_env(&mut command);
let output = command.args(args).current_dir(dir).output().unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
/// Run `git-collab` in another working tree, same isolation as `git_in`.
fn collab_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String {
let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab"));
env_from.apply_env(&mut command);
let output = command.args(args).current_dir(dir).output().unwrap();
assert!(
output.status.success(),
"git-collab {:?} failed:\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
// ---------------------------------------------------------------------------
// The event
// ---------------------------------------------------------------------------
#[test]
fn patch_merge_event_carries_the_commit_that_landed_it() {
use git_collab::event::{Action, Author, Event};
let event = Event {
timestamp: "2026-08-09T12:00:00Z".to_string(),
author: Author {
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
},
action: Action::PatchMerge {
commit: "4b2e1cd0123456789012345678901234567890ab".to_string(),
},
clock: 4,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"patch.merge\""), "{}", json);
assert!(
json.contains("\"commit\":\"4b2e1cd0123456789012345678901234567890ab\""),
"{}",
json
);
let parsed: Event = serde_json::from_str(&json).unwrap();
match parsed.action {
Action::PatchMerge { commit } => {
assert_eq!(commit, "4b2e1cd0123456789012345678901234567890ab")
}
other => panic!("expected PatchMerge, got {:?}", other),
}
}
#[test]
fn a_patch_merge_written_before_the_commit_field_existed_still_reads() {
// Signatures are checked by re-serializing, so a legacy `patch.merge` must
// both deserialize and round-trip back to the bytes its signer produced.
use git_collab::event::{Action, Event};
let raw = r#"{"timestamp":"2026-01-01T00:00:00Z","author":{"name":"A","email":"a@b.c"},"action":{"type":"patch.merge"},"clock":2}"#;
let parsed: Event = serde_json::from_str(raw).unwrap();
match &parsed.action {
Action::PatchMerge { commit } => assert_eq!(commit, ""),
other => panic!("expected PatchMerge, got {:?}", other),
}
let round_tripped = serde_json::to_string(&parsed).unwrap();
assert!(
!round_tripped.contains("\"commit\""),
"an empty commit must not be written back, or legacy signatures break: {}",
round_tripped
);
}
// ---------------------------------------------------------------------------
// Layer 3: `patch merge`
// ---------------------------------------------------------------------------
#[test]
fn patch_merge_records_the_merge() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
let json = show_json(&repo, &short);
assert_eq!(json["status"], "merged");
}
#[test]
fn patch_merge_records_the_base_tip_as_the_landing_commit() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
squash_merge_keeping_message(&repo, "feat");
let landed = repo.git(&["rev-parse", "main"]).trim().to_string();
repo.run_ok(&["patch", "merge", &short]);
let log = repo.run_ok(&["log"]);
assert!(
log.contains(&landed[..7]),
"the recorded merge commit should be the base tip {}: {}",
&landed[..7],
log
);
}
#[test]
fn merged_state_survives_branch_deletion_and_a_cold_cache() {
// The case that fails without recording: deleting the merged branch used
// to send the patch back to Open the moment the state cache was cleared.
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
repo.git(&["branch", "-D", "feat"]);
let cache = repo.dir.path().join(".git").join("collab").join("cache");
if cache.exists() {
std::fs::remove_dir_all(&cache).unwrap();
}
assert_eq!(show_json(&repo, &short)["status"], "merged");
}
#[test]
fn patch_merge_twice_records_nothing_the_second_time() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
let events_ref = patch_events_ref(&repo, &short);
let after_first = count_events(&repo, &events_ref);
let out = repo.run_ok(&["patch", "merge", &short]);
assert!(
out.contains("already"),
"a second merge should say so, got: {}",
out
);
assert_eq!(
count_events(&repo, &events_ref),
after_first,
"recording a merge twice must append nothing the second time"
);
}
#[test]
fn patch_merge_closes_the_fixed_issue() {
let repo = TestRepo::new("Alice", "alice@example.com");
let issue = repo.issue_open("Broken thing");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the fix");
let out = repo.run_ok(&[
"patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
assert_eq!(issue_json(&repo, &issue)["status"], "closed");
}
// ---------------------------------------------------------------------------
// Layer 2: sync scans `Patch:` trailers on the patch's own base branch
// ---------------------------------------------------------------------------
#[test]
fn sync_records_a_squash_merge_whose_trailer_survived() {
let (repo, _bare) = repo_with_origin();
let short = patch_with_trailer(&repo, "feat", "a.txt");
squash_merge_keeping_message(&repo, "feat");
assert_eq!(
show_json(&repo, &short)["status"],
"open",
"precondition: reachability cannot see a squash"
);
repo.run_ok(&["sync"]);
assert_eq!(show_json(&repo, &short)["status"], "merged");
}
#[test]
fn sync_does_not_record_a_squash_whose_message_was_rewritten() {
let (repo, _bare) = repo_with_origin();
let short = patch_with_trailer(&repo, "feat", "a.txt");
repo.git(&["merge", "--squash", "feat"]);
repo.git(&["commit", "-m", "a message with no trailer at all"]);
repo.run_ok(&["sync"]);
assert_eq!(show_json(&repo, &short)["status"], "open");
// Layer 3 is what covers this.
repo.run_ok(&["patch", "merge", &short]);
assert_eq!(show_json(&repo, &short)["status"], "merged");
}
#[test]
fn a_second_sync_after_a_recorded_merge_emits_nothing() {
let (repo, _bare) = repo_with_origin();
let short = patch_with_trailer(&repo, "feat", "a.txt");
squash_merge_keeping_message(&repo, "feat");
repo.run_ok(&["sync"]);
let events_ref = patch_events_ref(&repo, &short);
let after_first = count_events(&repo, &events_ref);
repo.run_ok(&["sync"]);
assert_eq!(
count_events(&repo, &events_ref),
after_first,
"the trailer is still in history; the second scan must emit nothing"
);
}
#[test]
fn a_trailer_on_a_branch_that_is_not_the_patches_base_does_not_record_a_merge() {
let (repo, _bare) = repo_with_origin();
let short = patch_with_trailer(&repo, "feat", "a.txt");
// Land the patch on a *different* branch. `main` — the patch's base — never
// sees the trailer.
repo.git(&["checkout", "-b", "someone-elses-branch"]);
squash_merge_keeping_message(&repo, "feat");
repo.git(&["checkout", "main"]);
repo.run_ok(&["sync"]);
assert_eq!(show_json(&repo, &short)["status"], "open");
}
#[test]
fn an_unknown_patch_id_in_a_trailer_warns_and_leaves_sync_successful() {
let (repo, _bare) = repo_with_origin();
// A base branch with no open patches is not walked at all, so there has to
// be something for the walk to be for.
let open = patch_with_trailer(&repo, "feat", "a.txt");
repo.commit_file(
"x.txt",
"x",
"land something\n\nPatch: ffffffffffffffffffffffff",
);
let out = repo.run(&["sync"]);
assert!(out.status.success(), "sync must not fail on a bad trailer");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("ffffffff"),
"expected a warning naming the unresolvable id, got: {}",
stderr
);
assert_eq!(show_json(&repo, &open)["status"], "open");
}
/// Give the patch whose events ref is `events_ref` a second ref under a
/// near-identical id, so any 8-character prefix of the real id matches two
/// patches. Random ids practically never collide, so ambiguity has to be
/// constructed.
fn duplicate_patch_ref_with_colliding_id(repo: &TestRepo, events_ref: &str) -> String {
let id = events_ref
.strip_prefix("refs/collab/patches/")
.and_then(|r| r.strip_suffix("/events"))
.unwrap();
// Flip the last hex digit: everything up to it still matches.
let last = id.chars().last().unwrap();
let replacement = if last == '0' { '1' } else { '0' };
let twin: String = id[..id.len() - 1].chars().chain([replacement]).collect();
let tip = repo.git(&["rev-parse", events_ref]).trim().to_string();
repo.git(&[
"update-ref",
&format!("refs/collab/patches/{}/events", twin),
&tip,
]);
twin
}
#[test]
fn an_ambiguous_patch_prefix_in_a_trailer_warns_and_leaves_sync_successful() {
let (repo, _bare) = repo_with_origin();
let short = patch_with_trailer(&repo, "feat", "a.txt");
let events_ref = patch_events_ref(&repo, &short);
duplicate_patch_ref_with_colliding_id(&repo, &events_ref);
repo.commit_file("x.txt", "x", &format!("land something\n\nPatch: {}", short));
let out = repo.run(&["sync"]);
assert!(
out.status.success(),
"sync must not fail on an ambiguous trailer"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("ambiguous"),
"expected an ambiguity warning, got: {}",
stderr
);
assert_eq!(count_events(&repo, &events_ref), 2, "nothing was recorded");
}
#[test]
fn a_trailer_naming_an_archived_patch_warns_and_leaves_sync_successful() {
let (repo, _bare) = repo_with_origin();
// One patch stays open so `main` is walked at all; the other is archived.
let _open = patch_with_trailer(&repo, "still-open", "b.txt");
let short = patch_with_trailer(&repo, "feat", "a.txt");
// `patch close` archives the ref.
repo.run_ok(&["patch", "close", &short]);
squash_merge_keeping_message(&repo, "feat");
let out = repo.run(&["sync"]);
assert!(
out.status.success(),
"sync must not fail on an archived patch"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("archived"),
"expected an archived-patch warning, got: {}",
stderr
);
assert_eq!(show_json(&repo, &short)["status"], "closed");
}
// ---------------------------------------------------------------------------
// `--fixes` closes its issue in the same operation that records the merge
// ---------------------------------------------------------------------------
#[test]
fn fixes_closes_the_issue_exactly_once_across_repeated_syncs() {
let (repo, _bare) = repo_with_origin();
let issue = repo.issue_open("Broken thing");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the fix");
let out = repo.run_ok(&[
"patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
stamp_head(&repo, &short);
repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
repo.git(&["checkout", "main"]);
squash_merge_keeping_message(&repo, "feat");
repo.run_ok(&["sync"]);
assert_eq!(issue_json(&repo, &issue)["status"], "closed");
let issue_ref = repo
.git(&[
"for-each-ref",
"--format=%(refname)",
"refs/collab/archive/issues/",
])
.lines()
.find(|r| r.contains(&issue))
.unwrap()
.to_string();
let after_first = count_events(&repo, &issue_ref);
repo.run_ok(&["sync"]);
repo.run_ok(&["sync"]);
assert_eq!(
count_events(&repo, &issue_ref),
after_first,
"repeated syncs must not append a second IssueClose"
);
}
#[test]
fn a_fixes_issue_that_is_already_closed_gets_no_further_close() {
let (repo, _bare) = repo_with_origin();
let issue = repo.issue_open("Broken thing");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the fix");
let out = repo.run_ok(&[
"patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
stamp_head(&repo, &short);
repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
repo.git(&["checkout", "main"]);
repo.issue_close(&issue);
let issue_ref = repo
.git(&[
"for-each-ref",
"--format=%(refname)",
"refs/collab/archive/issues/",
])
.lines()
.find(|r| r.contains(&issue))
.unwrap()
.to_string();
let before = count_events(&repo, &issue_ref);
squash_merge_keeping_message(&repo, "feat");
repo.run_ok(&["sync"]);
assert_eq!(show_json(&repo, &short)["status"], "merged");
assert_eq!(
count_events(&repo, &issue_ref),
before,
"the merge is recorded, but an already-closed issue gets no IssueClose"
);
}
#[test]
fn a_close_that_did_not_happen_with_the_merge_is_retried_by_the_next_scan() {
// The merge and the close write to different refs, so they are not atomic.
// If the close does not land, the merge still stands — and the next scan,
// seeing a merged patch with an open `fixes` issue, must retry rather than
// leave the issue open forever. Simulated here by recording the merge with
// `patch merge --no-close`, which is precisely the "merge landed, close did
// not" state.
let (repo, _bare) = repo_with_origin();
let issue = repo.issue_open("Broken thing");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the fix");
let out = repo.run_ok(&[
"patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
stamp_head(&repo, &short);
repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
repo.git(&["checkout", "main"]);
squash_merge_keeping_message(&repo, "feat");
repo.run_ok(&["patch", "merge", &short, "--no-close"]);
assert_eq!(show_json(&repo, &short)["status"], "merged");
assert_eq!(
issue_json(&repo, &issue)["status"],
"open",
"precondition: the merge landed and the close did not"
);
repo.run_ok(&["sync"]);
assert_eq!(
issue_json(&repo, &issue)["status"],
"closed",
"the next scan must retry the close it could not make atomic"
);
}
// ---------------------------------------------------------------------------
// Reachability is a hint, and never writes
// ---------------------------------------------------------------------------
#[test]
fn a_reachable_patch_with_no_merge_event_shows_as_merged_with_a_question_mark() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
let list = repo.run_ok(&["patch", "list"]);
assert!(
list.contains("merged?"),
"a reachable patch with no recorded merge is a hint, not a fact: {}",
list
);
assert_eq!(
show_json(&repo, &short)["status"],
"open",
"the hint must not become the status"
);
}
#[test]
fn displaying_a_reachable_patch_writes_no_event() {
// The governing constraint: recording is never a side effect of a read.
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
let events_ref = patch_events_ref(&repo, &short);
let before = count_events(&repo, &events_ref);
let tip_before = repo.git(&["rev-parse", &events_ref]).trim().to_string();
repo.run_ok(&["patch", "list"]);
repo.run_ok(&["patch", "show", &short]);
repo.run_ok(&["patch", "show", &short, "--json"]);
repo.run_ok(&["patch", "log", &short]);
assert_eq!(count_events(&repo, &events_ref), before);
assert_eq!(
repo.git(&["rev-parse", &events_ref]).trim(),
tip_before,
"reading a patch must not move its ref"
);
}
#[test]
fn sync_names_the_patches_that_look_merged_but_are_not_recorded() {
let (repo, _bare) = repo_with_origin();
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
let out = repo.run_ok(&["sync"]);
assert!(
out.contains(&short) && out.contains("patch merge"),
"sync should name the patch and the command that records it: {}",
out
);
}
#[test]
fn a_recorded_merge_is_not_reported_as_a_hint() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
let list = repo.run_ok(&["patch", "list", "--all"]);
assert!(list.contains("merged"), "{}", list);
assert!(
!list.contains("merged?"),
"a recorded merge is a fact, not a hint: {}",
list
);
}
// ---------------------------------------------------------------------------
// The walk bound must be an ancestor of *every* recorded base
// ---------------------------------------------------------------------------
#[test]
fn common_ancestor_is_an_ancestor_of_every_base_whatever_the_order() {
// `merge_base_many` is the obvious call and the wrong one: it computes
// `merge_base(oids[0], merge(oids[1..]))`, so on a linear chain A-B-C-D-E
// it answers A for `[A, B, D]` but B for `[B, A, D]` — and B is not an
// ancestor of A. Feeding it the orderings it gets wrong is the only way to
// tell the two implementations apart.
//
// This needs *three* bases. For two, `merge_base_many` degenerates to the
// symmetric binary `merge_base` and is correct, so no two-base test — at
// this level or end to end — can distinguish them.
let repo = TestRepo::new("Alice", "alice@example.com");
let mut chain = Vec::new();
for name in ["A", "B", "C", "D", "E"] {
chain.push(repo.commit_file("f.txt", name, name));
}
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
let oid = |s: &String| git2::Oid::from_str(s).unwrap();
let (a, b, d) = (oid(&chain[0]), oid(&chain[1]), oid(&chain[3]));
for order in [[a, b, d], [b, a, d], [d, a, b], [d, b, a]] {
let bound = git_collab::merge_scan::common_ancestor(&git_repo, &order)
.expect("a linear chain always has a common ancestor");
for base in order {
let is_ancestor =
bound == base || git_repo.graph_descendant_of(base, bound).unwrap_or(false);
assert!(
is_ancestor,
"bound {:.8} must be an ancestor of every base, but is not of {:.8}",
bound, base
);
}
assert_eq!(bound, a, "the bound should be the oldest base");
}
}
#[test]
fn common_ancestor_of_nothing_is_nothing() {
let repo = TestRepo::new("Alice", "alice@example.com");
let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
assert!(git_collab::merge_scan::common_ancestor(&git_repo, &[]).is_none());
}
#[test]
fn sync_records_a_merge_older_than_another_open_patchs_base() {
// The multi-base bound, end to end. Three open patches share `main` with
// bases at three points of its history, and the oldest one's merge sits
// below the other two's bases. A bound that is not an ancestor of *every*
// base hides that merge commit and the scan records nothing — silently, and
// for a squash, so `looks_merged` cannot flag it either.
let (repo, _bare) = repo_with_origin();
// P1, based at main's first commit, squash-merged onto main.
let p1 = patch_with_trailer(&repo, "feat1", "a.txt");
squash_merge_keeping_message(&repo, "feat1");
// main moves on, and P2 is based there.
repo.commit_file("upstream1.txt", "u1", "upstream work");
let p2 = patch_with_trailer(&repo, "feat2", "b.txt");
// main moves again, and P3 is based there. Three distinct bases.
repo.commit_file("upstream2.txt", "u2", "more upstream work");
let p3 = patch_with_trailer(&repo, "feat3", "c.txt");
repo.run_ok(&["sync"]);
assert_eq!(
show_json(&repo, &p1)["status"],
"merged",
"the merge sits below the other patches' bases; the bound must not hide it"
);
assert_eq!(show_json(&repo, &p2)["status"], "open");
assert_eq!(show_json(&repo, &p3)["status"], "open");
}
// ---------------------------------------------------------------------------
// The retry must not override a deliberate reopen
// ---------------------------------------------------------------------------
#[test]
fn an_issue_reopened_after_a_merge_closed_it_stays_open() {
// Reopening a merged patch's issue is ordinary — the fix landed and turned
// out to be wrong. The retry exists to restore a close that never happened;
// it must never override a decision made after one did. Without the reopen
// clause this re-closes on every sync, forever, and silently.
let (repo, _bare) = repo_with_origin();
let issue = repo.issue_open("Broken thing");
let _short = patch_fixing_issue(&repo, "feat", "a.txt", &issue);
squash_merge_keeping_message(&repo, "feat");
repo.run_ok(&["sync"]);
assert_eq!(issue_json(&repo, &issue)["status"], "closed");
repo.run_ok(&["issue", "reopen", &issue]);
assert_eq!(issue_json(&repo, &issue)["status"], "open");
let after_reopen = count_events(&repo, &issue_ref_of(&repo, &issue));
repo.run_ok(&["sync"]);
repo.run_ok(&["sync"]);
assert_eq!(
issue_json(&repo, &issue)["status"],
"open",
"a deliberate reopen must survive the retry"
);
assert_eq!(
count_events(&repo, &issue_ref_of(&repo, &issue)),
after_reopen,
"and nothing should have been appended trying"
);
}
#[test]
fn patch_merge_does_not_reclose_an_issue_reopened_after_its_merge() {
// The same guard on the hand-recorded path. `patch merge` on an
// already-merged patch is a no-op, and it must not smuggle a re-close in.
let repo = TestRepo::new("Alice", "alice@example.com");
let issue = repo.issue_open("Broken thing");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the fix");
let out = repo.run_ok(&[
"patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
assert_eq!(issue_json(&repo, &issue)["status"], "closed");
repo.run_ok(&["issue", "reopen", &issue]);
repo.run_ok(&["patch", "merge", &short]);
assert_eq!(issue_json(&repo, &issue)["status"], "open");
}
#[test]
fn a_reopened_issue_is_still_closed_by_a_different_merged_patch() {
// The guard is scoped to the patch whose close was overridden, not to the
// issue. Another patch naming the same issue has had no decision of its own
// overridden, so its close still fires.
let (repo, _bare) = repo_with_origin();
let issue = repo.issue_open("Broken thing");
let first = patch_fixing_issue(&repo, "feat1", "a.txt", &issue);
squash_merge_keeping_message(&repo, "feat1");
repo.run_ok(&["sync"]);
assert_eq!(issue_json(&repo, &issue)["status"], "closed");
repo.run_ok(&["issue", "reopen", &issue]);
let second = patch_fixing_issue(&repo, "feat2", "b.txt", &issue);
squash_merge_keeping_message(&repo, "feat2");
repo.run_ok(&["sync"]);
assert_eq!(show_json(&repo, &first)["status"], "merged");
assert_eq!(show_json(&repo, &second)["status"], "merged");
assert_eq!(
issue_json(&repo, &issue)["status"],
"closed",
"the second patch's close has not been overridden by anyone"
);
}
// ---------------------------------------------------------------------------
// Two clones recording the same merge converge
// ---------------------------------------------------------------------------
#[test]
fn two_clones_recording_a_merge_concurrently_converge() {
let (alice, bare) = repo_with_origin();
alice.git(&["checkout", "-b", "feat"]);
alice.commit_file("a.txt", "x", "the patch");
let out = alice.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
alice.git(&["checkout", "main"]);
alice.git(&["merge", "--ff-only", "feat"]);
alice.git(&["push", "origin", "main"]);
alice.run_ok(&["sync"]);
// Bob clones the same bare remote and picks up the patch.
let bob_root = TempDir::new().unwrap();
let bob = bob_root.path().join("clone");
git_in(
&alice,
bob_root.path(),
&["clone", bare.path().to_str().unwrap(), "clone"],
);
git_in(&alice, &bob, &["config", "user.name", "Bob"]);
git_in(&alice, &bob, &["config", "user.email", "bob@example.com"]);
git_in(&alice, &bob, &["config", "collab.autoSync", "false"]);
collab_in(&alice, &bob, &["init"]);
collab_in(&alice, &bob, &["sync"]);
// Both record the merge without having seen the other's event.
alice.run_ok(&["patch", "merge", &short]);
collab_in(&alice, &bob, &["patch", "merge", &short]);
// Alice pushes first, so Bob's sync has to reconcile a genuinely divergent
// DAG rather than fast-forward.
alice.run_ok(&["sync"]);
collab_in(&alice, &bob, &["sync"]);
alice.run_ok(&["sync"]);
let bob_json: Value = serde_json::from_str(&collab_in(
&alice,
&bob,
&["patch", "show", &short, "--json"],
))
.unwrap();
assert_eq!(show_json(&alice, &short)["status"], "merged");
assert_eq!(bob_json["status"], "merged");
let events_ref = patch_events_ref(&alice, &short);
assert_eq!(
alice.git(&["rev-parse", &events_ref]).trim(),
git_in(&alice, &bob, &["rev-parse", &events_ref]).trim(),
"both clones must end at the same DAG tip"
);
}
// ---------------------------------------------------------------------------
// The hint is reported in --json too: beside the status, never inside it
// ---------------------------------------------------------------------------
#[test]
fn json_reports_the_merge_hint_beside_the_status() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
// Not merged: no hint at all, so its absence never reads as an assertion.
let json = show_json(&repo, &short);
assert_eq!(json["status"], "open");
assert!(json.get("looks_merged").is_none(), "{}", json);
repo.git(&["merge", "--ff-only", "feat"]);
let json = show_json(&repo, &short);
assert_eq!(json["status"], "open", "the hint is never the status");
assert_eq!(json["looks_merged"], true);
let list: Value = serde_json::from_str(&repo.run_ok(&["patch", "list", "--json"])).unwrap();
assert_eq!(list[0]["status"], "open");
assert_eq!(
list[0]["looks_merged"], true,
"list and show render the same entries; the hint has to be in both"
);
// Once recorded it is a fact, and the hint goes away.
repo.run_ok(&["patch", "merge", &short]);
let json = show_json(&repo, &short);
assert_eq!(json["status"], "merged");
assert!(json.get("looks_merged").is_none(), "{}", json);
}
// ---------------------------------------------------------------------------
// The recorded merge commit has to reach the views
// ---------------------------------------------------------------------------
#[test]
fn the_recorded_merge_commit_reaches_show_json_and_patch_log() {
// The commit is the reason it is in the event at all: it is what a UI links
// to, and for a squash it is the only route from the patch back to the code
// — the patch's own commits are ancestors of nothing on the base branch.
// Folding it to a bare `status = merged` threw that away.
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
squash_merge_keeping_message(&repo, "feat");
let landed = repo.git(&["rev-parse", "main"]).trim().to_string();
// Before the merge is recorded there is no commit to report, and the field
// must not read as an assertion that one exists.
assert!(
show_json(&repo, &short)["merge_commit"].is_null(),
"an unmerged patch has no merge commit"
);
repo.run_ok(&["patch", "merge", &short]);
assert_eq!(
show_json(&repo, &short)["merge_commit"],
landed,
"the recorded commit must survive the fold into `patch show --json`"
);
let list: Value =
serde_json::from_str(&repo.run_ok(&["patch", "list", "--json", "--all"])).unwrap();
assert_eq!(
list[0]["merge_commit"], landed,
"list and show render the same entries; the commit has to be in both"
);
let show = repo.run_ok(&["patch", "show", &short]);
assert!(
show.contains(&landed[..8]),
"`patch show` must name the commit that landed the patch: {}",
show
);
let log = repo.run_ok(&["patch", "log", &short]);
assert!(
log.contains(&landed[..8]),
"`patch log` must name the commit that landed the patch: {}",
log
);
}
#[test]
fn displaying_a_merged_patch_writes_no_event() {
// The governing constraint, on the path that now renders the merge commit.
// Surfacing a recorded fact must stay a pure read.
let repo = TestRepo::new("Alice", "alice@example.com");
repo.git(&["checkout", "-b", "feat"]);
repo.commit_file("a.txt", "x", "the patch");
let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
repo.git(&["checkout", "main"]);
repo.git(&["merge", "--ff-only", "feat"]);
repo.run_ok(&["patch", "merge", &short]);
let events_ref = patch_events_ref(&repo, &short);
let before = count_events(&repo, &events_ref);
let tip_before = repo.git(&["rev-parse", &events_ref]).trim().to_string();
repo.run_ok(&["patch", "list", "--all"]);
repo.run_ok(&["patch", "show", &short]);
repo.run_ok(&["patch", "show", &short, "--json"]);
repo.run_ok(&["patch", "list", "--json", "--all"]);
repo.run_ok(&["patch", "log", &short]);
assert_eq!(count_events(&repo, &events_ref), before);
assert_eq!(
repo.git(&["rev-parse", &events_ref]).trim(),
tip_before,
"reading a merged patch must not move its ref"
);
}
#[test]
fn two_clones_recording_different_merge_commits_agree_on_one() {
// Concurrent recordings name different commits — one person points at the
// squash commit, another at a later one. `(clock, oid)` decides, exactly as
// it does for the status, so both clones fold the same event set to the
// same answer. A merged patch whose commit differed per clone would be
// worse than none: it is what a UI links to.
let (alice, bare) = repo_with_origin();
alice.git(&["checkout", "-b", "feat"]);
alice.commit_file("a.txt", "x", "the patch");
let out = alice.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
let short = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
alice.git(&["checkout", "main"]);
alice.git(&["merge", "--ff-only", "feat"]);
let first = alice.git(&["rev-parse", "main"]).trim().to_string();
// A second commit on main, so the two clones have two real commits to
// disagree about.
let second = alice.commit_file("b.txt", "y", "later work");
alice.git(&["push", "origin", "main"]);
alice.run_ok(&["sync"]);
let bob_root = TempDir::new().unwrap();
let bob = bob_root.path().join("clone");
git_in(
&alice,
bob_root.path(),
&["clone", bare.path().to_str().unwrap(), "clone"],
);
git_in(&alice, &bob, &["config", "user.name", "Bob"]);
git_in(&alice, &bob, &["config", "user.email", "bob@example.com"]);
git_in(&alice, &bob, &["config", "collab.autoSync", "false"]);
collab_in(&alice, &bob, &["init"]);
collab_in(&alice, &bob, &["sync"]);
assert_ne!(first, second, "the two commits must actually differ");
// Neither has seen the other's event.
alice.run_ok(&["patch", "merge", &short, "--commit", &first]);
collab_in(
&alice,
&bob,
&["patch", "merge", &short, "--commit", &second],
);
alice.run_ok(&["sync"]);
collab_in(&alice, &bob, &["sync"]);
alice.run_ok(&["sync"]);
let alice_commit = show_json(&alice, &short)["merge_commit"].clone();
let bob_json: Value = serde_json::from_str(&collab_in(
&alice,
&bob,
&["patch", "show", &short, "--json"],
))
.unwrap();
assert!(
alice_commit == Value::String(first.clone()) || alice_commit == Value::String(second),
"the winner must be one of the two recorded commits, got {}",
alice_commit
);
assert_eq!(
alice_commit, bob_json["merge_commit"],
"both clones must agree on which commit landed the patch"
);
}
// ---------------------------------------------------------------------------
// The local scans do not need a remote
// ---------------------------------------------------------------------------
#[test]
fn a_repo_with_no_remote_records_a_merge_and_links_a_commit() {
// Both scans are purely local: they walk local refs and append local
// events. Nothing about either needs a remote. A single clone with no
// remote is the most basic case of the project's premise that
// collaboration lives in the repository — and it is the state everyone is
// in *before* adding a remote, so this is also the first impression.
let repo = TestRepo::new("Alice", "alice@example.com");
// Deliberately no `git remote add` and no `git-collab init`.
let issue = repo.issue_open("Broken thing");
let short = patch_with_trailer(&repo, "feat", "a.txt");
squash_merge_keeping_message(&repo, "feat");
let linked = repo.commit_file("b.txt", "y", &format!("unrelated fix\n\nIssue: {}", issue));
let out = repo.run_ok(&["sync"]);
assert_eq!(
show_json(&repo, &short)["status"],
"merged",
"a repo with no remote must still record a merge from a Patch: trailer.\nsync said: {}",
out
);
let linked_commits = issue_json(&repo, &issue)["linked_commits"]
.as_array()
.expect("issue JSON should carry linked_commits")
.iter()
.map(|c| c["commit"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>();
assert!(
linked_commits.contains(&linked),
"a repo with no remote must still link a commit from an Issue: trailer, \
got {:?}.\nsync said: {}",
linked_commits,
out
);
}
#[test]
fn sync_with_no_remote_reports_what_it_did_instead_of_only_an_instruction() {
// Returning at the no-remotes gate printed a bare instruction and skipped
// the local half of sync's job entirely. It should still do that half and
// say so — while still pointing at `init`, since nothing is being shared.
let repo = TestRepo::new("Alice", "alice@example.com");
let short = patch_with_trailer(&repo, "feat", "a.txt");
squash_merge_keeping_message(&repo, "feat");
let out = repo.run_ok(&["sync"]);
assert!(
out.contains("Recorded 1 merged patch(es)."),
"sync must report the merge it recorded: {}",
out
);
assert!(
out.contains("git-collab init"),
"and still say how to get a remote: {}",
out
);
assert_eq!(show_json(&repo, &short)["status"], "merged");
}
#[test]
fn a_second_sync_with_no_remote_records_nothing_further() {
// The no-remote path appends events, so it has to be as idempotent as the
// remote one: a trailer stays in history forever.
let repo = TestRepo::new("Alice", "alice@example.com");
let short = patch_with_trailer(&repo, "feat", "a.txt");
squash_merge_keeping_message(&repo, "feat");
repo.run_ok(&["sync"]);
let events_ref = patch_events_ref(&repo, &short);
let after_first = count_events(&repo, &events_ref);
repo.run_ok(&["sync"]);
repo.run_ok(&["sync"]);
assert_eq!(
count_events(&repo, &events_ref),
after_first,
"repeated syncs with no remote must append nothing further"
);
}