diff --git a/src/git.zig b/src/git.zig
new file mode 100644
index 0000000..64332fd
--- /dev/null
+++ b/src/git.zig
@@ -0,0 +1,332 @@
+//! 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");
+
+// ── 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,
+ 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,
+};
+
+/// 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, // 40-char SHA
+ /// Committer timestamp (Unix epoch seconds).
+ timestamp: i64,
+};
+
+// ── 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(allocator: std.mem.Allocator, path: []const u8) Error!RepoInfo {
+ // Resolve the file's directory. realpath requires the file to exist.
+ const abs_path = std.fs.cwd().realpathAlloc(allocator, path) 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.Child.run(.{
+ .allocator = allocator,
+ .argv = &.{ "git", "-C", dir, "rev-parse", "--show-toplevel" },
+ .max_output_bytes = 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.trimLeft(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(
+ allocator: std.mem.Allocator,
+ root: []const u8,
+ rel_path: []const u8,
+) Error!PathStatus {
+ const result = std.process.Child.run(.{
+ .allocator = allocator,
+ .argv = &.{ "git", "-C", root, "status", "--porcelain", "--", rel_path },
+ .max_output_bytes = 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(
+ 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.Child.run(.{
+ .allocator = allocator,
+ .argv = &.{ "git", "-C", root, "show", spec },
+ .max_output_bytes = 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(
+ 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);
+
+ 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});
+ // The string lives inside `argv` until we invoke the child; we
+ // free it after the child completes. Track it for cleanup.
+ try argv.append(allocator, since_arg);
+ }
+ try argv.appendSlice(allocator, &.{ "--", rel_path });
+
+ const result = std.process.Child.run(.{
+ .allocator = allocator,
+ .argv = argv.items,
+ .max_output_bytes = 16 * 1024 * 1024,
+ }) catch {
+ if (since_iso != null) {
+ // argv.items[6] is the "--since=..." we allocated above.
+ allocator.free(argv.items[6]);
+ }
+ return error.GitUnavailable;
+ };
+ defer allocator.free(result.stdout);
+ defer allocator.free(result.stderr);
+ if (since_iso != null) {
+ allocator.free(argv.items[6]);
+ }
+
+ 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(
+ allocator: std.mem.Allocator,
+ root: []const u8,
+ rel_path: []const u8,
+) Error!?i64 {
+ const result = std.process.Child.run(.{
+ .allocator = allocator,
+ .argv = &.{ "git", "-C", root, "log", "-1", "--format=%ct", "--", rel_path },
+ .max_output_bytes = 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;
+}
+
+// ── 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(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(allocator, "build.zig") catch return;
+ defer allocator.free(info.root);
+ defer allocator.free(info.rel_path);
+ const commits = listCommitsTouching(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);
+}