add git.zig

This commit is contained in:
Emil Lerch 2026-04-21 13:43:26 -07:00
parent b3c1751eb6
commit 8af5c5f696
Signed by: lobo
GPG key ID: A7B62D657EF764F8

332
src/git.zig Normal file
View file

@ -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 <dir> 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 <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(
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 -- <rel_path>`.
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);
}