Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `zig-git diff` in a repository: the index against the working tree, a//! commit against the index (`--cached`) or the working tree, and two//! commits or trees, written as `git diff` writes them, with the//! porcelain's configuration (`diff.context`, the prefixes and the rest).//!//! zig-git diff [<option>...] [--cached | --staged] [<commit> [<commit>]] [--] [<path>...]//!//! git refreshes the index before comparing with the working tree, so a//! file whose stat data changed and whose contents did not is not//! listed; zig-git compares the contents of such a file instead, and//! writes nothing. Files in the working tree are converted on their way//! in as git converts them (`git.convert`). Renames are looked for as//! `diff.renames` says, and//! as git does by default. Refused by name: `--merge-base`, `A...B`,//! blobs, more than two commits, and combined diffs of unmerged paths.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const git = @import("git");const output = git.diff.output;const IndexDiff = git.IndexDiff;const ObjectId = git.ObjectId;const Diagnostics = git.Diagnostics;const tool = @import("zig_git.zig");const Context = tool.Context;const discovery = @import("discovery.zig");const object_names = @import("object_names.zig");const revisions = @import("revisions.zig");const pathspecs = @import("pathspecs.zig");const check_attr = @import("check_attr.zig");const repo_diff = @import("repo_diff.zig");const line_prefix = @import("line_prefix.zig");const ls_files = @import("ls_files.zig");const diff_tree = @import("diff_tree.zig");const diff_index = @import("diff_index.zig");
const usage = @embedFile("diff_porcelain_usage.txt");
/// Runs `zig-git diff` without `--no-index`.pub fn run(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var s: repo_diff.Settings = .{ .command = .diff }; defer s.deinit(gpa); var cached = false; var words: std.ArrayList([]const u8) = .empty; defer words.deinit(gpa); var after: std.ArrayList([]const u8) = .empty; defer after.deinit(gpa); var dashdash = false; // The first option no form of the command knows. var invalid: ?[]const u8 = null;
var repo = try discovery.open(ctx, stderr); defer repo.close(ctx.io); try repo_diff.readConfig(&repo.config, &s, stderr);
var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (dashdash) { try after.append(gpa, arg); continue; } if (std.mem.eql(u8, arg, "--")) { dashdash = true; continue; } if (arg.len < 2 or arg[0] != '-') { try words.append(gpa, arg); continue; } if (std.mem.eql(u8, arg, "--cached") or std.mem.eql(u8, arg, "--staged")) { cached = true; } else if (std.mem.eql(u8, arg, "--merge-base")) { return repo_diff.unsupported(stderr, .diff, arg); } else { const next: ?[]const u8 = if (i + 1 < args.len) args[i + 1] else null; switch (try repo_diff.parseOption(gpa, &s, arg, next, stderr)) { .yes => {}, .took_next => i += 1, // Refused once the revisions are known, as git refuses // it: see below. .no => if (invalid == null) { invalid = arg; }, } } } try repo_diff.check(&s, stderr); if (!s.format_given) s.out.format.patch = true; if (s.quiet) s.exit_code = true;
const settings = try object_names.settings(&repo.config, stderr); var names: object_names.Names = .init(ctx, &repo, settings); defer names.deinit(); var revs = try revisions.Revisions.init(ctx, &repo, settings, .rev_parse, stderr); defer revs.deinit(); revs.resolver.options.sets = false; try repo_diff.setUp(ctx, &repo, &s, &revs, stderr);
var commits: std.ArrayList(ObjectId) = .empty; defer commits.deinit(gpa); var paths: std.ArrayList([]const u8) = .empty; defer paths.deinit(gpa); var in_paths = false; for (words.items) |w| { if (!in_paths) { if (git.revision.parseSet(w)) |set| switch (set) { .symmetric => return repo_diff.unsupported(stderr, .diff, "A...B"), .range => |r| { const from = resolveTree(&revs, if (r.from.len == 0) "HEAD" else r.from) orelse return ambiguous(stderr, w); const to = resolveTree(&revs, if (r.to.len == 0) "HEAD" else r.to) orelse return ambiguous(stderr, w); try commits.append(gpa, from); try commits.append(gpa, to); continue; }, else => {}, }; if (try resolveWord(&revs, &repo, w, stderr)) |id| { try commits.append(gpa, id); continue; } in_paths = true; if (!try pathspecs.takenForPath(ctx, &repo, w)) return ambiguous(stderr, w); } else if (!try pathspecs.takenForPath(ctx, &repo, w)) { return tool.fatalMessage(stderr, "{s}: no such path in the working tree.\nUse 'git <command> -- <path>...' to specify paths that do not exist locally.", .{w}); } try paths.append(gpa, w); } try paths.appendSlice(gpa, after.items); // git takes every option and every revision before it refuses an // option it does not know, and says which only when comparing the // index with the working tree; given a commit or `--cached`, the usage // is all it writes (as git was seen to). if (invalid) |arg| { if (!cached and commits.items.len == 0) stderr.print("error: invalid option: {s}\n", .{arg}) catch {}; stderr.writeAll(usage) catch {}; return error.UsageQuiet; } if (commits.items.len > 2) return repo_diff.unsupported(stderr, .diff, "more than two commits"); if (cached and commits.items.len > 1) { stderr.writeAll(usage) catch {}; return error.UsageQuiet; }
if (s.relative != null and paths.items.len != 0) return repo_diff.unsupported(stderr, .diff, "--relative with paths"); var spec: git.Pathspec = if (paths.items.len == 0) .empty else try pathspecs.parse(ctx, &repo, paths.items, "diff", stderr); defer if (paths.items.len != 0) spec.deinit(gpa);
const Kind = enum { files, cached, worktree, trees }; const kind: Kind = if (commits.items.len == 2) .trees else if (cached) .cached else if (commits.items.len == 1) .worktree else .files; const mnemonic: [2][]const u8 = switch (kind) { .files => .{ "i/", "w/" }, .cached => .{ "c/", "i/" }, .worktree => .{ "c/", "w/" }, .trees => .{ "a/", "b/" }, }; s.out.patch.object_format = repo.objectFormat(); repo_diff.finish(ctx, &s, mnemonic, &names, settings); var short: diff_tree.Short = .{ .ctx = ctx, .names = &names }; s.out.patch.shorten = .{ .context = &short, .length = diff_tree.Short.length };
var from: check_attr.From = .worktree; if (try check_attr.configuredTree(ctx, &repo, &revs, stderr)) |f| from = f; const attrs = try check_attr.setup(ctx, &repo, from, stderr); defer attrs.deinit(); const conversion_attrs = try check_attr.setup(ctx, &repo, from, stderr); defer conversion_attrs.deinit(); const whitespace_attrs = try check_attr.setup(ctx, &repo, from, stderr); defer whitespace_attrs.deinit(); var drivers: repo_diff.Drivers = .{ .gpa = gpa, .config = &repo.config, .setup = attrs, .conversions = conversion_attrs, .whitespace = whitespace_attrs, .stderr = stderr, .command = .diff }; if (s.out.format.check) drivers.check = s.out.whitespace; defer drivers.deinit();
var list: std.ArrayList(output.Pair) = .empty; defer list.deinit(gpa); var tree_pairs: diff_tree.Pairs = .init(gpa); defer tree_pairs.deinit(gpa); var index_pairs: IndexDiff.Pairs = .init(gpa); defer index_pairs.deinit(gpa); var index: ?git.Index = null; defer if (index) |*ix| ix.deinit(); var wt: diff_index.Worktree = undefined; var have_worktree = false; defer if (have_worktree) wt.deinit(); var unmerged = false;
if (kind == .trees) { // Reversal and filtering happen here, with the others'. var plain = s; plain.reverse = false; plain.filter_given = false; try diff_tree.collectTrees(ctx, &repo, &spec, commits.items[0], commits.items[1], true, false, &plain, &tree_pairs, stderr); try list.appendSlice(gpa, tree_pairs.list.items); } else { if (repo.location.work_tree == null and kind != .cached) return tool.fatalMessage(stderr, "this operation must be run in a work tree", .{}); var diag: Diagnostics = .{}; index = repo.readIndex(ctx.io, .{ .index_file = ctx.env.get("GIT_INDEX_FILE") }, &diag) catch |err| return ls_files.describe(stderr, err, &diag); for (index.?.warnings) |w| try ls_files.writeWarning(stderr, w); if (index.?.hasSparseDirectories()) return repo_diff.unsupported(stderr, .diff, "a sparse index"); if (kind != .cached and fsmonitor(&repo)) return repo_diff.unsupported(stderr, .diff, "core.fsmonitor"); var options = try worktreeOptions(&repo, stderr); options.refresh = true; options.ita_invisible = true; { const index_path = try repo.indexPath(gpa, .{ .index_file = ctx.env.get("GIT_INDEX_FILE") }); defer gpa.free(index_path); if (repo.work_dir.statFile(ctx.io, index_path, .{})) |st| { options.index_mtime = @truncate(@as(u64, @intCast(@max(0, @divFloor(st.mtime.toNanoseconds(), std.time.ns_per_s))))); } else |_| {} } wt = .init(ctx, &repo, conversion_attrs, &index.?, stderr, .diff); wt.refresh = true; have_worktree = true; var comparison: IndexDiff.Comparison = .{ .gpa = gpa, .io = ctx.io, .db = repo.objects(), .format = repo.objectFormat(), .index = &index.?, .pathspec = &spec, .worktree = wt.workTree(), .options = options, }; const head: ?ObjectId = if (commits.items.len == 1) commits.items[0] else headTree(&revs); (switch (kind) { .files => comparison.filesAgainstWorktree(.none, &index_pairs), .cached => comparison.treeAgainst(head, false, &index_pairs), .worktree => comparison.treeAgainst(head, true, &index_pairs), .trees => unreachable, }) catch |err| return failed(stderr, &wt, err); for (index_pairs.list.items) |p| { if (p.status == .unmerged) unmerged = true; try list.append(gpa, p); } }
// Reverse, then find renames and filter. var reversed: std.ArrayList(output.Pair) = .empty; defer reversed.deinit(gpa); for (list.items) |p| { var q = p; if (s.reverse and q.status != .unmerged) { std.mem.swap(output.Side, &q.old, &q.new); q.status = output.statusOf(q.old, q.new); } try reversed.append(gpa, q); } if (kind == .files and unmerged and s.out.format.patch and !s.out.format.name_only and !s.out.format.name_status) return repo_diff.unsupported(stderr, .diff, "a combined diff of unmerged paths");
var contents: IndexDiff.Contents = .{ .io = ctx.io, .db = repo.objects(), .worktree = if (have_worktree) wt.workTree() else null }; var unchanged: diff_tree.Pairs = .init(gpa); defer unchanged.deinit(gpa); var warnings: repo_diff.RenameWarnings = .{}; if (s.harder and s.renames) { // The copy sources: the old side's unchanged paths. if (kind == .trees) { const copy_from = if (s.reverse) commits.items[1] else commits.items[0]; var tp: diff_tree.Pairs = .init(gpa); defer tp.deinit(gpa); try tp.list.appendSlice(gpa, reversed.items); try diff_tree.collectUnchanged(ctx, &repo, &spec, copy_from, &tp, &s, &unchanged, stderr); } else if (s.reverse) { return repo_diff.unsupported(stderr, .diff, "--find-copies-harder with -R against the index or the working tree"); } else if (kind == .files) { try diff_index.collectUnchangedIndex(gpa, &index.?, &spec, reversed.items, &unchanged); } else { var tp: diff_tree.Pairs = .init(gpa); defer tp.deinit(gpa); try tp.list.appendSlice(gpa, reversed.items); const head: ?ObjectId = if (commits.items.len == 1) commits.items[0] else headTree(&revs); try diff_tree.collectUnchanged(ctx, &repo, &spec, head, &tp, &s, &unchanged, stderr); } } if (have_worktree) wt.startOutput(s.out, s.quiet); const shown_list = repo_diff.prepare(gpa, &s, reversed.items, unchanged.list.items, contents.source(), drivers.drivers(), &warnings, stderr) catch |err| switch (err) { error.ContentsUnreadable => { if (have_worktree and wt.refused) return error.Fatal; return tool.fatalMessage(stderr, "zig-git diff: cannot read the contents of a file", .{}); }, else => |e| return e, }; defer gpa.free(shown_list); try warnings.flush(stderr); var out_alloc: Io.Writer.Allocating = .init(gpa); defer out_alloc.deinit(); const base_w: *Io.Writer = if (s.output_path != null) &out_alloc.writer else stdout; var prefix_buffer: [256]u8 = undefined; var prefixed: line_prefix.LinePrefix = .init(base_w, s.line_prefix orelse "", &prefix_buffer); defer prefixed.writer.flush() catch {}; const w: *Io.Writer = if (s.line_prefix != null) &prefixed.writer else base_w; var discarding: Io.Writer.Discarding = .init(&.{}); const sink = if (s.quiet) &discarding.writer else w; const outcome = output.write(gpa, sink, shown_list, contents.source(), drivers.drivers(), s.out) catch |err| switch (err) { error.DriverUnsupported => return error.Fatal, error.ContentsUnreadable => { if (have_worktree and wt.refused) return error.Fatal; return tool.fatalMessage(stderr, "zig-git diff: cannot read the contents of a file", .{}); }, else => |e| return repo_diff.refuseComparison(stderr, .diff, e), }; try prefixed.writer.flush(); if (s.output_path) |p| { var base = Io.Dir.cwd().openDir(ctx.io, ctx.cwd, .{}) catch |err| return tool.fatalMessage(stderr, "cannot change to '{s}': {s}", .{ ctx.cwd, git.Config.reason(err) }); defer base.close(ctx.io); base.writeFile(ctx.io, .{ .sub_path = p, .data = out_alloc.written() }) catch |err| return tool.fatalMessage(stderr, "could not open '{s}' for writing: {s}", .{ p, git.Config.reason(err) }); } if (outcome.problems) return if (s.exit_code and outcome.differs) error.CheckProblemsDiffer else error.CheckProblems; if (s.exit_code and outcome.differs and !repo_diff.exitCodeSeesNothing(&s, true)) return error.No;}
fn ambiguous(stderr: *Io.Writer, w: []const u8) anyerror { try stderr.print( \\fatal: ambiguous argument '{s}': unknown revision or path not in the working tree. \\Use '--' to separate paths from revisions, like this: \\'git <command> [<revision>...] -- [<file>...]' \\ , .{w}); return error.Fatal;}
fn resolveTree(revs: *revisions.Revisions, text: []const u8) ?ObjectId { var diag: Diagnostics = .{}; const found = revs.resolver.resolve(text, &diag) catch { revs.resolver.clearMessages(); return null; }; revs.resolver.clearMessages(); return (revs.resolver.peelToTree(found.id) catch null) orelse null;}
/// A word naming a commit or tree, as its tree; a blob is refused.fn resolveWord(revs: *revisions.Revisions, repo: *git.Repository, text: []const u8, stderr: *Io.Writer) !?ObjectId { _ = repo; var diag: Diagnostics = .{}; const found = revs.resolver.resolve(text, &diag) catch { revs.resolver.clearMessages(); return null; }; revs.resolver.clearMessages(); if (found.kind) |k| if (k == .blob) return repo_diff.unsupported(stderr, .diff, "comparing blobs"); return (revs.resolver.peelToTree(found.id) catch null) orelse null;}
fn headTree(revs: *revisions.Revisions) ?ObjectId { return resolveTree(revs, "HEAD");}
fn fsmonitor(repo: *git.Repository) bool { const e = (repo.config.get("core.fsmonitor") catch null) orelse return false; const v = e.value orelse return true; if (git.Config.value.parseBool(v)) |b| return b; return v.len != 0;}
fn worktreeOptions(repo: *git.Repository, stderr: *Io.Writer) !IndexDiff.Options { var o: IndexDiff.Options = .{}; o.file_mode = try ls_files.boolSetting(repo, "core.fileMode", stderr) orelse true; o.trust_ctime = try ls_files.boolSetting(repo, "core.trustctime", stderr) orelse true; o.symlinks = try ls_files.boolSetting(repo, "core.symlinks", stderr) orelse true; if (repo.config.get("core.checkstat") catch null) |e| if (e.value) |v| { o.minimal_stat = std.mem.eql(u8, v, "minimal"); }; return o;}
fn failed(stderr: *Io.Writer, wt: *diff_index.Worktree, err: anyerror) anyerror { return switch (err) { error.WorkTreeUnsupported => if (wt.refused) error.Fatal else repo_diff.unsupported(stderr, .diff, "reading the working tree on this system"), error.SubmoduleUnsupported => repo_diff.unsupported(stderr, .diff, "a checked-out submodule"), error.WorkTreeUnreadable => tool.fatalMessage(stderr, "zig-git: cannot read the working tree", .{}), error.OutOfMemory => err, else => tool.fatalMessage(stderr, "zig-git: cannot compare: {f}", .{tool.errorWords(err)}), };}