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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Rename and copy detection: deletions and additions among a list of//! changed paths paired up as `-M` and `-C` pair them, scored as//! `similarity` measures files.//!//! gitdiffcore(7) describes the stages in prose; how each one chooses was//! found by asking git over thousands of generated trees://!//! - **Sources** are the deleted paths; with copies also the modified//! ones and those whose type changed (their old contents), and with `--find-copies-harder` the//! unchanged ones. **Destinations** are the added paths. Both are taken//! in the order of the list.//! - **Exact matches** come first, destination by destination: of the//! sources with the same object ID and the same kind of entry, the one//! scoring best on "same file name" plus "deleted and not yet taken"//! wins, the first in order on a tie. Without copies only deleted//! sources not yet taken qualify.//! - **Same file name** (renames alone: not with copies, nor where `-B`//! broke a pair, though `-B` breaking nothing leaves it in): where//! exactly one remaining deleted source and exactly one remaining//! destination share a file name, they are paired if their score//! reaches halfway between the minimum and a full score.//! Where `-B` broke a pair, the deleted sources exact matches took//! stay among the inexact candidates below, holding places, though//! they cannot be renamed from again.//! - **The limit**: the rest is skipped, with a warning, if the sources//! times the destinations left come to more than the limit squared;//! under `--find-copies-harder` git first tries again with the//! unchanged sources left out.//! - **Inexact matches**: each remaining destination keeps four//! candidates. The first four sources fill them whatever they score;//! each later one replaces the first of the lowest-scoring if it scores//! higher (with "same file name" breaking ties). Sources whose sizes//! cannot reach the minimum score nothing but still take a place. The//! candidates reaching the minimum are sorted by score, ties kept in//! order, and taken first for renames (each deleted source once), then,//! with copies, for copies from any source.//! - **Breaks** (`-B`): a modified file whose deletions and additions//! come to the break score is split into a deletion and an addition//! before any of this. The addition is a destination; the deletion is a//! rename source only if what was deleted reaches the merge score, and//! otherwise a copy source as a modified file is. A broken//! pair whose addition nothing else took is put back together, shown as//! a rewrite if its deletion was a source. A type change between a//! file and a symbolic link is always broken, as deep as can be, and put back together keeps its letter//! (`T100`).//! - **Labels**: a deleted source used by several destinations is a//! rename for the last of them and a copy for the others, and its//! deletion is no longer listed. Inexact matches are only ever between//! regular files.
const std = @import("std");const Allocator = std.mem.Allocator;
const object = @import("../object.zig");const output = @import("output.zig");const similarity = @import("similarity.zig");
const Pair = output.Pair;const Side = output.Side;
/// How to look.pub const Options = struct { /// Whether to look for renames at all; `-B` alone breaks rewrites /// without. renames: bool = true, /// `-C`: modified paths are sources too. copies: bool = false, /// `-B`: rewrites are broken into a deletion and an addition, which /// renames may take, and shown as rewrites if nothing does. It turns /// the same-file-name step off. breaks: ?Breaks = null, /// The minimum score, out of `similarity.max_score`. min_score: u32 = similarity.default_min_score, /// `-l`, `diff.renameLimit`; 0 for no limit. limit: u32 = 1000, /// `--rename-empty`: empty files may be paired. rename_empty: bool = true,};
/// `-B[<n>][/<m>]`'s two scores, out of `similarity.max_score`.pub const Breaks = struct { /// How much must be deleted and added together, against the larger /// file, to break a pair: 50% unless `<n>` says otherwise. break_score: u32 = similarity.max_score / 2, /// How much of the old file must be gone, against its size, to break /// it: 60% unless `<m>` says otherwise. merge_score: u32 = similarity.max_score * 6 / 10,};
/// The smallest file, in bytes, `-B` breaks: the larger side must have/// at least this many.pub const break_min_size: u64 = 400;
/// What the limit made of the search, for the warnings git writes.pub const Limited = struct { /// Whether the unchanged sources were left out rather than the /// inexact search skipped. copies_from_modified_only: bool, /// The limit that would have been enough. needed: usize,};
/// What detection found.pub const Result = struct { /// The pairs, renames and copies among them, in git's order. pairs: []Pair, /// Set when the limit cut the search short. limited: ?Limited = null,
/// Frees the list (not the paths, which are the caller's). pub fn deinit(r: *Result, gpa: Allocator) void { gpa.free(r.pairs); r.* = undefined; }};
/// What detection can fail with: reading contents.pub const Error = Allocator.Error || error{ContentsUnreadable};
const kind_mask: u32 = 0o170000;const regular: u32 = 0o100000;
fn baseName(path: []const u8) []const u8 { return if (std.mem.findScalarLast(u8, path, '/')) |s| path[s + 1 ..] else path;}
const SideInfo = struct { loaded: bool = false, id: object.Id = undefined, size: u64 = 0, signature: ?similarity.Signature = null,};
const Detector = struct { gpa: Allocator, pairs: []const Pair, source: output.Source, options: Options, /// Per pair: the old side's and the new side's information. old_info: []SideInfo, new_info: []SideInfo,
fn init(gpa: Allocator, pairs: []const Pair, source: output.Source, options: Options) Allocator.Error!Detector { const old_info = try gpa.alloc(SideInfo, pairs.len); errdefer gpa.free(old_info); const new_info = try gpa.alloc(SideInfo, pairs.len); @memset(old_info, .{}); @memset(new_info, .{}); return .{ .gpa = gpa, .pairs = pairs, .source = source, .options = options, .old_info = old_info, .new_info = new_info }; }
/// Takes what was loaded of one side, leaving nothing behind. fn take(d: *Detector, index: usize, old: bool) SideInfo { const info = if (old) &d.old_info[index] else &d.new_info[index]; const taken = info.*; info.* = .{}; return taken; }
fn deinit(d: *Detector) void { for (d.old_info) |*i| if (i.signature) |*s| s.deinit(d.gpa); for (d.new_info) |*i| if (i.signature) |*s| s.deinit(d.gpa); d.gpa.free(d.old_info); d.gpa.free(d.new_info); }
/// Loads one side: its object ID (hashing a working tree file whose ID /// is not known), size and, for a regular file, its signature. fn load(d: *Detector, index: usize, old: bool, want_signature: bool) Error!*SideInfo { const p = &d.pairs[index]; const info = if (old) &d.old_info[index] else &d.new_info[index]; const side = if (old) p.old else p.new; if (info.loaded and (!want_signature or info.signature != null or side.mode & kind_mask != regular)) return info; if (side.mode & kind_mask == 0o160000) { info.* = .{ .loaded = true, .id = side.id, .size = 0 }; return info; } var view = p.*; if (old) if (p.old_path) |op| { view.path = op; }; const contents = d.source.read(d.source.context, d.gpa, &view, side) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.ContentsUnreadable, }; defer if (contents.allocation) |a| d.gpa.free(a); info.loaded = true; info.size = contents.bytes.len; info.id = if (side.id_known) side.id else object.hash(side.id.object_format, .blob, contents.bytes); if (want_signature and side.mode & kind_mask == regular and info.signature == null) info.signature = try similarity.signature(d.gpa, contents.bytes); return info; }
fn idOf(d: *Detector, index: usize, old: bool) Error!object.Id { const p = &d.pairs[index]; const side = if (old) p.old else p.new; if (side.id_known) return side.id; return (try d.load(index, old, false)).id; }};
const Match = struct { source: usize, score: u32,};
const Candidate = struct { score: u32, same_name: bool, destination: usize, source: usize,};
fn candidateBetter(a: Candidate, b: Candidate) bool { if (a.score != b.score) return a.score > b.score; return a.same_name and !b.same_name;}
fn candidateOrder(_: void, a: Candidate, b: Candidate) bool { return candidateBetter(a, b);}
/// Labels the pairs `pairs` takes from each deleted source again, once/// they have been put in another order (an orderfile, `--rotate-to`,/// `--skip-to` before it drops any): the last of them is the rename and/// the others are copies, as git was seen to decide in the order the/// pairs are written in rather than the order they were found in.pub fn relabel(gpa: Allocator, pairs: []Pair) Allocator.Error!void { // The sources a rename took, and whether a later pair has claimed // the rename yet. var gone: std.StringHashMapUnmanaged(bool) = .empty; defer gone.deinit(gpa); for (pairs) |*p| if (p.status == .renamed) try gone.put(gpa, p.old_path.?, false); if (gone.count() == 0) return; var i = pairs.len; while (i > 0) { i -= 1; const p = &pairs[i]; if (!p.moved()) continue; const claimed = gone.getPtr(p.old_path.?) orelse continue; p.status = if (claimed.*) .copied else .renamed; claimed.* = true; }}
/// Pairs deletions with additions among `pairs` (in git's order, with/// any unchanged paths `--find-copies-harder` adds marked `unmodified`),/// reading contents from `source`, after breaking rewrites as `-B` asks./// The result lists every pair but the unchanged ones and the deletions/// that became renames; a broken pair nothing took is a rewrite.pub fn detect(gpa: Allocator, pairs: []const Pair, source: output.Source, options: Options) Error!Result { // Break rewrites first: each broken pair becomes a deletion and an // addition at its place. var expanded: std.ArrayList(Pair) = .empty; defer expanded.deinit(gpa); // For each entry of `expanded`, the pair it came from, if broken, // and how dissimilar the two were. var halves: std.ArrayList(?Half) = .empty; defer halves.deinit(gpa); var infos: std.ArrayList([2]SideInfo) = .empty; defer infos.deinit(gpa); { var b: Detector = try .init(gpa, pairs, source, options); defer b.deinit(); for (pairs, 0..) |p, i| { if (options.breaks) |br| if (try breakScore(&b, i, br)) |brk| { // The old half of a pair not deep enough is no rename // source, but still a copy source, as a modified file is. if (brk.deep) try expanded.append(gpa, .{ .status = .deleted, .path = p.path, .old = p.old, .new = .{ .id = object.Id.zero(p.old.id.object_format) } }) else try expanded.append(gpa, p); try expanded.append(gpa, .{ .status = .added, .path = p.path, .old = .{ .id = object.Id.zero(p.new.id.object_format) }, .new = p.new }); const origin: Half = .{ .pair = p, .dissimilarity = brk.dissimilarity, .deep = brk.deep, .partner = expanded.items.len - 1 }; try halves.append(gpa, origin); var other = origin; other.partner = expanded.items.len - 2; try halves.append(gpa, other); try infos.append(gpa, .{ b.take(i, true), .{} }); try infos.append(gpa, .{ .{}, b.take(i, false) }); continue; }; try expanded.append(gpa, p); try halves.append(gpa, null); try infos.append(gpa, .{ b.take(i, true), b.take(i, false) }); } }
var d: Detector = try .init(gpa, expanded.items, source, options); defer d.deinit(); for (infos.items, 0..) |info, k| { d.old_info[k] = info[0]; d.new_info[k] = info[1]; } // The detector owns the signatures now. infos.clearRetainingCapacity();
var sources: std.ArrayList(usize) = .empty; defer sources.deinit(gpa); var destinations: std.ArrayList(usize) = .empty; defer destinations.deinit(gpa); for (expanded.items, 0..) |p, i| switch (p.status) { .deleted => try sources.append(gpa, i), .modified, .type_changed => if (options.copies) try sources.append(gpa, i), .unmodified => if (options.copies) try sources.append(gpa, i), .added => try destinations.append(gpa, i), else => {}, };
const matches = try gpa.alloc(?Match, expanded.items.len); defer gpa.free(matches); @memset(matches, null); // Deleted sources taken by a rename. const taken = try gpa.alloc(bool, expanded.items.len); defer gpa.free(taken); @memset(taken, false);
var limited: ?Limited = null; if (options.renames and destinations.items.len != 0 and sources.items.len != 0) { try exactStep(&d, sources.items, destinations.items, matches, taken); // -B was seen to leave the same-name step out only where it // actually broke a pair. const broke_any = expanded.items.len != pairs.len; if (!options.copies and !broke_any) try nameStep(&d, sources.items, destinations.items, matches, taken); limited = try inexactStep(&d, sources.items, destinations.items, matches, taken, broke_any); } return .{ .pairs = try assemble(gpa, &d, matches, halves.items), .limited = limited };}
/// One half of a broken pair.const Half = struct { /// The pair before it was broken. pair: Pair, /// How much of the old file is gone, out of `similarity.max_score`. dissimilarity: u32, /// Whether that reaches the merge score: only then is the old half a /// rename source, and the pair shown as a rewrite if nothing takes it. deep: bool, /// The other half's place. partner: usize,};
const Broken = struct { dissimilarity: u32, deep: bool };
/// Whether `-B` breaks pair `i`, and if so how: as git was seen to decide/// over hundreds of generated edits, a modified regular file is broken/// when the larger side has at least `break_min_size` bytes and what was/// deleted and what was added come to `break_score` of the larger side;/// it is deep when what was deleted comes to `merge_score` of the old.////// The two amounts are not measured alike. What was deleted is the old/// file's whole size less what was copied, so the carriage returns of a/// CRLF file count as deleted; what was added is the new file's chunked/// bytes less what was copied, so its carriage returns before newlines/// do not count at all. Sizes (the larger side, the 400 bytes, the old/// size the merge score is taken against) are whole sizes. gitdiffcore(7)/// says the smaller side; git was seen to use the larger. What was/// deleted on its own, against the old size, also breaks a pair, with/// one exception for pairs that added almost nothing (see the body).fn breakScore(d: *Detector, i: usize, br: Breaks) Error!?Broken { const p = &d.pairs[i]; // A type change between a file and a symbolic link is always // broken, wholly dissimilar, whatever its size: git shows one nothing takes as `T100` and a rewrite in // `--summary`, and its old half was seen to be taken as a source. if (p.status == .type_changed) { // Not a gitlink, on either side. if (p.old.mode & kind_mask == 0o160000 or p.new.mode & kind_mask == 0o160000) return null; return .{ .dissimilarity = similarity.max_score, .deep = true }; } if (p.status != .modified) return null; if (p.old.mode & kind_mask != regular or p.new.mode & kind_mask != regular) return null; if (p.old.id_known and p.new.id_known and p.old.id.eql(p.new.id)) return null; const a = try d.load(i, true, true); const b = try d.load(i, false, true); const larger = @max(a.size, b.size); // A file that was empty is never broken. if (larger < break_min_size or a.size == 0) return null; const shared = similarity.copied(a.signature.?, b.signature.?); const deleted = a.size - shared; const added = b.signature.?.chunked - shared; const max: u128 = similarity.max_score; const have = @as(u128, deleted) * max; const want = @as(u128, br.break_score) * a.size; // What was deleted alone, against the old size, breaks the pair once // it is a whole point of the score above the break score, whatever // was added and however large the new file grew. if (have < want + a.size) { // Just above the break score, a pair that added next to nothing // (less than a twentieth of what was deleted and of what was // kept) is a removal, not a rewrite: git left such pairs whole // at that one score while breaking them both below and above it. if (have > want and added * 20 < deleted and added * 20 < shared) return null; if (@as(u128, deleted + added) * max < @as(u128, br.break_score) * larger) return null; } const deep = @as(u128, deleted) * max >= @as(u128, br.merge_score) * a.size; return .{ .dissimilarity = @intCast(@as(u128, deleted) * max / a.size), .deep = deep };}
fn isFree(d: *const Detector, s: usize, taken: []const bool) bool { return d.pairs[s].status == .deleted and !taken[s];}
fn emptyId(format: object.Format) object.Id { return object.hash(format, .blob, "");}
fn exactStep(d: *Detector, sources: []const usize, destinations: []const usize, matches: []?Match, taken: []bool) Error!void { for (destinations) |t| { const dst = &d.pairs[t]; const dst_id = try d.idOf(t, false); if (!d.options.rename_empty and dst_id.eql(emptyId(dst_id.object_format))) continue; var best: ?usize = null; var best_rank: u8 = 0; for (sources) |s| { const src = &d.pairs[s]; if (src.old.mode & kind_mask != dst.new.mode & kind_mask) continue; const free = isFree(d, s, taken); if (!free and !d.options.copies) continue; if (!(try d.idOf(s, true)).eql(dst_id)) continue; const rank: u8 = @as(u8, @intFromBool(std.mem.eql(u8, baseName(src.path), baseName(dst.path)))) + @intFromBool(free); if (best == null or rank > best_rank) { best = s; best_rank = rank; } } if (best) |s| { matches[t] = .{ .source = s, .score = similarity.max_score }; if (d.pairs[s].status == .deleted) taken[s] = true; } }}
fn scorePair(d: *Detector, s: usize, t: usize, check_sizes: bool) Error!u32 { const src = &d.pairs[s]; const dst = &d.pairs[t]; if (src.old.mode & kind_mask != regular or dst.new.mode & kind_mask != regular) return 0; const a = try d.load(s, true, false); const b = try d.load(t, false, false); // Empty files pair only exactly. if (a.size == 0 or b.size == 0) return 0; if (check_sizes and !similarity.sizesCanReach(a.size, b.size, d.options.min_score)) return 0; const as = (try d.load(s, true, true)).signature.?; const bs = (try d.load(t, false, true)).signature.?; return similarity.score(a.size, b.size, similarity.copied(as, bs));}
fn nameStep(d: *Detector, sources: []const usize, destinations: []const usize, matches: []?Match, taken: []bool) Error!void { const gpa = d.gpa; // For each file name: the one remaining source and destination, or // more than one. const Slot = struct { index: usize = 0, count: usize = 0 }; var by_source: std.StringHashMapUnmanaged(Slot) = .empty; defer by_source.deinit(gpa); var by_destination: std.StringHashMapUnmanaged(Slot) = .empty; defer by_destination.deinit(gpa); for (sources) |s| { if (!isFree(d, s, taken)) continue; const gop = try by_source.getOrPut(gpa, baseName(d.pairs[s].path)); if (!gop.found_existing) gop.value_ptr.* = .{}; gop.value_ptr.index = s; gop.value_ptr.count += 1; } for (destinations) |t| { if (matches[t] != null) continue; const gop = try by_destination.getOrPut(gpa, baseName(d.pairs[t].path)); if (!gop.found_existing) gop.value_ptr.* = .{}; gop.value_ptr.index = t; gop.value_ptr.count += 1; } const threshold = (d.options.min_score + similarity.max_score) / 2; for (destinations) |t| { if (matches[t] != null) continue; const name = baseName(d.pairs[t].path); const ds = by_destination.get(name) orelse continue; if (ds.count != 1) continue; const ss = by_source.get(name) orelse continue; if (ss.count != 1) continue; const sc = try scorePair(d, ss.index, t, false); if (sc >= threshold) { matches[t] = .{ .source = ss.index, .score = sc }; taken[ss.index] = true; } }}
const slots = 4;
/// `broke_any`: `-B` broke a pair, and then git was seen to keep the/// deleted sources exact matches took among the candidates, holding/// places they can no longer be renamed from.fn inexactStep(d: *Detector, all_sources: []const usize, destinations: []const usize, matches: []?Match, taken: []bool, broke_any: bool) Error!?Limited { const gpa = d.gpa; var remaining: std.ArrayList(usize) = .empty; defer remaining.deinit(gpa); for (destinations) |t| if (matches[t] == null) try remaining.append(gpa, t); if (remaining.items.len == 0) return null;
var sources: std.ArrayList(usize) = .empty; defer sources.deinit(gpa); for (all_sources) |s| { const listed = isFree(d, s, taken) or (broke_any and d.pairs[s].status == .deleted); if (!d.options.copies and !listed) continue; try sources.append(gpa, s); } if (sources.items.len == 0) return null;
var limited: ?Limited = null; const limit: u64 = d.options.limit; const too_many = struct { fn f(lim: u64, a: usize, b: usize) bool { return lim != 0 and @as(u128, a) * b > @as(u128, lim) * lim; } }.f; if (too_many(limit, sources.items.len, remaining.items.len)) { const needed = @max(sources.items.len, remaining.items.len); // Under --find-copies-harder, git tries again without the // unchanged sources. var modified: usize = 0; var any_unchanged = false; for (sources.items) |s| { if (d.pairs[s].status == .unmodified) any_unchanged = true else modified += 1; } if (!any_unchanged or too_many(limit, modified, remaining.items.len)) { return .{ .copies_from_modified_only = false, .needed = needed }; } var k: usize = 0; for (sources.items) |s| { if (d.pairs[s].status == .unmodified) continue; sources.items[k] = s; k += 1; } sources.shrinkRetainingCapacity(k); limited = .{ .copies_from_modified_only = true, .needed = needed }; }
var kept: std.ArrayList(Candidate) = .empty; defer kept.deinit(gpa); for (remaining.items) |t| { if (d.pairs[t].new.mode & kind_mask != regular) continue; var best: [slots]Candidate = undefined; var n: usize = 0; const dst_name = baseName(d.pairs[t].path); for (sources.items) |s| { const c: Candidate = .{ .score = try scorePair(d, s, t, true), .same_name = std.mem.eql(u8, baseName(d.pairs[s].path), dst_name), .destination = t, .source = s, }; if (n < slots) { best[n] = c; n += 1; continue; } var low: usize = 0; for (best[1..], 1..) |b, j| { if (candidateBetter(best[low], b)) low = j; } if (candidateBetter(c, best[low])) best[low] = c; } for (best[0..n]) |c| if (c.score >= d.options.min_score) try kept.append(gpa, c); } std.mem.sort(Candidate, kept.items, {}, candidateOrder); for (kept.items) |c| { if (matches[c.destination] != null) continue; if (!isFree(d, c.source, taken)) continue; matches[c.destination] = .{ .source = c.source, .score = c.score }; taken[c.source] = true; } if (d.options.copies) for (kept.items) |c| { if (matches[c.destination] != null) continue; matches[c.destination] = .{ .source = c.source, .score = c.score }; }; return limited;}
/// A side with the object ID detection worked out, if it hashed one: git/// shows the IDs of working tree files it read to look for renames.fn known(side: Side, info: SideInfo) Side { var s = side; if (!s.id_known and info.loaded and s.mode & kind_mask != 0o160000) { s.id = info.id; s.id_known = true; } return s;}
fn assemble(gpa: Allocator, d: *const Detector, matches: []const ?Match, halves: []const ?Half) Allocator.Error![]Pair { const pairs = d.pairs; // Whether a deleted source is gone from the new side: a broken // pair's deleted half is not, if its added half was left to it. const gone = try gpa.alloc(bool, pairs.len); defer gpa.free(gone); for (pairs, 0..) |p, i| { gone[i] = p.status == .deleted; if (halves[i]) |h| if (p.status == .deleted) { const m = matches[h.partner]; gone[i] = m != null and m.?.source != i; }; } // The last destination using each source that is gone is its rename. const last_use = try gpa.alloc(?usize, pairs.len); defer gpa.free(last_use); @memset(last_use, null); for (matches, 0..) |m, t| if (m) |x| { if (gone[x.source]) last_use[x.source] = t; }; var out: std.ArrayList(Pair) = .empty; errdefer out.deinit(gpa); for (pairs, 0..) |p, i| { if (p.status == .unmodified) continue; if (halves[i]) |h| { // The old half is shown with the new half, or not at all. if (h.partner > i) continue; const m = matches[i]; if (m == null or m.?.source == h.partner) { var q = h.pair; q.old = known(q.old, d.old_info[h.partner]); q.new = known(q.new, d.new_info[i]); if (h.deep) { q.score = h.dissimilarity; q.rewrite = true; } try out.append(gpa, q); continue; } } if (p.status == .deleted and last_use[i] != null) continue; if (matches[i]) |m| { const src = pairs[m.source]; const rename = gone[m.source] and last_use[m.source].? == i; try out.append(gpa, .{ .status = if (rename) .renamed else .copied, .path = p.path, .old_path = src.path, .old = known(src.old, d.old_info[m.source]), .new = known(p.new, d.new_info[i]), .score = m.score, }); continue; } var q = p; if (q.status == .added) q.new = known(q.new, d.new_info[i]); try out.append(gpa, q); } return out.toOwnedSlice(gpa);}
// -- tests ---------------------------------------------------------------------
const TestSource = struct { contents: std.StringHashMapUnmanaged([]const u8) = .empty,
fn read(context: *anyopaque, gpa: Allocator, pair: *const Pair, side: Side) output.Error!output.Contents { _ = gpa; _ = side; const self: *TestSource = @ptrCast(@alignCast(context)); return .{ .bytes = self.contents.get(pair.path) orelse return error.ContentsUnreadable }; }};
fn testPair(status: output.Status, path: []const u8, data: []const u8) Pair { const id = object.hash(.sha1, .blob, data); const present: Side = .{ .mode = 0o100644, .id = id }; const absent: Side = .{ .id = object.Id.zero(.sha1) }; return .{ .status = status, .path = path, .old = if (status == .added) absent else present, .new = if (status == .deleted) absent else present, };}
test "a rename and a copy" { const gpa = std.testing.allocator; const base = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"; const edited = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nTEN\n"; var ts: TestSource = .{}; defer ts.contents.deinit(gpa); try ts.contents.put(gpa, "a", base); try ts.contents.put(gpa, "b", edited); try ts.contents.put(gpa, "c", base); const pairs = [_]Pair{ testPair(.deleted, "a", base), testPair(.added, "b", edited), testPair(.added, "c", base) }; var r = try detect(gpa, &pairs, .{ .context = &ts, .read = TestSource.read }, .{ .copies = true }); defer r.deinit(gpa); try std.testing.expectEqual(@as(usize, 2), r.pairs.len); // "c" is the exact match, and comes last, so it is the rename; "b" is // a copy of the same source. try std.testing.expectEqual(output.Status.copied, r.pairs[0].status); try std.testing.expectEqualStrings("b", r.pairs[0].path); try std.testing.expectEqualStrings("a", r.pairs[0].old_path.?); try std.testing.expectEqual(@as(u32, 91), similarity.percent(r.pairs[0].score)); try std.testing.expectEqual(output.Status.renamed, r.pairs[1].status); try std.testing.expectEqual(similarity.max_score, r.pairs[1].score);}