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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `zig-git show` and `zig-git check-mailmap`; `zig-git log` is//! `rev_list.zig`'s walk, described as log describes commits.//!//! zig-git show [<option>...] [<object>...]//! zig-git check-mailmap [--stdin] [--mailmap-file=<file>] [--mailmap-blob=<blob>] <contact>...//!//! `show` shows each object as git shows it: a commit in the format asked//! for, with its diff against its parent (see `log_diff.zig`; a merge's//! combined diff is refused, `--first-parent` shows its first parent's); an//! annotated tag's tagger and message, and then the object it tags; a//! tree's entries; a blob's contents. Between them it writes the blank//! lines git writes, which a blob neither writes nor asks for. A range or//! any other set of commits, which git would walk, is refused by name.//!//! `check-mailmap` maps each contact through the repository's mailmap as//! `git check-mailmap` does: `Name <email>`, `<email>` or a bare email//! address, written back as `Name <email>` or `<email>`.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const git = @import("git");const ObjectId = git.ObjectId;const Diagnostics = git.Diagnostics;const tool = @import("zig_git.zig");const Context = tool.Context;const discovery = @import("discovery.zig");const object_names = @import("object_names.zig");const revisions = @import("revisions.zig");const describe = @import("describe.zig");const log_diff = @import("log_diff.zig");const line_prefix = @import("line_prefix.zig");
/// Runs `show` with `args`, the arguments after its name.pub fn show(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { // --line-prefix puts its prefix before every line `show` writes. if (line_prefix.find(args)) |prefix| { var buffer: [256]u8 = undefined; var lp: line_prefix.LinePrefix = .init(stdout, prefix, &buffer); defer lp.writer.flush() catch {}; return showPrefixed(ctx, args, &lp.writer, stderr); } return showPrefixed(ctx, args, stdout, stderr);}
fn showPrefixed(ctx: Context, args: []const []const u8, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var fmt: describe.Options = .{}; defer fmt.deinit(gpa); var no_patch = false; _ = &no_patch; var first_parent = false; var diff_args: log_diff.Collected = .{}; defer diff_args.deinit(gpa); var names_given: std.ArrayList([]const u8) = .empty; defer names_given.deinit(gpa); var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (std.mem.eql(u8, arg, "--")) { if (i + 1 < args.len) return tool.fatalMessage(stderr, "zig-git show: paths are not supported", .{}); break; } if (arg.len < 2 or arg[0] != '-') { try names_given.append(gpa, arg); continue; } if (describe.isUnsupported(arg)) return tool.fatalMessage(stderr, "zig-git show: '{s}' is not supported", .{arg}); if (std.mem.eql(u8, arg, "--first-parent")) { first_parent = true; continue; } // The diff options, kept for once the configuration is read; some // mean something to show as well. const took = try diff_args.take(gpa, .show, arg, if (i + 1 < args.len) args[i + 1] else null, stderr); if (took != 0) { const shared = std.mem.startsWith(u8, arg, "--abbrev") or std.mem.eql(u8, arg, "--no-abbrev") or std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--no-patch") or std.mem.startsWith(u8, arg, "--color") or std.mem.eql(u8, arg, "--no-color") or std.mem.eql(u8, arg, "-i") or std.mem.eql(u8, arg, "--regexp-ignore-case"); if (!shared) { i += took - 1; continue; } } switch (try describe.parseOption(&fmt, gpa, arg, if (i + 1 < args.len) args[i + 1] else null, stderr)) { .yes => continue, .took_next => { i += 1; continue; }, .no => {}, } const Option = enum { @"-s", @"--no-patch", @"--no-decorate", @"--no-notes", @"--no-walk", @"--no-show-signature" }; const o = std.meta.stringToEnum(Option, arg) orelse return tool.fatalMessage(stderr, "zig-git show: '{s}' is not supported", .{arg}); if (o == .@"-s" or o == .@"--no-patch") no_patch = true; } if (fmt.messages.items.len != 0 or fmt.authors.items.len != 0 or fmt.committers.items.len != 0) return tool.fatalMessage(stderr, "zig-git show: searching is not supported", .{}); if (names_given.items.len == 0) try names_given.append(gpa, "HEAD");
var repo = try discovery.open(ctx, stderr); defer repo.close(ctx.io); const settings = try object_names.settings(&repo.config, stderr); var names: object_names.Names = .init(ctx, &repo, settings); defer names.deinit(); var revs = try revisions.Revisions.init(ctx, &repo, settings, .rev_parse, stderr); defer revs.deinit(); revs.resolver.options.sets = false; var d = try describe.Describer.init(ctx, &repo, &names, &revs, &fmt, .show, stderr); defer d.deinit(); const diffs = try log_diff.Writer.init(ctx, &repo, &revs, &names, settings, &diff_args, .show, &git.Pathspec.empty, first_parent, true, stderr); defer if (diffs) |dw| dw.deinit();
// Each name resolved first, as git resolves them all before showing // any. var objects: std.ArrayList(ObjectId) = .empty; defer objects.deinit(gpa); for (names_given.items) |text| { if (git.revision.parseSet(text) != null) return tool.fatalMessage(stderr, "zig-git show: '{s}' names a set of commits, which is not supported", .{text}); var diag: Diagnostics = .{}; const found = revs.resolver.resolve(text, &diag) catch |err| { try revs.flushMessages(stderr, 1, false); try revisions.stopAt(err, &diag, false, revs.resolver.loud, stderr); if (git.Resolver.severity(err) == .diagnosed) return tool.fatalMessage(stderr, "{s}", .{diag.detail()}); try stderr.print( \\fatal: ambiguous argument '{s}': unknown revision or path not in the working tree. \\Use '--' to separate paths from revisions, like this: \\'git <command> [<revision>...] -- [<file>...]' \\ , .{text}); return error.Fatal; }; try revs.flushMessages(stderr, 1, false); try objects.append(gpa, found.id); }
var shown_one = false; for (objects.items, names_given.items, 0..) |id, text, k| { // An object given twice is shown once. const again = for (objects.items[0..k]) |earlier| { if (earlier.eql(id)) break true; } else false; if (again) continue; var name = text; var current = id; // Through tags to what they tag, each shown on the way. var depth: usize = 0; while (depth < 64) : (depth += 1) { var diag: Diagnostics = .{}; var obj = repo.objects().read(ctx.io, gpa, current, &diag) catch |err| return tool.fatalMessage(stderr, "zig-git show: cannot read {f}: {t}", .{ current, err }); defer obj.deinit(gpa); switch (obj.kind) { .blob => { try stdout.writeAll(obj.data); break; }, .tree => { try stdout.print("{s}tree {s}\n\n", .{ if (shown_one) "\n" else "", name }); var it = git.tree.iterate(repo.objectFormat(), obj.data); while (it.next() catch return tool.fatalMessage(stderr, "zig-git show: cannot read tree {f}", .{current})) |e| { try stdout.print("{s}{s}\n", .{ e.name, if (e.mode() == .directory) "/" else "" }); } shown_one = true; break; }, .commit => { const c = git.Commit.parse(repo.objectFormat(), obj.data) catch return tool.fatalMessage(stderr, "zig-git show: cannot read commit {f}", .{current}); var parents: std.ArrayList(ObjectId) = .empty; defer parents.deinit(gpa); const grafts = try @import("history.zig").loadGrafts(ctx, &repo, stderr); if (grafts.lookup(current)) |g| { try parents.appendSlice(gpa, g.parents); } else { var ps = c.parents(); while (ps.next()) |p| try parents.append(gpa, p); } // git reads the parents before it shows the commit. for (parents.items) |p| { if (!(repo.objects().contains(ctx.io, p, null) catch false)) { try stderr.print("error: Could not read {f}\n", .{p}); return tool.fatalMessage(stderr, "unable to parse commit {f}", .{p}); } } // With --diff-filter, a commit whose diff shows // nothing is not shown at all. if (diffs) |dw| if (dw.filtering() and !try dw.keeps(current, parents.items)) break; d.written = shown_one; const combined = diffs != null and diffs.?.combines(parents.items); if (diffs) |dw| try dw.checkCombined(parents.items); if (diffs != null and diffs.?.separately(parents.items)) { var entries: usize = 0; for (parents.items) |p| { // A parent against which nothing differs (or // nothing is kept) has no entry, as git was // seen to leave it out. if (!try diffs.?.keepsAgainst(current, p)) continue; if (entries != 0) d.written = true; entries += 1; try d.writeCommit(stdout, .{ .id = current, .parents = parents.items, .source = name, .from = p }, stderr); try diffs.?.writeAgainst(stdout, current, p, d.framing()); } } else { try d.writeCommit(stdout, .{ .id = current, .parents = parents.items, .source = name }, stderr); if (diffs) |dw| { if (combined) try dw.writeCombined(stdout, current, parents.items, d.framing()) else try dw.write(stdout, current, parents.items, d.framing()); } } shown_one = true; break; }, .tag => { if (shown_one) try stdout.writeByte('\n'); var ddiag: Diagnostics = .{}; git.pretty.writeTag(gpa, stdout, &d.format.?, obj.data, repo.objectFormat(), d.settings, &ddiag) catch |err| switch (err) { error.MalformedTag => return tool.fatalMessage(stderr, "zig-git show: cannot read tag {f}", .{current}), else => return describe.describeFailure(err, &ddiag, stderr), }; shown_one = true; const t = git.Tag.parse(repo.objectFormat(), obj.data, null) catch unreachable; // What the tag names goes by the name given, unless that // was the tag's object ID. if (ObjectId.parseHex(repo.objectFormat(), text)) |_| { name = try std.fmt.allocPrint(d.arena.allocator(), "{f}", .{t.target}); } else |_| {} current = t.target; }, } } } if (diffs) |dw| if (dw.problems) return error.CheckProblems;}
/// Runs `check-mailmap` with `args`, the arguments after its name.pub fn checkMailmap(ctx: Context, args: []const []const u8, stdin: *Io.Reader, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var use_stdin = false; var extra: git.Mailmap.Extra = .{ .home = ctx.env.get("HOME") }; var contacts: std.ArrayList([]const u8) = .empty; defer contacts.deinit(gpa); var i: usize = 0; var only_contacts = false; while (i < args.len) : (i += 1) { const arg = args[i]; if (!only_contacts and arg.len > 1 and arg[0] == '-') { if (std.mem.eql(u8, arg, "--")) { only_contacts = true; continue; } if (std.mem.eql(u8, arg, "--stdin")) { use_stdin = true; continue; } if (std.mem.eql(u8, arg, "--no-stdin")) { use_stdin = false; continue; } const Valued = enum { @"--mailmap-file", @"--mailmap-blob" }; const eq = std.mem.findScalar(u8, arg, '='); if (std.meta.stringToEnum(Valued, arg[0 .. eq orelse arg.len])) |v| { const value = if (eq) |e| arg[e + 1 ..] else if (i + 1 < args.len) next: { i += 1; break :next args[i]; } else { try stderr.print("error: option `{s}' requires a value\n", .{arg[2..]}); return error.UsageQuiet; }; switch (v) { .@"--mailmap-file" => extra.file = value, .@"--mailmap-blob" => extra.blob = value, } continue; } try stderr.print("error: unknown option `{s}'\n", .{std.mem.trimStart(u8, arg, "-")}); try stderr.writeAll(check_mailmap_usage); return error.UsageQuiet; } try contacts.append(gpa, arg); } if (contacts.items.len == 0 and !use_stdin) return tool.fatalMessage(stderr, "no contacts specified", .{});
var repo = try discovery.open(ctx, stderr); defer repo.close(ctx.io); const settings = try object_names.settings(&repo.config, stderr); var revs = try revisions.Revisions.init(ctx, &repo, settings, .rev_parse, stderr); defer revs.deinit(); var mailmap: git.Mailmap = .init(gpa); defer mailmap.deinit(); var warnings: std.ArrayList(git.Mailmap.Warning) = .empty; defer warnings.deinit(gpa); try mailmap.addRepository(ctx.io, &repo, &revs.resolver, extra, &warnings); for (warnings.items) |w| switch (w) { .symbolic_link => try stderr.writeAll("error: unable to open mailmap at .mailmap: Too many levels of symbolic links\n"), }; for (contacts.items) |c| try writeContact(stdout, &mailmap, c); if (use_stdin) { while (stdin.takeDelimiterInclusive('\n')) |line| { try writeContact(stdout, &mailmap, line[0 .. line.len - 1]); try stdout.flush(); } else |err| switch (err) { error.EndOfStream => { const rest = stdin.buffered(); if (rest.len != 0) { try writeContact(stdout, &mailmap, rest); stdin.toss(rest.len); } }, else => |e| return e, } }}
const check_mailmap_usage = \\usage: git check-mailmap [<options>] <contact>... \\ \\ --[no-]stdin also read contacts from stdin \\ --[no-]mailmap-file <file> \\ read additional mailmap entries from file \\ --[no-]mailmap-blob <blob> \\ read additional mailmap entries from blob \\ \\;
/// Writes a contact as the mailmap maps it: an identity's name and email/// address if it has both, and otherwise the whole of it as an email/// address.fn writeContact(stdout: *Io.Writer, mailmap: *const git.Mailmap, contact: []const u8) !void { var name: []const u8 = ""; var email: []const u8 = contact; if (git.Identity.parse(contact)) |id| { name = id.name; email = id.email; } const m = mailmap.map(name, email); if (m.name.len != 0) try stdout.print("{s} ", .{m.name}); try stdout.print("<{s}>\n", .{m.email});}