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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `zig-git diff-tree`: two trees compared, or a commit with its parents,//! written as `git diff-tree` writes them.//!//! zig-git diff-tree [-r] [-t] [--root] [-m] [--no-commit-id] [--always]//! [--stdin] [-s] [<diff-option>...] <tree-ish> [<tree-ish>] [[--] <path>...]//!//! A commit's line comes before its changes; a merge shows nothing//! unless `-m` asks for each parent in turn, and a root commit nothing//! unless `--root`. Combined diffs (`-c`, `--cc`) and `-v`/`--pretty`//! are refused by name.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const git = @import("git");const output = git.diff.output;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 usage = @embedFile("diff_tree_usage.txt");
const Options = struct { recursive: bool = false, show_trees: bool = false, root: bool = false, each_parent: bool = false, commit_id: bool = true, always: bool = false, stdin: bool = false,};
/// Runs `zig-git diff-tree`.pub fn run(ctx: Context, args: []const []const u8, stdin: *Io.Reader, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var s: repo_diff.Settings = .{ .command = .diff_tree }; defer s.deinit(gpa); var o: Options = .{}; var words: std.ArrayList([]const u8) = .empty; defer words.deinit(gpa); var after_dashdash: std.ArrayList([]const u8) = .empty; defer after_dashdash.deinit(gpa); var dashdash = false;
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_dashdash.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, "-r")) { o.recursive = true; } else if (std.mem.eql(u8, arg, "-t")) { o.recursive = true; o.show_trees = true; } else if (std.mem.eql(u8, arg, "--root")) { o.root = true; } else if (std.mem.eql(u8, arg, "-m")) { o.each_parent = true; } else if (std.mem.eql(u8, arg, "--no-commit-id")) { o.commit_id = false; } else if (std.mem.eql(u8, arg, "--always")) { o.always = true; } else if (std.mem.eql(u8, arg, "--stdin")) { o.stdin = true; } else if (std.mem.eql(u8, arg, "-v") or std.mem.startsWith(u8, arg, "--pretty") or std.mem.startsWith(u8, arg, "--format") or std.mem.eql(u8, arg, "--first-parent") or std.mem.eql(u8, arg, "--merge-base")) { return repo_diff.unsupported(stderr, .diff_tree, 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, .no => { stderr.writeAll(usage) catch {}; return error.UsageQuiet; }, } } } try repo_diff.check(&s, stderr); if (!s.format_given) s.out.format.raw = true; // The formats that read contents go into subtrees, unless the name // formats replace them. const fmt = s.out.format; if (!fmt.name_only and !fmt.name_status and (fmt.patch or fmt.stat or fmt.numstat or fmt.shortstat or fmt.summary)) o.recursive = 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); // The pickaxe looks into subtrees. if (s.pickaxe_kind != null) o.recursive = true;
// The revisions, then paths: a word that names no revision begins the // paths, each of which must then be in the working tree. var trees: std.ArrayList(ObjectId) = .empty; defer trees.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 (try resolveWord(&revs, w, &trees)) continue; in_paths = true; if (!try pathspecs.takenForPath(ctx, &repo, w)) { 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; } } 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_dashdash.items); if (o.stdin) { if (trees.items.len != 0) return repo_diff.unsupported(stderr, .diff_tree, "--stdin with revisions"); } else if (trees.items.len == 0) { stderr.writeAll(usage) catch {}; return error.UsageQuiet; } else if (trees.items.len > 2) { return repo_diff.unsupported(stderr, .diff_tree, "more than two revisions"); }
// With paths, git matches them against the paths --relative has cut // short, which is not matched. if (s.relative != null and paths.items.len != 0) return repo_diff.unsupported(stderr, .diff_tree, "--relative with paths"); var spec: git.Pathspec = if (paths.items.len == 0) .empty else try pathspecs.parse(ctx, &repo, paths.items, "diff-tree", stderr); defer if (paths.items.len != 0) spec.deinit(gpa);
s.out.patch.object_format = repo.objectFormat(); repo_diff.finish(ctx, &s, null, &names, settings); var short: Short = .{ .ctx = ctx, .names = &names }; s.out.patch.shorten = .{ .context = &short, .length = 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_tree }; if (s.out.format.check) drivers.check = s.out.whitespace; defer drivers.deinit(); var blobs: repo_diff.BlobSource = .{ .ctx = ctx, .repo = &repo };
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;
var t: Comparer = .{ .ctx = ctx, .repo = &repo, .settings = &s, .options = &o, .spec = &spec, .source = blobs.source(), .drivers = drivers.drivers(), .w = sink, .header_w = w, .stderr = stderr, }; var differs = false; if (o.stdin) { var line: std.ArrayList(u8) = .empty; defer line.deinit(gpa); while (true) { line.clearRetainingCapacity(); var lw: Io.Writer.Allocating = .fromArrayList(gpa, &line); const n = stdin.streamDelimiterEnding(&lw.writer, '\n') catch |err| { line = lw.toArrayList(); return err; }; line = lw.toArrayList(); const ended = stdin.takeByte() catch |err| switch (err) { error.EndOfStream => null, else => |e| return e, }; if (n == 0 and ended == null) break; if (try t.stdinLine(line.items)) differs = true; if (ended == null) break; } } else if (trees.items.len == 2) { differs = try t.trees(trees.items[0], trees.items[1], null); } else { differs = try t.commit(trees.items[0]); } try t.warnings.flush(stderr); 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 (t.problems) return if (s.exit_code and differs) error.CheckProblemsDiffer else error.CheckProblems; if (s.exit_code and differs and !repo_diff.exitCodeSeesNothing(&s, false)) return error.No;}
/// Resolves `w` as a revision or a range, appending its objects; false/// when it names none.fn resolveWord(revs: *revisions.Revisions, w: []const u8, out: *std.ArrayList(ObjectId)) !bool { const gpa = revs.ctx.gpa; if (git.revision.parseSet(w)) |set| switch (set) { .range => |r| { const from = resolveOne(revs, if (r.from.len == 0) "HEAD" else r.from) orelse return false; const to = resolveOne(revs, if (r.to.len == 0) "HEAD" else r.to) orelse return false; try out.append(gpa, from); try out.append(gpa, to); return true; }, else => return false, }; const id = resolveOne(revs, w) orelse return false; try out.append(gpa, id); return true;}
fn resolveOne(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 found.id;}
/// Shortens object IDs as the repository would.pub const Short = struct { ctx: Context, names: *object_names.Names,
pub fn length(context: *anyopaque, id: ObjectId, min: usize) usize { const self: *Short = @ptrCast(@alignCast(context)); return self.names.lookup.uniqueLength(self.ctx.io, id, min) catch min; }};
/// Compares trees and commits and writes what differs.pub const Comparer = struct { ctx: Context, repo: *git.Repository, settings: *repo_diff.Settings, options: *const Options, spec: *const git.Pathspec, source: output.Source, drivers: output.Drivers, w: *Io.Writer, /// Where the commit's line goes: `--quiet` keeps it. header_w: *Io.Writer, stderr: *Io.Writer, warnings: repo_diff.RenameWarnings = .{}, /// Whether `--check` found a problem. problems: bool = false,
/// Compares two tree-ishes, writing `header` first if anything is /// written. Returns whether anything differs. fn trees(t: *Comparer, old: ObjectId, new: ObjectId, header: ?[]const u8) !bool { const gpa = t.ctx.gpa; const old_tree = try t.treeOf(old); const new_tree = try t.treeOf(new); var pairs: Pairs = .init(gpa); defer pairs.deinit(gpa); try collectTrees(t.ctx, t.repo, t.spec, old_tree, new_tree, t.options.recursive, t.options.show_trees, t.settings, &pairs, t.stderr); return t.emitPrepared(&pairs, if (t.settings.reverse) new_tree else old_tree, header); }
/// Finds renames and applies `--diff-filter`, then writes. fn emitPrepared(t: *Comparer, pairs: *Pairs, source_tree: ?ObjectId, header: ?[]const u8) !bool { const gpa = t.ctx.gpa; var unchanged: Pairs = .init(gpa); defer unchanged.deinit(gpa); if (t.settings.harder and t.settings.renames) try collectUnchanged(t.ctx, t.repo, t.spec, source_tree, pairs, t.settings, &unchanged, t.stderr); const shown = repo_diff.prepare(gpa, t.settings, pairs.list.items, unchanged.list.items, t.source, t.drivers, &t.warnings, t.stderr) catch |err| return t.failed(err); defer gpa.free(shown); return t.emit(shown, header); }
fn emit(t: *Comparer, all_pairs: []const output.Pair, header: ?[]const u8) !bool { const s = t.settings; // The commit's line goes by what differs before a whitespace // option or -I drops anything, as git was seen to write it. if (header) |h| if (t.options.commit_id and (all_pairs.len != 0 or t.options.always)) { // The line's end is a NUL under -z. try t.header_w.writeAll(h[0 .. h.len - 1]); try t.header_w.writeByte(if (s.out.nul) 0 else '\n'); }; const outcome = output.write(t.ctx.gpa, t.w, all_pairs, t.source, t.drivers, s.out) catch |err| return t.failed(err); if (outcome.problems) t.problems = true; // With --no-commit-id, --always still writes the commit's line, // after what differs, as git was seen to. if (header) |h| if (!t.options.commit_id and t.options.always) { try t.header_w.writeAll(h[0 .. h.len - 1]); try t.header_w.writeByte(if (s.out.nul) 0 else '\n'); }; return outcome.differs; }
fn failed(t: *Comparer, err: anyerror) anyerror { return switch (err) { error.DriverUnsupported => error.Fatal, error.BlankFileUnsupported => repo_diff.unsupported(t.stderr, .diff_tree, "--check of a file of blank lines alone"), error.ContentsUnreadable => tool.fatalMessage(t.stderr, "zig-git diff-tree: cannot read a blob", .{}), error.AlgorithmUnsupported => repo_diff.unsupported(t.stderr, .diff_tree, @tagName(t.settings.out.patch.lines.algorithm)), error.LongSearchUnsupported => repo_diff.unsupported(t.stderr, .diff_tree, "a line diff searching past 256 steps"), else => err, }; }
fn treeOf(t: *Comparer, id: ObjectId) !ObjectId { var o = t.repo.objects().read(t.ctx.io, t.ctx.gpa, id, null) catch return tool.fatalMessage(t.stderr, "unable to read tree {f}", .{id}); defer o.deinit(t.ctx.gpa); switch (o.kind) { .tree => return id, .commit => { const c = git.Commit.parse(t.repo.objectFormat(), o.data) catch return tool.fatalMessage(t.stderr, "unable to read tree {f}", .{id}); return c.tree; }, .tag => { const target = git.Tag.targetOf(t.repo.objectFormat(), o.data) orelse return tool.fatalMessage(t.stderr, "unable to read tree {f}", .{id}); return t.treeOf(target); }, .blob => return tool.fatalMessage(t.stderr, "unable to read tree {f}", .{id}), } }
/// One commit against its parents. fn commit(t: *Comparer, id: ObjectId) !bool { const gpa = t.ctx.gpa; var o = t.repo.objects().read(t.ctx.io, gpa, id, null) catch return tool.fatalMessage(t.stderr, "zig-git diff-tree: cannot read {f}", .{id}); defer o.deinit(gpa); if (o.kind != .commit) { try t.stderr.print("error: object {f} is a {s}, not a commit\n", .{ id, @tagName(o.kind) }); return false; } const c = git.Commit.parse(t.repo.objectFormat(), o.data) catch return tool.fatalMessage(t.stderr, "zig-git diff-tree: cannot read commit {f}", .{id}); var parents: std.ArrayList(ObjectId) = .empty; defer parents.deinit(gpa); var it = c.parents(); while (it.next()) |p| try parents.append(gpa, p); var header_buf: [2 * git.object_max_hex_length + 16]u8 = undefined; const header = std.fmt.bufPrint(&header_buf, "{f}\n", .{id}) catch unreachable; if (parents.items.len == 0) { if (!t.options.root) return t.emit(&.{}, header); var pairs: Pairs = .init(gpa); defer pairs.deinit(gpa); try collectTrees(t.ctx, t.repo, t.spec, null, c.tree, t.options.recursive, t.options.show_trees, t.settings, &pairs, t.stderr); return t.emitPrepared(&pairs, if (t.settings.reverse) c.tree else null, header); } if (parents.items.len > 1 and !t.options.each_parent) return t.emit(&.{}, header); var differs = false; for (parents.items) |p| { if (try t.trees(p, c.tree, header)) differs = true; } return differs; }
/// A line of `--stdin`: a commit with or without parents, or two /// trees. fn stdinLine(t: *Comparer, line: []const u8) !bool { const f = t.repo.objectFormat(); var words = std.mem.tokenizeScalar(u8, line, ' '); const first_text = words.next() orelse return false; const first = ObjectId.parseHex(f, first_text) catch return tool.fatalMessage(t.stderr, "zig-git diff-tree: cannot read '{s}' from standard input", .{line}); const second_text = words.next() orelse return t.commit(first); if (words.next() != null) return repo_diff.unsupported(t.stderr, .diff_tree, "--stdin with a commit and its parents"); const second = ObjectId.parseHex(f, second_text) catch return tool.fatalMessage(t.stderr, "zig-git diff-tree: cannot read '{s}' from standard input", .{line}); var o = t.repo.objects().read(t.ctx.io, t.ctx.gpa, first, null) catch return tool.fatalMessage(t.stderr, "zig-git diff-tree: cannot read {f}", .{first}); defer o.deinit(t.ctx.gpa); if (o.kind == .commit) return repo_diff.unsupported(t.stderr, .diff_tree, "--stdin with a commit and its parents"); var header: std.ArrayList(u8) = .empty; defer header.deinit(t.ctx.gpa); try header.appendSlice(t.ctx.gpa, line); try header.append(t.ctx.gpa, '\n'); return t.trees(first, second, header.items); }};
/// A list of pairs and the paths they hold.pub const Pairs = struct { list: std.ArrayList(output.Pair) = .empty, paths: std.heap.ArenaAllocator,
pub fn init(gpa: Allocator) Pairs { return .{ .paths = .init(gpa) }; }
pub fn deinit(p: *Pairs, gpa: Allocator) void { p.list.deinit(gpa); p.paths.deinit(); }};
fn sideOf(s: ?git.TreeDiff.Side, format: git.ObjectFormat) output.Side { const x = s orelse return .{ .id = ObjectId.zero(format) }; return .{ .mode = @backingInt(x.mode), .id = x.id };}
/// The paths of `old` (a tree) under the pathspec that `pairs` does not/// list as changed, as unchanged pairs: the extra copy sources of/// `--find-copies-harder`.pub fn collectUnchanged(ctx: Context, repo: *git.Repository, spec: *const git.Pathspec, old: ?ObjectId, pairs: *const Pairs, s: *const repo_diff.Settings, out: *Pairs, stderr: *Io.Writer) !void { const gpa = ctx.gpa; const root = old orelse return; var all: Pairs = .init(gpa); defer all.deinit(gpa); var plain = s.*; plain.reverse = false; try collectTrees(ctx, repo, spec, root, null, true, false, &plain, &all, stderr); var changed: std.StringHashMapUnmanaged(void) = .empty; defer changed.deinit(gpa); for (pairs.list.items) |p| { try changed.put(gpa, p.path, {}); if (p.old_path) |op| try changed.put(gpa, op, {}); } for (all.list.items) |p| { if (changed.contains(p.path)) continue; const path = try out.paths.allocator().dupe(u8, p.path); try out.list.append(gpa, .{ .status = .unmodified, .path = path, .old = p.old, .new = p.old }); }}
/// The pairs between two trees, reversed as the settings say.pub fn collectTrees(ctx: Context, repo: *git.Repository, spec: *const git.Pathspec, old: ?ObjectId, new: ?ObjectId, recursive: bool, show_trees: bool, s: *const repo_diff.Settings, pairs: *Pairs, stderr: *Io.Writer) !void { const gpa = ctx.gpa; const f = repo.objectFormat(); var d = git.TreeDiff.init(gpa, ctx.io, repo.objects(), f, spec); defer d.deinit(); d.recursive = recursive; d.show_trees = show_trees; var diag: Diagnostics = .{}; d.start(old, new, &diag) catch |err| return tool.fatalMessage(stderr, "zig-git: cannot compare trees: {t}", .{err}); while (d.next(&diag) catch |err| return tool.fatalMessage(stderr, "zig-git: cannot compare trees: {t}", .{err})) |c| { var o = sideOf(c.old, f); var n = sideOf(c.new, f); if (s.reverse) std.mem.swap(output.Side, &o, &n); const status = output.statusOf(o, n); const path = try pairs.paths.allocator().dupe(u8, c.path); try pairs.list.append(gpa, .{ .status = status, .path = path, .old = o, .new = n }); }}