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); +}