add git.zig
This commit is contained in:
parent
b3c1751eb6
commit
8af5c5f696
1 changed files with 332 additions and 0 deletions
332
src/git.zig
Normal file
332
src/git.zig
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue