a73x

tests/mutating_json_test.rs

Ref:   Size: 20.3 KiB   History

//! `--json` on the commands that write.
//!
//! Read commands have had it for a long time; the commands that *create*
//! something did not, so a script could only learn the id of what it had just
//! made by parsing prose — and the prose moved underneath it the moment ids
//! became sized to the repo. See issue a6adfe39.
//!
//! Two rules are asserted everywhere below, because together they are the
//! contract:
//!
//! - stdout is exactly one JSON value and nothing else, even when auto-sync is
//!   narrating at the same time (`serde_json::from_str` on the whole of stdout
//!   fails on trailing content, so every `json_ok` call asserts this).
//! - every id is the **full** id. Abbreviation is a display policy; `--json`
//!   already carries full ids on the read commands and must not grow a second
//!   convention here.

mod common;

use std::process::Command;

use serde_json::Value;
use tempfile::TempDir;

use common::TestRepo;

// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------

/// Run a command expected to succeed, and parse the whole of stdout as one
/// JSON value. Parsing the *whole* string is the assertion that nothing else
/// was mixed in: `serde_json` rejects trailing content.
fn json_ok(repo: &TestRepo, args: &[&str]) -> Value {
    let out = repo.run_ok(args);
    serde_json::from_str(&out).unwrap_or_else(|e| {
        panic!(
            "git-collab {:?} did not print exactly one JSON value: {}\nstdout was:\n{}",
            args, e, out
        )
    })
}

/// A full git object name: 40 lowercase hex characters, never an abbreviation.
fn assert_full_id(value: &Value, what: &str) {
    let s = value
        .as_str()
        .unwrap_or_else(|| panic!("{} is not a string: {}", what, value));
    assert_eq!(
        s.len(),
        40,
        "{} must be the full id, got {:?} ({} chars)",
        what,
        s,
        s.len()
    );
    assert!(
        s.chars()
            .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
        "{} must be a hex object name, got {:?}",
        what,
        s
    );
}

fn open_issue_json(repo: &TestRepo, title: &str) -> String {
    let json = json_ok(repo, &["issue", "open", "-t", title, "--json"]);
    json["issue"].as_str().unwrap().to_string()
}

/// Create a patch on its own branch, with `--json`, and return its full id.
fn create_patch_json(repo: &TestRepo, branch: &str, file: &str) -> String {
    repo.git(&["checkout", "-b", branch]);
    repo.commit_file(file, "content", &format!("work on {}", branch));
    let json = json_ok(
        repo,
        &["patch", "create", "-t", branch, "-B", branch, "--json"],
    );
    repo.git(&["checkout", "main"]);
    json["patch"].as_str().unwrap().to_string()
}

fn collab_refs(repo: &TestRepo) -> String {
    repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"])
}

// ---------------------------------------------------------------------------
// The two ids a script most needs: what `open` and `create` just made
// ---------------------------------------------------------------------------

#[test]
fn issue_open_json_emits_the_full_id_and_it_names_a_ref() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let json = json_ok(&repo, &["issue", "open", "-t", "A bug", "--json"]);

    assert_eq!(json["action"], "issue.open");
    assert_full_id(&json["issue"], "issue");

    let id = json["issue"].as_str().unwrap();
    assert!(
        collab_refs(&repo).contains(&format!("refs/collab/issues/{}", id)),
        "the id must be the one the ref is named after"
    );
    // The prose the id used to have to be scraped out of is abbreviated, and
    // the JSON one is not — that difference is the whole point.
    let prose = repo.run_ok(&["issue", "show", id]);
    assert!(prose.contains("A bug"));
}

#[test]
fn patch_create_json_emits_the_full_id_and_it_names_a_ref() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = create_patch_json(&repo, "feat", "a.txt");

    assert_eq!(id.len(), 40, "patch id must be full, got {:?}", id);
    assert!(
        collab_refs(&repo).contains(&format!("refs/collab/patches/{}/events", id)),
        "the id must be the one the ref is named after"
    );
}

