a73x

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