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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Rename and copy detection compared with git: `-M`, `-C`,//! `--find-copies-harder`, thresholds, the rename limit and its warnings,//! `diff.renames`, and every format, through `diff-tree`, `diff`,//! `diff-index`, `diff-files`, `log` and `show`.
const std = @import("std");const testing = std.testing;
const tool = @import("zig_git.zig");const Repo = tool.Repo;
fn same(repo: *Repo, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{});}
fn write(repo: *Repo, path: []const u8, data: []const u8) !void { if (std.fs.path.dirname(path)) |dir| try repo.tmp.dir.createDirPath(repo.io, dir); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = path, .data = data });}
/// Numbered lines, `first` to `first + count`, each starting with `tag`.fn lines(gpa: std.mem.Allocator, tag: []const u8, first: usize, count: usize) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); for (first..first + count) |i| try out.writer.print("{s} line {d}\n", .{ tag, i }); return out.toOwnedSlice();}
/// A history in which files are renamed, copied, edited and rewritten,/// across directories and onto names they share.fn makeHistory(repo: *Repo) !void { const gpa = repo.gpa; const a = try lines(gpa, "a", 0, 40); defer gpa.free(a); const b = try lines(gpa, "b", 0, 30); defer gpa.free(b); const c = try lines(gpa, "c", 0, 50); defer gpa.free(c); try write(repo, "a.txt", a); try write(repo, "dir/b.c", b); try write(repo, "dir/sub/c", c); try write(repo, "keep", c); try write(repo, "empty", ""); try write(repo, "small", "x\ny\n"); try repo.git(&.{ "add", "." }); try repo.commit("root");
// A rename with an edit, an exact rename into another directory, a // copy of an unchanged file, and an empty file renamed. try repo.git(&.{ "mv", "a.txt", "dir/a.txt" }); const a2 = try std.mem.replaceOwned(u8, gpa, a, "a line 7\n", "A LINE SEVEN\n"); defer gpa.free(a2); try write(repo, "dir/a.txt", a2); try repo.tmp.dir.createDirPath(repo.io, "other"); try repo.git(&.{ "mv", "dir/sub/c", "other/c" }); try write(repo, "copy-of-keep", c); try repo.git(&.{ "mv", "empty", "empty2" }); try repo.git(&.{ "add", "-A" }); try repo.commit("moves");
// A copy of a modified file, a rename with a mode change, and two // destinations for one deleted source. const b2 = try std.mem.concat(gpa, u8, &.{ b, "more\n" }); defer gpa.free(b2); try write(repo, "dir/b.c", b2); try write(repo, "dir/b-copy.c", b); try repo.git(&.{ "mv", "small", "small2" }); try repo.git(&.{ "update-index", "--chmod=+x", "small2" }); try repo.git(&.{ "rm", "-q", "dir/a.txt" }); try write(repo, "x/a.txt", a2); const a3 = try std.mem.concat(gpa, u8, &.{ a2, "tail\n" }); defer gpa.free(a3); try write(repo, "y/a.txt", a3); try repo.git(&.{ "add", "-A" }); try repo.commit("copies");
// A rewrite, and a deletion and an addition too different to pair. const r = try lines(gpa, "rewritten", 0, 50); defer gpa.free(r); try write(repo, "keep", r); try repo.git(&.{ "rm", "-q", "copy-of-keep" }); try write(repo, "unrelated", "nothing in common\n"); try repo.git(&.{ "add", "-A" }); try repo.commit("rewrite");}
test "renames: diff-tree with every detection option and format" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); const gpa = testing.allocator; const detections = [_][]const []const u8{ &.{"-M"}, &.{"-C"}, &.{ "-C", "-C" }, &.{"--find-copies-harder"}, &.{"-M30%"}, &.{"-M99"}, &.{"--find-renames=80%"}, &.{"-C2"}, &.{ "-M", "-l1" }, &.{ "-C", "-C", "-l2" }, &.{ "-M", "--no-rename-empty" }, &.{ "-C", "-M" }, &.{ "-M", "--no-renames" }, }; const formats = [_][]const []const u8{ &.{}, &.{"-p"}, &.{"--stat"}, &.{"--numstat"}, &.{"--summary"}, &.{"--name-status"}, &.{"--name-only"}, &.{ "--raw", "-z" }, &.{ "--numstat", "-z" }, &.{"-R"}, &.{"--diff-filter=R"}, &.{"--diff-filter=c"}, &.{ "-p", "--stat", "--summary" }, }; for (detections) |d| for (formats) |f| for ([_][]const u8{ "HEAD~2", "HEAD~1", "HEAD" }) |rev| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "diff-tree", "-r" }); try args.appendSlice(gpa, d); try args.appendSlice(gpa, f); try args.append(gpa, rev); try same(&repo, args.items); }; try same(&repo, &.{ "diff-tree", "-r", "-M", "HEAD~3", "HEAD" }); try same(&repo, &.{ "diff-tree", "-r", "-C", "-C", "HEAD~3", "HEAD", "--", "dir" }); try same(&repo, &.{ "-c", "diff.renameLimit=1", "diff-tree", "-r", "-M", "HEAD~3", "HEAD" }); // The value of each option, and git's words for the bad ones. for ([_][]const u8{ "-M5", "-M50%", "-M0.5", "-M.9", "-M100%", "-M0", "-Mx", "-M5x", "-Cx", "--find-copies=x", "-l", "-lx", "-l5k", "-l-1", "--find-copies-harder=1" }) |o| { try same(&repo, &.{ "diff-tree", "-r", o, "HEAD~1" }); }}
test "renames: diff, log and show find renames by default" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); for ([_][]const []const u8{ &.{ "diff", "HEAD~3", "HEAD" }, &.{ "diff", "--stat", "HEAD~2", "HEAD~1" }, &.{ "-c", "diff.renames=copies", "diff", "HEAD~2", "HEAD~1" }, &.{ "-c", "diff.renames=false", "diff", "--name-status", "HEAD~2", "HEAD~1" }, &.{ "log", "-p" }, &.{ "log", "--stat", "--summary" }, &.{ "log", "--name-status", "-C" }, &.{ "log", "--raw", "--find-copies-harder" }, &.{ "log", "--oneline", "--diff-filter=R" }, &.{ "log", "--oneline", "-1", "--diff-filter=C", "-C" }, &.{ "-c", "diff.renameLimit=1", "log", "--stat" }, &.{ "-c", "diff.renameLimit=1", "log", "--oneline", "--full-diff" }, &.{ "show", "HEAD~1" }, &.{ "show", "-s", "-l1", "HEAD~2" }, &.{ "show", "--diff-filter=R", "HEAD" }, }) |args| try same(&repo, args);}
test "renames: the index and the working tree" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); try repo.git(&.{ "mv", "dir/b.c", "moved.c" }); try repo.tmp.dir.rename("x/a.txt", repo.tmp.dir, "unstaged.txt", repo.io); try repo.git(&.{ "add", "-N", "unstaged.txt" }); for ([_][]const []const u8{ &.{"diff"}, &.{ "diff", "--cached" }, &.{ "diff", "HEAD" }, &.{ "diff", "--stat", "HEAD~2" }, &.{ "diff-index", "-M", "HEAD" }, &.{ "diff-index", "-M", "--cached", "HEAD" }, &.{ "diff-index", "-C", "-C", "-p", "HEAD" }, &.{ "diff-files", "-M" }, &.{ "diff-files", "-C", "--find-copies-harder", "--name-status" }, }) |args| try same(&repo, args);}
test "renames: breaking rewrites with -B" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); const gpa = testing.allocator; const a = try lines(gpa, "a", 0, 60); defer gpa.free(a); const b = try lines(gpa, "b", 0, 60); defer gpa.free(b); const half = try std.mem.concat(gpa, u8, &.{ a[0 .. a.len / 2], b[b.len / 2 ..] }); defer gpa.free(half); try write(&repo, "rewritten", a); try write(&repo, "halved", a); try write(&repo, "swap1", a); try write(&repo, "swap2", b); try write(&repo, "small", "x\ny\n"); try repo.git(&.{ "add", "." }); try repo.commit("root"); try write(&repo, "rewritten", b); try write(&repo, "halved", half); try write(&repo, "kept-copy", a); try write(&repo, "swap1", b); try write(&repo, "swap2", a); try write(&repo, "small", "z\n"); try repo.git(&.{ "add", "-A" }); try repo.commit("rewrites"); for ([_][]const []const u8{ &.{"-B"}, &.{ "-B", "-M" }, &.{ "-B", "-C" }, &.{ "-B/01", "-M" }, &.{ "-B20/30", "-C", "-C" }, &.{ "-B", "-M", "-R" }, &.{"--break-rewrites=90%"}, &.{"-B5x"}, }) |opts| for ([_][]const []const u8{ &.{}, &.{"-p"}, &.{ "--stat", "--summary", "--numstat" }, &.{"--diff-filter=B"} }) |f| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "diff-tree", "-r" }); try args.appendSlice(gpa, opts); try args.appendSlice(gpa, f); try args.append(gpa, "HEAD"); try same(&repo, args.items); }; try same(&repo, &.{ "log", "-p", "-B", "-M" }); try same(&repo, &.{ "show", "-B", "--stat" });}
/// `count` lines of 20 bytes tagged `tag`, each ending in `eol`.fn fixedLines(gpa: std.mem.Allocator, tag: u8, count: usize, eol: []const u8) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); for (0..count) |i| try out.writer.print("{c}{d:0>6}qqqqqqqqqqqq{s}", .{ tag, i, eol }); return out.toOwnedSlice();}
test "renames: -B counts what was added without carriage returns" { // What was deleted is measured on the old file's whole size, CRs and // all; what was added only on the new file's chunks, which pass over // a CR before a newline. So an LF file given 10 new CRLF lines in // place of 10 old ones is not broken at 50% (360 of 730 bytes), and // the same edit the other way round is (370 of 730); a binary file // keeps its CRs. Sizes straddle the 400-byte minimum, which is taken // on whole sizes. const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); const keep = try fixedLines(gpa, 'k', 30, "\n"); defer gpa.free(keep); const keep_crlf = try fixedLines(gpa, 'k', 30, "\r\n"); defer gpa.free(keep_crlf); const gone = try fixedLines(gpa, 'd', 10, "\n"); defer gpa.free(gone); const gone_crlf = try fixedLines(gpa, 'd', 10, "\r\n"); defer gpa.free(gone_crlf); const new_crlf = try fixedLines(gpa, 'i', 10, "\r\n"); defer gpa.free(new_crlf); const new_lf = try fixedLines(gpa, 'i', 10, "\n"); defer gpa.free(new_lf); const many_crlf = try fixedLines(gpa, 'i', 30, "\r\n"); defer gpa.free(many_crlf); const small = try fixedLines(gpa, 'a', 18, "\n"); defer gpa.free(small); const crlf_399 = try fixedLines(gpa, 'b', 19, "\r\n"); defer gpa.free(crlf_399); const crlf_420 = try fixedLines(gpa, 'b', 20, "\r\n"); defer gpa.free(crlf_420); const lf_380 = try fixedLines(gpa, 'b', 19, "\n"); defer gpa.free(lf_380); const lf_400 = try fixedLines(gpa, 'b', 20, "\n"); defer gpa.free(lf_400);
const Pair = struct { path: []const u8, old: []const []const u8, new: []const []const u8 }; const pairs = [_]Pair{ .{ .path = "lf-to-crlf", .old = &.{ keep, gone }, .new = &.{ keep, new_crlf } }, .{ .path = "crlf-to-lf", .old = &.{ keep_crlf, gone_crlf }, .new = &.{ keep, new_lf } }, .{ .path = "both-crlf", .old = &.{ keep_crlf, gone_crlf }, .new = &.{ keep_crlf, new_crlf } }, .{ .path = "grown-crlf", .old = &.{ keep, gone }, .new = &.{ keep, many_crlf } }, .{ .path = "binary-crlf", .old = &.{ keep, gone }, .new = &.{ keep, new_crlf, "\x00" } }, .{ .path = "small-crlf-399", .old = &.{small}, .new = &.{crlf_399} }, .{ .path = "small-crlf-420", .old = &.{small}, .new = &.{crlf_420} }, .{ .path = "small-lf-380", .old = &.{small}, .new = &.{lf_380} }, .{ .path = "small-lf-400", .old = &.{small}, .new = &.{lf_400} }, }; for (pairs) |p| { const data = try std.mem.concat(gpa, u8, p.old); defer gpa.free(data); try write(&repo, p.path, data); } try repo.git(&.{ "add", "." }); try repo.commit("root"); for (pairs) |p| { const data = try std.mem.concat(gpa, u8, p.new); defer gpa.free(data); try write(&repo, p.path, data); } try repo.git(&.{ "add", "-A" }); try repo.commit("edits"); for ([_][]const []const u8{ &.{"-B/01"}, &.{"-B49%/01"}, &.{"-B49.3%/01"}, &.{"-B49.4%/01"}, &.{"-B50.7%/01"}, &.{"-B50.8%/01"}, &.{"-B63%/01"}, &.{"-B64%/01"}, &.{"-B"}, &.{ "-B", "-M" }, &.{"-B1%/1%"}, }) |opts| for ([_][]const []const u8{ &.{}, &.{ "--stat", "--summary" } }) |f| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "diff-tree", "-r" }); try args.appendSlice(gpa, opts); try args.appendSlice(gpa, f); try args.append(gpa, "HEAD"); try same(&repo, args.items); };}
test "renames: -B on deletions, and on pairs that added next to nothing" { // What was deleted, against the old size alone, breaks a pair a whole // point above the break score even where the new file grew (its CRs); // and at the score just short of the deleted fraction, a pair that // added less than a twentieth of what it deleted and kept stays whole. const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); const keep = try fixedLines(gpa, 'k', 30, "\n"); defer gpa.free(keep); const keep_crlf = try fixedLines(gpa, 'k', 30, "\r\n"); defer gpa.free(keep_crlf); const d40 = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD\n"; const d9 = "DDDDDDDDD\n"; const d53 = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD\n"; const Pair = struct { path: []const u8, old: []const []const u8, new: []const []const u8 }; const pairs = [_]Pair{ .{ .path = "next-to-nothing", .old = &.{ keep, d40 }, .new = &.{ keep, "Z\n" } }, .{ .path = "a-little", .old = &.{ keep, d40 }, .new = &.{ keep, "ZZZZZZZZZZ\n" } }, .{ .path = "deleted-grew", .old = &.{ keep, d9 }, .new = &.{keep_crlf} }, .{ .path = "deleted", .old = &.{ keep, d53 }, .new = &.{keep} }, }; for (pairs) |p| { const data = try std.mem.concat(gpa, u8, p.old); defer gpa.free(data); try write(&repo, p.path, data); } try repo.git(&.{ "add", "." }); try repo.commit("root"); for (pairs) |p| { const data = try std.mem.concat(gpa, u8, p.new); defer gpa.free(data); try write(&repo, p.path, data); } try repo.git(&.{ "add", "-A" }); try repo.commit("edits"); // 4234, 4235 (next-to-nothing's deleted fraction is 4234.08 of // 60000), 1089, 1090 (deleted-grew), 5453, 5454 (deleted), and the // default. for ([_][]const u8{ "-B0.07055/01", "-B0.07057/01", "-B0.07059/01", "-B0.01815/01", "-B0.01817/01", "-B0.09089/01", "-B0.09090/01", "-B" }) |b| { for ([_][]const []const u8{ &.{}, &.{ "--stat", "--summary" }, &.{"-M"} }) |f| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "diff-tree", "-r", b }); try args.appendSlice(gpa, f); try args.append(gpa, "HEAD"); try same(&repo, args.items); } }}
test "renames: log --follow through renames and copies" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); for ([_][]const []const u8{ &.{ "log", "--follow", "--oneline", "--", "y/a.txt" }, &.{ "log", "--follow", "--name-status", "--format=%s", "--", "x/a.txt" }, &.{ "log", "--follow", "-p", "--", "other/c" }, &.{ "log", "--follow", "--stat", "--", "dir/b-copy.c" }, &.{ "log", "--follow", "-M99%", "--name-status", "--", "x/a.txt" }, &.{ "log", "--follow", "--diff-filter=R", "--oneline", "--", "x/a.txt" }, &.{ "log", "--follow", "--oneline", "--", "x/a.txt", "y/a.txt" }, &.{ "log", "--follow", "--oneline" }, }) |args| try same(&repo, args);}
test "renames: log --follow with patterns and log.follow" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); for ([_][]const []const u8{ &.{ "log", "--follow", "--name-status", "--format=%s", "--", "dir" }, &.{ "log", "--follow", "-p", "--", "*.txt" }, &.{ "-c", "log.follow=true", "log", "--oneline", "--", "x/a.txt" }, &.{ "-c", "log.follow=true", "log", "--oneline", "--", "x/a.txt", "dir" }, &.{ "-c", "log.follow=bad", "log", "--oneline", "--", "x/a.txt" }, }) |args| try same(&repo, args);}
/// `count` lines tagged `tag`, the first `changed` of them with `suffix`/// added.fn edited(gpa: std.mem.Allocator, tag: []const u8, count: usize, changed: usize, suffix: []const u8) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); for (0..count) |i| try out.writer.print("{s} line {d}{s}\n", .{ tag, i, if (i < changed) suffix else "" }); return out.toOwnedSlice();}
test "renames: what -B breaking a pair changes for the others" { const gpa = testing.allocator; const orig = try edited(gpa, "orig", 100, 0, ""); defer gpa.free(orig); const near = try edited(gpa, "orig", 100, 6, " x"); defer gpa.free(near); const named = try edited(gpa, "orig", 100, 16, " y"); defer gpa.free(named); const old = try edited(gpa, "rw", 100, 0, ""); defer gpa.free(old); const new = try edited(gpa, "fresh", 100, 0, ""); defer gpa.free(new); // One deleted source, a destination sharing its file name and a // closer one: the same-name step pairs the first, unless -B broke a // pair (here `f`, with nothing to take its halves). for ([_]bool{ false, true }) |rewrite| { var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try write(&repo, "dir/a", orig); try write(&repo, "f", old); try repo.git(&.{ "add", "." }); try repo.commit("root"); try repo.tmp.dir.deleteFile(repo.io, "dir/a"); try write(&repo, "b.c", near); try write(&repo, "other/a", named); if (rewrite) try write(&repo, "f", new); try repo.git(&.{ "add", "-A" }); try repo.commit("moved"); for ([_][]const []const u8{ &.{"-M"}, &.{ "-B", "-M" }, &.{ "-B", "-C" }, &.{ "-B", "-M90%" } }) |opts| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "diff-tree", "-r", "--name-status" }); try args.appendSlice(gpa, opts); try args.append(gpa, "HEAD"); try same(&repo, args.items); } } // Where -B broke a pair, a source an exact match took still holds a // place among the inexact candidates: here it keeps `y` from // displacing an early candidate, so `x` wins the tie for `z`. for ([_]bool{ false, true }) |rewrite| { var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); const exact = try edited(gpa, "exact", 40, 0, ""); defer gpa.free(exact); const shared = try edited(gpa, "shared", 40, 0, ""); defer gpa.free(shared); const z = try edited(gpa, "shared", 40, 8, " chg"); defer gpa.free(z); const low_c = try edited(gpa, "c low", 40, 0, ""); defer gpa.free(low_c); const low_d = try edited(gpa, "d low", 40, 0, ""); defer gpa.free(low_d); try write(&repo, "a", exact); try write(&repo, "b", old); try write(&repo, "c", low_c); try write(&repo, "d", low_d); try write(&repo, "x", shared); try write(&repo, "y", shared); try repo.git(&.{ "add", "." }); try repo.commit("root"); for ([_][]const u8{ "a", "c", "d", "x", "y" }) |p| try repo.tmp.dir.deleteFile(repo.io, p); try write(&repo, "z", z); try write(&repo, "b", if (rewrite) exact else new); try repo.git(&.{ "add", "-A" }); try repo.commit("moved"); try same(&repo, &.{ "diff-tree", "-r", "--name-status", "-B", "-M", "HEAD" }); try same(&repo, &.{ "diff-tree", "-r", "--name-status", "-M", "HEAD" }); }}