#[test]
fn patch_create_json_carries_the_fixed_issue_in_full() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let issue = open_issue_json(&repo, "A bug");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "work");
    let json = json_ok(
        &repo,
        &[
            "patch",
            "create",
            "-t",
            "Fix",
            "-B",
            "feat",
            "--fixes",
            &issue[..8],
            "--json",
        ],
    );
    assert_full_id(&json["patch"], "patch");
    assert_eq!(
        json["fixes"], issue,
        "an id resolved from a prefix comes back in full"
    );
}

// ---------------------------------------------------------------------------
// Every other issue mutation
// ---------------------------------------------------------------------------

#[test]
fn every_issue_mutation_reports_the_issue_in_full() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = open_issue_json(&repo, "A bug");
    let other = open_issue_json(&repo, "Another bug");
    let short = &id[..8];

    let comment = json_ok(&repo, &["issue", "comment", short, "-b", "hi", "--json"]);
    assert_eq!(comment["action"], "issue.comment");
    assert_full_id(&comment["issue"], "issue");
    assert_eq!(comment["issue"], id.as_str());
    assert_full_id(&comment["comment"], "comment");
    let comment_id = comment["comment"].as_str().unwrap().to_string();

    let cases: Vec<(Vec<&str>, &str)> = vec![
        (vec!["issue", "edit", short, "-t", "Retitled"], "issue.edit"),
        (vec!["issue", "label", short, "bug"], "issue.label"),
        (vec!["issue", "unlabel", short, "bug"], "issue.unlabel"),
        (vec!["issue", "assign", short, "alice"], "issue.assign"),
        (vec!["issue", "unassign", short, "alice"], "issue.unassign"),
        (vec!["issue", "relate", short, &other[..8]], "issue.relate"),
        (
            vec!["issue", "unrelate", short, &other[..8]],
            "issue.unrelate",
        ),
    ];
    for (args, action) in cases {
        let mut args = args;
        args.push("--json");
        let json = json_ok(&repo, &args);
        assert_eq!(json["action"], action, "for {:?}", args);
        assert_full_id(&json["issue"], "issue");
        assert_eq!(json["issue"], id.as_str(), "for {:?}", args);
        assert_full_id(&json["event"], "event");
    }

    let edited = json_ok(
        &repo,
        &[
            "issue",
            "edit-comment",
            short,
            &comment_id[..8],
            "-b",
            "hello",
            "--json",
        ],
    );
    assert_eq!(edited["action"], "issue.edit_comment");
    assert_eq!(edited["issue"], id.as_str());
    assert_eq!(
        edited["comment"], comment_id,
        "the corrected comment is named in full, not by the prefix given"
    );
    assert_full_id(&edited["event"], "event");

    let deleted = json_ok(
        &repo,
        &["issue", "delete-comment", short, &comment_id[..8], "--json"],
    );
    assert_eq!(deleted["action"], "issue.delete_comment");
    assert_eq!(deleted["comment"], comment_id);

    let closed = json_ok(&repo, &["issue", "close", short, "--json"]);
    assert_eq!(closed["action"], "issue.close");
    assert_eq!(closed["issue"], id.as_str());
    assert_eq!(closed["status"], "closed");

    let reopened = json_ok(&repo, &["issue", "reopen", short, "--json"]);
    assert_eq!(reopened["action"], "issue.reopen");
    assert_eq!(reopened["status"], "open");

    let deleted = json_ok(&repo, &["issue", "delete", short, "--json"]);
    assert_eq!(deleted["action"], "issue.delete");
    assert_eq!(
        deleted["issue"],
        id.as_str(),
        "the id of something deleted is exactly what it was"
    );
}

// ---------------------------------------------------------------------------
// Every other patch mutation
// ---------------------------------------------------------------------------

