Skip to main content

grit_lib/
commit.rs

1//! Commit-metadata helpers shared by the porcelain commands.
2//!
3//! This module holds pure-domain pieces of commit creation that compute a
4//! result from plain inputs and carry **no** presentation, argv, or process
5//! state. The first piece extracted is the date normalisation used to fill the
6//! author/committer timestamp: turning a user-supplied `--date` string (or
7//! `GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE`) into Git's stored `<epoch>
8//! <offset>` form. It is shared by `commit`, `commit --amend`, and the
9//! sequencer commands (`rebase`, `cherry-pick`, `revert`, `stash`, `notes`,
10//! `tag`, `format-patch`, `checkout`).
11//!
12//! Commit creation from the index (write-tree, object write, ref/reflog updates)
13//! lives in [`crate::porcelain::commit`]; the `grit commit` command stages paths
14//! then calls that API.
15//!
16//! Library guide: [commit](https://grit-scm.com/docs/library/embedding)
17//!
18//! ```rust
19//! use grit_lib::repo::{InitOptions, Repository};
20//! # fn main() -> grit_lib::error::Result<()> {
21//! let dir = tempfile::tempdir()?;
22//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
23//! # Ok(())
24//! # }
25//! ```
26
27use time::format_description::well_known::Rfc3339;
28use time::OffsetDateTime;
29
30use crate::git_date::parse::{parse_date, DateParseError};
31
32/// Normalise a date string into Git's stored `<epoch> <offset>` timestamp.
33///
34/// Accepts the forms Git's `commit`/`--date` path understands: RFC 3339 / ISO
35/// 8601 (with or without an explicit zone, in which case UTC is assumed),
36/// `YYYY-MM-DD HH:MM:SS <tz>`, `@<epoch> <tz>`, and the looser
37/// approxidate-style strings handled by [`parse_date`]. Returns `None` when the
38/// input is already in `<epoch> <offset>` form (nothing to convert) or cannot
39/// be parsed; callers then fall back to using the raw string.
40pub fn parse_date_to_git_timestamp(date_str: &str) -> Option<String> {
41    let trimmed = date_str.trim();
42
43    // ISO 8601 / RFC 3339, including forms Git accepts without an explicit offset
44    // (e.g. `2020-01-01T00:00:00` — treated as UTC when no zone is present).
45    if let Ok(dt) = OffsetDateTime::parse(trimmed, &Rfc3339) {
46        return Some(format_git_timestamp(dt));
47    }
48    let with_utc_z = format!("{trimmed}Z");
49    if let Ok(dt) = OffsetDateTime::parse(&with_utc_z, &Rfc3339) {
50        return Some(format_git_timestamp(dt));
51    }
52
53    // Already in `<epoch> <offset>` format? (epoch is all digits)
54    let parts: Vec<&str> = trimmed.rsplitn(2, ' ').collect();
55    if parts.len() == 2 {
56        let maybe_epoch = parts[1];
57        if maybe_epoch.chars().all(|c| c.is_ascii_digit()) {
58            // Already epoch + offset
59            return None;
60        }
61    }
62
63    // Try parsing "YYYY-MM-DD HH:MM:SS <tz>" format
64    if parts.len() == 2 {
65        let tz = parts[0];
66        let datetime = parts[1];
67
68        // Parse tz offset
69        let tz_bytes = tz.as_bytes();
70        if tz_bytes.len() >= 5 {
71            let sign: i64 = if tz_bytes[0] == b'-' { -1 } else { 1 };
72            let h: i64 = tz[1..3].parse().unwrap_or(0);
73            let m: i64 = tz[3..5].parse().unwrap_or(0);
74            let tz_secs = sign * (h * 3600 + m * 60);
75
76            // Try YYYY-MM-DD HH:MM:SS
77            if let Ok(offset) = time::UtcOffset::from_whole_seconds(tz_secs as i32) {
78                let fmt = time::format_description::parse_borrowed::<1>(
79                    "[year]-[month]-[day] [hour]:[minute]:[second]",
80                )
81                .ok()?;
82                if let Ok(naive) = time::PrimitiveDateTime::parse(datetime, &fmt) {
83                    let dt = naive.assume_offset(offset);
84                    let epoch = dt.unix_timestamp();
85                    return Some(format!("{epoch} {tz}"));
86                }
87            }
88        }
89    }
90
91    // Try "@<epoch>" format (git uses this for testing)
92    if let Some(epoch_str) = trimmed.strip_prefix('@') {
93        // @<epoch> <tz>
94        let ep_parts: Vec<&str> = epoch_str.splitn(2, ' ').collect();
95        if ep_parts.len() == 2 {
96            if let Ok(_epoch) = ep_parts[0].parse::<i64>() {
97                return Some(format!("{} {}", ep_parts[0], ep_parts[1]));
98            }
99        }
100    }
101
102    // Loose Git dates without explicit zone (e.g. `2022-02-01 00:00` from GIT_COMMITTER_DATE).
103    if let Ok(canonical) = parse_date(trimmed) {
104        return Some(canonical);
105    }
106
107    None
108}
109
110/// True when `s` is already Git's canonical `<unix-seconds> <±HHMM>` form with a valid zone.
111fn is_canonical_epoch_numeric_tz(s: &str) -> bool {
112    let Some((epoch, tz)) = s.rsplit_once(' ') else {
113        return false;
114    };
115    if epoch.is_empty() || !epoch.as_bytes().iter().all(|b| b.is_ascii_digit()) {
116        return false;
117    }
118    let tz = tz.as_bytes();
119    if tz.len() != 5 || (tz[0] != b'+' && tz[0] != b'-') {
120        return false;
121    }
122    if !tz[1..].iter().all(|b| b.is_ascii_digit()) {
123        return false;
124    }
125    let hh = (tz[1] - b'0') as u32 * 10 + (tz[2] - b'0') as u32;
126    let mm = (tz[3] - b'0') as u32 * 10 + (tz[4] - b'0') as u32;
127    hh <= 23 && mm <= 59
128}
129
130/// Error when a `--trailer` argument is not `key=value` or `key: value`.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct TrailerArgParseError {
133    raw: String,
134}
135
136impl std::fmt::Display for TrailerArgParseError {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        write!(
139            f,
140            "trailer must be key=value or key: value (got '{}')",
141            self.raw
142        )
143    }
144}
145
146impl std::error::Error for TrailerArgParseError {}
147
148/// Split a `git commit --trailer` argument into token and value.
149///
150/// The separator is the first `:` or `=` in the string (whichever comes first),
151/// matching Git's `parse_trailer_arg` / `git interpret-trailers --trailer`.
152///
153/// # Errors
154///
155/// Returns [`TrailerArgParseError`] when neither separator appears.
156pub fn parse_trailer_command_line_arg(raw: &str) -> Result<(String, String), TrailerArgParseError> {
157    let raw = raw.trim();
158    let sep_pos = raw
159        .char_indices()
160        .find(|(_, c)| *c == ':' || *c == '=')
161        .map(|(i, _)| i);
162    let Some(pos) = sep_pos else {
163        return Err(TrailerArgParseError {
164            raw: raw.to_owned(),
165        });
166    };
167    Ok((
168        raw[..pos].trim().to_owned(),
169        raw[pos + 1..].trim().to_owned(),
170    ))
171}
172
173/// Normalise a user-supplied author/committer date (CLI `--date`, env overrides) into
174/// Git's stored `<epoch> <±HHMM>` form.
175///
176/// Canonical raw timestamps pass through unchanged. Other strings are parsed with the
177/// same rules as Git's `parse_date` (invalid zone tokens such as `garbage` become
178/// `+0000`, matching `git commit --date`).
179///
180/// # Errors
181///
182/// Returns [`DateParseError`] when the string cannot be interpreted as any date Git accepts.
183pub fn normalize_commit_date_override(raw: &str) -> Result<String, DateParseError> {
184    let trimmed = raw.trim();
185    if is_canonical_epoch_numeric_tz(trimmed) {
186        return Ok(trimmed.to_owned());
187    }
188    if let Some(ts) = parse_date_to_git_timestamp(trimmed) {
189        if is_canonical_epoch_numeric_tz(&ts) {
190            return Ok(ts);
191        }
192    }
193    parse_date(trimmed)
194}
195
196/// Format a timestamp in Git's format: `<epoch> <offset>`.
197pub fn format_git_timestamp(dt: OffsetDateTime) -> String {
198    let epoch = dt.unix_timestamp();
199    let offset = dt.offset();
200    let sign = if offset.is_negative() { '-' } else { '+' };
201    let hours = offset.whole_hours().unsigned_abs();
202    let minutes = offset.minutes_past_hour().unsigned_abs();
203    format!("{epoch} {sign}{hours:02}{minutes:02}")
204}
205
206/// Assemble a commit identity line (`Name <email> <epoch> <offset>`) from an
207/// already-resolved name and email plus a timestamp.
208///
209/// `date_override` is the raw value of a `GIT_AUTHOR_DATE` /
210/// `GIT_COMMITTER_DATE`-style environment variable, if set: it is parsed with
211/// [`parse_date_to_git_timestamp`] and used verbatim if parsing fails (matching
212/// Git's lenient behavior). When `None`, `now` is formatted instead.
213#[must_use]
214pub fn assemble_identity(
215    name: &str,
216    email: &str,
217    date_override: Option<&str>,
218    now: OffsetDateTime,
219) -> String {
220    let timestamp = match date_override {
221        Some(d) => {
222            let trimmed = d.trim();
223            if let Some(rest) = trimmed.strip_prefix('@') {
224                let mut parts = rest.split_whitespace();
225                if let Some(epoch_str) = parts.next() {
226                    if parts.next().is_none() {
227                        if let Ok(epoch) = epoch_str.parse::<i64>() {
228                            if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch)
229                                .map(|t| t.to_offset(now.offset()))
230                            {
231                                return format!("{name} <{email}> {}", format_git_timestamp(dt));
232                            }
233                        }
234                    }
235                }
236            }
237            normalize_commit_date_override(d).unwrap_or_else(|_| d.to_string())
238        }
239        None => format_git_timestamp(now),
240    };
241    format!("{name} <{email}> {timestamp}")
242}
243
244/// Commit encoding helpers for non-UTF-8 commit messages.
245pub mod encoding {
246    pub use crate::commit_encoding::*;
247}
248
249/// Commit trailers and interpretation helpers.
250pub mod trailers {
251    pub use crate::commit_trailers::*;
252    /// Parse and apply trailer commands.
253    pub mod interpret {
254        pub use crate::interpret_trailers::*;
255    }
256}
257
258/// OpenPGP / SSH signature verification.
259pub mod signing {
260    pub use crate::signing::*;
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::environment::Environment;
267    use std::ffi::OsString;
268    use time::OffsetDateTime;
269
270    #[test]
271    fn assemble_identity_formats_supplied_now_without_override() {
272        let now = OffsetDateTime::from_unix_timestamp(1_112_911_993).unwrap();
273        let line = assemble_identity("A U Thor", "author@example.com", None, now);
274        assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
275    }
276
277    #[test]
278    fn format_git_timestamp_keeps_sign_of_sub_hour_negative_offsets() {
279        let at = |h, m| {
280            OffsetDateTime::from_unix_timestamp(1_700_000_000)
281                .unwrap()
282                .to_offset(time::UtcOffset::from_hms(h, m, 0).unwrap())
283        };
284        assert_eq!(format_git_timestamp(at(0, -30)), "1700000000 -0030");
285        assert_eq!(format_git_timestamp(at(-9, -30)), "1700000000 -0930");
286        assert_eq!(format_git_timestamp(at(5, 45)), "1700000000 +0545");
287        assert_eq!(format_git_timestamp(at(0, 0)), "1700000000 +0000");
288    }
289
290    #[test]
291    fn assemble_identity_respects_tz_from_environment() {
292        let vars = [("TZ", "+0200")]
293            .into_iter()
294            .map(|(k, v)| (OsString::from(k), OsString::from(v)));
295        let env = Environment::from_vars(vars, std::path::PathBuf::from("/tmp"));
296        let now = crate::environment::offset_from_epoch_and_tz(1_112_911_993, env.tz.as_deref());
297        let line = assemble_identity("A U Thor", "author@example.com", None, now);
298        assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0200");
299    }
300
301    #[test]
302    fn assemble_identity_parses_date_override() {
303        let now = OffsetDateTime::from_unix_timestamp(0).unwrap();
304        let line = assemble_identity(
305            "A U Thor",
306            "author@example.com",
307            Some("2005-04-07T22:13:13 +0000"),
308            now,
309        );
310        assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
311    }
312
313    #[test]
314    fn parse_trailer_command_line_arg_splits_on_first_colon_or_equals() {
315        let (k, v) = parse_trailer_command_line_arg("Link: https://x?a=b").unwrap();
316        assert_eq!(k, "Link");
317        assert_eq!(v, "https://x?a=b");
318        let (k, v) = parse_trailer_command_line_arg("Acked-by: Alice=Bob").unwrap();
319        assert_eq!(k, "Acked-by");
320        assert_eq!(v, "Alice=Bob");
321        let (k, v) = parse_trailer_command_line_arg("key=value").unwrap();
322        assert_eq!(k, "key");
323        assert_eq!(v, "value");
324    }
325
326    #[test]
327    fn normalize_commit_date_override_rejects_invalid_tz_suffix() {
328        assert_eq!(
329            normalize_commit_date_override("946684800 garbage").unwrap(),
330            "946684800 +0000"
331        );
332        assert_eq!(
333            normalize_commit_date_override("@946684800 garbage").unwrap(),
334            "946684800 +0000"
335        );
336        assert_eq!(
337            normalize_commit_date_override("946684800 +0000").unwrap(),
338            "946684800 +0000"
339        );
340        assert_eq!(
341            normalize_commit_date_override("946684800 +0530").unwrap(),
342            "946684800 +0530"
343        );
344    }
345
346    #[test]
347    fn assemble_identity_normalizes_bad_tz_suffix_like_git() {
348        let now = OffsetDateTime::from_unix_timestamp(0).unwrap();
349        let line = assemble_identity(
350            "A U Thor",
351            "author@example.com",
352            Some("946684800 garbage"),
353            now,
354        );
355        assert_eq!(line, "A U Thor <author@example.com> 946684800 +0000");
356    }
357
358    #[test]
359    fn assemble_identity_at_epoch_uses_now_offset() {
360        let vars = [("TZ", "America/New_York")]
361            .into_iter()
362            .map(|(k, v)| (OsString::from(k), OsString::from(v)));
363        let env = Environment::from_vars(vars, std::path::PathBuf::from("/tmp"));
364        let now = crate::environment::offset_from_epoch_and_tz(1_112_911_993, env.tz.as_deref());
365        let line = assemble_identity("A U Thor", "author@example.com", Some("@1112911993"), now);
366        assert_eq!(line, "A U Thor <author@example.com> 1112911993 -0400");
367    }
368}