Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! What the find bar is looking for, and, for what grebe can answer//! itself, the answer, a row at a time.//!//! As in gitk, a find is a mode — commits containing some text, touching//! some paths, adding or removing a string, or changing lines that match//! — and, for text, how it matches and in which fields. The subject and the//! author are in the history already, and are matched here as the text is//! typed. Everything else — the rest of the message, the committer, paths,//! and what the diffs do — is git's to answer, with `git log` and the same//! revision arguments; see `git/Find.zig`.
const std = @import("std");const Allocator = std.mem.Allocator;const zregex = @import("zregex");
const History = @import("../history/History.zig");
const Query = @This();
pub const Mode = enum { /// Commits whose fields contain the text. containing, /// Commits changing any of the paths, separated by spaces. touching_paths, /// Commits changing how many times the string occurs: `git log -S`. adding_removing, /// Commits with an added or removed line that matches: `git log -G`. changing_lines,
/// The message naming it. pub fn label(m: Mode) []const u8 { return switch (m) { .containing => "find-mode-containing", .touching_paths => "find-mode-touching-paths", .adding_removing => "find-mode-adding-removing", .changing_lines => "find-mode-changing-lines", }; }};
pub const Matching = enum { exact, ignore_case, regex,
/// The message naming it. pub fn label(m: Matching) []const u8 { return switch (m) { .exact => "find-matching-exact", .ignore_case => "find-matching-ignore-case", .regex => "find-matching-regex", }; }};
pub const Fields = enum { /// The subject: a message's first line. headline, /// The rest of the message too. comments, author, committer, all,
/// The message naming it. pub fn label(f: Fields) []const u8 { return switch (f) { .headline => "find-fields-headline", .comments => "find-fields-comments", .author => "find-fields-author", .committer => "find-fields-committer", .all => "find-fields-all", }; }};
text: []const u8 = "",mode: Mode = .containing,matching: Matching = .exact,fields: Fields = .all,
/// Whether grebe matches this itself, from the history, rather than/// asking git.pub fn local(q: Query) bool { return q.mode == .containing and (q.fields == .headline or q.fields == .author);}
/// The text matched, compiled; for a local query.pub const Matcher = struct { gpa: Allocator, query: Query, regex: ?zregex.Regex = null, /// The text lowercased, for ignoring case. folded: []u8 = &.{},
pub const Error = Allocator.Error || error{InvalidPattern};
pub fn init(gpa: Allocator, q: Query) Error!Matcher { var m: Matcher = .{ .gpa = gpa, .query = q }; switch (q.matching) { .exact => {}, .ignore_case => m.folded = try std.ascii.allocLowerString(gpa, q.text), .regex => m.regex = zregex.Regex.compile(gpa, q.text) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.InvalidPattern, }, } return m; }
pub fn deinit(m: *Matcher) void { if (m.regex) |*r| r.deinit(); m.gpa.free(m.folded); m.* = undefined; }
/// Whether `s` matches. pub fn matchesText(m: *const Matcher, s: []const u8) bool { if (m.query.text.len == 0) return false; return switch (m.query.matching) { .exact => std.mem.find(u8, s, m.query.text) != null, .ignore_case => std.ascii.findIgnoreCase(s, m.folded) != null, // A search the engine gives up on is no match. .regex => m.regex.?.isMatch(m.gpa, s) catch false, }; }
/// Whether the commit in `row` matches. pub fn matchesRow(m: *const Matcher, h: *const History, row: usize) bool { const c = h.get(row); if (c.pseudo != .none) return false; return switch (m.query.fields) { .headline => m.matchesText(h.text(c.subject)), .author => blk: { const p = h.person(c.author); break :blk m.matchesText(h.text(p.name)) or m.matchesText(h.text(p.email)); }, else => false, }; }};
/// What `git log` is run with to find this, once or more: options, or for/// paths, `--` and the paths. Each run's matches are added to the last's./// They are allocated with `a`, or point into `q.text`.////// "All fields" is three runs, since git takes a commit matching `--grep`/// and `--author` together only if it matches both.pub fn gitArgs(q: Query, a: Allocator) Allocator.Error![]const []const []const u8 { var runs: std.ArrayList([]const []const u8) = .empty; const regex = q.matching == .regex; switch (q.mode) { .containing => { const flavor: []const []const u8 = if (regex) &.{"--extended-regexp"} else if (q.matching == .ignore_case) &.{ "--fixed-strings", "--regexp-ignore-case" } else &.{"--fixed-strings"}; const grep = try std.fmt.allocPrint(a, "--grep={s}", .{q.text}); const author = try std.fmt.allocPrint(a, "--author={s}", .{q.text}); const committer = try std.fmt.allocPrint(a, "--committer={s}", .{q.text}); const which: []const []const u8 = switch (q.fields) { .headline, .comments => &.{grep}, .author => &.{author}, .committer => &.{committer}, .all => &.{ grep, author, committer }, }; for (which) |w| try runs.append(a, try std.mem.concat(a, []const u8, &.{ flavor, &.{w} })); }, else => { var out: std.ArrayList([]const u8) = .empty; try q.oneRun(a, &out); try runs.append(a, out.items); }, } return runs.items;}
fn oneRun(q: Query, a: Allocator, out: *std.ArrayList([]const u8)) Allocator.Error!void { const regex = q.matching == .regex; switch (q.mode) { .containing => unreachable, .adding_removing => { try out.append(a, try std.fmt.allocPrint(a, "-S{s}", .{q.text})); if (regex) try out.append(a, "--pickaxe-regex"); }, .changing_lines => try out.append(a, try std.fmt.allocPrint(a, "-G{s}", .{q.text})), .touching_paths => { try out.append(a, "--"); var it = std.mem.tokenizeScalar(u8, q.text, ' '); while (it.next()) |p| try out.append(a, p); }, }}
// ---------------------------------------------------------------------------
const testing = std.testing;
fn testHistory(h: *History) !void { var id: History.ObjectId = .{ .len = 20 }; id.bytes[0] = 1; try h.append(.{ .id = id, .parents = &.{}, .author_name = "Ada Lovelace", .author_email = "ada@example.com", .time = 0, .offset = 0, .subject = "Fix the Analytical Engine" }); id.bytes[0] = 2; try h.append(.{ .id = id, .parents = &.{}, .author_name = "Grace Hopper", .author_email = "grace@navy.mil", .time = 0, .offset = 0, .subject = "Find the moth" });}
test "exact, ignoring case, and by regex, in the headline and the author" { var h: History = .init(testing.allocator); defer h.deinit(); try testHistory(&h);
const cases = [_]struct { q: Query, rows: [2]bool }{ .{ .q = .{ .text = "Engine", .fields = .headline }, .rows = .{ true, false } }, .{ .q = .{ .text = "engine", .fields = .headline }, .rows = .{ false, false } }, .{ .q = .{ .text = "engine", .fields = .headline, .matching = .ignore_case }, .rows = .{ true, false } }, .{ .q = .{ .text = "^F", .fields = .headline, .matching = .regex }, .rows = .{ true, true } }, .{ .q = .{ .text = "m[ao]th$", .fields = .headline, .matching = .regex }, .rows = .{ false, true } }, .{ .q = .{ .text = "navy", .fields = .author }, .rows = .{ false, true } }, .{ .q = .{ .text = "", .fields = .headline }, .rows = .{ false, false } }, }; for (cases) |c| { try testing.expect(c.q.local()); var m = try Matcher.init(testing.allocator, c.q); defer m.deinit(); for (c.rows, 0..) |want, row| try testing.expectEqual(want, m.matchesRow(&h, row)); }}
test "a pattern that is not one says so" { try testing.expectError(error.InvalidPattern, Matcher.init(testing.allocator, .{ .text = "(", .matching = .regex, .fields = .headline }));}
test "what git is asked" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); try testing.expect(!(Query{ .text = "x" }).local()); const all = try gitArgs(.{ .text = "x", .matching = .ignore_case }, a); try testing.expectEqual(@as(usize, 3), all.len); try testing.expectEqualStrings("--fixed-strings", all[0][0]); try testing.expectEqualStrings("--regexp-ignore-case", all[0][1]); try testing.expectEqualStrings("--author=x", all[1][2]); const paths = (try gitArgs(.{ .text = "src/a.zig docs", .mode = .touching_paths }, a))[0]; try testing.expectEqualSlices(u8, "--", paths[0]); try testing.expectEqual(@as(usize, 3), paths.len); const pick = (try gitArgs(.{ .text = "fn main", .mode = .adding_removing, .matching = .regex }, a))[0]; try testing.expectEqualStrings("-Sfn main", pick[0]); try testing.expectEqualStrings("--pickaxe-regex", pick[1]);}