Something went wrong. Try again.
Reading and writing git repositories in Zig, without running git.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A patch for one file, written as `git diff` writes it: the `diff --git`//! header, mode and `index` lines, `---` and `+++`, and the hunks, or//! git's line for binary files. The contents are bytes the caller already//! has; the only output is the `Io.Writer` given.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const object = @import("../object.zig");const quote = @import("../quote.zig");const LineDiff = @import("LineDiff.zig");const hunks = @import("hunks.zig");
/// One side of the comparison.pub const Side = struct { /// The path shown, without its prefix. path: []const u8, /// The contents, or null for a side that does not exist /// (`/dev/null`). contents: ?[]const u8, /// `0o100644`, `0o100755`, `0o120000` or `0o160000`. mode: u32 = 0o100644, /// The object ID, when the caller knows it; otherwise the contents /// are hashed as a blob. id: ?object.Id = null,};
/// What a patch shows, and how.pub const Options = struct { /// `--src-prefix`; `a/` by default, empty for `--no-prefix`. src_prefix: []const u8 = "a/", /// `--dst-prefix`; `b/` by default. dst_prefix: []const u8 = "b/", /// The object format the `index` line's object IDs are computed in. object_format: object.Format = .sha1, /// Digits of each object ID on the `index` line; null for all of /// them (`--full-index`). At least 4. abbrev: ?usize = 7, /// A seam for shortening object IDs as a repository would, longer /// where another object shares the prefix. Given the ID and the /// minimum length, returns the length to show. shorten: ?Shortener = null, /// `-a`, `--text`: never binary. text: bool = false, /// `core.quotePath`. quote_path: bool = true, /// `diff.suppressBlankEmpty`: an empty context line is written /// without its leading space. suppress_blank_empty: bool = false, /// How lines are compared. lines: LineDiff.Options = .{}, /// Context and the changes that may be left out. hunks: hunks.Options = .{}, /// The rule that finds the text after `@@`: git's default, or a seam /// for attributes' `xfuncname`. function_name: hunks.FunctionFinder = .default, /// What the `diff` attribute or `diff.<driver>.binary` says: true for /// binary, false for text, null to look at the contents. binary: ?bool = null, /// For a rename or copy, the lines saying so. similarity: ?Similarity = null, /// For a rewrite (`-B`), its dissimilarity in percent: the header /// says so, and the hunk shows every old line and then every new one. dissimilarity: ?u32 = null, /// Whether `-R` swapped the prefixes: a rewrite's `---` and `+++` /// lines swap them back, as git was seen to write them. reversed: bool = false, /// `-D`, `--irreversible-delete`: a deletion is written as its header /// and `index` line alone, and a rewrite without its old lines, its /// hunk header counting them as `-?,?`. irreversible_delete: bool = false,};
/// The header lines of a rename or copy.pub const Similarity = struct { /// A copy rather than a rename. copy: bool, /// The similarity, in percent. percent: u32,};
/// Says how many digits of an object ID to show.pub const Shortener = struct { context: *anyopaque, length: *const fn (context: *anyopaque, id: object.Id, min: usize) usize,};
/// What writing the patch found.pub const Outcome = struct { /// Whether anything was written. written: bool, /// Whether the two sides differ as `--exit-code` counts it. differs: bool,};
/// Errors from writing a patch.pub const Error = LineDiff.Error || Io.Writer.Error || hunks.FunctionFinder.PrepareError;
/// Whether `contents` is binary as git decides it: a NUL in its first/// 8000 bytes.pub fn isBinary(contents: []const u8) bool { return std.mem.findScalar(u8, contents[0..@min(contents.len, 8000)], 0) != null;}
fn writePrefixed(w: *Io.Writer, prefix: []const u8, path: []const u8, options: Options) Io.Writer.Error!void { const q: quote.Options = .{ .quote_path = options.quote_path }; if (!quote.needsQuotingWith(prefix, q) and !quote.needsQuotingWith(path, q)) { try w.writeAll(prefix); return w.writeAll(path); } // Quoted as one string, prefix and path together. try w.writeByte('"'); try quote.writeQuotedBody(w, prefix, q); try quote.writeQuotedBody(w, path, q); try w.writeByte('"');}
fn writeId(w: *Io.Writer, id: object.Id, options: Options) Io.Writer.Error!void { var hex_buf: [object.max_hex_length]u8 = undefined; const hex = id.toHex(&hex_buf); const n = if (options.abbrev) |a| blk: { const min = @min(@max(a, 4), hex.len); break :blk if (options.shorten) |s| @min(s.length(s.context, id, min), hex.len) else min; } else hex.len; try w.writeAll(hex[0..n]);}
/// Writes the patch from `old` to `new`.pub fn write(gpa: Allocator, w: *Io.Writer, old: Side, new: Side, options: Options) Error!Outcome { const f = options.object_format; const old_bytes = old.contents orelse ""; const new_bytes = new.contents orelse ""; const old_id = if (old.contents) |c| old.id orelse object.hash(f, .blob, c) else object.Id.zero(f); const new_id = if (new.contents) |c| new.id orelse object.hash(f, .blob, c) else object.Id.zero(f); const same_bytes = old.contents != null and new.contents != null and std.mem.eql(u8, old_bytes, new_bytes); const mode_change = old.contents != null and new.contents != null and old.mode != new.mode; if (same_bytes and !mode_change and options.similarity == null) return .{ .written = false, .differs = false };
const binary = !options.text and (options.binary orelse (isBinary(old_bytes) or isBinary(new_bytes))); // Whether the lines are looked at, and so the function rule readied: // not for a binary file or a rewrite, as git was seen to. const text = !binary and options.dissimilarity == null; // Whether the header is written whatever the lines turn out to be. // git writes such a header before it readies the function rule, and // a plain change's header only once it has something to show, so a // driver's pattern that does not compile stops it after the one and // before the other. const eager = old.contents == null or new.contents == null or mode_change or options.similarity != null;
var d: ?LineDiff = null; defer if (d) |*x| x.deinit(gpa); var cs: []hunks.Change = &.{}; defer gpa.free(cs); var hs: []hunks.Hunk = &.{}; defer gpa.free(hs); // Text compared before the header, so that a change only whitespace // options hide shows nothing at all. if (text and !eager) { try prepare(options.function_name); if (!same_bytes) try compare(gpa, old_bytes, new_bytes, options, &d, &cs, &hs); } const old_path = if (old.contents != null) old.path else new.path; const new_path = if (new.contents != null) new.path else old.path; if (!same_bytes and !binary and hs.len == 0 and !mode_change and options.similarity == null and options.dissimilarity == null and old.contents != null and new.contents != null) { // Nothing to show: whitespace options hid every change, or every // change may be left out. git looks at what it would show to // decide whether the files differ only for the whitespace options // and `-I`; with `--ignore-blank-lines` alone, different bytes // differ. const looks = options.lines.whitespace.any() or options.hunks.ignore_line != null; return .{ .written = false, .differs = !looks }; }
try w.writeAll("diff --git "); try writePrefixed(w, options.src_prefix, old_path, options); try w.writeByte(' '); try writePrefixed(w, options.dst_prefix, new_path, options); try w.writeByte('\n'); if (old.contents == null) { try w.print("new file mode {o:0>6}\n", .{new.mode}); } else if (new.contents == null) { try w.print("deleted file mode {o:0>6}\n", .{old.mode}); } else if (mode_change) { try w.print("old mode {o:0>6}\nnew mode {o:0>6}\n", .{ old.mode, new.mode }); } if (options.dissimilarity) |n| try w.print("dissimilarity index {d}%\n", .{n}); if (options.similarity) |sim| { const q: quote.Options = .{ .quote_path = options.quote_path }; const word = if (sim.copy) "copy" else "rename"; try w.print("similarity index {d}%\n{s} from ", .{ sim.percent, word }); try quote.writePathWith(w, old.path, q); try w.print("\n{s} to ", .{word}); try quote.writePathWith(w, new.path, q); try w.writeByte('\n'); } if (same_bytes) { if (text and eager) try prepare(options.function_name); return .{ .written = true, .differs = true }; } try w.writeAll("index "); try writeId(w, old_id, options); try w.writeAll(".."); try writeId(w, new_id, options); if (old.contents != null and new.contents != null and !mode_change) try w.print(" {o:0>6}", .{old.mode}); try w.writeByte('\n'); // Under -D a deletion stops after its `index` line, binary or not. if (options.irreversible_delete and new.contents == null) return .{ .written = true, .differs = true }; if (binary) { try w.writeAll("Binary files "); if (old.contents == null) try w.writeAll("/dev/null") else try writePrefixed(w, options.src_prefix, old_path, options); try w.writeAll(" and "); if (new.contents == null) try w.writeAll("/dev/null") else try writePrefixed(w, options.dst_prefix, new_path, options); try w.writeAll(" differ\n"); return .{ .written = true, .differs = true }; } if (options.dissimilarity != null) { const src = if (options.reversed) options.dst_prefix else options.src_prefix; const dst = if (options.reversed) options.src_prefix else options.dst_prefix; try writeFileLine(w, "--- ", old.contents == null, src, old_path, options); try writeFileLine(w, "+++ ", new.contents == null, dst, new_path, options); try writeRewrite(w, old_bytes, new_bytes, options.irreversible_delete); return .{ .written = true, .differs = true }; } if (eager) { try prepare(options.function_name); try compare(gpa, old_bytes, new_bytes, options, &d, &cs, &hs); } if (hs.len == 0) return .{ .written = true, .differs = true }; try writeFileLine(w, "--- ", old.contents == null, options.src_prefix, old_path, options); try writeFileLine(w, "+++ ", new.contents == null, options.dst_prefix, new_path, options); for (hs) |h| try writeHunk(w, &d.?, h, options); return .{ .written = true, .differs = true };}
fn prepare(finder: hunks.FunctionFinder) hunks.FunctionFinder.PrepareError!void { if (finder.prepare) |f| try f(finder.context);}
/// Compares the lines of `old_bytes` and `new_bytes` and finds the hunks.fn compare(gpa: Allocator, old_bytes: []const u8, new_bytes: []const u8, options: Options, d: *?LineDiff, cs: *[]hunks.Change, hs: *[]hunks.Hunk) Error!void { var lo = options.lines; lo.trim_tail = options.hunks.context == 0; d.* = try LineDiff.compute(gpa, old_bytes, new_bytes, lo); cs.* = try hunks.changes(gpa, &d.*.?); var ho = options.hunks; ho.whitespace = options.lines.whitespace.any(); ho.function_lines = options.function_name; hs.* = try hunks.build(gpa, &d.*.?, cs.*, ho);}
fn writeFileLine(w: *Io.Writer, lead: []const u8, absent: bool, prefix: []const u8, path: []const u8, options: Options) Io.Writer.Error!void { try w.writeAll(lead); if (absent) { try w.writeAll("/dev/null\n"); return; } try writePrefixed(w, prefix, path, options); // git ends the line with a tab when the name has a space in it, so // that patch(1) can tell where it ends. if (std.mem.findScalar(u8, path, ' ') != null or std.mem.findScalar(u8, prefix, ' ') != null) try w.writeByte('\t'); try w.writeByte('\n');}
/// The number of lines in `text`, a last line without a newline counted.pub fn lineCount(text: []const u8) usize { if (text.len == 0) return 0; var n = std.mem.countScalar(u8, text, '\n'); if (text[text.len - 1] != '\n') n += 1; return n;}
/// A rewrite's one hunk: every old line deleted, then every new line/// added.fn writeRewrite(w: *Io.Writer, old: []const u8, new: []const u8, irreversible: bool) Io.Writer.Error!void { const a = lineCount(old); const b = lineCount(new); try w.writeAll("@@ -"); if (irreversible) try w.writeAll("?,?") else try writeRange(w, 0, a); try w.writeAll(" +"); try writeRange(w, 0, b); try w.writeAll(" @@\n"); for ([_][]const u8{ if (irreversible) "" else old, new }, [_]u8{ '-', '+' }) |text, mark| { var it = std.mem.splitScalar(u8, text, '\n'); var pos: usize = 0; while (pos < text.len) { const line = it.next().?; pos += line.len + 1; try w.writeByte(mark); try w.writeAll(line); try w.writeByte('\n'); if (pos > text.len) try w.writeAll("\\ No newline at end of file\n"); } }}
fn writeRange(w: *Io.Writer, start: usize, len: usize) Io.Writer.Error!void { if (len == 1) return w.print("{d}", .{start + 1}); if (len == 0) return w.print("{d},0", .{start}); try w.print("{d},{d}", .{ start + 1, len });}
fn writeHunk(w: *Io.Writer, d: *const LineDiff, h: hunks.Hunk, options: Options) Error!void { try w.writeAll("@@ -"); try writeRange(w, h.a_start, h.a_len); try w.writeAll(" +"); try writeRange(w, h.b_start, h.b_len); try w.writeAll(" @@"); var name_buffer: [hunks.function_name_max]u8 = undefined; if (try hunks.functionName(d.a, h.a_start, options.function_name, &name_buffer)) |name| { // A function line whose name is empty shows nothing, not even // the space; one whose name stops at a byte that is not UTF-8 // keeps the space. if (name.len != 0) { try w.writeByte(' '); try w.writeAll(hunks.shownFunctionName(name)); } } try w.writeByte('\n'); var i = h.a_start; var j = h.b_start; const a_end = h.a_start + h.a_len; const b_end = h.b_start + h.b_len; while (i < a_end or j < b_end) { if ((i < a_end and d.a_changed[i]) or (j < b_end and d.b_changed[j])) { while (i < a_end and d.a_changed[i]) : (i += 1) try writeLine(w, '-', d.a, i); while (j < b_end and d.b_changed[j]) : (j += 1) try writeLine(w, '+', d.b, j); } else { if (options.suppress_blank_empty and std.mem.eql(u8, d.b.line(j), "\n")) try w.writeByte('\n') else try writeLine(w, ' ', d.b, j); i += 1; j += 1; } }}
fn writeLine(w: *Io.Writer, mark: u8, lines: LineDiff.Lines, i: usize) Io.Writer.Error!void { const line = lines.line(i); try w.writeByte(mark); try w.writeAll(line); if (line.len == 0 or line[line.len - 1] != '\n') try w.writeAll("\n\\ No newline at end of file\n");}