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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `--dirstat`: how the changes of a diff are spread over directories,//! as git-diff(1) describes `--dirstat`, `diff.dirstat` and their//! parameters.//!//! What the documentation leaves out was found by asking git 2.56 about//! trees made to tell rules apart://!//! - **Damage**, under `changes`: the bytes of the old file less those//! the new one keeps, plus the bytes of the new file's chunks (as rename//! detection cuts it, so a carriage return before a line feed of a text//! file is not counted) less those kept. Under `lines`, the lines//! deleted and added, and for a binary file the two sizes together in//! 64-byte pieces, rounded up. Under `files`, one for each path.//! - A directory's share is its damage out of the whole diff's, in tenths//! of a percent, rounded down. Directories are visited depth first, a//! directory after those in it, and shown, as `%4d.%d%% <dir>/`, when//! their share reaches the limit (3% unless a number says otherwise;//! `2.5` is 25 tenths, and digits past the first after the point do not//! count). Without `cumulative` a directory shown gives nothing to the//! one it is in, and one not shown gives it all its damage. The top of//! the tree is never shown, nor a directory whose changed paths are all//! in one directory within it.//! - Parameters are read left to right, the last of each kind winning; an//! unknown one (an empty one included) is named in git's error.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
/// What a share is measured in.pub const Mode = enum { changes, lines, files };
/// How `--dirstat` measures and shows.pub const Options = struct { mode: Mode = .changes, cumulative: bool = false, /// The limit, in tenths of a percent. permille: u32 = 30,
/// Reads a comma-separated list of parameters onto `o`. Each unknown /// one is appended to `unknown`, for git's message; returns whether /// all were known. pub fn parse(o: *Options, text: []const u8, unknown: ?*std.ArrayList([]const u8), gpa: Allocator) Allocator.Error!bool { var ok = true; var it = std.mem.splitScalar(u8, text, ','); while (it.next()) |p| { if (std.mem.eql(u8, p, "changes")) { o.mode = .changes; } else if (std.mem.eql(u8, p, "lines")) { o.mode = .lines; } else if (std.mem.eql(u8, p, "files")) { o.mode = .files; } else if (std.mem.eql(u8, p, "cumulative")) { o.cumulative = true; } else if (std.mem.eql(u8, p, "noncumulative")) { o.cumulative = false; } else if (limit(p)) |n| { o.permille = n; } else { ok = false; if (unknown) |u| try u.append(gpa, p); } } return ok; }};
/// A number of percent with at most one digit after the point that/// counts, in tenths; null if `p` is not one.fn limit(p: []const u8) ?u32 { if (p.len == 0 or !std.ascii.isDigit(p[0])) return null; var i: usize = 0; var n: u32 = 0; while (i < p.len and std.ascii.isDigit(p[i])) : (i += 1) n = @min(n *| 10 +| (p[i] - '0'), 1_000_000); n *|= 10; if (i < p.len and p[i] == '.') { i += 1; if (i < p.len and std.ascii.isDigit(p[i])) { n +|= p[i] - '0'; i += 1; } while (i < p.len and std.ascii.isDigit(p[i])) i += 1; } if (i != p.len) return null; return n;}
/// One path's damage.pub const Entry = struct { path: []const u8, damage: u64,};
/// Writes the directories' shares of `entries`, which are in path order.pub fn write(w: *Io.Writer, entries: []const Entry, options: Options) Io.Writer.Error!void { var total: u64 = 0; for (entries) |e| total += e.damage; if (total == 0) return; var g: Gatherer = .{ .w = w, .entries = entries, .total = total, .options = options }; _ = try g.gather("");}
const Gatherer = struct { w: *Io.Writer, entries: []const Entry, total: u64, options: Options, next: usize = 0,
/// Gathers the entries under `base` (ending in `/`, or empty for the /// top), writing the directories that reach the limit; returns the /// damage it passes up. fn gather(g: *Gatherer, base: []const u8) Io.Writer.Error!u64 { var sum: u64 = 0; var files: usize = 0; var dirs: usize = 0; while (g.next < g.entries.len) { const e = g.entries[g.next]; if (!std.mem.startsWith(u8, e.path, base)) break; const rest = e.path[base.len..]; if (std.mem.findScalar(u8, rest, '/')) |slash| { sum += try g.gather(e.path[0 .. base.len + slash + 1]); dirs += 1; } else { sum += e.damage; files += 1; g.next += 1; } } // A directory holding one directory and nothing else is not shown. if (base.len != 0 and sum != 0 and (files != 0 or dirs != 1)) { const permille = sum * 1000 / g.total; if (permille >= g.options.permille) { try g.w.print("{d: >4}.{d}% {s}\n", .{ permille / 10, permille % 10, base }); if (!g.options.cumulative) return 0; } } return sum; }};
test "dirstat parameters" { var o: Options = .{}; try std.testing.expect(try o.parse("files,10,cumulative", null, std.testing.allocator)); try std.testing.expectEqual(Mode.files, o.mode); try std.testing.expectEqual(@as(u32, 100), o.permille); try std.testing.expect(o.cumulative); try std.testing.expect(try o.parse("1.25", null, std.testing.allocator)); try std.testing.expectEqual(@as(u32, 12), o.permille); try std.testing.expect(!try o.parse("-1", null, std.testing.allocator));}
test "directories after what is in them, folded when not shown" { var out: Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); const entries = [_]Entry{ .{ .path = "a/b/c/z", .damage = 10 }, .{ .path = "a/b/y", .damage = 190 }, .{ .path = "a/x", .damage = 10 }, .{ .path = "top", .damage = 590 }, }; try write(&out.writer, &entries, .{}); try std.testing.expectEqualStrings(" 25.0% a/b/\n", out.written());}
const fuzz_input = @import("../testing/fuzz.zig");
/// Parameters, a NUL, then NUL-separated paths, each with a damage of its/// length: the output is a run of shares, each of a directory of those/// paths. A path may hold any byte but NUL, and git writes a directory's/// name as it is, newlines and all (`a<LF>b/` is shown as those four/// bytes), so the output cannot be split into lines to check it: it is/// read as records whose directory is one of the paths' own.fn fuzzDirstat(_: void, smith: *std.testing.Smith) anyerror!void { const gpa = std.testing.allocator; var buffer: [512]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); var parts = std.mem.splitScalar(u8, input, 0); var o: Options = .{}; _ = try o.parse(parts.next() orelse return, null, gpa); var entries: std.ArrayList(Entry) = .empty; defer entries.deinit(gpa); while (parts.next()) |path| try entries.append(gpa, .{ .path = path, .damage = path.len }); std.mem.sort(Entry, entries.items, {}, struct { fn less(_: void, a: Entry, b: Entry) bool { return std.mem.order(u8, a.path, b.path) == .lt; } }.less); var out: Io.Writer.Allocating = .init(gpa); defer out.deinit(); try write(&out.writer, entries.items, o); try std.testing.expect(try readsAsShares(gpa, out.written(), entries.items));}
/// Whether `text` is a run of `%4d.%d%% <dir>/` lines, each `<dir>/` a/// directory some entry is in. A directory's name may hold a newline, or/// text that looks like another share, so where one record ends is not/// known until the next is read: every position a run of records can/// reach is found, from the start.fn readsAsShares(gpa: Allocator, text: []const u8, entries: []const Entry) Allocator.Error!bool { const reached = try gpa.alloc(bool, text.len + 1); defer gpa.free(reached); @memset(reached, false); reached[0] = true; records: for (0..text.len) |at| { if (!reached[at]) continue; const rest = text[at..]; if (rest.len < 8 or rest[4] != '.' or !std.ascii.isDigit(rest[5]) or rest[6] != '%' or rest[7] != ' ') continue; const number = std.mem.trimStart(u8, rest[0..4], " "); if (number.len == 0) continue; for (number) |c| if (!std.ascii.isDigit(c)) continue :records; const after = rest[8..]; for (entries) |e| { // Each directory the entry is in, the top excepted. var from: usize = 0; while (std.mem.findScalarPos(u8, e.path, from, '/')) |slash| : (from = slash + 1) { const dir = e.path[0 .. slash + 1]; if (std.mem.startsWith(u8, after, dir) and after.len > dir.len and after[dir.len] == '\n') reached[at + 8 + dir.len + 1] = true; } } } return reached[text.len];}
test "a directory's name is written as it is, newline and all" { var out: Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); const entries = [_]Entry{ .{ .path = "a\nb/f", .damage = 10 }, .{ .path = "tab\tdir/g", .damage = 10 }, .{ .path = "top", .damage = 20 }, }; try write(&out.writer, &entries, .{}); try std.testing.expectEqualStrings(" 25.0% a\nb/\n 25.0% tab\tdir/\n", out.written()); try std.testing.expect(try readsAsShares(std.testing.allocator, out.written(), &entries));}
test "fuzz: dirstat" { try fuzz_input.fuzz({}, fuzzDirstat, .{ .corpus = &.{ @embedFile("../fuzz/dirstat"), // A fuzzer's paths, some of whose directories' names hold // newlines. @embedFile("../fuzz/dirstat-newlines"), }, });}