35a8d4fa
Address review: sound walk bound, reopen clause, JSON hint
a73x 2026-08-10 07:30
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -62,9 +62,14 @@ $ git-collab patch merge a1b2c3d4 | |||
| 62 | ``` | 62 | ``` |
| 63 | 63 | ||
| 64 | Either way, a patch created with `--fixes` closes the issue it fixes at the same | 64 | Either way, a patch created with `--fixes` closes the issue it fixes at the same |
| 65 | moment. A patch whose commits are simply reachable from its base tip is shown as | 65 | moment. `git-collab issue reopen` then stays reopened: the fix landing and |
| 66 | turning out to be wrong is ordinary, and nothing re-closes an issue over a | ||
| 67 | decision you made after it. | ||
| 68 | |||
| 69 | A patch whose commits are simply reachable from its base tip is shown as | ||
| 66 | `merged?` — a hint, and one that can only ever be a hint, so it decides nothing | 70 | `merged?` — a hint, and one that can only ever be a hint, so it decides nothing |
| 67 | and writes nothing. | 71 | and writes nothing. `--json` reports it as a separate `looks_merged` field, |
| 72 | never inside `status`. | ||
| 68 | 73 | ||
| 69 | ## Install | 74 | ## Install |
| 70 | 75 | ||
src/merge_scan.rs
| Old | New | ||
|---|---|---|---|
| @@ -96,16 +96,69 @@ pub fn record_merge( | |||
| 96 | /// `PatchMerge` could never retry, because on the retry pass the merge is a | 96 | /// `PatchMerge` could never retry, because on the retry pass the merge is a |
| 97 | /// no-op. Being idempotent is what makes it safe to run every time: an already | 97 | /// no-op. Being idempotent is what makes it safe to run every time: an already |
| 98 | /// closed issue is left alone, so repeated syncs append nothing. | 98 | /// closed issue is left alone, so repeated syncs append nothing. |
| 99 | /// | ||
| 100 | /// The invariant needs the reopen clause in [`reopened_after_this_patch_closed`] | ||
| 101 | /// or it is too strong — see there. | ||
| 99 | pub fn close_fixed_issue(repo: &Repository, patch: &PatchState) -> CloseOutcome { | 102 | pub fn close_fixed_issue(repo: &Repository, patch: &PatchState) -> CloseOutcome { |
| 100 | close_fixed_issue_inner(repo, patch, true) | 103 | close_fixed_issue_inner(repo, patch, true) |
| 101 | } | 104 | } |
| 102 | 105 | ||
| 103 | /// `warn_unresolvable` is false on the retry pass. An issue that does not | 106 | /// The `IssueClose` reason this feature writes, and the marker it reads back to |
| 104 | /// resolve — deleted, or an ambiguous prefix — will not resolve on the next | 107 | /// recognize its own close. One function so the writer and the reader cannot |
| 105 | /// sync either, and the attempt made alongside the merge already said so; going | 108 | /// drift: if this string changes, a close written by an older version stops |
| 106 | /// on to warn about it on every sync forever is noise nobody can act on. A | 109 | /// being attributable, and the reopen guard below silently stops guarding. |
| 107 | /// failure to *append* still warns either way, because that one is worth | 110 | pub fn merge_close_reason(patch_id: &str) -> String { |
| 108 | /// retrying and worth hearing about. | 111 | format!("merged in patch {:.8}", patch_id) |
| 112 | } | ||
| 113 | |||
| 114 | /// Whether this patch's close of `issue_ref` already happened and the issue was | ||
| 115 | /// subsequently reopened. | ||
| 116 | /// | ||
| 117 | /// Called only when the issue is currently open, which is what makes the test | ||
| 118 | /// this cheap: if this patch's `IssueClose` is in the DAG *and* the issue reads | ||
| 119 | /// open, then something reopened it after that close landed. No ordering | ||
| 120 | /// analysis is needed — the status fold already resolved the ordering, and the | ||
| 121 | /// answer it reached is the one to respect. | ||
| 122 | /// | ||
| 123 | /// This is the difference between the two states the retry cannot otherwise | ||
| 124 | /// tell apart: | ||
| 125 | /// | ||
| 126 | /// - A genuine half-failure — the merge landed, the close did not — has **no** | ||
| 127 | /// `IssueClose` attributable to this patch. The close never happened, and | ||
| 128 | /// restoring it is exactly the retry's job. | ||
| 129 | /// - A deliberate `issue reopen` has one, followed by a reopen. Reopening a | ||
| 130 | /// merged patch's issue is ordinary: the fix landed and turned out to be | ||
| 131 | /// wrong. Without this clause the retry re-closes it on every sync forever, | ||
| 132 | /// silently, which makes `issue reopen` unusable for any issue a merged patch | ||
| 133 | /// names — a worse bug than the one the retry fixes. | ||
| 134 | /// | ||
| 135 | /// The retry restores a close that never happened. It must never override a | ||
| 136 | /// decision made after one did. | ||
| 137 | fn reopened_after_this_patch_closed(repo: &Repository, issue_ref: &str, patch_id: &str) -> bool { | ||
| 138 | let marker = merge_close_reason(patch_id); | ||
| 139 | let Ok(events) = dag::walk_events(repo, issue_ref) else { | ||
| 140 | // Unreadable DAG. Treat as "guard applies": declining to close is | ||
| 141 | // recoverable by hand, re-closing a reopened issue every sync is not. | ||
| 142 | return true; | ||
| 143 | }; | ||
| 144 | events.iter().any(|(_oid, event)| { | ||
| 145 | matches!(&event.action, Action::IssueClose { reason: Some(r) } if *r == marker) | ||
| 146 | }) | ||
| 147 | } | ||
| 148 | |||
| 149 | /// The rule for `warn_unresolvable`: **an unresolvable `fixes` issue is | ||
| 150 | /// reported once, by the operation that recorded the merge, or by any command | ||
| 151 | /// the user invoked by hand.** It is never reported by a pass that merely | ||
| 152 | /// re-observed an already-recorded merge. | ||
| 153 | /// | ||
| 154 | /// An issue that does not resolve — deleted, or an ambiguous prefix — will not | ||
| 155 | /// resolve on the next sync either. Both the retry pass and the trailer scan | ||
| 156 | /// revisit merged patches on every sync (a trailer stays in history forever), so | ||
| 157 | /// warning from either would repeat until someone edits git history, about | ||
| 158 | /// something nobody can act on. | ||
| 159 | /// | ||
| 160 | /// A failure to *append* the close warns unconditionally, because that one is | ||
| 161 | /// transient, is worth retrying, and is worth hearing about every time. | ||
| 109 | fn close_fixed_issue_inner( | 162 | fn close_fixed_issue_inner( |
| 110 | repo: &Repository, | 163 | repo: &Repository, |
| 111 | patch: &PatchState, | 164 | patch: &PatchState, |
| @@ -143,7 +196,10 @@ fn close_fixed_issue_inner( | |||
| 143 | if issue.status != IssueStatus::Open { | 196 | if issue.status != IssueStatus::Open { |
| 144 | return CloseOutcome::NothingToDo; | 197 | return CloseOutcome::NothingToDo; |
| 145 | } | 198 | } |
| 146 | let reason = format!("merged in patch {:.8}", patch.id); | 199 | if reopened_after_this_patch_closed(repo, &issue_ref, &patch.id) { |
| 200 | return CloseOutcome::NothingToDo; | ||
| 201 | } | ||
| 202 | let reason = merge_close_reason(&patch.id); | ||
| 147 | if let Err(e) = crate::issue::close(repo, &issue_id, Some(&reason)) { | 203 | if let Err(e) = crate::issue::close(repo, &issue_id, Some(&reason)) { |
| 148 | eprintln!( | 204 | eprintln!( |
| 149 | "warning: patch {:.8}: failed to close fixed issue {:.8}: {} — \ | 205 | "warning: patch {:.8}: failed to close fixed issue {:.8}: {} — \ |
| @@ -220,9 +276,13 @@ pub fn scan_and_record_merges( | |||
| 220 | /// merge could never retry. | 276 | /// merge could never retry. |
| 221 | /// | 277 | /// |
| 222 | /// So the rule is the invariant, not the emission: a merged patch's `fixes` | 278 | /// So the rule is the invariant, not the emission: a merged patch's `fixes` |
| 223 | /// issue is closed. Cheap, because it only looks at patches that are merged | 279 | /// issue is closed, *unless it has been closed for this patch once already and |
| 224 | /// *and* carry a `fixes`; idempotent, because an already-closed issue is left | 280 | /// since reopened* — see `reopened_after_this_patch_closed` for why the bare |
| 225 | /// alone, which is what keeps repeated syncs from appending anything. | 281 | /// invariant is too strong. Cheap, because it only looks at patches that are |
| 282 | /// merged *and* carry a `fixes`, and the DAG walk behind the reopen guard is | ||
| 283 | /// only reached while the issue is still open, which after one successful close | ||
| 284 | /// it never is. Idempotent, which is what keeps repeated syncs from appending | ||
| 285 | /// anything. | ||
| 226 | /// | 286 | /// |
| 227 | /// Patches merged during this very scan already had their close attempted | 287 | /// Patches merged during this very scan already had their close attempted |
| 228 | /// inline, in the same operation that emitted the merge. This pass is for the | 288 | /// inline, in the same operation that emitted the merge. This pass is for the |
| @@ -277,14 +337,40 @@ fn scan_one_base( | |||
| 277 | emitted | 337 | emitted |
| 278 | } | 338 | } |
| 279 | 339 | ||
| 280 | /// Walk `base_tip` back to the oldest base among `patches`, collecting | 340 | /// A commit that is an ancestor of every one of `bases`, or `None` when there |
| 281 | /// `Patch:` trailers. | 341 | /// is no such commit or the set is empty. |
| 342 | /// | ||
| 343 | /// Not `merge_base_many`, which is the obvious call and the wrong one. | ||
| 344 | /// libgit2's `git_merge_base_many` is *not* the octopus base: it computes | ||
| 345 | /// `merge_base(oids[0], merge(oids[1..]))`, so its answer depends on argument | ||
| 346 | /// order and is an ancestor of only some of its inputs. On a linear chain | ||
| 347 | /// A-B-C-D-E, `git merge-base A B D` is A but `git merge-base B A D` is B — | ||
| 348 | /// and B is not an ancestor of A. Using it as a walk bound hides commits | ||
| 349 | /// between A and B, which is where a merge of the A-based patch lives, so the | ||
| 350 | /// scan silently records nothing for exactly the case it exists to serve. | ||
| 351 | /// | ||
| 352 | /// Folding binary `merge_base` is sound by induction: each step returns a | ||
| 353 | /// common ancestor of the accumulator and the next base, so the result is an | ||
| 354 | /// ancestor of everything folded in so far. git2 0.19 exposes no octopus | ||
| 355 | /// equivalent, which would be the other way. | ||
| 356 | /// | ||
| 357 | /// `None` on the first pair with no common ancestor (disjoint histories) — the | ||
| 358 | /// safe direction, since an absent bound only costs a longer walk. | ||
| 359 | pub fn common_ancestor(repo: &Repository, bases: &[Oid]) -> Option<Oid> { | ||
| 360 | let mut acc = *bases.first()?; | ||
| 361 | for base in &bases[1..] { | ||
| 362 | acc = repo.merge_base(acc, *base).ok()?; | ||
| 363 | } | ||
| 364 | Some(acc) | ||
| 365 | } | ||
| 366 | |||
| 367 | /// Walk `base_tip` back to a commit older than every open patch's base, | ||
| 368 | /// collecting `Patch:` trailers. | ||
| 282 | /// | 369 | /// |
| 283 | /// The bound matters: everything older than the oldest open patch's base | 370 | /// The bound matters: nothing older than the oldest open patch's base can have |
| 284 | /// cannot have merged a currently-open patch, so walking it is pure cost. The | 371 | /// merged a currently-open patch, so walking it is pure cost. It has to be an |
| 285 | /// merge-base of all the recorded bases is an ancestor of every one of them, so | 372 | /// ancestor of *every* recorded base, or the walk skips the region where some |
| 286 | /// hiding it can never hide a commit we needed — and when the bases are | 373 | /// patch's merge actually sits — see `common_ancestor`. |
| 287 | /// linearly ordered, which is the normal case, it *is* the oldest of them. | ||
| 288 | /// | 374 | /// |
| 289 | /// The first trailer seen for a given prefix wins. The walk is newest-first, so | 375 | /// The first trailer seen for a given prefix wins. The walk is newest-first, so |
| 290 | /// that is the newest commit carrying it: the rebased tip, or the squash commit. | 376 | /// that is the newest commit carrying it: the rebased tip, or the squash commit. |
| @@ -301,11 +387,9 @@ fn collect_trailers( | |||
| 301 | .iter() | 387 | .iter() |
| 302 | .filter_map(|p| p.effective_base(repo)) | 388 | .filter_map(|p| p.effective_base(repo)) |
| 303 | .collect(); | 389 | .collect(); |
| 304 | if !bases.is_empty() { | 390 | if let Some(bound) = common_ancestor(repo, &bases) { |
| 305 | if let Ok(bound) = repo.merge_base_many(&bases) { | 391 | // A failure to hide is not fatal — it only means a longer walk. |
| 306 | // A failure to hide is not fatal — it only means a longer walk. | 392 | let _ = revwalk.hide(bound); |
| 307 | let _ = revwalk.hide(bound); | ||
| 308 | } | ||
| 309 | } | 393 | } |
| 310 | 394 | ||
| 311 | let mut found = Vec::new(); | 395 | let mut found = Vec::new(); |
| @@ -400,7 +484,15 @@ fn record_from_trailer( | |||
| 400 | // Whether or not the merge was freshly emitted, the `fixes` issue must end | 484 | // Whether or not the merge was freshly emitted, the `fixes` issue must end |
| 401 | // up closed — see `close_fixed_issue` for why the retry has to be able to | 485 | // up closed — see `close_fixed_issue` for why the retry has to be able to |
| 402 | // run on a pass where the merge itself was a no-op. | 486 | // run on a pass where the merge itself was a no-op. |
| 403 | close_fixed_issue(repo, &patch); | 487 | // |
| 488 | // Warn about an unresolvable issue only on the pass that actually recorded | ||
| 489 | // the merge. A trailer stays in history forever, so while any other open | ||
| 490 | // patch keeps this base branch walked, this line is reached on every sync | ||
| 491 | // for an already-merged patch — and warning there would repeat, every sync, | ||
| 492 | // about something nobody can act on. That is the same noise | ||
| 493 | // `retry_pending_closes` is quiet about, arriving by a different route. | ||
| 494 | let warn = outcome == MergeOutcome::Recorded; | ||
| 495 | close_fixed_issue_inner(repo, &patch, warn); | ||
| 404 | 496 | ||
| 405 | Ok(outcome) | 497 | Ok(outcome) |
| 406 | } | 498 | } |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -178,6 +178,35 @@ pub fn list_to_writer( | |||
| 178 | Ok(()) | 178 | Ok(()) |
| 179 | } | 179 | } |
| 180 | 180 | ||
| 181 | /// Serialize a patch for `--json`, adding `"looks_merged": true` when | ||
| 182 | /// reachability suggests a merge nobody has recorded. | ||
| 183 | /// | ||
| 184 | /// The hint lives beside `status`, never inside it: `status` is the recorded | ||
| 185 | /// fact and stays `"open"`. But leaving the hint out of JSON altogether is not | ||
| 186 | /// neutral either — the text and JSON paths render the same entries, so a | ||
| 187 | /// consumer that only reads JSON cannot tell "no hint" from "hints are not | ||
| 188 | /// reported here", and JSON-reading agents are the one consumer with no text | ||
| 189 | /// output to fall back on. | ||
| 190 | /// | ||
| 191 | /// It is added here rather than on `PatchState` because computing it needs the | ||
| 192 | /// repository, and because `PatchState` is what the on-disk fold cache stores: | ||
| 193 | /// a derived field in there would be written to disk as though it were folded | ||
| 194 | /// state, and served stale from a cache entry keyed on a DAG tip that says | ||
| 195 | /// nothing about where the base branch has since moved. | ||
| 196 | /// | ||
| 197 | /// Skipped entirely when false, so it never reads as a positive assertion that | ||
| 198 | /// a patch is unmerged — the hint cannot see a squash, so its absence means | ||
| 199 | /// nothing. | ||
| 200 | fn patch_json_value(repo: &Repository, patch: &PatchState) -> Result<serde_json::Value, Error> { | ||
| 201 | let mut value = serde_json::to_value(patch)?; | ||
| 202 | if patch.looks_merged(repo) { | ||
| 203 | if let Some(obj) = value.as_object_mut() { | ||
| 204 | obj.insert("looks_merged".to_string(), serde_json::Value::Bool(true)); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | Ok(value) | ||
| 208 | } | ||
| 209 | |||
| 181 | pub fn list_json( | 210 | pub fn list_json( |
| 182 | repo: &Repository, | 211 | repo: &Repository, |
| 183 | show_closed: bool, | 212 | show_closed: bool, |
| @@ -186,14 +215,17 @@ pub fn list_json( | |||
| 186 | labels: &[String], | 215 | labels: &[String], |
| 187 | ) -> Result<String, crate::error::Error> { | 216 | ) -> Result<String, crate::error::Error> { |
| 188 | let entries = list(repo, show_closed, show_archived, None, None, sort, labels)?; | 217 | let entries = list(repo, show_closed, show_archived, None, None, sort, labels)?; |
| 189 | let patches: Vec<&PatchState> = entries.iter().map(|e| &e.patch).collect(); | 218 | let patches: Vec<serde_json::Value> = entries |
| 219 | .iter() | ||
| 220 | .map(|e| patch_json_value(repo, &e.patch)) | ||
| 221 | .collect::<Result<_, _>>()?; | ||
| 190 | Ok(serde_json::to_string_pretty(&patches)?) | 222 | Ok(serde_json::to_string_pretty(&patches)?) |
| 191 | } | 223 | } |
| 192 | 224 | ||
| 193 | pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { | 225 | pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { |
| 194 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | 226 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; |
| 195 | let p = PatchState::from_ref(repo, &ref_name, &id)?; | 227 | let p = PatchState::from_ref(repo, &ref_name, &id)?; |
| 196 | Ok(serde_json::to_string_pretty(&p)?) | 228 | Ok(serde_json::to_string_pretty(&patch_json_value(repo, &p)?)?) |
| 197 | } | 229 | } |
| 198 | 230 | ||
| 199 | pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { | 231 | pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { |
tests/collab_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -1056,6 +1056,10 @@ fn test_reachability_is_a_hint_and_does_not_set_the_status() { | |||
| 1056 | repo.reference("refs/heads/main", feat_tip, true, "manual merge") | 1056 | repo.reference("refs/heads/main", feat_tip, true, "manual merge") |
| 1057 | .unwrap(); | 1057 | .unwrap(); |
| 1058 | 1058 | ||
| 1059 | // Captured before anything derives, so the assertion below spans the | ||
| 1060 | // derivation it is meant to guard. | ||
| 1061 | let tip_before = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1062 | |||
| 1059 | // The patch now looks merged, and is still Open until someone records it. | 1063 | // The patch now looks merged, and is still Open until someone records it. |
| 1060 | let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); | 1064 | let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); |
| 1061 | assert!(state.looks_merged(&repo), "the hint should fire"); | 1065 | assert!(state.looks_merged(&repo), "the hint should fire"); |
| @@ -1066,13 +1070,13 @@ fn test_reachability_is_a_hint_and_does_not_set_the_status() { | |||
| 1066 | ); | 1070 | ); |
| 1067 | assert_eq!(state.status_display(&repo), "merged?"); | 1071 | assert_eq!(state.status_display(&repo), "merged?"); |
| 1068 | 1072 | ||
| 1069 | // Recording it is what makes it Merged — and reading it never did. | ||
| 1070 | let tip_before = repo.refname_to_id(&ref_name).unwrap(); | ||
| 1071 | assert_eq!( | 1073 | assert_eq!( |
| 1072 | repo.refname_to_id(&ref_name).unwrap(), | 1074 | repo.refname_to_id(&ref_name).unwrap(), |
| 1073 | tip_before, | 1075 | tip_before, |
| 1074 | "deriving state must not append to the DAG" | 1076 | "deriving state must not append to the DAG" |
| 1075 | ); | 1077 | ); |
| 1078 | |||
| 1079 | // Recording it is what makes it Merged. | ||
| 1076 | patch::merge(&repo, &id, None, true).unwrap(); | 1080 | patch::merge(&repo, &id, None, true).unwrap(); |
| 1077 | let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); | 1081 | let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); |
| 1078 | assert_eq!(state.status, PatchStatus::Merged); | 1082 | assert_eq!(state.status, PatchStatus::Merged); |
tests/merge_recording_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -94,6 +94,60 @@ fn patch_events_ref(repo: &TestRepo, short: &str) -> String { | |||
| 94 | .to_string() | 94 | .to_string() |
| 95 | } | 95 | } |
| 96 | 96 | ||
| 97 | /// The issue's ref wherever it currently lives — `close` archives it and | ||
| 98 | /// `reopen` moves it back, so neither namespace alone will do. | ||
| 99 | fn issue_ref_of(repo: &TestRepo, short: &str) -> String { | ||
| 100 | let out = repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]); | ||
| 101 | out.lines() | ||
| 102 | .find(|r| r.contains(short) && r.contains("issues/")) | ||
| 103 | .unwrap_or_else(|| panic!("no issue ref for {} in {}", short, out)) | ||
| 104 | .to_string() | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Like `patch_with_trailer`, but the patch declares the issue it fixes. | ||
| 108 | fn patch_fixing_issue(repo: &TestRepo, branch: &str, file: &str, issue: &str) -> String { | ||
| 109 | repo.git(&["checkout", "-b", branch]); | ||
| 110 | repo.commit_file(file, "content", &format!("work on {}", branch)); | ||
| 111 | let out = repo.run_ok(&[ | ||
| 112 | "patch", "create", "-t", branch, "-B", branch, "--fixes", issue, | ||
| 113 | ]); | ||
| 114 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 115 | stamp_head(repo, &short); | ||
| 116 | repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]); | ||
| 117 | repo.git(&["checkout", "main"]); | ||
| 118 | short | ||
| 119 | } | ||
| 120 | |||
| 121 | /// Run `git` in another working tree, under `env_from`'s isolated HOME so the | ||
| 122 | /// signing key and git config are the test's, not the developer's. | ||
| 123 | fn git_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String { | ||
| 124 | let mut command = Command::new("git"); | ||
| 125 | env_from.apply_env(&mut command); | ||
| 126 | let output = command.args(args).current_dir(dir).output().unwrap(); | ||
| 127 | assert!( | ||
| 128 | output.status.success(), | ||
| 129 | "git {:?} failed: {}", | ||
| 130 | args, | ||
| 131 | String::from_utf8_lossy(&output.stderr) | ||
| 132 | ); | ||
| 133 | String::from_utf8(output.stdout).unwrap() | ||
| 134 | } | ||
| 135 | |||
| 136 | /// Run `git-collab` in another working tree, same isolation as `git_in`. | ||
| 137 | fn collab_in(env_from: &TestRepo, dir: &std::path::Path, args: &[&str]) -> String { | ||
| 138 | let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab")); | ||
| 139 | env_from.apply_env(&mut command); | ||
| 140 | let output = command.args(args).current_dir(dir).output().unwrap(); | ||
| 141 | assert!( | ||
| 142 | output.status.success(), | ||
| 143 | "git-collab {:?} failed:\nstdout: {}\nstderr: {}", | ||
| 144 | args, | ||
| 145 | String::from_utf8_lossy(&output.stdout), | ||
| 146 | String::from_utf8_lossy(&output.stderr) | ||
| 147 | ); | ||
| 148 | String::from_utf8(output.stdout).unwrap() | ||
| 149 | } | ||
| 150 | |||
| 97 | // --------------------------------------------------------------------------- | 151 | // --------------------------------------------------------------------------- |
| 98 | // The event | 152 | // The event |
| 99 | // --------------------------------------------------------------------------- | 153 | // --------------------------------------------------------------------------- |
| @@ -635,3 +689,263 @@ fn a_recorded_merge_is_not_reported_as_a_hint() { | |||
| 635 | list | 689 | list |
| 636 | ); | 690 | ); |
| 637 | } | 691 | } |
| 692 | |||
| 693 | // --------------------------------------------------------------------------- | ||
| 694 | // The walk bound must be an ancestor of *every* recorded base | ||
| 695 | // --------------------------------------------------------------------------- | ||
| 696 | |||
| 697 | #[test] | ||
| 698 | fn common_ancestor_is_an_ancestor_of_every_base_whatever_the_order() { | ||
| 699 | // `merge_base_many` is the obvious call and the wrong one: it computes | ||
| 700 | // `merge_base(oids[0], merge(oids[1..]))`, so on a linear chain A-B-C-D-E | ||
| 701 | // it answers A for `[A, B, D]` but B for `[B, A, D]` — and B is not an | ||
| 702 | // ancestor of A. Feeding it the orderings it gets wrong is the only way to | ||
| 703 | // tell the two implementations apart. | ||
| 704 | // | ||
| 705 | // This needs *three* bases. For two, `merge_base_many` degenerates to the | ||
| 706 | // symmetric binary `merge_base` and is correct, so no two-base test — at | ||
| 707 | // this level or end to end — can distinguish them. | ||
| 708 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 709 | let mut chain = Vec::new(); | ||
| 710 | for name in ["A", "B", "C", "D", "E"] { | ||
| 711 | chain.push(repo.commit_file("f.txt", name, name)); | ||
| 712 | } | ||
| 713 | let git_repo = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 714 | let oid = |s: &String| git2::Oid::from_str(s).unwrap(); | ||
| 715 | let (a, b, d) = (oid(&chain[0]), oid(&chain[1]), oid(&chain[3])); | ||
| 716 | |||
| 717 | for order in [[a, b, d], [b, a, d], [d, a, b], [d, b, a]] { | ||
| 718 | let bound = git_collab::merge_scan::common_ancestor(&git_repo, &order) | ||
| 719 | .expect("a linear chain always has a common ancestor"); | ||
| 720 | for base in order { | ||
| 721 | let is_ancestor = | ||
| 722 | bound == base || git_repo.graph_descendant_of(base, bound).unwrap_or(false); | ||
| 723 | assert!( | ||
| 724 | is_ancestor, | ||
| 725 | "bound {:.8} must be an ancestor of every base, but is not of {:.8}", | ||
| 726 | bound, base | ||
| 727 | ); | ||
| 728 | } | ||
| 729 | assert_eq!(bound, a, "the bound should be the oldest base"); | ||
| 730 | } | ||
| 731 | } | ||
| 732 | |||
| 733 | #[test] | ||
| 734 | fn common_ancestor_of_nothing_is_nothing() { | ||
| 735 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 736 | let git_repo = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 737 | assert!(git_collab::merge_scan::common_ancestor(&git_repo, &[]).is_none()); | ||
| 738 | } | ||
| 739 | |||
| 740 | #[test] | ||
| 741 | fn sync_records_a_merge_older_than_another_open_patchs_base() { | ||
| 742 | // The multi-base bound, end to end. Three open patches share `main` with | ||
| 743 | // bases at three points of its history, and the oldest one's merge sits | ||
| 744 | // below the other two's bases. A bound that is not an ancestor of *every* | ||
| 745 | // base hides that merge commit and the scan records nothing — silently, and | ||
| 746 | // for a squash, so `looks_merged` cannot flag it either. | ||
| 747 | let (repo, _bare) = repo_with_origin(); | ||
| 748 | |||
| 749 | // P1, based at main's first commit, squash-merged onto main. | ||
| 750 | let p1 = patch_with_trailer(&repo, "feat1", "a.txt"); | ||
| 751 | squash_merge_keeping_message(&repo, "feat1"); | ||
| 752 | |||
| 753 | // main moves on, and P2 is based there. | ||
| 754 | repo.commit_file("upstream1.txt", "u1", "upstream work"); | ||
| 755 | let p2 = patch_with_trailer(&repo, "feat2", "b.txt"); | ||
| 756 | |||
| 757 | // main moves again, and P3 is based there. Three distinct bases. | ||
| 758 | repo.commit_file("upstream2.txt", "u2", "more upstream work"); | ||
| 759 | let p3 = patch_with_trailer(&repo, "feat3", "c.txt"); | ||
| 760 | |||
| 761 | repo.run_ok(&["sync"]); | ||
| 762 | |||
| 763 | assert_eq!( | ||
| 764 | show_json(&repo, &p1)["status"], | ||
| 765 | "merged", | ||
| 766 | "the merge sits below the other patches' bases; the bound must not hide it" | ||
| 767 | ); | ||
| 768 | assert_eq!(show_json(&repo, &p2)["status"], "open"); | ||
| 769 | assert_eq!(show_json(&repo, &p3)["status"], "open"); | ||
| 770 | } | ||
| 771 | |||
| 772 | // --------------------------------------------------------------------------- | ||
| 773 | // The retry must not override a deliberate reopen | ||
| 774 | // --------------------------------------------------------------------------- | ||
| 775 | |||
| 776 | #[test] | ||
| 777 | fn an_issue_reopened_after_a_merge_closed_it_stays_open() { | ||
| 778 | // Reopening a merged patch's issue is ordinary — the fix landed and turned | ||
| 779 | // out to be wrong. The retry exists to restore a close that never happened; | ||
| 780 | // it must never override a decision made after one did. Without the reopen | ||
| 781 | // clause this re-closes on every sync, forever, and silently. | ||
| 782 | let (repo, _bare) = repo_with_origin(); | ||
| 783 | let issue = repo.issue_open("Broken thing"); | ||
| 784 | let _short = patch_fixing_issue(&repo, "feat", "a.txt", &issue); | ||
| 785 | squash_merge_keeping_message(&repo, "feat"); | ||
| 786 | |||
| 787 | repo.run_ok(&["sync"]); | ||
| 788 | assert_eq!(issue_json(&repo, &issue)["status"], "closed"); | ||
| 789 | |||
| 790 | repo.run_ok(&["issue", "reopen", &issue]); | ||
| 791 | assert_eq!(issue_json(&repo, &issue)["status"], "open"); | ||
| 792 | let after_reopen = count_events(&repo, &issue_ref_of(&repo, &issue)); | ||
| 793 | |||
| 794 | repo.run_ok(&["sync"]); | ||
| 795 | repo.run_ok(&["sync"]); | ||
| 796 | |||
| 797 | assert_eq!( | ||
| 798 | issue_json(&repo, &issue)["status"], | ||
| 799 | "open", | ||
| 800 | "a deliberate reopen must survive the retry" | ||
| 801 | ); | ||
| 802 | assert_eq!( | ||
| 803 | count_events(&repo, &issue_ref_of(&repo, &issue)), | ||
| 804 | after_reopen, | ||
| 805 | "and nothing should have been appended trying" | ||
| 806 | ); | ||
| 807 | } | ||
| 808 | |||
| 809 | #[test] | ||
| 810 | fn patch_merge_does_not_reclose_an_issue_reopened_after_its_merge() { | ||
| 811 | // The same guard on the hand-recorded path. `patch merge` on an | ||
| 812 | // already-merged patch is a no-op, and it must not smuggle a re-close in. | ||
| 813 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 814 | let issue = repo.issue_open("Broken thing"); | ||
| 815 | repo.git(&["checkout", "-b", "feat"]); | ||
| 816 | repo.commit_file("a.txt", "x", "the fix"); | ||
| 817 | let out = repo.run_ok(&[ | ||
| 818 | "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue, | ||
| 819 | ]); | ||
| 820 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 821 | repo.git(&["checkout", "main"]); | ||
| 822 | repo.git(&["merge", "--ff-only", "feat"]); | ||
| 823 | |||
| 824 | repo.run_ok(&["patch", "merge", &short]); | ||
| 825 | assert_eq!(issue_json(&repo, &issue)["status"], "closed"); | ||
| 826 | |||
| 827 | repo.run_ok(&["issue", "reopen", &issue]); | ||
| 828 | repo.run_ok(&["patch", "merge", &short]); | ||
| 829 | |||
| 830 | assert_eq!(issue_json(&repo, &issue)["status"], "open"); | ||
| 831 | } | ||
| 832 | |||
| 833 | #[test] | ||
| 834 | fn a_reopened_issue_is_still_closed_by_a_different_merged_patch() { | ||
| 835 | // The guard is scoped to the patch whose close was overridden, not to the | ||
| 836 | // issue. Another patch naming the same issue has had no decision of its own | ||
| 837 | // overridden, so its close still fires. | ||
| 838 | let (repo, _bare) = repo_with_origin(); | ||
| 839 | let issue = repo.issue_open("Broken thing"); | ||
| 840 | |||
| 841 | let first = patch_fixing_issue(&repo, "feat1", "a.txt", &issue); | ||
| 842 | squash_merge_keeping_message(&repo, "feat1"); | ||
| 843 | repo.run_ok(&["sync"]); | ||
| 844 | assert_eq!(issue_json(&repo, &issue)["status"], "closed"); | ||
| 845 | |||
| 846 | repo.run_ok(&["issue", "reopen", &issue]); | ||
| 847 | let second = patch_fixing_issue(&repo, "feat2", "b.txt", &issue); | ||
| 848 | squash_merge_keeping_message(&repo, "feat2"); | ||
| 849 | |||
| 850 | repo.run_ok(&["sync"]); | ||
| 851 | |||
| 852 | assert_eq!(show_json(&repo, &first)["status"], "merged"); | ||
| 853 | assert_eq!(show_json(&repo, &second)["status"], "merged"); | ||
| 854 | assert_eq!( | ||
| 855 | issue_json(&repo, &issue)["status"], | ||
| 856 | "closed", | ||
| 857 | "the second patch's close has not been overridden by anyone" | ||
| 858 | ); | ||
| 859 | } | ||
| 860 | |||
| 861 | // --------------------------------------------------------------------------- | ||
| 862 | // Two clones recording the same merge converge | ||
| 863 | // --------------------------------------------------------------------------- | ||
| 864 | |||
| 865 | #[test] | ||
| 866 | fn two_clones_recording_a_merge_concurrently_converge() { | ||
| 867 | let (alice, bare) = repo_with_origin(); | ||
| 868 | alice.git(&["checkout", "-b", "feat"]); | ||
| 869 | alice.commit_file("a.txt", "x", "the patch"); | ||
| 870 | let out = alice.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]); | ||
| 871 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 872 | alice.git(&["checkout", "main"]); | ||
| 873 | alice.git(&["merge", "--ff-only", "feat"]); | ||
| 874 | alice.git(&["push", "origin", "main"]); | ||
| 875 | alice.run_ok(&["sync"]); | ||
| 876 | |||
| 877 | // Bob clones the same bare remote and picks up the patch. | ||
| 878 | let bob_root = TempDir::new().unwrap(); | ||
| 879 | let bob = bob_root.path().join("clone"); | ||
| 880 | git_in( | ||
| 881 | &alice, | ||
| 882 | bob_root.path(), | ||
| 883 | &["clone", bare.path().to_str().unwrap(), "clone"], | ||
| 884 | ); | ||
| 885 | git_in(&alice, &bob, &["config", "user.name", "Bob"]); | ||
| 886 | git_in(&alice, &bob, &["config", "user.email", "bob@example.com"]); | ||
| 887 | git_in(&alice, &bob, &["config", "collab.autoSync", "false"]); | ||
| 888 | collab_in(&alice, &bob, &["init"]); | ||
| 889 | collab_in(&alice, &bob, &["sync"]); | ||
| 890 | |||
| 891 | // Both record the merge without having seen the other's event. | ||
| 892 | alice.run_ok(&["patch", "merge", &short]); | ||
| 893 | collab_in(&alice, &bob, &["patch", "merge", &short]); | ||
| 894 | |||
| 895 | // Alice pushes first, so Bob's sync has to reconcile a genuinely divergent | ||
| 896 | // DAG rather than fast-forward. | ||
| 897 | alice.run_ok(&["sync"]); | ||
| 898 | collab_in(&alice, &bob, &["sync"]); | ||
| 899 | alice.run_ok(&["sync"]); | ||
| 900 | |||
| 901 | let bob_json: Value = | ||
| 902 | serde_json::from_str(&collab_in(&alice, &bob, &["patch", "show", &short, "--json"])) | ||
| 903 | .unwrap(); | ||
| 904 | assert_eq!(show_json(&alice, &short)["status"], "merged"); | ||
| 905 | assert_eq!(bob_json["status"], "merged"); | ||
| 906 | |||
| 907 | let events_ref = patch_events_ref(&alice, &short); | ||
| 908 | assert_eq!( | ||
| 909 | alice.git(&["rev-parse", &events_ref]).trim(), | ||
| 910 | git_in(&alice, &bob, &["rev-parse", &events_ref]).trim(), | ||
| 911 | "both clones must end at the same DAG tip" | ||
| 912 | ); | ||
| 913 | } | ||
| 914 | |||
| 915 | // --------------------------------------------------------------------------- | ||
| 916 | // The hint is reported in --json too: beside the status, never inside it | ||
| 917 | // --------------------------------------------------------------------------- | ||
| 918 | |||
| 919 | #[test] | ||
| 920 | fn json_reports_the_merge_hint_beside_the_status() { | ||
| 921 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 922 | repo.git(&["checkout", "-b", "feat"]); | ||
| 923 | repo.commit_file("a.txt", "x", "the patch"); | ||
| 924 | let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]); | ||
| 925 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 926 | repo.git(&["checkout", "main"]); | ||
| 927 | |||
| 928 | // Not merged: no hint at all, so its absence never reads as an assertion. | ||
| 929 | let json = show_json(&repo, &short); | ||
| 930 | assert_eq!(json["status"], "open"); | ||
| 931 | assert!(json.get("looks_merged").is_none(), "{}", json); | ||
| 932 | |||
| 933 | repo.git(&["merge", "--ff-only", "feat"]); | ||
| 934 | |||
| 935 | let json = show_json(&repo, &short); | ||
| 936 | assert_eq!(json["status"], "open", "the hint is never the status"); | ||
| 937 | assert_eq!(json["looks_merged"], true); | ||
| 938 | |||
| 939 | let list: Value = serde_json::from_str(&repo.run_ok(&["patch", "list", "--json"])).unwrap(); | ||
| 940 | assert_eq!(list[0]["status"], "open"); | ||
| 941 | assert_eq!( | ||
| 942 | list[0]["looks_merged"], true, | ||
| 943 | "list and show render the same entries; the hint has to be in both" | ||
| 944 | ); | ||
| 945 | |||
| 946 | // Once recorded it is a fact, and the hint goes away. | ||
| 947 | repo.run_ok(&["patch", "merge", &short]); | ||
| 948 | let json = show_json(&repo, &short); | ||
| 949 | assert_eq!(json["status"], "merged"); | ||
| 950 | assert!(json.get("looks_merged").is_none(), "{}", json); | ||
| 951 | } | ||