Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Dates as git shows and reads them: every `--date=` mode git-log(1)//! documents, the `TZ` values a glibc-linked git understands, and git's//! two date parsers.//!//! The calendar, the time zone database, strftime and the parsers are//! zig-datetime's; nothing here is ported from git or from glibc//! (ADR 0004). What is here is the git-flavored layer over them, and every//! rule in it was found by asking git, with `TZ` and `GIT_TEST_DATE_NOW`//! set, and comparing (the tests do so under both object formats)://!//! - **A date in its own zone.** The recorded zone `+hhmm` is read as//! `hh` hours and `mm` minutes, neither checked, so `+0099` is an hour//! and 39 minutes; that many minutes, times 60, is wrapped to 32 bits,//! which is what makes `+2147483646` seventeen minutes and twelve//! seconds. A date whose time in its zone is before the epoch is//! `error.TimestampBeforeEpoch` in every mode that shows a calendar//! date, as git refuses it (`fatal: Timestamp before Unix epoch`);//! `raw`, `unix`, `relative` and the `-local` modes show it.//! - **`default`** leaves out a recorded zone of `-0001`, as it leaves//! out the zone in `default-local`; every other mode shows it.//! - **The calendar's edges.** A date whose year zig-datetime's `Year`//! cannot hold is `error.DateOutOfRange` in every mode that shows a//! calendar date. git goes on to the year 2147485547, wrapping years//! past 2147483647 to negative ones, and then shows the epoch; that is//! not followed.//! - **Local time.** The `-local` modes show the time in the zone given,//! and git works out the offset they print by turning that local time//! back into seconds with arithmetic that covers only the years 1970 to//! 2099 and otherwise fails: outside those years the offset printed is//! `+0000`, though the time shown is still the local one. Inside them//! it is the zone's offset truncated to whole minutes.//! - **`format:`** is zig-datetime's strftime over the date at its//! recorded offset, and `format-local:` over it in the local zone, with//! conversions glibc does not know copied as text; `%N` and `%:z` are//! among those, whatever zig-datetime makes of them. git puts its own//! `%s`, `%z` and, in `format:`, an empty `%Z` in before strftime sees//! the string, and where that comes out differently the string is//! refused rather than imitated; `format` lists those cases.//! - **`TZ`** is a TZif file if one by that name exists, and otherwise a//! POSIX rule read as glibc reads one, never refused: an offset past 24//! hours is 24, junk after the offset is a daylight saving time with no//! name, and no dates are the United States' — unless a `posixrules`//! file supplies them, which is refused, as is a month of 0 or past 12.//! - **`relative`** rounds at each step — seconds to minutes to hours to//! days, each to the nearest — and changes unit at 90 seconds, 90//! minutes, 36 hours, 14 days, 70 days (weeks), 365 days (months, of//! 30 days), and from five years on drops the months.//! - **`human`** compares the date, in its own zone, with now, in the//! local one: another year shows `Mon D YYYY`; the same day is//! `relative`; one of the four days before shows the weekday and time,//! with the zone if it is not the local zone's offset now; and any//! other day of the year shows weekday, month, day and time.//!//! The current time is always passed in: nothing here reads the clock, the//! environment or a locale.
const std = @import("std");const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;
const datetime = @import("datetime");const TimeZone = datetime.TimeZone;const DateTime = datetime.DateTime;
const Diagnostics = @import("Diagnostics.zig");const Identity = @import("Identity.zig");
/// When something happened, and the time zone it was recorded in.pub const Date = Identity.Date;
// -- time zones --------------------------------------------------------------
/// A time zone as a glibc-linked git reads it from `TZ`.pub const Zone = struct { /// The rules. time_zone: TimeZone, /// Text the zone borrows, owned on its behalf. text: []u8 = &.{}, /// Whether glibc's `mktime` turns a local time in this zone back into /// the timestamp it came from, which is what strftime's own `%s` /// shows. It does for a TZif file and for a rule POSIX would accept; /// for one only glibc's lenient reading makes sense of, such as /// `XYZ3,junk`, it can answer otherwise, and that is not followed. round_trips: bool = true,
/// Coordinated Universal Time, which `TZ=:` means. pub const utc: Zone = .{ .time_zone = TimeZone.utc };
/// Frees what the zone owns. pub fn deinit(z: *Zone, gpa: Allocator) void { z.time_zone.deinit(gpa); gpa.free(z.text); z.* = undefined; }
/// The offset from UTC at `timestamp`, in seconds east. pub fn offsetAt(z: *const Zone, timestamp: i64) i32 { return z.time_zone.offsetAt(timestamp); }
/// The zone's abbreviation at `timestamp`, as `%Z` shows it. pub fn designationAt(z: *const Zone, timestamp: i64) []const u8 { return z.time_zone.typeAt(timestamp).designation; }};
/// What loading a zone can fail with. `UnsupportedTimeZone` is a `TZ`/// glibc reads by rules zig-datetime does not follow, rather than a guess/// at what glibc would make of it: a POSIX rule naming daylight saving/// time without saying when, while the database has a `posixrules` file/// for glibc to take the dates from, and a rule with a month of 0 or past/// 12.pub const LoadZoneError = error{ UnsupportedTimeZone, OutOfMemory, Canceled };
/// The zone a `TZ` value names, read as glibc reads it, which is what git/// shows local times in. `tz` is the value, or null if `TZ` is unset;/// `tzdir` is `TZDIR`, or null to look where the database usually is.////// - Unset is the machine's zone, `/etc/localtime`, or UTC without one./// - Empty is the database's `Universal` zone, or UTC under that name/// without one; `:` is UTC./// - Anything else, after an optional `:`, is a TZif file if there is one/// by that name — an absolute path, or a name in the database — and/// otherwise a POSIX rule, such as `CST6CDT,M3.2.0,M11.1.0`, read as/// leniently as glibc reads one: `ABC-25` is a day ahead, `XYZ3:75`/// three hours and 59 minutes behind, `EST5EDT` without a database has/// the United States' dates, and what is no rule at all, such as/// `Bogus/Zone`, is UTC under the name it began with, if any.pub fn loadZone(io: std.Io, gpa: Allocator, tz: ?[]const u8, tzdir: ?[]const u8) LoadZoneError!Zone { const system = datetime.tzdb.system; switch (system.resolveTz(tz)) { .utc => { // A bare `:` is plain UTC. An empty `TZ` is glibc's // `Universal` zone from the database, or UTC under that name // without one, which is what the lenient reading makes of it. if (tz.?.len > 0) return .utc; if (loadNamed(io, gpa, tzdir, "Universal")) |z| return .{ .time_zone = z } else |err| try passOn(err); return fromRule(io, gpa, tzdir, ""); }, .local => { if (system.loadLocal(io, gpa, null)) |z| return .{ .time_zone = z } else |err| try passOn(err); return .utc; }, .path => |path| { if (system.loadLocal(io, gpa, path)) |z| return .{ .time_zone = z } else |err| try passOn(err); return fromRule(io, gpa, tzdir, path); }, // glibc looks for a file first, whatever the value looks like, and // reads it as a rule only when there is none. .name, .rule => |text| { if (loadNamed(io, gpa, tzdir, text)) |z| return .{ .time_zone = z } else |err| try passOn(err); return fromRule(io, gpa, tzdir, text); }, }}
/// Lets through the failures that are not the zone's: anything else/// means the value names no zone, as glibc reads it.fn passOn(err: datetime.tzdb.system.LoadError) error{ OutOfMemory, Canceled }!void { switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Canceled => return error.Canceled, else => {}, }}
fn loadNamed(io: std.Io, gpa: Allocator, tzdir: ?[]const u8, name: []const u8) datetime.tzdb.system.LoadError!TimeZone { const system = datetime.tzdb.system; if (tzdir) |dir| return system.load(io, gpa, dir, name); var last: datetime.tzdb.system.LoadError = error.NoSystemDatabase; for (system.search_directories) |dir| { return system.load(io, gpa, dir, name) catch |err| switch (err) { error.OutOfMemory, error.Canceled => return err, else => { last = err; continue; }, }; } return last;}
/// `rule` as glibc reads a `TZ` that names no file, refusing the two/// readings zig-datetime leaves out.fn fromRule(io: std.Io, gpa: Allocator, tzdir: ?[]const u8, rule: []const u8) LoadZoneError!Zone { const posix = datetime.posixtz.parseLenient(rule); if (posix.dst) |dst| { if (monthOutOfRange(rule)) return error.UnsupportedTimeZone; // glibc takes the dates from `posixrules` when the value gives // none — nothing after the daylight saving time, or a lone comma // — and the database has that file. const comma = std.mem.findScalar(u8, rule, ','); const no_dates = comma == null or comma.? == rule.len - 1; if (no_dates and std.meta.eql(dst.start, us_start) and std.meta.eql(dst.end, us_end)) { var rules = loadNamed(io, gpa, tzdir, "posixrules") catch |err| blk: { try passOn(err); break :blk null; }; if (rules) |*r| { r.deinit(gpa); return error.UnsupportedTimeZone; } } } const text = try gpa.dupe(u8, rule); const strict_rule = if (TimeZone.fromRule(rule)) |_| true else |_| false; return .{ .time_zone = TimeZone.fromRuleLenient(text), .text = text, .round_trips = strict_rule };}
/// The dates glibc gives daylight saving time named without any, when the/// database has no `posixrules` file to take them from: the second Sunday/// of March and the first of November, at two in the morning.const us_start: datetime.posixtz.Transition = .{ .rule = .{ .month_week_day = .{ .month = .Mar, .week = 2, .weekday = .Sun } } };const us_end: datetime.posixtz.Transition = .{ .rule = .{ .month_week_day = .{ .month = .Nov, .week = 1, .weekday = .Sun } } };
/// Whether a `Mm.w.d` date after the first comma of `rule` has a month of/// 0 or past 12, the number read as glibc reads it, modulo 65536. Text/// glibc stops reading before is looked at too, which refuses a little/// more than it has to.fn monthOutOfRange(rule: []const u8) bool { const comma = std.mem.findScalar(u8, rule, ',') orelse return false; var i = comma; while (std.mem.findScalarPos(u8, rule, i, 'M')) |m| { i = m + 1; if (i >= rule.len or !std.ascii.isDigit(rule[i])) continue; var month: u16 = 0; while (i < rule.len and std.ascii.isDigit(rule[i])) : (i += 1) month = month *% 10 +% (rule[i] - '0'); if (month == 0 or month > 12) return true; } return false;}
// -- parsing -----------------------------------------------------------------
/// What `approximate` answers: a timestamp, and whether anything in the/// text was understood at all.pub const Approximate = datetime.gitdate.Approximate;
/// Reads a date as `git log --since`, `--until` and `ref@{date}` do:/// `yesterday`, `2 weeks ago`, `noon last monday` and every strict form./// It never fails; a text it understood nothing of is `now`, with/// `failed` set, which `--since` takes quietly and `ref@{...}` does not.pub fn approximate(text: []const u8, now: i64, zone: *const Zone) Approximate { return datetime.gitdate.approximate(text, .{ .now = now, .zone = &zone.time_zone });}
/// What `strict` can fail with: the text is not a whole date, which git/// reports as `invalid date format`.pub const StrictError = datetime.gitdate.ParseError;
/// Reads a date as `GIT_COMMITTER_DATE` and `git commit --date` do, and/// returns it as git would record it: a date without a zone is local, and/// recorded with the local offset, truncated to whole minutes.pub fn strict(text: []const u8, zone: *const Zone) StrictError!Date { const exact = try datetime.gitdate.strict(text, &zone.time_zone); return .{ .seconds = exact.timestamp, .zone = hhmm(@divTrunc(exact.offset, 60)) };}
// -- modes -------------------------------------------------------------------
/// How a date is shown: the `--date=` modes of git-log(1).pub const Style = enum { /// `Thu Apr 7 15:13:13 2005 -0700`. default, /// `2 hours ago`. relative, /// `2005-04-07 15:13:13 -0700`: `iso` or `iso8601`. iso, /// `2005-04-07T15:13:13-07:00`: `iso-strict` or `iso8601-strict`. iso_strict, /// `Thu, 7 Apr 2005 15:13:13 -0700`: `rfc` or `rfc2822`. rfc, /// `2005-04-07`. short, /// `1112911993 -0700`. raw, /// `1112911993`. unix, /// `relative` for today, and less of the date the nearer it is. human, /// `format:` with a strftime string. format,};
/// A `--date=` mode.pub const Mode = struct { style: Style = .default, /// The `-local` variant: shown in the local zone rather than the one /// the date was recorded in. local: bool = false, /// For `format`, the strftime string, borrowed from the mode's text. format: []const u8 = "",};
/// What `parseMode` can fail with, which git reports as/// `fatal: unknown date format <mode>` and/// `fatal: date format missing colon separator: <mode>`. The diagnostics/// hold that text, without `fatal: `.pub const ParseModeError = error{ UnknownDateFormat, MissingColonSeparator };
/// What `parseMode` needs to know besides the text.pub const ParseModeOptions = struct { /// Whether the output is a terminal. `auto:<mode>` is `<mode>` on a /// terminal and `default` anywhere else — where git does not even /// check what follows the colon. terminal: bool = false,};
const style_names = [_]struct { []const u8, Style }{ .{ "default", .default }, .{ "relative", .relative }, .{ "iso8601", .iso }, .{ "iso", .iso }, .{ "iso8601-strict", .iso_strict }, .{ "iso-strict", .iso_strict }, .{ "rfc2822", .rfc }, .{ "rfc", .rfc }, .{ "short", .short }, .{ "raw", .raw }, .{ "unix", .unix }, .{ "human", .human },};
/// Reads a `--date=` mode exactly as git does: the names are matched/// whole and with case; any of them takes `-local`; `local` is/// `default-local`; `format:` and `format-local:` take the rest as a/// strftime string; and `auto:` chooses by `options.terminal`.pub fn parseMode(text: []const u8, options: ParseModeOptions, diagnostics: ?*Diagnostics) ParseModeError!Mode { const auto = "auto:"; if (std.mem.startsWith(u8, text, auto)) { if (!options.terminal) return .{}; return parseNamedMode(text[auto.len..], diagnostics); } return parseNamedMode(text, diagnostics);}
fn parseNamedMode(text: []const u8, diagnostics: ?*Diagnostics) ParseModeError!Mode { const local_suffix = "-local"; if (std.mem.startsWith(u8, text, "format")) { var rest = text["format".len..]; const local = std.mem.startsWith(u8, rest, local_suffix); if (local) rest = rest[local_suffix.len..]; if (rest.len == 0 or rest[0] != ':') { if (diagnostics) |d| d.setDetail("date format missing colon separator: {s}", .{text}); return error.MissingColonSeparator; } return .{ .style = .format, .local = local, .format = rest[1..] }; } if (std.mem.eql(u8, text, "local")) return .{ .local = true }; const local = std.mem.endsWith(u8, text, local_suffix); const base = if (local) text[0 .. text.len - local_suffix.len] else text; for (style_names) |entry| { if (std.mem.eql(u8, base, entry[0])) return .{ .style = entry[1], .local = local }; } if (diagnostics) |d| d.setDetail("unknown date format {s}", .{text}); return error.UnknownDateFormat;}
// -- formatting --------------------------------------------------------------
/// What `format` needs besides the date and the mode.pub const FormatOptions = struct { /// The current time, for `relative` and `human`. now: i64, /// The local zone, for the `-local` modes and `human`. zone: *const Zone,};
/// What `format` can fail with.////// - `TimestampBeforeEpoch`: the date, in its own zone, is before 1970/// (or the timestamp is negative), which git refuses to show./// - `DateOutOfRange`: a date whose year, in the zone it is shown in, a/// zig-datetime `Year` cannot hold, in any mode that shows a calendar/// date. git goes on past it, wrapping the year; that is not followed./// `format:` also refuses the last year a `Year` holds, whose ISO/// week-numbering year can be the one after./// - `UnsupportedConversion`: a `format:` string git would write/// differently from strftime, which the diagnostics name. See/// `format`.pub const FormatError = error{ TimestampBeforeEpoch, DateOutOfRange, UnsupportedConversion } || Writer.Error;
/// Writes `date` as `git log --date=<mode>` shows it.////// `format:` and `format-local:` are zig-datetime's strftime over the/// date at its recorded offset, or in the local zone, with conversions/// strftime does not know copied as text, as glibc copies them; `%N` and/// `%:z`, which zig-datetime takes from date(1), are copied too, since/// glibc knows neither. git writes the string in two steps — its own/// values for `%s`, `%z` and (in `format:`) `%Z` first, a `%` and the/// byte after it at a time, then strftime for the rest — so where that/// comes out differently the string is refused rather than imitated:////// - a `%s`, `%z` or `%Z` that git's pairing of `%` signs and strftime's/// reading disagree about, as in `%E%s`, `%-%z` or `%5%%s`;/// - `%s` with a flag, width or modifier, which git leaves to the C/// library's `mktime`, outside a local time in a zone known to give the/// timestamp back;/// - in `format:`, `%z` with a flag, width or modifier at a nonzero/// offset, and `%Z` with one, which git writes as UTC's;/// - git's own `%s` where it is not the timestamp (a local time from 2100/// on, or a local offset with seconds in it);/// - git's own `%z` where it is not the offset strftime writes: a/// recorded zone that is not hours and minutes, such as `+0099`, or a/// local time outside 1970 to 2099, which git shows as `+0000`;/// - in `format-local:`, `%Z` for an abbreviation longer than a/// `DateTime` holds;/// - a field width past 255.pub fn format( w: *Writer, date: Date, mode: Mode, options: FormatOptions, diagnostics: ?*Diagnostics,) FormatError!void { const t = date.seconds; switch (mode.style) { .unix => return w.print("{d}", .{t}), .raw => { try w.print("{d} ", .{t}); return writeZone(w, if (mode.local) localZone(options.zone, t).zone else date.zone); }, .relative => { if (t < 0) return error.TimestampBeforeEpoch; return writeRelative(w, @as(i128, options.now) - t); }, .human => return writeHuman(w, date, mode.local, options), else => {}, } if (t < 0) return error.TimestampBeforeEpoch; const view = if (mode.local) try localView(options.zone, t) else try recordedView(date); switch (mode.style) { .default => { try put(w, view.dt, "%a %b %-d %H:%M:%S %Y"); // git leaves out a zone of -0001 here, as it does a local one. if (!mode.local and view.zone != -1) { try w.writeByte(' '); try writeZone(w, view.zone); } }, .iso => { try put(w, view.dt, "%Y-%m-%d %H:%M:%S "); try writeZone(w, view.zone); }, .iso_strict => { try put(w, view.dt, "%Y-%m-%dT%H:%M:%S"); if (view.zone == 0) return w.writeByte('Z'); const magnitude = @abs(view.zone); try w.print("{c}{d:0>2}:{d:0>2}", .{ sign(view.zone), magnitude / 100, magnitude % 100 }); }, .rfc => { try put(w, view.dt, "%a, %-d %b %Y %H:%M:%S "); try writeZone(w, view.zone); }, .short => try put(w, view.dt, "%Y-%m-%d"), .format => { try checkStrftime(mode.format, mode.local, view, diagnostics); try writeStrftime(w, mode.format, view.dt); }, .unix, .raw, .relative, .human => unreachable, }}
/// The first second of 2100, where git's conversion of a local time back/// to seconds stops working.const conversion_end: i64 = 4102444800;
/// A time as git breaks it down to show it.const View = struct { /// The fields, with the offset they were read at and, for a local /// time, the zone's abbreviation. dt: DateTime, /// The timestamp. timestamp: i64, /// The zone git shows, as hours times a hundred plus minutes. zone: i32, /// What git shows for `%s`, before it is printed unsigned. epoch: i64, /// The zone's abbreviation in full, which `dt` may not have room for. designation: []const u8 = "", /// Whether the C library's `mktime` gives the timestamp back from the /// fields, which is what strftime's own `%s` shows to git. round_trips: bool = false,};
/// The fields `local` seconds past the epoch, read at `offset`.fn civil(local: i128, offset: i32) error{DateOutOfRange}!DateTime { if (local < datetime.Date.min_seconds or local > datetime.Date.max_seconds) return error.DateOutOfRange; var dt = datetime.Instant.fromNanoTimeStamp(local * std.time.ns_per_s).asDateTime(); dt.offset = offset; return dt;}
/// The date in the zone it was recorded in.fn recordedView(date: Date) error{ TimestampBeforeEpoch, DateOutOfRange }!View { if (date.seconds < 0) return error.TimestampBeforeEpoch; const offset = zoneSeconds(date.zone); const local = @as(i128, date.seconds) + offset; if (local < 0) return error.TimestampBeforeEpoch; return .{ .dt = try civil(local, offset), .timestamp = date.seconds, .zone = date.zone, .epoch = if (local < conversion_end) date.seconds else -1 - offset, };}
/// The zone git shows for `timestamp` in the local zone, and what it/// shows for `%s`. git works these out by turning the local time back/// into seconds with arithmetic that covers only 1970 to 2099; outside/// those years the zone is `+0000` and the seconds -1. Inside them the/// offset is truncated to whole minutes.fn localZone(zone: *const Zone, timestamp: i64) struct { offset: i32, zone: i32, epoch: i64 } { const offset = zone.offsetAt(timestamp); const local = @as(i128, timestamp) + offset; if (local < 0 or local >= conversion_end) return .{ .offset = offset, .zone = 0, .epoch = -1 }; const minutes = @divTrunc(offset, 60); return .{ .offset = offset, .zone = hhmm(minutes), .epoch = timestamp + (offset - minutes * 60) };}
/// `timestamp` in the local zone, as the `-local` modes show it.fn localView(zone: *const Zone, timestamp: i64) error{DateOutOfRange}!View { const git = localZone(zone, timestamp); const designation = zone.designationAt(timestamp); var dt = try civil(@as(i128, timestamp) + git.offset, git.offset); dt.designation = .from(designation); return .{ .dt = dt, .timestamp = timestamp, .zone = git.zone, .epoch = git.epoch, .designation = designation, .round_trips = zone.round_trips, };}
/// A recorded zone as seconds east of UTC, the way git reckons it: hours/// and minutes taken as written, and the product wrapped to 32 bits.fn zoneSeconds(zone: i32) i32 { const magnitude: i64 = @abs(zone); const minutes = @divTrunc(magnitude, 100) * 60 + @mod(magnitude, 100); return @truncate((if (zone < 0) -minutes else minutes) * 60);}
/// Minutes east of UTC as a zone is written: hours times a hundred plus/// minutes.fn hhmm(minutes: i32) i32 { const magnitude: i32 = @intCast(@abs(minutes)); const value = @divTrunc(magnitude, 60) * 100 + @mod(magnitude, 60); return if (minutes < 0) -value else value;}
fn sign(zone: i32) u8 { return if (zone < 0) '-' else '+';}
fn writeZone(w: *Writer, zone: i32) Writer.Error!void { try w.print("{c}{d:0>4}", .{ sign(zone), @abs(zone) });}
/// Writes `dt` with a strftime string known at compile time, without/// flushing `w`.fn put(w: *Writer, dt: DateTime, comptime spec: []const u8) Writer.Error!void { var buffer: [128]u8 = undefined; try w.writeAll(try strftime.bufFormat(&buffer, dt, spec));}
fn writeCount(w: *Writer, n: i128, unit: []const u8) Writer.Error!void { try w.print("{d} {s}{s}", .{ n, unit, if (n == 1) "" else "s" });}
/// `relative`: how long ago, `diff` seconds before now.fn writeRelative(w: *Writer, diff: i128) Writer.Error!void { if (diff < 0) return w.writeAll("in the future"); if (diff < 90) return writeAgo(w, diff, "second"); const minutes = @divTrunc(diff + 30, 60); if (minutes < 90) return writeAgo(w, minutes, "minute"); const hours = @divTrunc(minutes + 30, 60); if (hours < 36) return writeAgo(w, hours, "hour"); const days = @divTrunc(hours + 12, 24); if (days < 14) return writeAgo(w, days, "day"); if (days < 70) return writeAgo(w, @divTrunc(days + 3, 7), "week"); if (days < 365) return writeAgo(w, @divTrunc(days + 15, 30), "month"); if (days < 5 * 365) { const total_months = @divTrunc(days * 12 + 182, 365); const years = @divTrunc(total_months, 12); const months = @mod(total_months, 12); try writeCount(w, years, "year"); if (months != 0) { try w.writeAll(", "); try writeCount(w, months, "month"); } return w.writeAll(" ago"); } return writeAgo(w, @divTrunc(days + 183, 365), "year");}
fn writeAgo(w: *Writer, n: i128, unit: []const u8) Writer.Error!void { try writeCount(w, n, unit); try w.writeAll(" ago");}
/// `human`.fn writeHuman(w: *Writer, date: Date, local: bool, options: FormatOptions) FormatError!void { if (date.seconds < 0) return error.TimestampBeforeEpoch; const view = if (local) try localView(options.zone, date.seconds) else try recordedView(date); const now = try localView(options.zone, options.now); if (view.dt.year != now.dt.year) return put(w, view.dt, "%b %-d %Y"); if (view.dt.month == now.dt.month and view.dt.day == now.dt.day) return writeRelative(w, @as(i128, options.now) - date.seconds); const days_back = @as(i32, now.dt.dayOfYear()) - view.dt.dayOfYear(); if (days_back > 0 and days_back < 5) { try put(w, view.dt, "%a %H:%M"); if (!local and view.zone != now.zone) { try w.writeByte(' '); try writeZone(w, view.zone); } return; } try put(w, view.dt, "%a %b %-d %H:%M");}
// -- format: -----------------------------------------------------------------
const strftime = datetime.strftime;const Scanner = strftime.Scanner;
/// Refuses a `format:` string git would write differently from/// strftime; `format` lists what that is.fn checkStrftime(spec: []const u8, local: bool, view: View, diagnostics: ?*Diagnostics) FormatError!void { // The last year a `Year` holds is refused too: its ISO week-numbering // year can be the one after. if (view.dt.year == std.math.maxInt(datetime.Year)) return error.DateOutOfRange;
// Where git puts in a value of its own: each `%` and the byte after // it, a pair at a time, is `%s`, `%z` or, for `format:`, `%Z`. var pairs: Pairs = .{ .spec = spec, .local = local }; var next_pair = pairs.next();
var scanner: Scanner = .{ .format = spec, .unknown = .copy }; while (true) { const start = scanner.index; const chunk = scanner.next() catch |err| switch (err) { error.WidthTooLarge => return unsupported(diagnostics, "a field width past 255 in {s}", spec[start..]), // Only a scanner refusing what it does not know returns these. error.UnknownConversion, error.UnfinishedConversion => unreachable, } orelse break; const text = spec[start..scanner.index]; const plain = text.len == 2; // git's value, put in where strftime reads the same conversion. const git_value = if (next_pair) |p| p == start and plain else false; if (next_pair) |p| if (p < scanner.index) { if (!git_value) return unsupported(diagnostics, "%{s} read by git as a pair of its own", spec[p + 1 .. p + 2]); next_pair = pairs.next(); }; const field = switch (chunk) { .conversion => |field| field, else => continue, }; switch (field.which) { .epoch_seconds => if (git_value) { if (view.epoch != view.timestamp) return unsupported(diagnostics, "{s}, which git shows as seconds of its own", text); } else if (!(local and view.round_trips)) { return unsupported(diagnostics, "{s}, which git leaves to the C library's mktime", text); }, .offset => if (git_value) { var git_buffer: [16]u8 = undefined; var git_writer: Writer = .fixed(&git_buffer); writeZone(&git_writer, view.zone) catch unreachable; var ours: [16]u8 = undefined; const written = strftime.bufFormat(&ours, view.dt, "%z") catch unreachable; if (!std.mem.eql(u8, git_writer.buffered(), written)) return unsupported(diagnostics, "%z, which git shows as {s}", git_writer.buffered()); } else if (!local and view.dt.offset != 0) { return unsupported(diagnostics, "{s}, which git writes at UTC's offset", text); }, .zone => if (!local) { if (!git_value) return unsupported(diagnostics, "{s}, which git writes as UTC's", text); } else if (view.designation.len > view.dt.designation.bytes.len) { return unsupported(diagnostics, "%Z of an abbreviation as long as {s}", view.designation); }, else => {}, } }}
/// The places git puts its own values in a `format:` string.const Pairs = struct { spec: []const u8, local: bool, index: usize = 0,
fn next(p: *Pairs) ?usize { while (std.mem.findScalarPos(u8, p.spec, p.index, '%')) |at| { if (at + 1 >= p.spec.len) break; p.index = at + 2; switch (p.spec[at + 1]) { 's', 'z' => return at, 'Z' => if (!p.local) return at, else => {}, } } p.index = p.spec.len; return null; }};
/// strftime over `dt`, copying what it does not know as text.fn writeStrftime(w: *Writer, spec: []const u8, dt: DateTime) Writer.Error!void { var scanner: Scanner = .{ .format = spec, .unknown = .copy }; while (true) { const start = scanner.index; // `checkStrftime` has seen every width. const chunk = (scanner.next() catch unreachable) orelse break; const text = spec[start..scanner.index]; switch (chunk) { .conversion => |field| switch (field.which) { // glibc knows none of these, which zig-datetime takes from // date(1), and git prints them as text: `%N` whole, and // the `%:z` family as far as the first colon, which is // where glibc stops knowing it, with the rest as text. .nanosecond, .offset_colon, .offset_colon_seconds, .offset_minimal => { const end = if (std.mem.findScalar(u8, text, ':')) |colon| colon + 1 else text.len; try strftime.writeChunk(dt, .{ .unknown = .{ .text = text[0..end], .zero = field.pad == .zero, .upper = field.case == .upper, .width = field.width, } }, datetime.locale.en, w); try w.writeAll(text[end..]); continue; }, else => {}, }, else => {}, } try strftime.writeChunk(dt, chunk, datetime.locale.en, w); }}
fn unsupported(diagnostics: ?*Diagnostics, comptime why: []const u8, arg: []const u8) error{UnsupportedConversion} { if (diagnostics) |d| d.setDetail("unsupported strftime conversion: " ++ why, .{arg}); return error.UnsupportedConversion;}
// -- tests -------------------------------------------------------------------
const testing = std.testing;const fixture = @import("testing/fixture.zig");const fuzz_input = @import("testing/fuzz.zig");
test "a mode name is read as git log --date= reads it" { const cases = [_]struct { []const u8, ParseModeError!Mode }{ .{ "default", .{} }, .{ "local", .{ .local = true } }, .{ "default-local", .{ .local = true } }, .{ "iso8601-strict-local", .{ .style = .iso_strict, .local = true } }, .{ "unix-local", .{ .style = .unix, .local = true } }, .{ "format:%Y", .{ .style = .format, .format = "%Y" } }, .{ "format-local:", .{ .style = .format, .local = true } }, .{ "auto:bogus", .{} }, .{ "local-local", error.UnknownDateFormat }, .{ "ISO", error.UnknownDateFormat }, .{ "iso-", error.UnknownDateFormat }, .{ "", error.UnknownDateFormat }, .{ "auto", error.UnknownDateFormat }, .{ "format", error.MissingColonSeparator }, .{ "format-localx:%Y", error.MissingColonSeparator }, }; for (cases) |case| { errdefer std.debug.print("mode: \"{s}\"\n", .{case[0]}); const got = parseMode(case[0], .{}, null); if (case[1]) |want| { const mode = try got; try testing.expectEqual(want.style, mode.style); try testing.expectEqual(want.local, mode.local); try testing.expectEqualStrings(want.format, mode.format); } else |want| try testing.expectError(want, got); } // On a terminal, `auto:` reads what follows, and only once. try testing.expectEqual(Style.human, (try parseMode("auto:human", .{ .terminal = true }, null)).style); var d: Diagnostics = .{}; try testing.expectError(error.UnknownDateFormat, parseMode("auto:auto:iso", .{ .terminal = true }, &d)); try testing.expectEqualStrings("unknown date format auto:iso", d.detail()); d.reset(); try testing.expectError(error.MissingColonSeparator, parseMode("auto:format", .{ .terminal = true }, &d)); try testing.expectEqualStrings("date format missing colon separator: format", d.detail());}
/// The modes the differential tests ask git for, every name and every/// `-local` variant.const tested_modes = [_][]const u8{ "default", "local", "relative", "iso", "iso8601", "iso-strict", "iso8601-strict", "rfc", "rfc2822", "short", "raw", "unix", "human", "default-local", "relative-local", "iso-local", "iso8601-local", "iso-strict-local", "rfc-local", "short-local", "raw-local", "unix-local", "human-local", "iso8601-strict-local", "rfc2822-local",};
/// strftime strings for `format:` and `format-local:`.const tested_formats = [_][]const u8{ "%Y-%m-%d %H:%M:%S %z %Z %s", "%c|%x|%X|%D|%F|%r|%R|%T", "%a %A %b %B %h %p %P %C %g %G %j %u %U %V %w %W %y %e %k %l %I", "%-d %_d %0e %-e %^a %#a %^A %^b %#B %#p %^p %#P %^P %-j %_j %0k %-l %-H %_M %0S %^#p %#^a %^c %#c %^r %-m", "%Ec %EC %Ex %EX %Ey %EY %Od %Oe %OH %OI %Om %OM %OS %Ou %OU %OV %Ow %OW %Oy %Ob %OB %Op %OG %Ep %Er", "%Q %+ %v %N %:z %Ea %OY %-Q %EH %Oc %^Q %+Y %+4Y %+% end%E", "%%z %%%z %%s 100%% %-% %n%t|", "", "trailing %", // Widths, flags and modifiers on everything strftime writes. "%5d|%-5d|%_5d|%05e|%10A|%^10a|%-10B|%#10b|%3j|%_10Y|%010Y|%-3Y|%4Y|%6G|%3C|%_3g|%-4y", "%8c|%^30c|%#30c|%-12x|%010D|%_3H|%08F|%12r|%_9T|%-7R|%5p|%-5P|%^#5p|%#^9a|%_^9B|%3%|%5n|%-4t|%0^7h", "%E5c|%05Ey|%_4OH|%^EX|%#Ec|%-3OS|%07EC|%_Oe|%5EY|%OI%Om%OM", // strftime's own `%z` and `%Z`, which git writes at UTC's offset in // `format:` and in the local zone in `format-local:`. "%Ez|%Oz|%-z|%_z|%07z|%5z|%^z|%^Z|%#Z|%8Z|%-6Z|%010Z|%^#Z|%EZ|%OZ", // date(1)'s conversions, which glibc writes as text. "%N|%3N|%^5N|%05N|%#N|%-5N|%EN|%ON|%:z|%::z|%:::z|%::::z|%5:z|%-:z|%^:z|%05:z|%E:z|%O:z|%_7::z|%3:::z|%:|%::",};
/// The zones the differential tests read local times in, and null for/// `TZ` unset: names, an empty value, POSIX rules (one with glibc's/// default rules), names glibc cannot read, and a path.const tested_zones = [_]?[]const u8{ "UTC", "", "America/Chicago", "Europe/London", "Australia/Lord_Howe", "Asia/Kolkata", "Pacific/Kiritimati", ":America/Chicago", ":", "CST6CDT,M3.2.0/2:00:00,M11.1.0/2:00:00", "<+0545>-5:45", "XYZ3ABC", "Bogus/Zone", "AB3", "Asia/Kolkata,junk", "ABC-25", "XYZ3:75", "XYZ3,junk", "XYZ3ABC,junk", "ABC-25DEF", "XYZ3ABC4x", "XYZ3/", ":XYZ3ABC", "XYZ3ABC4,", "EST5EDT", "XYZ3:75ABC,M3.2.0/-1,J300/500", "<+0545>-25:99:99<-26>+30,M3.5.6/167,M10.5.0", "/nonexistent", ":Bogus", null,};
/// The current time the differential tests set: Monday 2026-09-21/// 04:26:40 UTC. `GIT_TEST_DATE_NOW` is read as a 32-bit integer, so it/// has to be before 2038.const test_now: i64 = 1790000000;
/// Dates for the differential tests, as an identity line writes them.fn testedDates(gpa: Allocator) ![][]const u8 { var list: std.ArrayList([]const u8) = .empty; errdefer { for (list.items) |s| gpa.free(s); list.deinit(gpa); } const fixed = [_][]const u8{ "0 +0000", "0 +0100", "3600 -0100", "21599 +0000", "21600 +0000", "1234567890 +0530", "1234567890 -0130", "1234567890 -0000", "1234567890 +1400", "1234567890 -1400", "1234567890 +1401", "1234567890 +2400", "1234567890 +9999", "1234567890 -9999", "1234567890 +0099", "1234567890 +2147483646", "1234567890 -0001", "1234567890 -2147483647", "1234567890 +12345", "1234567890 +5", "1772953199 -0600", "1772953200 -0500", "1793516399 -0500", "1793516400 -0600", "1774745999 +0000", "1774746000 +0100", "1792889999 +0100", "1792890000 +0000", "1775313000 +1100", "1775314800 +1030", "1791041399 +1030", "1791043200 +1100", "4102444799 +0000", "4102444800 +0000", "4102444800 +0100", "4102466400 -0600", "253402300799 +0000", "253402300800 +0000", "67767976233316800 +0000", "67768036191676799 +0000", "67768036191676800 +0000", "67768036191698400 -0100", "9223372036854775807 +0000", "9223372036854775807 -1400", }; for (fixed) |s| try list.append(gpa, try gpa.dupe(u8, s)); // Either side of every boundary `relative` changes at. const relative_steps = [_]i64{ 0, 1, 89, 90, 149, 150, 5369, 5370, 127769, 127770, 1164569, 1164570, 6002969, 6002970, 31490969, 31490970, 32873369, 32873370, 156338969, 156338970, 173359769, 173359770, 1700000000, }; for (relative_steps) |step| try list.append(gpa, try std.fmt.allocPrint(gpa, "{d} +0000", .{test_now - step})); try list.append(gpa, try std.fmt.allocPrint(gpa, "{d} +0000", .{test_now + 1})); // `human`, through the days around now, recorded in UTC, in Chicago's // and Kolkata's offsets and in one far ahead of the local day. const human_zones = [_][]const u8{ "+0000", "-0500", "+0530", "+1400", "-1100", "-0001" }; var step: i64 = -86400 * 2; while (step < 86400 * 9) : (step += 7 * 3600 + 1234) { for (human_zones) |zone| try list.append(gpa, try std.fmt.allocPrint(gpa, "{d} {s}", .{ test_now - step, zone })); } return list.toOwnedSlice(gpa);}
fn freeDates(gpa: Allocator, dates: [][]const u8) void { for (dates) |s| gpa.free(s); gpa.free(dates);}
fn testDate(text: []const u8) Date { var buffer: [64]u8 = undefined; const line = std.fmt.bufPrint(&buffer, "A <a> {s}", .{text}) catch unreachable; return Identity.parse(line).?.date().?;}
/// A repository holding a commit for each date, authored at one and/// committed at the date as far from it in the list, so that `%ad` and/// `%cd` each see every date.const History = struct { repo: fixture.Repo, /// The object IDs, a line each, for `git log --stdin`. ids: []u8, tzdir: ?[]u8,
fn init(gpa: Allocator, dates: []const []const u8) !History { var repo = try fixture.Repo.init(gpa, testing.io); errdefer repo.deinit(); const tree_out = try repo.output(&.{ "mktree", "--missing" }); defer gpa.free(tree_out); const tree = std.mem.trimEnd(u8, tree_out, "\n"); var ids: std.ArrayList(u8) = .empty; errdefer ids.deinit(gpa); for (dates, 0..) |author, i| { const committer = dates[dates.len - 1 - i]; const commit = try std.fmt.allocPrint(gpa, "tree {s}\nauthor A <a> {s}\ncommitter C <c> {s}\n\nm{d}\n", .{ tree, author, committer, i }); defer gpa.free(commit); const id = try repo.hashObject("commit", commit); defer gpa.free(id); try ids.appendSlice(gpa, id); try ids.append(gpa, '\n'); } try repo.env.put("LC_ALL", "C"); var now_buffer: [32]u8 = undefined; try repo.env.put("GIT_TEST_DATE_NOW", try std.fmt.bufPrint(&now_buffer, "{d}", .{test_now})); // glibc finds zones where the library does. const tzdir = testing.environ.getAlloc(gpa, "TZDIR") catch null; errdefer if (tzdir) |d| gpa.free(d); if (tzdir) |d| try repo.env.put("TZDIR", d); return .{ .repo = repo, .ids = try ids.toOwnedSlice(gpa), .tzdir = tzdir }; }
fn deinit(h: *History, gpa: Allocator) void { gpa.free(h.ids); if (h.tzdir) |d| gpa.free(d); h.repo.deinit(); }
fn setZone(h: *History, tz: ?[]const u8) !void { if (tz) |value| try h.repo.env.put("TZ", value) else _ = h.repo.env.swapRemove("TZ"); }
/// What `git log` shows of every commit's `%ad` and `%cd`, a NUL after /// each, which the caller frees. fn show(h: *History, mode: []const u8) ![]u8 { const arg = try std.fmt.allocPrint(h.repo.gpa, "--date={s}", .{mode}); defer h.repo.gpa.free(arg); return h.repo.outputWithInput(&.{ "log", "-z", "--no-walk=unsorted", "--stdin", arg, "--format=%ad%x00%cd" }, h.ids); }};
test "every date mode shows what git log shows, in every zone" { const gpa = testing.allocator; const dates = try testedDates(gpa); defer freeDates(gpa, dates); var history = try History.init(gpa, dates); defer history.deinit(gpa);
var modes: std.ArrayList([]const u8) = .empty; defer { for (modes.items) |m| gpa.free(m); modes.deinit(gpa); } for (tested_modes) |m| try modes.append(gpa, try gpa.dupe(u8, m)); for (tested_formats) |f| { try modes.append(gpa, try std.fmt.allocPrint(gpa, "format:{s}", .{f})); try modes.append(gpa, try std.fmt.allocPrint(gpa, "format-local:{s}", .{f})); }
for (tested_zones) |tz| { try history.setZone(tz); var zone = try loadZone(testing.io, gpa, tz, history.tzdir); defer zone.deinit(gpa); for (modes.items) |mode_text| { const mode = try parseMode(mode_text, .{}, null); const shown = try history.show(mode_text); defer gpa.free(shown); var fields = std.mem.splitScalar(u8, shown, 0); for (dates, 0..) |author, i| { for ([_][]const u8{ author, dates[dates.len - 1 - i] }) |text| { const want = fields.next() orelse return error.TestUnexpectedResult; errdefer std.debug.print("TZ={?s} --date={s} date {s}\n", .{ tz, mode_text, text }); var out: Writer.Allocating = .init(gpa); defer out.deinit(); var d: Diagnostics = .{}; format(&out.writer, testDate(text), mode, .{ .now = test_now, .zone = &zone }, &d) catch |err| switch (err) { // Refused only for years zig-datetime cannot hold. error.DateOutOfRange => { try testing.expect(testDate(text).seconds > datetime.Date.max_seconds - 368 * std.time.s_per_day); continue; }, // And where git writes something other than what // strftime does. error.UnsupportedConversion => { try testing.expect(mode.style == .format); const natural = try naturalOutput(gpa, testDate(text), mode, &zone); defer gpa.free(natural); try testing.expect(!std.mem.eql(u8, want, natural)); continue; }, else => return err, }; try testing.expectEqualStrings(want, out.written()); } } } }}
test "git show agrees, under a different local zone" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); // Needs the zone database, which a Nix build's sandbox does not have. const tzdir = testing.environ.getAlloc(gpa, "TZDIR") catch return error.SkipZigTest; defer gpa.free(tzdir); try repo.env.put("TZDIR", tzdir); try repo.env.put("TZ", "Australia/Lord_Howe"); try repo.env.put("LC_ALL", "C"); try repo.env.put("GIT_AUTHOR_DATE", "1775313000 +1100"); try repo.env.put("GIT_COMMITTER_DATE", "1791043200 -0930"); try repo.git(&.{ "commit", "-q", "--allow-empty", "-m", "m" }); var zone = try loadZone(testing.io, gpa, "Australia/Lord_Howe", tzdir); defer zone.deinit(gpa); for ([_][]const u8{ "default", "local", "rfc-local", "iso-strict", "format-local:%c %Z %z" }) |mode_text| { const arg = try std.fmt.allocPrint(gpa, "--date={s}", .{mode_text}); defer gpa.free(arg); const shown = try repo.output(&.{ "show", "-s", arg, "--format=%ad%n%cd", "HEAD" }); defer gpa.free(shown); var lines = std.mem.splitScalar(u8, shown, '\n'); for ([_][]const u8{ "1775313000 +1100", "1791043200 -0930" }) |text| { var out: Writer.Allocating = .init(gpa); defer out.deinit(); try format(&out.writer, testDate(text), try parseMode(mode_text, .{}, null), .{ .now = test_now, .zone = &zone }, null); try testing.expectEqualStrings(lines.next().?, out.written()); } }}
test "a date before the epoch in its own zone is refused where git refuses it" { const gpa = testing.allocator; const dates = [_][]const u8{ "0 -0100", "1800 -0100", "3599 -0001" }; var history = try History.init(gpa, &dates); defer history.deinit(gpa); try history.setZone("America/Chicago"); var zone = try loadZone(testing.io, gpa, "America/Chicago", history.tzdir); defer zone.deinit(gpa); for (tested_modes ++ [_][]const u8{ "format:%Y", "format-local:%Y" }) |mode_text| { const mode = try parseMode(mode_text, .{}, null); var ids = std.mem.splitScalar(u8, history.ids, '\n'); for (dates) |text| { const id = ids.next().?; const arg = try std.fmt.allocPrint(gpa, "--date={s}", .{mode_text}); defer gpa.free(arg); const o = try history.repo.runIn(".", &.{ "log", "-1", arg, "--format=%ad", id }, ""); defer o.deinit(gpa); errdefer std.debug.print("--date={s} date {s}: {s}\n", .{ mode_text, text, o.stderr }); var out: Writer.Allocating = .init(gpa); defer out.deinit(); if (format(&out.writer, testDate(text), mode, .{ .now = test_now, .zone = &zone }, null)) |_| { try testing.expectEqual(@as(u8, 0), o.code); try testing.expectEqualStrings(std.mem.trimEnd(u8, o.stdout, "\n"), out.written()); } else |err| { try testing.expectEqual(error.TimestampBeforeEpoch, err); try testing.expectEqual(@as(u8, 128), o.code); try testing.expect(std.mem.startsWith(u8, o.stderr, "fatal: Timestamp before Unix epoch")); } } }}
test "an unknown mode is refused with git's words" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commit("m"); for ([_][]const u8{ "bogus", "", "auto", "local-local", "format", "format-localx:%Y", "relative:" }) |mode_text| { const arg = try std.fmt.allocPrint(gpa, "--date={s}", .{mode_text}); defer gpa.free(arg); const o = try repo.runIn(".", &.{ "log", "-1", arg }, ""); defer o.deinit(gpa); var d: Diagnostics = .{}; _ = parseMode(mode_text, .{}, &d) catch { try testing.expectEqual(@as(u8, 128), o.code); const want = try std.fmt.allocPrint(gpa, "fatal: {s}\n", .{d.detail()}); defer gpa.free(want); try testing.expectEqualStrings(want, o.stderr); continue; }; return error.TestUnexpectedResult; }}
/// What strftime writes for a `format:` mode, with nothing refused.fn naturalOutput(gpa: Allocator, date: Date, mode: Mode, zone: *const Zone) ![]u8 { const view = if (mode.local) try localView(zone, date.seconds) else try recordedView(date); var out: Writer.Allocating = .init(gpa); defer out.deinit(); try writeStrftime(&out.writer, mode.format, view.dt); return out.toOwnedSlice();}
test "format: refuses where git writes something strftime does not, and names it" { const gpa = testing.allocator; const Case = struct { spec: []const u8, local: bool = false, tz: []const u8 = "America/Chicago", date: []const u8 = "1234567890 +0530" }; const cases = [_]Case{ // git pairs `%` signs a pair at a time, strftime does not. .{ .spec = "%E%s" }, .{ .spec = "%-%z" }, .{ .spec = "%E%Z" }, .{ .spec = "%5%%s" }, // strftime's own `%s` is the C library's `mktime`. .{ .spec = "%-s" }, .{ .spec = "%-s", .local = true, .tz = "XYZ3,junk", .date = "0 +0000" }, // strftime's own `%z` and `%Z` are UTC's in `format:`. .{ .spec = "%Ez" }, .{ .spec = "%^Z" }, // git's own `%z` is the zone as recorded, hours and minutes or not. .{ .spec = "%z", .date = "1234567890 +0099" }, .{ .spec = "%z", .date = "1234567890 +2147483646" }, // git's own `%s` and `%z` from 2100 on. .{ .spec = "%s", .date = "4102444800 +0100" }, .{ .spec = "%s", .local = true, .date = "4102466400 -0600" }, .{ .spec = "%z", .local = true, .date = "4102466400 -0600" }, // An abbreviation longer than a `DateTime` holds. .{ .spec = "%Z", .local = true, .tz = "<ABCDEFGH>5" }, // A width past 255, which glibc pads to. .{ .spec = "%256d" }, }; var dates: [cases.len][]const u8 = undefined; for (cases, &dates) |case, *date| date.* = case.date; var history = try History.init(gpa, &dates); defer history.deinit(gpa); // Needs the zone database, which a Nix build's sandbox does not have. if (history.tzdir == null) return error.SkipZigTest; for (cases, 0..) |case, i| { errdefer std.debug.print("TZ={s} format{s}:{s} date {s}\n", .{ case.tz, if (case.local) "-local" else "", case.spec, case.date }); try history.setZone(case.tz); var zone = try loadZone(testing.io, gpa, case.tz, history.tzdir); defer zone.deinit(gpa); const mode: Mode = .{ .style = .format, .local = case.local, .format = case.spec }; var buffer: [64]u8 = undefined; var w: Writer = .fixed(&buffer); var d: Diagnostics = .{}; try testing.expectError(error.UnsupportedConversion, format(&w, testDate(case.date), mode, .{ .now = test_now, .zone = &zone }, &d)); try testing.expect(std.mem.startsWith(u8, d.detail(), "unsupported strftime conversion: ")); // Nothing is written before the refusal. try testing.expectEqual(@as(usize, 0), w.end);
// And git does write something else. const arg = try std.fmt.allocPrint(gpa, "format{s}:{s}", .{ if (case.local) "-local" else "", case.spec }); defer gpa.free(arg); const shown = try history.show(arg); defer gpa.free(shown); var fields = std.mem.splitScalar(u8, shown, 0); for (0..i) |_| _ = .{ fields.next(), fields.next() }; const git = fields.next().?; if (std.mem.eql(u8, case.spec, "%256d")) { try testing.expectEqual(@as(usize, 256), git.len); continue; } const natural = try naturalOutput(gpa, testDate(case.date), mode, &zone); defer gpa.free(natural); try testing.expect(!std.mem.eql(u8, git, natural)); }}
test "a year a Year cannot hold is refused in every mode that shows one" { var buffer: [64]u8 = undefined; var w: Writer = .fixed(&buffer); const options: FormatOptions = .{ .now = test_now, .zone = &Zone.utc }; const date = testDate("9223372036854775807 +0000"); for (std.enums.values(Style)) |style| for ([_]bool{ false, true }) |local| { w.end = 0; const result = format(&w, date, .{ .style = style, .local = local, .format = "%Y" }, options, null); switch (style) { .raw, .unix, .relative => try result, else => try testing.expectError(error.DateOutOfRange, result), } };}
test "a TZ glibc reads by rules zig-datetime does not follow is refused rather than guessed" { const gpa = testing.allocator; const dates = [_][]const u8{ "1773500000 +0000", "1793200000 +0000", "1234567890 +0000", "1800000000 +0000" }; var history = try History.init(gpa, &dates); defer history.deinit(gpa); const real_tzdir = history.tzdir orelse return error.SkipZigTest; // A database holding nothing but a `posixrules` file, with dates of // its own that glibc takes for a rule that gives none. const london_path = try std.fs.path.join(gpa, &.{ real_tzdir, "Europe/London" }); defer gpa.free(london_path); const london = try std.Io.Dir.cwd().readFileAlloc(testing.io, london_path, gpa, .unlimited); defer gpa.free(london); try history.repo.tmp.dir.createDirPath(testing.io, "tz"); try history.repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "tz/posixrules", .data = london }); const tzdir = try std.fs.path.join(gpa, &.{ history.repo.path, "tz" }); defer gpa.free(tzdir); try history.repo.env.put("TZDIR", tzdir);
const modes = [_][]const u8{ "iso-local", "format-local:%c %z %Z" }; // Refused: no dates while `posixrules` is there, and months glibc // reads past the end of its own table for. The first two are // checked to come out differently from the United States' dates. const refused = [_]struct { []const u8, bool }{ .{ "XYZ3ABC", true }, .{ "XYZ3ABC4,", true }, .{ ":XYZ3ABC", false }, .{ "ABC-25DEF", false }, .{ "XYZ3ABC,M13.1.0,M11.1.0", false }, .{ "XYZ3ABC,M3.2.0,M0.1.0", false }, .{ "XYZ3ABC,M65549.1.0", false }, }; for (refused) |case| { errdefer std.debug.print("TZ={s}\n", .{case[0]}); try testing.expectError(error.UnsupportedTimeZone, loadZone(testing.io, gpa, case[0], tzdir)); if (!case[1]) continue; try history.setZone(case[0]); var lenient: Zone = .{ .time_zone = TimeZone.fromRuleLenient(case[0]) }; const shown = try history.show("iso-local"); defer gpa.free(shown); var out: Writer.Allocating = .init(gpa); defer out.deinit(); for (dates, 0..) |text, i| { try format(&out.writer, testDate(text), .{ .style = .iso, .local = true }, .{ .now = test_now, .zone = &lenient }, null); try out.writer.writeByte(0); try format(&out.writer, testDate(dates[dates.len - 1 - i]), .{ .style = .iso, .local = true }, .{ .now = test_now, .zone = &lenient }, null); if (i + 1 < dates.len) try out.writer.writeByte(0); } try testing.expect(!std.mem.eql(u8, std.mem.trimEnd(u8, shown, "\x00"), out.written())); } // Read leniently, as glibc reads them, with `posixrules` there or not: // each gives its own dates, or none that are the United States'. for ([_][]const u8{ "XYZ3ABC,M3.2.0,M11.1.0", "XYZ3ABC,M3.2.0", "XYZ3ABC4x", "XYZ3,junk", "XYZ3/", "ABC-25", "Bogus/Zone", "AB3" }) |tz| { try history.setZone(tz); var zone = try loadZone(testing.io, gpa, tz, tzdir); defer zone.deinit(gpa); for (modes) |mode_text| { const shown = try history.show(mode_text); defer gpa.free(shown); var fields = std.mem.splitScalar(u8, shown, 0); for (dates, 0..) |author, i| for ([_][]const u8{ author, dates[dates.len - 1 - i] }) |text| { errdefer std.debug.print("TZ={s} --date={s} date {s}\n", .{ tz, mode_text, text }); var out: Writer.Allocating = .init(gpa); defer out.deinit(); try format(&out.writer, testDate(text), try parseMode(mode_text, .{}, null), .{ .now = test_now, .zone = &zone }, null); try testing.expectEqualStrings(fields.next().?, out.written()); }; } } var z = try loadZone(testing.io, gpa, "Bogus/Zone", "/nonexistent"); defer z.deinit(gpa); try testing.expectEqualStrings("Bogus", z.designationAt(0)); try testing.expectEqual(@as(i32, 0), z.offsetAt(0));}
test "--since and --until read dates as the library does" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); const tzdir = testing.environ.getAlloc(gpa, "TZDIR") catch null; defer if (tzdir) |d| gpa.free(d); if (tzdir) |d| try repo.env.put("TZDIR", d); try repo.env.put("TZ", "America/Chicago"); var now_buffer: [32]u8 = undefined; try repo.env.put("GIT_TEST_DATE_NOW", try std.fmt.bufPrint(&now_buffer, "{d}", .{test_now})); // A commit every 13 hours for three weeks before now. var times: [40]i64 = undefined; for (×, 0..) |*t, i| { t.* = test_now - @as(i64, @intCast(times.len - i)) * 13 * 3600; var buffer: [32]u8 = undefined; const date = try std.fmt.bufPrint(&buffer, "@{d} +0000", .{t.*}); try repo.env.put("GIT_COMMITTER_DATE", date); try repo.env.put("GIT_AUTHOR_DATE", date); try repo.git(&.{ "commit", "-q", "--allow-empty", "-m", "m" }); } var zone = try loadZone(testing.io, gpa, "America/Chicago", tzdir); defer zone.deinit(gpa); for ([_][]const u8{ "1 week ago", "yesterday", "2026-09-10", "2026-09-10 13:00", "last monday", "noon", "3 days ago", "Sep 5", "tea", "nonsense" }) |text| { for ([_][]const u8{ "--since", "--until" }) |option| { const arg = try std.fmt.allocPrint(gpa, "{s}={s}", .{ option, text }); defer gpa.free(arg); const out = try repo.output(&.{ "rev-list", "--count", arg, "HEAD" }); defer gpa.free(out); const at = approximate(text, test_now, &zone).timestamp; var want: usize = 0; for (times) |t| { if (if (option[2] == 's') t >= at else t <= at) want += 1; } errdefer std.debug.print("{s}\n", .{arg}); try testing.expectEqual(want, try std.fmt.parseInt(usize, std.mem.trimEnd(u8, out, "\n"), 10)); } }}
test "GIT_COMMITTER_DATE is accepted and recorded as the library reads it" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); const tzdir = testing.environ.getAlloc(gpa, "TZDIR") catch null; defer if (tzdir) |d| gpa.free(d); if (tzdir) |d| try repo.env.put("TZDIR", d); for ([_][]const u8{ "Australia/Lord_Howe", "Asia/Kolkata", "America/Chicago", "UTC" }) |tz| { try repo.env.put("TZ", tz); var zone = try loadZone(testing.io, gpa, tz, tzdir); defer zone.deinit(gpa); for ([_][]const u8{ "@1234567890", "1234567890 +0530", "2026-04-05 01:45", "2026-10-04 02:15:00", "Tue, 1 Sep 2026 12:00:00 -0700", "2026-09-01T12:00:00Z", "yesterday", "2026-13-01 00:00", "1969-12-31 23:00 +0000" }) |text| { try repo.env.put("GIT_COMMITTER_DATE", text); const o = try repo.runIn(".", &.{ "var", "GIT_COMMITTER_IDENT" }, ""); defer o.deinit(gpa); errdefer std.debug.print("TZ={s} GIT_COMMITTER_DATE={s}: {s}{s}\n", .{ tz, text, o.stdout, o.stderr }); if (strict(text, &zone)) |date| { try testing.expectEqual(@as(u8, 0), o.code); var buffer: [64]u8 = undefined; const raw = try std.fmt.bufPrint(&buffer, " {f}\n", .{date}); try testing.expect(std.mem.endsWith(u8, o.stdout, raw)); } else |_| { try testing.expect(o.code != 0); } } }}
test "relative rounds at every boundary" { const cases = [_]struct { i64, []const u8 }{ .{ -1, "in the future" }, .{ 0, "0 seconds ago" }, .{ 1, "1 second ago" }, .{ 89, "89 seconds ago" }, .{ 90, "2 minutes ago" }, .{ 5369, "89 minutes ago" }, .{ 5370, "2 hours ago" }, .{ 127769, "35 hours ago" }, .{ 127770, "2 days ago" }, .{ 1164570, "2 weeks ago" }, .{ 6002970, "2 months ago" }, .{ 31490969, "12 months ago" }, .{ 31490970, "1 year ago" }, .{ 32873370, "1 year, 1 month ago" }, .{ 61730970, "2 years ago" }, .{ 156338970, "5 years ago" }, .{ 173359770, "6 years ago" }, }; for (cases) |case| { var buffer: [64]u8 = undefined; var w: Writer = .fixed(&buffer); try writeRelative(&w, case[0]); try testing.expectEqualStrings(case[1], w.buffered()); }}
/// The zones the fuzz test formats in, built from rules alone so that it/// reads no files.fn fuzzZones() [5]Zone { return .{ .utc, .{ .time_zone = TimeZone.fromRuleLenient("CST6CDT,M3.2.0,M11.1.0") }, .{ .time_zone = TimeZone.fromRuleLenient("<+1030>-10:30<+11>-11,M10.1.0,M4.1.0") }, .{ .time_zone = TimeZone.fromRuleLenient("ABC-25DEF") }, .{ .time_zone = TimeZone.fromRuleLenient("XYZ3:75,junk") }, };}
test "fuzz: mode names" { try testing.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [128]u8 = undefined; const text = fuzz_input.bytes(smith, &buffer); const zones = fuzzZones(); for ([_]bool{ false, true }) |terminal| { var d: Diagnostics = .{}; const mode = parseMode(text, .{ .terminal = terminal }, &d) catch continue; var out: [512]u8 = undefined; var w: Writer = .fixed(&out); format(&w, testDate("1234567890 +0530"), mode, .{ .now = test_now, .zone = &zones[1] }, &d) catch |err| switch (err) { error.WriteFailed, error.UnsupportedConversion, error.DateOutOfRange => {}, else => return err, }; } } }.one, .{ .corpus = &[_][]const u8{ @embedFile("fuzz/date-modes"), @embedFile("fuzz/date-modes-strftime"), "format-local:%c %Z %z %s", "auto:human", "iso-strict-local" } });}
/// Reads `len` bytes of `in` as a little-endian integer, zeros past its/// end.fn takeInt(comptime T: type, in: *[]const u8) T { var bytes: [@sizeOf(T)]u8 = @splat(0); const n = @min(bytes.len, in.len); @memcpy(bytes[0..n], in.*[0..n]); in.* = in.*[n..]; return std.mem.readInt(T, &bytes, .little);}
test "fuzz: formatting any seconds and zone in every mode" { try testing.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [96]u8 = undefined; var in = fuzz_input.bytes(smith, &buffer); const date: Date = .{ .seconds = takeInt(i64, &in), .zone = takeInt(i32, &in) }; const now = takeInt(i64, &in); const which = takeInt(u8, &in); const zones = fuzzZones(); const zone = &zones[which % zones.len]; const styles = std.enums.values(Style); for (styles) |style| for ([_]bool{ false, true }) |local| { var out: [1024]u8 = undefined; var w: Writer = .fixed(&out); format(&w, date, .{ .style = style, .local = local, .format = in }, .{ .now = now, .zone = zone }, null) catch |err| switch (err) { error.TimestampBeforeEpoch, error.DateOutOfRange, error.UnsupportedConversion, error.WriteFailed => {}, }; }; } }.one, .{ .corpus = &[_][]const u8{ @embedFile("fuzz/date-values"), @embedFile("fuzz/date-values-huge"), @embedFile("fuzz/date-values-lenient") } });}