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%
719 lines
29 KiB
Zig
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);
|
|
}
|