#[test]
fn every_patch_mutation_reports_the_patch_in_full() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = create_patch_json(&repo, "feat", "a.txt");
    let short = &id[..8];

    let comment = json_ok(&repo, &["patch", "comment", short, "-b", "hi", "--json"]);
    assert_eq!(comment["action"], "patch.comment");
    assert_eq!(comment["patch"], id.as_str());
    assert_full_id(&comment["comment"], "comment");
    let comment_id = comment["comment"].as_str().unwrap().to_string();

    let inline = json_ok(
        &repo,
        &[
            "patch",
            "comment",
            short,
            "--at",
            "a.txt:1",
            "-b",
            "nit",
            "--non-blocking",
            "--json",
        ],
    );
    assert_eq!(inline["action"], "patch.inline_comment");
    assert_eq!(inline["patch"], id.as_str());
    assert_full_id(&inline["comment"], "comment");
    assert_eq!(inline["file"], "a.txt");
    assert_eq!(inline["line"], 1);
    assert_eq!(inline["revision"], 1);
    assert_eq!(inline["non_blocking"], true);

    let review = json_ok(
        &repo,
        &[
            "patch", "review", short, "-v", "approve", "-b", "lgtm", "--json",
        ],
    );
    assert_eq!(review["action"], "patch.review");
    assert_eq!(review["patch"], id.as_str());
    assert_full_id(&review["review"], "review");
    assert_eq!(review["verdict"], "approve");
    assert_eq!(review["revision"], 1);

    for (args, action) in [
        (vec!["patch", "label", short, "wip"], "patch.label"),
        (vec!["patch", "unlabel", short, "wip"], "patch.unlabel"),
    ] {
        let mut args = args;
        args.push("--json");
        let json = json_ok(&repo, &args);
        assert_eq!(json["action"], action);
        assert_eq!(json["patch"], id.as_str());
        assert_full_id(&json["event"], "event");
    }

    let edited = json_ok(
        &repo,
        &[
            "patch",
            "edit-comment",
            short,
            &comment_id[..8],
            "-b",
            "hello",
            "--json",
        ],
    );
    assert_eq!(edited["action"], "patch.edit_comment");
    assert_eq!(edited["comment"], comment_id);
    assert_full_id(&edited["event"], "event");

    let deleted = json_ok(
        &repo,
        &["patch", "delete-comment", short, &comment_id[..8], "--json"],
    );
    assert_eq!(deleted["action"], "patch.delete_comment");
    assert_eq!(deleted["comment"], comment_id);

    // A revision names the commit it snapshots, in full.
    repo.git(&["checkout", "feat"]);
    let commit = repo.commit_file("a.txt", "more", "second");
    repo.git(&["checkout", "main"]);
    let revised = json_ok(
        &repo,
        &[
            "patch",
            "revise",
            short,
            "-B",
            "feat",
            "-b",
            "round two",
            "--json",
        ],
    );
    assert_eq!(revised["action"], "patch.revision");
    assert_eq!(revised["patch"], id.as_str());
    assert_eq!(revised["revision"], 2);
    assert_eq!(revised["commit"], commit.trim());
    assert_full_id(&revised["commit"], "commit");

    let edited_rev = json_ok(
        &repo,
        &[
            "patch",
            "edit-revision",
            short,
            "2",
            "-b",
            "fixed",
            "--json",
        ],
    );
    assert_eq!(edited_rev["action"], "patch.edit_revision");
    assert_eq!(edited_rev["patch"], id.as_str());
    assert_eq!(edited_rev["revision"], 2);
    assert_full_id(&edited_rev["event"], "event");

    let checked_out = json_ok(&repo, &["patch", "checkout", short, "--json"]);
    assert_eq!(checked_out["action"], "patch.checkout");
    assert_eq!(checked_out["patch"], id.as_str());
    assert!(checked_out["branch"].is_string());
    assert_full_id(&checked_out["commit"], "commit");
    repo.git(&["checkout", "main"]);

    let closed = json_ok(&repo, &["patch", "close", short, "--json"]);
    assert_eq!(closed["action"], "patch.close");
    assert_eq!(closed["patch"], id.as_str());
    assert_eq!(closed["status"], "closed");

    let reopened = json_ok(&repo, &["patch", "reopen", short, "--json"]);
    assert_eq!(reopened["action"], "patch.reopen");
    assert_eq!(reopened["patch"], id.as_str());
    assert_eq!(reopened["status"], "open");

    let deleted = json_ok(&repo, &["patch", "delete", short, "--json"]);
    assert_eq!(deleted["action"], "patch.delete");
    assert_eq!(deleted["patch"], id.as_str());
}

