diff --git a/GitIntegration.Test/Parsing/GitLogParserTests.cs b/GitIntegration.Test/Parsing/GitLogParserTests.cs index 5f50aec..7b6ca83 100644 --- a/GitIntegration.Test/Parsing/GitLogParserTests.cs +++ b/GitIntegration.Test/Parsing/GitLogParserTests.cs @@ -173,6 +173,36 @@ public void RejectsARecordWithTooFewFields() () => GitLogParser.Parse(FirstSha + Us + FirstTree + Nul)); } + [TestMethod] + [DataRow("2023-11-15T13:13:20+15:00", DisplayName = "+15:00")] + [DataRow("2023-11-14T07:13:20-15:00", DisplayName = "-15:00")] + [DataRow("2023-11-14T22:13:20+00:00", DisplayName = "In range, for comparison")] + public void ReadsATimestampWithAnOffsetBeyondFourteenHours(string timestamp) + { + // git accepts any ±hhmm zone (GIT_AUTHOR_DATE="1700000000 +1500"), and DateTimeOffset only + // holds ±14:00. One such commit must not fail the whole log (#262). + string record = MergeCommit + .Replace("2026-08-20T00:05:20+10:00", timestamp, StringComparison.Ordinal) + .Replace("2026-08-21T09:00:00-04:00", timestamp, StringComparison.Ordinal); + + IReadOnlyList commits = GitLogParser.Parse(record); + + DateTime expected = DateTimeOffset.FromUnixTimeSeconds(1700000000).UtcDateTime; + Assert.AreEqual(expected, commits[0].Author.Timestamp.UtcDateTime); + Assert.AreEqual(expected, commits[0].Committer.Timestamp.UtcDateTime); + } + + [TestMethod] + [DataRow("2023-11-15T13:13:20+15:0x", DisplayName = "Malformed offset")] + [DataRow("2023-13-15T13:13:20+15:00", DisplayName = "Invalid month")] + [DataRow("+15:00", DisplayName = "Offset only")] + public void RejectsAMalformedTimestampWithAnOutOfRangeOffset(string timestamp) + { + string broken = TwoCommits.Replace("2026-08-20T00:05:20+10:00", timestamp, StringComparison.Ordinal); + + Assert.ThrowsExactly(() => GitLogParser.Parse(broken)); + } + [TestMethod] public void RejectsAnUnparseableTimestamp() { diff --git a/GitIntegration/Models/GitCommit.cs b/GitIntegration/Models/GitCommit.cs index 7c56c67..65fe0bf 100644 --- a/GitIntegration/Models/GitCommit.cs +++ b/GitIntegration/Models/GitCommit.cs @@ -62,6 +62,9 @@ public sealed record GitSignature /// /// Parsed from git's strict ISO-8601 output, so the original offset is preserved rather than /// normalized to UTC — the local time a commit was made in is information a caller may want. + /// The one exception is an offset beyond ±14:00, which git accepts but + /// cannot hold: that timestamp is reported in UTC, with the same + /// instant. /// public required DateTimeOffset Timestamp { get; init; } } diff --git a/GitIntegration/Parsing/GitLogParser.cs b/GitIntegration/Parsing/GitLogParser.cs index cd9bbf8..7798ba6 100644 --- a/GitIntegration/Parsing/GitLogParser.cs +++ b/GitIntegration/Parsing/GitLogParser.cs @@ -97,8 +97,71 @@ private static GitCommitSha[] ReadParents(string value) return parents; } - private static DateTimeOffset ReadTimestamp(string value) => - DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset timestamp) - ? timestamp - : throw new GitParseException($"git reported a commit timestamp that is not valid: '{value}'."); + private static DateTimeOffset ReadTimestamp(string value) + { + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset timestamp) + || TryReadOutOfRangeOffset(value, out timestamp)) + { + return timestamp; + } + + throw new GitParseException($"git reported a commit timestamp that is not valid: '{value}'."); + } + + /// + /// Reads a timestamp whose offset is beyond the ±14:00 that can hold. + /// + /// + /// git stores a commit's zone as an arbitrary ±hhmm and prints it unchanged, so imported + /// history and misconfigured clocks produce values such as +15:00 that git itself accepts + /// (ktsu-dev/GitIntegration#262). The instant is still exact, so it is returned in UTC rather + /// than failing the whole log for one record. + /// + private static bool TryReadOutOfRangeOffset(string value, out DateTimeOffset timestamp) + { + timestamp = default; + + // %aI is "yyyy-MM-ddTHH:mm:ss" followed by "+hh:mm" or "-hh:mm". + const int OffsetLength = 6; + if (value.Length <= OffsetLength) + { + return false; + } + + string localPart = value[..^OffsetLength]; + string offsetPart = value[^OffsetLength..]; + + if (offsetPart[0] is not ('+' or '-') + || offsetPart[3] != ':' + || !int.TryParse(offsetPart[1..3], NumberStyles.None, CultureInfo.InvariantCulture, out int hours) + || !int.TryParse(offsetPart[4..], NumberStyles.None, CultureInfo.InvariantCulture, out int minutes) + || !DateTime.TryParseExact(localPart, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime local)) + { + return false; + } + + TimeSpan offset = new(hours, minutes, 0); + if (offsetPart[0] == '-') + { + offset = offset.Negate(); + } + + // Only the case DateTimeOffset refuses is handled here: an in-range offset that still failed + // to parse is malformed for some other reason. + if (offset.Duration() <= TimeSpan.FromHours(14)) + { + return false; + } + + try + { + timestamp = new DateTimeOffset(DateTime.SpecifyKind(local - offset, DateTimeKind.Utc)); + return true; + } + catch (ArgumentOutOfRangeException) + { + // The instant falls outside the range DateTime can represent. + return false; + } + } }