a73x

f3f0d57b

Record merges as events instead of deriving them on read

a73x   2026-08-10 07:30

Commit message
Record merges as events instead of deriving them on read

`PatchStatus::Merged` was derived on every read, by asking whether the
patch's head was reachable from its base tip. Both halves fail in
ordinary use: deleting the merged branch is routine housekeeping and
sent the patch back to Open the moment the state cache went cold, and
reachability cannot see a squash merge at all -- a squash puts a new
commit with the same tree on the base branch, so the patch's own commits
are never ancestors of it.

Merged state is now an event. `Action::PatchMerge` gains the commit that
landed the patch, which is what the UI links to and the only durable
evidence of how a squashed patch reached the branch.

Two of the design's three layers land here; stamping (the commit-msg
hook and `patch create --stamp`) is a separate patch, and this is
correct without it -- the trailer can be written by hand.

- `git-collab patch merge <id> [--commit <rev>] [--no-close]` records a
  merge by hand. Always works, and is the only layer that must exist for
  the design to be correct.
- `sync` scans each open patch's own base branch for `Patch:` trailers
  and records the merges they name. One revwalk per distinct base
  branch, bounded by the merge-base of the open patches' recorded bases
  -- an ancestor of every one of them, so it can never hide a commit we
  needed, and the oldest of them whenever they are linearly ordered.
  Warn-and-continue on every per-commit and per-patch error, exactly as
  the `Issue:` scan does, so a scan can never break sync.
- `--fixes` closes its issue in the same operation that emits the merge.

The trailer parser is now token-parameterised and lives in `trailer`, so
`Issue:` and `Patch:` share one parser and one set of tests. Rejecting
interior whitespace carries over and matters more here: `Patch: abc
merged by me` parses to nothing, visibly, rather than silently to `abc`.

Recording is never a side effect of a read. Reachability detection stays
as a hint that decides nothing and writes nothing: `patch list` and
`patch show` render it `merged?`, and `sync` closes by naming the
patches that look merged and the command that records them.

The close writes to a different ref than the merge, so the two are not
atomic. If the close does not land, the merge stands, and a later scan
retries it -- which is why the close is driven by the invariant "a
merged patch's fixes issue is closed" rather than by the emission alone.
A rule that only fired alongside a freshly-emitted `PatchMerge` could
never retry, because on the retry pass the merge itself is a no-op.
Idempotent throughout: an already-closed issue is left alone, so
repeated syncs append nothing.

Cache format bumped to v5: a v4 entry can hold a `merged` status derived
from reachability, which no event backs and nothing recomputes any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

