1b680f01
Surface the recorded merge commit, and scan without a remote
a73x 2026-08-10 16:32
Commit message
src/cache.rs
| Old | New | ||
|---|---|---|---|
| @@ -27,7 +27,12 @@ fn sanitize_ref_name(ref_name: &str) -> String { | |||
| 27 | /// reachability against a base branch — a verdict no event backs and which | 27 | /// reachability against a base branch — a verdict no event backs and which |
| 28 | /// nothing recomputes any more, so it would be served as recorded fact | 28 | /// nothing recomputes any more, so it would be served as recorded fact |
| 29 | /// forever. The shape is unchanged, so only the version can reject it. | 29 | /// forever. The shape is unchanged, so only the version can reject it. |
| 30 | const CACHE_FORMAT_VERSION: u32 = 5; | 30 | /// v6: `PatchState` gained `merge_commit`. Same trap as v4's `labels` — a v5 |
| 31 | /// entry is missing the key and `#[serde(default)]` deserializes it to `None` | ||
| 32 | /// indistinguishably from a patch that really has no recorded merge, so a | ||
| 33 | /// merged patch cached before this field existed would read as merged with no | ||
| 34 | /// commit forever. Only the version can force the refold. | ||
| 35 | const CACHE_FORMAT_VERSION: u32 = 6; | ||
| 31 | 36 | ||
| 32 | /// Cache entry stored on disk: the tip OID at cache time + serialized state. | 37 | /// Cache entry stored on disk: the tip OID at cache time + serialized state. |
| 33 | #[derive(serde::Serialize, serde::Deserialize)] | 38 | #[derive(serde::Serialize, serde::Deserialize)] |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -429,6 +429,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 429 | println!("Branch: {}", p.branch); | 429 | println!("Branch: {}", p.branch); |
| 430 | } | 430 | } |
| 431 | } | 431 | } |
| 432 | // The commit that landed the patch, printed next to the head it | ||
| 433 | // replaced. For a squash there is no path from `Head` to the | ||
| 434 | // base branch at all, so this line is the only thing that says | ||
| 435 | // where the code went. | ||
| 436 | if let Some(ref merged_in) = p.merge_commit { | ||
| 437 | println!("Merged in: {:.8}", merged_in); | ||
| 438 | } | ||
| 432 | println!("Created: {}", p.created_at); | 439 | println!("Created: {}", p.created_at); |
| 433 | if !p.labels.is_empty() { | 440 | if !p.labels.is_empty() { |
| 434 | println!("Labels: {}", p.labels.join(", ")); | 441 | println!("Labels: {}", p.labels.join(", ")); |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -594,7 +594,7 @@ pub fn patch_log_to_writer( | |||
| 594 | ) -> Result<(), Error> { | 594 | ) -> Result<(), Error> { |
| 595 | if patch.revisions.is_empty() { | 595 | if patch.revisions.is_empty() { |
| 596 | writeln!(writer, "No revisions recorded.")?; | 596 | writeln!(writer, "No revisions recorded.")?; |
| 597 | return Ok(()); | 597 | return write_merge_line(patch, writer); |
| 598 | } | 598 | } |
| 599 | 599 | ||
| 600 | for (i, rev) in patch.revisions.iter().enumerate() { | 600 | for (i, rev) in patch.revisions.iter().enumerate() { |
| @@ -649,6 +649,23 @@ pub fn patch_log_to_writer( | |||
| 649 | rev.number, short_oid, rev.timestamp, label, file_summary, body_display | 649 | rev.number, short_oid, rev.timestamp, label, file_summary, body_display |
| 650 | )?; | 650 | )?; |
| 651 | } | 651 | } |
| 652 | write_merge_line(patch, writer) | ||
| 653 | } | ||
| 654 | |||
| 655 | /// The last line of `patch log`: the commit that landed the patch, below the | ||
| 656 | /// revisions it landed. | ||
| 657 | /// | ||
| 658 | /// `patch log` reads as the patch's life story and used to stop one step short | ||
| 659 | /// of the end. The commit is the only thing that connects the last revision to | ||
| 660 | /// the base branch when the merge was a squash or a rebase, since neither | ||
| 661 | /// leaves the revision's own commits reachable from anything. | ||
| 662 | /// | ||
| 663 | /// Only ever the *recorded* commit — nothing here consults reachability, and | ||
| 664 | /// nothing here writes. | ||
| 665 | fn write_merge_line(patch: &PatchState, writer: &mut dyn std::io::Write) -> Result<(), Error> { | ||
| 666 | if let Some(ref merged_in) = patch.merge_commit { | ||
| 667 | writeln!(writer, "merged in {:.8}", merged_in)?; | ||
| 668 | } | ||
| 652 | Ok(()) | 669 | Ok(()) |
| 653 | } | 670 | } |
| 654 | 671 | ||
src/server/http/repo/patches.rs
| Old | New | ||
|---|---|---|---|
| @@ -105,6 +105,14 @@ pub struct PatchDetailView { | |||
| 105 | pub title: String, | 105 | pub title: String, |
| 106 | pub body: String, | 106 | pub body: String, |
| 107 | pub status: String, | 107 | pub status: String, |
| 108 | /// The commit that landed the patch, recorded by `Action::PatchMerge`. | ||
| 109 | /// | ||
| 110 | /// The link this page can offer is the reason the commit is in the event | ||
| 111 | /// at all: for a squash or a rebase-merge the revisions listed below are | ||
| 112 | /// ancestors of nothing on the base branch, so their `/diff/` links lead | ||
| 113 | /// away from where the code actually went. Recorded only — never inferred | ||
| 114 | /// from reachability, which points at whatever happens to be reachable. | ||
| 115 | pub merge_commit: Option<String>, | ||
| 108 | pub author: String, | 116 | pub author: String, |
| 109 | pub labels: String, | 117 | pub labels: String, |
| 110 | pub branch: String, | 118 | pub branch: String, |
| @@ -220,6 +228,7 @@ pub async fn patch_detail( | |||
| 220 | title: ps.title, | 228 | title: ps.title, |
| 221 | body: ps.body, | 229 | body: ps.body, |
| 222 | status: ps.status.as_str().to_string(), | 230 | status: ps.status.as_str().to_string(), |
| 231 | merge_commit: ps.merge_commit, | ||
| 223 | author: ps.author.name, | 232 | author: ps.author.name, |
| 224 | labels: ps.labels.join(", "), | 233 | labels: ps.labels.join(", "), |
| 225 | branch: ps.branch, | 234 | branch: ps.branch, |
src/server/http/templates/patch_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,9 @@ | |||
| 9 | by <strong>{{ patch.author }}</strong> | 9 | by <strong>{{ patch.author }}</strong> |
| 10 | <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> | 10 | <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> |
| 11 | </p> | 11 | </p> |
| 12 | {% if let Some(merge_commit) = patch.merge_commit %} | ||
| 13 | <p>Merged in <a class="mono" href="/{{ repo_name }}/diff/{{ merge_commit }}">{{ merge_commit }}</a></p> | ||
| 14 | {% endif %} | ||
| 12 | {% if !patch.labels.is_empty() %} | 15 | {% if !patch.labels.is_empty() %} |
| 13 | <p style="color: #666;">Labels: {{ patch.labels }}</p> | 16 | <p style="color: #666;">Labels: {{ patch.labels }}</p> |
| 14 | {% endif %} | 17 | {% endif %} |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -231,6 +231,34 @@ pub struct PatchState { | |||
| 231 | pub title: String, | 231 | pub title: String, |
| 232 | pub body: String, | 232 | pub body: String, |
| 233 | pub status: PatchStatus, | 233 | pub status: PatchStatus, |
| 234 | /// The commit on the base branch that landed the patch, as recorded by the | ||
| 235 | /// winning `Action::PatchMerge`. `None` for any patch whose current status | ||
| 236 | /// was not decided by a merge. | ||
| 237 | /// | ||
| 238 | /// This is the whole reason the commit is carried in the event. For a | ||
| 239 | /// squash or a rebase-merge there is no other route from the patch back to | ||
| 240 | /// the code — the patch's own commits are ancestors of nothing on the base | ||
| 241 | /// branch — so this is the only durable evidence of *how* the patch landed, | ||
| 242 | /// and it is what a UI links to. | ||
| 243 | /// | ||
| 244 | /// Always a *recorded* commit, never a derived one. Reachability guesses | ||
| 245 | /// wrong in ways that are worse than silence: a patch in this repo had its | ||
| 246 | /// head dragged onto a commit about unrelated work that merely happened to | ||
| 247 | /// be reachable, and was called merged for that reason. The hint that | ||
| 248 | /// reachability still feeds (`looks_merged`) deliberately carries no commit. | ||
| 249 | /// | ||
| 250 | /// Written by the fold under the same `(clock, oid)` guard as `status`, so | ||
| 251 | /// two clones recording different commits converge on one — see | ||
| 252 | /// `from_ref_uncached`. | ||
| 253 | /// | ||
| 254 | /// `#[serde(default)]` is what lets a `PatchState` serialized before this | ||
| 255 | /// field existed deserialize at all; it is *not* what keeps the on-disk | ||
| 256 | /// fold cache correct. A stale entry missing this key deserializes just as | ||
| 257 | /// cleanly whether or not it predates a `patch.merge` folded into that ref, | ||
| 258 | /// so `cache::CACHE_FORMAT_VERSION` is bumped alongside this field to force | ||
| 259 | /// a refold instead of quietly serving a merge-commit-less hit. | ||
| 260 | #[serde(default)] | ||
| 261 | pub merge_commit: Option<String>, | ||
| 234 | pub base_ref: String, | 262 | pub base_ref: String, |
| 235 | pub fixes: Option<String>, | 263 | pub fixes: Option<String>, |
| 236 | /// The branch the patch was created from, recorded for provenance only. | 264 | /// The branch the patch was created from, recorded for provenance only. |
| @@ -699,6 +727,7 @@ impl PatchState { | |||
| 699 | title, | 727 | title, |
| 700 | body, | 728 | body, |
| 701 | status: PatchStatus::Open, | 729 | status: PatchStatus::Open, |
| 730 | merge_commit: None, | ||
| 702 | base_ref, | 731 | base_ref, |
| 703 | fixes, | 732 | fixes, |
| 704 | branch, | 733 | branch, |
| @@ -843,15 +872,31 @@ impl PatchState { | |||
| 843 | let key = (event.clock, oid.to_string()); | 872 | let key = (event.clock, oid.to_string()); |
| 844 | if status_key.as_ref().is_none_or(|k| key >= *k) { | 873 | if status_key.as_ref().is_none_or(|k| key >= *k) { |
| 845 | s.status = PatchStatus::Closed; | 874 | s.status = PatchStatus::Closed; |
| 875 | // A close that wins over a merge un-merges the | ||
| 876 | // patch — the documented way to correct a | ||
| 877 | // `PatchMerge` recorded in error. Leaving the | ||
| 878 | // commit behind would leave `patch show` naming a | ||
| 879 | // merge the status denies. | ||
| 880 | s.merge_commit = None; | ||
| 846 | status_key = Some(key); | 881 | status_key = Some(key); |
| 847 | } | 882 | } |
| 848 | } | 883 | } |
| 849 | } | 884 | } |
| 850 | Action::PatchMerge { .. } => { | 885 | Action::PatchMerge { commit } => { |
| 851 | if let Some(ref mut s) = state { | 886 | if let Some(ref mut s) = state { |
| 852 | let key = (event.clock, oid.to_string()); | 887 | let key = (event.clock, oid.to_string()); |
| 853 | if status_key.as_ref().is_none_or(|k| key >= *k) { | 888 | if status_key.as_ref().is_none_or(|k| key >= *k) { |
| 854 | s.status = PatchStatus::Merged; | 889 | s.status = PatchStatus::Merged; |
| 890 | // Under the *same* guard as the status, so the two | ||
| 891 | // can never disagree and concurrent merges naming | ||
| 892 | // different commits converge: every clone folds the | ||
| 893 | // same event set and `(clock, oid)` is a total | ||
| 894 | // order over it, so every clone picks the same one. | ||
| 895 | // | ||
| 896 | // Empty for a `patch.merge` written before the | ||
| 897 | // event carried a commit; those stay `None` rather | ||
| 898 | // than surfacing an empty string as a commit id. | ||
| 899 | s.merge_commit = (!commit.is_empty()).then(|| commit.clone()); | ||
| 855 | status_key = Some(key); | 900 | status_key = Some(key); |
| 856 | } | 901 | } |
| 857 | } | 902 | } |
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -524,8 +524,7 @@ pub fn sync_all(repo: &Repository) -> Result<(), Error> { | |||
| 524 | let remotes = collab_remotes(repo)?; | 524 | let remotes = collab_remotes(repo)?; |
| 525 | 525 | ||
| 526 | if remotes.is_empty() { | 526 | if remotes.is_empty() { |
| 527 | println!("No remotes with collab refspecs configured. Run `git-collab init` first."); | 527 | return sync_local_only(repo); |
| 528 | return Ok(()); | ||
| 529 | } | 528 | } |
| 530 | 529 | ||
| 531 | let multiple = remotes.len() > 1; | 530 | let multiple = remotes.len() > 1; |
| @@ -578,6 +577,75 @@ pub fn sync_all(repo: &Repository) -> Result<(), Error> { | |||
| 578 | } | 577 | } |
| 579 | } | 578 | } |
| 580 | 579 | ||
| 580 | /// The half of `sync` that needs no remote: scan local history for `Issue:` | ||
| 581 | /// and `Patch:` trailers and append the events they name. | ||
| 582 | /// | ||
| 583 | /// Both scans walk local refs and append local events — nothing about either | ||
| 584 | /// reaches the network. They used to live inside the per-remote path, behind | ||
| 585 | /// the no-remotes gate, so a repo with no remote never auto-recorded a merge | ||
| 586 | /// and never linked a commit to an issue however many trailers its history | ||
| 587 | /// carried. That contradicts the premise that collaboration lives in the | ||
| 588 | /// repository, and it is the configuration everyone is in *before* adding a | ||
| 589 | /// remote, so it was also the first impression the features made. | ||
| 590 | /// | ||
| 591 | /// **Never breaks sync.** Both scans absorb their own per-commit and per-item | ||
| 592 | /// errors; a returned `Err` from either means only "skip that scan this time", | ||
| 593 | /// so neither can take down the other or the sync around them. | ||
| 594 | fn run_local_scans( | ||
| 595 | repo: &Repository, | ||
| 596 | author: &crate::event::Author, | ||
| 597 | sk: &ed25519_dalek::SigningKey, | ||
| 598 | ) { | ||
| 599 | // Scan local branches for Issue: trailers and emit link events. | ||
| 600 | match crate::commit_link::scan_and_link(repo, author, sk) { | ||
| 601 | Ok(n) if n > 0 => println!("Linked {} commit(s) to issues.", n), | ||
| 602 | Ok(_) => {} | ||
| 603 | Err(e) => eprintln!("warning: commit link scan failed: {}", e), | ||
| 604 | } | ||
| 605 | |||
| 606 | // Scan each open patch's own base branch for Patch: trailers and record | ||
| 607 | // the merges they name. | ||
| 608 | match crate::merge_scan::scan_and_record_merges(repo, author, sk) { | ||
| 609 | Ok(n) if n > 0 => println!("Recorded {} merged patch(es).", n), | ||
| 610 | Ok(_) => {} | ||
| 611 | Err(e) => eprintln!("warning: merge scan failed: {}", e), | ||
| 612 | } | ||
| 613 | } | ||
| 614 | |||
| 615 | /// `sync` in a repo where no remote carries collab refspecs. | ||
| 616 | /// | ||
| 617 | /// There is nothing to fetch and nowhere to push, but that is only half of what | ||
| 618 | /// `sync` does. The trailer scans are the other half and they apply unchanged, | ||
| 619 | /// so run them and report what they did rather than printing an instruction and | ||
| 620 | /// stopping. The instruction still goes out — nothing recorded here is being | ||
| 621 | /// shared with anyone — but as a closing note, not as a refusal. | ||
| 622 | fn sync_local_only(repo: &Repository) -> Result<(), Error> { | ||
| 623 | // The scans append events, so this path takes the same advisory lock the | ||
| 624 | // remote path does. Two syncs racing to record the same merge is exactly | ||
| 625 | // the case the lock exists for, and having no remote does not change it. | ||
| 626 | let _lock = SyncLock::acquire(repo)?; | ||
| 627 | |||
| 628 | println!("No remotes with collab refspecs configured — scanning local history only."); | ||
| 629 | |||
| 630 | let author = get_author(repo)?; | ||
| 631 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | ||
| 632 | |||
| 633 | // Same reason the remote path migrates: a bare `<id>` patch ref predating | ||
| 634 | // the `<id>/events` layout is invisible to everything that folds patches, | ||
| 635 | // so a merge scan over it would silently find no patches to record against. | ||
| 636 | state::migrate_patch_layout(repo); | ||
| 637 | |||
| 638 | run_local_scans(repo, &author, &sk); | ||
| 639 | |||
| 640 | println!("Local scan complete. Add a remote and run `git-collab init` to share these events."); | ||
| 641 | |||
| 642 | // The same closing hint the remote path prints, and on the same terms: a | ||
| 643 | // suggestion drawn from reachability, which cannot see a squash — so its | ||
| 644 | // silence proves nothing, and it writes nothing. | ||
| 645 | crate::merge_scan::print_merge_hints(repo); | ||
| 646 | Ok(()) | ||
| 647 | } | ||
| 648 | |||
| 581 | /// Sync with a specific remote: fetch, reconcile, push. | 649 | /// Sync with a specific remote: fetch, reconcile, push. |
| 582 | pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | 650 | pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { |
| 583 | // Acquire advisory lock — held until _lock is dropped (RAII) | 651 | // Acquire advisory lock — held until _lock is dropped (RAII) |
| @@ -637,24 +705,12 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | |||
| 637 | reconcile_refs(&repo, "issues", &author, &sk)?; | 705 | reconcile_refs(&repo, "issues", &author, &sk)?; |
| 638 | reconcile_refs(&repo, "patches", &author, &sk)?; | 706 | reconcile_refs(&repo, "patches", &author, &sk)?; |
| 639 | 707 | ||
| 640 | // Step 2.5: Scan local branches for Issue: trailers and emit link events. | 708 | // Step 2.5/2.6: the local trailer scans. Shared verbatim with the |
| 641 | // Never breaks sync — scan_and_link absorbs per-commit/per-issue errors. | 709 | // no-remote path — they are the half of sync that needs no remote, which |
| 642 | match crate::commit_link::scan_and_link(&repo, &author, &sk) { | 710 | // is why they live in their own function rather than here. They run before |
| 643 | Ok(n) if n > 0 => println!("Linked {} commit(s) to issues.", n), | 711 | // the push so anything they record travels in the same sync rather than |
| 644 | Ok(_) => {} | 712 | // waiting for the next one. |
| 645 | Err(e) => eprintln!("warning: commit link scan failed: {}", e), | 713 | run_local_scans(&repo, &author, &sk); |
| 646 | } | ||
| 647 | |||
| 648 | // Step 2.6: Scan each open patch's own base branch for Patch: trailers and | ||
| 649 | // record the merges they name. Same contract as the link scan above: it | ||
| 650 | // absorbs its own per-commit and per-patch errors and can never break sync. | ||
| 651 | // This runs before the push so anything it records travels in the same | ||
| 652 | // sync rather than waiting for the next one. | ||
| 653 | match crate::merge_scan::scan_and_record_merges(&repo, &author, &sk) { | ||
| 654 | Ok(n) if n > 0 => println!("Recorded {} merged patch(es).", n), | ||
| 655 | Ok(_) => {} | ||
| 656 | Err(e) => eprintln!("warning: merge scan failed: {}", e), | ||
| 657 | } | ||
| 658 | 714 | ||
| 659 | // Step 3: Push collab refs individually | 715 | // Step 3: Push collab refs individually |
| 660 | println!("Pushing to '{}'...", remote_name); | 716 | println!("Pushing to '{}'...", remote_name); |
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -77,6 +77,7 @@ mod tests { | |||
| 77 | title: title.into(), | 77 | title: title.into(), |
| 78 | body: String::new(), | 78 | body: String::new(), |
| 79 | status, | 79 | status, |
| 80 | merge_commit: None, | ||
| 80 | base_ref: "main".into(), | 81 | base_ref: "main".into(), |
| 81 | fixes: None, | 82 | fixes: None, |
| 82 | branch: format!("feature/{}", id), | 83 | branch: format!("feature/{}", id), |
| @@ -274,6 +275,7 @@ mod tests { | |||
| 274 | } else { | 275 | } else { |
| 275 | PatchStatus::Closed | 276 | PatchStatus::Closed |
| 276 | }, | 277 | }, |
| 278 | merge_commit: None, | ||
| 277 | base_ref: "main".to_string(), | 279 | base_ref: "main".to_string(), |
| 278 | fixes: None, | 280 | fixes: None, |
| 279 | branch: format!("feature/p{:07x}", i), | 281 | branch: format!("feature/p{:07x}", i), |
| @@ -1031,6 +1033,7 @@ mod tests { | |||
| 1031 | title: "Fix the thing".into(), | 1033 | title: "Fix the thing".into(), |
| 1032 | body: "Detailed description".into(), | 1034 | body: "Detailed description".into(), |
| 1033 | status: PatchStatus::Open, | 1035 | status: PatchStatus::Open, |
| 1036 | merge_commit: None, | ||
| 1034 | base_ref: "main".into(), | 1037 | base_ref: "main".into(), |
| 1035 | fixes: Some("i1".into()), | 1038 | fixes: Some("i1".into()), |
| 1036 | branch: "feature/fix-thing".into(), | 1039 | branch: "feature/fix-thing".into(), |
tests/merge_recording_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -949,3 +949,246 @@ fn json_reports_the_merge_hint_beside_the_status() { | |||
| 949 | assert_eq!(json["status"], "merged"); | 949 | assert_eq!(json["status"], "merged"); |
| 950 | assert!(json.get("looks_merged").is_none(), "{}", json); | 950 | assert!(json.get("looks_merged").is_none(), "{}", json); |
| 951 | } | 951 | } |
| 952 | |||
| 953 | // --------------------------------------------------------------------------- | ||
| 954 | // The recorded merge commit has to reach the views | ||
| 955 | // --------------------------------------------------------------------------- | ||
| 956 | |||
| 957 | #[test] | ||
| 958 | fn the_recorded_merge_commit_reaches_show_json_and_patch_log() { | ||
| 959 | // The commit is the reason it is in the event at all: it is what a UI links | ||
| 960 | // to, and for a squash it is the only route from the patch back to the code | ||
| 961 | // — the patch's own commits are ancestors of nothing on the base branch. | ||
| 962 | // Folding it to a bare `status = merged` threw that away. | ||
| 963 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 964 | repo.git(&["checkout", "-b", "feat"]); | ||
| 965 | repo.commit_file("a.txt", "x", "the patch"); | ||
| 966 | let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]); | ||
| 967 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 968 | repo.git(&["checkout", "main"]); | ||
| 969 | squash_merge_keeping_message(&repo, "feat"); | ||
| 970 | let landed = repo.git(&["rev-parse", "main"]).trim().to_string(); | ||
| 971 | |||
| 972 | // Before the merge is recorded there is no commit to report, and the field | ||
| 973 | // must not read as an assertion that one exists. | ||
| 974 | assert!( | ||
| 975 | show_json(&repo, &short)["merge_commit"].is_null(), | ||
| 976 | "an unmerged patch has no merge commit" | ||
| 977 | ); | ||
| 978 | |||
| 979 | repo.run_ok(&["patch", "merge", &short]); | ||
| 980 | |||
| 981 | assert_eq!( | ||
| 982 | show_json(&repo, &short)["merge_commit"], | ||
| 983 | landed, | ||
| 984 | "the recorded commit must survive the fold into `patch show --json`" | ||
| 985 | ); | ||
| 986 | |||
| 987 | let list: Value = | ||
| 988 | serde_json::from_str(&repo.run_ok(&["patch", "list", "--json", "--all"])).unwrap(); | ||
| 989 | assert_eq!( | ||
| 990 | list[0]["merge_commit"], landed, | ||
| 991 | "list and show render the same entries; the commit has to be in both" | ||
| 992 | ); | ||
| 993 | |||
| 994 | let show = repo.run_ok(&["patch", "show", &short]); | ||
| 995 | assert!( | ||
| 996 | show.contains(&landed[..8]), | ||
| 997 | "`patch show` must name the commit that landed the patch: {}", | ||
| 998 | show | ||
| 999 | ); | ||
| 1000 | |||
| 1001 | let log = repo.run_ok(&["patch", "log", &short]); | ||
| 1002 | assert!( | ||
| 1003 | log.contains(&landed[..8]), | ||
| 1004 | "`patch log` must name the commit that landed the patch: {}", | ||
| 1005 | log | ||
| 1006 | ); | ||
| 1007 | } | ||
| 1008 | |||
| 1009 | #[test] | ||
| 1010 | fn displaying_a_merged_patch_writes_no_event() { | ||
| 1011 | // The governing constraint, on the path that now renders the merge commit. | ||
| 1012 | // Surfacing a recorded fact must stay a pure read. | ||
| 1013 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 1014 | repo.git(&["checkout", "-b", "feat"]); | ||
| 1015 | repo.commit_file("a.txt", "x", "the patch"); | ||
| 1016 | let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]); | ||
| 1017 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 1018 | repo.git(&["checkout", "main"]); | ||
| 1019 | repo.git(&["merge", "--ff-only", "feat"]); | ||
| 1020 | repo.run_ok(&["patch", "merge", &short]); | ||
| 1021 | |||
| 1022 | let events_ref = patch_events_ref(&repo, &short); | ||
| 1023 | let before = count_events(&repo, &events_ref); | ||
| 1024 | let tip_before = repo.git(&["rev-parse", &events_ref]).trim().to_string(); | ||
| 1025 | |||
| 1026 | repo.run_ok(&["patch", "list", "--all"]); | ||
| 1027 | repo.run_ok(&["patch", "show", &short]); | ||
| 1028 | repo.run_ok(&["patch", "show", &short, "--json"]); | ||
| 1029 | repo.run_ok(&["patch", "list", "--json", "--all"]); | ||
| 1030 | repo.run_ok(&["patch", "log", &short]); | ||
| 1031 | |||
| 1032 | assert_eq!(count_events(&repo, &events_ref), before); | ||
| 1033 | assert_eq!( | ||
| 1034 | repo.git(&["rev-parse", &events_ref]).trim(), | ||
| 1035 | tip_before, | ||
| 1036 | "reading a merged patch must not move its ref" | ||
| 1037 | ); | ||
| 1038 | } | ||
| 1039 | |||
| 1040 | #[test] | ||
| 1041 | fn two_clones_recording_different_merge_commits_agree_on_one() { | ||
| 1042 | // Concurrent recordings name different commits — one person points at the | ||
| 1043 | // squash commit, another at a later one. `(clock, oid)` decides, exactly as | ||
| 1044 | // it does for the status, so both clones fold the same event set to the | ||
| 1045 | // same answer. A merged patch whose commit differed per clone would be | ||
| 1046 | // worse than none: it is what a UI links to. | ||
| 1047 | let (alice, bare) = repo_with_origin(); | ||
| 1048 | alice.git(&["checkout", "-b", "feat"]); | ||
| 1049 | alice.commit_file("a.txt", "x", "the patch"); | ||
| 1050 | let out = alice.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]); | ||
| 1051 | let short = out.trim().strip_prefix("Created patch ").unwrap().to_string(); | ||
| 1052 | alice.git(&["checkout", "main"]); | ||
| 1053 | alice.git(&["merge", "--ff-only", "feat"]); | ||
| 1054 | let first = alice.git(&["rev-parse", "main"]).trim().to_string(); | ||
| 1055 | // A second commit on main, so the two clones have two real commits to | ||
| 1056 | // disagree about. | ||
| 1057 | let second = alice.commit_file("b.txt", "y", "later work"); | ||
| 1058 | alice.git(&["push", "origin", "main"]); | ||
| 1059 | alice.run_ok(&["sync"]); | ||
| 1060 | |||
| 1061 | let bob_root = TempDir::new().unwrap(); | ||
| 1062 | let bob = bob_root.path().join("clone"); | ||
| 1063 | git_in( | ||
| 1064 | &alice, | ||
| 1065 | bob_root.path(), | ||
| 1066 | &["clone", bare.path().to_str().unwrap(), "clone"], | ||
| 1067 | ); | ||
| 1068 | git_in(&alice, &bob, &["config", "user.name", "Bob"]); | ||
| 1069 | git_in(&alice, &bob, &["config", "user.email", "bob@example.com"]); | ||
| 1070 | git_in(&alice, &bob, &["config", "collab.autoSync", "false"]); | ||
| 1071 | collab_in(&alice, &bob, &["init"]); | ||
| 1072 | collab_in(&alice, &bob, &["sync"]); | ||
| 1073 | |||
| 1074 | assert_ne!(first, second, "the two commits must actually differ"); | ||
| 1075 | |||
| 1076 | // Neither has seen the other's event. | ||
| 1077 | alice.run_ok(&["patch", "merge", &short, "--commit", &first]); | ||
| 1078 | collab_in( | ||
| 1079 | &alice, | ||
| 1080 | &bob, | ||
| 1081 | &["patch", "merge", &short, "--commit", &second], | ||
| 1082 | ); | ||
| 1083 | |||
| 1084 | alice.run_ok(&["sync"]); | ||
| 1085 | collab_in(&alice, &bob, &["sync"]); | ||
| 1086 | alice.run_ok(&["sync"]); | ||
| 1087 | |||
| 1088 | let alice_commit = show_json(&alice, &short)["merge_commit"].clone(); | ||
| 1089 | let bob_json: Value = serde_json::from_str(&collab_in( | ||
| 1090 | &alice, | ||
| 1091 | &bob, | ||
| 1092 | &["patch", "show", &short, "--json"], | ||
| 1093 | )) | ||
| 1094 | .unwrap(); | ||
| 1095 | |||
| 1096 | assert!( | ||
| 1097 | alice_commit == Value::String(first.clone()) || alice_commit == Value::String(second), | ||
| 1098 | "the winner must be one of the two recorded commits, got {}", | ||
| 1099 | alice_commit | ||
| 1100 | ); | ||
| 1101 | assert_eq!( | ||
| 1102 | alice_commit, bob_json["merge_commit"], | ||
| 1103 | "both clones must agree on which commit landed the patch" | ||
| 1104 | ); | ||
| 1105 | } | ||
| 1106 | |||
| 1107 | // --------------------------------------------------------------------------- | ||
| 1108 | // The local scans do not need a remote | ||
| 1109 | // --------------------------------------------------------------------------- | ||
| 1110 | |||
| 1111 | #[test] | ||
| 1112 | fn a_repo_with_no_remote_records_a_merge_and_links_a_commit() { | ||
| 1113 | // Both scans are purely local: they walk local refs and append local | ||
| 1114 | // events. Nothing about either needs a remote. A single clone with no | ||
| 1115 | // remote is the most basic case of the project's premise that | ||
| 1116 | // collaboration lives in the repository — and it is the state everyone is | ||
| 1117 | // in *before* adding a remote, so this is also the first impression. | ||
| 1118 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 1119 | // Deliberately no `git remote add` and no `git-collab init`. | ||
| 1120 | |||
| 1121 | let issue = repo.issue_open("Broken thing"); | ||
| 1122 | let short = patch_with_trailer(&repo, "feat", "a.txt"); | ||
| 1123 | squash_merge_keeping_message(&repo, "feat"); | ||
| 1124 | let linked = repo.commit_file("b.txt", "y", &format!("unrelated fix\n\nIssue: {}", issue)); | ||
| 1125 | |||
| 1126 | let out = repo.run_ok(&["sync"]); | ||
| 1127 | |||
| 1128 | assert_eq!( | ||
| 1129 | show_json(&repo, &short)["status"], | ||
| 1130 | "merged", | ||
| 1131 | "a repo with no remote must still record a merge from a Patch: trailer.\nsync said: {}", | ||
| 1132 | out | ||
| 1133 | ); | ||
| 1134 | |||
| 1135 | let linked_commits = issue_json(&repo, &issue)["linked_commits"] | ||
| 1136 | .as_array() | ||
| 1137 | .expect("issue JSON should carry linked_commits") | ||
| 1138 | .iter() | ||
| 1139 | .map(|c| c["commit"].as_str().unwrap_or_default().to_string()) | ||
| 1140 | .collect::<Vec<_>>(); | ||
| 1141 | assert!( | ||
| 1142 | linked_commits.contains(&linked), | ||
| 1143 | "a repo with no remote must still link a commit from an Issue: trailer, \ | ||
| 1144 | got {:?}.\nsync said: {}", | ||
| 1145 | linked_commits, | ||
| 1146 | out | ||
| 1147 | ); | ||
| 1148 | } | ||
| 1149 | |||
| 1150 | #[test] | ||
| 1151 | fn sync_with_no_remote_reports_what_it_did_instead_of_only_an_instruction() { | ||
| 1152 | // Returning at the no-remotes gate printed a bare instruction and skipped | ||
| 1153 | // the local half of sync's job entirely. It should still do that half and | ||
| 1154 | // say so — while still pointing at `init`, since nothing is being shared. | ||
| 1155 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 1156 | let short = patch_with_trailer(&repo, "feat", "a.txt"); | ||
| 1157 | squash_merge_keeping_message(&repo, "feat"); | ||
| 1158 | |||
| 1159 | let out = repo.run_ok(&["sync"]); | ||
| 1160 | |||
| 1161 | assert!( | ||
| 1162 | out.contains("Recorded 1 merged patch(es)."), | ||
| 1163 | "sync must report the merge it recorded: {}", | ||
| 1164 | out | ||
| 1165 | ); | ||
| 1166 | assert!( | ||
| 1167 | out.contains("git-collab init"), | ||
| 1168 | "and still say how to get a remote: {}", | ||
| 1169 | out | ||
| 1170 | ); | ||
| 1171 | assert_eq!(show_json(&repo, &short)["status"], "merged"); | ||
| 1172 | } | ||
| 1173 | |||
| 1174 | #[test] | ||
| 1175 | fn a_second_sync_with_no_remote_records_nothing_further() { | ||
| 1176 | // The no-remote path appends events, so it has to be as idempotent as the | ||
| 1177 | // remote one: a trailer stays in history forever. | ||
| 1178 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 1179 | let short = patch_with_trailer(&repo, "feat", "a.txt"); | ||
| 1180 | squash_merge_keeping_message(&repo, "feat"); | ||
| 1181 | |||
| 1182 | repo.run_ok(&["sync"]); | ||
| 1183 | let events_ref = patch_events_ref(&repo, &short); | ||
| 1184 | let after_first = count_events(&repo, &events_ref); | ||
| 1185 | |||
| 1186 | repo.run_ok(&["sync"]); | ||
| 1187 | repo.run_ok(&["sync"]); | ||
| 1188 | |||
| 1189 | assert_eq!( | ||
| 1190 | count_events(&repo, &events_ref), | ||
| 1191 | after_first, | ||
| 1192 | "repeated syncs with no remote must append nothing further" | ||
| 1193 | ); | ||
| 1194 | } | ||