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//! 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}