#[test]
fn patch_merge_json_names_the_patch_the_commit_and_the_issue_it_closed() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let issue = open_issue_json(&repo, "A bug");
    repo.git(&["checkout", "-b", "feat"]);
    repo.commit_file("a.txt", "x", "work");
    let created = json_ok(
        &repo,
        &[
            "patch",
            "create",
            "-t",
            "Fix",
            "-B",
            "feat",
            "--fixes",
            &issue[..8],
            "--json",
        ],
    );
    let id = created["patch"].as_str().unwrap().to_string();
    repo.git(&["checkout", "main"]);
    repo.git(&["merge", "--ff-only", "feat"]);
    let landed = repo.git(&["rev-parse", "main"]).trim().to_string();

    let merged = json_ok(&repo, &["patch", "merge", &id[..8], "--json"]);
    assert_eq!(merged["action"], "patch.merge");
    assert_eq!(merged["patch"], id.as_str());
    assert_eq!(
        merged["commit"], landed,
        "the commit that landed the patch, in full — the only route back from a squash"
    );
    assert_eq!(merged["already_recorded"], false);
    assert_eq!(
        merged["closed_issue"], issue,
        "the issue `--fixes` named, in full, because the merge closed it"
    );

    // Recording it twice says so rather than pretending it was new.
    let again = json_ok(&repo, &["patch", "merge", &id[..8], "--json"]);
    assert_eq!(again["already_recorded"], true);
    assert_eq!(again["patch"], id.as_str());
}

#[test]
fn patch_reopen_json_reports_the_merge_commit_it_cleared() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = create_patch_json(&repo, "feat", "a.txt");
    repo.git(&["merge", "--ff-only", "feat"]);
    let landed = repo.git(&["rev-parse", "main"]).trim().to_string();
    repo.run_ok(&["patch", "merge", &id[..8]]);

    let json = json_ok(&repo, &["patch", "reopen", &id[..8], "--json"]);
    assert_eq!(json["status"], "open");
    assert_eq!(
        json["cleared_merge_commit"], landed,
        "reopening drops the recorded merge, so it has to hand it back"
    );

    // Nothing to clear on a patch that was merely closed.
    repo.run_ok(&["patch", "close", &id[..8]]);
    let json = json_ok(&repo, &["patch", "reopen", &id[..8], "--json"]);
    assert!(json["cleared_merge_commit"].is_null(), "{}", json);
}

// ---------------------------------------------------------------------------
// The rest of the writing surface
// ---------------------------------------------------------------------------

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

    let added = json_ok(
        &repo,
        &["key", "add", "--self", "--label", "mine", "--json"],
    );
    assert_eq!(added["action"], "key.add");
    assert_eq!(added["added"], true);
    assert_eq!(added["label"], "mine");
    assert_eq!(added["global"], false);
    let pubkey = added["pubkey"].as_str().unwrap().to_string();

    let again = json_ok(&repo, &["key", "add", "--self", "--json"]);
    assert_eq!(again["added"], false, "already trusted is not a new key");
    assert_eq!(again["pubkey"], pubkey);

    let removed = json_ok(&repo, &["key", "remove", &pubkey, "--json"]);
    assert_eq!(removed["action"], "key.remove");
    assert_eq!(removed["pubkey"], pubkey);

    let aliased = json_ok(&repo, &["identity", "alias", "a@b.example", "--json"]);
    assert_eq!(aliased["action"], "identity.alias");
    assert_eq!(aliased["email"], "a@b.example");
    assert_eq!(aliased["added"], true);

    let unaliased = json_ok(&repo, &["identity", "unalias", "a@b.example", "--json"]);
    assert_eq!(unaliased["action"], "identity.unalias");
    assert_eq!(unaliased["email"], "a@b.example");
}