README.md
Old New
@@ -47,9 +47,24 @@ carries the patch and every revision it ever had. Contributing needs no `git pus
47 write access to `refs/heads/*`, and rebasing your branch cannot strip an earlier 47 write access to `refs/heads/*`, and rebasing your branch cannot strip an earlier
48 revision of the objects a reviewer is looking at. 48 revision of the objects a reviewer is looking at.
49 49
50 Merging is not a command. When a patch's commits become reachable from the base 50 Merge however you already merge — merging stays plain git. That a patch merged
51 branch, `git-collab` notices and marks the patch merged. Merge however you 51 is *recorded*, though, rather than guessed at on every read: a merged branch is
52 already merge. 52 routinely deleted, and no amount of reachability can see a squash, whose commit
53 shares nothing with the patch's own.
54
55 Put `Patch: <id>` in the commit that lands the patch and `git-collab sync` finds
56 it on the patch's base branch and records the merge — which works retroactively,
57 on any machine, and survives a squash, because `git merge --squash` carries the
58 message along. Or record it by hand:
59
60 ```console
61 $ git-collab patch merge a1b2c3d4
62 ```
63
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
66 `merged?` — a hint, and one that can only ever be a hint, so it decides nothing
67 and writes nothing.
53 68
54 ## Install 69 ## Install
55 70
@@ -98,7 +113,7 @@ than type.
98 | | | 113 | | |
99 |---|---| 114 |---|---|
100 | `issue` | open, list, show, comment, edit, label, assign, close | 115 | `issue` | open, list, show, comment, edit, label, assign, close |
101 | `patch` | create, list, show, diff, comment, review, revise, log, checkout, close | 116 | `patch` | create, list, show, diff, comment, review, revise, log, checkout, merge, close |
102 | `sync` | fetch, reconcile and push collab refs | 117 | `sync` | fetch, reconcile and push collab refs |
103 | `status` | project overview | 118 | `status` | project overview |
104 | `dashboard` | interactive TUI | 119 | `dashboard` | interactive TUI |
src/cache.rs
Old New
@@ -22,8 +22,12 @@ fn sanitize_ref_name(ref_name: &str) -> String {
22 /// single optional value. v4: `PatchState` gained a `labels` field; a 22 /// single optional value. v4: `PatchState` gained a `labels` field; a
23 /// stale v3 entry would deserialize fine (the field is `#[serde(default)]`) 23 /// stale v3 entry would deserialize fine (the field is `#[serde(default)]`)
24 /// but silently omit any labels folded in since, so it must be rejected by 24 /// but silently omit any labels folded in since, so it must be rejected by
25 /// version rather than relying on that default to catch it. 25 /// version rather than relying on that default to catch it. v5: `PatchStatus`
26 const CACHE_FORMAT_VERSION: u32 = 4; 26 /// is folded from the DAG alone. A v4 entry could hold `merged` derived from
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
29 /// forever. The shape is unchanged, so only the version can reject it.
30 const CACHE_FORMAT_VERSION: u32 = 5;
27 31
28 /// Cache entry stored on disk: the tip OID at cache time + serialized state. 32 /// Cache entry stored on disk: the tip OID at cache time + serialized state.
29 #[derive(serde::Serialize, serde::Deserialize)] 33 #[derive(serde::Serialize, serde::Deserialize)]
src/cli.rs
Old New
@@ -467,6 +467,22 @@ pub enum PatchCmd {
467 /// Label to remove 467 /// Label to remove
468 label: String, 468 label: String,
469 }, 469 },
470 /// Record that a patch was merged into its base branch
471 ///
472 /// Merging stays plain git; this records that it happened. Use it whenever
473 /// the merge is not visible to `sync`'s `Patch:` trailer scan — a squash
474 /// whose message was rewritten, a merge made before the trailer existed, or
475 /// a base branch this clone does not have.
476 Merge {
477 /// Patch ID (prefix match)
478 id: String,
479 /// The commit that landed the patch (default: the base branch tip)
480 #[arg(long)]
481 commit: Option<String>,
482 /// Record the merge without closing the issue named by --fixes
483 #[arg(long)]
484 no_close: bool,
485 },
470 /// Close a patch 486 /// Close a patch
471 Close { 487 Close {
472 /// Patch ID (prefix match) 488 /// Patch ID (prefix match)
@@ -513,6 +529,7 @@ impl Commands {
513 | PatchCmd::Label { .. } 529 | PatchCmd::Label { .. }
514 | PatchCmd::Unlabel { .. } 530 | PatchCmd::Unlabel { .. }
515 | PatchCmd::Close { .. } 531 | PatchCmd::Close { .. }
532 | PatchCmd::Merge { .. }
516 ), 533 ),
517 _ => false, 534 _ => false,
518 } 535 }
src/commit_link.rs
Old New
@@ -27,111 +27,10 @@ pub fn collect_linked_shas(repo: &Repository, issue_ref: &str) -> Result<HashSet
27 27
28 /// Parse `Issue:` trailers from a commit message. 28 /// Parse `Issue:` trailers from a commit message.
29 /// 29 ///
30 /// Returns the list of trailer values in order of appearance. Follows git's 30 /// A thin naming of the shared parser in [`crate::trailer`], which `Patch:`
31 /// own trailer-block semantics: only the final paragraph is considered, and 31 /// trailers use too.
32 /// *every* non-empty line in it must be trailer-shaped (a `token: value`
33 /// line) for the paragraph to qualify. Any prose line in the final paragraph
34 /// disqualifies the whole paragraph — this prevents false positives like
35 /// `"Thanks Bob.\nIssue: abc"` in commit bodies.
36 ///
37 /// The key match is `(?i)issue`; the value must be a single non-whitespace
38 /// token followed by optional trailing whitespace and end-of-line. Values
39 /// like `abc fixes thing` are rejected so that loose commentary never
40 /// becomes a silent issue-prefix lookup that warns every sync forever.
41 pub fn parse_issue_trailers(message: &str) -> Vec<String> { 32 pub fn parse_issue_trailers(message: &str) -> Vec<String> {
42 // 1. Split into paragraphs (blank-line separated), preserving order. 33 crate::trailer::parse_trailers(message, crate::trailer::ISSUE_TOKEN)
43 // Trim trailing whitespace from each line for the trailer-shape check,
44 // but keep enough structure to recognize blank lines.
45 let lines: Vec<&str> = message.lines().collect();
46
47 // 2. Find the last paragraph: the longest tail slice that contains at
48 // least one non-empty line and has no blank line *before* its first
49 // non-empty line in the tail.
50 //
51 // Walking from the end: skip trailing blank/whitespace-only lines,
52 // then collect lines until we hit a blank line.
53 let mut end = lines.len();
54 while end > 0 && lines[end - 1].trim().is_empty() {
55 end -= 1;
56 }
57 if end == 0 {
58 return Vec::new();
59 }
60 let mut start = end;
61 while start > 0 && !lines[start - 1].trim().is_empty() {
62 start -= 1;
63 }
64 let paragraph = &lines[start..end];
65
66 // 3. Validate every non-empty line in the paragraph is trailer-shaped.
67 for line in paragraph {
68 if line.trim().is_empty() {
69 continue;
70 }
71 if !is_trailer_shaped(line) {
72 return Vec::new();
73 }
74 }
75
76 // 4. Extract `Issue:` values.
77 let mut out = Vec::new();
78 for line in paragraph {
79 if let Some(value) = match_issue_line(line) {
80 out.push(value);
81 }
82 }
83 out
84 }
85
86 /// Returns true if a line looks like a git trailer: `<token>: <value>`, where
87 /// token starts with a letter and consists of `[A-Za-z0-9-]`, and value is at
88 /// least one non-whitespace character.
89 fn is_trailer_shaped(line: &str) -> bool {
90 let trimmed = line.trim_start();
91 let Some(colon_pos) = trimmed.find(':') else {
92 return false;
93 };
94 // Use trim_end() so that `ISSUE : abc` is recognized as the token `ISSUE`
95 // — matching what `match_issue_line` does. Without this, the space before
96 // the colon would disqualify the line and make the whole paragraph fail
97 // the trailer-shape check.
98 let token = trimmed[..colon_pos].trim_end();
99 if token.is_empty() {
100 return false;
101 }
102 let mut chars = token.chars();
103 let first = chars.next().unwrap();
104 if !first.is_ascii_alphabetic() {
105 return false;
106 }
107 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') {
108 return false;
109 }
110 let value = trimmed[colon_pos + 1..].trim();
111 !value.is_empty()
112 }
113
114 /// If `line` is an `Issue: <token>` trailer with exactly one non-whitespace
115 /// token in its value, returns the token. Otherwise returns None.
116 fn match_issue_line(line: &str) -> Option<String> {
117 let trimmed = line.trim_start();
118 let colon_pos = trimmed.find(':')?;
119 let key = trimmed[..colon_pos].trim_end();
120 if !key.eq_ignore_ascii_case("issue") {
121 return None;
122 }
123 let value_region = &trimmed[colon_pos + 1..];
124 let value = value_region.trim();
125 if value.is_empty() {
126 return None;
127 }
128 // Reject values with interior whitespace: `abc fixes thing` must not
129 // parse to `abc` silently — it must parse to nothing so the user sees
130 // that their commentary is being ignored.
131 if value.split_whitespace().count() != 1 {
132 return None;
133 }
134 Some(value.to_string())
135 } 34 }
136 35
137 const ACTIVE_ISSUE_PREFIX: &str = "refs/collab/issues/"; 36 const ACTIVE_ISSUE_PREFIX: &str = "refs/collab/issues/";
src/dag.rs
Old New
@@ -337,7 +337,7 @@ fn commit_message(action: &Action) -> String {
337 format!("patch: inline comment on {}:{}", file, line) 337 format!("patch: inline comment on {}:{}", file, line)
338 } 338 }
339 Action::PatchClose { .. } => "patch: close".to_string(), 339 Action::PatchClose { .. } => "patch: close".to_string(),
340 Action::PatchMerge => "patch: merge".to_string(), 340 Action::PatchMerge { .. } => "patch: merge".to_string(),
341 Action::Merge => "collab: merge".to_string(), 341 Action::Merge => "collab: merge".to_string(),
342 } 342 }
343 } 343 }
src/event.rs
Old New
@@ -136,8 +136,24 @@ pub enum Action {
136 }, 136 },
137 #[serde(rename = "patch.close")] 137 #[serde(rename = "patch.close")]
138 PatchClose { reason: Option<String> }, 138 PatchClose { reason: Option<String> },
139 /// A patch landed on its base branch. Recorded, never derived: reachability
140 /// cannot see a squash merge and cannot survive the merged branch being
141 /// deleted, so merged state has to be an event like any other.
142 ///
143 /// `commit` is the commit on the base branch that constitutes the merge —
144 /// the squash commit, the merge commit, or the rebased tip. It is the only
145 /// durable evidence of *how* a squashed patch reached the branch.
146 ///
147 /// Events written before the field existed carry no `commit`, so it
148 /// defaults to empty and an empty one is skipped on the way out: signatures
149 /// are verified by re-serializing, and writing back a field the signer
150 /// never wrote would invalidate every one of them. An empty `commit` means
151 /// "not recorded", never "the null OID".
139 #[serde(rename = "patch.merge")] 152 #[serde(rename = "patch.merge")]
140 PatchMerge, 153 PatchMerge {
154 #[serde(default, skip_serializing_if = "String::is_empty")]
155 commit: String,
156 },
141 #[serde(rename = "collab.merge")] 157 #[serde(rename = "collab.merge")]
142 Merge, 158 Merge,
143 } 159 }
src/lib.rs
Old New
@@ -8,6 +8,7 @@ pub mod event;
8 pub mod identity; 8 pub mod identity;
9 pub mod issue; 9 pub mod issue;
10 pub mod log; 10 pub mod log;
11 pub mod merge_scan;
11 pub mod patch; 12 pub mod patch;
12 pub mod release; 13 pub mod release;
13 pub mod signing; 14 pub mod signing;
@@ -15,6 +16,7 @@ pub mod state;
15 pub mod status; 16 pub mod status;
16 pub mod sync; 17 pub mod sync;
17 pub mod sync_lock; 18 pub mod sync_lock;
19 pub mod trailer;
18 pub mod trust; 20 pub mod trust;
19 pub mod tui; 21 pub mod tui;
20 22
@@ -351,28 +353,19 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
351 println!("{}", output); 353 println!("{}", output);
352 return Ok(()); 354 return Ok(());
353 } 355 }
354 let entries = patch::list(repo, all, archived, limit, offset, sort, &label)?; 356 // One renderer, not two: this used to be a copy of
355 if entries.is_empty() { 357 // `list_to_writer`'s body, and the copies drifted apart every
356 println!("No patches found."); 358 // time either grew a column.
357 } else { 359 patch::list_to_writer(
358 for e in &entries { 360 repo,
359 let p = &e.patch; 361 all,
360 let labels = cli::label_suffix(&p.labels); 362 archived,
361 let stale = match p.staleness(repo) { 363 limit,
362 Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind), 364 offset,
363 Ok(_) => String::new(), 365 sort,
364 Err(_) => String::new(), 366 &label,
365 }; 367 &mut std::io::stdout(),
366 let unread = match e.unread { 368 )?;
367 Some(n) if n > 0 => format!(" ({} new)", n),
368 _ => String::new(),
369 };
370 println!(
371 "{:.8} {:6} {}{} (by {}){}{}",
372 p.id, p.status, p.title, labels, p.author.name, stale, unread
373 );
374 }
375 }
376 Ok(()) 369 Ok(())
377 } 370 }
378 PatchCmd::Show { id, json, revision } => { 371 PatchCmd::Show { id, json, revision } => {
@@ -389,7 +382,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
389 } 382 }
390 _ => String::new(), 383 _ => String::new(),
391 }; 384 };
392 println!("Patch {} [{}{}] (r{})", &p.id[..8], p.status, status_detail, rev_count); 385 println!(
386 "Patch {} [{}{}] (r{})",
387 &p.id[..8],
388 p.status_display(repo),
389 status_detail,
390 rev_count
391 );
393 println!("Title: {}", p.title); 392 println!("Title: {}", p.title);
394 println!("Author: {} <{}>", p.author.name, p.author.email); 393 println!("Author: {} <{}>", p.author.name, p.author.email);
395 match p.resolve_head(repo) { 394 match p.resolve_head(repo) {
@@ -571,6 +570,26 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
571 println!("Label '{}' removed.", label); 570 println!("Label '{}' removed.", label);
572 Ok(()) 571 Ok(())
573 } 572 }
573 PatchCmd::Merge {
574 id,
575 commit,
576 no_close,
577 } => {
578 let report = patch::merge(repo, &id, commit.as_deref(), !no_close)?;
579 match report.outcome {
580 merge_scan::MergeOutcome::Recorded => println!(
581 "Recorded patch {:.8} as merged in {:.8}",
582 report.id, report.commit
583 ),
584 merge_scan::MergeOutcome::AlreadyMerged => {
585 println!("Patch {:.8} is already recorded as merged.", report.id)
586 }
587 }
588 if report.close == merge_scan::CloseOutcome::Closed {
589 println!("Closed the issue it fixes.");
590 }
591 Ok(())
592 }
574 PatchCmd::Close { id, reason } => { 593 PatchCmd::Close { id, reason } => {
575 patch::close(repo, &id, reason.as_deref())?; 594 patch::close(repo, &id, reason.as_deref())?;
576 println!("Patch closed."); 595 println!("Patch closed.");
src/log.rs
Old New
@@ -128,7 +128,7 @@ fn action_type_name(action: &Action) -> String {
128 Action::PatchComment { .. } => "PatchComment".to_string(), 128 Action::PatchComment { .. } => "PatchComment".to_string(),
129 Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(), 129 Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(),
130 Action::PatchClose { .. } => "PatchClose".to_string(), 130 Action::PatchClose { .. } => "PatchClose".to_string(),
131 Action::PatchMerge => "PatchMerge".to_string(), 131 Action::PatchMerge { .. } => "PatchMerge".to_string(),
132 Action::Merge => "Merge".to_string(), 132 Action::Merge => "Merge".to_string(),
133 } 133 }
134 } 134 }
@@ -175,7 +175,13 @@ fn action_summary(action: &Action) -> String {
175 Some(r) => format!("close: {}", r), 175 Some(r) => format!("close: {}", r),
176 None => "close".to_string(), 176 None => "close".to_string(),
177 }, 177 },
178 Action::PatchMerge => "merge".to_string(), 178 Action::PatchMerge { commit } => {
179 if commit.is_empty() {
180 "merge".to_string()
181 } else {
182 format!("merge {}", &commit[..commit.len().min(7)])
183 }
184 }
179 Action::Merge => "dag merge".to_string(), 185 Action::Merge => "dag merge".to_string(),
180 } 186 }
181 } 187 }
src/merge_scan.rs
Old New
@@ -0,0 +1,433 @@
1 //! Record merges as events, from `Patch:` trailers on a patch's base branch.
2 //!
3 //! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
4 //!
5 //! `PatchStatus::Merged` used to be derived on every read, by asking whether
6 //! the patch's head was reachable from its base tip. That fails in ordinary
7 //! use: the merged branch is routinely deleted, and reachability cannot see a
8 //! squash merge at all. So a merge is recorded, three ways that degrade into
9 //! each other:
10 //!
11 //! 1. a `commit-msg` hook stamps `Patch: <id>` onto commits (not implemented);
12 //! 2. `sync` scans the base branch for those trailers — [`scan_and_record_merges`];
13 //! 3. `git-collab patch merge <id>` records it by hand — [`crate::patch::merge`].
14 //!
15 //! Layer 3 always works and is the only one that must exist for the design to
16 //! be correct. Layers 1 and 2 remove the need to remember it.
17 //!
18 //! **Recording is never a side effect of a read.** Nothing in this module runs
19 //! on a display path; [`crate::state::PatchState::looks_merged`] is the only
20 //! thing a read consults, and it writes nothing.
21
22 use std::collections::{HashMap, HashSet};
23
24 use git2::{Oid, Repository, Sort};
25
26 use crate::dag;
27 use crate::error::Error;
28 use crate::event::{Action, Author, Event};
29 use crate::state::{self, IssueStatus, PatchState, PatchStatus};
30 use crate::trailer;
31
32 const ARCHIVED_PATCH_PREFIX: &str = "refs/collab/archive/patches/";
33
34 /// What recording a merge on one patch did.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub enum MergeOutcome {
37 /// A `PatchMerge` was appended.
38 Recorded,
39 /// The patch was already merged, so nothing was appended.
40 AlreadyMerged,
41 }
42
43 /// What reconciling a patch's `--fixes` issue did.
44 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45 pub enum CloseOutcome {
46 /// An `IssueClose` was appended.
47 Closed,
48 /// The patch has no `fixes`, or the issue was already closed.
49 NothingToDo,
50 /// The issue could not be resolved, or the close failed. Warned about;
51 /// the merge still stands and the next scan retries.
52 Failed,
53 }
54
55 /// Append a `PatchMerge` naming the commit that landed the patch, unless the
56 /// patch is already merged.
57 ///
58 /// Applying a `PatchMerge` to a patch that is already `Merged` is a no-op in
59 /// the fold, so a duplicate would be harmless — but appending one anyway would
60 /// make every sync grow the DAG forever, so the check is here rather than left
61 /// to the fold.
62 pub fn record_merge(
63 repo: &Repository,
64 events_ref: &str,
65 patch: &PatchState,
66 commit: Oid,
67 author: &Author,
68 sk: &ed25519_dalek::SigningKey,
69 ) -> Result<MergeOutcome, Error> {
70 if patch.status == PatchStatus::Merged {
71 return Ok(MergeOutcome::AlreadyMerged);
72 }
73 let event = Event {
74 timestamp: chrono::Utc::now().to_rfc3339(),
75 author: author.clone(),
76 action: Action::PatchMerge {
77 commit: commit.to_string(),
78 },
79 clock: 0,
80 };
81 dag::append_event(repo, events_ref, &event, sk)?;
82 Ok(MergeOutcome::Recorded)
83 }
84
85 /// Close the issue a patch was created to fix, if it is still open.
86 ///
87 /// `--fixes` is documented as "auto-closes on merge" and this is where that
88 /// happens: in the same operation that records the merge, never during
89 /// derivation.
90 ///
91 /// The two writes go to different refs, so they are not atomic. If this half
92 /// fails the merge still stands, and the next scan — which sees a merged patch
93 /// with an open `fixes` issue — retries it. That is why this is driven by the
94 /// invariant "a merged patch's `fixes` issue is closed" rather than by the
95 /// merge emission alone: a rule that only fired alongside a freshly-emitted
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
98 /// closed issue is left alone, so repeated syncs append nothing.
99 pub fn close_fixed_issue(repo: &Repository, patch: &PatchState) -> CloseOutcome {
100 close_fixed_issue_inner(repo, patch, true)
101 }
102
103 /// `warn_unresolvable` is false on the retry pass. An issue that does not
104 /// resolve — deleted, or an ambiguous prefix — will not resolve on the next
105 /// sync either, and the attempt made alongside the merge already said so; going
106 /// on to warn about it on every sync forever is noise nobody can act on. A
107 /// failure to *append* still warns either way, because that one is worth
108 /// retrying and worth hearing about.
109 fn close_fixed_issue_inner(
110 repo: &Repository,
111 patch: &PatchState,
112 warn_unresolvable: bool,
113 ) -> CloseOutcome {
114 let Some(fixes) = patch.fixes.as_deref() else {
115 return CloseOutcome::NothingToDo;
116 };
117 let (issue_ref, issue_id) = match state::resolve_issue_ref(repo, fixes) {
118 Ok(v) => v,
119 Err(e) => {
120 // resolve_issue_ref distinguishes "no issue found" from "ambiguous
121 // prefix" in its message.
122 if warn_unresolvable {
123 eprintln!(
124 "warning: patch {:.8}: cannot close fixed issue {:.8}: {}",
125 patch.id, fixes, e
126 );
127 }
128 return CloseOutcome::Failed;
129 }
130 };
131 let issue = match state::IssueState::from_ref(repo, &issue_ref, &issue_id) {
132 Ok(i) => i,
133 Err(e) => {
134 if warn_unresolvable {
135 eprintln!(
136 "warning: patch {:.8}: cannot read fixed issue {:.8}: {}",
137 patch.id, fixes, e
138 );
139 }
140 return CloseOutcome::Failed;
141 }
142 };
143 if issue.status != IssueStatus::Open {
144 return CloseOutcome::NothingToDo;
145 }
146 let reason = format!("merged in patch {:.8}", patch.id);
147 if let Err(e) = crate::issue::close(repo, &issue_id, Some(&reason)) {
148 eprintln!(
149 "warning: patch {:.8}: failed to close fixed issue {:.8}: {} — \
150 the merge stands; the next scan will retry the close",
151 patch.id, fixes, e
152 );
153 return CloseOutcome::Failed;
154 }
155 CloseOutcome::Closed
156 }
157
158 /// A `Patch:` trailer found during the walk, and the commit that carried it.
159 struct FoundTrailer {
160 /// The patch id prefix, verbatim from the trailer.
161 prefix: String,
162 /// The commit on the base branch that constitutes the merge.
163 commit: Oid,
164 }
165
166 /// Walk each base branch that has open patches, collect `Patch:` trailers, and
167 /// emit a `PatchMerge` for every patch whose trailer appears on *its own*
168 /// base branch.
169 ///
170 /// **Never breaks sync.** Per-commit and per-patch errors are logged as
171 /// one-line stderr warnings and iteration continues, mirroring the `Issue:`
172 /// scan exactly. The only errors that propagate are "couldn't even start"
173 /// failures; callers treat a returned `Err` as "skip the merge scan for this
174 /// sync" and proceed.
175 ///
176 /// Returns the number of `PatchMerge` events actually emitted.
177 pub fn scan_and_record_merges(
178 repo: &Repository,
179 author: &Author,
180 sk: &ed25519_dalek::SigningKey,
181 ) -> Result<usize, Error> {
182 // Only open patches are candidates, and a trailer only counts on the
183 // patch's own base branch — a `Patch:` trailer on some unrelated branch is
184 // not a merge. So group the open patches by base branch and walk each base
185 // once, rather than walking every branch as the `Issue:` scan does, or
186 // walking once per patch.
187 let all = state::list_patches(repo)?;
188
189 let mut by_base: HashMap<String, Vec<PatchState>> = HashMap::new();
190 for patch in &all {
191 if patch.status == PatchStatus::Open {
192 by_base
193 .entry(patch.base_ref.clone())
194 .or_default()
195 .push(patch.clone());
196 }
197 }
198
199 let mut emitted = 0usize;
200 // Iterate in a stable order so warnings do not shuffle between runs.
201 let mut bases: Vec<&String> = by_base.keys().collect();
202 bases.sort();
203 for base in bases {
204 let patches = &by_base[base];
205 emitted += scan_one_base(repo, base, patches, author, sk);
206 }
207
208 retry_pending_closes(repo, &all);
209 Ok(emitted)
210 }
211
212 /// Close the `fixes` issue of any patch that was *already* merged when this
213 /// scan started.
214 ///
215 /// This is the explicit retry the non-atomicity demands. A merge and the close
216 /// of the issue it fixes write to different refs, so the pair can half-succeed;
217 /// when it does, the merge stands and the issue is left open with nothing to
218 /// finish the job. Recording the merge again is not an option — the second
219 /// `PatchMerge` would be a no-op, so hanging the close off a freshly-emitted
220 /// merge could never retry.
221 ///
222 /// 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
224 /// *and* carry a `fixes`; idempotent, because an already-closed issue is left
225 /// alone, which is what keeps repeated syncs from appending anything.
226 ///
227 /// 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
229 /// ones merged by some earlier operation — including by `patch merge
230 /// --no-close`, and including merges recorded on another machine and arriving
231 /// by sync.
232 fn retry_pending_closes(repo: &Repository, patches: &[PatchState]) {
233 for patch in patches {
234 if patch.status == PatchStatus::Merged && patch.fixes.is_some() {
235 close_fixed_issue_inner(repo, patch, false);
236 }
237 }
238 }
239
240 /// Scan one base branch. Returns the number of merges recorded. Absorbs every
241 /// error itself, because a base branch that cannot be walked must not stop the
242 /// other base branches, let alone the sync.
243 fn scan_one_base(
244 repo: &Repository,
245 base: &str,
246 patches: &[PatchState],
247 author: &Author,
248 sk: &ed25519_dalek::SigningKey,
249 ) -> usize {
250 let base_ref = format!("refs/heads/{}", base);
251 let Ok(base_tip) = repo.refname_to_id(&base_ref) else {
252 // The clone does not have this base branch. Detecting merges on a base
253 // branch we do not have is out of scope, and it is not an error: a
254 // contributor legitimately syncs without every base branch checked out.
255 return 0;
256 };
257
258 let trailers = match collect_trailers(repo, base_tip, patches) {
259 Ok(t) => t,
260 Err(e) => {
261 eprintln!(
262 "warning: cannot scan '{}' for Patch: trailers: {} — skipping",
263 base, e
264 );
265 return 0;
266 }
267 };
268
269 let mut emitted = 0usize;
270 for found in trailers {
271 match record_from_trailer(repo, base, &found, author, sk) {
272 Ok(MergeOutcome::Recorded) => emitted += 1,
273 Ok(MergeOutcome::AlreadyMerged) => {}
274 Err(()) => {}
275 }
276 }
277 emitted
278 }
279
280 /// Walk `base_tip` back to the oldest base among `patches`, collecting
281 /// `Patch:` trailers.
282 ///
283 /// The bound matters: everything older than the oldest open patch's base
284 /// cannot have merged a currently-open patch, so walking it is pure cost. The
285 /// merge-base of all the recorded bases is an ancestor of every one of them, so
286 /// hiding it can never hide a commit we needed — and when the bases are
287 /// linearly ordered, which is the normal case, it *is* the oldest of them.
288 ///
289 /// 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.
291 fn collect_trailers(
292 repo: &Repository,
293 base_tip: Oid,
294 patches: &[PatchState],
295 ) -> Result<Vec<FoundTrailer>, Error> {
296 let mut revwalk = repo.revwalk()?;
297 revwalk.set_sorting(Sort::TOPOLOGICAL)?;
298 revwalk.push(base_tip)?;
299
300 let bases: Vec<Oid> = patches
301 .iter()
302 .filter_map(|p| p.effective_base(repo))
303 .collect();
304 if !bases.is_empty() {
305 if let Ok(bound) = repo.merge_base_many(&bases) {
306 // A failure to hide is not fatal — it only means a longer walk.
307 let _ = revwalk.hide(bound);
308 }
309 }
310
311 let mut found = Vec::new();
312 let mut seen_prefix: HashSet<String> = HashSet::new();
313 for oid_result in revwalk {
314 let oid = match oid_result {
315 Ok(o) => o,
316 Err(e) => {
317 eprintln!("warning: revwalk error, stopping merge scan: {}", e);
318 break;
319 }
320 };
321 let commit = match repo.find_commit(oid) {
322 Ok(c) => c,
323 Err(e) => {
324 eprintln!("warning: cannot load commit {}: {}", oid, e);
325 continue;
326 }
327 };
328 let message = commit.message().unwrap_or("");
329 for prefix in trailer::parse_trailers(message, trailer::PATCH_TOKEN) {
330 if seen_prefix.insert(prefix.clone()) {
331 found.push(FoundTrailer { prefix, commit: oid });
332 }
333 }
334 }
335 Ok(found)
336 }
337
338 /// Resolve one trailer and record the merge it names. `Err(())` means the
339 /// trailer was warned about and skipped; it never propagates.
340 fn record_from_trailer(
341 repo: &Repository,
342 base: &str,
343 found: &FoundTrailer,
344 author: &Author,
345 sk: &ed25519_dalek::SigningKey,
346 ) -> Result<MergeOutcome, ()> {
347 let (events_ref, id) = match state::resolve_patch_ref(repo, &found.prefix) {
348 Ok(v) => v,
349 Err(e) => {
350 // resolve_patch_ref's message already distinguishes "no patch
351 // found" from "ambiguous prefix".
352 eprintln!(
353 "warning: commit {:.8}: Patch: {} — {}, skipping",
354 found.commit, found.prefix, e
355 );
356 return Err(());
357 }
358 };
359 if events_ref.starts_with(ARCHIVED_PATCH_PREFIX) {
360 eprintln!(
361 "warning: commit {:.8}: Patch: {} — patch is archived, skipping",
362 found.commit, found.prefix
363 );
364 return Err(());
365 }
366
367 let patch = match PatchState::from_ref(repo, &events_ref, &id) {
368 Ok(p) => p,
369 Err(e) => {
370 eprintln!(
371 "warning: commit {:.8}: Patch: {} — cannot read patch: {}, skipping",
372 found.commit, found.prefix, e
373 );
374 return Err(());
375 }
376 };
377
378 // A trailer only records a merge on the patch's *own* base branch. The
379 // walk was seeded from one branch, but the trailer may name a patch based
380 // on another, and landing on the wrong branch is not landing.
381 if patch.base_ref != base {
382 return Err(());
383 }
384 // A patch someone deliberately closed is not resurrected by a trailer.
385 if patch.status == PatchStatus::Closed {
386 return Err(());
387 }
388
389 let outcome = match record_merge(repo, &events_ref, &patch, found.commit, author, sk) {
390 Ok(o) => o,
391 Err(e) => {
392 eprintln!(
393 "warning: commit {:.8}: failed to record merge of patch {:.8}: {}",
394 found.commit, id, e
395 );
396 return Err(());
397 }
398 };
399
400 // 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
402 // run on a pass where the merge itself was a no-op.
403 close_fixed_issue(repo, &patch);
404
405 Ok(outcome)
406 }
407
408 /// Open patches whose head is reachable from their base tip but which carry no
409 /// `PatchMerge`. Returned so `sync` can name them; nothing here writes.
410 pub fn merge_hints(repo: &Repository) -> Result<Vec<PatchState>, Error> {
411 Ok(state::list_patches(repo)?
412 .into_iter()
413 .filter(|p| p.looks_merged(repo))
414 .collect())
415 }
416
417 /// Print the closing hint `sync` shows for patches that look merged but are
418 /// recorded nowhere. Knowingly partial — it cannot see a squash — so it is
419 /// phrased as a suggestion.
420 pub fn print_merge_hints(repo: &Repository) {
421 let Ok(hints) = merge_hints(repo) else { return };
422 if hints.is_empty() {
423 return;
424 }
425 println!(
426 "\n{} patch(es) look merged but are not recorded:",
427 hints.len()
428 );
429 for p in &hints {
430 println!(" {:.8} {}", p.id, p.title);
431 }
432 println!("Record one with: git-collab patch merge <id>");
433 }
src/patch.rs
Old New
@@ -160,10 +160,18 @@ pub fn list_to_writer(
160 Some(n) if n > 0 => format!(" ({} new)", n), 160 Some(n) if n > 0 => format!(" ({} new)", n),
161 _ => String::new(), 161 _ => String::new(),
162 }; 162 };
163 // `merged?` — not `merged` — for a patch that only looks merged.
164 // Reachability is a hint here, never the recorded status.
163 writeln!( 165 writeln!(
164 writer, 166 writer,
165 "{:.8} {:6} {}{} (by {}){}{}", 167 "{:.8} {:7} {}{} (by {}){}{}",
166 p.id, p.status, p.title, labels, p.author.name, stale, unread 168 p.id,
169 p.status_display(repo),
170 p.title,
171 labels,
172 p.author.name,
173 stale,
174 unread
167 ) 175 )
168 .ok(); 176 .ok();
169 } 177 }
@@ -693,6 +701,82 @@ pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), cr
693 Ok(()) 701 Ok(())
694 } 702 }
695 703
704 /// What `patch merge` did, so the caller can say so.
705 pub struct MergeReport {
706 pub id: String,
707 pub outcome: crate::merge_scan::MergeOutcome,
708 pub close: crate::merge_scan::CloseOutcome,
709 /// The commit recorded as having landed the patch.
710 pub commit: Oid,
711 }
712
713 /// Record that a patch landed on its base branch — layer 3 of merge recording,
714 /// the one that always works.
715 ///
716 /// `commit` names the commit that constitutes the merge; with none given it is
717 /// the current tip of the patch's base branch, which is what it is immediately
718 /// after merging. This is the fallback for everything the trailer scan cannot
719 /// see: a squash whose message was rewritten by hand, a merge made before any
720 /// of this existed, a base branch this clone does not have the history of.
721 ///
722 /// `close_fixes` is on by default: `--fixes` is documented as auto-closing on
723 /// merge, and this is one of the two places that happens. Pass `false` to
724 /// record a merge that does not resolve the issue — a partial fix — or when the
725 /// issue should be closed by hand. The two writes go to different refs and are
726 /// not atomic; if the close does not land, the merge still stands and the next
727 /// `sync` retries it.
728 pub fn merge(
729 repo: &Repository,
730 id_prefix: &str,
731 commit: Option<&str>,
732 close_fixes: bool,
733 ) -> Result<MergeReport, crate::error::Error> {
734 let (events_ref, id) = state::resolve_patch_ref(repo, id_prefix)?;
735 let patch = PatchState::from_ref(repo, &events_ref, &id)?;
736
737 let commit_oid = match commit {
738 Some(rev) => repo
739 .revparse_single(rev)
740 .map_err(|e| Error::Cmd(format!("cannot resolve '{}': {}", rev, e)))?
741 .id(),
742 None => {
743 let base_ref = format!("refs/heads/{}", patch.base_ref);
744 repo.refname_to_id(&base_ref).map_err(|e| {
745 Error::Cmd(format!(
746 "base branch '{}' not found: {} — pass --commit to name the commit that landed the patch",
747 patch.base_ref, e
748 ))
749 })?
750 }
751 };
752
753 let author = get_author(repo)?;
754 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
755 let outcome = crate::merge_scan::record_merge(
756 repo,
757 &events_ref,
758 &patch,
759 commit_oid,
760 &author,
761 &sk,
762 )?;
763 // Reconcile the `fixes` issue whether or not the merge was freshly
764 // emitted, so a close that failed the first time is retried rather than
765 // stranded. Idempotent: an already-closed issue is left alone.
766 let close = if close_fixes {
767 crate::merge_scan::close_fixed_issue(repo, &patch)
768 } else {
769 crate::merge_scan::CloseOutcome::NothingToDo
770 };
771
772 Ok(MergeReport {
773 id,
774 outcome,
775 close,
776 commit: commit_oid,
777 })
778 }
779
696 pub fn close( 780 pub fn close(
697 repo: &Repository, 781 repo: &Repository,
698 id_prefix: &str, 782 id_prefix: &str,
src/state.rs
Old New
@@ -542,7 +542,7 @@ impl PatchState {
542 /// unknown base cannot be recovered by recomputing a merge-base against the 542 /// unknown base cannot be recovered by recomputing a merge-base against the
543 /// tip as it stands now — once the patch is merged that yields the head 543 /// tip as it stands now — once the patch is merged that yields the head
544 /// itself, which looks like a base that never moved. 544 /// itself, which looks like a base that never moved.
545 fn effective_base(&self, repo: &Repository) -> Option<Oid> { 545 pub fn effective_base(&self, repo: &Repository) -> Option<Oid> {
546 let end = self.latest_usable_index(repo)?; 546 let end = self.latest_usable_index(repo)?;
547 self.revisions[..=end] 547 self.revisions[..=end]
548 .iter() 548 .iter()
@@ -561,17 +561,31 @@ impl PatchState {
561 Ok((ahead, behind)) 561 Ok((ahead, behind))
562 } 562 }
563 563
564 /// Auto-detect merge: if the patch is still Open and its head is 564 /// Whether this patch *looks* merged: it is still Open, and its head is
565 /// reachable from the base branch tip, the user merged it outside of 565 /// reachable from the tip of its base branch.
566 /// git-collab. We compare the current base tip to where the *latest*
567 /// revision stood — if the base has moved to include the patch head, the
568 /// patch is merged.
569 /// 566 ///
570 /// Anchoring this to the latest revision rather than to creation matters 567 /// This is a hint and nothing more. It used to decide the patch's status on
571 /// twice over. It survives a rebase, which moves the base out from under 568 /// every read, which was wrong in both directions:
572 /// an earlier revision; and it no longer routes through 569 ///
573 /// `refs/heads/<branch>`, which used to make detection no-op silently 570 /// - It cannot see a squash or a rebase-merge. Those put a *new* commit
574 /// whenever the source branch had been deleted or was never pushed. 571 /// with the same tree on the base branch, so the patch's own commits are
572 /// never ancestors of it and `graph_descendant_of` is false forever.
573 /// - Anything it decided was recorded nowhere, so it had to be re-derived
574 /// every time, from a base branch the clone might not have.
575 ///
576 /// So its absence must never be read as "not merged", and it never writes:
577 /// a merge is recorded by `Action::PatchMerge`, emitted only by an explicit
578 /// act (`patch merge`, or the `Patch:` trailer scan during sync). Making
579 /// this method append during derivation would rebuild the phantom-revision
580 /// bug with a wider blast radius — `patch list` would mutate the DAG,
581 /// read-only clones would break, and two people running it concurrently
582 /// would both append.
583 ///
584 /// Anchoring to the latest revision rather than to creation matters twice
585 /// over. It survives a rebase, which moves the base out from under an
586 /// earlier revision; and it does not route through `refs/heads/<branch>`,
587 /// which used to make detection no-op silently whenever the source branch
588 /// had been deleted or was never pushed.
575 /// 589 ///
576 /// `base_moved` exists only to spare the degenerate patch whose recorded 590 /// `base_moved` exists only to spare the degenerate patch whose recorded
577 /// base is its own head — one created on the base branch itself. With a 591 /// base is its own head — one created on the base branch itself. With a
@@ -580,25 +594,24 @@ impl PatchState {
580 /// The base therefore has to be the one actually recorded, never one 594 /// The base therefore has to be the one actually recorded, never one
581 /// recomputed against the tip as it stands now: in the exact fast-forward 595 /// recomputed against the tip as it stands now: in the exact fast-forward
582 /// case a recomputed merge-base is the head itself, so a merged patch looks 596 /// case a recomputed merge-base is the head itself, so a merged patch looks
583 /// unmoved and stays Open forever. That is the commonest merge for a 597 /// unmoved. That is the commonest merge for a single-commit patch.
584 /// single-commit patch. `effective_base` searches back for the newest 598 /// `effective_base` searches back for the newest recorded base rather than
585 /// recorded base rather than reading only the newest revision, which is 599 /// reading only the newest revision, which is what keeps migrated patches —
586 /// what keeps migrated patches — base on the create, none on the revisions 600 /// base on the create, none on the revisions after it — out of that trap.
587 /// after it — out of that trap.
588 /// 601 ///
589 /// A patch with no recorded base anywhere predates the field entirely. 602 /// A patch with no recorded base anywhere predates the field entirely.
590 /// Nothing distinguishes it from the degenerate case, so it keeps the 603 /// Nothing distinguishes it from the degenerate case, so it is not guessed
591 /// behaviour it has always had rather than being guessed at. 604 /// at.
592 fn check_auto_merge(&mut self, repo: &Repository) { 605 pub fn looks_merged(&self, repo: &Repository) -> bool {
593 if self.status != PatchStatus::Open { 606 if self.status != PatchStatus::Open {
594 return; 607 return false;
595 } 608 }
596 let Ok(patch_head) = self.resolve_head(repo) else { 609 let Ok(patch_head) = self.resolve_head(repo) else {
597 return; 610 return false;
598 }; 611 };
599 let base_ref = format!("refs/heads/{}", self.base_ref); 612 let base_ref = format!("refs/heads/{}", self.base_ref);
600 let Ok(base_tip) = repo.refname_to_id(&base_ref) else { 613 let Ok(base_tip) = repo.refname_to_id(&base_ref) else {
601 return; 614 return false;
602 }; 615 };
603 let base_moved = match self.effective_base(repo) { 616 let base_moved = match self.effective_base(repo) {
604 Some(base) => base != base_tip, 617 Some(base) => base != base_tip,
@@ -608,8 +621,18 @@ impl PatchState {
608 || repo 621 || repo
609 .graph_descendant_of(base_tip, patch_head) 622 .graph_descendant_of(base_tip, patch_head)
610 .unwrap_or(false); 623 .unwrap_or(false);
611 if base_moved && reachable { 624 base_moved && reachable
612 self.status = PatchStatus::Merged; 625 }
626
627 /// What to print where the status goes: the recorded status, or `merged?`
628 /// for a patch that only *looks* merged. The question mark is the whole
629 /// point — the hint is knowingly partial, so it must never be mistaken for
630 /// the recorded fact.
631 pub fn status_display(&self, repo: &Repository) -> String {
632 if self.looks_merged(repo) {
633 "merged?".to_string()
634 } else {
635 self.status.to_string()
613 } 636 }
614 } 637 }
615 638
@@ -618,17 +641,10 @@ impl PatchState {
618 ref_name: &str, 641 ref_name: &str,
619 id: &str, 642 id: &str,
620 ) -> Result<Self, crate::error::Error> { 643 ) -> Result<Self, crate::error::Error> {
621 // Check cache first 644 // Check cache first. Nothing is recomputed on the way out: status is
622 if let Some(mut cached) = cache::get_cached_state::<PatchState>(repo, ref_name) { 645 // now folded from the DAG alone, so a cache entry keyed on the DAG tip
623 // The cache may return a stale Open status if the patch was merged 646 // is complete by construction.
624 // outside of git-collab since the DAG tip hasn't changed. 647 if let Some(cached) = cache::get_cached_state::<PatchState>(repo, ref_name) {
625 cached.check_auto_merge(repo);
626 if cached.status == PatchStatus::Merged {
627 // Update the cache with the corrected status
628 if let Ok(tip) = repo.refname_to_id(ref_name) {
629 cache::set_cached_state(repo, ref_name, tip, &cached);
630 }
631 }
632 return Ok(cached); 648 return Ok(cached);
633 } 649 }
634 650
@@ -831,7 +847,7 @@ impl PatchState {
831 } 847 }
832 } 848 }
833 } 849 }
834 Action::PatchMerge => { 850 Action::PatchMerge { .. } => {
835 if let Some(ref mut s) = state { 851 if let Some(ref mut s) = state {
836 let key = (event.clock, oid.to_string()); 852 let key = (event.clock, oid.to_string());
837 if status_key.as_ref().is_none_or(|k| key >= *k) { 853 if status_key.as_ref().is_none_or(|k| key >= *k) {
@@ -846,7 +862,6 @@ impl PatchState {
846 862
847 if let Some(ref mut s) = state { 863 if let Some(ref mut s) = state {
848 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default(); 864 s.last_updated = latest.map(|(_, raw)| raw).unwrap_or_default();
849 s.check_auto_merge(repo);
850 } 865 }
851 state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into()) 866 state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into())
852 } 867 }
src/sync.rs
Old New
@@ -484,6 +484,17 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
484 Err(e) => eprintln!("warning: commit link scan failed: {}", e), 484 Err(e) => eprintln!("warning: commit link scan failed: {}", e),
485 } 485 }
486 486
487 // Step 2.6: Scan each open patch's own base branch for Patch: trailers and
488 // record the merges they name. Same contract as the link scan above: it
489 // absorbs its own per-commit and per-patch errors and can never break sync.
490 // This runs before the push so anything it records travels in the same
491 // sync rather than waiting for the next one.
492 match crate::merge_scan::scan_and_record_merges(&repo, &author, &sk) {
493 Ok(n) if n > 0 => println!("Recorded {} merged patch(es).", n),
494 Ok(_) => {}
495 Err(e) => eprintln!("warning: merge scan failed: {}", e),
496 }
497
487 // Step 3: Push collab refs individually 498 // Step 3: Push collab refs individually
488 println!("Pushing to '{}'...", remote_name); 499 println!("Pushing to '{}'...", remote_name);
489 let refs_to_push = collect_push_refs(&repo)?; 500 let refs_to_push = collect_push_refs(&repo)?;
@@ -530,6 +541,11 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
530 cleanup_sync_refs(&repo)?; 541 cleanup_sync_refs(&repo)?;
531 542
532 println!("Sync complete for '{}'.", remote_name); 543 println!("Sync complete for '{}'.", remote_name);
544
545 // A closing hint, not a decision: patches whose head is reachable from
546 // their base tip but which carry no `PatchMerge`. Reachability cannot see a
547 // squash, so its silence proves nothing — and it writes nothing.
548 crate::merge_scan::print_merge_hints(&repo);
533 Ok(()) 549 Ok(())
534 } 550 }
535 551
src/trailer.rs
Old New
@@ -0,0 +1,129 @@
1 //! Git trailer parsing, shared by everything that reads a `<Token>: <value>`
2 //! line out of a commit message.
3 //!
4 //! Two features use it: `Issue:` trailers link commits to issues
5 //! (`commit_link`), and `Patch:` trailers record that a patch landed on its
6 //! base branch (`merge_scan`). They differ only in the token, so they share one
7 //! parser and one set of tests (`tests/trailer_test.rs`) — a fix to the block
8 //! semantics or the value rules cannot land for one and miss the other.
9 //!
10 //! See: docs/superpowers/specs/2026-04-12-commit-issue-link-design.md
11 //! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
12
13 /// Parse `<token>: <value>` trailers out of a commit message.
14 ///
15 /// Returns the values in order of appearance. Follows git's own trailer-block
16 /// semantics: only the final paragraph is considered, and *every* non-empty
17 /// line in it must be trailer-shaped (a `token: value` line) for the paragraph
18 /// to qualify. Any prose line in the final paragraph disqualifies the whole
19 /// paragraph — this prevents false positives like `"Thanks Bob.\nIssue: abc"`
20 /// in commit bodies.
21 ///
22 /// The key match is case-insensitive. The value must be a single
23 /// non-whitespace token followed by optional trailing whitespace and
24 /// end-of-line; values like `abc fixes thing` are rejected so that loose
25 /// commentary never becomes a silent prefix lookup that warns every sync
26 /// forever.
27 pub fn parse_trailers(message: &str, token: &str) -> Vec<String> {
28 // 1. Split into paragraphs (blank-line separated), preserving order.
29 // Trim trailing whitespace from each line for the trailer-shape check,
30 // but keep enough structure to recognize blank lines.
31 let lines: Vec<&str> = message.lines().collect();
32
33 // 2. Find the last paragraph: the longest tail slice that contains at
34 // least one non-empty line and has no blank line *before* its first
35 // non-empty line in the tail.
36 //
37 // Walking from the end: skip trailing blank/whitespace-only lines,
38 // then collect lines until we hit a blank line.
39 let mut end = lines.len();
40 while end > 0 && lines[end - 1].trim().is_empty() {
41 end -= 1;
42 }
43 if end == 0 {
44 return Vec::new();
45 }
46 let mut start = end;
47 while start > 0 && !lines[start - 1].trim().is_empty() {
48 start -= 1;
49 }
50 let paragraph = &lines[start..end];
51
52 // 3. Validate every non-empty line in the paragraph is trailer-shaped.
53 for line in paragraph {
54 if line.trim().is_empty() {
55 continue;
56 }
57 if !is_trailer_shaped(line) {
58 return Vec::new();
59 }
60 }
61
62 // 4. Extract the values whose key is `token`.
63 let mut out = Vec::new();
64 for line in paragraph {
65 if let Some(value) = match_trailer_line(line, token) {
66 out.push(value);
67 }
68 }
69 out
70 }
71
72 /// Returns true if a line looks like a git trailer: `<token>: <value>`, where
73 /// token starts with a letter and consists of `[A-Za-z0-9-]`, and value is at
74 /// least one non-whitespace character.
75 fn is_trailer_shaped(line: &str) -> bool {
76 let trimmed = line.trim_start();
77 let Some(colon_pos) = trimmed.find(':') else {
78 return false;
79 };
80 // Use trim_end() so that `ISSUE : abc` is recognized as the token `ISSUE`
81 // — matching what `match_trailer_line` does. Without this, the space before
82 // the colon would disqualify the line and make the whole paragraph fail
83 // the trailer-shape check.
84 let token = trimmed[..colon_pos].trim_end();
85 if token.is_empty() {
86 return false;
87 }
88 let mut chars = token.chars();
89 let first = chars.next().unwrap();
90 if !first.is_ascii_alphabetic() {
91 return false;
92 }
93 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') {
94 return false;
95 }
96 let value = trimmed[colon_pos + 1..].trim();
97 !value.is_empty()
98 }
99
100 /// If `line` is a `<token>: <value>` trailer with exactly one non-whitespace
101 /// token in its value, returns the value. Otherwise returns None.
102 fn match_trailer_line(line: &str, token: &str) -> Option<String> {
103 let trimmed = line.trim_start();
104 let colon_pos = trimmed.find(':')?;
105 let key = trimmed[..colon_pos].trim_end();
106 if !key.eq_ignore_ascii_case(token) {
107 return None;
108 }
109 let value_region = &trimmed[colon_pos + 1..];
110 let value = value_region.trim();
111 if value.is_empty() {
112 return None;
113 }
114 // Reject values with interior whitespace: `abc fixes thing` must not
115 // parse to `abc` silently — it must parse to nothing so the user sees
116 // that their commentary is being ignored. This matters more for `Patch:`
117 // than for `Issue:`, because there the silent reading would record a
118 // merge under an id the author never wrote on its own.
119 if value.split_whitespace().count() != 1 {
120 return None;
121 }
122 Some(value.to_string())
123 }
124
125 /// The token `Issue:` trailers use.
126 pub const ISSUE_TOKEN: &str = "issue";
127
128 /// The token `Patch:` trailers use.
129 pub const PATCH_TOKEN: &str = "patch";
src/tui/widgets.rs
Old New
@@ -21,7 +21,7 @@ pub(crate) fn action_type_label(action: &Action) -> &str {
21 Action::PatchComment { .. } => "Patch Comment", 21 Action::PatchComment { .. } => "Patch Comment",
22 Action::PatchInlineComment { .. } => "Inline Comment", 22 Action::PatchInlineComment { .. } => "Inline Comment",
23 Action::PatchClose { .. } => "Patch Close", 23 Action::PatchClose { .. } => "Patch Close",
24 Action::PatchMerge => "Patch Merge", 24 Action::PatchMerge { .. } => "Patch Merge",
25 Action::Merge => "Merge", 25 Action::Merge => "Merge",
26 Action::IssueEdit { .. } => "Issue Edit", 26 Action::IssueEdit { .. } => "Issue Edit",
27 Action::IssueLabel { .. } => "Issue Label", 27 Action::IssueLabel { .. } => "Issue Label",
@@ -129,7 +129,12 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str
129 Action::IssueCommitLink { commit } => { 129 Action::IssueCommitLink { commit } => {
130 detail.push_str(&format!("\nCommit: {}\n", commit)); 130 detail.push_str(&format!("\nCommit: {}\n", commit));
131 } 131 }
132 Action::IssueReopen | Action::PatchMerge | Action::Merge => {} 132 Action::PatchMerge { commit } => {
133 if !commit.is_empty() {
134 detail.push_str(&format!("\nMerged as: {}\n", commit));
135 }
136 }
137 Action::IssueReopen | Action::Merge => {}
133 } 138 }
134 139
135 detail 140 detail
tests/adversarial_test.rs
Old New
@@ -553,7 +553,9 @@ fn arb_action() -> impl Strategy<Value = Action> {
553 base: None, 553 base: None,
554 }), 554 }),
555 (".*",).prop_map(|(body,)| Action::PatchComment { body }), 555 (".*",).prop_map(|(body,)| Action::PatchComment { body }),
556 Just(Action::PatchMerge), 556 Just(Action::PatchMerge {
557 commit: "0000000000000000000000000000000000000000".to_string(),
558 }),
557 Just(Action::Merge), 559 Just(Action::Merge),
558 ] 560 ]
559 } 561 }
tests/cli_test.rs
Old New
@@ -733,12 +733,10 @@ fn test_patch_list_label_all_flag_isolated_from_archived() {
733 // archived" patch (archiving happens only inside `close`), so that half 733 // archived" patch (archiving happens only inside `close`), so that half
734 // of the pair genuinely cannot be isolated under current semantics. 734 // of the pair genuinely cannot be isolated under current semantics.
735 // 735 //
736 // But the other half can: a patch auto-detected as Merged (its branch 736 // But the other half can: a patch recorded as merged by `patch merge`
737 // fast-forwarded into base *outside* git-collab, which 737 // becomes non-open without ever being archived -- archiving is something
738 // `PatchState::check_auto_merge` picks up on read) becomes non-open 738 // only `close` does. That gives a labeled, non-open, still-*active* patch,
739 // without ever being archived -- archiving is something only `close` 739 // which isolates --all: visible with --all alone, hidden without it, and
740 // does. That gives a labeled, non-open, still-*active* patch, which
741 // isolates --all: visible with --all alone, hidden without it, and
742 // --archived is irrelevant either way since it was never archived. 740 // --archived is irrelevant either way since it was never archived.
743 let repo = TestRepo::new("Alice", "alice@example.com"); 741 let repo = TestRepo::new("Alice", "alice@example.com");
744 742
@@ -752,9 +750,10 @@ fn test_patch_list_label_all_flag_isolated_from_archived() {
752 .to_string(); 750 .to_string();
753 repo.run_ok(&["patch", "label", &id, "bug"]); 751 repo.run_ok(&["patch", "label", &id, "bug"]);
754 752
755 // Simulate `git merge merge-me` on main, outside of git-collab. 753 // Merge with plain git, then record it.
756 repo.git(&["checkout", "main"]); 754 repo.git(&["checkout", "main"]);
757 repo.git(&["merge", "--ff-only", "merge-me"]); 755 repo.git(&["merge", "--ff-only", "merge-me"]);
756 repo.run_ok(&["patch", "merge", &id]);
758 757
759 let out = repo.run_ok(&["patch", "list", "--label", "bug"]); 758 let out = repo.run_ok(&["patch", "list", "--label", "bug"]);
760 assert!(!out.contains("Merged labeled")); 759 assert!(!out.contains("Merged labeled"));
@@ -960,7 +959,7 @@ fn test_patch_close() {
960 } 959 }
961 960
962 #[test] 961 #[test]
963 fn test_patch_auto_detect_merge_on_git_merge() { 962 fn test_git_merge_shows_a_hint_and_patch_merge_records_it() {
964 let repo = TestRepo::new("Alice", "alice@example.com"); 963 let repo = TestRepo::new("Alice", "alice@example.com");
965 964
966 // Create a feature branch ahead of main 965 // Create a feature branch ahead of main
@@ -970,14 +969,19 @@ fn test_patch_auto_detect_merge_on_git_merge() {
970 969
971 // Create patch pointing at the feature branch 970 // Create patch pointing at the feature branch
972 let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "-B", "feature"]); 971 let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "-B", "feature"]);
973 let id = out.trim().strip_prefix("Created patch ").unwrap(); 972 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
974 973
975 // Merge via git directly 974 // Merge via git directly
976 repo.git(&["merge", "feature"]); 975 repo.git(&["merge", "feature"]);
977 976
978 // Patch should auto-detect as merged 977 // Reachability makes the patch *look* merged, with a question mark: it
979 let out = repo.run_ok(&["patch", "show", id]); 978 // cannot see a squash, so it never gets to decide.
980 assert!(out.contains("[merged]")); 979 let out = repo.run_ok(&["patch", "show", &id]);
980 assert!(out.contains("[merged?]"), "{}", out);
981
982 repo.run_ok(&["patch", "merge", &id]);
983 let out = repo.run_ok(&["patch", "show", &id]);
984 assert!(out.contains("[merged]") && !out.contains("[merged?]"), "{}", out);
981 } 985 }
982 986
983 #[test] 987 #[test]
@@ -1283,11 +1287,13 @@ fn test_full_patch_review_cycle() {
1283 // Approve 1287 // Approve
1284 repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]); 1288 repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]);
1285 1289
1286 // Merge via git 1290 // Merge via git, then record it. Merging stays plain git; git-collab
1291 // records that it happened rather than deriving it.
1287 repo.git(&["checkout", "main"]); 1292 repo.git(&["checkout", "main"]);
1288 repo.git(&["merge", "feature"]); 1293 repo.git(&["merge", "feature"]);
1294 repo.run_ok(&["patch", "merge", &id]);
1289 1295
1290 // Verify final state — auto-detected as merged 1296 // Verify final state
1291 let out = repo.run_ok(&["patch", "show", &id]); 1297 let out = repo.run_ok(&["patch", "show", &id]);
1292 assert!(out.contains("[merged]")); 1298 assert!(out.contains("[merged]"));
1293 assert!(out.contains("Added documentation")); 1299 assert!(out.contains("Added documentation"));
tests/collab_test.rs
Old New
@@ -1023,14 +1023,15 @@ fn test_resolve_head_with_oid_string() {
1023 } 1023 }
1024 1024
1025 // --------------------------------------------------------------------------- 1025 // ---------------------------------------------------------------------------
1026 // Phase 5: US3 — Merge auto-detection 1026 // Phase 5: US3 — Merge detection, demoted to a hint
1027 // --------------------------------------------------------------------------- 1027 // ---------------------------------------------------------------------------
1028 1028
1029 #[test] 1029 #[test]
1030 fn test_auto_detect_merged_patch_via_git_merge() { 1030 fn test_reachability_is_a_hint_and_does_not_set_the_status() {
1031 // When a user merges the patch branch into the base branch manually 1031 // Merging the patch branch into the base branch with plain git makes the
1032 // (using git merge), PatchState should auto-detect that the patch 1032 // patch *look* merged, and that is all it does. Merged state is recorded
1033 // is merged without needing `patch merge`. 1033 // by an event, never derived: reachability cannot see a squash, so its
1034 // verdict is a suggestion in one direction and silence in the other.
1034 let cfg = ScopedTestConfig::new(); 1035 let cfg = ScopedTestConfig::new();
1035 cfg.ensure_signing_key(); 1036 cfg.ensure_signing_key();
1036 let tmp = TempDir::new().unwrap(); 1037 let tmp = TempDir::new().unwrap();
@@ -1055,12 +1056,29 @@ fn test_auto_detect_merged_patch_via_git_merge() {
1055 repo.reference("refs/heads/main", feat_tip, true, "manual merge") 1056 repo.reference("refs/heads/main", feat_tip, true, "manual merge")
1056 .unwrap(); 1057 .unwrap();
1057 1058
1058 // Now PatchState should auto-detect that it's merged 1059 // The patch now looks merged, and is still Open until someone records it.
1059 let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); 1060 let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
1061 assert!(state.looks_merged(&repo), "the hint should fire");
1060 assert_eq!( 1062 assert_eq!(
1061 state.status, 1063 state.status,
1062 PatchStatus::Merged, 1064 PatchStatus::Open,
1063 "should auto-detect merge" 1065 "the hint must not become the status"
1066 );
1067 assert_eq!(state.status_display(&repo), "merged?");
1068
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!(
1072 repo.refname_to_id(&ref_name).unwrap(),
1073 tip_before,
1074 "deriving state must not append to the DAG"
1075 );
1076 patch::merge(&repo, &id, None, true).unwrap();
1077 let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap();
1078 assert_eq!(state.status, PatchStatus::Merged);
1079 assert!(
1080 !state.looks_merged(&repo),
1081 "a recorded merge is a fact, not a hint"
1064 ); 1082 );
1065 } 1083 }
1066 1084
@@ -1094,10 +1112,12 @@ fn test_auto_detect_merged_patch_deleted_branch() {
1094 } 1112 }
1095 1113
1096 #[test] 1114 #[test]
1097 fn test_cache_does_not_defeat_auto_detect_merge() { 1115 fn test_a_cache_hit_neither_hides_nor_writes_the_merge_hint() {
1098 // Regression: from_ref() returned cached Open status even after the 1116 // The cache used to have to be corrected on read, because status was
1099 // patch branch was merged into main, because the cache hit bypassed 1117 // derived from the base branch rather than folded from the DAG — a cache
1100 // the auto-detect merge logic that only ran in from_ref_uncached(). 1118 // entry keyed on the DAG tip could not see main move. Now status comes from
1119 // the DAG alone, so a cache hit is complete by construction, and the hint
1120 // is computed fresh at display time without touching the cache or the DAG.
1101 let cfg = ScopedTestConfig::new(); 1121 let cfg = ScopedTestConfig::new();
1102 cfg.ensure_signing_key(); 1122 cfg.ensure_signing_key();
1103 let tmp = TempDir::new().unwrap(); 1123 let tmp = TempDir::new().unwrap();
@@ -1122,12 +1142,19 @@ fn test_cache_does_not_defeat_auto_detect_merge() {
1122 repo.reference("refs/heads/main", feat_tip, true, "manual merge") 1142 repo.reference("refs/heads/main", feat_tip, true, "manual merge")
1123 .unwrap(); 1143 .unwrap();
1124 1144
1125 // Second call: cache hit (DAG tip unchanged), but should still detect merge 1145 // Second call: cache hit (DAG tip unchanged). The status is still the
1146 // recorded one, and the hint is available all the same.
1147 let tip_before = repo.refname_to_id(&ref_name).unwrap();
1126 let state2 = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); 1148 let state2 = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
1149 assert_eq!(state2.status, PatchStatus::Open);
1150 assert!(
1151 state2.looks_merged(&repo),
1152 "the hint is computed from the repo, not read out of the cache"
1153 );
1127 assert_eq!( 1154 assert_eq!(
1128 state2.status, 1155 repo.refname_to_id(&ref_name).unwrap(),
1129 PatchStatus::Merged, 1156 tip_before,
1130 "cached from_ref should detect merge" 1157 "a cached read must not append to the DAG"
1131 ); 1158 );
1132 } 1159 }
1133 1160
tests/commit_link_test.rs
Old New
@@ -186,99 +186,17 @@ fn issue_state_dedups_commit_links_by_sha_keeping_earliest() {
186 186
187 use git_collab::commit_link::parse_issue_trailers; 187 use git_collab::commit_link::parse_issue_trailers;
188 188
189 #[test] 189 // The trailer parser itself lives in `git_collab::trailer` and is shared with
190 fn parser_no_trailer_block() { 190 // `Patch:` trailers; its cases are in `tests/trailer_test.rs`, run against both
191 assert_eq!(parse_issue_trailers("Just a plain commit"), Vec::<String>::new()); 191 // tokens. All that is left to pin here is which token this entry point picks —
192 } 192 // the one thing the shared tests cannot see.
193
194 #[test]
195 fn parser_empty_message() {
196 assert_eq!(parse_issue_trailers(""), Vec::<String>::new());
197 }
198 193
199 #[test] 194 #[test]
200 fn parser_single_trailer_in_pure_block() { 195 fn parse_issue_trailers_reads_issue_and_not_patch() {
201 let msg = "Fix thing\n\nSome context in the body.\n\nIssue: abc"; 196 let msg = "subject\n\nIssue: abc\nPatch: def";
202 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]); 197 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
203 } 198 }
204 199
205 #[test]
206 fn parser_case_variants() {
207 let msg1 = "subject\n\nissue: abc";
208 let msg2 = "subject\n\nISSUE : abc";
209 let msg3 = "subject\n\n Issue: abc ";
210 assert_eq!(parse_issue_trailers(msg1), vec!["abc".to_string()]);
211 assert_eq!(parse_issue_trailers(msg2), vec!["abc".to_string()]);
212 assert_eq!(parse_issue_trailers(msg3), vec!["abc".to_string()]);
213 }
214
215 #[test]
216 fn parser_two_trailers_in_pure_block() {
217 let msg = "subject\n\nIssue: abc\nIssue: def";
218 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string(), "def".to_string()]);
219 }
220
221 #[test]
222 fn parser_issue_in_body_but_not_final_paragraph() {
223 let msg = "subject\n\nIssue: abc\n\nSigned-off-by: alice <a@example.com>";
224 // The final paragraph is the signed-off-by block, not the issue line.
225 // It's a valid trailer block (Signed-off-by is trailer-shaped), but it
226 // contains no Issue: key, so we extract nothing.
227 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
228 }
229
230 #[test]
231 fn parser_wrong_key() {
232 let msg = "subject\n\nIssues: abc";
233 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
234 }
235
236 #[test]
237 fn parser_prose_mention() {
238 let msg = "subject\n\nthis fixes issue abc in the body";
239 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
240 }
241
242 #[test]
243 fn parser_single_paragraph_whole_message_is_trailer_block() {
244 let msg = "Issue: abc";
245 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
246 }
247
248 #[test]
249 fn parser_mixed_final_paragraph_rejects_all() {
250 let msg = "subject\n\nThanks to Bob for the catch.\nIssue: a3f9";
251 // Final paragraph has a prose line, so it's not a trailer block and we
252 // extract nothing. This is the "false positive in prose" guard.
253 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
254 }
255
256 #[test]
257 fn parser_trailing_whitespace_paragraph_does_not_shadow_trailer_block() {
258 // The final paragraph is empty/whitespace, so the walk should fall back
259 // to the previous non-empty paragraph, which is a valid trailer block.
260 let msg = "subject\n\nIssue: abc\n\n \n";
261 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
262 }
263
264 #[test]
265 fn parser_pure_block_with_mixed_keys() {
266 let msg = "subject\n\nSigned-off-by: alice <a@example.com>\nIssue: abc";
267 assert_eq!(parse_issue_trailers(msg), vec!["abc".to_string()]);
268 }
269
270 #[test]
271 fn parser_rejects_value_with_trailing_garbage() {
272 let msg = "subject\n\nIssue: abc fixes thing";
273 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
274 }
275
276 #[test]
277 fn parser_rejects_empty_value() {
278 let msg = "subject\n\nIssue: ";
279 assert_eq!(parse_issue_trailers(msg), Vec::<String>::new());
280 }
281
282 use git_collab::commit_link::collect_linked_shas; 200 use git_collab::commit_link::collect_linked_shas;
283 201
284 #[test] 202 #[test]
tests/crdt_test.rs
Old New
@@ -362,7 +362,9 @@ fn concurrent_patch_close_merge_higher_clock_wins() {
362 let merge = Event { 362 let merge = Event {
363 timestamp: "2026-01-03T00:00:00Z".to_string(), 363 timestamp: "2026-01-03T00:00:00Z".to_string(),
364 author: bob(), 364 author: bob(),
365 action: Action::PatchMerge, 365 action: Action::PatchMerge {
366 commit: String::new(),
367 },
366 clock: 0, 368 clock: 0,
367 }; 369 };
368 dag::append_event(&repo, remote_ref, &merge, &sk).unwrap(); 370 dag::append_event(&repo, remote_ref, &merge, &sk).unwrap();
tests/merge_recording_test.rs
Old New
@@ -0,0 +1,637 @@
1 //! Merges are recorded as events, not derived on read.
2 //!
3 //! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
4 //!
5 //! Layer 3 (`patch merge`) and layer 2 (sync scanning `Patch:` trailers) are
6 //! covered here, along with the demotion of reachability detection to a hint
7 //! that never writes. Layer 1 (stamping the trailer — the `commit-msg` hook
8 //! and `patch create --stamp`) is deliberately not implemented, so every test
9 //! below writes the trailer by hand, which is exactly what a user without the
10 //! hook does.
11
12 mod common;
13
14 use std::process::Command;
15
16 use serde_json::Value;
17 use tempfile::TempDir;
18
19 use common::TestRepo;
20
21 // ---------------------------------------------------------------------------
22 // Harness
23 // ---------------------------------------------------------------------------
24
25 /// A `TestRepo` with a local bare `origin` it can sync against. Never a real
26 /// network remote. The returned `TempDir` owns the bare repo — keep it alive.
27 fn repo_with_origin() -> (TestRepo, TempDir) {
28 let bare = TempDir::new().unwrap();
29 let status = Command::new("git")
30 .args(["init", "--bare", "-b", "main"])
31 .arg(bare.path())
32 .status()
33 .unwrap();
34 assert!(status.success());
35
36 let repo = TestRepo::new("Alice", "alice@example.com");
37 repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
38 repo.git(&["push", "-u", "origin", "main"]);
39 repo.run_ok(&["init"]);
40 (repo, bare)
41 }
42
43 fn show_json(repo: &TestRepo, id: &str) -> Value {
44 serde_json::from_str(&repo.run_ok(&["patch", "show", id, "--json"])).unwrap()
45 }
46
47 fn issue_json(repo: &TestRepo, id: &str) -> Value {
48 serde_json::from_str(&repo.run_ok(&["issue", "show", id, "--json"])).unwrap()
49 }
50
51 /// Create a branch with one commit whose message carries `Patch: <id>`, and a
52 /// patch for it. Returns the patch's short id. The trailer has to be written
53 /// before the patch exists to know the id, so this creates the patch first and
54 /// then amends the trailer in — which is what `patch create --stamp` (layer 1,
55 /// out of scope) would do for the user.
56 fn patch_with_trailer(repo: &TestRepo, branch: &str, file: &str) -> String {
57 repo.git(&["checkout", "-b", branch]);
58 repo.commit_file(file, "content", &format!("work on {}", branch));
59 let out = repo.run_ok(&["patch", "create", "-t", branch, "-B", branch]);
60 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
61 stamp_head(repo, &short);
62 repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
63 repo.git(&["checkout", "main"]);
64 short
65 }
66
67 /// Amend HEAD's message to carry a `Patch:` trailer.
68 fn stamp_head(repo: &TestRepo, patch_id: &str) {
69 let message = repo.git(&["log", "-1", "--format=%B"]);
70 let stamped = format!("{}\n\nPatch: {}\n", message.trim_end(), patch_id);
71 repo.git(&["commit", "--amend", "-m", &stamped]);
72 }
73
74 /// Squash-merge `branch` into the current branch, keeping the source commit's
75 /// message (and therefore its trailer) verbatim.
76 fn squash_merge_keeping_message(repo: &TestRepo, branch: &str) {
77 let message = repo.git(&["log", "-1", "--format=%B", branch]);
78 repo.git(&["merge", "--squash", branch]);
79 repo.git(&["commit", "-m", message.trim_end()]);
80 }
81
82 fn count_events(repo: &TestRepo, ref_name: &str) -> usize {
83 repo.git(&["rev-list", "--count", ref_name])
84 .trim()
85 .parse()
86 .unwrap()
87 }
88
89 fn patch_events_ref(repo: &TestRepo, short: &str) -> String {
90 let out = repo.git(&["for-each-ref", "--format=%(refname)", "refs/collab/patches/"]);
91 out.lines()
92 .find(|r| r.contains(short) && r.ends_with("/events"))
93 .unwrap_or_else(|| panic!("no events ref for {} in {}", short, out))
94 .to_string()
95 }
96
97 // ---------------------------------------------------------------------------
98 // The event
99 // ---------------------------------------------------------------------------
100
101 #[test]
102 fn patch_merge_event_carries_the_commit_that_landed_it() {
103 use git_collab::event::{Action, Author, Event};
104
105 let event = Event {
106 timestamp: "2026-08-09T12:00:00Z".to_string(),
107 author: Author {
108 name: "Alice".to_string(),
109 email: "alice@example.com".to_string(),
110 },
111 action: Action::PatchMerge {
112 commit: "4b2e1cd0123456789012345678901234567890ab".to_string(),
113 },
114 clock: 4,
115 };
116
117 let json = serde_json::to_string(&event).unwrap();
118 assert!(json.contains("\"type\":\"patch.merge\""), "{}", json);
119 assert!(
120 json.contains("\"commit\":\"4b2e1cd0123456789012345678901234567890ab\""),
121 "{}",
122 json
123 );
124
125 let parsed: Event = serde_json::from_str(&json).unwrap();
126 match parsed.action {
127 Action::PatchMerge { commit } => {
128 assert_eq!(commit, "4b2e1cd0123456789012345678901234567890ab")
129 }
130 other => panic!("expected PatchMerge, got {:?}", other),
131 }
132 }
133
134 #[test]
135 fn a_patch_merge_written_before_the_commit_field_existed_still_reads() {
136 // Signatures are checked by re-serializing, so a legacy `patch.merge` must
137 // both deserialize and round-trip back to the bytes its signer produced.
138 use git_collab::event::{Action, Event};
139
140 let raw = r#"{"timestamp":"2026-01-01T00:00:00Z","author":{"name":"A","email":"a@b.c"},"action":{"type":"patch.merge"},"clock":2}"#;
141 let parsed: Event = serde_json::from_str(raw).unwrap();
142 match &parsed.action {
143 Action::PatchMerge { commit } => assert_eq!(commit, ""),
144 other => panic!("expected PatchMerge, got {:?}", other),
145 }
146 let round_tripped = serde_json::to_string(&parsed).unwrap();
147 assert!(
148 !round_tripped.contains("\"commit\""),
149 "an empty commit must not be written back, or legacy signatures break: {}",
150 round_tripped
151 );
152 }
153
154 // ---------------------------------------------------------------------------
155 // Layer 3: `patch merge`
156 // ---------------------------------------------------------------------------
157
158 #[test]
159 fn patch_merge_records_the_merge() {
160 let repo = TestRepo::new("Alice", "alice@example.com");
161 repo.git(&["checkout", "-b", "feat"]);
162 repo.commit_file("a.txt", "x", "the patch");
163 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
164 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
165 repo.git(&["checkout", "main"]);
166 repo.git(&["merge", "--ff-only", "feat"]);
167
168 repo.run_ok(&["patch", "merge", &short]);
169
170 let json = show_json(&repo, &short);
171 assert_eq!(json["status"], "merged");
172 }
173
174 #[test]
175 fn patch_merge_records_the_base_tip_as_the_landing_commit() {
176 let repo = TestRepo::new("Alice", "alice@example.com");
177 repo.git(&["checkout", "-b", "feat"]);
178 repo.commit_file("a.txt", "x", "the patch");
179 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
180 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
181 repo.git(&["checkout", "main"]);
182 squash_merge_keeping_message(&repo, "feat");
183 let landed = repo.git(&["rev-parse", "main"]).trim().to_string();
184
185 repo.run_ok(&["patch", "merge", &short]);
186
187 let log = repo.run_ok(&["log"]);
188 assert!(
189 log.contains(&landed[..7]),
190 "the recorded merge commit should be the base tip {}: {}",
191 &landed[..7],
192 log
193 );
194 }
195
196 #[test]
197 fn merged_state_survives_branch_deletion_and_a_cold_cache() {
198 // The case that fails without recording: deleting the merged branch used
199 // to send the patch back to Open the moment the state cache was cleared.
200 let repo = TestRepo::new("Alice", "alice@example.com");
201 repo.git(&["checkout", "-b", "feat"]);
202 repo.commit_file("a.txt", "x", "the patch");
203 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
204 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
205 repo.git(&["checkout", "main"]);
206 repo.git(&["merge", "--ff-only", "feat"]);
207 repo.run_ok(&["patch", "merge", &short]);
208
209 repo.git(&["branch", "-D", "feat"]);
210 let cache = repo.dir.path().join(".git").join("collab").join("cache");
211 if cache.exists() {
212 std::fs::remove_dir_all(&cache).unwrap();
213 }
214
215 assert_eq!(show_json(&repo, &short)["status"], "merged");
216 }
217
218 #[test]
219 fn patch_merge_twice_records_nothing_the_second_time() {
220 let repo = TestRepo::new("Alice", "alice@example.com");
221 repo.git(&["checkout", "-b", "feat"]);
222 repo.commit_file("a.txt", "x", "the patch");
223 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
224 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
225 repo.git(&["checkout", "main"]);
226 repo.git(&["merge", "--ff-only", "feat"]);
227
228 repo.run_ok(&["patch", "merge", &short]);
229 let events_ref = patch_events_ref(&repo, &short);
230 let after_first = count_events(&repo, &events_ref);
231
232 let out = repo.run_ok(&["patch", "merge", &short]);
233 assert!(
234 out.contains("already"),
235 "a second merge should say so, got: {}",
236 out
237 );
238 assert_eq!(
239 count_events(&repo, &events_ref),
240 after_first,
241 "recording a merge twice must append nothing the second time"
242 );
243 }
244
245 #[test]
246 fn patch_merge_closes_the_fixed_issue() {
247 let repo = TestRepo::new("Alice", "alice@example.com");
248 let issue = repo.issue_open("Broken thing");
249 repo.git(&["checkout", "-b", "feat"]);
250 repo.commit_file("a.txt", "x", "the fix");
251 let out = repo.run_ok(&[
252 "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
253 ]);
254 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
255 repo.git(&["checkout", "main"]);
256 repo.git(&["merge", "--ff-only", "feat"]);
257
258 repo.run_ok(&["patch", "merge", &short]);
259
260 assert_eq!(issue_json(&repo, &issue)["status"], "closed");
261 }
262
263 // ---------------------------------------------------------------------------
264 // Layer 2: sync scans `Patch:` trailers on the patch's own base branch
265 // ---------------------------------------------------------------------------
266
267 #[test]
268 fn sync_records_a_squash_merge_whose_trailer_survived() {
269 let (repo, _bare) = repo_with_origin();
270 let short = patch_with_trailer(&repo, "feat", "a.txt");
271
272 squash_merge_keeping_message(&repo, "feat");
273 assert_eq!(
274 show_json(&repo, &short)["status"],
275 "open",
276 "precondition: reachability cannot see a squash"
277 );
278
279 repo.run_ok(&["sync"]);
280
281 assert_eq!(show_json(&repo, &short)["status"], "merged");
282 }
283
284 #[test]
285 fn sync_does_not_record_a_squash_whose_message_was_rewritten() {
286 let (repo, _bare) = repo_with_origin();
287 let short = patch_with_trailer(&repo, "feat", "a.txt");
288
289 repo.git(&["merge", "--squash", "feat"]);
290 repo.git(&["commit", "-m", "a message with no trailer at all"]);
291
292 repo.run_ok(&["sync"]);
293 assert_eq!(show_json(&repo, &short)["status"], "open");
294
295 // Layer 3 is what covers this.
296 repo.run_ok(&["patch", "merge", &short]);
297 assert_eq!(show_json(&repo, &short)["status"], "merged");
298 }
299
300 #[test]
301 fn a_second_sync_after_a_recorded_merge_emits_nothing() {
302 let (repo, _bare) = repo_with_origin();
303 let short = patch_with_trailer(&repo, "feat", "a.txt");
304 squash_merge_keeping_message(&repo, "feat");
305
306 repo.run_ok(&["sync"]);
307 let events_ref = patch_events_ref(&repo, &short);
308 let after_first = count_events(&repo, &events_ref);
309
310 repo.run_ok(&["sync"]);
311 assert_eq!(
312 count_events(&repo, &events_ref),
313 after_first,
314 "the trailer is still in history; the second scan must emit nothing"
315 );
316 }
317
318 #[test]
319 fn a_trailer_on_a_branch_that_is_not_the_patches_base_does_not_record_a_merge() {
320 let (repo, _bare) = repo_with_origin();
321 let short = patch_with_trailer(&repo, "feat", "a.txt");
322
323 // Land the patch on a *different* branch. `main` — the patch's base — never
324 // sees the trailer.
325 repo.git(&["checkout", "-b", "someone-elses-branch"]);
326 squash_merge_keeping_message(&repo, "feat");
327 repo.git(&["checkout", "main"]);
328
329 repo.run_ok(&["sync"]);
330 assert_eq!(show_json(&repo, &short)["status"], "open");
331 }
332
333 #[test]
334 fn an_unknown_patch_id_in_a_trailer_warns_and_leaves_sync_successful() {
335 let (repo, _bare) = repo_with_origin();
336 // A base branch with no open patches is not walked at all, so there has to
337 // be something for the walk to be for.
338 let open = patch_with_trailer(&repo, "feat", "a.txt");
339 repo.commit_file(
340 "x.txt",
341 "x",
342 "land something\n\nPatch: ffffffffffffffffffffffff",
343 );
344
345 let out = repo.run(&["sync"]);
346 assert!(out.status.success(), "sync must not fail on a bad trailer");
347 let stderr = String::from_utf8(out.stderr).unwrap();
348 assert!(
349 stderr.contains("ffffffff"),
350 "expected a warning naming the unresolvable id, got: {}",
351 stderr
352 );
353 assert_eq!(show_json(&repo, &open)["status"], "open");
354 }
355
356 /// Give the patch whose events ref is `events_ref` a second ref under a
357 /// near-identical id, so any 8-character prefix of the real id matches two
358 /// patches. Random ids practically never collide, so ambiguity has to be
359 /// constructed.
360 fn duplicate_patch_ref_with_colliding_id(repo: &TestRepo, events_ref: &str) -> String {
361 let id = events_ref
362 .strip_prefix("refs/collab/patches/")
363 .and_then(|r| r.strip_suffix("/events"))
364 .unwrap();
365 // Flip the last hex digit: everything up to it still matches.
366 let last = id.chars().last().unwrap();
367 let replacement = if last == '0' { '1' } else { '0' };
368 let twin: String = id[..id.len() - 1].chars().chain([replacement]).collect();
369 let tip = repo.git(&["rev-parse", events_ref]).trim().to_string();
370 repo.git(&[
371 "update-ref",
372 &format!("refs/collab/patches/{}/events", twin),
373 &tip,
374 ]);
375 twin
376 }
377
378 #[test]
379 fn an_ambiguous_patch_prefix_in_a_trailer_warns_and_leaves_sync_successful() {
380 let (repo, _bare) = repo_with_origin();
381 let short = patch_with_trailer(&repo, "feat", "a.txt");
382 let events_ref = patch_events_ref(&repo, &short);
383 duplicate_patch_ref_with_colliding_id(&repo, &events_ref);
384
385 repo.commit_file("x.txt", "x", &format!("land something\n\nPatch: {}", short));
386
387 let out = repo.run(&["sync"]);
388 assert!(
389 out.status.success(),
390 "sync must not fail on an ambiguous trailer"
391 );
392 let stderr = String::from_utf8(out.stderr).unwrap();
393 assert!(
394 stderr.contains("ambiguous"),
395 "expected an ambiguity warning, got: {}",
396 stderr
397 );
398 assert_eq!(count_events(&repo, &events_ref), 2, "nothing was recorded");
399 }
400
401 #[test]
402 fn a_trailer_naming_an_archived_patch_warns_and_leaves_sync_successful() {
403 let (repo, _bare) = repo_with_origin();
404 // One patch stays open so `main` is walked at all; the other is archived.
405 let _open = patch_with_trailer(&repo, "still-open", "b.txt");
406 let short = patch_with_trailer(&repo, "feat", "a.txt");
407 // `patch close` archives the ref.
408 repo.run_ok(&["patch", "close", &short]);
409 squash_merge_keeping_message(&repo, "feat");
410
411 let out = repo.run(&["sync"]);
412 assert!(
413 out.status.success(),
414 "sync must not fail on an archived patch"
415 );
416 let stderr = String::from_utf8(out.stderr).unwrap();
417 assert!(
418 stderr.contains("archived"),
419 "expected an archived-patch warning, got: {}",
420 stderr
421 );
422 assert_eq!(show_json(&repo, &short)["status"], "closed");
423 }
424
425 // ---------------------------------------------------------------------------
426 // `--fixes` closes its issue in the same operation that records the merge
427 // ---------------------------------------------------------------------------
428
429 #[test]
430 fn fixes_closes_the_issue_exactly_once_across_repeated_syncs() {
431 let (repo, _bare) = repo_with_origin();
432 let issue = repo.issue_open("Broken thing");
433
434 repo.git(&["checkout", "-b", "feat"]);
435 repo.commit_file("a.txt", "x", "the fix");
436 let out = repo.run_ok(&[
437 "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
438 ]);
439 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
440 stamp_head(&repo, &short);
441 repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
442 repo.git(&["checkout", "main"]);
443 squash_merge_keeping_message(&repo, "feat");
444
445 repo.run_ok(&["sync"]);
446 assert_eq!(issue_json(&repo, &issue)["status"], "closed");
447
448 let issue_ref = repo
449 .git(&[
450 "for-each-ref",
451 "--format=%(refname)",
452 "refs/collab/archive/issues/",
453 ])
454 .lines()
455 .find(|r| r.contains(&issue))
456 .unwrap()
457 .to_string();
458 let after_first = count_events(&repo, &issue_ref);
459
460 repo.run_ok(&["sync"]);
461 repo.run_ok(&["sync"]);
462 assert_eq!(
463 count_events(&repo, &issue_ref),
464 after_first,
465 "repeated syncs must not append a second IssueClose"
466 );
467 }
468
469 #[test]
470 fn a_fixes_issue_that_is_already_closed_gets_no_further_close() {
471 let (repo, _bare) = repo_with_origin();
472 let issue = repo.issue_open("Broken thing");
473
474 repo.git(&["checkout", "-b", "feat"]);
475 repo.commit_file("a.txt", "x", "the fix");
476 let out = repo.run_ok(&[
477 "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
478 ]);
479 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
480 stamp_head(&repo, &short);
481 repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
482 repo.git(&["checkout", "main"]);
483
484 repo.issue_close(&issue);
485 let issue_ref = repo
486 .git(&[
487 "for-each-ref",
488 "--format=%(refname)",
489 "refs/collab/archive/issues/",
490 ])
491 .lines()
492 .find(|r| r.contains(&issue))
493 .unwrap()
494 .to_string();
495 let before = count_events(&repo, &issue_ref);
496
497 squash_merge_keeping_message(&repo, "feat");
498 repo.run_ok(&["sync"]);
499
500 assert_eq!(show_json(&repo, &short)["status"], "merged");
501 assert_eq!(
502 count_events(&repo, &issue_ref),
503 before,
504 "the merge is recorded, but an already-closed issue gets no IssueClose"
505 );
506 }
507
508 #[test]
509 fn a_close_that_did_not_happen_with_the_merge_is_retried_by_the_next_scan() {
510 // The merge and the close write to different refs, so they are not atomic.
511 // If the close does not land, the merge still stands — and the next scan,
512 // seeing a merged patch with an open `fixes` issue, must retry rather than
513 // leave the issue open forever. Simulated here by recording the merge with
514 // `patch merge --no-close`, which is precisely the "merge landed, close did
515 // not" state.
516 let (repo, _bare) = repo_with_origin();
517 let issue = repo.issue_open("Broken thing");
518
519 repo.git(&["checkout", "-b", "feat"]);
520 repo.commit_file("a.txt", "x", "the fix");
521 let out = repo.run_ok(&[
522 "patch", "create", "-t", "The fix", "-B", "feat", "--fixes", &issue,
523 ]);
524 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
525 stamp_head(&repo, &short);
526 repo.run_ok(&["patch", "revise", &short, "-b", "stamped"]);
527 repo.git(&["checkout", "main"]);
528 squash_merge_keeping_message(&repo, "feat");
529
530 repo.run_ok(&["patch", "merge", &short, "--no-close"]);
531 assert_eq!(show_json(&repo, &short)["status"], "merged");
532 assert_eq!(
533 issue_json(&repo, &issue)["status"],
534 "open",
535 "precondition: the merge landed and the close did not"
536 );
537
538 repo.run_ok(&["sync"]);
539 assert_eq!(
540 issue_json(&repo, &issue)["status"],
541 "closed",
542 "the next scan must retry the close it could not make atomic"
543 );
544 }
545
546 // ---------------------------------------------------------------------------
547 // Reachability is a hint, and never writes
548 // ---------------------------------------------------------------------------
549
550 #[test]
551 fn a_reachable_patch_with_no_merge_event_shows_as_merged_with_a_question_mark() {
552 let repo = TestRepo::new("Alice", "alice@example.com");
553 repo.git(&["checkout", "-b", "feat"]);
554 repo.commit_file("a.txt", "x", "the patch");
555 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
556 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
557 repo.git(&["checkout", "main"]);
558 repo.git(&["merge", "--ff-only", "feat"]);
559
560 let list = repo.run_ok(&["patch", "list"]);
561 assert!(
562 list.contains("merged?"),
563 "a reachable patch with no recorded merge is a hint, not a fact: {}",
564 list
565 );
566 assert_eq!(
567 show_json(&repo, &short)["status"],
568 "open",
569 "the hint must not become the status"
570 );
571 }
572
573 #[test]
574 fn displaying_a_reachable_patch_writes_no_event() {
575 // The governing constraint: recording is never a side effect of a read.
576 let repo = TestRepo::new("Alice", "alice@example.com");
577 repo.git(&["checkout", "-b", "feat"]);
578 repo.commit_file("a.txt", "x", "the patch");
579 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
580 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
581 repo.git(&["checkout", "main"]);
582 repo.git(&["merge", "--ff-only", "feat"]);
583
584 let events_ref = patch_events_ref(&repo, &short);
585 let before = count_events(&repo, &events_ref);
586 let tip_before = repo.git(&["rev-parse", &events_ref]).trim().to_string();
587
588 repo.run_ok(&["patch", "list"]);
589 repo.run_ok(&["patch", "show", &short]);
590 repo.run_ok(&["patch", "show", &short, "--json"]);
591 repo.run_ok(&["patch", "log", &short]);
592
593 assert_eq!(count_events(&repo, &events_ref), before);
594 assert_eq!(
595 repo.git(&["rev-parse", &events_ref]).trim(),
596 tip_before,
597 "reading a patch must not move its ref"
598 );
599 }
600
601 #[test]
602 fn sync_names_the_patches_that_look_merged_but_are_not_recorded() {
603 let (repo, _bare) = repo_with_origin();
604 repo.git(&["checkout", "-b", "feat"]);
605 repo.commit_file("a.txt", "x", "the patch");
606 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
607 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
608 repo.git(&["checkout", "main"]);
609 repo.git(&["merge", "--ff-only", "feat"]);
610
611 let out = repo.run_ok(&["sync"]);
612 assert!(
613 out.contains(&short) && out.contains("patch merge"),
614 "sync should name the patch and the command that records it: {}",
615 out
616 );
617 }
618
619 #[test]
620 fn a_recorded_merge_is_not_reported_as_a_hint() {
621 let repo = TestRepo::new("Alice", "alice@example.com");
622 repo.git(&["checkout", "-b", "feat"]);
623 repo.commit_file("a.txt", "x", "the patch");
624 let out = repo.run_ok(&["patch", "create", "-t", "Feature", "-B", "feat"]);
625 let short = out.trim().strip_prefix("Created patch ").unwrap().to_string();
626 repo.git(&["checkout", "main"]);
627 repo.git(&["merge", "--ff-only", "feat"]);
628 repo.run_ok(&["patch", "merge", &short]);
629
630 let list = repo.run_ok(&["patch", "list", "--all"]);
631 assert!(list.contains("merged"), "{}", list);
632 assert!(
633 !list.contains("merged?"),
634 "a recorded merge is a fact, not a hint: {}",
635 list
636 );
637 }
tests/revision_refs_test.rs
Old New
@@ -59,6 +59,16 @@ fn show_json(repo: &TestRepo, id: &str) -> serde_json::Value {
59 serde_json::from_str(&out).unwrap() 59 serde_json::from_str(&out).unwrap()
60 } 60 }
61 61
62 /// Whether reachability detection fires for this patch.
63 ///
64 /// It is a hint, not a status: a merge is recorded by `Action::PatchMerge`, and
65 /// detection only ever *suggests* — it cannot see a squash, so its silence
66 /// proves nothing, and it never writes. `patch show` renders the suggestion as
67 /// `[merged?]`, which is what these tests read.
68 fn looks_merged(repo: &TestRepo, id: &str) -> bool {
69 repo.run_ok(&["patch", "show", id]).contains("[merged?")
70 }
71
62 /// Create a patch on a fresh branch holding one commit. Returns (short id, full 72 /// Create a patch on a fresh branch holding one commit. Returns (short id, full
63 /// id, tip commit). 73 /// id, tip commit).
64 fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> (String, String, String) { 74 fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> (String, String, String) {
@@ -334,51 +344,77 @@ fn two_branches_with_generated_names_get_their_own_patches() {
334 } 344 }
335 345
336 // =========================================================================== 346 // ===========================================================================
337 // Merge detection, anchored to the latest revision's base 347 // The merge hint, anchored to the latest revision's base
348 //
349 // These exercise reachability detection, which no longer decides the status —
350 // it only raises `merged?`. The discrimination it has to get right is the same
351 // either way, so the assertions moved from the status to the hint.
338 // =========================================================================== 352 // ===========================================================================
339 353
340 #[test] 354 #[test]
341 fn merging_the_base_branch_forward_marks_the_patch_merged() { 355 fn merging_the_base_branch_forward_makes_the_patch_look_merged() {
342 let repo = TestRepo::new("Alice", "alice@example.com"); 356 let repo = TestRepo::new("Alice", "alice@example.com");
343 let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); 357 let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
344 358
345 repo.git(&["checkout", "main"]); 359 repo.git(&["checkout", "main"]);
346 repo.git(&["merge", "--ff-only", "feat"]); 360 repo.git(&["merge", "--ff-only", "feat"]);
347 361
348 assert_eq!(show_json(&repo, &short)["status"], "merged"); 362 assert!(looks_merged(&repo, &short));
363 assert_eq!(
364 show_json(&repo, &short)["status"], "open",
365 "the hint is not the status"
366 );
349 } 367 }
350 368
351 #[test] 369 #[test]
352 fn an_unmerged_patch_stays_open() { 370 fn an_unmerged_patch_raises_no_hint() {
353 // Guards the failure mode where dropping `base_commit` leaves `base_moved` 371 // Guards the failure mode where dropping `base_commit` leaves `base_moved`
354 // permanently false — or permanently true — and merge detection silently 372 // permanently false — or permanently true — and the hint silently stops
355 // stops telling the truth. 373 // telling the truth.
356 let repo = TestRepo::new("Alice", "alice@example.com"); 374 let repo = TestRepo::new("Alice", "alice@example.com");
357 let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); 375 let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
358 376
359 repo.git(&["checkout", "main"]); 377 repo.git(&["checkout", "main"]);
360 repo.commit_file("unrelated.txt", "x", "main moves on its own"); 378 repo.commit_file("unrelated.txt", "x", "main moves on its own");
361 379
380 assert!(!looks_merged(&repo, &short));
362 assert_eq!(show_json(&repo, &short)["status"], "open"); 381 assert_eq!(show_json(&repo, &short)["status"], "open");
363 } 382 }
364 383
365 #[test] 384 #[test]
366 fn merge_detection_survives_deleting_the_source_branch() { 385 fn the_merge_hint_survives_deleting_the_source_branch() {
367 // Merge detection used to go through `refs/heads/<branch>` and no-op 386 // The hint used to go through `refs/heads/<branch>` and no-op silently when
368 // silently when it was absent, so deleting the branch after merging left 387 // it was absent, so deleting the branch after merging left the patch with
369 // the patch Open forever with no diagnostic. 388 // no diagnostic at all.
389 let repo = TestRepo::new("Alice", "alice@example.com");
390 let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
391
392 repo.git(&["checkout", "main"]);
393 repo.git(&["merge", "--ff-only", "feat"]);
394 repo.git(&["branch", "-D", "feat"]);
395
396 assert!(looks_merged(&repo, &short));
397 }
398
399 #[test]
400 fn a_recorded_merge_survives_deleting_the_source_branch() {
401 // The hint above is a convenience. This is the fact: once recorded, merged
402 // state does not depend on the branch, or on the base branch, existing at
403 // all — which is the case that used to revert to Open the moment the state
404 // cache went cold.
370 let repo = TestRepo::new("Alice", "alice@example.com"); 405 let repo = TestRepo::new("Alice", "alice@example.com");
371 let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); 406 let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt");
372 407
373 repo.git(&["checkout", "main"]); 408 repo.git(&["checkout", "main"]);
374 repo.git(&["merge", "--ff-only", "feat"]); 409 repo.git(&["merge", "--ff-only", "feat"]);
410 repo.run_ok(&["patch", "merge", &short]);
375 repo.git(&["branch", "-D", "feat"]); 411 repo.git(&["branch", "-D", "feat"]);
376 412
377 assert_eq!(show_json(&repo, &short)["status"], "merged"); 413 assert_eq!(show_json(&repo, &short)["status"], "merged");
378 } 414 }
379 415
380 #[test] 416 #[test]
381 fn merge_detection_reads_the_latest_revisions_base_not_the_first() { 417 fn the_merge_hint_reads_the_latest_revisions_base_not_the_first() {
382 // After a rebase the patch's base moves. Detection must compare against 418 // After a rebase the patch's base moves. Detection must compare against
383 // where the *latest* revision stood, not where revision 1 did. 419 // where the *latest* revision stood, not where revision 1 did.
384 let repo = TestRepo::new("Alice", "alice@example.com"); 420 let repo = TestRepo::new("Alice", "alice@example.com");
@@ -390,14 +426,11 @@ fn merge_detection_reads_the_latest_revisions_base_not_the_first() {
390 repo.git(&["rebase", "main"]); 426 repo.git(&["rebase", "main"]);
391 repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]); 427 repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]);
392 428
393 assert_eq!( 429 assert!(!looks_merged(&repo, &short), "rebasing is not merging");
394 show_json(&repo, &short)["status"], "open",
395 "rebasing is not merging"
396 );
397 430
398 repo.git(&["checkout", "main"]); 431 repo.git(&["checkout", "main"]);
399 repo.git(&["merge", "--ff-only", "feat"]); 432 repo.git(&["merge", "--ff-only", "feat"]);
400 assert_eq!(show_json(&repo, &short)["status"], "merged"); 433 assert!(looks_merged(&repo, &short));
401 } 434 }
402 435
403 fn tree_of(repo: &TestRepo, commit: &str) -> String { 436 fn tree_of(repo: &TestRepo, commit: &str) -> String {
@@ -465,9 +498,9 @@ fn a_migrated_patch_merged_by_exact_fast_forward_is_detected() {
465 // The latest revision predates `base`, which is the normal shape of a 498 // The latest revision predates `base`, which is the normal shape of a
466 // migrated patch. In the exact fast-forward case — main fast-forwarded onto 499 // migrated patch. In the exact fast-forward case — main fast-forwarded onto
467 // the patch head, so base tip == head — recomputing a merge-base yields the 500 // the patch head, so base tip == head — recomputing a merge-base yields the
468 // head itself, so the base looks like it never moved and the merged patch 501 // head itself, so the base looks like it never moved and the merge goes
469 // stays Open forever. That is the commonest merge for a single-commit 502 // unnoticed. That is the commonest merge for a single-commit patch, so it
470 // patch, so it has to come from the base that was actually recorded. 503 // has to come from the base that was actually recorded.
471 let repo = TestRepo::new("Alice", "alice@example.com"); 504 let repo = TestRepo::new("Alice", "alice@example.com");
472 let base = repo.git(&["rev-parse", "main"]).trim().to_string(); 505 let base = repo.git(&["rev-parse", "main"]).trim().to_string();
473 repo.git(&["checkout", "-b", "feat"]); 506 repo.git(&["checkout", "-b", "feat"]);
@@ -484,7 +517,7 @@ fn a_migrated_patch_merged_by_exact_fast_forward_is_detected() {
484 "precondition: base tip and patch head are the same commit" 517 "precondition: base tip and patch head are the same commit"
485 ); 518 );
486 519
487 assert_eq!(show_json(&repo, &id[..8])["status"], "merged"); 520 assert!(looks_merged(&repo, &id[..8]));
488 } 521 }
489 522
490 #[test] 523 #[test]
@@ -509,7 +542,8 @@ fn a_patch_created_on_the_base_branch_is_not_reported_merged() {
509 // The degenerate case `base_moved` exists for: the recorded base IS the 542 // The degenerate case `base_moved` exists for: the recorded base IS the
510 // patch's own head, so the head is trivially reachable from the base tip 543 // patch's own head, so the head is trivially reachable from the base tip
511 // without anything having been merged. Guards against widening the 544 // without anything having been merged. Guards against widening the
512 // unknown-base handling until it swallows this. 545 // unknown-base handling until it swallows this. It stays a hint either
546 // way, but a hint that fires on every patch is worse than none.
513 let repo = TestRepo::new("Alice", "alice@example.com"); 547 let repo = TestRepo::new("Alice", "alice@example.com");
514 let tip = repo.git(&["rev-parse", "main"]).trim().to_string(); 548 let tip = repo.git(&["rev-parse", "main"]).trim().to_string();
515 repo.git(&["branch", "feat"]); 549 repo.git(&["branch", "feat"]);
@@ -541,6 +575,7 @@ fn a_patch_created_on_the_base_branch_is_not_reported_merged() {
541 .unwrap(); 575 .unwrap();
542 drop(git_repo); 576 drop(git_repo);
543 577
578 assert!(!looks_merged(&repo, &id[..8]));
544 assert_eq!(show_json(&repo, &id[..8])["status"], "open"); 579 assert_eq!(show_json(&repo, &id[..8])["status"], "open");
545 } 580 }
546 581
@@ -604,11 +639,9 @@ fn merge_detection_reads_the_base_of_the_revision_it_resolved_the_head_from() {
604 639
605 repo.git(&["merge", "--ff-only", "feat"]); 640 repo.git(&["merge", "--ff-only", "feat"]);
606 641
607 let json = show_json(&repo, &id[..8]); 642 assert!(
608 assert_eq!( 643 looks_merged(&repo, &id[..8]),
609 json["status"], "merged", 644 "head came from r1, so the base must come from r1 too"
610 "head came from r1, so the base must come from r1 too: {}",
611 json
612 ); 645 );
613 } 646 }
614 647
tests/server_behavior_test.rs
Old New
@@ -267,7 +267,7 @@ fn patches_list_defaults_to_open_and_offers_closed_and_merged_filters() {
267 "content for merged patch", 267 "content for merged patch",
268 "add file for merged patch", 268 "add file for merged patch",
269 ); 269 );
270 harness.work_repo().run_ok(&[ 270 let merged_out = harness.work_repo().run_ok(&[
271 "patch", 271 "patch",
272 "create", 272 "create",
273 "-t", 273 "-t",
@@ -275,15 +275,19 @@ fn patches_list_defaults_to_open_and_offers_closed_and_merged_filters() {
275 "-B", 275 "-B",
276 "feature-merged", 276 "feature-merged",
277 ]); 277 ]);
278 let merged_id = merged_out
279 .trim()
280 .strip_prefix("Created patch ")
281 .unwrap()
282 .to_string();
278 harness.work_repo().git(&["checkout", "main"]); 283 harness.work_repo().git(&["checkout", "main"]);
279 harness.work_repo().git(&["merge", "feature-merged"]); 284 harness.work_repo().git(&["merge", "feature-merged"]);
285 // Merged state is recorded, so it travels with the collab refs. The server
286 // does not have to re-derive it — which it could not do for a squash, and
287 // which used to make this test push `feature-merged` so the server had a
288 // branch to resolve.
289 harness.work_repo().run_ok(&["patch", "merge", &merged_id]);
280 290
281 // Auto-merge detection resolves the patch's source branch on the server
282 // side too (`refs/heads/feature-merged`), so it needs to be pushed
283 // alongside the fast-forwarded main.
284 harness
285 .work_repo()
286 .git(&["push", "origin", "feature-merged"]);
287 harness.push_head(); 291 harness.push_head();
288 harness.push_collab_refs(); 292 harness.push_collab_refs();
289 293
tests/status_test.rs
Old New
@@ -25,7 +25,9 @@ fn merge_patch(repo: &git2::Repository, ref_name: &str, author: &git_collab::eve
25 let event = git_collab::event::Event { 25 let event = git_collab::event::Event {
26 timestamp: common::now(), 26 timestamp: common::now(),
27 author: author.clone(), 27 author: author.clone(),
28 action: git_collab::event::Action::PatchMerge, 28 action: git_collab::event::Action::PatchMerge {
29 commit: String::new(),
30 },
29 clock: 0, 31 clock: 0,
30 }; 32 };
31 git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap(); 33 git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap();
tests/trailer_test.rs
Old New
@@ -0,0 +1,157 @@
1 //! One set of tests for the one git-trailer parser.
2 //!
3 //! `Issue:` (commit linking) and `Patch:` (merge recording) share
4 //! `trailer::parse_trailers`; every case below is run against both tokens so a
5 //! change that fixes one and breaks the other cannot pass.
6
7 use git_collab::trailer::parse_trailers;
8
9 /// Run one parser case against both tokens that use it. `message` is written
10 /// with `{}` where the token goes, and `expected` is the list of values the
11 /// parser must return.
12 fn both_tokens(message_template: &str, expected: &[&str]) {
13 for token in ["issue", "patch"] {
14 // Capitalized in the message, lowercase as the token argument: the
15 // match is case-insensitive.
16 let capitalized = format!("{}{}", token[..1].to_uppercase(), &token[1..]);
17 let message = message_template.replace("{}", &capitalized);
18 let got = parse_trailers(&message, token);
19 assert_eq!(
20 got,
21 expected.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
22 "token {} on message {:?}",
23 token,
24 message
25 );
26 }
27 }
28
29 #[test]
30 fn no_trailer_block() {
31 both_tokens("Just a plain commit", &[]);
32 }
33
34 #[test]
35 fn empty_message() {
36 both_tokens("", &[]);
37 }
38
39 #[test]
40 fn single_trailer_in_pure_block() {
41 both_tokens("Fix thing\n\nSome context in the body.\n\n{}: abc", &["abc"]);
42 }
43
44 #[test]
45 fn case_and_spacing_variants() {
46 for token in ["issue", "patch"] {
47 let upper = token.to_uppercase();
48 assert_eq!(
49 parse_trailers(&format!("subject\n\n{}: abc", token), token),
50 vec!["abc".to_string()]
51 );
52 assert_eq!(
53 parse_trailers(&format!("subject\n\n{} : abc", upper), token),
54 vec!["abc".to_string()]
55 );
56 assert_eq!(
57 parse_trailers(&format!("subject\n\n {}: abc ", upper), token),
58 vec!["abc".to_string()]
59 );
60 }
61 }
62
63 #[test]
64 fn two_trailers_in_pure_block() {
65 both_tokens("subject\n\n{}: abc\n{}: def", &["abc", "def"]);
66 }
67
68 #[test]
69 fn trailer_present_but_not_in_final_paragraph() {
70 // The final paragraph is the signed-off-by block. It is a valid trailer
71 // block, but it carries no matching key, so nothing is extracted.
72 both_tokens(
73 "subject\n\n{}: abc\n\nSigned-off-by: alice <a@example.com>",
74 &[],
75 );
76 }
77
78 #[test]
79 fn wrong_key() {
80 for token in ["issue", "patch"] {
81 // `Issues:` / `Patchs:` — trailer-shaped, but not our token.
82 let msg = format!("subject\n\n{}s: abc", token);
83 assert_eq!(parse_trailers(&msg, token), Vec::<String>::new());
84 }
85 }
86
87 #[test]
88 fn prose_mention() {
89 for token in ["issue", "patch"] {
90 let msg = format!("subject\n\nthis fixes {} abc in the body", token);
91 assert_eq!(parse_trailers(&msg, token), Vec::<String>::new());
92 }
93 }
94
95 #[test]
96 fn single_paragraph_whole_message_is_trailer_block() {
97 both_tokens("{}: abc", &["abc"]);
98 }
99
100 #[test]
101 fn mixed_final_paragraph_rejects_all() {
102 // A prose line in the final paragraph disqualifies the whole paragraph.
103 both_tokens("subject\n\nThanks to Bob for the catch.\n{}: a3f9", &[]);
104 }
105
106 #[test]
107 fn trailing_whitespace_paragraph_does_not_shadow_trailer_block() {
108 both_tokens("subject\n\n{}: abc\n\n \n", &["abc"]);
109 }
110
111 #[test]
112 fn pure_block_with_mixed_keys() {
113 both_tokens(
114 "subject\n\nSigned-off-by: alice <a@example.com>\n{}: abc",
115 &["abc"],
116 );
117 }
118
119 #[test]
120 fn rejects_value_with_interior_whitespace() {
121 // `Patch: abc merged by me` must parse to *nothing*, not silently to
122 // `abc`. Silently truncating would record a merge the author did not
123 // name; parsing to nothing makes the ignored commentary visible.
124 both_tokens("subject\n\n{}: abc merged by me", &[]);
125 both_tokens("subject\n\n{}: abc fixes thing", &[]);
126 }
127
128 #[test]
129 fn rejects_empty_value() {
130 both_tokens("subject\n\n{}: ", &[]);
131 }
132
133 #[test]
134 fn a_trailer_for_the_other_token_is_not_matched() {
135 // The two tokens genuinely select: an `Issue:` line is not a `Patch:` one.
136 let msg = "subject\n\nIssue: abc\nPatch: def";
137 assert_eq!(parse_trailers(msg, "issue"), vec!["abc".to_string()]);
138 assert_eq!(parse_trailers(msg, "patch"), vec!["def".to_string()]);
139 }
140
141 #[test]
142 fn squashed_message_keeps_only_the_final_trailer_block() {
143 // `git merge --squash` concatenates the source messages. Only the last
144 // commit's trailer block is the final paragraph — which is fine, because
145 // every commit of a patch carries the same id.
146 let msg = "\
147 Squashed commit of the following:
148
149 first commit
150
151 Patch: aaaa1111
152
153 second commit
154
155 Patch: aaaa1111";
156 assert_eq!(parse_trailers(msg, "patch"), vec!["aaaa1111".to_string()]);
157 }