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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The diffs `git log` and `git show` write after each commit: the commit//! against its first parent, or against the empty tree for a root commit//! (`log.showRoot`), after the separator git puts between the message and//! the diff. How a merge is shown is `MergeDiffs`: by default nothing in//! `log` and a dense combined diff (`--cc`) in `show`; against each parent//! with `-m`, or as a combined diff with `-c`.//!//! The separator, found by asking git: none after `--oneline` or an//! empty format string (which writes nothing for the commit); `---`//! when the patch comes with `--stat`; otherwise an empty line. It is//! written for every commit with a changed path, even where the formats//! write nothing for them, and only then. Under `-z` the empty line//! before a combined diff is a NUL, and so is the one between its raw//! lines and its patch, while the one before a two-way diff stays a line//! feed, as git was seen to write them.
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 tool = @import("zig_git.zig");const Context = tool.Context;const repo_diff = @import("repo_diff.zig");const check_attr = @import("check_attr.zig");const diff_tree = @import("diff_tree.zig");const object_names = @import("object_names.zig");const revisions = @import("revisions.zig");const pathspecs = @import("pathspecs.zig");
/// How the commit a diff follows was written, which decides the/// separator before the diff.pub const Framing = enum { /// Any other format: an empty line, or `---` before a patch with /// `--stat`. full, /// `--oneline`: no separator. oneline, /// An empty format string (`--format=`, `--pretty=format:`), which /// writes nothing for the commit: no separator, even before a /// combined diff. empty,};
/// How a merge's diff is shown: `--diff-merges` and its shorthands.pub const MergeDiffs = enum { /// Not chosen: `log` shows none (the first parent's under /// `--first-parent`), `show` a dense combined diff. default, off, /// `on`, `m`, `-m`: as `log.diffMerges` says, separately by default. on, first_parent, separate, combined, dense_combined,
/// Reads a `--diff-merges` or `log.diffMerges` value. pub fn parse(text: []const u8) ?MergeDiffs { const names = [_]struct { []const u8, MergeDiffs }{ .{ "off", .off }, .{ "none", .off }, .{ "on", .on }, .{ "m", .on }, .{ "first-parent", .first_parent }, .{ "1", .first_parent }, .{ "separate", .separate }, .{ "combined", .combined }, .{ "c", .combined }, .{ "dense-combined", .dense_combined }, .{ "cc", .dense_combined }, }; for (names) |n| if (std.mem.eql(u8, n[0], text)) return n[1]; return null; }};
/// The diff options `log` and `show` were given, kept to be read once the/// repository is open, after its configuration.pub const Collected = struct { args: std.ArrayList([]const u8) = .empty, /// `--full-diff`: the diff is not limited to the paths. full_diff: bool = false, /// `--diff-merges`, `-m`, `-c`, `--cc`, `--dd`, `--no-diff-merges`. merges: MergeDiffs = .default, /// Whether `--diff-merges` was given: it turns the diff on, with the /// patch as merges' format if none is given (and none for other /// commits), where `-m` alone does not. merges_given: bool = false, /// Whether `-c`, `--cc` or `--dd` made the patch the default format /// for every commit. patch_default: bool = false, /// `--color` asking for colors, which a diff cannot have. color: bool = false, /// `whatchanged`: the raw format unless another is given, and commits /// whose diff is empty left out. whatchanged: bool = false,
pub fn deinit(c: *Collected, gpa: Allocator) void { c.args.deinit(gpa); }
/// Takes `arg` (and `next`, if it takes it) if it is a diff option, /// returning how many arguments it took: 0 when it is not one. pub fn take(c: *Collected, gpa: Allocator, command: repo_diff.Command, arg: []const u8, next: ?[]const u8, stderr: *Io.Writer) !usize { if (std.mem.eql(u8, arg, "--full-diff")) { c.full_diff = true; return 1; } // How merges are shown; all but -m imply a patch. if (std.mem.eql(u8, arg, "-m")) { c.merges = .on; return 1; } if (std.mem.eql(u8, arg, "-c") or std.mem.eql(u8, arg, "--cc") or std.mem.eql(u8, arg, "--dd")) { c.merges = if (arg[1] == 'c') .combined else if (arg[2] == 'c') .dense_combined else .first_parent; // Each turns the patch on for every commit, unless a format // is given. c.patch_default = true; return 1; } if (std.mem.eql(u8, arg, "--no-diff-merges")) { c.merges = .off; return 1; } if (std.mem.eql(u8, arg, "--remerge-diff")) return repo_diff.unsupported(stderr, command, "--remerge-diff"); if (std.mem.startsWith(u8, arg, "--diff-merges")) { const v = if (std.mem.findScalar(u8, arg, '=')) |eq| arg[eq + 1 ..] else (next orelse return tool.fatalMessage(stderr, "Option '--diff-merges' requires a value", .{})); if (std.mem.eql(u8, v, "remerge") or std.mem.eql(u8, v, "r")) return repo_diff.unsupported(stderr, command, "--diff-merges=remerge"); c.merges = MergeDiffs.parse(v) orelse return tool.fatalMessage(stderr, "invalid value for '--diff-merges': '{s}'", .{v}); if (c.merges != .off) c.merges_given = true; return if (std.mem.findScalar(u8, arg, '=') == null) 2 else 1; } // Colors are the log's to read; they would color the diff as well, // which is refused once there is a diff to color. if (std.mem.startsWith(u8, arg, "--color") or std.mem.eql(u8, arg, "--no-color")) { if (std.mem.eql(u8, arg, "--no-color")) { c.color = false; } else { const v = if (std.mem.findScalar(u8, arg, '=')) |eq| arg[eq + 1 ..] else "always"; c.color = !(std.mem.eql(u8, v, "never") or std.mem.eql(u8, v, "auto")); } return 0; } var scratch: repo_diff.Settings = .{ .command = command }; defer scratch.deinit(gpa); switch (try repo_diff.parseOption(gpa, &scratch, arg, next, stderr)) { .no => return 0, .yes => { try c.args.append(gpa, arg); return 1; }, .took_next => { try c.args.append(gpa, arg); try c.args.append(gpa, next.?); return 2; }, } }};
/// Writes the diff after each commit.pub const Writer = struct { ctx: Context, repo: *git.Repository, settings: repo_diff.Settings, spec: *const git.Pathspec, first_parent: bool, /// How merges are shown, settled from the options, `log.diffMerges` /// and the command. merges: MergeDiffs, /// Whether only merges have a diff: `--diff-merges` without a format. merges_only: bool = false, show_root: bool, command: repo_diff.Command, stderr: *Io.Writer, names: *object_names.Names, short: diff_tree.Short, attrs: *check_attr.Setup, conversion_attrs: *check_attr.Setup, whitespace_attrs: *check_attr.Setup, drivers: repo_diff.Drivers, blobs: repo_diff.BlobSource, buffer: Io.Writer.Allocating, /// The copy sources `--find-copies-harder` adds for the diff being /// written, kept while its pairs are. unchanged: diff_tree.Pairs, warnings: repo_diff.RenameWarnings = .{}, /// `whatchanged`: commits whose diff is empty are left out, and /// merges unless `-m` or `--diff-merges` asks for them. skip_empty: bool = false, skip_merges: bool = false, /// Set under `--follow`: each commit's diff is of the path it had /// there, renames and copies found among every path. follower: ?*Follower = null, /// The commit being written, for `follower`. current: ?ObjectId = null, /// Whether `--check` found a problem in any diff written. problems: bool = false,
/// Sets up the diffs `collected` asks for; null when they ask for /// none. `default_patch` is `show`'s: a patch when no format is given. pub fn init(ctx: Context, repo: *git.Repository, revs: *revisions.Revisions, names: *object_names.Names, settings_names: object_names.Settings, collected: *const Collected, command: repo_diff.Command, spec: *const git.Pathspec, first_parent: bool, default_patch: bool, stderr: *Io.Writer) !?*Writer { const gpa = ctx.gpa; // Whether any diff is asked for at all, before the configuration, // which only matters then, is read. { var scratch: repo_diff.Settings = .{ .command = command }; defer scratch.deinit(gpa); var k: usize = 0; while (k < collected.args.items.len) : (k += 1) { const next: ?[]const u8 = if (k + 1 < collected.args.items.len) collected.args.items[k + 1] else null; if (try repo_diff.parseOption(gpa, &scratch, collected.args.items[k], next, stderr) == .took_next) k += 1; } // `show` works the diff out even when it writes none, and so // does `log` under --full-diff, as their rename warnings // show. if (!(collected.whatchanged or scratch.out.format.any() or scratch.filter_given or scratch.pickaxe_kind != null or default_patch or collected.full_diff or collected.merges_given or collected.patch_default)) return null; } if (collected.color) return repo_diff.unsupported(stderr, command, "--color"); var s: repo_diff.Settings = .{ .command = command }; errdefer s.deinit(gpa); try repo_diff.readConfig(&repo.config, &s, stderr); var i: usize = 0; while (i < collected.args.items.len) : (i += 1) { const next: ?[]const u8 = if (i + 1 < collected.args.items.len) collected.args.items[i + 1] else null; if (try repo_diff.parseOption(gpa, &s, collected.args.items[i], next, stderr) == .took_next) i += 1; } try repo_diff.check(&s, stderr); const merges_only = !s.format_given and !default_patch and !collected.patch_default and collected.merges_given; if (!s.format_given and (default_patch or collected.merges_given or collected.patch_default)) s.out.format.patch = true; if (!s.format_given and collected.whatchanged and !s.out.format.any()) s.out.format.raw = true; if (!collected.whatchanged and !s.out.format.any() and !s.filter_given and s.pickaxe_kind == null and !default_patch and !collected.full_diff and !collected.merges_given and !collected.patch_default) { s.deinit(gpa); return null; } if (s.relative != null and spec.items.len != 0) return repo_diff.unsupported(stderr, command, "--relative with paths"); if (s.output_path != null) return repo_diff.unsupported(stderr, command, "--output"); if (s.exit_code or s.quiet) return repo_diff.unsupported(stderr, command, if (s.quiet) "--quiet" else "--exit-code");
try repo_diff.setUp(ctx, repo, &s, revs, stderr); // git was seen to list the commits --find-object chooses in // another order than its walk, by a rule not found. if (command == .log and s.pickaxe_kind == .object) return repo_diff.unsupported(stderr, command, "--find-object"); const w = try gpa.create(Writer); errdefer gpa.destroy(w); s.out.patch.object_format = repo.objectFormat(); repo_diff.finish(ctx, &s, null, names, settings_names); const show_root = if (repo.config.get("log.showroot") catch null) |e| git.Config.value.parseBool(e.value) orelse true else true; var merges = collected.merges; if (merges == .on) { merges = .separate; if (repo.config.get("log.diffmerges") catch null) |e| { const v = e.value orelse ""; const m = MergeDiffs.parse(v) orelse .default; if (m == .default or m == .on) { if (e.origin.kind == .file) return tool.fatalMessage(stderr, "bad config variable 'log.diffmerges' in file '{s}' at line {d}", .{ e.origin.name, e.line }); return tool.fatalMessage(stderr, "unable to parse 'log.diffmerges' from command-line config", .{}); } merges = m; } if (first_parent) merges = .first_parent; } if (merges == .default) merges = if (first_parent) .first_parent else if (command == .show) .dense_combined else .off; 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); errdefer attrs.deinit(); const conversion_attrs = try check_attr.setup(ctx, repo, from, stderr); errdefer conversion_attrs.deinit(); const whitespace_attrs = try check_attr.setup(ctx, repo, from, stderr); errdefer whitespace_attrs.deinit(); w.* = .{ .ctx = ctx, .repo = repo, .settings = s, .spec = if (collected.full_diff) &git.Pathspec.empty else spec, .first_parent = first_parent, .merges = merges, .merges_only = merges_only, .show_root = show_root, .command = command, .stderr = stderr, .names = names, .short = .{ .ctx = ctx, .names = names }, .attrs = attrs, .conversion_attrs = conversion_attrs, .whitespace_attrs = whitespace_attrs, .drivers = undefined, .blobs = .{ .ctx = ctx, .repo = repo }, .buffer = .init(gpa), .unchanged = .init(gpa), .skip_empty = collected.whatchanged, .skip_merges = collected.whatchanged and collected.merges == .default, }; w.settings.out.patch.shorten = .{ .context = &w.short, .length = diff_tree.Short.length }; // The -I matcher points into the settings, which have moved. if (w.settings.out.patch.hunks.ignore_line) |*il| il.context = &w.settings.matcher; w.drivers = .{ .gpa = gpa, .config = &repo.config, .setup = attrs, .conversions = conversion_attrs, .whitespace = whitespace_attrs, .stderr = stderr, .command = command }; if (w.settings.out.format.check) w.drivers.check = w.settings.out.whitespace; return w; }
pub fn deinit(w: *Writer) void { const gpa = w.ctx.gpa; w.warnings.flush(w.stderr) catch {}; w.unchanged.deinit(gpa); w.settings.deinit(gpa); w.attrs.deinit(); w.conversion_attrs.deinit(); w.whitespace_attrs.deinit(); w.drivers.deinit(); w.buffer.deinit(); gpa.destroy(w); }
/// Writes the diff of commit `id`, whose parents are `parents`, after /// its message, if it has one; `framing` says how the commit itself /// was written. pub fn write(w: *Writer, stdout: *Io.Writer, id: ObjectId, parents: []const ObjectId, framing: Framing) !void { const gpa = w.ctx.gpa; var old: ?ObjectId = null; if (w.merges_only and parents.len < 2) return; if (parents.len == 0) { if (!w.show_root) return; } else if (parents.len > 1) switch (w.merges) { .off => return, .first_parent, .separate => old = try treeOf(w, parents[0]), else => return w.writeCombined(stdout, id, parents, framing), } else old = try treeOf(w, parents[0]); w.current = id; return w.writeTrees(stdout, old, try treeOfCommitParentsGiven(w, id), framing, gpa); }
/// Whether merge commit `id` is written once for each of its /// `parents`, each time with `(from <parent>)` and the diff against /// that parent (`-m`, `--diff-merges=separate`). pub fn separately(w: *const Writer, parents: []const ObjectId) bool { return parents.len > 1 and w.merges == .separate; }
/// Writes the diff of commit `id` against its parent `parent`, as /// `separately` asks. pub fn writeAgainst(w: *Writer, stdout: *Io.Writer, id: ObjectId, parent: ObjectId, framing: Framing) !void { w.current = id; return w.writeTrees(stdout, try treeOf(w, parent), try treeOf(w, id), framing, w.ctx.gpa); }
/// Stops as git stops when a merge is about to be shown as a combined /// diff under `-I`, which git refuses there: before anything of the /// merge is written. pub fn checkCombined(w: *const Writer, parents: []const ObjectId) !void { if (w.combines(parents) and w.settings.out.patch.hunks.ignore_line != null) return tool.fatalMessage(w.stderr, "combined diff and '--ignore-matching-lines' cannot be used together", .{}); }
/// Whether a merge is shown as a combined diff. pub fn combines(w: *const Writer, parents: []const ObjectId) bool { return parents.len > 1 and (w.merges == .combined or w.merges == .dense_combined); }
/// Whether commits are chosen by their diffs (`--diff-filter`, the /// pickaxe): a commit whose diff shows nothing is not listed at all, /// and nor is a merge's entry for a parent under `-m`. pub fn filtering(w: *const Writer) bool { return w.skip_empty or w.settings.filter_given or w.settings.pickaxe != null; }
/// Whether commit `id`, with `parents`, has anything in its diff as /// `log` would write it, for `filtering`. pub fn keeps(w: *Writer, id: ObjectId, parents: []const ObjectId) !bool { const gpa = w.ctx.gpa; // Even its first parent's diff, under --first-parent. if (w.skip_merges and parents.len > 1) return false; var old: ?ObjectId = null; if (parents.len == 0) { if (!w.show_root) return false; } else if (parents.len > 1) switch (w.merges) { .off => return false, .first_parent => old = try treeOf(w, parents[0]), .separate => { for (parents) |p| if (try w.keepsAgainst(id, p)) return true; return false; }, // git was seen to list every merge it shows combined whatever // the pickaxe finds; `writeCombined` refuses one whose // combined diff is not empty. else => { if (w.settings.pickaxe != null and !w.settings.filter_given) return true; if (w.skip_empty and !w.settings.filter_given) return repo_diff.unsupported(w.stderr, w.command, "whatchanged with a merge's combined diff"); return repo_diff.unsupported(w.stderr, w.command, "--diff-filter with a merge's combined diff"); }, } else old = try treeOf(w, parents[0]); const new = try treeOf(w, id); var pairs: diff_tree.Pairs = .init(gpa); defer pairs.deinit(gpa); w.current = id; const d = try w.diffPairs(&pairs, old, new); defer gpa.free(d.owned); return d.shown.len != 0; }
/// Whether merge `id`'s diff against `parent` keeps anything: under /// `-m` a parent's entry is left out when it does not. pub fn keepsAgainst(w: *Writer, id: ObjectId, parent: ObjectId) !bool { const gpa = w.ctx.gpa; var pairs: diff_tree.Pairs = .init(gpa); defer pairs.deinit(gpa); w.current = id; const d = try w.diffPairs(&pairs, try treeOf(w, parent), try treeOf(w, id)); defer gpa.free(d.owned); return d.shown.len != 0; }
/// Writes a merge's combined diff after its message (`-c`, `--cc`, /// `show`'s default): an empty line, the counts and summary against /// the first parent, then the raw format (`::`), the names or the /// combined patch of each path that differs from every parent, under /// `--cc` only the changes `git.diff.combined` keeps. A path renamed or /// copied against a parent is compared with its source there. Refused /// by name: a rewrite `-B` broke, `--ignore-blank-lines`, and more than /// 32 parents. pub fn writeCombined(w: *Writer, stdout: *Io.Writer, id: ObjectId, parents: []const ObjectId, framing: Framing) !void { const gpa = w.ctx.gpa; const f = w.settings.out.format; if (!f.any()) return; const new = try treeOf(w, id); // Each parent's pairs, renames and copies found as for any other // diff: a path renamed against a parent is compared with where // it came from there, and its status there is R (or C). var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var all: std.ArrayList([]output.Pair) = .empty; defer all.deinit(gpa); { const saved = w.settings; defer w.settings = saved; w.settings.reverse = false; w.settings.filter_given = false; for (parents) |p| { var pairs: diff_tree.Pairs = .init(gpa); defer pairs.deinit(gpa); const old = try treeOf(w, p); try diff_tree.collectTrees(w.ctx, w.repo, w.spec, old, new, true, false, &w.settings, &pairs, w.stderr); const shown = try w.prepare(&pairs, old, new); defer gpa.free(shown); // The pairs may borrow paths from what the next parent's // pairs replace. const copy = try arena.alloc(output.Pair, shown.len); for (shown, copy) |q, *c| { c.* = q; c.path = try arena.dupe(u8, q.path); if (q.old_path) |op| c.old_path = try arena.dupe(u8, op); } try all.append(gpa, copy); } } // The paths that differ from every parent, in the first parent's // order, with each parent's pair. var paths: std.ArrayList([]output.Pair) = .empty; defer { for (paths.items) |x| gpa.free(x); paths.deinit(gpa); } for (all.items[0]) |p0| { const row = try gpa.alloc(output.Pair, parents.len); row[0] = p0; const everywhere = for (all.items[1..], 1..) |other, k| { const q = for (other) |q| { if (std.mem.eql(u8, q.path, p0.path)) break q; } else break false; row[k] = q; } else true; if (!everywhere) { gpa.free(row); continue; } try paths.append(gpa, row); } const shows_paths = f.patch or f.raw or f.name_only or f.name_status; if (shows_paths and paths.items.len != 0) { if (w.settings.pickaxe != null) return repo_diff.unsupported(w.stderr, w.command, "the pickaxe with a merge's combined diff"); if (parents.len > git.diff.combined.max_parents) return repo_diff.unsupported(w.stderr, w.command, "a combined diff of more than 32 parents"); // A rewrite -B broke and nothing took is shown whole in a // two-way patch; what git makes of one here is not matched. for (paths.items) |row| for (row) |q| if (q.rewrite) return repo_diff.unsupported(w.stderr, w.command, "a combined diff of a rewrite (-B)"); } // --oneline still takes the empty line here; an empty format // string takes nothing. // Under -z the empty line is a NUL, as git was seen to write it // before a combined diff and never before a two-way one, and the // paths are written as they are, each ended by a NUL. const nul = w.settings.out.nul; const end: u8 = if (nul) 0 else '\n'; if (framing != .empty) try stdout.writeByte(end); if (f.name_only or f.name_status) { for (paths.items) |row| { if (f.name_status) { for (row) |q| try stdout.writeByte(q.status.letter()); try stdout.writeByte(if (nul) 0 else '\t'); } try rawPath(stdout, row[0].path, &w.settings); try stdout.writeByte(end); } return; } var written = false; if (f.numstat or f.stat or f.shortstat or f.summary or f.dirstat) { const saved = w.settings.out.format; defer w.settings.out.format = saved; w.settings.out.format = .{ .numstat = saved.numstat, .stat = saved.stat, .shortstat = saved.shortstat, .summary = saved.summary, .dirstat = saved.dirstat }; var pairs: diff_tree.Pairs = .init(gpa); defer pairs.deinit(gpa); const old = try treeOf(w, parents[0]); try diff_tree.collectTrees(w.ctx, w.repo, w.spec, old, new, true, false, &w.settings, &pairs, w.stderr); const shown = try w.prepare(&pairs, old, new); defer gpa.free(shown); const outcome = output.write(gpa, stdout, shown, w.blobs.source(), w.drivers.drivers(), w.settings.out) catch |err| switch (err) { error.DriverUnsupported => return error.Fatal, error.ContentsUnreadable => return tool.fatalMessage(w.stderr, "zig-git {s}: cannot read a blob", .{w.command.name()}), else => |e| return e, }; written = outcome.written; } if (f.raw) for (paths.items) |row| { // A colon for each parent. try stdout.splatByteAll(':', parents.len); for (row) |q| try stdout.print("{o:0>6} ", .{q.old.mode}); try stdout.print("{o:0>6} ", .{row[0].new.mode}); for (row) |q| { try w.writeId(stdout, q.old.id, w.settings.out.raw_abbrev); try stdout.writeByte(' '); } try w.writeId(stdout, row[0].new.id, w.settings.out.raw_abbrev); try stdout.writeByte(' '); for (row) |q| try stdout.writeByte(q.status.letter()); try stdout.writeByte(if (nul) 0 else '\t'); try rawPath(stdout, row[0].path, &w.settings); try stdout.writeByte(end); written = true; }; if (f.patch and paths.items.len != 0) { if (written) try stdout.writeByte(end); for (paths.items) |row| try w.writeCombinedPatch(stdout, row); } }
fn writeId(w: *Writer, out: *Io.Writer, oid: ObjectId, abbrev: ?usize) !void { var buf: [git.object_max_hex_length]u8 = undefined; const hex = oid.toHex(&buf); var len = hex.len; if (abbrev) |a| { len = @min(@max(a, 4), hex.len); if (!oid.eql(ObjectId.zero(oid.object_format))) len = @min(diff_tree.Short.length(&w.short, oid, len), hex.len); } try out.writeAll(hex[0..len]); }
fn readSide(w: *Writer, side: output.Side) ![]u8 { const gpa = w.ctx.gpa; if (!side.present()) return gpa.alloc(u8, 0); if (side.mode & 0o170000 == 0o160000) return std.fmt.allocPrint(gpa, "Subproject commit {f}\n", .{side.id}); var o = w.repo.objects().read(w.ctx.io, gpa, side.id, null) catch return tool.fatalMessage(w.stderr, "zig-git {s}: cannot read a blob", .{w.command.name()}); defer o.deinit(gpa); return gpa.dupe(u8, o.data); }
fn writeCombinedPatch(w: *Writer, out: *Io.Writer, row: []const output.Pair) !void { const gpa = w.ctx.gpa; const result = row[0].new; var texts: std.ArrayList([]u8) = .empty; defer { for (texts.items) |t| gpa.free(t); texts.deinit(gpa); } for (row) |q| try texts.append(gpa, try w.readSide(q.old)); const result_text = try w.readSide(result); defer gpa.free(result_text); // git leaves changes out of a combined diff by another rule than // a two-way patch's under these, not matched. if (w.settings.out.patch.hunks.ignore_blank_lines) return repo_diff.unsupported(w.stderr, w.command, "a combined diff with --ignore-blank-lines"); // The path's driver says whether it is binary, as it does for a // two-way patch; its function lines play no part (see // `git.diff.combined`), so its pattern is never compiled. const driver = w.drivers.drivers().get(w.drivers.drivers().context, row[0].path) catch |err| switch (err) { error.DriverUnsupported => return error.Fatal, else => |e| return e, }; const binary = driver.binary orelse binary: { for (texts.items) |t| if (git.diff.patch.isBinary(t)) break :binary true; break :binary git.diff.patch.isBinary(result_text); }; const po = w.settings.out.patch; const dense = w.merges == .dense_combined; const absent_everywhere = for (row) |q| { if (q.old.present()) break false; } else true; const modes_differ = for (row) |q| { if (q.old.mode != result.mode) break true; } else false; // The hunks first: a file with none is not shown at all (its // changes all left out by --cc, or a rename against every parent // with the contents unchanged), unless its header says something // of its own (a mode, a new or deleted file), or it is binary. var hunk_text: Io.Writer.Allocating = .init(gpa); defer hunk_text.deinit(); if (!binary) { var parents_text: [git.diff.combined.max_parents][]const u8 = undefined; for (texts.items, 0..) |t, k| parents_text[k] = t; _ = git.diff.combined.writeHunks(gpa, &hunk_text.writer, parents_text[0..texts.items.len], result_text, .{ // A combined diff was seen to take no notice of // diff.<driver>.algorithm, which `--stat` against the // first parent does. .lines = po.lines, .context = po.hunks.context, .dense = dense, }) catch |err| switch (err) { error.LongSearchUnsupported => return repo_diff.unsupported(w.stderr, w.command, "a line diff searching past 256 steps"), else => |e| return e, }; if (hunk_text.written().len == 0 and !absent_everywhere and result.present() and !modes_differ) return; } try out.writeAll(if (dense) "diff --cc " else "diff --combined "); try quotePath(out, row[0].path, &w.settings); try out.writeAll("\nindex "); for (row, 0..) |q, k| { if (k != 0) try out.writeByte(','); try w.writeId(out, q.old.id, po.abbrev orelse null); } try out.writeAll(".."); try w.writeId(out, result.id, po.abbrev orelse null); try out.writeByte('\n'); if (absent_everywhere) { try out.print("new file mode {o:0>6}\n", .{result.mode}); } else if (!result.present()) { try out.writeAll("deleted file mode "); for (row, 0..) |q, k| { if (k != 0) try out.writeByte(','); try out.print("{o:0>6}", .{q.old.mode}); } try out.writeByte('\n'); } else if (modes_differ) { try out.writeAll("mode "); for (row, 0..) |q, k| { if (k != 0) try out.writeByte(','); try out.print("{o:0>6}", .{q.old.mode}); } try out.print("..{o:0>6}\n", .{result.mode}); } // A binary file gets no hunks, and no ---/+++ lines either. if (binary) return out.writeAll("Binary files differ\n"); if (absent_everywhere) try out.writeAll("--- /dev/null\n") else { try out.writeAll("--- "); try prefixedPath(out, po.src_prefix, row[0].path, &w.settings); } if (!result.present()) try out.writeAll("+++ /dev/null\n") else { try out.writeAll("+++ "); try prefixedPath(out, po.dst_prefix, row[0].path, &w.settings); } try out.writeAll(hunk_text.written()); }
/// Renames found and `--diff-filter` applied to the pairs between /// `old` and `new`. fn prepare(w: *Writer, pairs: *diff_tree.Pairs, old: ?ObjectId, new: ObjectId) ![]output.Pair { const gpa = w.ctx.gpa; w.unchanged.list.clearRetainingCapacity(); _ = w.unchanged.paths.reset(.retain_capacity); const source_tree = if (w.settings.reverse) new else old; if (w.settings.harder and w.settings.renames) try diff_tree.collectUnchanged(w.ctx, w.repo, w.spec, source_tree, pairs, &w.settings, &w.unchanged, w.stderr); return repo_diff.prepare(gpa, &w.settings, pairs.list.items, w.unchanged.list.items, w.blobs.source(), w.drivers.drivers(), &w.warnings, w.stderr) catch |err| switch (err) { error.ContentsUnreadable => return tool.fatalMessage(w.stderr, "zig-git {s}: cannot read a blob", .{w.command.name()}), else => |e| return e, }; }
/// The pairs of the diff between `old` and `new` as the formats see /// them, and the list to free (which `pairs` may borrow paths from). const Diff = struct { shown: []output.Pair, owned: []output.Pair };
fn diffPairs(w: *Writer, pairs: *diff_tree.Pairs, old: ?ObjectId, new: ObjectId) !Diff { const follow_spec: ?*const git.Pathspec = if (w.follower) |f| if (w.current) |c| f.pathspecAt(c) else null else null; try diff_tree.collectTrees(w.ctx, w.repo, if (w.follower != null) &git.Pathspec.empty else w.spec, old, new, true, false, &w.settings, pairs, w.stderr); // Under --follow, the paths under the pathspec followed, and only // where the path it names was added is that one looked for among // the rest. if (follow_spec) |sp| { const fp = sp.items[0].pattern; var added = false; for (pairs.list.items) |p| { if (p.status == .added and std.mem.eql(u8, p.path, fp)) added = true; } if (added) { // Where the path was added, it is looked for as the // follower looks for it. dropOtherAdditions(pairs, fp); const saved = w.settings; defer w.settings = saved; w.settings.copies = true; w.settings.harder = true; const all = try w.prepare(pairs, old, new); var k: usize = 0; for (all) |p| if (sp.matches(p.path, isGitlink(p))) { all[k] = p; k += 1; }; return .{ .shown = all[0..k], .owned = all }; } // Elsewhere, the paths under the pathspec are compared as log // compares them. var k: usize = 0; for (pairs.list.items) |p| if (sp.matches(p.path, isGitlink(p))) { pairs.list.items[k] = p; k += 1; }; pairs.list.shrinkRetainingCapacity(k); const saved_spec = w.spec; defer w.spec = saved_spec; w.spec = sp; const list = try w.prepare(pairs, old, new); return .{ .shown = list, .owned = list }; } const all_shown = try w.prepare(pairs, old, new); return .{ .shown = all_shown, .owned = all_shown }; }
fn writeTrees(w: *Writer, stdout: *Io.Writer, old: ?ObjectId, new: ObjectId, framing: Framing, gpa: Allocator) !void { var pairs: diff_tree.Pairs = .init(gpa); defer pairs.deinit(gpa); const d = try w.diffPairs(&pairs, old, new); defer gpa.free(d.owned); const shown = d.shown; if (shown.len == 0 or !w.settings.out.format.any()) return; const source = w.blobs.source(); w.buffer.clearRetainingCapacity(); const outcome = output.write(gpa, &w.buffer.writer, shown, source, w.drivers.drivers(), w.settings.out) catch |err| switch (err) { error.BlankFileUnsupported => return repo_diff.unsupported(w.stderr, w.command, "--check of a file of blank lines alone"), error.DriverUnsupported => { // A driver's pattern that does not compile stops git at // the first patch that needs it, with all that came // before already written. try w.writeSeparated(stdout, framing); return error.Fatal; }, error.ContentsUnreadable => return tool.fatalMessage(w.stderr, "zig-git {s}: cannot read a blob", .{w.command.name()}), error.LongSearchUnsupported => return repo_diff.unsupported(w.stderr, w.command, "a line diff searching past 256 steps"), else => |e| return e, }; if (outcome.problems) w.problems = true; try w.writeSeparated(stdout, framing); }
/// Writes the separator and then what the formats wrote. fn writeSeparated(w: *Writer, stdout: *Io.Writer, framing: Framing) Io.Writer.Error!void { // The separator goes by the changed paths, not by what the // formats make of them: git writes it before an empty --summary // and before what -w hides whole. const text = w.buffer.written(); const f = w.settings.out.format; if (framing == .full) { if (f.patch and f.stat and !f.name_only and !f.name_status) try stdout.writeAll("---\n") else try stdout.writeByte('\n'); } try stdout.writeAll(text); }};
fn treeOf(w: *Writer, commit: ObjectId) !ObjectId { var o = w.repo.objects().read(w.ctx.io, w.ctx.gpa, commit, null) catch return tool.fatalMessage(w.stderr, "zig-git {s}: cannot read commit {f}", .{ w.command.name(), commit }); defer o.deinit(w.ctx.gpa); return git.Commit.treeOf(w.repo.objectFormat(), o.data) orelse return tool.fatalMessage(w.stderr, "zig-git {s}: cannot read commit {f}", .{ w.command.name(), commit });}
fn treeOfCommitParentsGiven(w: *Writer, commit: ObjectId) !ObjectId { return treeOf(w, commit);}
/// `log --follow <path>`: the path a file had at each commit shown,/// following it back through renames and copies.////// git follows a file by looking, at each commit, for a change under the/// pathspec it has then; where the path the pathspec names was added, it/// looks for where that path came from among every path of the commit,/// copies from unchanged files included (as `--find-copies-harder` finds/// them), and goes on with a pathspec naming that path. A history with/// merges is refused by name, at the first merge: git looks for the file/// through every parent of a merge, which is not matched.pub const Follower = struct { ctx: Context, repo: *git.Repository, stderr: *Io.Writer, settings: repo_diff.Settings, /// Every pathspec followed, the first the one given; owned. specs: std.ArrayList(*git.Pathspec) = .empty, /// What the current pathspec names, for finding where it came from. current: std.ArrayList(u8) = .empty, /// The pathspec at each commit kept, by object ID. at: std.StringHashMapUnmanaged(*const git.Pathspec) = .empty, arena: std.heap.ArenaAllocator, blobs: repo_diff.BlobSource, warnings: repo_diff.RenameWarnings = .{},
/// Follows `spec` (taking it over), the only pathspec, with the rename /// options among `collected`. pub fn init(ctx: Context, repo: *git.Repository, given: git.Pathspec, collected: *const Collected, stderr: *Io.Writer) !Follower { var f: Follower = .{ .ctx = ctx, .repo = repo, .stderr = stderr, .settings = .{ .command = .log }, .arena = .init(ctx.gpa), .blobs = .{ .ctx = ctx, .repo = repo }, }; errdefer f.deinit(); try repo_diff.readConfig(&repo.config, &f.settings, stderr); var i: usize = 0; while (i < collected.args.items.len) : (i += 1) { const next: ?[]const u8 = if (i + 1 < collected.args.items.len) collected.args.items[i + 1] else null; if (try repo_diff.parseOption(ctx.gpa, &f.settings, collected.args.items[i], next, stderr) == .took_next) i += 1; } f.settings.renames = true; f.settings.copies = true; f.settings.harder = true; f.settings.filter_given = false; const owned = try ctx.gpa.create(git.Pathspec); owned.* = given; f.specs.append(ctx.gpa, owned) catch |err| { owned.deinit(ctx.gpa); ctx.gpa.destroy(owned); return err; }; try f.current.appendSlice(ctx.gpa, given.items[0].pattern); return f; }
pub fn deinit(f: *Follower) void { const gpa = f.ctx.gpa; f.warnings.flush(f.stderr) catch {}; for (f.specs.items) |sp| { sp.deinit(gpa); gpa.destroy(sp); } f.specs.deinit(gpa); f.current.deinit(gpa); f.at.deinit(gpa); f.arena.deinit(); f.settings.deinit(gpa); }
/// The pathspec followed at commit `id`, if it was kept. pub fn pathspecAt(f: *const Follower, id: ObjectId) ?*const git.Pathspec { return f.at.get(id.bytes[0..id.object_format.byteLength()]); }
/// The path the pathspec at commit `id` names, if it was kept. pub fn pathAt(f: *const Follower, id: ObjectId) ?[]const u8 { const sp = f.pathspecAt(id) orelse return null; return sp.items[0].pattern; }
fn followed(f: *const Follower) *const git.Pathspec { return f.specs.items[f.specs.items.len - 1]; }
/// Whether commit `id` changes a path under the followed pathspec; if /// it does, notes the pathspec, and moves to where the file came from /// if the path it names was added here. Commits must come newest /// first. pub fn keep(f: *Follower, id: ObjectId, parents: []const ObjectId) !bool { const gpa = f.ctx.gpa; if (parents.len > 1) return repo_diff.unsupported(f.stderr, .log, "--follow across a merge"); const old: ?ObjectId = if (parents.len == 1) try treeOfIn(f.ctx, f.repo, parents[0], f.stderr) else null; const new = try treeOfIn(f.ctx, f.repo, id, f.stderr); var pairs: diff_tree.Pairs = .init(gpa); defer pairs.deinit(gpa); try diff_tree.collectTrees(f.ctx, f.repo, &git.Pathspec.empty, old, new, true, false, &f.settings, &pairs, f.stderr); const sp = f.followed(); var any = false; var added = false; for (pairs.list.items) |p| { if (!sp.matches(p.path, p.new.mode & 0o170000 == 0o160000 or p.old.mode & 0o170000 == 0o160000)) continue; any = true; if (p.status == .added and std.mem.eql(u8, p.path, f.current.items)) added = true; } if (!any) return false; const a = f.arena.allocator(); try f.at.put(gpa, try a.dupe(u8, id.bytes[0..id.object_format.byteLength()]), sp); if (!added) return true; var unchanged: diff_tree.Pairs = .init(gpa); defer unchanged.deinit(gpa); try diff_tree.collectUnchanged(f.ctx, f.repo, &git.Pathspec.empty, old, &pairs, &f.settings, &unchanged, f.stderr); dropOtherAdditions(&pairs, f.current.items); const shown = repo_diff.prepare(gpa, &f.settings, pairs.list.items, unchanged.list.items, f.blobs.source(), output.default_drivers, &f.warnings, f.stderr) catch |err| switch (err) { error.ContentsUnreadable => return tool.fatalMessage(f.stderr, "zig-git log: cannot read a blob", .{}), else => |e| return e, }; defer gpa.free(shown); for (shown) |p| if (p.moved() and std.mem.eql(u8, p.path, f.current.items)) { // Go on with a pathspec naming where it came from. const literal = try std.fmt.allocPrint(gpa, ":(top,literal){s}", .{p.oldPath()}); defer gpa.free(literal); const next = try gpa.create(git.Pathspec); errdefer gpa.destroy(next); next.* = try pathspecs.parse(f.ctx, f.repo, &.{literal}, "log", f.stderr); errdefer next.deinit(gpa); try f.specs.append(gpa, next); f.current.clearRetainingCapacity(); try f.current.appendSlice(gpa, p.oldPath()); break; }; return true; }};
/// A path in the raw format or a list of names: quoted, or under -z as/// it is.fn rawPath(out: *Io.Writer, path: []const u8, s: *const repo_diff.Settings) !void { if (s.out.nul) return out.writeAll(path); return quotePath(out, path, s);}
fn quotePath(out: *Io.Writer, path: []const u8, s: *const repo_diff.Settings) !void { try git.quote.writePathWith(out, path, .{ .quote_path = s.out.quote_path });}
fn prefixedPath(out: *Io.Writer, prefix: []const u8, path: []const u8, s: *const repo_diff.Settings) !void { const q: git.quote.Options = .{ .quote_path = s.out.quote_path }; if (!git.quote.needsQuotingWith(prefix, q) and !git.quote.needsQuotingWith(path, q)) { try out.writeAll(prefix); try out.writeAll(path); } else { try out.writeByte('"'); try git.quote.writeQuotedBody(out, prefix, q); try git.quote.writeQuotedBody(out, path, q); try out.writeByte('"'); } // Unlike a two-way patch's, no tab after a name with a space. try out.writeByte('\n');}
fn isGitlink(p: output.Pair) bool { return p.new.mode & 0o170000 == 0o160000 or p.old.mode & 0o170000 == 0o160000;}
/// Leaves `path` the only addition: following a file, git looks for where/// that one path came from, every other addition left out of the search.fn dropOtherAdditions(pairs: *diff_tree.Pairs, path: []const u8) void { var k: usize = 0; for (pairs.list.items) |p| { if (p.status == .added and !std.mem.eql(u8, p.path, path)) continue; pairs.list.items[k] = p; k += 1; } pairs.list.shrinkRetainingCapacity(k);}
fn treeOfIn(ctx: Context, repo: *git.Repository, commit: ObjectId, stderr: *Io.Writer) !ObjectId { var o = repo.objects().read(ctx.io, ctx.gpa, commit, null) catch return tool.fatalMessage(stderr, "zig-git log: cannot read commit {f}", .{commit}); defer o.deinit(ctx.gpa); return git.Commit.treeOf(repo.objectFormat(), o.data) orelse return tool.fatalMessage(stderr, "zig-git log: cannot read commit {f}", .{commit});}