From 414dfeb0e94ed51f103b2ae65d3a70bf1ae5aab7 Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Thu, 6 Aug 2026 16:27:44 +0530 Subject: [PATCH 1/3] fix: accept the '@' prefix on epoch dates, like Git does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse()` is documented as parsing any time that Git can parse, and `@` is the one prefix Git checks before anything else in `parse_date_basic()`. Two paths reach a timestamp there: `@ ±HHMM` is taken by that fast path for any value, while a bare `@` is skipped as an unmatched byte and recognised only by the epoch heuristic that starts at 100000000. Both forms were rejected here even though the underlying `1660874655 +0800` and `1234567890` formats already parse, so the prefix is stripped and those parsers reused. Also corrects the relative-date documentation, which advertised `1 hour from now` although only `now`, `today`, `yesterday` and ` ago` are understood. --- gix-date/src/parse/function.rs | 19 ++++++++++++++++++- .../fixtures/generate_git_date_baseline.sh | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/gix-date/src/parse/function.rs b/gix-date/src/parse/function.rs index b700511e72c..06079ac2ce7 100644 --- a/gix-date/src/parse/function.rs +++ b/gix-date/src/parse/function.rs @@ -54,6 +54,9 @@ use gix_error::{Exn, ResultExt}; /// * `1660874655 +0800` /// * `-1660874655 +0800` /// +/// A leading `@` may introduce either of the two forms above, as in `@1745582210 +0200` +/// or `@1700000000`. +/// /// See also the [`parse_header()`]. /// /// ### 8. GITOXIDE Format @@ -66,13 +69,27 @@ use gix_error::{Exn, ResultExt}; /// * `Thu Sep 4 10:45:06 2022 -0400` /// * `Mon Oct 27 10:30:00 2023 +0000` /// -/// ### 10. Relative Dates (e.g., "2 minutes ago", "1 hour from now") +/// ### 10. Relative Dates (e.g., "2 minutes ago") /// /// These dates are parsed *relative to a `now` timestamp*. The examples depend entirely on the value of `now`. /// If `now` is October 27, 2023 at 10:00:00 UTC: /// * `2 minutes ago` (October 27, 2023 at 09:58:00 UTC) /// * `3 hours ago` (October 27, 2023 at 07:00:00 UTC) +/// +/// The forms understood are `now`, `today`, `yesterday`, and ` ago`. Future forms such +/// as `1 hour from now` are not accepted. pub fn parse(input: &str, now: Option) -> Result> { + // Git accepts a leading `@` before a commit-header date: `match_object_header_date()` in + // `date.c` takes ` ±HHMM`, while an offsetless `@` arrives at the same + // result through the generic loop, which skips the `@` and reads the digits as an epoch. + if let Some(rest) = input.strip_prefix('@') { + if let Some(val) = parse_raw(rest) { + return Ok(val); + } + if let Ok(seconds) = SecondsSinceUnixEpoch::from_str(rest) { + return Ok(Time::new(seconds, 0)); + } + } Ok(if let Ok(val) = Date::strptime(SHORT.0, input) { let val = val .to_zoned(TimeZone::UTC) diff --git a/gix-date/tests/fixtures/generate_git_date_baseline.sh b/gix-date/tests/fixtures/generate_git_date_baseline.sh index eee8bf002f3..f5f296cf97c 100755 --- a/gix-date/tests/fixtures/generate_git_date_baseline.sh +++ b/gix-date/tests/fixtures/generate_git_date_baseline.sh @@ -130,6 +130,13 @@ baseline '946684800 +0000' 'RAW' baseline '1466000000 +0200' 'RAW' # from git t0006 baseline '1466000000 -0200' 'RAW' # from git t0006 +# Git accepts a leading `@` before either of the two forms above. Re-formatting is not checked, +# as the `@` isn't reproduced. +baseline '@1234567890' '' +baseline '@100000000' '' +baseline '@1660874655 +0800' '' +baseline '@1466000000 -0200' '' + # Note: Git does not support negative timestamps through --type=expiry-date # gix-date does support them, but they can't be tested via the baseline. From 1ebbd8cb34e8083b19e7186d07c6316ad4f9e280 Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Fri, 7 Aug 2026 00:13:39 +0530 Subject: [PATCH 2/3] fix: accept the relative dates Git accepts, and read their units case-insensitively. Five shapes `approxidate()` in Git's `date.c` accepts were not accepted here. Units are now matched without regard to case, as `match_string()` does. This one produced a wrong answer rather than a rejection: `2 HOURS ago` fell through to the catch-all and became two *seconds* ago, which looks like a date rather than an error. More than one ` ` pair is read, so `2 days 3 hours ago` is both of them. Git applies a unit the moment it sees one and carries on, and stopping after the first pair would substitute a plausible-looking two days. Any byte that is neither a digit nor a letter separates the parts, because `approxidate_alpha()` ends a word at the first byte that is not a letter. So `1.hour.ago` and `1-hour-ago` read as `1 hour ago` does, and a leading `-` is a separator too, which is why `-1 days ago` is one day rather than a rejection. Note this holds for the ` ` shapes handled here; `approxidate_digit()` does look at the byte after a digit run, which is how `2008.12.24` stays a date. Counts may be spelled out, from `one` to `ten`, as they are in `number_name[]`. `zero` is not among them, since Git's lookup starts at one. `last` is a count of one, so `last week` resolves. The trailing `ago` is not required, because Git applies a unit as soon as it sees one; even `2 days hence` resolves into the past there. It is still what permits an unknown unit to count as seconds, though, or `1745582210 +0200` would parse as a count in the unknown unit `0200` and never reach `parse_raw()`. Seven entries are added to the baseline, so these record Git's own answers. The comment claiming Git reads an unknown unit as seconds is corrected: it does not, and leaves the count pending, where it stands in for a field of the date itself. Not addressed: `noon`, `midnight`, `tea`, `AM` and `PM` are in Git's `special[]` table and still rejected here. They resolve against the local time zone, and this parser deliberately works in UTC. --- gix-date/src/parse/function.rs | 9 +- gix-date/src/parse/relative.rs | 100 +++++++++++++++--- .../fixtures/generate_git_date_baseline.sh | 20 ++++ gix-date/tests/time/baseline.rs | 12 ++- 4 files changed, 117 insertions(+), 24 deletions(-) diff --git a/gix-date/src/parse/function.rs b/gix-date/src/parse/function.rs index 06079ac2ce7..a671c41d681 100644 --- a/gix-date/src/parse/function.rs +++ b/gix-date/src/parse/function.rs @@ -76,8 +76,13 @@ use gix_error::{Exn, ResultExt}; /// * `2 minutes ago` (October 27, 2023 at 09:58:00 UTC) /// * `3 hours ago` (October 27, 2023 at 07:00:00 UTC) /// -/// The forms understood are `now`, `today`, `yesterday`, and ` ago`. Future forms such -/// as `1 hour from now` are not accepted. +/// The forms understood are `now`, `today`, `yesterday`, and one or more ` ` pairs, +/// as in `2 days 3 hours ago`. A count may be spelled out from `one` to `ten`, or be `last`, and +/// any byte that is neither a digit nor a letter separates the parts, so `1.hour.ago` is the same +/// as `1 hour ago`. The trailing `ago` is optional. +/// +/// Note that there is no way to name a time in the future: Git has none either, so `1 hour from +/// now` is an hour in the past to it, and to this function. pub fn parse(input: &str, now: Option) -> Result> { // Git accepts a leading `@` before a commit-header date: `match_object_header_date()` in // `date.c` takes ` ±HHMM`, while an offsetless `@` arrives at the same diff --git a/gix-date/src/parse/relative.rs b/gix-date/src/parse/relative.rs index a86f0a3c66c..e04b19134c0 100644 --- a/gix-date/src/parse/relative.rs +++ b/gix-date/src/parse/relative.rs @@ -40,13 +40,63 @@ fn parse_named(input: &str, now: Option) -> Option Option>> { - let mut split = input.split_whitespace(); - let units = i64::from_str(split.next()?).ok()?; - let period = split.next()?; - if split.next()? != "ago" { + // For the ` ` shapes handled here, any byte that is neither a digit nor a letter + // separates the parts, because `approxidate_alpha()` in Git's `date.c` ends a word at the + // first byte that is not a letter. So `1.hour.ago` and `1 hour ago` are the same to it. + let mut words = input + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .peekable(); + + // Git applies a unit the moment it sees one and keeps going, so `2 days 3 hours ago` is both + // of them. Stopping after the first pair would turn that into a plausible-looking two days. + let mut pairs = Vec::new(); + let mut ago = false; + while let Some(word) = words.peek() { + if word.eq_ignore_ascii_case("ago") { + ago = true; + break; + } + let Some(units) = count(word) else { break }; + words.next(); + let Some(period) = words.next() else { break }; + pairs.push((period, units)); + } + if pairs.is_empty() { return None; } - span(period, units) + // `ago` may still be further along, past a word that is no count of ours. + ago |= words.any(|word| word.eq_ignore_ascii_case("ago")); + + // The trailing `ago` is not required: `2 days` is `2 days ago` to Git. It is what permits an + // unknown unit to count as seconds, though, or `1745582210 +0200` would read as a count of + // `1745582210` in the unknown unit `0200` and never reach the raw-format parser that owns it. + let mut total = Span::new(); + for (period, units) in pairs { + match span(total, period, units, ago)? { + Ok(next) => total = next, + Err(err) => return Some(Err(err)), + } + } + Some(Ok(total)) +} + +/// The count in front of the unit, either written out in digits or spelled with one of the names +/// Git keeps in `number_name[]`. Note that `zero` is deliberately absent: Git's lookup starts at +/// one, so `zero days ago` is not a relative date there either. +fn count(input: &str) -> Option { + if let Ok(units) = i64::from_str(input) { + return Some(units); + } + const NAMES: &[&str] = &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + ]; + NAMES + .iter() + .position(|name| input.eq_ignore_ascii_case(name)) + .map(|pos| pos as i64 + 1) + // `last week` is `1 week ago` to Git, which sets the count to one for it. + .or_else(|| input.eq_ignore_ascii_case("last").then_some(1)) } fn subtract_span(now: Option, span: Span) -> Result> { @@ -63,18 +113,34 @@ fn subtract_span(now: Option, span: Span) -> Result Option>> { - let period = period.strip_suffix('s').unwrap_or(period); - let result = match period { - "second" => Span::new().try_seconds(units), - "minute" => Span::new().try_minutes(units), - "hour" => Span::new().try_hours(units), - "day" => Span::new().try_days(units), - "week" => Span::new().try_weeks(units), - "month" => Span::new().try_months(units), - "year" => Span::new().try_years(units), - // Ignore values you don't know, assume seconds then (so does git) - _anything => Span::new().try_seconds(units), +fn span(total: Span, period: &str, units: i64, ago: bool) -> Option>> { + let period = period + .strip_suffix('s') + .or_else(|| period.strip_suffix('S')) + .unwrap_or(period); + // Git compares unit names with `match_string()`, which folds case. + let result = if period.eq_ignore_ascii_case("second") { + total.try_seconds(units) + } else if period.eq_ignore_ascii_case("minute") { + total.try_minutes(units) + } else if period.eq_ignore_ascii_case("hour") { + total.try_hours(units) + } else if period.eq_ignore_ascii_case("day") { + total.try_days(units) + } else if period.eq_ignore_ascii_case("week") { + total.try_weeks(units) + } else if period.eq_ignore_ascii_case("month") { + total.try_months(units) + } else if period.eq_ignore_ascii_case("year") { + total.try_years(units) + } else if ago { + // An unknown unit still counts as seconds, but only once `ago` has marked the input as a + // relative date. Note that Git does *not* read it as seconds, despite what this comment + // used to claim: it leaves the count pending, where it ends up standing in for a field of + // the date itself, so how far `1 banana ago` lands from now depends on today's date. + total.try_seconds(units) + } else { + return None; }; Some(result.or_raise(|| Error::new(format!("Couldn't parse span from '{period} {units}'")))) } diff --git a/gix-date/tests/fixtures/generate_git_date_baseline.sh b/gix-date/tests/fixtures/generate_git_date_baseline.sh index f5f296cf97c..302d52bf494 100755 --- a/gix-date/tests/fixtures/generate_git_date_baseline.sh +++ b/gix-date/tests/fixtures/generate_git_date_baseline.sh @@ -202,3 +202,23 @@ baseline_relative '10 years ago' '' baseline_relative '20 years ago' '' # Note that we can't necessarily put 64bit dates here yet as `git` on the system might not yet support it. + +# ============================================================================ +# RELATIVE FORMS GIT ACCEPTS BEYOND " ago" +# ============================================================================ +# `approxidate_str()` only ever reads runs of digits and runs of letters, so any +# other byte separates them just as a space would. +baseline_relative '1.hour.ago' '' +baseline_relative '1-hour-ago' '' + +# Unit names are compared with `match_string()`, which folds case. +baseline_relative '2 HOURS ago' '' +baseline_relative '2 Days ago' '' + +# Counts may be spelled out; `number_name[]` holds one through ten. `zero` is not +# in it, as Git's lookup starts at one. +baseline_relative 'two days ago' '' +baseline_relative 'ten minutes ago' '' + +# `last` is a count of one, and the trailing `ago` is not required. +baseline_relative 'last week' '' diff --git a/gix-date/tests/time/baseline.rs b/gix-date/tests/time/baseline.rs index cee788acca5..ad9555ea4ad 100644 --- a/gix-date/tests/time/baseline.rs +++ b/gix-date/tests/time/baseline.rs @@ -14,12 +14,14 @@ struct Sample { seconds: SecondsSinceUnixEpoch, } -/// Returns true if the pattern looks like a relative date of the form "N unit ago". -/// Note: This only covers the relative dates tested in the baseline (e.g., "1 day ago"). -/// Other relative formats like "yesterday", "last week" etc. are not included in baseline -/// testing because they would require additional handling in the baseline script. +/// Returns true if the pattern is one of the relative dates recorded by `baseline_relative()`, +/// which are the entries whose expected value depends on `GIT_TEST_DATE_NOW`. +/// +/// `ago` is matched anywhere rather than as a suffix, because a separator other than a space is +/// just as good to Git: `1.hour.ago` is a relative date too. fn is_relative_date(pattern: &str) -> bool { - pattern.ends_with(" ago") || pattern == "now" || pattern == "today" || pattern == "yesterday" + let pattern = pattern.trim().to_ascii_lowercase(); + pattern.contains("ago") || matches!(pattern.as_str(), "now" | "today" | "yesterday" | "last week") } /// The fixed "now" timestamp used for testing relative dates. From a094c4e2684f2cae8ae18e2cec13179b2895f7b0 Mon Sep 17 00:00:00 2001 From: Byron Date: Fri, 7 Aug 2026 11:05:10 +0200 Subject: [PATCH 3/3] review Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- gix-date/src/parse/relative.rs | 63 +++++++++--------- .../fixtures/generate_git_date_baseline.sh | 14 ++-- .../generate_git_date_baseline.tar | Bin 52736 -> 53248 bytes gix-date/tests/time/parse/relative.rs | 4 ++ 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/gix-date/src/parse/relative.rs b/gix-date/src/parse/relative.rs index e04b19134c0..c8d7aca1d8a 100644 --- a/gix-date/src/parse/relative.rs +++ b/gix-date/src/parse/relative.rs @@ -39,10 +39,10 @@ fn parse_named(input: &str, now: Option) -> Option Option>> { - // For the ` ` shapes handled here, any byte that is neither a digit nor a letter - // separates the parts, because `approxidate_alpha()` in Git's `date.c` ends a word at the - // first byte that is not a letter. So `1.hour.ago` and `1 hour ago` are the same to it. let mut words = input .split(|c: char| !c.is_ascii_alphanumeric()) .filter(|s| !s.is_empty()) @@ -55,9 +55,13 @@ fn parse_ago(input: &str) -> Option>> { while let Some(word) = words.peek() { if word.eq_ignore_ascii_case("ago") { ago = true; - break; + words.next(); + continue; } - let Some(units) = count(word) else { break }; + let Some(units) = count(word) else { + words.next(); + continue; + }; words.next(); let Some(period) = words.next() else { break }; pairs.push((period, units)); @@ -65,12 +69,6 @@ fn parse_ago(input: &str) -> Option>> { if pairs.is_empty() { return None; } - // `ago` may still be further along, past a word that is no count of ours. - ago |= words.any(|word| word.eq_ignore_ascii_case("ago")); - - // The trailing `ago` is not required: `2 days` is `2 days ago` to Git. It is what permits an - // unknown unit to count as seconds, though, or `1745582210 +0200` would read as a count of - // `1745582210` in the unknown unit `0200` and never reach the raw-format parser that owns it. let mut total = Span::new(); for (period, units) in pairs { match span(total, period, units, ago)? { @@ -81,8 +79,8 @@ fn parse_ago(input: &str) -> Option>> { Some(Ok(total)) } -/// The count in front of the unit, either written out in digits or spelled with one of the names -/// Git keeps in `number_name[]`. Note that `zero` is deliberately absent: Git's lookup starts at +/// The count in front of the unit, either written out in digits or spelled with one of one-ten. +/// Note that `zero` is deliberately absent: Git's lookup starts at /// one, so `zero days ago` is not a relative date there either. fn count(input: &str) -> Option { if let Ok(units) = i64::from_str(input) { @@ -95,30 +93,18 @@ fn count(input: &str) -> Option { .iter() .position(|name| input.eq_ignore_ascii_case(name)) .map(|pos| pos as i64 + 1) - // `last week` is `1 week ago` to Git, which sets the count to one for it. .or_else(|| input.eq_ignore_ascii_case("last").then_some(1)) } -fn subtract_span(now: Option, span: Span) -> Result> { - let now = now.ok_or(ValidationError::new("Missing current time"))?; - let ts: Timestamp = Timestamp::try_from(now).or_raise(|| Error::new("Could not convert current time"))?; - // N.B. This matches the behavior of this code when it was - // written with `time`, but we might consider using the system - // time zone here. If we did, then it would implement "1 day - // ago" correctly, even when it crosses DST transitions. Since - // we're in the UTC time zone here, which has no DST, 1 day is - // in practice always 24 hours. ---AG - let zdt = ts.to_zoned(TimeZone::UTC); - zdt.checked_sub(span) - .or_raise(|| Error::new(format!("Failed to subtract {zdt} from {span}"))) -} - +/// Add `units` of `period` to `total`. +/// +/// An unknown period returns `None` unless `ago` occurred in the input, in which case it is +/// treated as seconds. Span validation failures are returned as `Some(Err(_))`. fn span(total: Span, period: &str, units: i64, ago: bool) -> Option>> { let period = period .strip_suffix('s') .or_else(|| period.strip_suffix('S')) .unwrap_or(period); - // Git compares unit names with `match_string()`, which folds case. let result = if period.eq_ignore_ascii_case("second") { total.try_seconds(units) } else if period.eq_ignore_ascii_case("minute") { @@ -134,13 +120,24 @@ fn span(total: Span, period: &str, units: i64, ago: bool) -> Option, span: Span) -> Result> { + let now = now.ok_or(ValidationError::new("Missing current time"))?; + let ts: Timestamp = Timestamp::try_from(now).or_raise(|| Error::new("Could not convert current time"))?; + // N.B. This matches the behavior of this code when it was + // written with `time`, but we might consider using the system + // time zone here. If we did, then it would implement "1 day + // ago" correctly, even when it crosses DST transitions. Since + // we're in the UTC time zone here, which has no DST, 1 day is + // in practice always 24 hours. ---AG + let zdt = ts.to_zoned(TimeZone::UTC); + zdt.checked_sub(span) + .or_raise(|| Error::new(format!("Failed to subtract {zdt} from {span}"))) +} diff --git a/gix-date/tests/fixtures/generate_git_date_baseline.sh b/gix-date/tests/fixtures/generate_git_date_baseline.sh index 302d52bf494..a4d1b4193eb 100755 --- a/gix-date/tests/fixtures/generate_git_date_baseline.sh +++ b/gix-date/tests/fixtures/generate_git_date_baseline.sh @@ -206,19 +206,23 @@ baseline_relative '20 years ago' '' # ============================================================================ # RELATIVE FORMS GIT ACCEPTS BEYOND " ago" # ============================================================================ -# `approxidate_str()` only ever reads runs of digits and runs of letters, so any -# other byte separates them just as a space would. +# ascii-alnum is the for relevant partitions, so anything else separates them. baseline_relative '1.hour.ago' '' baseline_relative '1-hour-ago' '' -# Unit names are compared with `match_string()`, which folds case. +# Unit names are case-insensitive baseline_relative '2 HOURS ago' '' baseline_relative '2 Days ago' '' +baseline_relative '2 Days ago 1 hour' '' +baseline_relative '2 Days 1 hour ago' '' +baseline_relative '2 Days and 1 hour ago' '' -# Counts may be spelled out; `number_name[]` holds one through ten. `zero` is not -# in it, as Git's lookup starts at one. +# Counts can be spelled out, 1-10. +baseline_relative 'zero days ago' '' baseline_relative 'two days ago' '' baseline_relative 'ten minutes ago' '' +baseline_relative 'eleven minutes ago' '' # `last` is a count of one, and the trailing `ago` is not required. baseline_relative 'last week' '' +baseline_relative 'last day ago' '' diff --git a/gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar b/gix-date/tests/fixtures/generated-archives/generate_git_date_baseline.tar index 3cb5aa33d6c7ecfcb51f3801baa7ae92c5829064..7af1076d5368a906fac063b10680ea90de3e3490 100644 GIT binary patch delta 611 zcmZWnze~eF6zGRLi=C_D&B90~x4hK%o? zOT36gpOc=0j2l9lmJON)S}t=CvPg(g;-HjZnT8aCE`kNAZiH!0Y$4OvRF928i{9_P zi)}3L)SC6OyIoWDY!%XoS=1G-kKv52u5WE@(mcVG9iuDmQ6q<2%v3hNI7O;zW$xPNL#+Yo3+kup(ab<*f-aaja)|04@=~n^1)MgVn+Pmw84c;H+@ZB wNQnbS!GzunDu>a0|0@ diff --git a/gix-date/tests/time/parse/relative.rs b/gix-date/tests/time/parse/relative.rs index c38ac6e057d..dc9e361981b 100644 --- a/gix-date/tests/time/parse/relative.rs +++ b/gix-date/tests/time/parse/relative.rs @@ -33,6 +33,10 @@ fn various() { ("5 minutes ago", 5.minutes()), ("5 hours ago", 5.hours()), ("5 days ago", 5.days()), + ("2 Days 1 hour ago", 49.hours()), + ("2 Days ago 1 hour", 49.hours()), + ("2 Days and 23 hours ago", 71.hours()), + ("last day", 24.hours()), ("3 weeks ago", 3.weeks()), ("21 days ago", 21.days()), // 3 weeks ("504 hours ago", 504.hours()), // 3 weeks