src/trailer.rs
Ref: Size: 7.9 KiB History
//! Git trailer parsing, shared by everything that reads a `<Token>: <value>`
//! line out of a commit message.
//!
//! Two features use it: `Issue:` trailers link commits to issues
//! (`commit_link`), and `Patch:` trailers record that a patch landed on its
//! base branch (`merge_scan`). They differ only in the token, so they share one
//! parser and one set of tests (`tests/trailer_test.rs`) — a fix to the block
//! semantics or the value rules cannot land for one and miss the other.
//!
//! See: docs/superpowers/specs/2026-04-12-commit-issue-link-design.md
//! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md
/// Parse `<token>: <value>` trailers out of a commit message.
///
/// Returns the values in order of appearance. Follows git's own trailer-block
/// semantics: only the final paragraph is considered, and *every* non-empty
/// line in it must be trailer-shaped (a `token: value` line) for the paragraph
/// to qualify. Any prose line in the final paragraph disqualifies the whole
/// paragraph — this prevents false positives like `"Thanks Bob.\nIssue: abc"`
/// in commit bodies.
///
/// The key match is case-insensitive. The value must be a single
/// non-whitespace token followed by optional trailing whitespace and
/// end-of-line; values like `abc fixes thing` are rejected so that loose
/// commentary never becomes a silent prefix lookup that warns every sync
/// forever.
pub fn parse_trailers(message: &str, token: &str) -> Vec<String> {
// 1. Split into paragraphs (blank-line separated), preserving order.
// Trim trailing whitespace from each line for the trailer-shape check,
// but keep enough structure to recognize blank lines.
let lines: Vec<&str> = message.lines().collect();
// 2. Find the last paragraph: the longest tail slice that contains at
// least one non-empty line and has no blank line *before* its first
// non-empty line in the tail.
//
// Walking from the end: skip trailing blank/whitespace-only lines,
// then collect lines until we hit a blank line.
let Some((start, end)) = final_paragraph_bounds(&lines) else {
return Vec::new();
};
let paragraph = &lines[start..end];
// 3. Validate every non-empty line in the paragraph is trailer-shaped.
if !is_trailer_block(paragraph) {
return Vec::new();
}
// 4. Extract the values whose key is `token`.
let mut out = Vec::new();
for line in paragraph {
if let Some(value) = match_trailer_line(line, token) {
out.push(value);
}
}
out
}
/// The span of the final paragraph of `lines` — the tail run of non-blank
/// lines, ignoring trailing blanks. `None` when there is no content at all.
fn final_paragraph_bounds(lines: &[&str]) -> Option<(usize, usize)> {
let mut end = lines.len();
while end > 0 && lines[end - 1].trim().is_empty() {
end -= 1;
}
if end == 0 {
return None;
}
let mut start = end;
while start > 0 && !lines[start - 1].trim().is_empty() {
start -= 1;
}
Some((start, end))
}
/// Whether every non-empty line of `paragraph` is trailer-shaped — git's rule
/// for a paragraph being a trailer block, and the one [`parse_trailers`]
/// enforces before reading anything out of it.
fn is_trailer_block(paragraph: &[&str]) -> bool {
paragraph
.iter()
.all(|line| line.trim().is_empty() || is_trailer_shaped(line))
}
/// What the writer of a trailer needs to know about the message it is about to
/// append to. See [`final_paragraph`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FinalParagraph {
/// The final paragraph is also the first — a message with no blank line in
/// it, i.e. a bare subject. Appending to it would fold the trailer into the
/// subject line, since git takes the whole first paragraph as the subject.
pub starts_the_message: bool,
/// Every non-empty line in it is trailer-shaped, so a trailer appended
/// directly to it stays readable — *and* so do the trailers already there,
/// which starting a new paragraph instead would strand outside the final
/// paragraph and silently unlink.
pub is_trailer_block: bool,
}
/// Describe the final paragraph of `message`, or `None` if it has no content.
///
/// This exists so that whatever *writes* a trailer decides where to put it
/// using the same notion of "trailer block" that [`parse_trailers`] uses to
/// read it back. Those are two implementations of the same rule when they are
/// written separately, and two implementations of one rule is how a writer
/// starts emitting trailers the reader cannot see.
pub fn final_paragraph(message: &str) -> Option<FinalParagraph> {
let lines: Vec<&str> = message.lines().collect();
let (start, end) = final_paragraph_bounds(&lines)?;
Some(FinalParagraph {
starts_the_message: start == 0,
is_trailer_block: is_trailer_block(&lines[start..end]),
})
}
/// Whether *any* line of `message`, in any paragraph, is a `<token>:` trailer
/// line.
///
/// Deliberately laxer than [`parse_trailers`], and only ever used to decide not
/// to write: a `Patch:` line that the parser refuses — because it sits in a
/// paragraph with prose, or carries a value with interior whitespace — is still
/// a line the author wrote, and stapling a second one underneath it helps
/// nobody. "Already mentions it" is the right test for leaving a message alone;
/// "already parses" is not.
pub fn contains_trailer_line(message: &str, token: &str) -> bool {
message
.lines()
.any(|line| trailer_key(line).is_some_and(|key| key.eq_ignore_ascii_case(token)))
}
/// The key of a trailer-shaped line: `<token>: <value>`, where token starts
/// with a letter and consists of `[A-Za-z0-9-]`, and value is at least one
/// non-whitespace character. `None` if the line is not trailer-shaped.
fn trailer_key(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let colon_pos = trimmed.find(':')?;
// Use trim_end() so that `ISSUE : abc` is recognized as the token `ISSUE`
// — matching what `match_trailer_line` does. Without this, the space before
// the colon would disqualify the line and make the whole paragraph fail
// the trailer-shape check.
let token = trimmed[..colon_pos].trim_end();
if token.is_empty() {
return None;
}
let mut chars = token.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() {
return None;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') {
return None;
}
if trimmed[colon_pos + 1..].trim().is_empty() {
return None;
}
Some(token)
}
/// Returns true if a line looks like a git trailer.
fn is_trailer_shaped(line: &str) -> bool {
trailer_key(line).is_some()
}
/// If `line` is a `<token>: <value>` trailer with exactly one non-whitespace
/// token in its value, returns the value. Otherwise returns None.
fn match_trailer_line(line: &str, token: &str) -> Option<String> {
let trimmed = line.trim_start();
let colon_pos = trimmed.find(':')?;
let key = trimmed[..colon_pos].trim_end();
if !key.eq_ignore_ascii_case(token) {
return None;
}
let value_region = &trimmed[colon_pos + 1..];
let value = value_region.trim();
if value.is_empty() {
return None;
}
// Reject values with interior whitespace: `abc fixes thing` must not
// parse to `abc` silently — it must parse to nothing so the user sees
// that their commentary is being ignored. This matters more for `Patch:`
// than for `Issue:`, because there the silent reading would record a
// merge under an id the author never wrote on its own.
if value.split_whitespace().count() != 1 {
return None;
}
Some(value.to_string())
}
/// The token `Issue:` trailers use.
pub const ISSUE_TOKEN: &str = "issue";
/// The token `Patch:` trailers use.
pub const PATCH_TOKEN: &str = "patch";