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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Choosing commits by their messages and identities, as `git log`'s//! `--grep`, `--author` and `--committer` do, with `--all-match`,//! `--invert-grep`, `-i` and the pattern dialects `--basic-regexp`, `-E`//! and `-F`.//!//! git-log(1) says what each option is for; how they combine was found by//! asking `git log`://!//! - **Messages** are matched a line at a time, and the blank line//! between a commit's headers and its message is one of the lines, so//! that `^$` matches every commit. Several `--grep` patterns match a//! commit when any does, or with `--all-match` when every one does.//! - **Identities** are matched against the `author` or `committer` line's//! name and email address, `Name <email>`, without its date, after the//! mailmap has mapped them when one is given. Several `--author`//! patterns match when any does, and so do several `--committer`//! patterns; `--all-match` changes neither.//! - A commit is kept when its author, committer and message each match//! whatever patterns there are for them. `--invert-grep` keeps instead//! those whose message no `--grep` pattern matches, whatever//! `--all-match` says, and leaves the identity patterns as they are.//! - Patterns are glibc's basic or extended regular expressions with//! GNU's escapes (`regex.zig`), or fixed strings, matched without regard//! to the case of ASCII letters with `-i`. Perl's (`-P`) are refused//! (`UnsupportedPatternType`).
const std = @import("std");const Allocator = std.mem.Allocator;
const regex = @import("regex.zig");const Commit = @import("commit.zig").Commit;const Identity = @import("Identity.zig");const Mailmap = @import("Mailmap.zig");const object = @import("object.zig");
/// How patterns are read: `grep.patternType`, `--basic-regexp`,/// `--extended-regexp`, `--fixed-strings` and `--perl-regexp`.pub const PatternType = enum { basic, extended, fixed, perl };
/// The patterns and how they combine.pub const Options = struct { pattern_type: PatternType = .basic, /// `-i`. ignore_case: bool = false, /// `--all-match`. all_match: bool = false, /// `--invert-grep`. invert: bool = false, /// `--grep`. messages: []const []const u8 = &.{}, /// `--author`. authors: []const []const u8 = &.{}, /// `--committer`. committers: []const []const u8 = &.{},};
/// What compiling the patterns can fail with: a malformed pattern, which/// `Failure` names; one using what `regex.zig` does not do; and Perl's/// patterns.pub const Error = error{ InvalidPattern, Unsupported, UnsupportedPatternType } || Allocator.Error;
/// Which pattern failed to compile, and why.pub const Failure = struct { pattern: []const u8 = "", /// Whether it was an identity's rather than a message's, which git /// names as `header` rather than `command line`. header: bool = false, regex: regex.Failure = .{},};
const Pattern = union(enum) { regex: regex.Regex, fixed: []const u8,};
/// Compiled patterns.pub const Grep = struct { arena: std.heap.ArenaAllocator, options: Options, messages: []Pattern, authors: []Pattern, committers: []Pattern,
/// Compiles the patterns `options` gives. pub fn init(gpa: Allocator, options: Options, failure: ?*Failure) Error!Grep { if (options.pattern_type == .perl) return error.UnsupportedPatternType; var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); var g: Grep = .{ .arena = undefined, .options = options, .messages = &.{}, .authors = &.{}, .committers = &.{} }; // git compiles the header patterns first. g.authors = try compileAll(gpa, a, options, options.authors, true, failure); errdefer deinitAll(g.authors); g.committers = try compileAll(gpa, a, options, options.committers, true, failure); errdefer deinitAll(g.committers); g.messages = try compileAll(gpa, a, options, options.messages, false, failure); g.arena = arena; return g; }
/// Frees the patterns. pub fn deinit(g: *Grep) void { deinitAll(g.messages); deinitAll(g.authors); deinitAll(g.committers); g.arena.deinit(); g.* = undefined; }
/// Whether there are any patterns at all. pub fn any(g: *const Grep) bool { return g.messages.len != 0 or g.authors.len != 0 or g.committers.len != 0; }
/// Whether `contents`, a commit's, is kept; identities are mapped /// through `mailmap` first when it is given. pub fn matches(g: *const Grep, gpa: Allocator, format: object.Format, contents: []const u8, mailmap: ?*const Mailmap) Allocator.Error!bool { const commit = Commit.parse(format, contents) catch return false; return g.matchesCommit(gpa, commit, mailmap); }
/// Whether the searches keep `commit`, as `matches` says. pub fn matchesCommit(g: *const Grep, gpa: Allocator, commit: Commit, mailmap: ?*const Mailmap) Allocator.Error!bool { if (g.authors.len != 0) { if (!try g.identityMatches(gpa, g.authors, commit.author(), headerLine(commit.headers, "author"), mailmap)) return false; } if (g.committers.len != 0) { if (!try g.identityMatches(gpa, g.committers, commit.committer(), headerLine(commit.headers, "committer"), mailmap)) return false; } if (g.messages.len == 0) return true; if (g.options.invert) { for (g.messages) |*p| if (try g.messageMatches(gpa, p, commit.message)) return false; return true; } if (g.options.all_match) { for (g.messages) |*p| if (!try g.messageMatches(gpa, p, commit.message)) return false; return true; } for (g.messages) |*p| if (try g.messageMatches(gpa, p, commit.message)) return true; return false; }
fn identityMatches(g: *const Grep, gpa: Allocator, patterns: []Pattern, id: ?Identity, raw: ?[]const u8, mailmap: ?*const Mailmap) Allocator.Error!bool { var buffer: std.ArrayList(u8) = .empty; defer buffer.deinit(gpa); const text: []const u8 = if (id) |i| text: { const m: Mailmap.Mapped = if (mailmap) |mm| mm.map(i.name, i.email) else .{ .name = i.name, .email = i.email }; try buffer.print(gpa, "{s} <{s}>", .{ m.name, m.email }); break :text buffer.items; } else raw orelse return false; for (patterns) |*p| if (try g.lineMatches(gpa, p, text)) return true; return false; }
fn messageMatches(g: *const Grep, gpa: Allocator, p: *const Pattern, message: []const u8) Allocator.Error!bool { // The blank line before the message is one of the lines. if (try g.lineMatches(gpa, p, "")) return true; var lines = std.mem.splitScalar(u8, message, '\n'); while (lines.next()) |line| if (try g.lineMatches(gpa, p, line)) return true; return false; }
fn lineMatches(g: *const Grep, gpa: Allocator, p: *const Pattern, line: []const u8) Allocator.Error!bool { switch (p.*) { .regex => |*r| return r.matches(gpa, line), .fixed => |f| { if (!g.options.ignore_case) return std.mem.find(u8, line, f) != null; return std.ascii.findIgnoreCase(line, f) != null; }, } }};
/// The text of the first header `name`, for an identity line git cannot/// read.fn headerLine(headers: []const u8, name: []const u8) ?[]const u8 { var lines = std.mem.splitScalar(u8, headers, '\n'); while (lines.next()) |line| { if (line.len > name.len and std.mem.startsWith(u8, line, name) and line[name.len] == ' ') return line[name.len + 1 ..]; } return null;}
fn compileAll(gpa: Allocator, a: Allocator, options: Options, texts: []const []const u8, header: bool, failure: ?*Failure) Error![]Pattern { const out = try a.alloc(Pattern, texts.len); var done: usize = 0; errdefer deinitAll(out[0..done]); for (texts) |text| { if (options.pattern_type == .fixed) { out[done] = .{ .fixed = text }; } else { var f: regex.Failure = .{}; const r = regex.Regex.compileWith(gpa, text, .{ .syntax = if (options.pattern_type == .extended) .extended else .basic, .ignore_case = options.ignore_case, .newline = true, .gnu = true, }, &f) catch |err| { if (failure) |fl| fl.* = .{ .pattern = text, .header = header, .regex = f }; return err; }; out[done] = .{ .regex = r }; } done += 1; } return out;}
fn deinitAll(patterns: []Pattern) void { for (patterns) |*p| switch (p.*) { .regex => |*r| r.deinit(), .fixed => {}, };}
const testing = std.testing;
test "patterns combine as git log's do" { const gpa = testing.allocator; const commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Alice Smith <alice@x> 1700000000 +0100\ncommitter Carol <carol@y> 1700000500 -0230\n\nfirst line\nsecond Line\n\nbody\n"; const Case = struct { Options, bool }; const cases = [_]Case{ .{ .{ .authors = &.{"alice"} }, true }, .{ .{ .authors = &.{"ALICE"} }, false }, .{ .{ .authors = &.{"ALICE"}, .ignore_case = true }, true }, .{ .{ .authors = &.{"1700000000"} }, false }, .{ .{ .authors = &.{"x>$"} }, true }, .{ .{ .committers = &.{"^Carol <carol@y>$"} }, true }, .{ .{ .messages = &.{"^second"} }, true }, .{ .{ .messages = &.{"line.second"} }, false }, .{ .{ .messages = &.{"^$"} }, true }, .{ .{ .messages = &.{ "body", "nothing" }, .all_match = true }, false }, .{ .{ .messages = &.{ "body", "nothing" } }, true }, .{ .{ .messages = &.{"body"}, .invert = true }, false }, .{ .{ .messages = &.{ "body", "nothing" }, .invert = true, .all_match = true }, false }, .{ .{ .messages = &.{"a|b"} }, false }, .{ .{ .messages = &.{"e|b"}, .pattern_type = .extended }, true }, .{ .{ .messages = &.{"Line$"}, .pattern_type = .fixed }, false }, .{ .{ .messages = &.{"LINE"}, .pattern_type = .fixed, .ignore_case = true }, true }, .{ .{ .messages = &.{"body"}, .authors = &.{"Bob"} }, false }, }; for (cases) |case| { var g = try Grep.init(gpa, case[0], null); defer g.deinit(); try testing.expectEqual(case[1], try g.matches(gpa, .sha1, commit, null)); }}