#[test]
fn hooks_install_json_reports_the_outcome_and_the_path() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let json = json_ok(&repo, &["hooks", "install", "--json"]);
    assert_eq!(json["action"], "hooks.install");
    assert_eq!(json["outcome"], "installed");
    assert!(json["path"].as_str().unwrap().ends_with("commit-msg"));

    let again = json_ok(&repo, &["hooks", "install", "--json"]);
    assert_eq!(again["outcome"], "already-installed");
}

// ---------------------------------------------------------------------------
// Failure keeps the shape it already had
// ---------------------------------------------------------------------------

#[test]
fn a_failing_write_prints_the_error_object_on_stdout() {
    // Established by e049a2bb for the read commands; a caller that asked for
    // JSON never has to read stderr, and that has to hold on the writes too.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run(&["issue", "close", "deadbeef", "--json"]);
    assert!(!out.status.success());
    let stdout = String::from_utf8(out.stdout).unwrap();
    let json: Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("stdout was not one JSON value: {}\n{}", e, stdout));
    assert!(json["error"].is_string(), "{}", json);
    assert!(json.get("issue").is_none(), "a failure reports no id");
}

// ---------------------------------------------------------------------------
// Auto-sync must not get into stdout
// ---------------------------------------------------------------------------

/// A repo with a local bare `origin` and auto-sync left switched **on**, which
/// `TestRepo::new` otherwise disables. This is the case the separation of
/// streams exists for: the write's result and the push's narration arrive
/// together, and only one of them may be on stdout.
fn repo_with_autosync() -> (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.git(&["config", "collab.autoSync", "true"]);
    (repo, bare)
}

#[test]
fn json_stdout_stays_pure_while_auto_sync_narrates() {
    let (repo, _bare) = repo_with_autosync();

    let out = repo.run(&["issue", "open", "-t", "A bug", "--json"]);
    let stdout = String::from_utf8(out.stdout).unwrap();
    let stderr = String::from_utf8(out.stderr).unwrap();
    assert!(out.status.success(), "stderr:\n{}", stderr);

    let json: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!(
            "auto-sync leaked into stdout: {}\nstdout:\n{}\nstderr:\n{}",
            e, stdout, stderr
        )
    });
    assert_full_id(&json["issue"], "issue");
    assert!(
        stderr.contains("auto-sync: "),
        "the push narration still has to be reported, on stderr:\n{}",
        stderr
    );
    assert!(
        !stdout.contains("auto-sync"),
        "and never on stdout:\n{}",
        stdout
    );
}

#[test]
fn json_stdout_stays_pure_when_the_auto_sync_push_fails() {
    // The failure path prints more, and prints advice — all of it narration
    // about the network, none of it the command's result.
    let (repo, bare) = repo_with_autosync();
    std::fs::remove_dir_all(bare.path()).unwrap();

    let out = repo.run(&["issue", "open", "-t", "A bug", "--json"]);
    let stdout = String::from_utf8(out.stdout).unwrap();
    let stderr = String::from_utf8(out.stderr).unwrap();

    let json: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!(
            "a failed auto-sync leaked into stdout: {}\nstdout:\n{}\nstderr:\n{}",
            e, stdout, stderr
        )
    });
    assert_full_id(&json["issue"], "issue");
    assert!(
        json.get("error").is_none(),
        "the local write succeeded; a failed push is not the command failing: {}",
        json
    );
}