a73x

tests/interdiff_test.rs

Ref:   Size: 16.6 KiB   History

//! Interdiff and per-revision diff behaviour.
//!
//! The defect these cover is a family: `interdiff` used to be a flat
//! tree-to-tree diff with no idea what base either revision stood on, so a
//! rebase between two revisions dragged every upstream commit rebased over
//! into the output and buried the author's actual response to review. The
//! related defects are the same mistake made at display time — recomputing a
//! merge-base when the answer was recorded at the time it was still true.

mod common;

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

/// Every path a unified diff touches, sorted and deduplicated.
///
/// Asserted as an exact set rather than "contains the file I care about":
/// the regression being prevented is extra files, and a `contains` assertion
/// is blind to exactly that.
fn changed_files(diff: &str) -> Vec<String> {
    let mut paths: Vec<String> = diff
        .lines()
        .filter_map(|l| l.strip_prefix("diff --git a/"))
        .map(|rest| rest.split(" b/").next().unwrap_or(rest).to_string())
        .collect();
    paths.sort();
    paths.dedup();
    paths
}

fn create_patch(repo: &TestRepo, branch: &str, title: &str) -> String {
    let out = repo.run_ok(&["patch", "create", "-t", title, "-B", branch]);
    out.trim()
        .strip_prefix("Created patch ")
        .unwrap_or_else(|| panic!("unexpected patch create output: {}", out))
        .to_string()
}

/// A patch with one real edit, an upstream that advanced three commits while
/// it sat in review, and an author who rebased before answering the review.
///
/// This is the measured 75%-noise case: the flat tree diff emitted four files,
/// one real and three pure upstream churn.
fn rebase_over_busy_upstream() -> (TestRepo, String) {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-rebase"]);
    repo.commit_file("feature.txt", "v1\n", "feature v1");
    let id = create_patch(&repo, "feat-rebase", "Rebase noise");

    // Upstream advances, touching only files the patch does not.
    repo.git(&["checkout", "main"]);
    repo.commit_file("up1.txt", "one\n", "upstream 1");
    repo.commit_file("up2.txt", "two\n", "upstream 2");
    repo.commit_file("up3.txt", "three\n", "upstream 3");

    // The author rebases, then answers the review.
    repo.git(&["checkout", "feat-rebase"]);
    repo.git(&["rebase", "main"]);
    repo.commit_file("feature.txt", "v2\n", "address review");
    repo.run_ok(&["patch", "revise", &id]);
    repo.git(&["checkout", "main"]);

    (repo, id)
}

// ===========================================================================
// 8db8d346 — interdiff must be rebase-aware
// ===========================================================================

#[test]
fn interdiff_across_a_rebase_shows_only_the_authors_changes() {
    let (repo, id) = rebase_over_busy_upstream();

    let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);

    assert_eq!(
        changed_files(&out),
        vec!["feature.txt".to_string()],
        "interdiff across a rebase must exclude every upstream commit rebased \
         over; got:\n{}",
        out
    );
    assert!(
        out.contains("+v2"),
        "the author's actual change must survive: {}",
        out
    );
    assert!(
        !out.contains("up1.txt") && !out.contains("up2.txt") && !out.contains("up3.txt"),
        "upstream churn leaked into the interdiff: {}",
        out
    );
}

#[test]
fn interdiff_across_a_rebase_is_reversible() {
    let (repo, id) = rebase_over_busy_upstream();

    // Asking for 2..1 is the reverse diff, not a different comparison. The
    // newer revision is still the one shown as recorded.
    let out = repo.run_ok(&["patch", "diff", &id, "--between", "2", "1"]);

    assert_eq!(changed_files(&out), vec!["feature.txt".to_string()]);
    assert!(
        out.contains("+v1"),
        "reversed interdiff should restore v1: {}",
        out
    );
}

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

    repo.git(&["checkout", "-b", "feat-squash"]);
    repo.commit_file("a.txt", "a\n", "first");
    repo.commit_file("b.txt", "b\n", "second");
    let id = create_patch(&repo, "feat-squash", "Squash");

    // Squash both commits into one. Same tree, different commit.
    repo.git(&["reset", "--soft", "main"]);
    repo.git(&["commit", "-m", "squashed"]);
    repo.run_ok(&["patch", "revise", &id]);
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);

    assert!(
        changed_files(&out).is_empty(),
        "a pure squash changes no code, so the interdiff must be empty: {}",
        out
    );
    assert!(
        out.contains("No diff available"),
        "an empty interdiff should say so rather than print nothing: {}",
        out
    );
}

