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