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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! What `git diff`, `diff-tree`, `diff-index` and `diff-files` write for a//! list of changed paths: the raw format, `--name-only`, `--name-status`,//! `--numstat`, `--stat`, `--shortstat`, `--summary` and the patch, in the//! order git writes them, with the separator git puts before the patch.//!//! The paths come in as `Pair`s, already compared and in git's order; the//! contents of each side are asked of a `Source`, and how each path is to//! be compared (binary or text, its function lines) of `Drivers`, both//! supplied by the caller, so that the same writing serves trees, the//! index and the working tree.//!//! What the documentation leaves out was found by asking git://!//! - `--name-only` and `--name-status` replace every other format; the//! others are written raw, numstat, stat, shortstat, summary, and then//! the patch, after an empty line (a NUL under `-z`) if anything was//! written before it.//! - A type change (a file becoming a symbolic link, say) is one entry,//! `T`, everywhere but the patch, which shows a deletion and then an//! addition; `--summary` calls it a mode change.//! - A gitlink's contents, for the patch and the counts, are the line//! `Subproject commit <object ID>`.//! - A path whose contents compare equal is left out of the patch and the//! counts, though the raw format and the name formats list it: that is//! how `diff-files` shows a file whose stat data alone changed.//! - The stat layout is described at `writeStat`.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const object = @import("../object.zig");const quote = @import("../quote.zig");const width = @import("../width.zig");const LineDiff = @import("LineDiff.zig");const hunks = @import("hunks.zig");const patch = @import("patch.zig");const similarity = @import("similarity.zig");const check = @import("check.zig");const dirstat = @import("dirstat.zig");
/// How a path differs, with the letter git gives it.pub const Status = enum(u8) { added = 'A', deleted = 'D', modified = 'M', type_changed = 'T', unmerged = 'U', /// Renamed: `old_path` is where it came from. renamed = 'R', /// Copied from `old_path`. copied = 'C', /// Unchanged: given to rename detection as a source for /// `--find-copies-harder`, and never written. unmodified = '=',
/// The letter. pub fn letter(s: Status) u8 { return @backingInt(s); }};
/// One side of a changed path. An absent side has mode 0 and the zero/// object ID.pub const Side = struct { mode: u32 = 0, id: object.Id, /// False for a working tree file whose object ID has not been /// computed: the raw format shows the zero ID, and the patch hashes /// the contents. id_known: bool = true, /// Whether the contents are a working tree file's, to be read from /// there rather than the object database (which may not hold them, /// even once `id` is known). worktree: bool = false,
/// Whether the side exists. pub fn present(s: Side) bool { return s.mode != 0; }};
/// One changed path.pub const Pair = struct { status: Status, /// The path, or for a rename or copy the new path. path: []const u8, old: Side, new: Side, /// For a rename or copy, the path it came from. old_path: ?[]const u8 = null, /// For a rename or copy, the similarity; for a rewrite (`-B`), the /// dissimilarity; out of `similarity.max_score`. score: u32 = 0, /// A rewrite: a pair `-B` broke and nothing took, shown whole, old /// lines and then new. rewrite: bool = false,
/// The path of the old side. pub fn oldPath(p: *const Pair) []const u8 { return p.old_path orelse p.path; }
/// Whether it is a rename or copy. pub fn moved(p: *const Pair) bool { return p.status == .renamed or p.status == .copied; }};
/// The status a pair of sides gets: added, deleted, a type change when/// the kinds differ (file, symbolic link, gitlink), and modified/// otherwise.pub fn statusOf(old: Side, new: Side) Status { if (!old.present()) return .added; if (!new.present()) return .deleted; if (kindOf(old.mode) != kindOf(new.mode)) return .type_changed; return .modified;}
fn kindOf(mode: u32) u32 { return mode & 0o170000;}
/// Which formats to write.pub const Format = struct { raw: bool = false, name_only: bool = false, name_status: bool = false, numstat: bool = false, stat: bool = false, shortstat: bool = false, summary: bool = false, patch: bool = false, /// `--check`, which replaces every other format. check: bool = false, /// `--dirstat`. dirstat: bool = false,
/// Whether any format is chosen. pub fn any(f: Format) bool { return f.raw or f.name_only or f.name_status or f.numstat or f.stat or f.shortstat or f.summary or f.patch or f.check or f.dirstat; }
/// Whether writing needs the contents compared. pub fn needsContents(f: Format) bool { if (f.name_only or f.name_status) return false; return f.numstat or f.stat or f.shortstat or f.patch; }};
/// `--stat`'s widths.pub const StatOptions = struct { /// The whole line's width: `--stat=<width>`, `--stat-width`, /// `COLUMNS`, or 80. width: usize = 80, /// `--stat-name-width`, `diff.statNameWidth`. name_width: ?usize = null, /// `--stat-graph-width`, `diff.statGraphWidth`. graph_width: ?usize = null, /// `--stat-count`: lines shown before `...`. count: ?usize = null, /// `--compact-summary`: after each name, what `--summary` would say /// of it, in brief. compact_summary: bool = false,};
/// A side's contents.pub const Contents = struct { bytes: []const u8, /// Freed with the allocator `Source.read` was given, if not null. allocation: ?[]u8 = null,};
/// Where the contents come from.pub const Source = struct { context: *anyopaque, /// The contents of `side` of `pair`, a file or symbolic link's (a /// link's contents are its target). read: *const fn (context: *anyopaque, gpa: Allocator, pair: *const Pair, side: Side) Error!Contents,};
/// How one path is compared.pub const Driver = struct { /// What the `diff` attribute and `diff.<driver>.binary` say: true for /// binary, false for text, null to look at the contents. binary: ?bool = null, function_name: hunks.FunctionFinder = .default, /// For `--check`: the `whitespace` attribute's rules, null for /// `Options.whitespace`. whitespace: ?check.Rules = null, /// For `--check`: `conflict-marker-size`. marker_size: usize = 7,};
/// Each path's driver.pub const Drivers = struct { context: *anyopaque, get: *const fn (context: *anyopaque, path: []const u8) Error!Driver, /// Called before a path's patch is worked out, for whatever the /// caller reads then (git reads the `whitespace` attribute). before_patch: ?*const fn (context: *anyopaque, path: []const u8) Error!void = null,};
/// The driver for every path: git's defaults.pub const default_drivers: Drivers = .{ .context = undefined, .get = defaultDriver };
fn defaultDriver(_: *anyopaque, _: []const u8) Error!Driver { return .{};}
/// What writing can fail with: the caller's reading, comparing, writing.pub const Error = LineDiff.Error || Io.Writer.Error || error{ /// The source could not read a side; it says why. ContentsUnreadable, /// The driver for a path is one zig-git does not support; the /// drivers say which. DriverUnsupported, /// `--check` met what `check.Error.BlankFileUnsupported` says. BlankFileUnsupported,};
/// How to write.pub const Options = struct { format: Format = .{}, /// `-z`. nul: bool = false, /// Digits of object IDs in the raw format; null for all of them. raw_abbrev: ?usize = null, /// `core.quotePath`. quote_path: bool = true, stat: StatOptions = .{}, /// The patch's options; its object format is the repository's. patch: patch.Options = .{}, /// `core.bigFileThreshold`: a file larger is binary, unless its /// attributes say otherwise. big_file_threshold: u64 = 512 * 1024 * 1024, /// `core.whitespace`, for `--check`. whitespace: check.Rules = .defaults, /// `--dirstat`'s parameters, `diff.dirstat` and the command line's. dirstat: dirstat.Options = .{},};
/// What writing found.pub const Outcome = struct { /// Whether any path differs, as `--exit-code` counts it. differs: bool, /// Whether anything was written. written: bool, /// Whether `--check` found a problem. problems: bool = false,};
const Counts = struct { added: usize = 0, deleted: usize = 0, binary: bool = false, old_size: usize = 0, new_size: usize = 0, /// Left out: the contents compare equal. same: bool = false, /// Binary, its contents the same: only its mode changed, and `--stat` /// writes `Bin` without sizes. same_bytes: bool = false, /// Unmerged: `--stat` says so, and the totals leave it out. unmerged: bool = false,};
/// Writes `pairs` as `options` say.pub fn write(gpa: Allocator, w: *Io.Writer, all_pairs: []const Pair, source: Source, drivers: Drivers, options: Options) Error!Outcome { const f = options.format; // With a whitespace option or -I, the raw, name, summary and patch // formats show only the paths whose patch shows something, trees // dropped: git compares the contents first. The counts compare the // contents themselves, over every path, and keep binary files they // cannot see into, as git was seen to. const ignoring = options.patch.lines.whitespace.any() or options.patch.hunks.ignore_line != null; const counts_only = !f.raw and !f.name_only and !f.name_status and !f.patch and !f.summary; var pairs = all_pairs; var filtered: []Pair = &.{}; defer gpa.free(filtered); // With no format at all (`-s`), what differs is decided the same way, // for `--exit-code`. if (ignoring and (!counts_only or !f.any())) { filtered = try filter(gpa, all_pairs, source, drivers, options); pairs = filtered; } var out: Outcome = .{ .differs = pairs.len != 0, .written = false }; if (f.check) { for (pairs) |*p| if (try writeCheck(gpa, w, p, source, drivers, options)) { out.problems = true; out.written = true; }; return out; } if (f.name_only or f.name_status) { for (pairs) |*p| { if (f.name_status) { try writeStatus(w, p); try w.writeByte(if (options.nul) 0 else '\t'); if (p.moved()) try writeNameThen(w, p.oldPath(), options, if (options.nul) 0 else '\t'); } try writeName(w, p.path, options); } out.written = pairs.len != 0; return out; } if (f.raw) { for (pairs) |*p| try writeRaw(w, p, options); if (pairs.len != 0) out.written = true; } if (f.numstat or f.stat or f.shortstat) { const counts = try gpa.alloc(Counts, all_pairs.len); defer gpa.free(counts); for (all_pairs, counts) |*p, *c| c.* = try count(gpa, p, source, drivers, options); if (counts_only and ignoring) { out.differs = false; for (counts) |c| out.differs = out.differs or !c.same; } if (f.numstat) { for (all_pairs, counts) |*p, c| { if (c.same) continue; if (c.binary) try w.writeAll("-\t-\t") else try w.print("{d}\t{d}\t", .{ c.added, c.deleted }); if (p.moved()) { if (options.nul) { try w.writeByte(0); try writeName(w, p.oldPath(), options); try writeName(w, p.path, options); } else { try writeMovedName(w, p, options); try w.writeByte('\n'); } } else try writeName(w, p.path, options); out.written = true; } } if (f.stat) { if (try writeStat(gpa, w, all_pairs, counts, options)) out.written = true; } if (f.shortstat) { if (try writeTotals(w, counts)) out.written = true; } } // The directories' shares come after the counts. Measured in lines // they call for an empty line before the patch, as the counts do, // even when no directory is shown; otherwise not, as git was seen to // write them. if (f.dirstat) { // Measured in lines, the shares count as the counts do: under a // whitespace option a path whose lines all compare equal does not // differ. if (counts_only and ignoring and options.dirstat.mode == .lines and !(f.numstat or f.stat or f.shortstat)) { out.differs = false; for (all_pairs) |*p| { if ((try count(gpa, p, source, drivers, options)).same) continue; out.differs = true; break; } } try writeDirstat(gpa, w, all_pairs, source, drivers, options); if (options.dirstat.mode == .lines and all_pairs.len != 0) out.written = true; } if (f.summary) { for (pairs) |*p| { if (try writeSummary(w, p, options)) out.written = true; } } if (f.patch) { if (out.written and pairs.len != 0) try w.writeByte(if (options.nul) 0 else '\n'); for (pairs) |*p| { if (try writePatch(gpa, w, p, source, drivers, options)) out.written = true; } } return out;}
/// Writes what `--check` finds in the lines `p` adds: all of a new file's,/// a type change's or a rewrite's, as their patches add them. Files/// binary on the new side, trees and unmerged paths are passed over.fn writeCheck(gpa: Allocator, w: *Io.Writer, p: *const Pair, source: Source, drivers: Drivers, options: Options) Error!bool { if (isTree(p) or p.status == .unmerged or !p.new.present()) return false; var ob: [64 + object.max_hex_length]u8 = undefined; var nb: [64 + object.max_hex_length]u8 = undefined; const whole = !p.old.present() or p.status == .type_changed or p.rewrite; const old: Contents = if (!whole) try load(gpa, p, p.old, true, source, &ob) else .{ .bytes = "" }; defer free(gpa, old); const new = try load(gpa, p, p.new, false, source, &nb); defer free(gpa, new); const driver = try drivers.get(drivers.context, p.path); // Only the new side's being binary passes a path over, as git was // seen to check a text file that had been binary. var view = p.*; view.old = .{ .id = p.old.id }; if (binaryPair(&view, "", new.bytes, driver, options)) return false; return check.write(gpa, w, p.path, old.bytes, new.bytes, .{ .rules = driver.whitespace orelse options.whitespace, .marker_size = driver.marker_size, .quote_path = options.quote_path, .lines = options.patch.lines, });}
fn writeDirstat(gpa: Allocator, w: *Io.Writer, pairs: []const Pair, source: Source, drivers: Drivers, options: Options) Error!void { const entries = try gpa.alloc(dirstat.Entry, pairs.len); defer gpa.free(entries); var n: usize = 0; for (pairs) |*p| { if (isTree(p) or p.status == .unmerged) continue; entries[n] = .{ .path = p.path, .damage = try damage(gpa, p, source, drivers, options) }; n += 1; } try dirstat.write(w, entries[0..n], options.dirstat);}
/// How much of a path changed, as `--dirstat`'s mode measures it.fn damage(gpa: Allocator, p: *const Pair, source: Source, drivers: Drivers, options: Options) Error!u64 { // A path whose contents are the same counts for nothing; under // `changes` one whose contents differ counts for at least one byte. if (p.old.present() and p.new.present() and p.old.id_known and p.new.id_known and p.old.id.eql(p.new.id)) return 0; const n = try measure(gpa, p, source, drivers, options); return if (options.dirstat.mode == .changes) @max(1, n) else n;}
fn measure(gpa: Allocator, p: *const Pair, source: Source, drivers: Drivers, options: Options) Error!u64 { switch (options.dirstat.mode) { .files => return 1, .lines => { const c = try count(gpa, p, source, drivers, options); if (c.binary) return (c.old_size + c.new_size + 63) / 64; if (c.same) return 0; return c.added + c.deleted; }, .changes => { var ob: [64 + object.max_hex_length]u8 = undefined; var nb: [64 + object.max_hex_length]u8 = undefined; const old: Contents = if (p.old.present()) try load(gpa, p, p.old, true, source, &ob) else .{ .bytes = "" }; defer free(gpa, old); const new: Contents = if (p.new.present()) try load(gpa, p, p.new, false, source, &nb) else .{ .bytes = "" }; defer free(gpa, new); // A side alone counts its whole size. if (!p.old.present()) return new.bytes.len; if (!p.new.present()) return old.bytes.len; var a = try similarity.signature(gpa, old.bytes); defer a.deinit(gpa); var b = try similarity.signature(gpa, new.bytes); defer b.deinit(gpa); const kept = similarity.copied(a, b); var new_total: u64 = 0; for (b.entries) |e| new_total += e.bytes; return (old.bytes.len - kept) + (new_total - kept); }, }}
fn writeName(w: *Io.Writer, path: []const u8, options: Options) Io.Writer.Error!void { return writeNameThen(w, path, options, if (options.nul) 0 else '\n');}
fn writeNameThen(w: *Io.Writer, path: []const u8, options: Options, end: u8) Io.Writer.Error!void { if (options.nul) { try w.writeAll(path); } else { try quote.writePathWith(w, path, .{ .quote_path = options.quote_path }); } try w.writeByte(end);}
/// The status letter, and a rename's or copy's similarity in percent.fn writeStatus(w: *Io.Writer, p: *const Pair) Io.Writer.Error!void { try w.writeByte(p.status.letter()); if (p.moved() or p.rewrite) try w.print("{d:0>3}", .{similarity.percent(p.score)});}
/// What `--compact-summary` writes after a name, as git was seen to/// write it: `(new)` or `(gone)`, with ` +l` for a new symbolic link and/// ` +x` for a new executable; `(mode +l)` or `(mode -l)` when the path/// becomes or stops being a symbolic link; `(mode +x)` or `(mode -x)`/// when a file's executable bit changes; nothing for any other change,/// one to or from a submodule among them.fn writeCompactSummary(w: *Io.Writer, p: *const Pair) Io.Writer.Error!void { const link = 0o120000; const isLink = struct { fn f(mode: u32) bool { return mode & 0o170000 == link; } }.f; const isFile = struct { fn f(mode: u32) bool { return mode & 0o170000 == 0o100000; } }.f; if (!p.old.present()) { try w.writeAll(" (new"); if (isLink(p.new.mode)) try w.writeAll(" +l") else if (isFile(p.new.mode) and p.new.mode & 0o100 != 0) try w.writeAll(" +x"); return w.writeByte(')'); } if (!p.new.present()) return w.writeAll(" (gone)"); if (p.old.mode == p.new.mode) return; if (isLink(p.new.mode) and !isLink(p.old.mode)) return w.writeAll(" (mode +l)"); if (isLink(p.old.mode) and !isLink(p.new.mode)) return w.writeAll(" (mode -l)"); if (isFile(p.old.mode) and isFile(p.new.mode) and (p.old.mode & 0o100) != (p.new.mode & 0o100)) return w.writeAll(if (p.new.mode & 0o100 != 0) " (mode +x)" else " (mode -x)");}
/// A rename's or copy's two paths as `--stat`, `--numstat` and/// `--summary` write them: what the paths share at the front up to a `/`/// and at the back from a `/` written once, and the parts that differ in/// braces, `a/{b => c}/d`; without anything shared, `old => new`.pub fn writeMovedName(w: *Io.Writer, p: *const Pair, options: Options) Io.Writer.Error!void { const q: quote.Options = .{ .quote_path = options.quote_path }; const a = p.oldPath(); const b = p.path; if (quote.needsQuotingWith(a, q) or quote.needsQuotingWith(b, q)) { try quote.writePathWith(w, a, q); try w.writeAll(" => "); try quote.writePathWith(w, b, q); return; } const parts = movedParts(a, b); if (parts.prefix == 0 and parts.suffix == 0) { try w.writeAll(a); try w.writeAll(" => "); try w.writeAll(b); return; } try w.writeAll(a[0..parts.prefix]); try w.writeByte('{'); try w.writeAll(a[parts.prefix..@max(parts.prefix, a.len - parts.suffix)]); try w.writeAll(" => "); try w.writeAll(b[parts.prefix..@max(parts.prefix, b.len - parts.suffix)]); try w.writeByte('}'); try w.writeAll(a[a.len - parts.suffix ..]);}
const MovedParts = struct { prefix: usize, suffix: usize };
/// The lengths of what two paths share: at the front, through the last/// `/` in common; at the back, from a `/`, overlapping the front by at/// most that `/`.fn movedParts(a: []const u8, b: []const u8) MovedParts { var prefix: usize = 0; var i: usize = 0; while (i < a.len and i < b.len and a[i] == b[i]) : (i += 1) { if (a[i] == '/') prefix = i + 1; } var suffix: usize = 0; var k: usize = 0; while (k < a.len and k < b.len and a[a.len - 1 - k] == b[b.len - 1 - k]) : (k += 1) { const at_a = a.len - 1 - k; const at_b = b.len - 1 - k; // The shared back may reach back into the shared front by its // final '/' and no further. if (at_a + 1 < prefix or at_b + 1 < prefix) break; if (a[at_a] == '/') suffix = k + 1; } return .{ .prefix = prefix, .suffix = suffix };}
fn writeRawId(w: *Io.Writer, side: Side, options: Options) Io.Writer.Error!void { var buf: [object.max_hex_length]u8 = undefined; const id = if (side.id_known) side.id else object.Id.zero(side.id.object_format); const hex = id.toHex(&buf); var n = hex.len; if (options.raw_abbrev) |a| { n = @min(@max(a, 4), hex.len); if (options.patch.shorten) |s| { if (!id.eql(object.Id.zero(id.object_format))) n = @min(s.length(s.context, id, n), hex.len); } } try w.writeAll(hex[0..n]);}
fn writeRaw(w: *Io.Writer, p: *const Pair, options: Options) Io.Writer.Error!void { try w.print(":{o:0>6} {o:0>6} ", .{ p.old.mode, p.new.mode }); try writeRawId(w, p.old, options); try w.writeByte(' '); try writeRawId(w, p.new, options); try w.writeByte(' '); try writeStatus(w, p); try w.writeByte(if (options.nul) 0 else '\t'); if (p.moved()) try writeNameThen(w, p.oldPath(), options, if (options.nul) 0 else '\t'); try writeName(w, p.path, options);}
/// The contents of a side as the comparison sees them: a gitlink's line,/// or what the source reads; `buf` holds a gitlink's line. Freed with/// `free`.pub fn load(gpa: Allocator, p: *const Pair, side: Side, is_old: bool, source: Source, buf: *[64 + object.max_hex_length]u8) Error!Contents { if (kindOf(side.mode) == 0o160000) { var hex: [object.max_hex_length]u8 = undefined; const text = std.fmt.bufPrint(buf, "Subproject commit {s}\n", .{side.id.toHex(&hex)}) catch unreachable; return .{ .bytes = text }; } // A working tree side is read from its own path. var view = p.*; if (is_old) view.path = p.oldPath(); return source.read(source.context, gpa, &view, side);}
/// Frees what `load` read.pub fn free(gpa: Allocator, c: Contents) void { if (c.allocation) |a| gpa.free(a);}
/// Whether a pair is compared as binary. Only a regular file's side can/// make it so — a symbolic link or a gitlink is always text, whatever the/// attributes say, as git was seen to treat them — by the `diff`/// attribute, a NUL in its first 8000 bytes, or its size past/// `core.bigFileThreshold`.pub fn binaryPair(p: *const Pair, old: []const u8, new: []const u8, driver: Driver, options: Options) bool { if (options.patch.text) return false; for ([_]Side{ p.old, p.new }, [_][]const u8{ old, new }) |side, bytes| { if (kindOf(side.mode) != 0o100000) continue; if (driver.binary) |b| { if (b) return true; continue; } if (patch.isBinary(bytes) or bytes.len > options.big_file_threshold) return true; } return false;}
/// Whether a pair is a tree's, which `diff-tree -t` lists: the raw and/// summary formats show it, and the patch and the counts pass over it.pub fn isTree(p: *const Pair) bool { return kindOf(p.old.mode) == 0o040000 or kindOf(p.new.mode) == 0o040000;}
fn count(gpa: Allocator, p: *const Pair, source: Source, drivers: Drivers, options: Options) Error!Counts { if (p.status == .unmerged) return .{ .unmerged = true }; if (isTree(p)) return .{ .same = true }; var c: Counts = .{}; var ob: [64 + object.max_hex_length]u8 = undefined; var nb: [64 + object.max_hex_length]u8 = undefined; const old: Contents = if (p.old.present()) try load(gpa, p, p.old, true, source, &ob) else .{ .bytes = "" }; defer free(gpa, old); const new: Contents = if (p.new.present()) try load(gpa, p, p.new, false, source, &nb) else .{ .bytes = "" }; defer free(gpa, new); const both = p.old.present() and p.new.present(); const bytes_equal = std.mem.eql(u8, old.bytes, new.bytes); const driver = try drivers.get(drivers.context, p.path); // The counts take no notice of --text, as git was seen to count. var co = options; co.patch.text = false; c.binary = binaryPair(p, old.bytes, new.bytes, driver, co); c.old_size = old.bytes.len; c.new_size = new.bytes.len; if (c.binary) { // A binary file is the same only by its object IDs: one whose // working tree side is not hashed is "Bin <old> -> <new> bytes" // even when nothing changed, as git was seen to count it. const ids_equal = p.old.id_known and p.new.id_known and p.old.id.eql(p.new.id); if (both and ids_equal and p.old.mode == p.new.mode and !p.moved()) c.same = true; c.same_bytes = ids_equal; return c; } if (both and p.old.mode == p.new.mode and bytes_equal and !p.moved()) { c.same = true; return c; } c.same_bytes = bytes_equal; if (c.same_bytes) return c; // A rewrite counts every line of both sides; a type change `-B` // broke is counted as any other. if (p.rewrite and p.status != .type_changed) { c.deleted = patch.lineCount(old.bytes); c.added = patch.lineCount(new.bytes); return c; } // The lines counted are those the patch would show: changes that // `--ignore-blank-lines` or `-I` leave out count only where a hunk // takes them along, as git was seen to count them. var lo = options.patch.lines; lo.trim_tail = options.patch.hunks.context == 0; var d = try LineDiff.compute(gpa, old.bytes, new.bytes, lo); defer d.deinit(gpa); const cs = try hunks.changes(gpa, &d); defer gpa.free(cs); var ho = options.patch.hunks; ho.whitespace = options.patch.lines.whitespace.any(); const hs = try hunks.build(gpa, &d, cs, ho); defer gpa.free(hs); for (hs) |h| for (cs[h.first..h.end]) |ch| { c.deleted += ch.a_len; c.added += ch.b_len; }; // What a whitespace option or -I hides whole is no change. if (hs.len == 0 and both and p.old.mode == p.new.mode and !p.moved()) c.same = true; return c;}
/// The pairs that would show anything under a whitespace option or/// `-I`, which git keeps alone in every format; `pairs` itself/// otherwise. The caller frees the list.pub fn filter(gpa: Allocator, pairs: []const Pair, source: Source, drivers: Drivers, options: Options) Error![]Pair { var kept: std.ArrayList(Pair) = .empty; errdefer kept.deinit(gpa); const filtering = options.patch.lines.whitespace.any() or options.patch.hunks.ignore_line != null; // git was seen to read what it reads before a patch here only when it // writes no counts, which compare the contents themselves. var quiet_drivers = drivers; const f = options.format; if (!f.patch and !f.raw and !f.name_only and !f.name_status and (f.numstat or f.stat or f.shortstat)) quiet_drivers.before_patch = null; for (pairs) |*p| { if (!filtering) { try kept.append(gpa, p.*); continue; } if (!try shows(gpa, p, source, quiet_drivers, options)) continue; // Having read the working tree's files to compare them, git // shows their object IDs. var q = p.*; for ([_]*Side{ &q.old, &q.new }, [_]bool{ true, false }) |side, is_old| { if (!side.present() or side.id_known or kindOf(side.mode) == 0o160000) continue; var view = p.*; if (is_old) view.path = p.oldPath(); const c = try source.read(source.context, gpa, &view, side.*); defer free(gpa, c); side.id = object.hash(side.id.object_format, .blob, c.bytes); side.id_known = true; } try kept.append(gpa, q); } return kept.toOwnedSlice(gpa);}
/// Whether the patch would show anything for `p`. Function lines do/// not change that, and git was seen not to compile a driver's pattern/// to find out, so the default rule stands in for the driver's.fn shows(gpa: Allocator, p: *const Pair, source: Source, drivers: Drivers, options: Options) Error!bool { if (isTree(p)) return false; if (p.status != .modified) return true; if (p.old.mode != p.new.mode) return true; var discarding: Io.Writer.Discarding = .init(&.{}); var plain: DefaultFunctionLines = .{ .inner = drivers }; return writePatch(gpa, &discarding.writer, p, source, plain.drivers(), options);}
/// Drivers as `inner` says, but with git's default rule for function/// lines.const DefaultFunctionLines = struct { inner: Drivers,
fn drivers(d: *DefaultFunctionLines) Drivers { return .{ .context = d, .get = get, .before_patch = if (d.inner.before_patch != null) beforePatch else null }; }
fn get(context: *anyopaque, path: []const u8) Error!Driver { const d: *DefaultFunctionLines = @ptrCast(@alignCast(context)); var driver = try d.inner.get(d.inner.context, path); driver.function_name = .default; return driver; }
fn beforePatch(context: *anyopaque, path: []const u8) Error!void { const d: *DefaultFunctionLines = @ptrCast(@alignCast(context)); return d.inner.before_patch.?(d.inner.context, path); }};
/// Writes the patch for one pair; a type change is a deletion and then an/// addition. Returns whether anything was written.fn writePatch(gpa: Allocator, w: *Io.Writer, p: *const Pair, source: Source, drivers: Drivers, options: Options) Error!bool { if (isTree(p)) return false; if (p.status == .unmerged) { try w.writeAll("* Unmerged path "); try quote.writePathWith(w, p.path, .{ .quote_path = options.quote_path }); try w.writeByte('\n'); return true; } if (p.status == .type_changed) { const del: Pair = .{ .status = .deleted, .path = p.path, .old = p.old, .new = .{ .id = object.Id.zero(p.old.id.object_format) } }; const add: Pair = .{ .status = .added, .path = p.path, .old = .{ .id = object.Id.zero(p.new.id.object_format) }, .new = p.new }; const a = try writePatch(gpa, w, &del, source, drivers, options); const b = try writePatch(gpa, w, &add, source, drivers, options); return a or b; } var ob: [64 + object.max_hex_length]u8 = undefined; var nb: [64 + object.max_hex_length]u8 = undefined; const old: ?Contents = if (p.old.present()) try load(gpa, p, p.old, true, source, &ob) else null; defer if (old) |c| free(gpa, c); const new: ?Contents = if (p.new.present()) try load(gpa, p, p.new, false, source, &nb) else null; defer if (new) |c| free(gpa, c); const driver = try drivers.get(drivers.context, p.path); if (drivers.before_patch) |hook| try hook(drivers.context, p.path); var po = options.patch; po.function_name = driver.function_name; if (p.moved()) po.similarity = .{ .copy = p.status == .copied, .percent = similarity.percent(p.score) }; if (p.rewrite) po.dissimilarity = similarity.percent(p.score); po.binary = binaryPair(p, if (old) |c| c.bytes else "", if (new) |c| c.bytes else "", driver, options); const outcome = patch.write(gpa, w, .{ .path = p.oldPath(), .contents = if (old) |c| c.bytes else null, .mode = p.old.mode, .id = if (p.old.id_known) p.old.id else null, }, .{ .path = p.path, .contents = if (new) |c| c.bytes else null, .mode = p.new.mode, .id = if (p.new.id_known) p.new.id else null, }, po) catch |err| switch (err) { // The driver has said what is wrong with its function lines. error.FunctionLinesUnusable => return error.DriverUnsupported, else => |e| return e, }; return outcome.written;}
fn writeSummary(w: *Io.Writer, p: *const Pair, options: Options) Io.Writer.Error!bool { if (p.moved()) { try w.writeAll(if (p.status == .copied) " copy " else " rename "); try writeMovedName(w, p, options); try w.print(" ({d}%)\n", .{similarity.percent(p.score)}); if (p.old.mode != p.new.mode) try w.print(" mode change {o:0>6} => {o:0>6}\n", .{ p.old.mode, p.new.mode }); return true; } switch (p.status) { .added => try w.print(" create mode {o:0>6} ", .{p.new.mode}), .deleted => try w.print(" delete mode {o:0>6} ", .{p.old.mode}), .modified, .type_changed => { if (p.rewrite) { try w.writeAll(" rewrite "); try quote.writePathWith(w, p.path, .{ .quote_path = options.quote_path }); try w.print(" ({d}%)\n", .{similarity.percent(p.score)}); if (p.old.mode != p.new.mode) try w.print(" mode change {o:0>6} => {o:0>6}\n", .{ p.old.mode, p.new.mode }); return true; } if (p.old.mode == p.new.mode) return false; try w.print(" mode change {o:0>6} => {o:0>6} ", .{ p.old.mode, p.new.mode }); }, .unmerged, .unmodified, .renamed, .copied => return false, } try quote.writePathWith(w, p.path, .{ .quote_path = options.quote_path }); try w.writeByte('\n'); return true;}
fn plural(n: usize, one: []const u8, many: []const u8) []const u8 { return if (n == 1) one else many;}
/// The last line of `--stat`, and all of `--shortstat`. Returns whether/// anything was written.fn writeTotals(w: *Io.Writer, counts: []const Counts) Io.Writer.Error!bool { var files: usize = 0; var lines: usize = 0; var added: usize = 0; var deleted: usize = 0; for (counts) |c| { if (c.same) continue; lines += 1; if (c.unmerged) continue; files += 1; added += c.added; deleted += c.deleted; } if (lines == 0) return false; if (files == 0) { // Only unmerged paths. try w.writeAll(" 0 files changed\n"); return true; } try w.print(" {d} {s} changed", .{ files, plural(files, "file", "files") }); if (added != 0 or deleted == 0) try w.print(", {d} {s}(+)", .{ added, plural(added, "insertion", "insertions") }); if (deleted != 0 or added == 0) try w.print(", {d} {s}(-)", .{ deleted, plural(deleted, "deletion", "deletions") }); try w.writeByte('\n'); return true;}
fn digits(n: usize) usize { var d: usize = 1; var x = n; while (x >= 10) : (x /= 10) d += 1; return d;}
/// One bar's length: nothing for nothing, and otherwise at least one/// column, the rest in proportion.fn scale(x: usize, graph: usize, max_change: usize) usize { if (x == 0) return 0; if (graph == 0) return 1; return 1 + x * (graph - 1) / max_change;}
/// Writes `--stat`, laid out as git was seen to lay it out:////// - The count's column is as wide as the largest change's digits, and/// at least three wide (`Bin`) when a binary file is listed. A binary/// file's `<old> -> <new> bytes` counts as a graph of its width./// - The width is `--stat=<width>`, `COLUMNS` or 80, and at least 22/// more than the count's column. Of it, the name and the graph share/// the width less six columns and the count's: each gets all it needs/// if both fit; otherwise the name is given up to five eighths of the/// width (rounded up), or less if the graph needs less, and the graph/// the rest, but never under six columns./// - A name too long for its column is cut to its last characters after/// `...`, from the first `/` in them if there is one./// - When the largest change does not fit in the graph, each count is/// scaled to one column plus its share of the rest, and if the two/// bars come to more than their total scaled, the bar of the larger/// count gives way, the insertions' when the counts are equal.fn writeStat(gpa: Allocator, w: *Io.Writer, pairs: []const Pair, counts: []const Counts, options: Options) Error!bool { var names: std.ArrayList([]u8) = .empty; defer { for (names.items) |n| gpa.free(n); names.deinit(gpa); } var max_len: usize = 0; var max_change: usize = 0; var any_binary = false; var bin_width: usize = 0; var shown: usize = 0; for (pairs, counts) |*p, c| { var nw: Io.Writer.Allocating = .init(gpa); errdefer nw.deinit(); if (p.moved()) try writeMovedName(&nw.writer, p, options) else try quote.writePathWith(&nw.writer, p.path, .{ .quote_path = options.quote_path }); // The note is part of the name, measured and cut with it. if (options.stat.compact_summary) try writeCompactSummary(&nw.writer, p); const name = try nw.toOwnedSlice(); try names.append(gpa, name); if (c.same) continue; shown += 1; // Only the lines --stat-count lets through are measured. if (options.stat.count) |limit| if (shown > limit) continue; max_len = @max(max_len, width.of(name)); if (c.binary) { any_binary = true; // "<old> -> <new> bytes" takes the graph's place, and its // width counts as the graph's. if (!c.same_bytes) bin_width = @max(bin_width, digits(c.old_size) + 4 + digits(c.new_size) + 6); } else max_change = @max(max_change, c.added + c.deleted); } if (shown == 0) return false; var number_width = digits(max_change); if (any_binary and number_width < 3) number_width = 3;
const so = options.stat; var total = so.width; if (total < 6 + number_width + 16) total = 6 + number_width + 16; const avail = total - 6 - number_width; var name_width = max_len; if (so.name_width) |n| name_width = @min(name_width, n); var graph_width = @max(max_change, bin_width); if (so.graph_width) |g| graph_width = @min(graph_width, g); if (name_width + graph_width > avail) { const cap = (5 * total + 7) / 8; if (graph_width < avail -| @min(name_width, cap)) { name_width = @min(name_width, avail - graph_width); } else { name_width = @min(name_width, cap); var g = avail -| name_width; if (g < 6) { g = 6; name_width = avail - 6; } graph_width = @min(graph_width, g); } }
var lines: usize = 0; for (pairs, counts, names.items) |*p, c, name| { _ = p; if (c.same) continue; if (so.count) |limit| if (lines == limit) { try w.writeAll(" ...\n"); break; }; lines += 1; try w.writeByte(' '); const cols = width.of(name); if (cols > name_width) { // The last characters that fit after "...". const keep = if (name_width > 3) name_width - 3 else 0; var start = name.len; var have: usize = 0; while (start > 0) { var s = start - 1; while (s > 0 and (name[s] & 0xc0) == 0x80) s -= 1; const cw = width.of(name[s..start]); if (have + cw > keep) break; have += cw; start = s; } var tail = name[start..]; if (std.mem.findScalar(u8, tail, '/')) |slash| tail = tail[slash..]; try w.writeAll("..."); try w.writeAll(tail); const used = 3 + width.of(tail); if (used < name_width) try w.splatByteAll(' ', name_width - used); } else { try w.writeAll(name); try w.splatByteAll(' ', name_width - cols); } try w.writeAll(" | "); if (c.unmerged) { try w.writeAll("Unmerged\n"); continue; } if (c.binary) { try w.splatByteAll(' ', number_width - 3); if (c.same_bytes) try w.writeAll("Bin\n") else try w.print("Bin {d} -> {d} bytes\n", .{ c.old_size, c.new_size }); continue; } const changes = c.added + c.deleted; const nd = digits(changes); try w.splatByteAll(' ', number_width - nd); try w.print("{d}", .{changes}); if (changes == 0) { try w.writeByte('\n'); continue; } var plus = c.added; var minus = c.deleted; if (max_change > graph_width) { plus = scale(c.added, graph_width, max_change); minus = scale(c.deleted, graph_width, max_change); const both = scale(changes, graph_width, max_change); while (plus + minus > both) { if (c.added >= c.deleted) { if (plus <= 1) break; plus -= 1; } else { if (minus <= 1) break; minus -= 1; } } } try w.writeByte(' '); try w.splatByteAll('+', plus); try w.splatByteAll('-', minus); try w.writeByte('\n'); } _ = try writeTotals(w, counts); return true;}
test "statuses" { const z = object.Id.zero(.sha1); try std.testing.expectEqual(Status.added, statusOf(.{ .id = z }, .{ .mode = 0o100644, .id = z })); try std.testing.expectEqual(Status.type_changed, statusOf(.{ .mode = 0o100644, .id = z }, .{ .mode = 0o120000, .id = z })); try std.testing.expectEqual(Status.modified, statusOf(.{ .mode = 0o100644, .id = z }, .{ .mode = 0o100755, .id = z }));}
const fuzz_input = @import("../testing/fuzz.zig");
/// Stat lines laid out from fuzzed input: a width, a name width and a/// graph width (each a byte, 0 for none), then entries of a name length,/// added and deleted counts (two bytes each, little-endian) and a flags/// byte (binary, unmerged).fn fuzzStat(_: void, smith: *std.testing.Smith) anyerror!void { const gpa = std.testing.allocator; var buffer: [512]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); if (input.len < 3) return; var options: Options = .{}; options.stat.width = input[0]; if (input[1] != 0) options.stat.name_width = input[1]; if (input[2] != 0) options.stat.graph_width = input[2]; var pairs: std.ArrayList(Pair) = .empty; defer pairs.deinit(gpa); var counts: std.ArrayList(Counts) = .empty; defer counts.deinit(gpa); var names: std.ArrayList([]u8) = .empty; defer { for (names.items) |n| gpa.free(n); names.deinit(gpa); } var rest = input[3..]; const z = object.Id.zero(.sha1); while (rest.len >= 6 and pairs.items.len < 32) : (rest = rest[6..]) { const name = try gpa.alloc(u8, @as(usize, rest[0]) % 120 + 1); @memset(name, 'n'); if (name.len > 3) name[name.len / 2] = '/'; try names.append(gpa, name); try pairs.append(gpa, .{ .status = .modified, .path = name, .old = .{ .mode = 0o100644, .id = z }, .new = .{ .mode = 0o100644, .id = z } }); try counts.append(gpa, .{ .added = std.mem.readInt(u16, rest[1..3], .little), .deleted = std.mem.readInt(u16, rest[3..5], .little), .binary = rest[5] & 1 != 0, .unmerged = rest[5] & 2 != 0, .old_size = rest[1], .new_size = rest[2], }); } var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); _ = try writeStat(gpa, &out.writer, pairs.items, counts.items, options); // Every line but the totals is a name, a bar and what follows it. var lines = std.mem.splitScalar(u8, out.written(), '\n'); while (lines.next()) |line| { if (line.len == 0) continue; try std.testing.expect(line[0] == ' '); }}
test "fuzz: stat layout" { try fuzz_input.fuzz({}, fuzzStat, .{ .corpus = &.{ @embedFile("../fuzz/stat-layout"), // A width bumped to its least, with a five-digit count: the name's // share of it came to more than there was. @embedFile("../fuzz/stat-layout-narrow"), }, });}