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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Revisions as gitrevisions(7) spells them, read from their text into a//! structured form with no I/O, so that they can be fuzzed and so that//! the walk can take the sides of a range apart without resolving them.//! `Resolver.zig` resolves what this reads against a repository.//!//! A revision is a **base**, then **suffixes**, then perhaps a **path**://!//! <base>[<suffix>...][:<path>]//!//! where the base is a ref expression (`main`, `@`, `@{-2}`,//! `topic@{upstream}`), a reflog entry of one (`main@{2}`,//! `@{yesterday}`), `:/<text>` (a commit message search) or//! `:[<n>:]<path>` (an index entry). The rules for where each part ends//! were found by asking `git rev-parse` about texts built to tell the//! possible readings apart://!//! - **The path** begins at the first `:` that is not inside a `^{...}`//! or `@{...}` group, so `HEAD^{/a:b}:x` is the path `x` in the commit//! whose message matches `a:b`. Nothing after it is read as a suffix://! `HEAD:a~1` is the path `a~1`.//! - **Suffixes** are read from the right: `~<n>`, `^<n>`, `~`, `^`, and a//! `^{...}` group that ends the text, which runs from the last `^{`, so//! `HEAD^{/x}y}` searches for `x}y`; a kind's name in one ends at its//! first `}`, and the rest is not read (`v1^{tree}x}` is `v1^{tree}`).//! - **`@{...}`** groups are read from the right too. `@{u}`, `@{upstream}`//! and `@{push}`, in any case, name the upstream or push branch of what//! comes before; `@{-<n>}` is the `n`th branch checked out before, and//! only begins a revision; anything else is a reflog entry: a count when//! it is digits worth less than 100000000, and otherwise a date. A//! reflog entry ends the ref expression (`main@{1}@{1}` names nothing),//! and a branch that is itself a reflog entry is taken whole as a name//! (`main@{0}@{u}` asks for the upstream of a branch called//! `main@{0}`).//! - **`@`** alone is `HEAD`, in a base or before `@{...}` (`@@{1}`).//! - **`:/<text>`** takes everything after it as the pattern;//! `:/!-<text>` matches messages that do not match, `:/!!<text>` a//! pattern beginning with `!`, and any other `:/!` names nothing.//! `:<n>:<path>` takes a stage of 0 to 3; any other digit is part of//! the path.//!//! Ranges and the other ways of naming a set of commits (`A..B`,//! `A...B`, `^A`, `A^@`, `A^!`, `A^-<n>`) are not revisions; `parseSet`//! says which a text would be, leaving git's rule — a text is a range//! only when both of its sides resolve — to whoever resolves it.
const std = @import("std");const Allocator = std.mem.Allocator;
const object = @import("object.zig");const Kind = object.Kind;
/// A commit message search: `:/<text>` or `^{/<text>}`.pub const Search = struct { /// The POSIX extended regular expression. pattern: []const u8, /// `!-`: the messages that do not match. negate: bool = false,};
/// What a `^{...}` suffix peels to.pub const Peel = union(enum) { /// `^{}`: through tags to the first object that is not one. any, /// `^{object}`: the object itself, which must exist. object, /// `^{commit}`, `^{tree}`, `^{blob}`, `^{tag}`. kind: Kind, /// `^{/<text>}`: the youngest commit reachable from this one whose /// message matches. search: Search,};
/// One step from an object to another.pub const Suffix = struct { step: Step, /// Where in the revision's text the suffix ends, so that /// `text[0..end]` is the revision up to and including it, as git names /// it in its messages. end: usize,
/// What the step does. pub const Step = union(enum) { /// `^<n>`: the `n`th parent; `^0` is the commit itself. parent: u64, /// `~<n>`: the `n`th first-parent ancestor. ancestor: u64, /// `^{...}`. peel: Peel, };};
/// `@{upstream}` or `@{push}`.pub const Tracking = enum { upstream, push };
/// Where a ref expression begins.pub const Start = union(enum) { /// Nothing before an `@{...}`: the branch `HEAD` names, or `HEAD` /// itself when it names none. current, /// A name: a ref, `HEAD` (for `@` too), an object ID or an /// abbreviation of one, or `git describe` output. name: []const u8, /// `@{-<n>}`: the `n`th branch or commit checked out before this one. previous: u64,};
/// A ref expression: a start, and the upstream and push branches taken/// of it in turn.pub const RefExpression = struct { start: Start, /// Applied in order: `main@{u}@{push}` is the push branch of `main`'s /// upstream. tracking: []const Tracking = &.{}, /// The expression as written, which git names in its warnings. text: []const u8,};
/// Which reflog entry: `@{<n>}` or `@{<date>}`.pub const Selector = union(enum) { /// The `n`th prior value; 0 is the current one. nth: u64, /// The value at a time, in git's approximate date syntax. date: []const u8, /// `@{}`, which names something only after `@{-<n>}`, and only when /// that was a detached `HEAD`: the commit it was at. empty,};
/// A revision's base.pub const Base = union(enum) { /// A ref expression's value. ref: RefExpression, /// A reflog entry of a ref expression. reflog: struct { ref: RefExpression, selector: Selector }, /// `:/<text>`: the youngest commit reachable from any ref whose /// message matches. search: Search, /// `:[<n>:]<path>`: an index entry, and whether the stage was /// written. index: struct { stage: u2, path: []const u8, explicit_stage: bool = false },};
/// A revision, read. Its strings are slices of the text it was read/// from; the lists are the caller's to free with `deinit`.pub const Revision = struct { /// The whole text. text: []const u8, base: Base, /// Applied in order. suffixes: []const Suffix, /// `<rev>:<path>`: the path in the tree the rest names, as written. path: ?[]const u8 = null, /// How much of `text` names the object a path is looked up in: all of /// it when there is no path. rev_len: usize, /// What `tracking` slices live in. tracking_storage: []Tracking = &.{},
/// The part naming the object a path is looked up in, as git names it /// in its messages. pub fn revText(r: *const Revision) []const u8 { return r.text[0..r.rev_len]; }
/// Frees the lists. pub fn deinit(r: *Revision, gpa: Allocator) void { gpa.free(r.suffixes); gpa.free(r.tracking_storage); r.* = undefined; }};
/// Why a text is not a revision: git finds no object by that name,/// whatever the repository holds.pub const ParseError = error{InvalidRevision} || Allocator.Error;
/// The most digits a count in a revision may have; git's own reading of a/// longer one overflows, and a count that large names nothing.const max_count_digits = 18;
/// Reads `text` as a single revision.pub fn parse(gpa: Allocator, text: []const u8) ParseError!Revision { if (text.len == 0) return error.InvalidRevision; if (text[0] == ':') return parseColonForm(text);
const colon = splitColon(text); const rev_text = text[0 .. colon orelse text.len]; var suffixes: std.ArrayList(Suffix) = .empty; defer suffixes.deinit(gpa); const base_len = try readSuffixes(gpa, rev_text, &suffixes); if (base_len == 0) return error.InvalidRevision; std.mem.reverse(Suffix, suffixes.items);
var tracking: std.ArrayList(Tracking) = .empty; defer tracking.deinit(gpa); const base = try parseBase(gpa, rev_text[0..base_len], &tracking); // The tracking list was filled while it may have moved; point the // base's slice at its final home. const tracking_storage = try tracking.toOwnedSlice(gpa); errdefer gpa.free(tracking_storage); var fixed = base; switch (fixed) { .ref => |*r| r.tracking = tracking_storage, .reflog => |*l| l.ref.tracking = tracking_storage, else => {}, } const owned = try suffixes.toOwnedSlice(gpa); return .{ .text = text, .base = fixed, .suffixes = owned, .path = if (colon) |c| text[c + 1 ..] else null, .rev_len = rev_text.len, .tracking_storage = tracking_storage, };}
/// `:/<text>` and `:[<n>:]<path>`.fn parseColonForm(text: []const u8) ParseError!Revision { const empty: []const Suffix = &.{}; const none: Revision = .{ .text = text, .base = undefined, .suffixes = empty, .rev_len = text.len }; var r = none; if (std.mem.startsWith(u8, text, ":/")) { const rest = text[2..]; if (rest.len == 0) return error.InvalidRevision; r.base = .{ .search = try readSearch(rest) }; return r; } if (text.len == 1) return error.InvalidRevision; if (text.len >= 3 and text[2] == ':' and text[1] >= '0' and text[1] <= '3') { r.base = .{ .index = .{ .stage = @intCast(text[1] - '0'), .path = text[3..], .explicit_stage = true } }; } else { r.base = .{ .index = .{ .stage = 0, .path = text[1..] } }; } return r;}
/// A search pattern after its `/`, with the `!` modifiers read.fn readSearch(text: []const u8) ParseError!Search { if (text.len > 0 and text[0] == '!') { if (text.len >= 2 and text[1] == '-') return .{ .pattern = text[2..], .negate = true }; if (text.len >= 2 and text[1] == '!') return .{ .pattern = text[1..] }; return error.InvalidRevision; } return .{ .pattern = text };}
/// Where the path begins: the first `:` outside a `^{...}` or `@{...}`/// group.fn splitColon(text: []const u8) ?usize { var i: usize = 0; while (i < text.len) : (i += 1) { const c = text[i]; if ((c == '^' or c == '@') and i + 1 < text.len and text[i + 1] == '{') { const close = std.mem.findScalarPos(u8, text, i + 2, '}') orelse return null; i = close; continue; } if (c == ':') return i; } return null;}
/// Reads suffixes from the right of `text` into `out`, last first, and/// returns the length of what is left: the base.fn readSuffixes(gpa: Allocator, text: []const u8, out: *std.ArrayList(Suffix)) ParseError!usize { var end = text.len; while (end > 0) { const s = text[0..end]; const last = s[s.len - 1]; if (last == '}') { const caret = std.mem.findLast(u8, s[0 .. s.len - 1], "^{") orelse break; // A trailing `@{...}` group belongs to the base. if (std.mem.findLast(u8, s[0 .. s.len - 1], "@{")) |at| if (at > caret) break; const inside = s[caret + 2 .. s.len - 1]; try out.append(gpa, .{ .step = .{ .peel = try readPeel(inside) }, .end = end }); end = caret; } else if (std.ascii.isDigit(last)) { var k = s.len; while (k > 0 and std.ascii.isDigit(s[k - 1])) k -= 1; if (k == 0 or (s[k - 1] != '^' and s[k - 1] != '~')) break; const digits = s[k..]; if (digits.len > max_count_digits) return error.InvalidRevision; const n = std.fmt.parseInt(u64, digits, 10) catch return error.InvalidRevision; const step: Suffix.Step = if (s[k - 1] == '^') .{ .parent = n } else .{ .ancestor = n }; try out.append(gpa, .{ .step = step, .end = end }); end = k - 1; } else if (last == '^' or last == '~') { const step: Suffix.Step = if (last == '^') .{ .parent = 1 } else .{ .ancestor = 1 }; try out.append(gpa, .{ .step = step, .end = end }); end -= 1; } else break; } return end;}
fn readPeel(inside: []const u8) ParseError!Peel { if (inside.len == 0) return .any; if (inside[0] == '/') return .{ .search = try readSearch(inside[1..]) }; // A kind's name ends at the first `}`, and git reads no further: // `v1^{tree}x}` is `v1^{tree}`. const name = inside[0 .. std.mem.findScalar(u8, inside, '}') orelse inside.len]; if (std.mem.eql(u8, name, "object")) return .object; const kind = Kind.fromName(name) orelse return error.InvalidRevision; return .{ .kind = kind };}
/// The base: a ref expression, perhaps with a reflog entry.fn parseBase(gpa: Allocator, text: []const u8, tracking: *std.ArrayList(Tracking)) ParseError!Base { if (lastGroup(text)) |g| { const inside = text[g + 2 .. text.len - 1]; if (trackingWord(inside) == null) { if (isPrevious(inside)) { if (g != 0) return error.InvalidRevision; return .{ .ref = .{ .start = .{ .previous = try previousCount(inside) }, .text = text } }; } const selector = try readSelector(inside); const ref = try parseRef(gpa, text[0..g], tracking, false); if (selector == .empty and (ref.start != .previous or ref.tracking.len != 0 or tracking.items.len != 0)) return error.InvalidRevision; return .{ .reflog = .{ .ref = ref, .selector = selector } }; } } return .{ .ref = try parseRef(gpa, text, tracking, false) };}
/// A ref expression without a reflog entry. With `as_branch`, a text that/// ends in a reflog entry is taken whole as a name, as git takes the/// branch before `@{upstream}`.fn parseRef(gpa: Allocator, text: []const u8, tracking: *std.ArrayList(Tracking), as_branch: bool) ParseError!RefExpression { if (text.len == 0) return .{ .start = .current, .text = text }; if (lastGroup(text)) |g| { const inside = text[g + 2 .. text.len - 1]; if (trackingWord(inside)) |t| { const inner = try parseRef(gpa, text[0..g], tracking, true); try tracking.append(gpa, t); return .{ .start = inner.start, .text = text }; } if (isPrevious(inside) and g == 0) return .{ .start = .{ .previous = try previousCount(inside) }, .text = text }; if (!as_branch) return error.InvalidRevision; return .{ .start = .{ .name = text }, .text = text }; } if (std.mem.eql(u8, text, "@")) return .{ .start = .{ .name = "HEAD" }, .text = text }; return .{ .start = .{ .name = text }, .text = text };}
/// Where the `@{...}` group ending `text` begins, if one does: the last/// `@{`, with no `}` between it and the end.fn lastGroup(text: []const u8) ?usize { if (text.len < 3 or text[text.len - 1] != '}') return null; const g = std.mem.findLast(u8, text[0 .. text.len - 1], "@{") orelse return null; if (std.mem.findScalar(u8, text[g + 2 .. text.len - 1], '}') != null) return null; return g;}
fn trackingWord(inside: []const u8) ?Tracking { if (std.ascii.eqlIgnoreCase(inside, "u") or std.ascii.eqlIgnoreCase(inside, "upstream")) return .upstream; if (std.ascii.eqlIgnoreCase(inside, "push")) return .push; return null;}
fn isPrevious(inside: []const u8) bool { return inside.len >= 2 and inside[0] == '-' and allDigits(inside[1..]);}
fn previousCount(inside: []const u8) ParseError!u64 { const digits = inside[1..]; if (digits.len > max_count_digits) return error.InvalidRevision; const n = std.fmt.parseInt(u64, digits, 10) catch return error.InvalidRevision; if (n == 0) return error.InvalidRevision; return n;}
/// The counts git reads as dates: digits worth this much or more are a/// timestamp.pub const date_threshold = 100_000_000;
fn readSelector(inside: []const u8) ParseError!Selector { if (inside.len == 0) return .empty; if (allDigits(inside)) { // Leading zeros count for nothing; a value too large for a count // is a date. const value = std.fmt.parseInt(u64, inside, 10) catch return .{ .date = inside }; if (value < date_threshold) return .{ .nth = value }; } return .{ .date = inside };}
fn allDigits(text: []const u8) bool { if (text.len == 0) return false; for (text) |c| if (!std.ascii.isDigit(c)) return false; return true;}
// -- sets of commits ---------------------------------------------------------------
/// A text naming a set of commits rather than one object, as the walk/// reads it. Each side is a revision's text, empty where git takes `HEAD`.pub const Set = union(enum) { /// `<from>..<to>`. range: struct { from: []const u8, to: []const u8 }, /// `<left>...<right>`. symmetric: struct { left: []const u8, right: []const u8 }, /// `^<rev>`: what `rev` reaches, excluded. exclude: []const u8, /// `<rev>^@`: the parents of `rev`. parents: []const u8, /// `<rev>^!`: `rev`, with its parents excluded. exclude_parents: []const u8, /// `<rev>^-<n>`: `rev`, with its `n`th parent excluded. parent_range: struct { rev: []const u8, n: u64 },
/// The form's name, for saying what was refused. pub fn describe(s: Set) []const u8 { return switch (s) { .range => "a range (<rev>..<rev>)", .symmetric => "a symmetric difference (<rev>...<rev>)", .exclude => "an exclusion (^<rev>)", .parents => "the parents of a commit (<rev>^@)", .exclude_parents => "a commit without its parents (<rev>^!)", .parent_range => "a commit without one parent (<rev>^-<n>)", }; }};
/// What `text` would name if it is a set of commits, or null if it can/// only be a single revision. git takes a range only if its sides/// resolve, so a text this reads as one may still be a revision:/// `HEAD:../x` is a path, not `HEAD:` and `/x`.pub fn parseSet(text: []const u8) ?Set { if (std.mem.find(u8, text, "..")) |dots| { if (dots + 2 < text.len and text[dots + 2] == '.') return .{ .symmetric = .{ .left = text[0..dots], .right = text[dots + 3 ..] } }; return .{ .range = .{ .from = text[0..dots], .to = text[dots + 2 ..] } }; } if (text.len > 1 and text[0] == '^') return .{ .exclude = text[1..] }; if (std.mem.endsWith(u8, text, "^@") and text.len > 2) return .{ .parents = text[0 .. text.len - 2] }; if (std.mem.endsWith(u8, text, "^!") and text.len > 2) return .{ .exclude_parents = text[0 .. text.len - 2] }; if (std.mem.findLast(u8, text, "^-")) |at| if (at > 0) { const digits = text[at + 2 ..]; if (digits.len == 0) return .{ .parent_range = .{ .rev = text[0..at], .n = 1 } }; if (allDigits(digits) and digits.len <= max_count_digits) { const n = std.fmt.parseInt(u64, digits, 10) catch return null; if (n > 0) return .{ .parent_range = .{ .rev = text[0..at], .n = n } }; } }; return null;}
// -- tests -----------------------------------------------------------------------
const testing = std.testing;const fuzz_input = @import("testing/fuzz.zig");
fn expectParse(text: []const u8) !Revision { errdefer std.debug.print("{s}\n", .{text}); return parse(testing.allocator, text);}
test "suffixes are read from the right" { var r = try expectParse("HEAD~2^{tree}"); defer r.deinit(testing.allocator); try testing.expectEqualStrings("HEAD", r.base.ref.start.name); try testing.expectEqual(2, r.suffixes.len); try testing.expectEqual(2, r.suffixes[0].step.ancestor); try testing.expectEqual(Kind.tree, r.suffixes[1].step.peel.kind); try testing.expectEqualStrings("HEAD~2^{tree}", r.text[0..r.suffixes[1].end]);
var s = try expectParse("HEAD^{/x}y}"); defer s.deinit(testing.allocator); try testing.expectEqualStrings("x}y", s.suffixes[0].step.peel.search.pattern); var k = try expectParse("v1^{tree}x}"); defer k.deinit(testing.allocator); try testing.expectEqual(Kind.tree, k.suffixes[0].step.peel.kind);
var t = try expectParse("v1^^0~"); defer t.deinit(testing.allocator); try testing.expectEqual(3, t.suffixes.len); try testing.expectEqual(1, t.suffixes[0].step.parent); try testing.expectEqual(0, t.suffixes[1].step.parent); try testing.expectEqual(1, t.suffixes[2].step.ancestor);}
test "the path begins at the first colon outside a group" { var r = try expectParse("HEAD^{/a:b}:x:y"); defer r.deinit(testing.allocator); try testing.expectEqualStrings("x:y", r.path.?); try testing.expectEqualStrings("HEAD^{/a:b}", r.revText()); var d = try expectParse("main@{2030-01-01 12:00:00}:top"); defer d.deinit(testing.allocator); try testing.expectEqualStrings("top", d.path.?); try testing.expectEqualStrings("2030-01-01 12:00:00", d.base.reflog.selector.date); var e = try expectParse("HEAD:"); defer e.deinit(testing.allocator); try testing.expectEqualStrings("", e.path.?);}
test "@{...} groups are read as git reads them" { const gpa = testing.allocator; var a = try expectParse("@{-1}@{0}"); defer a.deinit(gpa); try testing.expectEqual(1, a.base.reflog.ref.start.previous); try testing.expectEqual(0, a.base.reflog.selector.nth); var b = try expectParse("main@{0}@{u}"); defer b.deinit(gpa); try testing.expectEqualStrings("main@{0}", b.base.ref.start.name); try testing.expectEqual(Tracking.upstream, b.base.ref.tracking[0]); var c = try expectParse("topic@{u}@{PUSH}@{1}"); defer c.deinit(gpa); try testing.expectEqualStrings("topic", c.base.reflog.ref.start.name); try testing.expectEqualSlices(Tracking, &.{ .upstream, .push }, c.base.reflog.ref.tracking); var d = try expectParse("@@{1}"); defer d.deinit(gpa); try testing.expectEqualStrings("HEAD", d.base.reflog.ref.start.name); var e = try expectParse("@{1700000000}"); defer e.deinit(gpa); try testing.expectEqualStrings("1700000000", e.base.reflog.selector.date); var f = try expectParse("@{0001}"); defer f.deinit(gpa); try testing.expectEqual(1, f.base.reflog.selector.nth); var g = try expectParse("@{1}~1"); defer g.deinit(gpa); try testing.expect(g.base.reflog.ref.start == .current); for ([_][]const u8{ "main@{1}@{1}", "main@{-1}", "@{-0}", "@{-1}@{-1}", "master@{}", "@{}", "@{-1}@{u}@{}", "~1", "HEAD^{bogus}", ":/!x", ":", "", ":/", "HEAD^{/!x}", "HEAD~99999999999999999999" }) |bad| { errdefer std.debug.print("{s}\n", .{bad}); try testing.expectError(error.InvalidRevision, parse(gpa, bad)); }}
test "colon forms" { const gpa = testing.allocator; var a = try expectParse(":1:a/g"); defer a.deinit(gpa); try testing.expectEqual(1, a.base.index.stage); try testing.expectEqualStrings("a/g", a.base.index.path); var b = try expectParse(":4:a/g"); defer b.deinit(gpa); try testing.expectEqualStrings("4:a/g", b.base.index.path); var c = try expectParse(":/!-alpha~1"); defer c.deinit(gpa); try testing.expect(c.base.search.negate); try testing.expectEqualStrings("alpha~1", c.base.search.pattern); var d = try expectParse(":/!!x"); defer d.deinit(gpa); try testing.expectEqualStrings("!x", d.base.search.pattern);}
test "sets of commits are told from revisions" { try testing.expectEqualStrings("a", parseSet("a..b").?.range.from); try testing.expectEqualStrings("", parseSet("...b").?.symmetric.left); try testing.expectEqualStrings("x", parseSet("^x").?.exclude); try testing.expectEqualStrings("HEAD~3", parseSet("HEAD~3^@").?.parents); try testing.expectEqualStrings("x", parseSet("x^!").?.exclude_parents); try testing.expectEqual(2, parseSet("HEAD~2^-2").?.parent_range.n); try testing.expectEqual(1, parseSet("HEAD^-").?.parent_range.n); try testing.expectEqual(null, parseSet("HEAD~2")); try testing.expectEqual(null, parseSet("^"));}
fn fuzzOne(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); _ = parseSet(input); var r = parse(testing.allocator, input) catch |err| switch (err) { error.InvalidRevision => return, else => |e| return e, }; defer r.deinit(testing.allocator); // Every suffix ends inside the revision part, in order. var last: usize = 0; for (r.suffixes) |s| { try testing.expect(s.end > last and s.end <= r.rev_len); last = s.end; } try testing.expect(r.rev_len <= r.text.len); if (r.path) |p| try testing.expectEqual(r.text.len, r.rev_len + 1 + p.len);}
test "fuzz: revisions" { try fuzz_input.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("fuzz/revision-suffixes"), @embedFile("fuzz/revision-reflog"), @embedFile("fuzz/revision-path"), @embedFile("fuzz/revision-search"), @embedFile("fuzz/revision-range"), } });}