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//! The larger commit-object assembly (tree-from-index, parent selection,
13//! message editing, hook dispatch, HEAD/reflog updates) still lives in the
14//! `grit` binary's `commands/commit.rs`; it is interleaved with editor launch,
15//! hook timing, and exit-code decisions and is extracted separately.
16
17use time::format_description::well_known::Rfc3339;
18use time::OffsetDateTime;
19
20use crate::git_date::parse::parse_date;
21
22/// Normalise a date string into Git's stored `<epoch> <offset>` timestamp.
23///
24/// Accepts the forms Git's `commit`/`--date` path understands: RFC 3339 / ISO
25/// 8601 (with or without an explicit zone, in which case UTC is assumed),
26/// `YYYY-MM-DD HH:MM:SS <tz>`, `@<epoch> <tz>`, and the looser
27/// approxidate-style strings handled by [`parse_date`]. Returns `None` when the
28/// input is already in `<epoch> <offset>` form (nothing to convert) or cannot
29/// be parsed; callers then fall back to using the raw string.
30pub fn parse_date_to_git_timestamp(date_str: &str) -> Option<String> {
31 let trimmed = date_str.trim();
32
33 // ISO 8601 / RFC 3339, including forms Git accepts without an explicit offset
34 // (e.g. `2020-01-01T00:00:00` — treated as UTC when no zone is present).
35 if let Ok(dt) = OffsetDateTime::parse(trimmed, &Rfc3339) {
36 return Some(format_git_timestamp(dt));
37 }
38 let with_utc_z = format!("{trimmed}Z");
39 if let Ok(dt) = OffsetDateTime::parse(&with_utc_z, &Rfc3339) {
40 return Some(format_git_timestamp(dt));
41 }
42
43 // Already in `<epoch> <offset>` format? (epoch is all digits)
44 let parts: Vec<&str> = trimmed.rsplitn(2, ' ').collect();
45 if parts.len() == 2 {
46 let maybe_epoch = parts[1];
47 if maybe_epoch.chars().all(|c| c.is_ascii_digit()) {
48 // Already epoch + offset
49 return None;
50 }
51 }
52
53 // Try parsing "YYYY-MM-DD HH:MM:SS <tz>" format
54 if parts.len() == 2 {
55 let tz = parts[0];
56 let datetime = parts[1];
57
58 // Parse tz offset
59 let tz_bytes = tz.as_bytes();
60 if tz_bytes.len() >= 5 {
61 let sign: i64 = if tz_bytes[0] == b'-' { -1 } else { 1 };
62 let h: i64 = tz[1..3].parse().unwrap_or(0);
63 let m: i64 = tz[3..5].parse().unwrap_or(0);
64 let tz_secs = sign * (h * 3600 + m * 60);
65
66 // Try YYYY-MM-DD HH:MM:SS
67 if let Ok(offset) = time::UtcOffset::from_whole_seconds(tz_secs as i32) {
68 let fmt = time::format_description::parse(
69 "[year]-[month]-[day] [hour]:[minute]:[second]",
70 )
71 .ok()?;
72 if let Ok(naive) = time::PrimitiveDateTime::parse(datetime, &fmt) {
73 let dt = naive.assume_offset(offset);
74 let epoch = dt.unix_timestamp();
75 return Some(format!("{epoch} {tz}"));
76 }
77 }
78 }
79 }
80
81 // Try "@<epoch>" format (git uses this for testing)
82 if let Some(epoch_str) = trimmed.strip_prefix('@') {
83 // @<epoch> <tz>
84 let ep_parts: Vec<&str> = epoch_str.splitn(2, ' ').collect();
85 if ep_parts.len() == 2 {
86 if let Ok(_epoch) = ep_parts[0].parse::<i64>() {
87 return Some(format!("{} {}", ep_parts[0], ep_parts[1]));
88 }
89 }
90 }
91
92 // Loose Git dates without explicit zone (e.g. `2022-02-01 00:00` from GIT_COMMITTER_DATE).
93 if let Ok(canonical) = parse_date(trimmed) {
94 return Some(canonical);
95 }
96
97 None
98}
99
100/// Format a timestamp in Git's format: `<epoch> <offset>`.
101pub fn format_git_timestamp(dt: OffsetDateTime) -> String {
102 let epoch = dt.unix_timestamp();
103 let offset = dt.offset();
104 let hours = offset.whole_hours();
105 let minutes = offset.minutes_past_hour().unsigned_abs();
106 format!("{epoch} {hours:+03}{minutes:02}")
107}
108
109/// Assemble a commit identity line (`Name <email> <epoch> <offset>`) from an
110/// already-resolved name and email plus a timestamp.
111///
112/// `date_override` is the raw value of a `GIT_AUTHOR_DATE` /
113/// `GIT_COMMITTER_DATE`-style environment variable, if set: it is parsed with
114/// [`parse_date_to_git_timestamp`] and used verbatim if parsing fails (matching
115/// Git's lenient behavior). When `None`, `now` is formatted instead.
116#[must_use]
117pub fn assemble_identity(
118 name: &str,
119 email: &str,
120 date_override: Option<&str>,
121 now: OffsetDateTime,
122) -> String {
123 let timestamp = match date_override {
124 Some(d) => parse_date_to_git_timestamp(d).unwrap_or_else(|| d.to_string()),
125 None => format_git_timestamp(now),
126 };
127 format!("{name} <{email}> {timestamp}")
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use time::OffsetDateTime;
134
135 #[test]
136 fn assemble_identity_formats_now_without_override() {
137 // 2005-04-07T22:13:13Z
138 let now = OffsetDateTime::from_unix_timestamp(1_112_911_993).unwrap();
139 let line = assemble_identity("A U Thor", "author@example.com", None, now);
140 assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
141 }
142
143 #[test]
144 fn assemble_identity_parses_date_override() {
145 let now = OffsetDateTime::from_unix_timestamp(0).unwrap();
146 let line = assemble_identity(
147 "A U Thor",
148 "author@example.com",
149 Some("2005-04-07T22:13:13 +0000"),
150 now,
151 );
152 assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
153 }
154}