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 ───────────────────────────────────────────