#[test]
fn interdiff_reports_a_conflict_rather_than_a_misleading_diff() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.commit_file("shared.txt", "original\n", "add shared");

    repo.git(&["checkout", "-b", "feat-conflict"]);
    repo.commit_file("shared.txt", "feature version\n", "feature edits shared");
    let id = create_patch(&repo, "feat-conflict", "Conflicting rebase");

    // Upstream rewrites the same line.
    repo.git(&["checkout", "main"]);
    repo.commit_file("shared.txt", "upstream version\n", "upstream edits shared");

    // The author rebases onto that and resolves in favour of the feature.
    repo.git(&["checkout", "feat-conflict"]);
    repo.git(&["reset", "--hard", "main"]);
    repo.commit_file(
        "shared.txt",
        "feature version, revised\n",
        "rebased and revised",
    );
    repo.run_ok(&["patch", "revise", &id]);
    repo.git(&["checkout", "main"]);

    let err = repo.run_err(&["patch", "diff", &id, "--between", "1", "2"]);

    assert!(
        err.contains("conflict"),
        "a conflicting replay must be reported as a conflict: {}",
        err
    );
    assert!(
        err.contains("shared.txt"),
        "the conflict should name the file: {}",
        err
    );
}

// ===========================================================================
// 243b7bc0 — differing bases must be disclosed
// ===========================================================================

#[test]
fn interdiff_discloses_that_the_bases_differ() {
    let (repo, id) = rebase_over_busy_upstream();

    let shown = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&shown).unwrap();
    let revisions = json["revisions"].as_array().unwrap();
    let base1 = revisions[0]["base"].as_str().unwrap().to_string();
    let base2 = revisions[1]["base"].as_str().unwrap().to_string();
    assert_ne!(base1, base2, "the fixture must actually rebase");

    let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);

    assert!(
        out.contains("bases differ"),
        "a reviewer cannot tell noise from signal unless the rebase is \
         disclosed: {}",
        out
    );
    assert!(
        out.contains(&base1[..8]) && out.contains(&base2[..8]),
        "the disclosure should name both bases: {}",
        out
    );
}

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

    repo.git(&["checkout", "-b", "feat-same-base"]);
    repo.commit_file("a.txt", "a\n", "v1");
    let id = create_patch(&repo, "feat-same-base", "Same base");
    repo.commit_file("a.txt", "a2\n", "v2");
    repo.run_ok(&["patch", "revise", &id]);
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);

    assert_eq!(changed_files(&out), vec!["a.txt".to_string()]);
    assert!(
        !out.contains("bases differ"),
        "an unrebased interdiff is already exactly the author's changes and \
         needs no caveat: {}",
        out
    );
}

#[test]
fn patch_show_displays_each_revisions_base() {
    let (repo, id) = rebase_over_busy_upstream();

    let shown = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&shown).unwrap();
    let base2 = json["revisions"][1]["base"].as_str().unwrap().to_string();

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(
        out.contains(&base2[..8]),
        "`patch show` must display the base each revision stands on: {}",
        out
    );
}

// ===========================================================================
// 40001f77 — --between on a single-revision patch
// ===========================================================================

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

    let err = repo.run_err(&["patch", "diff", &id, "--between", "1"]);

    assert!(
        err.contains("1 revision"),
        "an empty diff reads as 'these revisions are identical'; the truth is \
         'there is no second revision': {}",
        err
    );
}

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

    repo.git(&["checkout", "-b", "feat-self"]);
    repo.commit_file("a.txt", "a\n", "v1");
    let id = create_patch(&repo, "feat-self", "Self compare");
    repo.commit_file("a.txt", "a2\n", "v2");
    repo.run_ok(&["patch", "revise", &id]);
    repo.git(&["checkout", "main"]);

    let err = repo.run_err(&["patch", "diff", &id, "--between", "2", "2"]);
    assert!(
        err.contains("2 revisions"),
        "comparing a revision with itself should name how many exist: {}",
        err
    );
}

// ===========================================================================
// 57575b50 — a merged patch's historical diff stays readable
// ===========================================================================

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

    repo.git(&["checkout", "-b", "feat-merged"]);
    repo.commit_file("a.txt", "hello\n", "add a");
    let id = create_patch(&repo, "feat-merged", "Merged patch");

    let before = repo.run_ok(&["patch", "diff", &id, "--revision", "1"]);
    assert_eq!(changed_files(&before), vec!["a.txt".to_string()]);

    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat-merged"]);

    let after = repo.run_ok(&["patch", "diff", &id, "--revision", "1"]);
    assert_eq!(
        changed_files(&after),
        vec!["a.txt".to_string()],
        "a fast-forward merge collapses the recomputed merge-base onto the \
         revision itself; the recorded base must be used instead: {}",
        after
    );
    assert!(after.contains("+hello"), "{}", after);
}

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

    repo.git(&["checkout", "-b", "feat-merged-head"]);
    repo.commit_file("a.txt", "hello\n", "add a");
    let id = create_patch(&repo, "feat-merged-head", "Merged patch head");

    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat-merged-head"]);

    let out = repo.run_ok(&["patch", "diff", &id]);
    assert_eq!(
        changed_files(&out),
        vec!["a.txt".to_string()],
        "the head diff is lost to the same recompute: {}",
        out
    );
}

