//! 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); }