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