From 37ad84c48d434b095729ebc7cb6df701e80a6ed4 Mon Sep 17 00:00:00 2001 From: AC Date: Wed, 7 Oct 2026 20:06:16 +0800 Subject: [PATCH 1/2] fix: preserve explicit year zero in timestamp flags --- flag_test.go | 21 +++++++++++++++++++++ flag_timestamp.go | 12 ++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/flag_test.go b/flag_test.go index 9dca7de7b8..eaac907457 100644 --- a/flag_test.go +++ b/flag_test.go @@ -2910,6 +2910,27 @@ func TestTimestampFlagApply_YearlessLayoutJanuaryFirst(t *testing.T) { } } +func TestTimestampFlagApply_ExplicitYearZero(t *testing.T) { + for _, tc := range []struct{ layout, value string }{ + {time.RFC3339, "0000-02-29T12:34:56Z"}, + {time.DateOnly, "0000-01-01"}, + {"2006 15:04", "0000 12:34"}, + } { + t.Run(tc.layout, func(t *testing.T) { + want, err := time.Parse(tc.layout, tc.value) + require.NoError(t, err) + cmd := &Command{ + Flags: []Flag{&TimestampFlag{Name: "time", Config: TimestampConfig{Layouts: []string{tc.layout}}}}, + Action: func(_ context.Context, cmd *Command) error { + assert.Equal(t, want, cmd.Timestamp("time")) + return nil + }, + } + assert.NoError(t, cmd.Run(buildTestContext(t), []string{"app", "--time", tc.value})) + }) + } +} + func TestTimestampFlagApplyValue(t *testing.T) { expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Config: TimestampConfig{Layouts: []string{time.RFC3339}}, Value: expectedResult} diff --git a/flag_timestamp.go b/flag_timestamp.go index 0f1736542b..57a691c968 100644 --- a/flag_timestamp.go +++ b/flag_timestamp.go @@ -94,7 +94,8 @@ func (t *timestampValue) Set(value string) error { // The date is compared in the timestamp's own location: truncating the // instant to a day works in UTC and misses January 1 of year 0 for part // of the day whenever Timezone is not UTC. - if !layoutHasDate(matchedLayout) && timestamp.Year() == 0 && timestamp.YearDay() == 1 { + yearMissing := timestamp.Year() == 0 && !layoutHasYear(matchedLayout) + if !layoutHasDate(matchedLayout) && yearMissing && timestamp.YearDay() == 1 { timestamp = time.Date( n.Year(), n.Month(), @@ -105,7 +106,7 @@ func (t *timestampValue) Set(value string) error { timestamp.Nanosecond(), timestamp.Location(), ) - } else if timestamp.Year() == 0 { + } else if yearMissing { timestamp = time.Date( n.Year(), timestamp.Month(), @@ -133,6 +134,13 @@ func layoutHasDate(layout string) bool { return err == nil && (p.Month() != time.January || p.Day() != 1) } +// layoutHasYear distinguishes an omitted year from an explicitly parsed year 0. +func layoutHasYear(layout string) bool { + ref := time.Date(2001, time.March, 4, 5, 6, 7, 0, time.UTC) + p, err := time.Parse(layout, ref.Format(layout)) + return err == nil && p.Year() == ref.Year() +} + // String returns a readable representation of this value (for usage defaults) func (t *timestampValue) String() string { return fmt.Sprintf("%v", t.timestamp) From 029b760c04695a22f42d1c2a8ab1dd7b5f8f1589 Mon Sep 17 00:00:00 2001 From: Eng Zer Jun Date: Thu, 8 Oct 2026 23:04:38 +0800 Subject: [PATCH 2/2] refactor: probe a timestamp layout for its year and date at once layoutHasDate and layoutHasYear round-tripped the same reference time through the layout, only with different reference years (2 and 2001). An omitted year always parses as year 0, and a two-digit "06" year never parses as 0, so one probe can report both whether the layout carries a year and whether it carries a month/day. Replace both helpers with layoutFields. Reference: 37ad84c4 ("fix: preserve explicit year zero in timestamp flags") Reference: e8c2eb56 ("Fix a year-less timestamp layout losing January 1") Assisted-by: claude:claude-opus-5-5 Signed-off-by: Eng Zer Jun --- flag_timestamp.go | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/flag_timestamp.go b/flag_timestamp.go index 57a691c968..f69d4c78cf 100644 --- a/flag_timestamp.go +++ b/flag_timestamp.go @@ -94,8 +94,9 @@ func (t *timestampValue) Set(value string) error { // The date is compared in the timestamp's own location: truncating the // instant to a day works in UTC and misses January 1 of year 0 for part // of the day whenever Timezone is not UTC. - yearMissing := timestamp.Year() == 0 && !layoutHasYear(matchedLayout) - if !layoutHasDate(matchedLayout) && yearMissing && timestamp.YearDay() == 1 { + hasYear, hasDate := layoutFields(matchedLayout) + yearMissing := timestamp.Year() == 0 && !hasYear + if !hasDate && yearMissing && timestamp.YearDay() == 1 { timestamp = time.Date( n.Year(), n.Month(), @@ -126,19 +127,15 @@ func (t *timestampValue) Set(value string) error { return nil } -// layoutHasDate reports whether layout carries month/day components, probed by -// round-tripping a reference date that is not January 1. -func layoutHasDate(layout string) bool { +// layoutFields reports whether layout carries a year and month/day components, +// probed by round-tripping a reference date that is not January 1 of year 0. +func layoutFields(layout string) (hasYear, hasDate bool) { ref := time.Date(2, time.March, 4, 5, 6, 7, 0, time.UTC) - p, err := time.Parse(layout, ref.Format(layout)) - return err == nil && (p.Month() != time.January || p.Day() != 1) -} - -// layoutHasYear distinguishes an omitted year from an explicitly parsed year 0. -func layoutHasYear(layout string) bool { - ref := time.Date(2001, time.March, 4, 5, 6, 7, 0, time.UTC) - p, err := time.Parse(layout, ref.Format(layout)) - return err == nil && p.Year() == ref.Year() + if p, err := time.Parse(layout, ref.Format(layout)); err == nil { + hasYear = p.Year() != 0 + hasDate = p.Month() != time.January || p.Day() != 1 + } + return hasYear, hasDate } // String returns a readable representation of this value (for usage defaults)