// ===========================================================================
// Revisions with no recorded base degrade, they do not fail
//
// Not a migration case and never was: a revision written before `base` was
// stored genuinely has none, nothing can recover it, and "unknown" is a state
// this tool has to be able to express and say out loud. Issue `e5096ffc`
// stripped the pre-release compatibility around it and deliberately kept this.
// ===========================================================================

#[test]
fn revisions_without_a_recorded_base_still_diff_and_say_the_base_is_unknown() {
    let work = TestRepo::new("Alice", "alice@example.com");

    work.git(&["checkout", "-b", "legacy"]);
    work.commit_file("x.txt", "one\n", "c1");
    let c1 = work.git(&["rev-parse", "HEAD"]).trim().to_string();
    let t1 = work.git(&["rev-parse", "HEAD^{tree}"]).trim().to_string();
    work.commit_file("y.txt", "two\n", "c2");
    let c2 = work.git(&["rev-parse", "HEAD"]).trim().to_string();
    let t2 = work.git(&["rev-parse", "HEAD^{tree}"]).trim().to_string();
    work.git(&["checkout", "main"]);

    let repo = git2::Repository::open(work.dir.path()).unwrap();
    let root = write_raw_event(
        &repo,
        None,
        json!({
            "type": "patch.create",
            "title": "Legacy without a base",
            "body": "",
            "base_ref": "main",
            "branch": "legacy",
            "commit": c1,
            "tree": t1,
        }),
        1,
    );
    let tip = write_raw_event(
        &repo,
        Some(root),
        json!({
            "type": "patch.revision",
            "commit": c2,
            "tree": t2,
        }),
        2,
    );
    let id = root.to_string();
    // The current ref layout. What makes this fixture the case under test is
    // that neither event carries a `base` key — not the shape of its refs.
    repo.reference(
        &format!("refs/collab/patches/{}/events", id),
        tip,
        false,
        "events",
    )
    .unwrap();
    drop(repo);

    let out = work.run_ok(&["patch", "diff", &id[..8], "--between", "1", "2"]);

    assert_eq!(
        changed_files(&out),
        vec!["y.txt".to_string()],
        "old patches must degrade to the flat tree diff, not fail: {}",
        out
    );
    assert!(
        out.contains("no base recorded"),
        "an undisclosed unknown base is the misleading case this family of \
         defects is about: {}",
        out
    );
}

// ===========================================================================
// Rendering a diff is a read
// ===========================================================================

fn count_objects(dir: &std::path::Path) -> usize {
    let mut n = 0;
    if let Ok(entries) = std::fs::read_dir(dir) {
        for e in entries.flatten() {
            let p = e.path();
            if p.is_dir() {
                n += count_objects(&p);
            } else {
                n += 1;
            }
        }
    }
    n
}

/// A rebase where upstream and the patch edit the same file in different
/// regions. The replay has to auto-merge rather than reuse a blob wholesale,
/// which is the case that would otherwise tempt an implementation into writing
/// a merged tree to the object database in the middle of a read.
#[test]
fn an_auto_merging_replay_shows_only_the_authors_hunk_and_writes_nothing() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.commit_file("both.txt", "a\nb\nc\nd\ne\nf\ng\n", "seed");
    repo.git(&["checkout", "-b", "feat-automerge"]);
    repo.commit_file(
        "both.txt",
        "a\nb\nc\nd\ne\nf\nGGG\n",
        "patch edits the tail",
    );
    let id = create_patch(&repo, "feat-automerge", "Auto-merging replay");
    repo.git(&["checkout", "main"]);
    repo.commit_file(
        "both.txt",
        "AAA\nb\nc\nd\ne\nf\ng\n",
        "upstream edits the head",
    );
    repo.git(&["checkout", "feat-automerge"]);
    repo.git(&["rebase", "main"]);
    repo.commit_file("both.txt", "AAA\nb\nc\nd\ne\nf\nHHH\n", "address review");
    repo.run_ok(&["patch", "revise", &id]);
    repo.git(&["checkout", "main"]);

    let objects = repo.dir.path().join(".git/objects");
    let before = count_objects(&objects);
    let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);
    let after = count_objects(&objects);

    assert_eq!(changed_files(&out), vec!["both.txt".to_string()], "{}", out);
    assert!(
        out.contains("-GGG") && out.contains("+HHH"),
        "the author's hunk must be the one shown: {}",
        out
    );
    assert!(
        !out.contains("AAA"),
        "upstream's hunk in the same file must cancel: {}",
        out
    );
    assert_eq!(
        before, after,
        "replaying a revision is done in memory; rendering a diff must not add \
         objects to the object database"
    );
}

#[test]
fn rendering_a_diff_moves_no_refs() {
    let (repo, id) = rebase_over_busy_upstream();

    let before = repo.git(&["for-each-ref", "refs/collab"]);
    repo.run_ok(&["patch", "diff", &id]);
    repo.run_ok(&["patch", "diff", &id, "--revision", "1"]);
    repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);
    let after = repo.git(&["for-each-ref", "refs/collab"]);

    assert_eq!(
        before, after,
        "rendering a diff must not append events or move refs"
    );
}