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, a faithful port of Git's
176/// `strbuf_add_wrapped_text` (`utf8.c`), 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#[must_use]
185pub fn add_wrapped_text(text: &str, indent1: i64, indent2: i64, width: i64) -> String {
186    use unicode_width::UnicodeWidthChar;
187
188    if width <= 0 {
189        return add_indented_text(text, indent1, indent2);
190    }
191
192    let bytes = text.as_bytes();
193    let mut out = String::new();
194
195    // Mirror Git's `strbuf_add_wrapped_text` (utf8.c). `bol` is the start of the current line's
196    // pending text; `space` (when set) is the index of the last whitespace breakpoint. `w` is the
197    // current column. The `new_line` label is emulated by `do_new_line`.
198    let mut bol: usize = 0;
199    let mut space: Option<usize> = None;
200    let mut indent: i64 = indent1;
201    let mut w: i64 = indent1;
202    if indent1 < 0 {
203        w = -indent1;
204        space = Some(0);
205    }
206
207    let mut text_pos: usize = 0;
208    loop {
209        let c = bytes.get(text_pos).copied();
210        let is_space = is_space_byte(c);
211        if c.is_none() || is_space {
212            let mut do_new_line = false;
213            if w <= width || space.is_none() {
214                let start = if let Some(sp) = space {
215                    sp
216                } else {
217                    if c.is_none() && text_pos == bol {
218                        return out;
219                    }
220                    for _ in 0..indent.max(0) {
221                        out.push(' ');
222                    }
223                    bol
224                };
225                out.push_str(&text[start..text_pos]);
226                if c.is_none() {
227                    return out;
228                }
229                let cc = c.unwrap();
230                let mut new_space = text_pos;
231                if cc == b'\t' {
232                    w |= 0x07;
233                } else if cc == b'\n' {
234                    new_space += 1;
235                    match bytes.get(new_space) {
236                        Some(b'\n') => {
237                            out.push('\n');
238                            space = Some(new_space);
239                            do_new_line = true;
240                        }
241                        nxt if !nxt.map(u8::is_ascii_alphanumeric).unwrap_or(false) => {
242                            space = Some(new_space);
243                            do_new_line = true;
244                        }
245                        _ => {
246                            out.push(' ');
247                        }
248                    }
249                }
250                if !do_new_line {
251                    space = Some(new_space);
252                    w += 1;
253                    text_pos += 1;
254                }
255            } else {
256                do_new_line = true;
257            }
258            if do_new_line {
259                out.push('\n');
260                let sp = space.unwrap();
261                text_pos = sp + usize::from(is_space_byte(bytes.get(sp).copied()));
262                bol = text_pos;
263                space = None;
264                w = indent2;
265                indent = indent2;
266            }
267            continue;
268        }
269        // Non-space character: advance by one display glyph.
270        let ch = text[text_pos..].chars().next().unwrap();
271        let gw = UnicodeWidthChar::width(ch).unwrap_or(0) as i64;
272        w += gw;
273        text_pos += ch.len_utf8();
274    }
275}
276
277fn is_space_byte(b: Option<u8>) -> bool {
278    matches!(
279        b,
280        Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | Some(11) | Some(12)
281    )
282}
283
284/// Indent each line of `text` (`Git strbuf_add_indented_text`): the first line by
285/// `indent1` spaces and subsequent lines by `indent2`. Used when wrap width is 0.
286#[must_use]
287pub fn add_indented_text(text: &str, indent1: i64, indent2: i64) -> String {
288    let indent1 = indent1.max(0);
289    let mut out = String::new();
290    let mut indent = indent1;
291    let bytes = text.as_bytes();
292    let mut pos = 0;
293    while pos < bytes.len() {
294        let eol = match bytes[pos..].iter().position(|&b| b == b'\n') {
295            Some(i) => pos + i + 1,
296            None => bytes.len(),
297        };
298        for _ in 0..indent {
299            out.push(' ');
300        }
301        out.push_str(&text[pos..eol]);
302        pos = eol;
303        indent = indent2;
304    }
305    out
306}
307
308#[cfg(test)]
309mod wrap_tests {
310    use super::add_wrapped_text;
311
312    #[test]
313    fn wrap_width_one_decoration_with_leading_newline() {
314        // t4205 "magical wrapping": the buffer after `%w(1)%+d` is "\n (tag: describe-me)%+w(2)"
315        // and Git rewraps it at width 1 to "\n(tag:\ndescribe-me)%+w(2)".
316        let input = "\n (tag: describe-me)%+w(2)";
317        assert_eq!(
318            add_wrapped_text(input, 0, 0, 1),
319            "\n(tag:\ndescribe-me)%+w(2)"
320        );
321    }
322
323    #[test]
324    fn wrap_zero_width_is_indent_only() {
325        assert_eq!(add_wrapped_text("a\nb", 2, 1, 0), "  a\n b");
326    }
327
328    #[test]
329    fn wrap_simple_words() {
330        // Two short words, width large enough to keep them on one line.
331        assert_eq!(add_wrapped_text("foo bar", 0, 0, 80), "foo bar");
332    }
333}