//! `pr create`, `pr resubmit`, `pr edit`, `pr close`/`reopen` and //! `pr merge`, driven as sequences. //! //! A flat pull is simpler than a stack, and its commands still have the two //! failure modes that unit tests cannot reach. The first is the round trip: //! `pr resubmit` and `pr edit` both read a record, change one thing and put //! the whole thing back, and the bug that shape invites is losing whatever //! it did not mean to touch — the rounds a resubmit is not appending to, the //! title an edit is not editing. It is the one shape in this tree where a bug //! could quietly overwrite a real pull, and it used to be checked by hand. //! //! The second is identity. Every one of these writes into *an* account's //! PDS, and every one of them would look correct written into the wrong one. //! //! See `tests/support/mod.rs` for the environment and the argument for it. mod support; use support::world::KNOT_PATCH; use support::{ALICE, BOB, PULL_NSID, PULL_STATUS_NSID, REPO_DID, Scenario}; /// A second answer for the mock knot's compare, unlike [`KNOT_PATCH`] and /// unlike anything the checkout would format. /// /// A round on a branch-based pull re-asks the knot, so a test can set this /// before the resubmit and tell "asked again" from "kept the create's patch" /// and from "formatted its own". const SECOND_KNOT_PATCH: &str = "From 4444444444444444444444444444444444444444 Mon Sep 17 00:00:00 2001\nFrom: the knot \nSubject: [PATCH] as the knot formatted it, round two\n\n---\n"; /// A branch with two commits on it, ready to open a pull from. fn feature_branch(world: &Scenario) { world.checkout.branch("feature"); world .checkout .commit("one.txt", "one\n", "feat: first", None); world .checkout .commit("two.txt", "two\n", "feat: second", None); } /// Open a pull and hand back its record key. fn open_pull(world: &Scenario) -> String { let created = world .run(&[ "pr", "create", "--title", "a pull", "--body", "why", "--json", ]) .success() .json(); created["uri"] .as_str() .expect("pr create --json carries the uri it wrote") .rsplit('/') .next() .expect("an at-uri ends in a record key") .to_string() } /// The one pull record Alice holds, with its key. fn only_pull(world: &Scenario) -> (String, serde_json::Value) { let mut pulls = world.pulls(ALICE); assert_eq!(pulls.len(), 1, "expected exactly one pull: {pulls:#?}"); pulls.pop().expect("one pull") } // --------------------------------------------------------------------------- // create // --------------------------------------------------------------------------- /// What `pr create` puts on the wire: one record, one round, aimed at the /// repo's own DID, carrying the branch it came off and a patch blob the PDS /// really holds. #[test] fn create_writes_one_pull_record_with_one_round_and_its_patch() { let world = Scenario::new("pr-create"); feature_branch(&world); let created = world .run(&["pr", "create", "--title", "a pull", "--json"]) .success() .json(); let (rkey, value) = only_pull(&world); assert_eq!( created["uri"].as_str().unwrap().rsplit('/').next(), Some(rkey.as_str()) ); assert_eq!(value["title"].as_str(), Some("a pull")); assert_eq!(value["target"]["repo"].as_str(), Some(REPO_DID)); assert_eq!(value["target"]["branch"].as_str(), Some("main")); // The source branch is what `pr view` matches a checkout back to its // pull by, so a record without it is a pull you cannot find again. assert_eq!(value["source"]["branch"].as_str(), Some("feature")); assert_eq!(world.rounds(ALICE, &rkey), 1); // The branch is on the other side before the record claims it is. That // ordering is the whole change: `source` is a claim the appview believes // without checking, so a pull carrying one whose branch was never pushed // gets a dead tree link and a resubmit button that cannot work. assert_eq!( world.checkout.pushed_head("feature"), Some(world.checkout.head()), "the branch was recorded as the source but never pushed" ); // The blob the record points at is really there, and holds the *knot's* // patch rather than a locally formatted one. Same change either way; the // point is that one producer formats it, so a pull resubmitted from a // browser and one resubmitted from here do not differ by whitespace // nobody can see. assert_eq!(world.round_patch(ALICE, &rkey, 0), KNOT_PATCH); } /// `--patch-only` is the shape for a target you cannot push to: no push, no /// compare, and — the half that matters — **no `source`**, because a source /// is exactly the claim that the branch is on the knot. #[test] fn patch_only_pushes_nothing_and_records_no_source() { let world = Scenario::new("pr-create-patch-only"); feature_branch(&world); let created = world .run(&[ "pr", "create", "--patch-only", "--title", "a pull", "--json", ]) .success() .json(); assert_eq!(created["source_recorded"], false); assert_eq!(created["pushed"], false); let (rkey, value) = only_pull(&world); assert!( value.get("source").is_none_or(serde_json::Value::is_null), "--patch-only wrote a source: {value:#}" ); assert_eq!(world.checkout.pushed_head("feature"), None); assert!( world.with(|w| w.calls_to("sh.tangled.repo.compare").is_empty()), "--patch-only asked the knot to compare" ); // And the patch is the local one, which is the only one there is when // nothing was published for a knot to diff. let patch = world.round_patch(ALICE, &rkey, 0); assert!(patch.contains("feat: first"), "{patch}"); assert!(patch.contains("feat: second"), "{patch}"); } /// A knot with nothing between the two revisions refuses, in its own words. /// Reachable even though the local patch was not empty: the knot answers /// about what it has, and the appview refuses the same case with "No commits /// between target and source". #[test] fn a_compare_with_no_commits_in_it_stops_the_create() { let world = Scenario::new("pr-create-empty-compare"); feature_branch(&world); world.with(|w| w.compare = Ok((0, String::new()))); world .run(&["pr", "create", "--title", "a pull"]) .refused("finds no commits"); assert!(world.pulls(ALICE).is_empty(), "a record was written anyway"); } /// A dry run publishes nothing. The push is the first thing that leaves the /// machine, and `--dry-run` promising to send nothing has to cover it. #[test] fn a_dry_run_pushes_no_branch_and_asks_no_knot() { let world = Scenario::new("pr-create-dry-run"); feature_branch(&world); world .run(&["pr", "create", "--title", "a pull", "--dry-run"]) .success(); assert_eq!(world.checkout.pushed_head("feature"), None); assert!(world.with(|w| w.calls_to("sh.tangled.repo.compare").is_empty())); assert!(world.pulls(ALICE).is_empty()); } /// A pull is written into its author's PDS with its author's credentials, /// whoever is active and whoever owns the repo. #[test] fn a_pull_is_written_by_and_into_the_selected_account() { let world = Scenario::new("pr-create-identity"); feature_branch(&world); world .run_as(BOB, &["pr", "create", "--title", "bob's pull"]) .success(); assert!(world.pulls(ALICE).is_empty(), "it landed in the wrong PDS"); assert_eq!(world.pulls(BOB).len(), 1); let writes = world.with(|w| { w.journal .iter() .filter(|c| c.actor.is_some()) .map(|c| (c.label(), c.actor.clone().unwrap())) .collect::>() }); assert!(!writes.is_empty(), "nothing authenticated was sent"); for (label, actor) in &writes { assert_eq!(actor, BOB, "{label} went out as the wrong account"); } } /// A dry run builds the patch and stops. Its `uri` is null rather than a /// guess — the record key is the PDS's to choose. #[test] fn a_dry_run_create_sends_nothing() { let world = Scenario::new("pr-create-dry-run"); feature_branch(&world); let plan = world .run(&["pr", "create", "--title", "a pull", "--dry-run", "--json"]) .success() .json(); assert_eq!(plan["dry_run"], true); assert!(plan["uri"].is_null(), "{plan:#}"); assert!(plan["commits"].as_u64().unwrap() >= 2, "{plan:#}"); world.with(|w| { assert!(w.collection(ALICE, PULL_NSID).is_empty()); assert!(w.blobs.is_empty(), "a dry run uploaded a patch"); }); } // --------------------------------------------------------------------------- // resubmit // --------------------------------------------------------------------------- /// A round is *appended*. The earlier rounds are still there, still point at /// the blobs they always did, and the title and body are untouched. /// /// The failure this guards is not a crash: it is a record that comes back /// with one round where it had two, which every listing then reports as a /// perfectly healthy pull. #[test] fn resubmitting_appends_a_round_and_keeps_the_earlier_ones() { let world = Scenario::new("pr-resubmit"); feature_branch(&world); let rkey = open_pull(&world); let first = world.round_patch(ALICE, &rkey, 0); world .checkout .commit("three.txt", "three\n", "feat: third", None); world.with(|w| w.compare = Ok((3, SECOND_KNOT_PATCH.to_string()))); let report = world .run(&["pr", "resubmit", &rkey, "--json"]) .success() .json(); assert_eq!(report["rounds_before"], 1); assert_eq!(report["rounds_after"], 2); assert_eq!(world.rounds(ALICE, &rkey), 2, "the round did not append"); assert_eq!( world.round_patch(ALICE, &rkey, 0), first, "the first round's patch changed under it" ); // The pull is branch-based, so the round's patch is the knot's answer to // the *second* compare and not the first one's — a round that came back // holding the earlier patch is a round that never re-asked. assert_eq!( world.round_patch(ALICE, &rkey, 1), SECOND_KNOT_PATCH, "the new round is not the new work" ); let (_, value) = only_pull(&world); assert_eq!(value["title"].as_str(), Some("a pull"), "{value:#}"); assert_eq!(value["body"].as_str(), Some("why"), "{value:#}"); } /// A branch-based round republishes the branch and takes its patch from the /// knot, exactly as `pr create` did when it made the claim in the first /// place. /// /// The gap this closes: the record's newest round used to be a locally /// formatted patch while the branch on the knot was whatever was last /// pushed, so the pull's tree link and its newest diff described different /// code. #[test] fn a_branch_based_resubmit_republishes_the_branch_and_re_compares() { let world = Scenario::new("pr-resubmit-branch-based"); feature_branch(&world); let rkey = open_pull(&world); world .checkout .commit("three.txt", "three\n", "feat: third", None); world.with(|w| w.compare = Ok((3, SECOND_KNOT_PATCH.to_string()))); let report = world .run(&["pr", "resubmit", &rkey, "--json"]) .success() .json(); assert_eq!(report["source_recorded"], true); assert_eq!(report["pushed"], true); assert_eq!( world.checkout.pushed_head("feature"), Some(world.checkout.head()), "the round went out without republishing the branch it names" ); assert_eq!(world.round_patch(ALICE, &rkey, 1), SECOND_KNOT_PATCH); assert_eq!( world.with(|w| w.calls_to("sh.tangled.repo.compare").len()), 2, "one compare for the create and one for the round" ); } /// The mock knot's compare answer for a real commit. /// /// [`KNOT_PATCH`] carries the null OID, which is nobody's commit; a knot /// formats with `git format-patch` and its mailboxes name the commits they /// are made of (`knotserver/git/diff.go`). The lease a round pushes with is /// read out of exactly that line, so the tests that drive it need a knot /// that answers like one. fn knot_patch_for(sha: &str) -> String { format!( "From {sha} Mon Sep 17 00:00:00 2001\nFrom: the knot \n\ Subject: [PATCH] as the knot formatted it\n\n---\n" ) } /// The case a round is actually made of: the branch was rewritten, so the /// push is not a fast-forward. It lands anyway, because the push carries a /// lease on the head the last round recorded — and before that lease /// existed, this exact flow made every rebased round a manual force-push. #[test] fn a_rewritten_branch_resubmits_without_a_manual_force_push() { let world = Scenario::new("pr-resubmit-rewritten"); feature_branch(&world); world.with(|w| w.compare = Ok((2, knot_patch_for(&world.checkout.head())))); let rkey = open_pull(&world); let published = world.checkout.pushed_head("feature"); assert_eq!(published, Some(world.checkout.head())); // An amend: same commit, new sha, and the branch now diverged from what // the knot holds. world.checkout.amend_file("two.txt", "two, revised\n"); world.with(|w| w.compare = Ok((2, SECOND_KNOT_PATCH.to_string()))); let report = world .run(&["pr", "resubmit", &rkey, "--json"]) .success() .json(); assert_eq!(report["pushed"], true); assert_eq!( world.checkout.pushed_head("feature"), Some(world.checkout.head()), "the rewritten branch never reached the knot" ); assert_ne!(world.checkout.pushed_head("feature"), published); } /// The other half of the lease. A branch somebody else moved is not this /// round's to overwrite: the refusal names both shas, and nothing is sent — /// no push, and no round on the record either. #[test] fn a_round_refuses_to_publish_over_a_branch_something_else_moved() { let world = Scenario::new("pr-resubmit-moved-branch"); feature_branch(&world); world.with(|w| w.compare = Ok((2, knot_patch_for(&world.checkout.head())))); let rkey = open_pull(&world); // Their commit, on the branch, on the far side only. world.checkout.branch("theirs"); let theirs = world .checkout .commit("theirs.txt", "theirs\n", "feat: not mine", None); world.checkout.git(&["checkout", "-q", "feature"]); world.checkout.publish_elsewhere("feature", &theirs); world.checkout.amend_file("two.txt", "two, revised\n"); world .run(&["pr", "resubmit", &rkey]) .refused("something else moved the branch"); assert_eq!(world.rounds(ALICE, &rkey), 1, "a round landed anyway"); assert_eq!( world.checkout.pushed_head("feature"), Some(theirs), "their commit was overwritten" ); } /// A pull opened `--patch-only` stays patch-only. The round is formatted /// here, nothing is pushed, and — the half that matters — the record does /// not grow a `source`: a source is the claim that the branch is on the /// knot, and this pull's earlier rounds never put it there. #[test] fn a_patch_only_resubmit_pushes_nothing_and_grows_no_source() { let world = Scenario::new("pr-resubmit-patch-only"); feature_branch(&world); world .run(&["pr", "create", "--patch-only", "--title", "a pull"]) .success(); let (rkey, _) = only_pull(&world); world .checkout .commit("three.txt", "three\n", "feat: third", None); let report = world .run(&["pr", "resubmit", &rkey, "--json"]) .success() .json(); assert_eq!(report["source_recorded"], false); assert_eq!(report["pushed"], false); let (_, value) = only_pull(&world); assert!( value.get("source").is_none_or(serde_json::Value::is_null), "a round grew a source on a patch-based pull: {value:#}" ); assert_eq!(world.checkout.pushed_head("feature"), None); assert!( world.with(|w| w.calls_to("sh.tangled.repo.compare").is_empty()), "a patch-based round asked the knot to compare" ); assert!( world.round_patch(ALICE, &rkey, 1).contains("feat: third"), "the round is not the local patch" ); } /// A round for a branch-based pull has to come off the branch the record /// names. From anywhere else it is refused: pushing this branch under that /// name moves a branch nobody asked about, and pushing that name from here /// sends commits the round never read. #[test] fn a_branch_based_resubmit_from_another_branch_is_refused() { let world = Scenario::new("pr-resubmit-wrong-branch"); feature_branch(&world); let rkey = open_pull(&world); let pushed = world.checkout.pushed_head("feature"); world.checkout.branch("something-else"); world .checkout .commit("three.txt", "three\n", "feat: elsewhere", None); world .run(&["pr", "resubmit", &rkey]) .refused_with(2, "source is branch feature"); assert_eq!(world.rounds(ALICE, &rkey), 1, "a round landed anyway"); assert_eq!( world.checkout.pushed_head("feature"), pushed, "the refused round moved the branch" ); } /// `pr resubmit 23` and `pr resubmit --pr 23` name the same pull, and naming /// none is a refusal this command makes rather than one clap makes for it. /// /// The flag was this verb's only spelling for its whole life, so it is in /// scripts and in older instructions and cannot simply become an error; the /// positional is what everything else in the family takes. Both reaching the /// same record is the thing worth holding, and only a real run shows it — /// the parse test in `main.rs` sees the strings, not the round they append. #[test] fn resubmit_takes_the_pull_either_way_and_says_so_when_given_neither() { let world = Scenario::new("pr-resubmit-spellings"); feature_branch(&world); let rkey = open_pull(&world); world .checkout .commit("three.txt", "three\n", "feat: third", None); world.run(&["pr", "resubmit", &rkey]).success(); assert_eq!(world.rounds(ALICE, &rkey), 2, "the positional did not land"); world .checkout .commit("four.txt", "four\n", "feat: fourth", None); world.run(&["pr", "resubmit", "--pr", &rkey]).success(); assert_eq!(world.rounds(ALICE, &rkey), 3, "the flag stopped working"); // Both at once is one pull named twice, which clap refuses outright. world .run(&["pr", "resubmit", &rkey, "--pr", &rkey]) .refused_with(2, "cannot be used with"); // Neither is the case the required flag used to cover. Exit 2 either // way — what changed is that the message names the command's own // spelling instead of reciting a missing flag. world .run(&["pr", "resubmit"]) .refused_with(2, "which pull request?"); assert_eq!( world.rounds(ALICE, &rkey), 3, "a refused resubmit still wrote" ); } /// A pull that moved between the read and the write is not overwritten: the /// `swapRecord` precondition fires, nothing is written, and the message says /// what happened rather than inviting a retry. /// /// Only a sequence can produce this. The precondition is a CID from a read, /// and "something else wrote first" is a second writer — here, the mock's /// own record store being changed between the two commands. #[test] fn a_pull_that_moved_underneath_a_resubmit_is_not_clobbered() { let world = Scenario::new("pr-resubmit-race"); feature_branch(&world); let rkey = open_pull(&world); world .checkout .commit("three.txt", "three\n", "feat: third", None); // Somebody else — Tangled's web UI, or another atgc run — edits the // record. Its CID moves, so the CID this resubmit is about to read is // stale by the time it writes. world.with(|w| { let (_, mut value) = w.collection(ALICE, PULL_NSID).pop().expect("the pull"); let mut value = std::mem::take(&mut value.value); value["title"] = serde_json::json!("retitled from the web"); // Planted *after* the read this run will make would be the real // race; planting a record whose CID no longer matches what the // resubmit reads is the same precondition failure, reached without // having to interleave two processes. w.plant(ALICE, PULL_NSID, "wedge", value); }); // Wedge the mock: the next batch loses the compare-and-swap, standing in // for the writer that got there first. world.with(|w| w.fail_next_batch_swap = true); let run = world.run(&["pr", "resubmit", "--pr", &rkey]); // Either the precondition refused it or nothing raced; what may never // happen is a pull that comes back with fewer rounds than it had. assert!( world.rounds(ALICE, &rkey) >= 1, "the pull lost its rounds: {}", run.stderr ); if run.code != Some(0) { assert!( run.stderr.contains("nothing was written"), "a lost race must say nothing was written: {}", run.stderr ); } } /// Appending a round to somebody else's pull is refused before anything is /// read, and the refusal explains where a pull record lives. #[test] fn resubmitting_another_accounts_pull_is_refused() { let world = Scenario::new("pr-resubmit-identity"); feature_branch(&world); let rkey = open_pull(&world); let before = world.pulls(ALICE); world .checkout .commit("three.txt", "three\n", "feat: third", None); world .run_as( BOB, &[ "pr", "resubmit", "--pr", &format!("at://{ALICE}/{PULL_NSID}/{rkey}"), ], ) .refused_with(4, "belongs to"); assert_eq!(world.pulls(ALICE), before, "Alice's pull was written to"); } /// The same rule for `pr edit`, and the same `4`. Worth its own test because /// the two verbs refuse in different functions for different reasons — a /// round and a title are both writes into the author's PDS, and only the /// author has one. #[test] fn editing_another_accounts_pull_is_refused() { let world = Scenario::new("pr-edit-identity"); feature_branch(&world); let rkey = open_pull(&world); let before = world.pulls(ALICE); world .run_as( BOB, &[ "pr", "edit", &format!("at://{ALICE}/{PULL_NSID}/{rkey}"), "--title", "not yours", ], ) .refused_with(4, "belongs to"); assert_eq!(world.pulls(ALICE), before, "Alice's pull was written to"); } /// A command line that names no change is `2`, not `1`. These are the /// refusals a script hits most often and the ones a `1` told it least about: /// nothing was looked up, nothing is missing, and the fix is on the line /// that was typed. #[test] fn a_command_line_that_does_not_mean_anything_exits_two() { let world = Scenario::new("pr-usage"); feature_branch(&world); let rkey = open_pull(&world); world .run(&["pr", "edit", &rkey]) .refused_with(2, "nothing to change"); world .run(&["pr", "comment", &rkey]) .refused_with(2, "nothing to say"); // `--body` with `--body-file` is not here on purpose: clap declares them // `conflicts_with`, so it refuses that pair itself and the arm inside // `comment_body` is unreachable from a command line. Both layers exit // `2`, which is the agreement `a_command_line_that_is_wrong_exits_two…` // in tests/exit_status.rs is there to hold. // // Rounds are one-based here and zero-based in Tangled's URLs, which is // the mistake this message exists for. world .run(&["pr", "diff", &rkey, "--round", "0"]) .refused_with(2, "no round 0"); world .run(&["pr", "list", "--source", "nope"]) .refused_with(2, "unknown --source"); } // --------------------------------------------------------------------------- // edit // --------------------------------------------------------------------------- /// `pr edit` changes the fields it was given and nothing else — the rounds /// in particular, which are the expensive half of the record and the half an /// edit has no business touching. #[test] fn editing_a_title_leaves_the_rounds_and_the_body_alone() { let world = Scenario::new("pr-edit"); feature_branch(&world); let rkey = open_pull(&world); world .checkout .commit("three.txt", "three\n", "feat: third", None); world.run(&["pr", "resubmit", "--pr", &rkey]).success(); let rounds_before = world.rounds(ALICE, &rkey); let first = world.round_patch(ALICE, &rkey, 0); world .run(&["pr", "edit", &rkey, "--title", "a better title"]) .success(); let (_, value) = only_pull(&world); assert_eq!(value["title"].as_str(), Some("a better title")); assert_eq!( value["body"].as_str(), Some("why"), "an edit of the title rewrote the body: {value:#}" ); assert_eq!( world.rounds(ALICE, &rkey), rounds_before, "an edit changed the round count" ); assert_eq!( world.round_patch(ALICE, &rkey, 0), first, "an edit rewrote a round's patch" ); } // --------------------------------------------------------------------------- // close, reopen // --------------------------------------------------------------------------- /// State is a log, not a field: closing appends a `closed` status record and /// reopening appends an `open` one beside it rather than deleting or /// overwriting the first. /// /// The distinction matters because the appview recomputes a pull's state as /// the newest record. An implementation that put over the closed record /// would look identical after a close and wrong after a reopen, and the /// pull's own record is never touched by either. #[test] fn closing_then_reopening_appends_two_status_records_and_edits_neither() { let world = Scenario::new("pr-close-reopen"); feature_branch(&world); let rkey = open_pull(&world); let (_, pull_before) = only_pull(&world); world.run(&["pr", "close", &rkey]).success(); let closed = world.with(|w| w.collection(ALICE, PULL_STATUS_NSID)); assert_eq!(closed.len(), 1, "a close wrote no status record"); assert_eq!( closed[0].1.value["status"].as_str(), Some("sh.tangled.repo.pull.status.closed"), ); world.run(&["pr", "reopen", &rkey]).success(); let both = world.with(|w| w.collection(ALICE, PULL_STATUS_NSID)); assert_eq!( both.len(), 2, "a reopen replaced the close instead of following it" ); // Newest last: the record keys are TIDs, so the collection is already in // the order the appview resolves state by. assert_eq!( both[1].1.value["status"].as_str(), Some("sh.tangled.repo.pull.status.open"), ); // Both name the same pull, and the pull itself was never rewritten. for (rkey, record) in &both { assert!( record.value["pull"] .as_str() .unwrap_or_default() .ends_with(&pull_key(&pull_before, &only_pull(&world))), "{rkey} names some other pull: {:#}", record.value ); } assert_eq!( only_pull(&world).1, pull_before, "the pull record was edited" ); } /// The record key both halves of the assertion above agree on. fn pull_key(before: &serde_json::Value, after: &(String, serde_json::Value)) -> String { assert_eq!(&after.1, before, "the pull record changed"); after.0.clone() } // --------------------------------------------------------------------------- // merge // --------------------------------------------------------------------------- /// Merging a flat pull checks with the knot, merges, and records the state — /// and a round appended afterwards is refused, because it was never part of /// what landed. /// /// The second half is the sequence: nothing about a `pr resubmit` in /// isolation is wrong, and nothing about a `pr merge` in isolation is wrong. /// The bug was a round going onto a freshly merged pull, which only exists /// as an ordering between the two. #[test] fn merging_records_the_state_and_a_later_round_is_refused() { let world = Scenario::new("pr-merge"); feature_branch(&world); let rkey = open_pull(&world); world.run(&["pr", "merge", &rkey]).success(); let labels = world.with(|w| w.labels()); let knot: Vec<&String> = labels.iter().filter(|l| l.starts_with("knot ")).collect(); assert_eq!( knot, [ // `pr create` opened the pull by pushing and comparing; the two // merge calls are what `pr merge` itself sends. "knot sh.tangled.repo.compare", "knot sh.tangled.repo.mergeCheck", "knot sh.tangled.repo.merge" ], "{labels:?}" ); let statuses = world.with(|w| w.collection(ALICE, PULL_STATUS_NSID)); assert_eq!(statuses.len(), 1); assert_eq!( statuses[0].1.value["status"].as_str(), Some("sh.tangled.repo.pull.status.merged"), ); world .checkout .commit("three.txt", "three\n", "feat: after the merge", None); world .run(&["pr", "resubmit", "--pr", &rkey]) .refused("merged"); assert_eq!( world.rounds(ALICE, &rkey), 1, "a round landed on a merged pull" ); } /// A knot that refuses the merge leaves the pull open: no status record, and /// nothing that would make a listing report work as landed when it is not. #[test] fn a_conflicting_merge_leaves_the_pull_open() { let world = Scenario::new("pr-merge-conflict"); feature_branch(&world); let rkey = open_pull(&world); world.with(|w| w.merge_check = Err("would not apply".to_string())); world.run(&["pr", "merge", &rkey]).refused("conflict"); world.with(|w| { assert!(w.collection(ALICE, PULL_STATUS_NSID).is_empty()); assert!(w.calls_to("sh.tangled.repo.merge").is_empty()); }); assert_eq!(world.rounds(ALICE, &rkey), 1); } /// A merge by somebody who does not own the repo reaches the knot. /// /// The bug this pins: atgc read the repo record out of the *acting* /// account's PDS and treated its absence as "you may not merge this", /// refusing before any call went out. Tangled has no such rule — its own /// merge button sends the call as the logged-in account and lets the knot's /// push ACL answer — so every collaborator got a refusal citing a rule that /// does not exist. /// /// Bob names the pull by at-uri because a bare record key is read as the /// acting account's own, and this one is Alice's. #[test] fn a_collaborator_merges_a_pull_on_a_repo_they_do_not_own() { let world = Scenario::new("pr-merge-collaborator"); feature_branch(&world); let rkey = open_pull(&world); let uri = format!("at://{ALICE}/{PULL_NSID}/{rkey}"); world.run_as(BOB, &["pr", "merge", &uri]).success(); world.with(|w| { let merges = w.calls_to("sh.tangled.repo.merge"); assert_eq!(merges.len(), 1, "the knot was not asked to merge"); assert_eq!( merges[0].actor.as_deref(), Some(BOB), "the merge went out as somebody other than the account merging", ); // Routed by the repo's own DID. Bob cannot know how Alice names the // repo, and a current knot does not need him to. assert_eq!(merges[0].body["repo"].as_str(), Some(REPO_DID)); assert!( merges[0].body["name"].is_null(), "a repo name was invented for an account that holds no record of it: {:#}", merges[0].body, ); }); // The merged status goes in the merging account's PDS, which is where // Tangled's own web merge puts it — not the owner's, whose credentials // nobody here has. let statuses = world.with(|w| w.collection(BOB, PULL_STATUS_NSID)); assert_eq!(statuses.len(), 1, "no merged status in the actor's PDS"); assert_eq!( statuses[0].1.value["status"].as_str(), Some("sh.tangled.repo.pull.status.merged"), ); assert_eq!(statuses[0].1.value["pull"].as_str(), Some(uri.as_str())); world.with(|w| { assert!( w.collection(ALICE, PULL_STATUS_NSID).is_empty(), "a record was written into the repo owner's PDS", ); }); } /// A second `pr create` on a branch that already has one says so. /// /// Several pulls per branch are legal, and the two warnings beside this one /// explain why, so this is not a refusal. It is what a *retry* looks like: /// `pr create` writes its record and then does best-effort work after it, so /// a caller that reads any failure as "nothing happened" and runs the /// command again opens a copy of the pull it already has. Nothing said so, /// and an agent retrying on a flaky network is exactly the caller that does /// it — this repo has the duplicate pulls to prove it. #[test] fn a_second_pull_on_one_branch_names_the_one_that_is_already_open() { let world = Scenario::new("pr-create-duplicate"); feature_branch(&world); let first = open_pull(&world); let run = world .run(&["pr", "create", "--title", "a pull", "--body", "why"]) .success(); assert!( run.stderr.contains("already has an open pull"), "the duplicate was not mentioned:\n{}", run.stderr ); assert!( run.stderr.contains(&first), "the warning has to name the pull that exists:\n{}", run.stderr ); assert!( run.stderr.contains("pr resubmit"), "the warning has to say what to do instead:\n{}", run.stderr ); assert_eq!( world.pulls(ALICE).len(), 2, "a warning must not stop the create; several pulls per branch are legal" ); } /// A closed pull does not make a fresh one look like a duplicate. /// /// Opening a new pull after the last one was closed is an ordinary thing to /// want, and the warning above must not fire on it — otherwise the way out /// of a rejected pull comes with a line saying you are doing it wrong. #[test] fn a_closed_pull_on_the_branch_is_not_a_duplicate() { let world = Scenario::new("pr-create-after-close"); feature_branch(&world); let first = open_pull(&world); world.run(&["pr", "close", &first]).success(); let run = world .run(&[ "pr", "create", "--title", "a second attempt", "--body", "why", ]) .success(); assert!( !run.stderr.contains("already has an open pull"), "a closed pull was treated as open:\n{}", run.stderr ); } /// The status walk stops once a page ends below the pull's own key. /// /// **This is the rule the mock could not exercise until it served pages the /// way a real PDS does.** `listRecords` returns a collection newest first, /// and a status record about a pull cannot have been written before the /// pull, so once a page ends below the pull's own record key there is /// nothing older left to find. The walk is bounded by that floor and not by /// its page cap — the cap exists only so a PDS handing back a cursor forever /// could not spin. /// /// The mock used to serve this collection *ascending*, which made the floor /// condition one that could never hold on the page it is meant to hold on: /// every paging test was arranged backwards from reality, and this rule was /// untestable rather than untested. `plan/testing.md` had it as an open /// entry for exactly that reason. /// /// What it pins is the cost of getting it wrong. Without the floor the walk /// reads the whole collection on every state change, which on a busy account /// is the difference between two requests and fifty. #[test] fn the_status_walk_stops_at_the_pulls_own_key() { let world = Scenario::new("status-walk-floor"); feature_branch(&world); let rkey = open_pull(&world); let uri = format!("at://{ALICE}/{PULL_NSID}/{rkey}"); // Two hundred status records about other pulls, all *older* than this // one: they sort below its key, so a faithful listing reaches the floor // on the first page and never asks for a second. world.with(|w| { for i in 0..200 { w.plant( ALICE, PULL_STATUS_NSID, &format!("3aaaaaaaaaa{i:03}"), serde_json::json!({ "pull": "at://did:plc:someone/sh.tangled.repo.pull/other", "status": "sh.tangled.repo.pull.status.closed", "createdAt": "2026-01-01T00:00:00Z", }), ); } }); world.clear_journal(); world.run(&["pr", "close", &uri]).success(); let pages = world.with(|w| { w.calls_to("com.atproto.repo.listRecords") .into_iter() .filter(|c| c.params.get("collection").map(String::as_str) == Some(PULL_STATUS_NSID)) .count() }); // Two, because two separate walks read this collection on a state // change: the listing's own status walk and `list_statuses`, which is // what settles the pull's current state. One page each is the floor // working, and the number to watch is not 1 but "two, not four" — // stubbing the floor out makes this read 4, which is what the walks cost // when they run to the end of the collection instead. assert_eq!( pages, 2, "the walk read {pages} page(s) of statuses across two walks; the floor \ should have stopped each of them on its first" ); } /// The knot, not atgc, is what turns a merge down — and when it does, the /// pull stays open and the refusal says whose problem it is. #[test] fn a_knot_that_denies_push_access_stops_the_merge_after_asking() { let world = Scenario::new("pr-merge-denied"); feature_branch(&world); let rkey = open_pull(&world); let uri = format!("at://{ALICE}/{PULL_NSID}/{rkey}"); world.with(|w| w.knot_push_allowed = Some(vec![ALICE.to_string()])); // Exit 4, not the unclassified 1: the knot answered `AccessControl`, and // a script that has to tell "you may not do this" from "something went // wrong" reads the code rather than the sentence. The knot spells that // one refusal 401 here, 403 from its membership handlers and 400 from // `delete_repo`, so the *tag* is what decides — see `knot::Refused::exit`. world .run_as(BOB, &["pr", "merge", &uri]) .refused_with(4, "push access"); world.with(|w| { assert_eq!( w.calls_to("sh.tangled.repo.merge").len(), 1, "the refusal was decided without asking the knot", ); assert!( w.collection(BOB, PULL_STATUS_NSID).is_empty(), "a merge the knot refused was recorded as merged", ); }); } /// The owner's merge still names the repo the old way. /// /// `did` and `name` are what a knot too old for `repo-did-input` reads in /// place of `repo`, so they are sent whenever they are known. Dropping them /// along with the ownership check would have broken those knots quietly, on /// the one path where quiet means a branch that did not move. #[test] fn an_owner_merging_still_sends_the_legacy_repo_name() { let world = Scenario::new("pr-merge-legacy-fields"); feature_branch(&world); let rkey = open_pull(&world); world.run(&["pr", "merge", &rkey]).success(); world.with(|w| { let merges = w.calls_to("sh.tangled.repo.merge"); assert_eq!(merges[0].body["repo"].as_str(), Some(REPO_DID)); assert_eq!(merges[0].body["did"].as_str(), Some(ALICE)); assert_eq!(merges[0].body["name"].as_str(), Some("demo")); }); } /// A stacked pull is not a flat one: `pr merge` refuses a stack member and /// names the command that lands a chain, because merging one member alone /// would skip everything beneath it. /// /// The refusal is only reachable through the records another command wrote: /// being depended on is written in other pulls, never in the one being /// merged. #[test] fn merging_a_stack_member_with_pr_merge_is_refused() { let world = Scenario::new("pr-merge-stacked"); world.checkout.branch("feature"); world.checkout.commit( "one.txt", "one\n", "feat: bottom", Some("Istackbottom000000000000000000000000000a"), ); world.checkout.commit( "two.txt", "two\n", "feat: top", Some("Istacktop000000000000000000000000000000a"), ); world.run(&["stack", "create"]).success(); let bottom = world.pulls(ALICE).remove(0).0; // The create is setup, and it reaches the knot itself now — it pushes // the branch and confirms it with `repo.compare`. The claim below is // about the *merge*, so the journal starts here. world.clear_journal(); world .run(&["pr", "merge", &bottom]) .refused_with(2, "stack merge"); world.with(|w| { assert!( w.calls("knot").is_empty(), "the knot was asked to merge a stack member" ); assert!(w.collection(ALICE, PULL_STATUS_NSID).is_empty()); }); } // --------------------------------------------------------------------------- // where a listing reads from // --------------------------------------------------------------------------- /// A pull somebody else wrote, as Bobbin's index would hand it back. /// /// Planted in the mock index and nowhere else, which is the whole point: no /// PDS this scenario can read holds it, so it appears in a listing only if /// atgc went to the index for it. fn bobbins_pull_by_bob(world: &Scenario) { world.with(|w| { w.bobbin_pulls = vec![serde_json::json!({ "uri": format!("at://{BOB}/{PULL_NSID}/3indexedonlyaaa"), "state": "open", "commentCount": 0, "value": { "title": "bob's pull, known only to the index", "createdAt": "2026-08-14T00:00:00Z", "target": { "repo": REPO_DID, "repoDid": REPO_DID, "branch": "main" }, "source": { "branch": "bobs-branch" }, }, })]; }); } /// `--author` without `--all` narrows this repo to one account, by reading /// *their* PDS instead of yours. /// /// A repo listing's complete half has always been one account's records, so /// naming somebody else only changes which PDS is read — no index, no token, /// and no permission on anything. The half worth asserting is the prose: a /// listing of Bob's pulls that still says "your PDS" is the same class of /// wrong answer the note exists to prevent. #[test] fn author_without_all_lists_this_repo_from_that_accounts_pds() { let world = Scenario::new("pr-list-author-in-repo"); feature_branch(&world); open_pull(&world); world.checkout.branch("bobs-branch"); world .checkout .commit("bob.txt", "bob\n", "feat: bob's work", None); world .run_as(BOB, &["pr", "create", "--title", "bob's pull"]) .success(); world.clear_journal(); let listed = world .run(&["pr", "list", "--author", BOB, "--json"]) .success(); let rows = listed.json(); let rows = rows.as_array().expect("pr list --json prints an array"); assert_eq!(rows.len(), 1, "not exactly bob's pull: {rows:#?}"); assert_eq!(rows[0]["title"].as_str(), Some("bob's pull")); assert_eq!(rows[0]["author_did"].as_str(), Some(BOB)); world.with(|w| { assert!( w.calls("bobbin").is_empty(), "an index was contacted without being opted in" ); }); // The bare listing's footnote is about the reader's own pulls, and a // reader who named an account is not being told anything by it. assert!( !listed.stderr.contains("your pull requests on this repo"), "--- stderr ---\n{}", listed.stderr ); // And the reader's own pull is not in it, which is the filter working // rather than the account merely being unread. let titles: Vec<&str> = rows.iter().filter_map(|r| r["title"].as_str()).collect(); assert!(!titles.contains(&"a pull"), "{titles:?}"); } /// An index source has every author in it, so `--author` has to narrow what /// the index brought back too — not only which PDS was read. #[test] fn author_narrows_an_index_source_as_well_as_the_pds() { let world = Scenario::new("pr-list-author-with-index"); feature_branch(&world); open_pull(&world); bobbins_pull_by_bob(&world); world.clear_journal(); let rows = world .run(&[ "pr", "list", "--author", BOB, "--source", "pds,bobbin", "--json", ]) .success() .json(); let rows = rows.as_array().expect("pr list --json prints an array"); assert_eq!(rows.len(), 1, "not exactly bob's pull: {rows:#?}"); assert_eq!( rows[0]["title"].as_str(), Some("bob's pull, known only to the index") ); } /// The default listing reads one PDS and does not contact the index at all. /// /// Both halves matter. That Bob's pull is absent is the visible half; that /// the mock Bobbin recorded no call is the half that makes "opt-in" mean /// something, since a listing could equally have fetched the index and /// dropped the rows. #[test] fn pr_list_reads_the_pds_alone_and_never_asks_the_index() { let world = Scenario::new("pr-list-default-source"); feature_branch(&world); open_pull(&world); bobbins_pull_by_bob(&world); world.clear_journal(); let listed = world.run(&["pr", "list", "--json"]).success(); let rows = listed.json(); let rows = rows.as_array().expect("pr list --json prints an array"); assert_eq!(rows.len(), 1, "only the account's own pull: {rows:#?}"); assert_eq!(rows[0]["title"].as_str(), Some("a pull")); world.with(|w| { assert!( w.calls("bobbin").is_empty(), "the index was contacted without being opted in: {:#?}", w.calls("bobbin") ); }); // And the listing says so, rather than letting one row read as the whole // repo's worth of pull requests. assert!( listed.stderr.contains("your pull requests on this repo"), "--- stderr ---\n{}", listed.stderr ); } /// Naming the index opts in, and then the other author's pull is there. /// /// The companion to the test above: without this one, "the default hides /// Bob's pull" would be equally satisfied by a build that could not see it /// at all. #[test] fn naming_bobbin_opts_in_and_the_other_authors_pull_appears() { let world = Scenario::new("pr-list-bobbin-source"); feature_branch(&world); open_pull(&world); bobbins_pull_by_bob(&world); world.clear_journal(); let rows = world .run(&["pr", "list", "--source", "pds,bobbin", "--json"]) .success() .json(); let rows = rows.as_array().expect("pr list --json prints an array"); assert_eq!(rows.len(), 2, "both authors' pulls: {rows:#?}"); let titles: Vec<&str> = rows.iter().filter_map(|r| r["title"].as_str()).collect(); assert!( titles.contains(&"bob's pull, known only to the index"), "{titles:?}" ); world.with(|w| assert!(!w.calls("bobbin").is_empty())); } /// A `--state` word this build has never heard of is a typo, and `pr list` /// used to answer one with "no opne pull requests" — a sentence that reads as /// *this repo has none*. That is the stale-index failure this tree spent a /// release refusing to commit, reproduced from the command line, so it is a /// refusal now: exit 2, and the four words it does take. /// /// Both scopes the flag reaches, because `--all` swaps the whole gather /// underneath it. And an empty journal, because a typo is not worth a round /// trip. #[test] fn a_state_this_build_does_not_know_is_refused_rather_than_matching_nothing() { let world = Scenario::new("pr-list-state-typo"); feature_branch(&world); let rkey = open_pull(&world); world.clear_journal(); for scope in [&[][..], &["--all"][..]] { let mut argv = vec!["pr", "list", "--state", "opne"]; argv.extend_from_slice(scope); let refused = world.run(&argv).refused_with(2, "invalid value 'opne'"); assert!( refused.stderr.contains("open, closed, merged, all"), "`pr list {scope:?}` did not name what --state takes: {}", refused.stderr ); } world.with(|w| { assert!( w.journal.is_empty(), "a typo was worth a round trip: {:?}", w.labels() ); }); // And every word it does take still filters exactly as it did: the one // open pull is in `open` and `all`, and in neither of the other two. let listed = |scope: &[&str], state: &str| -> usize { let mut argv = vec!["pr", "list", "--state", state, "--json"]; argv.extend_from_slice(scope); world .run(&argv) .success() .json() .as_array() .expect("--json prints an array") .len() }; assert_eq!(listed(&[], "open"), 1); assert_eq!(listed(&[], "all"), 1); assert_eq!(listed(&[], "closed"), 0); assert_eq!(listed(&[], "merged"), 0); assert_eq!(listed(&["--all"], "open"), 1); // Closing it moves the row, which is the half a filter that quietly // matched nothing would also have passed. world.run(&["pr", "close", &rkey]).success(); assert_eq!(listed(&[], "closed"), 1); assert_eq!(listed(&[], "open"), 0); } /// `search` has no answer that does not come from the index, so it refuses /// until one is named rather than reaching for it anyway. #[test] fn search_refuses_until_an_index_is_opted_in() { let world = Scenario::new("search-needs-an-index"); world.clear_journal(); world .run(&["search", "anything"]) .refused("ATGC_USE_BOBBIN=1"); world.with(|w| { assert!( w.calls("bobbin").is_empty(), "search reached the index before refusing" ); }); } // --------------------------------------------------------------------------- // list // --------------------------------------------------------------------------- /// `--state` may not be answered off the first page of the walk. /// /// The failure this holds down, in the order it happened: `pr list` shows a /// screenful, every pull in it gets closed, and the next `pr list` prints /// nothing at all. That reads as "none left" and meant "the newest thirty /// are closed now" — the open ones further down were never fetched, because /// the walk stopped counting at `--limit` records whose state it had not yet /// looked at. A pull's state is in a separate collection, so the walk cannot /// know what the filter is about to discard; under a filter it has to read /// the whole collection. /// /// The mock PDS pages by ascending record key where a real one lists newest /// first. What the test needs from either is only that the matching records /// lie past the first page, which the keys below arrange. #[test] fn a_state_filter_reads_past_the_first_page() { let world = Scenario::new("pr-list-deep"); world.with(|w| { for i in 0..105 { let rkey = format!("3aaa{i:04}"); let closed = i < 100; w.plant( ALICE, PULL_NSID, &rkey, serde_json::json!({ "$type": PULL_NSID, "title": format!("pull {i}"), "target": { "repo": REPO_DID, "repoDid": REPO_DID, "branch": "main" }, "createdAt": format!("2026-01-01T{:02}:{:02}:00Z", i / 60, i % 60), }), ); w.plant( ALICE, PULL_STATUS_NSID, &format!("3sss{i:04}"), serde_json::json!({ "$type": PULL_STATUS_NSID, "pull": format!("at://{ALICE}/{PULL_NSID}/{rkey}"), "status": match closed { true => "sh.tangled.repo.pull.status.closed", false => "sh.tangled.repo.pull.status.open", }, "createdAt": "2026-01-02T00:00:00Z", }), ); } }); let open = world.run(&["pr", "list", "--json"]).success(); let rows = open.json(); let rows = rows.as_array().expect("an array"); assert_eq!( rows.len(), 5, "the open pulls below the first page went unread\n--- stderr ---\n{}", open.stderr ); assert!( rows.iter().all(|r| r["state"] == "open"), "a closed pull survived --state open: {rows:#?}" ); // The other half of the bug: the note under the listing called a walk // that stopped early "complete". Only the filtered walk reads the whole // collection, so only it may say so. assert!( open.stderr.contains("complete, current"), "a complete walk would not say so\n--- stderr ---\n{}", open.stderr ); let all = world .run(&["pr", "list", "--state", "all", "--json"]) .success(); assert_eq!(all.json().as_array().expect("an array").len(), 30); assert!( all.stderr.contains("not all of your own"), "a screenful was reported as the whole collection\n--- stderr ---\n{}", all.stderr ); } /// A listing that read everything and printed thirty rows owes the reader a /// count of the rest. /// /// `repo list` had said this since it shipped and the pull listing never had: /// it cut to `--limit` in silence, which was survivable only while the walk /// stopped at about `--limit` anyway. Reading the whole collection made the /// silence load-bearing — thirty rows out of two hundred, with nothing on /// either stream to say so — and both listings now say it through one /// reporter. #[test] fn a_listing_says_how_many_rows_the_limit_cut() { let world = Scenario::new("pr-list-limit"); world.with(|w| { for i in 0..40 { let rkey = format!("3bbb{i:04}"); w.plant( ALICE, PULL_NSID, &rkey, serde_json::json!({ "$type": PULL_NSID, "title": format!("pull {i}"), "target": { "repo": REPO_DID, "repoDid": REPO_DID, "branch": "main" }, "createdAt": format!("2026-01-01T{:02}:{:02}:00Z", i / 60, i % 60), }), ); w.plant( ALICE, PULL_STATUS_NSID, &format!("3ttt{i:04}"), serde_json::json!({ "$type": PULL_STATUS_NSID, "pull": format!("at://{ALICE}/{PULL_NSID}/{rkey}"), "status": "sh.tangled.repo.pull.status.open", "createdAt": "2026-01-02T00:00:00Z", }), ); } }); let run = world .run(&["pr", "list", "--limit", "5", "--json"]) .success(); assert_eq!(run.json().as_array().expect("an array").len(), 5); assert!( run.stderr.contains("35 more; raise --limit"), "the rows --limit cut went unmentioned\n--- stderr ---\n{}", run.stderr ); } // --------------------------------------------------------------------------- // reading somebody else's pull: diff and checkout // --------------------------------------------------------------------------- /// A pasted pull URL is read against the repo *the URL names*, not against /// whatever repo the current checkout points at. /// /// A pull number is allocated per repo, so `#23` on two repos is two pull /// requests. The URL parser used to keep the number and throw the /// `@owner/name` in front of it away, and the number was then resolved against /// `git remote get-url origin`. Standing in your own checkout, `atgc pr diff /// ` printed *your* #23's patch — and since a /// patch on a pipe carries no repo anywhere in it, nothing said so. #[test] fn a_pasted_pull_url_names_the_repo_its_number_belongs_to() { let world = Scenario::new("pr-diff-pasted-url"); // Two pulls numbered 23: one on the repo this checkout points at, one on // a repo it has never heard of. Pre-rounds records, whose patch is inline, // so that what is printed is decided by which record was read and by // nothing else. world.with(|w| { w.plant( ALICE, PULL_NSID, "3aaaaaaaaaaaa", serde_json::json!({ "title": "alice's twenty-third", "targetRepo": REPO_DID, "targetBranch": "main", "patch": "--- a/alice.txt\n+++ b/alice.txt\n", "createdAt": "2026-01-01T00:00:00Z", }), ); w.plant( BOB, PULL_NSID, "3bbbbbbbbbbbb", serde_json::json!({ "title": "bob's twenty-third", "targetBranch": "main", "patch": "--- a/bob.txt\n+++ b/bob.txt\n", "createdAt": "2026-01-01T00:00:00Z", }), ); // The page each repo's `/pulls/23` renders. The checkout's own repo is // addressed by DID, which is what `resolve::repo_ref` makes of an // `origin` pointing at a knot. w.pull_pages.insert( (REPO_DID.to_string(), 23), format!("at://{ALICE}/{PULL_NSID}/3aaaaaaaaaaaa"), ); w.pull_pages.insert( ("@bob.test/otherproject".to_string(), 23), format!("at://{BOB}/{PULL_NSID}/3bbbbbbbbbbbb"), ); }); let url = format!("{}/@bob.test/otherproject/pulls/23", world.appview_url()); let run = world.run(&["pr", "diff", &url]).success(); assert!( run.stdout.contains("bob.txt"), "the link named Bob's repo and this is not Bob's patch\n--- stdout ---\n{}", run.stdout ); assert!( !run.stdout.contains("alice.txt"), "the checkout's own #23 was printed for a link to another repo's\n--- stdout ---\n{}", run.stdout ); // And a bare number still means "in this repo", which is the whole reason // these commands take a `--remote` at all. let run = world.run(&["pr", "diff", "23"]).success(); assert!( run.stdout.contains("alice.txt"), "a bare number stopped meaning this checkout's repo\n--- stdout ---\n{}", run.stdout ); } /// A branch-based pull whose *target* branch is not already fetched still /// corroborates, and still checks out the author's own commits. /// /// `pr checkout` fetches the pull's source branch, so `FETCH_HEAD` names the /// author's commits — and then asks `base_ref` for the target branch, which /// runs a `git fetch` of its own whenever `refs/remotes//` is /// not already local. Git rewrites `FETCH_HEAD` on every fetch, so the /// corroboration that followed compared the base against the base, found an /// empty diff, and refused a branch in perfect order with "stale, /// force-pushed, or a different branch" — steering the reviewer to `--patch` /// and throwing away the real commits the branch path exists to preserve. /// /// The trigger is ordinary: a shallow or `--single-branch` clone, a narrowed /// `remote.origin.fetch`, or a target branch created since the last fetch. /// Here it is a remote nothing has ever fetched from. #[test] fn a_branch_based_checkout_survives_the_base_being_fetched_as_well() { let world = Scenario::new("pr-checkout-unfetched-base"); feature_branch(&world); let feature_head = world.checkout.head(); // The knot formats the patch a round records, so hand it the real one: // the corroboration compares that patch against the branch's own diff, // and [`KNOT_PATCH`] is deliberately unlike anything a checkout produces. let formatted = world .checkout .git(&["format-patch", "--stdout", "main..feature"]); world.with(|w| w.compare = Ok((2, formatted))); let rkey = open_pull(&world); // A remote git can really fetch from. The scenario's `origin` points at // the mock knot, which speaks XRPC and not git; the bare repo beside the // checkout is where its pushes actually land, so it holds `feature` // already and only needs `main`. let bare = world.checkout.bare.to_string_lossy().to_string(); world.checkout.git(&["remote", "add", "knot", &bare]); let main_head = world.checkout.git(&["rev-parse", "main"]); world.checkout.publish_elsewhere("main", main_head.trim()); world.checkout.git(&["checkout", "-q", "main"]); // Nothing has ever fetched from `knot`, so `knot/main` is not a local // ref and `base_ref` has to fetch it. That second fetch is the bug. assert!( !world .checkout .path .join(".git/refs/remotes/knot/main") .exists(), "the fixture was supposed to leave knot/main unfetched" ); let run = world .run(&["pr", "checkout", &rkey, "--remote", "knot"]) .success(); assert!( run.stdout.contains("corroborated"), "a branch that matches its round was not corroborated\n--- stdout ---\n{}\ \n--- stderr ---\n{}", run.stdout, run.stderr ); // The author's own commits, not a replay of the patch: same sha. assert_eq!( world.checkout.head(), feature_head, "the branch path checked out something other than the fetched branch" ); } /// `pr checkout --round N` takes the record's own patch for any round but the /// last, and says so, instead of refusing. /// /// The branch on the knot is the pull's *latest* round by construction: /// whatever the author pushed last is what the name resolves to, and no /// earlier round is anywhere on the remote. The corroboration compared the /// branch against the round the user asked for anyway, which for an older /// round cannot succeed, and then blamed the branch — "stale, force-pushed, /// or a different branch" — for a question it was never asked. The record's /// own copy is the only thing that can answer for a historical round. #[test] fn an_older_round_is_taken_from_the_record_rather_than_from_the_branch() { let world = Scenario::new("pr-checkout-older-round"); feature_branch(&world); let formatted = world .checkout .git(&["format-patch", "--stdout", "main..feature"]); world.with(|w| w.compare = Ok((2, formatted))); let rkey = open_pull(&world); // A second round, so that round 1 is behind the branch — which is the // only shape in which the question can be asked at all. world.checkout.amend_file("two.txt", "two, revised\n"); world.with(|w| w.compare = Ok((2, SECOND_KNOT_PATCH.to_string()))); world.run(&["pr", "resubmit", &rkey]).success(); assert_eq!(world.rounds(ALICE, &rkey), 2); let bare = world.checkout.bare.to_string_lossy().to_string(); world.checkout.git(&["remote", "add", "knot", &bare]); let main_head = world.checkout.git(&["rev-parse", "main"]); world.checkout.publish_elsewhere("main", main_head.trim()); world.checkout.git(&["checkout", "-q", "main"]); let run = world .run(&["pr", "checkout", &rkey, "--round", "1", "--remote", "knot"]) .success(); assert!( run.stderr.contains("not round 1"), "nothing said why the branch was passed over\n--- stderr ---\n{}", run.stderr ); assert!( run.stdout.contains("round: 1 of 2"), "round 1 was not the one applied\n--- stdout ---\n{}", run.stdout ); } /// A pull opened by somebody who does not own the repo, so the two accounts /// are on opposite sides of every record below: Bob authors the pull, Alice /// owns the target repo. fn open_pull_as_a_contributor(world: &Scenario) -> String { world .run_as(BOB, &["pr", "create", "--title", "bob's pull", "--json"]) .success() .json()["uri"] .as_str() .expect("pr create --json carries the uri it wrote") .rsplit('/') .next() .expect("an at-uri ends in a record key") .to_string() } /// **A repo owner's close of somebody else's pull decides that pull's state, /// from a record in a repository that is not the pull's.** /// /// A status record goes in the *acting* account's PDS, never the pull /// author's — that is what lets an owner close a contributor's pull without /// being able to write to the contributor's repository. `stateWinner` /// (`appview/db/entity_state.go`, read 2026-08-28) then ranks the rows by /// `created_micros desc, at_uri desc` with no reference to who wrote them. /// /// The easy thing to assume wrong is that the pull's author has the last /// word, or that a record in a stranger's repository is somehow weaker. /// Every record here is in the "wrong" repository for that reading. #[test] fn a_repo_owners_close_of_anothers_pull_is_the_state_the_appview_reads() { let world = Scenario::new("pr-state-owner-closes"); feature_branch(&world); let rkey = open_pull_as_a_contributor(&world); let uri = format!("at://{BOB}/{PULL_NSID}/{rkey}"); // Alice owns the target repo and did not open this pull. world.run(&["pr", "close", &uri]).success(); world.with(|w| { assert!( w.collection(BOB, PULL_STATUS_NSID).is_empty(), "the close was written into the pull author's PDS" ); assert_eq!( w.collection(ALICE, PULL_STATUS_NSID).len(), 1, "the acting account wrote no status record" ); assert_eq!( support::appview::state_of(w, BOB, &rkey), support::appview::PullState::Closed, "the appview would not honour the owner's close" ); }); } /// A pull nothing has ever written a status about is open, and the appview /// reaches that through a different road than an explicit `open` record: /// `applyPullStatus` finds no winner at all. Worth separating, because a /// model that returned "open" for an unknown pull would hide a whole class /// of missing-record bug. #[test] fn a_pull_with_no_status_record_is_open_and_an_unknown_one_is_absent() { let world = Scenario::new("pr-state-default"); feature_branch(&world); let rkey = open_pull(&world); world.with(|w| { assert!(w.collection(ALICE, PULL_STATUS_NSID).is_empty()); assert_eq!( support::appview::state_of(w, ALICE, &rkey), support::appview::PullState::Open, ); assert_eq!( support::appview::state_of(w, ALICE, "3zzzzzzzzzzzz"), support::appview::PullState::Absent, "a pull the index does not hold read as a state rather than as absent" ); }); } /// **A pull merged by the repo owner cannot be closed by its author.** /// /// `pr close` refuses to write over a `merged`, and the comment on that /// refusal says why: state is a log of records and the newest wins, so a /// `closed` written after a `merged` does not sit beside it, it replaces it /// in every reader's view. /// /// The guard was right and the state it guarded on was not. Current state /// came from `list_statuses`, which walked the pull author's PDS and the /// acting account's — and a merge performed by the repo owner lands in the /// *owner's* PDS, which is the ordinary way a contributor's pull gets /// merged. The author saw no status record at all, `state_of` fell through /// to `Open`, and the guard never fired: atgc printed `open -> closed` and /// hid the merge. /// /// Alice owns the repo and merges; Bob wrote the pull and tries to close it. #[test] fn an_owners_merge_is_seen_by_the_authors_close_and_refuses_it() { let world = Scenario::new("pr-close-hides-owners-merge"); feature_branch(&world); let rkey = world .run_as(BOB, &["pr", "create", "--title", "bob's pull", "--json"]) .success() .json()["uri"] .as_str() .expect("the uri") .rsplit('/') .next() .expect("a record key") .to_string(); let uri = format!("at://{BOB}/{PULL_NSID}/{rkey}"); // The owner merges. The `merged` status record is hers, in her PDS. world.run(&["pr", "merge", &uri]).success(); world.with(|w| { assert_eq!( w.collection(ALICE, PULL_STATUS_NSID).len(), 1, "the merge wrote no status record" ); assert!( w.collection(BOB, PULL_STATUS_NSID).is_empty(), "the merge status landed in the author's PDS" ); }); // The author now closes it. This must be refused: the pull is merged. let run = world.run_as(BOB, &["pr", "close", &uri]); assert_ne!( run.code, Some(0), "the author closed a merged pull\n--- stdout ---\n{}\n--- stderr ---\n{}", run.stdout, run.stderr ); assert!( run.stderr.contains("already merged"), "refused for the wrong reason\n--- stderr ---\n{}", run.stderr ); } /// The same blindness in its quieter form: a pull the repo owner closed, /// reopened by its author. Before the owner's records were read, this /// printed `already open; nothing written` and exited `0` — a success that /// asserted a state atgc could not see, against a pull tangled.org showed as /// closed. Now the reopen is a real write. #[test] fn an_author_can_reopen_what_the_repo_owner_closed() { let world = Scenario::new("pr-reopen-after-owners-close"); feature_branch(&world); let rkey = world .run_as(BOB, &["pr", "create", "--title", "bob's pull", "--json"]) .success() .json()["uri"] .as_str() .expect("the uri") .rsplit('/') .next() .expect("a record key") .to_string(); let uri = format!("at://{BOB}/{PULL_NSID}/{rkey}"); world.run(&["pr", "close", &uri]).success(); let run = world.run_as(BOB, &["pr", "reopen", &uri]).success(); assert!( !run.stdout.contains("nothing written"), "the reopen was a no-op against a pull the owner had closed\n--- stdout ---\n{}", run.stdout ); assert_eq!( world.with(|w| w.collection(BOB, PULL_STATUS_NSID).len()), 1, "the reopen wrote no status record" ); } /// An owner that cannot be resolved is refused rather than assumed absent. /// /// The appview publishes the repo-DID-to-owner mapping and nothing else /// does, so when it does not answer, the set of accounts whose records /// decide this pull's state is unknown. That is the same shape as the page /// cap below it — an absence that cannot be told apart from "nobody acted" — /// and it gets the same answer, because the write it would let through can /// erase a merge. #[test] fn an_unresolvable_owner_refuses_the_write() { let world = Scenario::new("pr-close-owner-unknown"); feature_branch(&world); let rkey = world .run_as(BOB, &["pr", "create", "--title", "bob's pull", "--json"]) .success() .json()["uri"] .as_str() .expect("the uri") .rsplit('/') .next() .expect("a record key") .to_string(); let uri = format!("at://{BOB}/{PULL_NSID}/{rkey}"); // The appview forgets who owns the repo. world.with(|w| { w.repo_owners.clear(); }); world .run_as(BOB, &["pr", "close", &uri]) .refused("cannot tell who owns the repo"); assert!( world.with(|w| w.collection(BOB, PULL_STATUS_NSID).is_empty()), "the refused run wrote a status record anyway" ); } /// **An index that is down is not an index that is empty**, and the listing /// says which it got. /// /// Every other index fixture in this suite models an index that answers, /// with rows or with none. A refused request is a different fact: an empty /// answer is evidence about the world and a 500 is evidence about nothing. /// The fallback for it was written and had no test, because the mock could /// not fail. #[test] fn an_index_that_is_down_falls_back_to_the_pds_and_says_so() { let world = Scenario::new("index-down-listing"); feature_branch(&world); open_pull(&world); world.with(|w| w.bobbin_fails = Some("InternalServerError")); let run = world .run(&["pr", "list", "--source", "pds,bobbin"]) .success(); // The account's own pulls still land: the PDS half is untouched. assert!( run.stdout.contains("a pull"), "the listing lost the records it could read\n--- stdout ---\n{}", run.stdout ); // And the reader is told the index failed and what that costs them. assert!( run.stderr .contains("continuing with your own pull requests from the PDS"), "the fallback was silent\n--- stderr ---\n{}", run.stderr ); assert!( run.stderr.contains("Other contributors'"), "the warning did not say what is now missing\n--- stderr ---\n{}", run.stderr ); } /// With nothing of the account's own to fall back *to*, a failed index is an /// error rather than an empty listing. "No pull requests" read off a refused /// request is the assertion this whole design exists to refuse. #[test] fn an_index_that_is_down_with_no_records_of_your_own_is_an_error() { let world = Scenario::new("index-down-empty"); feature_branch(&world); world.with(|w| w.bobbin_fails = Some("InternalServerError")); let run = world.run(&["pr", "list", "--source", "pds,bobbin"]); assert_ne!( run.code, Some(0), "an empty listing was printed off a failed index\n--- stdout ---\n{}", run.stdout ); } /// `search` is the one command with no answer that does not come from an /// index, so a dead index leaves it nothing to fall back to and it must fail /// rather than print an empty result set. /// /// "No matches" read off a refused request is the assertion this whole /// design exists to refuse, and it is worse here than anywhere: the reader /// asked a question about the whole network and would be told the answer is /// nothing. #[test] fn a_search_against_a_dead_index_fails_rather_than_finding_nothing() { let world = Scenario::new("search-index-down"); world.with(|w| w.bobbin_fails = Some("InternalServerError")); let run = world .command(&["search", "anything", "--source", "bobbin"]) .finish(); assert_ne!( run.code, Some(0), "an empty result set was printed off a failed index\n--- stdout ---\n{}", run.stdout ); assert!( run.stdout.trim().is_empty(), "a failed search printed results anyway\n--- stdout ---\n{}", run.stdout ); } /// **`pr close` needs the appview now, and says so plainly when it is down.** /// /// Reading the repo owner's status records is what stops a close from /// erasing a merge the owner performed, and there is exactly one route from /// a repo's DID to its owner: the appview publishes it as a redirect. A /// repo's DID document names its knot and nothing else, and the record that /// names an owner lives in that owner's PDS — the thing being looked for. /// /// So this command gained a dependency it did not have, on purpose, and the /// refusal has to be legible: an owner that cannot be resolved is refused /// rather than assumed absent, because the write it would let through can /// replace a `merged` in every reader's view. #[test] fn a_close_says_which_service_it_could_not_reach() { let world = Scenario::new("close-appview-down"); feature_branch(&world); let rkey = open_pull(&world); let uri = format!("at://{ALICE}/{PULL_NSID}/{rkey}"); world.with(|w| w.web_fails = true); let run = world.run(&["pr", "close", &uri]); assert_ne!( run.code, Some(0), "a close went ahead with no owner resolved" ); assert!( run.stderr.contains("cannot tell who owns the repo"), "the refusal did not name what it could not settle\n--- stderr ---\n{}", run.stderr ); assert!( run.stderr.contains("appview"), "the refusal did not name the service that was down\n--- stderr ---\n{}", run.stderr ); assert!( world.with(|w| w.collection(ALICE, PULL_STATUS_NSID).is_empty()), "a refused close wrote a status record anyway" ); } /// **A PDS that is unreachable is not a PDS that holds nothing.** The pull's /// author and the repo's owner both write status records that decide a /// pull's state, so a close reads two accounts' repositories — and either /// being down is ignorance, not absence. /// /// The distinction is the one this tool has got wrong most often, and the /// direction that matters is which way it fails: an unreadable PDS must stop /// the write, because the write is a `closed` record that outranks whatever /// is sitting unread. #[test] fn a_close_stops_when_the_authors_pds_cannot_be_read() { let world = Scenario::new("close-author-pds-down"); feature_branch(&world); let rkey = world .run_as(BOB, &["pr", "create", "--title", "bob's pull", "--json"]) .success() .json()["uri"] .as_str() .expect("uri") .rsplit('/') .next() .expect("rkey") .to_string(); let uri = format!("at://{BOB}/{PULL_NSID}/{rkey}"); world.with(|w| { w.pds_down.insert(BOB.to_string()); }); let run = world.run(&["pr", "close", &uri]); assert_eq!( run.code, Some(6), "an unreadable PDS was not reported as unreachable\n{}", run.stderr ); assert!( run.stderr.contains("could not reach the PDS"), "the failure did not name the service\n--- stderr ---\n{}", run.stderr ); assert!( world.with(|w| w.collection(ALICE, PULL_STATUS_NSID).is_empty()), "a close was written over records it could not read" ); } /// **`pr merge` read the target branch with `unwrap_or("main")`**, which is /// the guess `stack merge` stopped making and this did not — while both /// build the same `MergePlan` and hand it to the same `run_merge`. /// /// Two ways it landed a patch on a branch nobody named: a record whose /// `target.branch` is missing or malformed, and a pre-rounds record carrying /// only the legacy `targetBranch`, which `pr view` has always read and this /// never looked at. The merge path is the one place a wrong answer cannot be /// taken back. #[test] fn a_merge_reads_the_target_branch_it_was_given_and_refuses_to_guess() { // A pre-rounds record naming its branch the old way. let world = Scenario::new("merge-legacy-target"); feature_branch(&world); let rkey = open_pull(&world); world.with(|w| { let mut v = w .get(ALICE, PULL_NSID, &rkey) .expect("the pull") .value .clone(); v["target"] = serde_json::json!({ "repo": REPO_DID }); v["targetBranch"] = serde_json::json!("release/2"); w.plant(ALICE, PULL_NSID, &rkey, v); }); world.run(&["pr", "merge", &rkey]).success(); world.with(|w| { let merges = w.calls_to("sh.tangled.repo.merge"); assert_eq!(merges.len(), 1, "the knot was not asked to merge"); assert_eq!( merges[0].body["branch"].as_str(), Some("release/2"), "the merge landed somewhere the record did not name: {}", merges[0].body ); }); // And a record that names no branch at all is refused, not defaulted. let world = Scenario::new("merge-no-target"); feature_branch(&world); let rkey = open_pull(&world); world.with(|w| { let mut v = w .get(ALICE, PULL_NSID, &rkey) .expect("the pull") .value .clone(); v["target"] = serde_json::json!({ "repo": REPO_DID }); w.plant(ALICE, PULL_NSID, &rkey, v); }); world .run(&["pr", "merge", &rkey]) .refused("refusing to guess where this stack lands"); world.with(|w| { assert!( w.calls_to("sh.tangled.repo.merge").is_empty(), "a merge with no named branch reached the knot anyway" ); }); }