1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
//! Commit-metadata helpers shared by the porcelain commands.
//!
//! This module holds pure-domain pieces of commit creation that compute a
//! result from plain inputs and carry **no** presentation, argv, or process
//! state. The first piece extracted is the date normalisation used to fill the
//! author/committer timestamp: turning a user-supplied `--date` string (or
//! `GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE`) into Git's stored `<epoch>
//! <offset>` form. It is shared by `commit`, `commit --amend`, and the
//! sequencer commands (`rebase`, `cherry-pick`, `revert`, `stash`, `notes`,
//! `tag`, `format-patch`, `checkout`).
//!
//! Commit creation from the index (write-tree, object write, ref/reflog updates)
//! lives in [`crate::porcelain::commit`]; the `grit commit` command stages paths
//! then calls that API.
//!
//! Library guide: [commit](https://grit-scm.com/docs/library/embedding)
//!
//! ```rust
//! use grit_lib::repo::{InitOptions, Repository};
//! # fn main() -> grit_lib::error::Result<()> {
//! let dir = tempfile::tempdir()?;
//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
//! # Ok(())
//! # }
//! ```
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use crate::git_date::parse::parse_date;
/// Normalise a date string into Git's stored `<epoch> <offset>` timestamp.
///
/// Accepts the forms Git's `commit`/`--date` path understands: RFC 3339 / ISO
/// 8601 (with or without an explicit zone, in which case UTC is assumed),
/// `YYYY-MM-DD HH:MM:SS <tz>`, `@<epoch> <tz>`, and the looser
/// approxidate-style strings handled by [`parse_date`]. Returns `None` when the
/// input is already in `<epoch> <offset>` form (nothing to convert) or cannot
/// be parsed; callers then fall back to using the raw string.
pub fn parse_date_to_git_timestamp(date_str: &str) -> Option<String> {
let trimmed = date_str.trim();
// ISO 8601 / RFC 3339, including forms Git accepts without an explicit offset
// (e.g. `2020-01-01T00:00:00` — treated as UTC when no zone is present).
if let Ok(dt) = OffsetDateTime::parse(trimmed, &Rfc3339) {
return Some(format_git_timestamp(dt));
}
let with_utc_z = format!("{trimmed}Z");
if let Ok(dt) = OffsetDateTime::parse(&with_utc_z, &Rfc3339) {
return Some(format_git_timestamp(dt));
}
// Already in `<epoch> <offset>` format? (epoch is all digits)
let parts: Vec<&str> = trimmed.rsplitn(2, ' ').collect();
if parts.len() == 2 {
let maybe_epoch = parts[1];
if maybe_epoch.chars().all(|c| c.is_ascii_digit()) {
// Already epoch + offset
return None;
}
}
// Try parsing "YYYY-MM-DD HH:MM:SS <tz>" format
if parts.len() == 2 {
let tz = parts[0];
let datetime = parts[1];
// Parse tz offset
let tz_bytes = tz.as_bytes();
if tz_bytes.len() >= 5 {
let sign: i64 = if tz_bytes[0] == b'-' { -1 } else { 1 };
let h: i64 = tz[1..3].parse().unwrap_or(0);
let m: i64 = tz[3..5].parse().unwrap_or(0);
let tz_secs = sign * (h * 3600 + m * 60);
// Try YYYY-MM-DD HH:MM:SS
if let Ok(offset) = time::UtcOffset::from_whole_seconds(tz_secs as i32) {
let fmt = time::format_description::parse_borrowed::<1>(
"[year]-[month]-[day] [hour]:[minute]:[second]",
)
.ok()?;
if let Ok(naive) = time::PrimitiveDateTime::parse(datetime, &fmt) {
let dt = naive.assume_offset(offset);
let epoch = dt.unix_timestamp();
return Some(format!("{epoch} {tz}"));
}
}
}
}
// Try "@<epoch>" format (git uses this for testing)
if let Some(epoch_str) = trimmed.strip_prefix('@') {
// @<epoch> <tz>
let ep_parts: Vec<&str> = epoch_str.splitn(2, ' ').collect();
if ep_parts.len() == 2 {
if let Ok(_epoch) = ep_parts[0].parse::<i64>() {
return Some(format!("{} {}", ep_parts[0], ep_parts[1]));
}
}
}
// Loose Git dates without explicit zone (e.g. `2022-02-01 00:00` from GIT_COMMITTER_DATE).
if let Ok(canonical) = parse_date(trimmed) {
return Some(canonical);
}
None
}
/// Format a timestamp in Git's format: `<epoch> <offset>`.
pub fn format_git_timestamp(dt: OffsetDateTime) -> String {
let epoch = dt.unix_timestamp();
let offset = dt.offset();
let hours = offset.whole_hours();
let minutes = offset.minutes_past_hour().unsigned_abs();
format!("{epoch} {hours:+03}{minutes:02}")
}
/// Assemble a commit identity line (`Name <email> <epoch> <offset>`) from an
/// already-resolved name and email plus a timestamp.
///
/// `date_override` is the raw value of a `GIT_AUTHOR_DATE` /
/// `GIT_COMMITTER_DATE`-style environment variable, if set: it is parsed with
/// [`parse_date_to_git_timestamp`] and used verbatim if parsing fails (matching
/// Git's lenient behavior). When `None`, `now` is formatted instead.
#[must_use]
pub fn assemble_identity(
name: &str,
email: &str,
date_override: Option<&str>,
now: OffsetDateTime,
) -> String {
let timestamp = match date_override {
Some(d) => {
let trimmed = d.trim();
if let Some(rest) = trimmed.strip_prefix('@') {
let mut parts = rest.split_whitespace();
if let Some(epoch_str) = parts.next() {
if parts.next().is_none() {
if let Ok(epoch) = epoch_str.parse::<i64>() {
if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch)
.map(|t| t.to_offset(now.offset()))
{
return format!("{name} <{email}> {}", format_git_timestamp(dt));
}
}
}
}
}
parse_date_to_git_timestamp(d).unwrap_or_else(|| d.to_string())
}
None => format_git_timestamp(now),
};
format!("{name} <{email}> {timestamp}")
}
/// Commit encoding helpers for non-UTF-8 commit messages.
pub mod encoding {
pub use crate::commit_encoding::*;
}
/// Commit trailers and interpretation helpers.
pub mod trailers {
pub use crate::commit_trailers::*;
/// Parse and apply trailer commands.
pub mod interpret {
pub use crate::interpret_trailers::*;
}
}
/// OpenPGP / SSH signature verification.
pub mod signing {
pub use crate::signing::*;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::environment::Environment;
use std::ffi::OsString;
use time::OffsetDateTime;
#[test]
fn assemble_identity_formats_supplied_now_without_override() {
let now = OffsetDateTime::from_unix_timestamp(1_112_911_993).unwrap();
let line = assemble_identity("A U Thor", "author@example.com", None, now);
assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
}
#[test]
fn assemble_identity_respects_tz_from_environment() {
let vars = [("TZ", "+0200")]
.into_iter()
.map(|(k, v)| (OsString::from(k), OsString::from(v)));
let env = Environment::from_vars(vars, std::path::PathBuf::from("/tmp"));
let now = crate::environment::offset_from_epoch_and_tz(1_112_911_993, env.tz.as_deref());
let line = assemble_identity("A U Thor", "author@example.com", None, now);
assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0200");
}
#[test]
fn assemble_identity_parses_date_override() {
let now = OffsetDateTime::from_unix_timestamp(0).unwrap();
let line = assemble_identity(
"A U Thor",
"author@example.com",
Some("2005-04-07T22:13:13 +0000"),
now,
);
assert_eq!(line, "A U Thor <author@example.com> 1112911993 +0000");
}
#[test]
fn assemble_identity_at_epoch_uses_now_offset() {
let vars = [("TZ", "America/New_York")]
.into_iter()
.map(|(k, v)| (OsString::from(k), OsString::from(v)));
let env = Environment::from_vars(vars, std::path::PathBuf::from("/tmp"));
let now = crate::environment::offset_from_epoch_and_tz(1_112_911_993, env.tz.as_deref());
let line = assemble_identity("A U Thor", "author@example.com", Some("@1112911993"), now);
assert_eq!(line, "A U Thor <author@example.com> 1112911993 -0400");
}
}