//! Git subprocess helpers. //! //! All functions shell out to the `git` binary. They are deliberately thin //! wrappers — they don't try to reimplement git's object model, just //! exec git with the right flags and classify common failure modes. //! //! Functions here exist primarily for commands that diff or walk a //! repo-tracked portfolio file: //! - `zfin contributions` (HEAD~1 → HEAD or HEAD → working copy) //! - planned: `zfin snapshot` retroactive-fixup scan, which needs the //! last-modified time of the portfolio file from git //! - planned: `zfin contributions --timeline` walking a commit range //! //! If you need to add another git-shelling command, put it here rather //! than inlining `std.process.Child.run` in the command module. const std = @import("std"); const Date = @import("models/date.zig").Date; // ── Types ──────────────────────────────────────────────────── pub const Error = error{ /// `git` binary not found on PATH, or failed to exec. GitUnavailable, /// The requested path is not in a git repo. NotInGitRepo, /// `git status` returned non-zero for the path. GitStatusFailed, /// `git show` returned non-zero for reasons other than the classified /// cases below (UnknownRevision, PathMissingInRev). GitShowFailed, /// The requested revision does not exist (e.g. HEAD~1 on a /// freshly-initialized repo with only one commit). UnknownRevision, /// The path is not present in the requested revision (e.g. asking for /// portfolio.srf at a commit that predates its addition). PathMissingInRev, /// `git log` returned non-zero. GitLogFailed, /// `resolveCommitRange` was asked for a `since` date with no commit /// at or before it — nothing to diff against. NoCommitAtOrBefore, /// The caller passed an invalid argument combination — e.g. /// `CommitSpec.working_copy` on the "before" side, which is /// nonsensical. InvalidArg, OutOfMemory, }; /// Location of a path within a git repo. pub const RepoInfo = struct { /// Absolute path to the repo root. root: []const u8, /// Relative path from root to the file (forward-slash separators). rel_path: []const u8, }; /// A user-facing commit endpoint. Captures the three input shapes /// accepted by `--commit-before` / `--commit-after` (and the /// date-oriented `--since` / `--until` / compare positional args, /// which the command layer parses into this type). /// /// - `git_ref` — a string `git show :` will accept /// directly (SHA, HEAD, HEAD~N). Validation deferred to git. /// - `date_at_or_before` — a calendar date. Resolved at /// `resolveCommitRange` time via `commitAtOrBeforeDate`. Kept as /// a date (not pre-resolved to a SHA) so the snap-note warning /// can compare the resolved commit's timestamp against the /// originally-requested date at report time. /// - `working_copy` — the filesystem state (possibly dirty). /// Valid only as the "after" endpoint — nonsensical as a /// "before" because diffing the working copy against itself /// produces nothing. /// /// Defined here (not in `commands/common.zig`) because the concept /// is git-layer. `commands/common.zig` has the user-input parser /// that produces values of this type. pub const CommitSpec = union(enum) { git_ref: []const u8, date_at_or_before: Date, working_copy, }; /// Working-tree state of a single tracked path. pub const PathStatus = enum { /// Tracked and has staged and/or unstaged changes. modified, /// Tracked and matches HEAD. clean, /// Not yet added to the repo. untracked, }; /// Wall-clock time a commit touched a given path. pub const CommitTouch = struct { commit: []const u8, // commit hash (40 chars for SHA-1, 64 for SHA-256) /// Committer timestamp (Unix epoch seconds). timestamp: i64, }; /// A before/after pair of git revisions to diff. /// /// `before_rev` is always a concrete revision (SHA or symbolic like /// `HEAD~1`). `after_rev` is null when the caller wants the working /// copy on the right-hand side; the caller reads the file directly /// rather than going through `git show`. pub const CommitRange = struct { before_rev: []const u8, /// null = working copy; non-null = a concrete git revision. after_rev: ?[]const u8, }; // ── Implementation ─────────────────────────────────────────── /// Locate the git repository containing `path` and the path's position /// relative to the repo root. /// /// Allocator is used for the returned `root` and `rel_path` strings /// (caller-owned). pub fn findRepo(io: std.Io, allocator: std.mem.Allocator, path: []const u8) Error!RepoInfo { // Resolve the file's directory. realpath requires the file to exist. const abs_path = std.Io.Dir.cwd().realPathFileAlloc(io, path, allocator) catch { return error.NotInGitRepo; }; defer allocator.free(abs_path); const dir = std.fs.path.dirname(abs_path) orelse "/"; // `git -C rev-parse --show-toplevel` prints the repo root. const result = std.process.run(allocator, io, .{ .argv = &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" }, .stdout_limit = .limited(64 * 1024), }) catch { return error.GitUnavailable; }; defer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| if (code != 0) return error.NotInGitRepo, else => return error.NotInGitRepo, } const root_raw = std.mem.trim(u8, result.stdout, " \t\r\n"); const root = try allocator.dupe(u8, root_raw); errdefer allocator.free(root); // Relative path from root to the file. If `abs_path` starts with the // repo root (the common case), trim the prefix; otherwise fall back to // just the basename (extremely unusual — repo root disagrees with // path). const rel_raw = if (std.mem.startsWith(u8, abs_path, root) and abs_path.len > root.len) std.mem.trimStart(u8, abs_path[root.len..], "/") else std.fs.path.basename(abs_path); const rel = try allocator.dupe(u8, rel_raw); return .{ .root = root, .rel_path = rel }; } /// Report the tracked/untracked/modified status of `rel_path` relative to /// the repo at `root`. pub fn pathStatus( io: std.Io, allocator: std.mem.Allocator, root: []const u8, rel_path: []const u8, ) Error!PathStatus { const result = std.process.run(allocator, io, .{ .argv = &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path }, .stdout_limit = .limited(64 * 1024), }) catch return error.GitUnavailable; defer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| if (code != 0) return error.GitStatusFailed, else => return error.GitStatusFailed, } const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); if (trimmed.len == 0) return .clean; // Porcelain format: "XY ". "??" is untracked, anything else is // modified (staged and/or unstaged). if (trimmed.len >= 2 and trimmed[0] == '?' and trimmed[1] == '?') return .untracked; return .modified; } /// Return the contents of `rel_path` at revision `rev` (as `git show /// :` would print). Caller owns the returned bytes. /// /// Classifies the common error cases so callers can print a useful /// message: `UnknownRevision` (e.g. HEAD~1 on a fresh repo), /// `PathMissingInRev` (file didn't exist at that commit). pub fn show( io: std.Io, allocator: std.mem.Allocator, root: []const u8, rev: []const u8, rel_path: []const u8, ) Error![]const u8 { const spec = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ rev, rel_path }); defer allocator.free(spec); const result = std.process.run(allocator, io, .{ .argv = &.{ "git", "-C", root, "show", spec }, .stdout_limit = .limited(32 * 1024 * 1024), }) catch return error.GitUnavailable; errdefer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| { if (code != 0) { allocator.free(result.stdout); // Distinguish "no such revision" from "path missing". if (std.mem.indexOf(u8, result.stderr, "unknown revision") != null or std.mem.indexOf(u8, result.stderr, "bad revision") != null or std.mem.indexOf(u8, result.stderr, "ambiguous argument") != null) { return error.UnknownRevision; } if (std.mem.indexOf(u8, result.stderr, "does not exist") != null or std.mem.indexOf(u8, result.stderr, "exists on disk, but not in") != null) { return error.PathMissingInRev; } return error.GitShowFailed; } }, else => { allocator.free(result.stdout); return error.GitShowFailed; }, } return result.stdout; } /// List commits that touched `rel_path`, newest-first, each with its /// committer timestamp. /// /// `since_iso` is passed verbatim to `git log --since=<...>`. If null, no /// since filter is applied (returns the full history of the path). Valid /// formats include ISO-8601 dates ("2026-01-01") and relative expressions /// ("1 week ago"). /// /// Returned slice (and each `commit` string within) is caller-owned. Free /// with `freeCommitTouches`. pub fn listCommitsTouching( io: std.Io, allocator: std.mem.Allocator, root: []const u8, rel_path: []const u8, since_iso: ?[]const u8, ) Error![]CommitTouch { var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(allocator); // Track the allocated `--since=...` string so we can free it regardless // of which index it ends up at in `argv`. (Don't rely on positional // arithmetic — it's brittle and freeing a string literal like "--" // would segfault on the debug allocator's memset-to-undefined.) var since_owned: ?[]u8 = null; defer if (since_owned) |s| allocator.free(s); try argv.appendSlice(allocator, &.{ "git", "-C", root, "log", "--format=%H %ct", }); if (since_iso) |s| { const since_arg = try std.fmt.allocPrint(allocator, "--since={s}", .{s}); since_owned = since_arg; try argv.append(allocator, since_arg); } try argv.appendSlice(allocator, &.{ "--", rel_path }); const result = std.process.run(allocator, io, .{ .argv = argv.items, .stdout_limit = .limited(16 * 1024 * 1024), }) catch return error.GitUnavailable; defer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| if (code != 0) return error.GitLogFailed, else => return error.GitLogFailed, } var list: std.ArrayList(CommitTouch) = .empty; errdefer { for (list.items) |ct| allocator.free(ct.commit); list.deinit(allocator); } var line_iter = std.mem.splitScalar(u8, result.stdout, '\n'); while (line_iter.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t\r"); if (trimmed.len == 0) continue; const sp = std.mem.indexOfScalar(u8, trimmed, ' ') orelse continue; const sha = trimmed[0..sp]; const ts_str = trimmed[sp + 1 ..]; const ts = std.fmt.parseInt(i64, ts_str, 10) catch continue; const sha_owned = try allocator.dupe(u8, sha); errdefer allocator.free(sha_owned); try list.append(allocator, .{ .commit = sha_owned, .timestamp = ts }); } return list.toOwnedSlice(allocator); } /// Free a `CommitTouch` slice returned by `listCommitsTouching`. pub fn freeCommitTouches(allocator: std.mem.Allocator, items: []const CommitTouch) void { for (items) |ct| allocator.free(ct.commit); allocator.free(items); } /// Return the committer timestamp (Unix epoch seconds) of the most recent /// commit that touched `rel_path`, or null if the path has no history yet. /// /// Equivalent to `git log -1 --format=%ct -- `. pub fn lastCommitTimestampForPath( io: std.Io, allocator: std.mem.Allocator, root: []const u8, rel_path: []const u8, ) Error!?i64 { const result = std.process.run(allocator, io, .{ .argv = &.{ "git", "-C", root, "log", "-1", "--format=%ct", "--", rel_path }, .stdout_limit = .limited(64 * 1024), }) catch return error.GitUnavailable; defer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| if (code != 0) return error.GitLogFailed, else => return error.GitLogFailed, } const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); if (trimmed.len == 0) return null; return std.fmt.parseInt(i64, trimmed, 10) catch return null; } /// Return the SHA of the most recent commit that touched `rel_path` at /// or before `date_iso` (YYYY-MM-DD, inclusive end-of-day semantics via /// `git log --until`). /// /// Returns null if no commit before `date_iso` touched `rel_path`. /// Caller owns the returned string. /// /// Used by `zfin contributions --since ` / `--until ` to /// resolve a date to the last commit that stamped a given snapshot of /// the portfolio file. pub fn commitAtOrBeforeDate( io: std.Io, allocator: std.mem.Allocator, root: []const u8, rel_path: []const u8, date_iso: []const u8, ) Error!?[]const u8 { // `git log --until=DATE` with a bare YYYY-MM-DD uses the *current // time-of-day* applied to DATE as the cutoff — NOT end of day as // intuition suggests. That means at 10:40am today, `--until=X` // excludes any commits on X made after 10:40am, which causes // day-of-review windows to randomly include or exclude commits // depending on when the command is run. Explicitly pin the cutoff // to 23:59:59 local so "since 2026-04-25" always means "include // all commits on 2026-04-25, regardless of what time I'm // looking". const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso}); defer allocator.free(until_arg); const result = std.process.run(allocator, io, .{ .argv = &.{ "git", "-C", root, "log", "-1", "--format=%H", until_arg, "--", rel_path, }, .stdout_limit = .limited(64 * 1024), }) catch return error.GitUnavailable; defer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| if (code != 0) return error.GitLogFailed, else => return error.GitLogFailed, } const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); if (trimmed.len == 0) return null; // Defensive: `git log --format=%H` emits the full commit hash and // nothing else. Guard against stdout noise (e.g. a warning // accidentally routed to stdout) by requiring the result to look // like a hash — all hex, sensible length. SHA-1 is 40 chars, // SHA-256 is 64; accept anything in that range or longer to stay // forward-compatible with future git hash formats. if (trimmed.len < 40) return error.GitLogFailed; for (trimmed) |c| if (!std.ascii.isHex(c)) return error.GitLogFailed; return try allocator.dupe(u8, trimmed); } /// Get the committer-date (Unix timestamp, seconds since epoch) of /// a given ref. Accepts anything `git log -1 --format=%ct ` /// accepts: SHAs, HEAD, HEAD~N. Used by the contributions pipeline /// to emit a snap-note when a date-form spec resolves to a commit /// that's far from the user's requested date. pub fn commitTimestamp( io: std.Io, allocator: std.mem.Allocator, root: []const u8, ref: []const u8, ) Error!i64 { const result = std.process.run(allocator, io, .{ .argv = &.{ "git", "-C", root, "log", "-1", "--format=%ct", ref, }, .stdout_limit = .limited(4 * 1024), }) catch return error.GitUnavailable; defer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| if (code != 0) return error.GitLogFailed, else => return error.GitLogFailed, } const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); if (trimmed.len == 0) return error.GitLogFailed; return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed; } /// Resolve a before/after commit range for diffing `repo.rel_path`. /// /// Three modes selected by `since` / `until`: /// /// - `since == null` (legacy): no date window. /// - `dirty == false`: before=`HEAD~1`, after=`HEAD` (review last commit). /// - `dirty == true`: before=`HEAD`, after=working-copy. /// - `since != null, until == null`: single cutoff. /// - before = commit-at-or-before(since). /// - `dirty == false`: after=`HEAD`. /// - `dirty == true`: after=working-copy. /// - `since != null, until != null`: date window between two commits. /// - before = commit-at-or-before(since). /// - after = commit-at-or-before(until). /// /// `until` without `since` is rejected via assertion — the window is /// ambiguous without a starting point. The caller is responsible for /// enforcing that at the argument-parsing layer. /// /// Returns `error.NoCommitAtOrBefore` when `since` or `until` resolves /// to "no commit exists at or before this date". Callers decide how /// to surface that to the user. /// /// Pure SHA-level output — no labels, no stderr side effects. All /// allocations use `arena`. /// Resolve a before/after commit range for diffing `repo.rel_path`. /// /// Takes `CommitSpec` for each endpoint. When `before` is null, /// applies the legacy default (HEAD~1 clean / HEAD dirty). When /// `after` is null, applies the legacy default for the after side /// based on `dirty`. When explicitly provided, the specs are /// honored as given. /// /// Returns `error.NoCommitAtOrBefore` when a `date_at_or_before` /// spec resolves to "no commit at or before this date." Returns /// `error.InvalidArg` when `before` is `.working_copy` (nonsensical). /// /// Pure SHA-level output — no labels, no stderr side effects. All /// allocations use `arena`. /// /// Three-tier rule for clarity: /// /// 1. Both specs explicit → honor as given. /// 2. One null, one explicit → fill the null from legacy defaults, /// keeping the explicit side untouched. /// 3. Both null → full legacy mode: HEAD~1..HEAD (clean) or /// HEAD..working-copy (dirty). Back-compat with pre-flag /// `zfin contributions` invocations. pub fn resolveCommitRangeSpec( io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, before: ?CommitSpec, after: ?CommitSpec, dirty: bool, ) Error!CommitRange { // Before can't be working_copy — would be diffing against itself. if (before) |b| { if (b == .working_copy) return error.InvalidArg; } // Resolve each endpoint independently. const before_rev: []const u8 = if (before) |b| try resolveSpec(io, arena, repo, b) else if (dirty) "HEAD" else "HEAD~1"; const after_rev: ?[]const u8 = if (after) |a| (switch (a) { .working_copy => null, else => try resolveSpec(io, arena, repo, a), }) else if (dirty) null else "HEAD"; return .{ .before_rev = before_rev, .after_rev = after_rev }; } /// Resolve one non-working `CommitSpec` to a string git can consume. /// Caller handles the `.working_copy` case separately (it's not a /// git ref). fn resolveSpec(io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, spec: CommitSpec) Error![]const u8 { return switch (spec) { .git_ref => |r| r, .date_at_or_before => |d| blk: { var buf: [10]u8 = undefined; const date_str = d.format(&buf); const sha = (try commitAtOrBeforeDate(io, arena, repo.root, repo.rel_path, date_str)) orelse return error.NoCommitAtOrBefore; break :blk sha; }, .working_copy => error.InvalidArg, }; } /// Back-compat wrapper for the original `Date`-based API. Existing /// callers (legacy `zfin contributions --since / --until`) keep /// working unchanged. New callers using explicit commit refs go /// through `resolveCommitRangeSpec`. /// /// `until` without `since` is rejected via assertion — the window is /// ambiguous without a starting point. pub fn resolveCommitRange( io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, since: ?Date, until: ?Date, dirty: bool, ) Error!CommitRange { std.debug.assert(!(since == null and until != null)); const before: ?CommitSpec = if (since) |d| .{ .date_at_or_before = d } else null; const after: ?CommitSpec = if (until) |d| .{ .date_at_or_before = d } else null; return resolveCommitRangeSpec(io, arena, repo, before, after, dirty); } // ── Tests ──────────────────────────────────────────────────── // // These tests shell out to `git init` and friends in a tmp dir. They // require `git` to be on PATH, which every developer setup already has // (otherwise the `contributions` command would be unusable anyway). test "findRepo locates the ambient zfin checkout" { // The test binary runs with cwd set to the project root, so this // should always succeed in CI and local dev. If git isn't available // we get NotInGitRepo or GitUnavailable — tolerate both (the test // environment is responsible for providing git). const allocator = std.testing.allocator; // Pick any file that exists in the repo — build.zig is stable. const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); try std.testing.expect(info.root.len > 0); try std.testing.expectEqualStrings("build.zig", info.rel_path); } test "listCommitsTouching returns at least one commit for build.zig" { const allocator = std.testing.allocator; const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); const commits = listCommitsTouching(std.testing.io, allocator, info.root, info.rel_path, null) catch return; defer freeCommitTouches(allocator, commits); try std.testing.expect(commits.len >= 1); // Timestamps are plausible (after 2020). try std.testing.expect(commits[0].timestamp > 1_577_836_800); } test "listCommitsTouching with non-null since_iso does not segfault" { // Regression: previously freed argv.items[6] which was the "--" // string literal when since_iso was non-null, segfaulting on the // debug allocator's memset-to-undefined. const allocator = std.testing.allocator; const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); // The test is primarily about not segfaulting; we don't assert on // commits.len since git's --since parsing may decline values that // are too far back (e.g. "100 years ago" can hit pre-epoch dates). const commits = listCommitsTouching(std.testing.io, allocator, info.root, info.rel_path, "30 years ago") catch return; defer freeCommitTouches(allocator, commits); } test "commitAtOrBeforeDate returns a SHA for a past date" { const allocator = std.testing.allocator; const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); // Any date well after the repo's creation — commitAtOrBeforeDate // should find the most recent commit touching build.zig. const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "2099-01-01") catch return; try std.testing.expect(sha_opt != null); const sha = sha_opt.?; defer allocator.free(sha); // Accept either SHA-1 (40) or SHA-256 (64) format. Git is // gradually rolling out SHA-256; this test mustn't assume one. try std.testing.expect(sha.len == 40 or sha.len == 64); for (sha) |c| try std.testing.expect(std.ascii.isHex(c)); } test "commitAtOrBeforeDate returns null for date before repo existed" { const allocator = std.testing.allocator; const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); // Pre-git — before any sensible project history. const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "1970-01-02") catch return; try std.testing.expect(sha_opt == null); } test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-day" { // Regression test for a subtle git UX trap. `git log --until=DATE` // with a bare YYYY-MM-DD applies the *current wall-clock // time-of-day* to DATE, rather than treating DATE as end-of-day. // So at 10:40am, `--until=2026-04-25` would exclude a commit // timestamped 2026-04-25 11:13am even though it's clearly within // 2026-04-25. We work around this by pinning the cutoff to // 23:59:59 in `commitAtOrBeforeDate`. // // This test pins the fix by querying a date far in the past and // confirming we get a commit (i.e. the helper doesn't over- // exclude). A more targeted test would need controllable // committer timestamps, which requires spinning up a tmp repo. // The behavior is validated end-to-end by compare + contributions // agreeing on `--since 1W` totals (see src/commands/contributions.zig // tests). const allocator = std.testing.allocator; const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); // Future-dated cutoff — should always return the tip of history // regardless of current wall-clock time. const sha_opt = commitAtOrBeforeDate(std.testing.io, allocator, info.root, info.rel_path, "2099-01-01") catch return; try std.testing.expect(sha_opt != null); if (sha_opt) |s| allocator.free(s); } test "resolveCommitRange: legacy clean → HEAD~1..HEAD" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), repo, null, null, false); try std.testing.expectEqualStrings("HEAD~1", range.before_rev); try std.testing.expectEqualStrings("HEAD", range.after_rev.?); } test "resolveCommitRange: legacy dirty → HEAD..working-copy" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const repo: RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; const range = try resolveCommitRange(std.testing.io, arena_state.allocator(), repo, null, null, true); try std.testing.expectEqualStrings("HEAD", range.before_rev); try std.testing.expect(range.after_rev == null); } test "resolveCommitRange: --since resolves to SHA..HEAD for clean tree" { const allocator = std.testing.allocator; const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); // Any date well after project start — resolves to latest commit. const range = resolveCommitRange( std.testing.io, arena_state.allocator(), info, Date.fromYmd(2099, 1, 1), null, false, ) catch return; try std.testing.expect(range.before_rev.len >= 40); try std.testing.expectEqualStrings("HEAD", range.after_rev.?); } test "resolveCommitRange: --since with no earlier commit → NoCommitAtOrBefore" { const allocator = std.testing.allocator; const info = findRepo(std.testing.io, allocator, "build.zig") catch return; defer allocator.free(info.root); defer allocator.free(info.rel_path); var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); // Before any commit exists in this repo. const result = resolveCommitRange( std.testing.io, arena_state.allocator(), info, Date.fromYmd(1970, 1, 2), null, false, ); try std.testing.expectError(error.NoCommitAtOrBefore, result); }