Skip to main content

grit_lib/
commit_pretty.rs

1//! Human-oriented commit one-line formats shared by porcelain commands.
2
3use crate::objects::ObjectId;
4
5/// Abbreviate `oid` to at most `abbrev_len` hex characters (minimum 4, maximum 40).
6///
7/// # Parameters
8///
9/// - `oid` — full commit object id.
10/// - `abbrev_len` — desired abbreviation length (clamped to 4..=40 and to the hex length).
11#[must_use]
12pub fn abbrev_hex(oid: &ObjectId, abbrev_len: usize) -> String {
13    let hex = oid.to_hex();
14    let n = abbrev_len.clamp(4, 40).min(hex.len());
15    hex[..n].to_owned()
16}
17
18/// Return the pretty subject for a commit or tag message.
19///
20/// The subject is the first non-empty paragraph with embedded line breaks
21/// collapsed to spaces. Both LF and CRLF line endings are recognized.
22///
23/// # Parameters
24///
25/// - `message` — raw commit or tag message text.
26#[must_use]
27pub fn message_subject(message: &str) -> String {
28    let mut subject_lines = Vec::new();
29    for line in MessageLines::new(message) {
30        if line.text.is_empty() {
31            if !subject_lines.is_empty() {
32                break;
33            }
34            continue;
35        }
36        subject_lines.push(line.text);
37    }
38    subject_lines.join(" ")
39}
40
41/// Return the body slice after the first message paragraph.
42///
43/// Leading blank lines before the first paragraph are ignored. The returned
44/// body starts after the blank-line separator and any additional blank lines,
45/// preserving the original body line endings and trailing newline bytes.
46///
47/// # Parameters
48///
49/// - `message` — raw commit or tag message text.
50#[must_use]
51pub fn message_body(message: &str) -> &str {
52    let mut saw_subject = false;
53    let mut body_start = message.len();
54    let mut iter = MessageLines::new(message).peekable();
55
56    while let Some(line) = iter.next() {
57        if line.text.is_empty() {
58            if saw_subject {
59                body_start = line.next_start;
60                while let Some(next) = iter.peek() {
61                    if !next.text.is_empty() {
62                        break;
63                    }
64                    body_start = next.next_start;
65                    iter.next();
66                }
67                break;
68            }
69            continue;
70        }
71        saw_subject = true;
72    }
73
74    &message[body_start..]
75}
76
77#[derive(Clone, Copy)]
78struct MessageLine<'a> {
79    text: &'a str,
80    next_start: usize,
81}
82
83struct MessageLines<'a> {
84    message: &'a str,
85    pos: usize,
86}
87
88impl<'a> MessageLines<'a> {
89    fn new(message: &'a str) -> Self {
90        Self { message, pos: 0 }
91    }
92}
93
94impl<'a> Iterator for MessageLines<'a> {
95    type Item = MessageLine<'a>;
96
97    fn next(&mut self) -> Option<Self::Item> {
98        if self.pos >= self.message.len() {
99            return None;
100        }
101        let start = self.pos;
102        let tail = &self.message[start..];
103        let newline_rel = tail.find('\n');
104        let (mut end, next_start) = match newline_rel {
105            Some(rel) => (start + rel, start + rel + 1),
106            None => (self.message.len(), self.message.len()),
107        };
108        if self.message.as_bytes().get(end.wrapping_sub(1)) == Some(&b'\r') && end > start {
109            end -= 1;
110        }
111        self.pos = next_start;
112        Some(MessageLine {
113            text: &self.message[start..end],
114            next_start,
115        })
116    }
117}
118
119fn parse_tz_offset_seconds(offset: &str) -> i64 {
120    if offset.len() < 5 {
121        return 0;
122    }
123    let sign = if offset.starts_with('-') { -1i64 } else { 1i64 };
124    let hours: i64 = offset[1..3].parse().unwrap_or(0);
125    let minutes: i64 = offset[3..5].parse().unwrap_or(0);
126    sign * (hours * 3600 + minutes * 60)
127}
128
129/// Format the author/committer date as `YYYY-MM-DD` in the commit's local timezone.
130///
131/// Matches Git's `DATE_SHORT` mode used by `--pretty=reference` (e.g. `2005-04-07`).
132#[must_use]
133pub fn format_short_date_from_ident(ident: &str) -> String {
134    let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
135    if parts.len() < 2 {
136        return ident.to_owned();
137    }
138    let ts_str = parts[1];
139    let offset_str = parts[0];
140    let Ok(ts) = ts_str.parse::<i64>() else {
141        return ident.to_owned();
142    };
143    let offset_secs = parse_tz_offset_seconds(offset_str);
144    let Ok(dt) = time::OffsetDateTime::from_unix_timestamp(ts + offset_secs) else {
145        return ident.to_owned();
146    };
147    let format = time::format_description::parse("[year]-[month]-[day]");
148    let Ok(fmt) = format else {
149        return ident.to_owned();
150    };
151    dt.format(&fmt).unwrap_or_else(|_| ident.to_owned())
152}
153
154/// One-line `reference` format: `abbrev (subject, YYYY-MM-DD)`.
155///
156/// Matches upstream `git show -s --pretty=reference` / sequencer `refer_to_commit` output.
157///
158/// # Parameters
159///
160/// - `subject_first_line` — first line of the commit message (no trailing newline).
161/// - `committer_ident` — raw `committer` header line (`Name <email> epoch tz`).
162/// - `abbrev_len` — abbreviation length for the hash (typically 7).
163#[must_use]
164pub fn format_reference_line(
165    oid: &ObjectId,
166    subject_first_line: &str,
167    committer_ident: &str,
168    abbrev_len: usize,
169) -> String {
170    let abbrev = abbrev_hex(oid, abbrev_len);
171    let date = format_short_date_from_ident(committer_ident);
172    format!("{abbrev} ({subject_first_line}, {date})")
173}
174
175/// Word-wrap `text` to `width` columns with the same wrapping behavior as Git's
176/// `strbuf_add_wrapped_text`, used by the `%w(width,indent1,indent2)`
177/// pretty directive.
178///
179/// `indent1` is the indent for the first output line, `indent2` for the rest. A
180/// negative `indent1` means that `-indent1` columns have already been consumed on
181/// the first line (no extra indent emitted there). With `width <= 0` the text is
182/// only indented, not wrapped. Column widths are measured with display width
183/// (East-Asian wide characters count as 2).
184// The `unwrap()`s below rest on loop invariants: `c`/`space` are `Some` whenever
185// the wrapping branch that reads them runs, and `text_pos` always sits on a
186// char boundary inside `text`.
187#[allow(clippy::unwrap_used)]
188#[must_use]
189pub fn add_wrapped_text(text: &str, indent1: i64, indent2: i64, width: i64) -> String {
190    use unicode_width::UnicodeWidthChar;
191
192    if width <= 0 {
193        return add_indented_text(text, indent1, indent2);
194    }
195
196    let bytes = text.as_bytes();
197    let mut out = String::new();
198
199    // Mirror Git's `strbuf_add_wrapped_text` (utf8.c). `bol` is the start of the current line's
200    // pending text; `space` (when set) is the index of the last whitespace breakpoint. `w` is the
201    // current column. The `new_line` label is emulated by `do_new_line`.
202    let mut bol: usize = 0;
203    let mut space: Option<usize> = None;
204    let mut indent: i64 = indent1;
205    let mut w: i64 = indent1;
206    if indent1 < 0 {
207        w = -indent1;
208        space = Some(0);
209    }
210
211    let mut text_pos: usize = 0;
212    loop {
213        let c = bytes.get(text_pos).copied();
214        let is_space = is_space_byte(c);
215        if c.is_none() || is_space {
216            let mut do_new_line = false;
217            if w <= width || space.is_none() {
218                let start = if let Some(sp) = space {
219                    sp
220                } else {
221                    if c.is_none() && text_pos == bol {
222                        return out;
223                    }
224                    for _ in 0..indent.max(0) {
225                        out.push(' ');
226                    }
227                    bol
228                };
229                out.push_str(&text[start..text_pos]);
230                if c.is_none() {
231                    return out;
232                }
233                let cc = c.unwrap();
234                let mut new_space = text_pos;
235                if cc == b'\t' {
236                    w |= 0x07;
237                } else if cc == b'\n' {
238                    new_space += 1;
239                    match bytes.get(new_space) {
240                        Some(b'\n') => {
241                            out.push('\n');
242                            space = Some(new_space);
243                            do_new_line = true;
244                        }
245                        nxt if !nxt.map(u8::is_ascii_alphanumeric).unwrap_or(false) => {
246                            space = Some(new_space);
247                            do_new_line = true;
248                        }
249                        _ => {
250                            out.push(' ');
251                        }
252                    }
253                }
254                if !do_new_line {
255                    space = Some(new_space);
256                    w += 1;
257                    text_pos += 1;
258                }
259            } else {
260                do_new_line = true;
261            }
262            if do_new_line {
263                out.push('\n');
264                let sp = space.unwrap();
265                text_pos = sp + usize::from(is_space_byte(bytes.get(sp).copied()));
266                bol = text_pos;
267                space = None;
268                w = indent2;
269                indent = indent2;
270            }
271            continue;
272        }
273        // Non-space character: advance by one display glyph.
274        let ch = text[text_pos..].chars().next().unwrap();
275        let gw = UnicodeWidthChar::width(ch).unwrap_or(0) as i64;
276        w += gw;
277        text_pos += ch.len_utf8();
278    }
279}
280
281fn is_space_byte(b: Option<u8>) -> bool {
282    matches!(
283        b,
284        Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | Some(11) | Some(12)
285    )
286}
287
288/// Indent each line of `text` (`Git strbuf_add_indented_text`): the first line by
289/// `indent1` spaces and subsequent lines by `indent2`. Used when wrap width is 0.
290#[must_use]
291pub fn add_indented_text(text: &str, indent1: i64, indent2: i64) -> String {
292    let indent1 = indent1.max(0);
293    let mut out = String::new();
294    let mut indent = indent1;
295    let bytes = text.as_bytes();
296    let mut pos = 0;
297    while pos < bytes.len() {
298        let eol = match bytes[pos..].iter().position(|&b| b == b'\n') {
299            Some(i) => pos + i + 1,
300            None => bytes.len(),
301        };
302        for _ in 0..indent {
303            out.push(' ');
304        }
305        out.push_str(&text[pos..eol]);
306        pos = eol;
307        indent = indent2;
308    }
309    out
310}
311
312#[cfg(test)]
313mod wrap_tests {
314    use super::add_wrapped_text;
315
316    #[test]
317    fn wrap_width_one_decoration_with_leading_newline() {
318        // t4205 "magical wrapping": the buffer after `%w(1)%+d` is "\n (tag: describe-me)%+w(2)"
319        // and Git rewraps it at width 1 to "\n(tag:\ndescribe-me)%+w(2)".
320        let input = "\n (tag: describe-me)%+w(2)";
321        assert_eq!(
322            add_wrapped_text(input, 0, 0, 1),
323            "\n(tag:\ndescribe-me)%+w(2)"
324        );
325    }
326
327    #[test]
328    fn wrap_zero_width_is_indent_only() {
329        assert_eq!(add_wrapped_text("a\nb", 2, 1, 0), "  a\n b");
330    }
331
332    #[test]
333    fn wrap_simple_words() {
334        // Two short words, width large enough to keep them on one line.
335        assert_eq!(add_wrapped_text("foo bar", 0, 0, 80), "foo bar");
336    }
337}