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}