diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig
index d7899b6..31a05cd 100644
--- a/src/commands/contributions.zig
+++ b/src/commands/contributions.zig
@@ -21,6 +21,7 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
+const git = @import("../git.zig");
const fmt = cli.fmt;
const Date = zfin.Date;
const Lot = zfin.Lot;
@@ -44,7 +45,7 @@ pub fn run(
const arena = arena_state.allocator();
// 1. Figure out the git repo and the portfolio's path inside it.
- const repo = findGitRepo(arena, portfolio_path) catch |err| {
+ const repo = git.findRepo(arena, portfolio_path) catch |err| {
switch (err) {
error.NotInGitRepo => try cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n"),
error.GitUnavailable => try cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n"),
@@ -54,7 +55,7 @@ pub fn run(
};
// 2. Decide which snapshots to compare.
- const status = try pathStatus(arena, repo.root, repo.rel_path);
+ const status = try git.pathStatus(arena, repo.root, repo.rel_path);
if (status == .untracked) {
try cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n");
return;
@@ -63,7 +64,7 @@ pub fn run(
// 3. Pull both snapshots.
const before = if (dirty)
- gitShow(arena, repo.root, "HEAD", repo.rel_path) catch |err| {
+ git.show(arena, repo.root, "HEAD", repo.rel_path) catch |err| {
switch (err) {
error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD.\n"),
else => try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n"),
@@ -71,7 +72,7 @@ pub fn run(
return;
}
else
- gitShow(arena, repo.root, "HEAD~1", repo.rel_path) catch |err| {
+ git.show(arena, repo.root, "HEAD~1", repo.rel_path) catch |err| {
switch (err) {
error.PathMissingInRev => try cli.stderrPrint("Error: portfolio.srf not present at HEAD~1.\n"),
error.UnknownRevision => try cli.stderrPrint("Error: no prior commit to compare against (HEAD~1 does not exist).\n"),
@@ -86,7 +87,7 @@ pub fn run(
return;
}
else
- gitShow(arena, repo.root, "HEAD", repo.rel_path) catch {
+ git.show(arena, repo.root, "HEAD", repo.rel_path) catch {
try cli.stderrPrint("Error reading HEAD:portfolio.srf from git.\n");
return;
};
@@ -141,108 +142,10 @@ pub fn run(
}
// ── Git discovery / invocation ───────────────────────────────
-
-const RepoInfo = struct {
- /// Absolute path to the repo root.
- root: []const u8,
- /// Relative path from root to the portfolio file (using '/'-style separators).
- rel_path: []const u8,
-};
-
-fn findGitRepo(allocator: std.mem.Allocator, portfolio_path: []const u8) !RepoInfo {
- // Resolve the portfolio file's directory.
- const abs_path = try std.fs.cwd().realpathAlloc(allocator, portfolio_path);
- const dir = std.fs.path.dirname(abs_path) orelse "/";
-
- // git -C
rev-parse --show-toplevel
- 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;
- };
-
- 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);
-
- // Relative path from root to the portfolio file.
- 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 };
-}
-
-/// Inspect the portfolio file's git status. Only the portfolio file is
-/// considered (via pathspec); untracked files elsewhere in the repo are
-/// ignored. Returns one of:
-/// - `.modified`: tracked and has unstaged and/or staged changes
-/// - `.clean`: tracked and matches HEAD (no uncommitted changes)
-/// - `.untracked`: not yet added to the repo (no HEAD version to diff against)
-const PathStatus = enum { modified, clean, untracked };
-
-fn pathStatus(allocator: std.mem.Allocator, root: []const u8, rel_path: []const u8) !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;
-
- 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 " where X is index status, Y is worktree status.
- // "??" means untracked. Anything else with at least one non-space is a modification.
- if (trimmed.len >= 2 and trimmed[0] == '?' and trimmed[1] == '?') return .untracked;
- return .modified;
-}
-
-/// Run `git show :` and return the stdout bytes.
-fn gitShow(allocator: std.mem.Allocator, root: []const u8, rev: []const u8, rel_path: []const u8) ![]const u8 {
- // Build ":".
- const spec = try std.fmt.allocPrint(allocator, "{s}:{s}", .{ rev, rel_path });
-
- const result = std.process.Child.run(.{
- .allocator = allocator,
- .argv = &.{ "git", "-C", root, "show", spec },
- .max_output_bytes = 32 * 1024 * 1024,
- }) catch return error.GitUnavailable;
-
- switch (result.term) {
- .Exited => |code| {
- if (code != 0) {
- // 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 => return error.GitShowFailed,
- }
-
- return result.stdout;
-}
+//
+// Git plumbing lives in `src/git.zig` (shared with future snapshot
+// features). This module only classifies which revisions to diff and
+// how to interpret the result.
// ── Diff algorithm ───────────────────────────────────────────