zfin/src/git.zig
Emil Lerch fad9be6ce8
All checks were successful
Generic zig build / build (push) Successful in 2m20s
Generic zig build / deploy (push) Successful in 27s
upgrade to zig 0.16.0
IO-as-an-interface refactor across the codebase. The big shifts:
- std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run.
- Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena,
  environ_map up front. main.zig + the build/ scripts use it directly.
- Threading io through everywhere that touches the outside world (HTTP,
  files, stderr, sleep, terminal detection). Functions taking `io` now
  announce side effects at the call site — the smell is the feature.
- date math takes `as_of: Date`, not `today: Date`. Caller resolves
  `--as-of` flag vs wall-clock at the boundary; the function operates
  on whatever date it's given. Every "today" parameter renamed and
  the as_of: ?Date + today: Date pattern collapsed.
- now_s: i64 (or before_s/after_s pairs) for sub-second metadata
  fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo.
  Also pure and testable.
- legitimate Timestamp.now callers (cache TTL math, FetchResult
  timestamps, rate limiter, per-frame TUI "now" captures) gain
  `// wall-clock required: ...` comments justifying the read.

Test discovery: replaced the local refAllDeclsRecursive with bare
std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level
decls reaches every test file transitively through the import graph;
no explicit _ = @import(...) lines needed.

Cleanup along the way:
- Dropped DataService.allocator()/io() accessor methods; renamed the
  fields to drop the base_ prefix. Callers use self.allocator and
  self.io directly.
- Dropped now-vestigial io parameters from buildSnapshot,
  analyzePortfolio, compareSchwabSummary, compareAccounts,
  buildPortfolioData, divs.display, quote.display, parsePortfolioOpts,
  aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator,
  aggregateDripLots, printLotRow, portfolio.display, printSnapNote.
- Dropped the unused contributions.computeAttribution date-form
  wrapper (only computeAttributionSpec is called).
- formatAge/fmtTimeAgo take (before_s, after_s) instead of io and
  reading the clock internally.
- parseProjectionsConfig uses an internal stack-buffer
  FixedBufferAllocator instead of an allocator parameter.
- ThreadSafeAllocator wrappers in cache concurrency tests dropped
  (0.16's DebugAllocator is thread-safe by default).
- analyzePortfolio bug surfaced by the rename: snapshot.zig was
  passing wall-clock today instead of as_of, mis-valuing cash/CDs
  for historical backfills.

83 new unit tests added due to removal of IO, bringing coverage from 58%
-> 64%
2026-05-09 22:40:33 -07:00

719 lines
29 KiB
Zig

//! 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 <ref>:<path>` 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 <dir> 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 <path>". "??" 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
/// <rev>:<rel_path>` 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 -- <rel_path>`.
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 <DATE>` / `--until <DATE>` 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 <ref>`
/// 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);
}