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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Decorations: the names `git log --decorate`, `%d` and `%D` show beside//! a commit — the refs that point at it, `HEAD`, and the commits that are//! grafted or replaced. git-log(1) describes `--decorate`,//! `--decorate-refs`, `--decorate-refs-exclude` and `--clear-decorations`;//! the rest was found by experiment://!//! - **Which refs**: by default `HEAD` and the refs under `refs/heads/`,//! `refs/remotes/`, `refs/stash`, `refs/tags/` and the replace refs'//! base. With `--clear-decorations` (or `log.initialDecorationSet=all`),//! or any pattern that excludes, every ref. With `--decorate-refs`, only the refs a pattern matches,//! and `log.excludeDecoration` is not consulted for them; less, always,//! those `--decorate-refs-exclude` matches. A grafted or shallow commit//! is decorated whatever the patterns say.//! - **Patterns** (`patternMatches`): a ref named exactly as written//! matches; otherwise the pattern has `refs/` put before it unless it//! begins so, and then, holding a glob character, is matched as a glob//! in which `*` crosses `/`, or, holding none, matches that ref and//! every ref below it.//! - **What is decorated**: the object a ref points at, and, through//! annotated tags, every object it peels to, a tag of a tag included. A//! replace ref decorates the object it replaces, as `replaced`.//! - **Order**: the reverse of the order the refs are listed in, with//! `HEAD` before them and `grafted` before that. When `HEAD` points at a//! branch that also decorates the commit, the two are one decoration,//! `HEAD -> <branch>`, where `HEAD` stands.//! - **Names**: shortened by taking off `refs/heads/`, `refs/tags/` or//! `refs/remotes/` (`--decorate=short`), or kept whole (`full`).
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const object = @import("object.zig");const Id = object.Id;const Diagnostics = @import("Diagnostics.zig");const ObjectDatabase = @import("ObjectDatabase.zig");const RefStore = @import("RefStore.zig");const Grafts = @import("Grafts.zig");const Replacements = @import("Replacements.zig");const Tag = @import("tag.zig").Tag;const wildmatch = @import("wildmatch.zig");
const Decorations = @This();
arena: std.heap.ArenaAllocator,/// Each decorated object's decorations, in the order they are shown.map: std.AutoHashMapUnmanaged([object.max_byte_length]u8, []const Decoration) = .empty,
/// What a decoration is, which says how it is written and colored.pub const Kind = enum { /// `HEAD`. head, /// A ref under `refs/heads/`. branch, /// A ref under `refs/remotes/`. remote_branch, /// A ref under `refs/tags/`, written after `tag: `. tag, /// `refs/stash`. stash, /// Any other ref. ref, /// A grafted or shallow commit. grafted, /// A replaced object. replaced,};
/// One name beside an object.pub const Decoration = struct { kind: Kind, /// As it is shown: shortened or whole. name: []const u8, /// For `HEAD` pointing at a branch that decorates the same object, the /// branch as it is shown, written after `HEAD -> `. target: ?[]const u8 = null,};
/// Which refs decorate, as the options and settings say.pub const Filter = struct { /// `--decorate-refs`, in order. include: []const []const u8 = &.{}, /// `--decorate-refs-exclude`, in order. exclude: []const []const u8 = &.{}, /// `log.excludeDecoration`, which `include` overrides. config_exclude: []const []const u8 = &.{}, /// `--clear-decorations` or `log.initialDecorationSet=all`: every ref /// when nothing is included. all: bool = false,
/// Whether the ref `name` decorates; `replace_base` is the replace /// refs' base, which the default set holds. pub fn accepts(f: Filter, name: []const u8, replace_base: ?[]const u8) bool { for (f.exclude) |p| if (patternMatches(p, name)) return false; if (f.include.len != 0) { for (f.include) |p| if (patternMatches(p, name)) return true; return false; } for (f.config_exclude) |p| if (patternMatches(p, name)) return false; // Any pattern at all, or the setting, leaves every ref in. if (f.all or f.exclude.len != 0 or f.config_exclude.len != 0) return true; if (std.mem.eql(u8, name, "HEAD") or std.mem.eql(u8, name, "refs/stash")) return true; for ([_][]const u8{ "refs/heads/", "refs/remotes/", "refs/tags/", "refs/stash/" }) |prefix| if (std.mem.startsWith(u8, name, prefix)) return true; if (replace_base) |b| if (std.mem.startsWith(u8, name, b)) return true; return false; }};
/// Whether `pattern`, as `--decorate-refs` takes it, matches the ref/// `name`.pub fn patternMatches(pattern: []const u8, name: []const u8) bool { if (std.mem.eql(u8, pattern, name)) return true; var buffer: [4096]u8 = undefined; const full = if (std.mem.startsWith(u8, pattern, "refs/")) pattern else std.fmt.bufPrint(&buffer, "refs/{s}", .{pattern}) catch return false; if (std.mem.findAny(u8, full, "*?[") != null) return wildmatch.match(full, name, .{}); if (!std.mem.startsWith(u8, name, full)) return false; return name.len == full.len or full[full.len - 1] == '/' or name[full.len] == '/';}
/// How decorations are found and named.pub const Options = struct { /// `--decorate=full`: names kept whole. full: bool = false, filter: Filter = .{}, /// The replace refs in use, if any, whose refs decorate the objects /// they replace. replacements: ?*const Replacements = null, /// Their base, `refs/replace/` unless `GIT_REPLACE_REF_BASE` says /// otherwise. replace_base: []const u8 = Replacements.default_base, /// The grafted and shallow commits, decorated as `grafted`. grafts: ?*const Grafts = null,};
/// The name a ref is shown by: shortened unless `full`.pub fn shortName(name: []const u8, full: bool) []const u8 { if (full) return name; for ([_][]const u8{ "refs/heads/", "refs/tags/", "refs/remotes/" }) |prefix| if (std.mem.startsWith(u8, name, prefix)) return name[prefix.len..]; return name;}
fn kindOf(name: []const u8) Kind { if (std.mem.eql(u8, name, "HEAD")) return .head; if (std.mem.startsWith(u8, name, "refs/heads/")) return .branch; if (std.mem.startsWith(u8, name, "refs/remotes/")) return .remote_branch; if (std.mem.startsWith(u8, name, "refs/tags/")) return .tag; if (std.mem.eql(u8, name, "refs/stash")) return .stash; return .ref;}
/// Why decorations could not be found: the refs could not be listed.pub const LoadError = RefStore.ReadError;
/// One decoration as it is collected, before `HEAD` is joined to its/// branch.const Pending = struct { decoration: Decoration, /// The ref's whole name, to find `HEAD`'s branch by. full: []const u8, /// For `HEAD`, the ref it points at. head_target: ?[]const u8 = null,};
/// Finds every decoration in `refs`, peeling tags through `db`.pub fn load(gpa: Allocator, io: Io, refs: RefStore, db: ObjectDatabase, options: Options, diag: ?*Diagnostics) LoadError!Decorations { var d: Decorations = .{ .arena = .init(gpa) }; errdefer d.deinit(); const a = d.arena.allocator(); var pending: std.AutoHashMapUnmanaged([object.max_byte_length]u8, std.ArrayList(Pending)) = .empty;
const replace_base: ?[]const u8 = if (options.replacements != null) options.replace_base else null; var list = try refs.list(io, gpa, "", diag); defer list.deinit(); for (list.entries) |e| { if (!options.filter.accepts(e.name, replace_base)) continue; const id = resolveEntry(io, gpa, refs, e) orelse continue; const full = try a.dupe(u8, e.name); if (replace_base) |b| if (std.mem.startsWith(u8, e.name, b)) { const original = Replacements.parseName(id.object_format, e.name[b.len..]) orelse continue; try add(a, &pending, original, .{ .decoration = .{ .kind = .replaced, .name = "replaced" }, .full = full }); continue; }; const decoration: Decoration = .{ .kind = kindOf(e.name), .name = shortName(full, options.full) }; try addPeeled(a, io, gpa, db, &pending, id, .{ .decoration = decoration, .full = full }); }
// `HEAD`, after the refs, so before them once reversed. if (options.filter.accepts("HEAD", replace_base)) head: { var resolved = refs.resolve(io, gpa, "HEAD", null) catch break :head; defer resolved.deinit(gpa); const id = switch (resolved.outcome) { .direct => |x| x.id, else => break :head, }; const target: ?[]const u8 = if (resolved.isSymbolic()) try a.dupe(u8, resolved.last()) else null; try addPeeled(a, io, gpa, db, &pending, id, .{ .decoration = .{ .kind = .head, .name = "HEAD" }, .full = "HEAD", .head_target = target }); }
if (options.grafts) |g| { var it = g.map.keyIterator(); while (it.next()) |k| { const id: Id = .{ .object_format = g.format, .bytes = k.* }; try add(a, &pending, id, .{ .decoration = .{ .kind = .grafted, .name = "grafted" }, .full = "" }); } }
// Reverse each list, and join `HEAD` to the branch it points at. var it = pending.iterator(); while (it.next()) |entry| { const items = entry.value_ptr.items; std.mem.reverse(Pending, items); var out: std.ArrayList(Decoration) = .empty; for (items) |p| { if (p.decoration.kind == .head) { var dec = p.decoration; if (p.head_target) |t| for (items) |q| { if (q.decoration.kind != .head and std.mem.eql(u8, q.full, t)) { dec.target = q.decoration.name; break; } }; try out.append(a, dec); continue; } const joined = for (items) |h| { if (h.decoration.kind == .head) if (h.head_target) |t| if (std.mem.eql(u8, t, p.full)) break true; } else false; if (joined) continue; try out.append(a, p.decoration); } try d.map.put(a, entry.key_ptr.*, out.items); } return d;}
fn resolveEntry(io: Io, gpa: Allocator, refs: RefStore, e: RefStore.Entry) ?Id { return switch (e.value) { .direct => |x| x.id, .symbolic => blk: { var resolved = refs.resolve(io, gpa, e.name, null) catch break :blk null; defer resolved.deinit(gpa); break :blk switch (resolved.outcome) { .direct => |x| x.id, else => null, }; }, .broken => null, };}
fn add(a: Allocator, pending: *std.AutoHashMapUnmanaged([object.max_byte_length]u8, std.ArrayList(Pending)), id: Id, p: Pending) Allocator.Error!void { const gop = try pending.getOrPut(a, id.bytes); if (!gop.found_existing) gop.value_ptr.* = .empty; try gop.value_ptr.append(a, p);}
/// Decorates `id` and every object it peels to through annotated tags.fn addPeeled(a: Allocator, io: Io, gpa: Allocator, db: ObjectDatabase, pending: anytype, id: Id, p: Pending) Allocator.Error!void { var current = id; for (0..RefStore.max_peel_depth) |_| { try add(a, pending, current, p); const header = db.readHeader(io, current, null) catch return; if (header.kind != .tag) return; var o = db.read(io, gpa, current, null) catch return; defer o.deinit(gpa); current = Tag.targetOf(current.object_format, o.data) orelse return; }}
/// Frees everything.pub fn deinit(d: *Decorations) void { d.arena.deinit(); d.* = undefined;}
/// The decorations of `id`, in the order they are shown; empty for none.pub fn of(d: *const Decorations, id: Id) []const Decoration { return d.map.get(id.bytes) orelse &.{};}
// -- tests -----------------------------------------------------------------------
const testing = std.testing;const fuzz_input = @import("testing/fuzz.zig");
test "fuzz: decoration patterns" { try fuzz_input.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [512]u8 = undefined; const text = fuzz_input.bytes(smith, &buffer); // Every pair of lines: a pattern and a ref name. var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |pattern| { const name = lines.next() orelse break; const matched = patternMatches(pattern, name); // A pattern always matches the ref it names exactly. if (std.mem.eql(u8, pattern, name)) try testing.expect(matched); const f: Filter = .{ .include = &.{pattern}, .exclude = &.{name} }; if (std.mem.eql(u8, pattern, name)) try testing.expect(!f.accepts(name, null)); } } }.one, .{ .corpus = &.{@embedFile("fuzz/decorate-patterns")} });}
test "decoration patterns match refs as git matches them" { const yes = [_][2][]const u8{ .{ "heads", "refs/heads/main" }, .{ "refs/heads", "refs/heads/nested/b" }, .{ "refs/heads/", "refs/heads/main" }, .{ "refs/heads/*", "refs/heads/nested/b" }, .{ "refs/heads/side", "refs/heads/side" }, .{ "heads/nested", "refs/heads/nested/b" }, .{ "refs/heads/?ide", "refs/heads/side" }, .{ "stash", "refs/stash" }, .{ "tags/*", "refs/tags/v1" }, .{ "**", "refs/notes/commits" }, .{ "", "refs/other/x" }, .{ "HEAD", "HEAD" }, }; for (yes) |c| try testing.expect(patternMatches(c[0], c[1])); const no = [_][2][]const u8{ .{ "side", "refs/heads/side" }, .{ "refs/heads/sid", "refs/heads/side" }, .{ "refs", "refs/heads/main" }, .{ "origin/*", "refs/remotes/origin/main" }, .{ "HEA*", "HEAD" }, .{ "[r]efs/heads", "refs/heads/main" }, .{ "", "HEAD" }, }; for (no) |c| try testing.expect(!patternMatches(c[0], c[1]));}
test "the filter chooses refs as git chooses them" { const default: Filter = .{}; try testing.expect(default.accepts("HEAD", null)); try testing.expect(default.accepts("refs/stash", null)); try testing.expect(!default.accepts("refs/notes/commits", null)); try testing.expect(default.accepts("refs/replace/x", "refs/replace/")); const all: Filter = .{ .all = true }; try testing.expect(all.accepts("refs/notes/commits", null)); const config: Filter = .{ .config_exclude = &.{"refs/tags"}, .include = &.{"refs/tags/light"} }; try testing.expect(config.accepts("refs/tags/light", null)); try testing.expect(!config.accepts("refs/heads/main", null)); const excluding: Filter = .{ .exclude = &.{"refs/heads"} }; try testing.expect(excluding.accepts("refs/notes/commits", null)); try testing.expect(!excluding.accepts("refs/heads/main", null)); const both: Filter = .{ .include = &.{"refs/heads"}, .exclude = &.{"refs/heads/side"} }; try testing.expect(!both.accepts("refs/heads/side", null)); try testing.expect(both.accepts("refs/heads/main", null));}