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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Object IDs on `zig-git`'s command lines, both ways: the settings that//! say how long they are shown (`core.abbrev`) and how an ambiguous one is//! told apart (`core.disambiguate`), the three ways git's commands read a//! length, and showing an object ID shortened. Naming objects is//! `revisions.zig`'s, through the library's `Resolver`.//!//! git reads both settings whenever a command reads its configuration, so//! a bad value stops every command that opens a repository, the first bad//! one in the order the configuration was read, even if a later one would//! have overridden it.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const git = @import("git");const abbrev = git.abbrev;const ObjectId = git.ObjectId;const Diagnostics = git.Diagnostics;const tool = @import("zig_git.zig");const Context = tool.Context;
/// How the repository's object IDs are to be shown and told apart.pub const Settings = struct { length: abbrev.Length = .auto, hint: abbrev.Hint = .none,};
/// Reads `core.abbrev` and `core.disambiguate` as git does, saying what/// git says and failing at the first value it would stop at, which may/// also be a `push.default` git does not know.pub fn settings(config: *const git.Config, stderr: *Io.Writer) !Settings { var s: Settings = .{}; for (config.entries.items) |*e| { if (std.mem.eql(u8, e.key, "core.abbrev")) { var diag: Diagnostics = .{}; s.length = abbrev.parseLength(e.value, &diag) catch |err| switch (err) { error.MissingValue => return badSetting(stderr, e, "error: missing value for 'core.abbrev'\n", .{}), error.LengthOutOfRange => return badSetting(stderr, e, "error: abbrev length out of range: {s}\n", .{diag.detail()}), error.InvalidUnit, error.OutOfRange => { const why = if (err == error.InvalidUnit) "invalid unit" else "out of range"; if (e.origin.kind == .command_line) return tool.fatalMessage(stderr, "bad numeric config value '{s}' for 'core.abbrev': {s}", .{ e.value.?, why }); return tool.fatalMessage(stderr, "bad numeric config value '{s}' for 'core.abbrev' in {f}: {s}", .{ e.value.?, std.fmt.Alt(git.Config.Origin, git.Config.Origin.describe){ .data = e.origin }, why }); }, }; } else if (std.mem.eql(u8, e.key, "push.default")) { // Read here only to stop where git stops; the library reads it // where it is used. const v = e.value orelse return badSetting(stderr, e, "error: missing value for 'push.default'\n", .{}); const known = [_][]const u8{ "nothing", "matching", "simple", "upstream", "tracking", "current" }; const ok = for (known) |k| { if (std.mem.eql(u8, k, v)) break true; } else false; if (!ok) return badSetting(stderr, e, "error: malformed value for push.default: {s}\nerror: must be one of nothing, matching, simple, upstream or current\n", .{v}); } else if (std.mem.eql(u8, e.key, "core.disambiguate")) { s.hint = abbrev.Hint.parse(e.value) catch |err| switch (err) { error.MissingValue => return badSetting(stderr, e, "error: missing value for 'core.disambiguate'\n", .{}), error.UnknownHint => return badSetting(stderr, e, "error: unknown hint type for 'core.disambiguate': {s}\n", .{e.value.?}), }; } } return s;}
fn badSetting(stderr: *Io.Writer, e: *const git.Config.Entry, comptime fmt: []const u8, args: anytype) error{Fatal} { stderr.print(fmt, args) catch {}; if (e.origin.kind == .file) return tool.fatalMessage(stderr, "bad config variable '{s}' in file '{s}' at line {d}", .{ e.key, e.origin.name, e.line }); return tool.fatalMessage(stderr, "unable to parse '{s}' from command-line config", .{e.key});}
/// Reads a number as git's option parser does for `--abbrev=<n>`: the/// whole text a decimal integer, after optional blank space and sign;/// null if it is not one.pub fn optionNumber(text: []const u8) ?i64 { var i: usize = 0; while (i < text.len and isCSpace(text[i])) i += 1; var negative = false; if (i < text.len and (text[i] == '+' or text[i] == '-')) { negative = text[i] == '-'; i += 1; } if (i == text.len) return null; var n: i64 = 0; for (text[i..]) |c| { if (!std.ascii.isDigit(c)) return null; n = std.math.mul(i64, n, 10) catch return null; n = std.math.add(i64, n, c - '0') catch return null; } return if (negative) -n else n;}
/// Reads a number as `rev-parse --short=<n>` does: blank space, a sign/// and digits, anything after them ignored, and 0 without any.pub fn leadingNumber(text: []const u8) i64 { var i: usize = 0; while (i < text.len and isCSpace(text[i])) i += 1; var negative = false; if (i < text.len and (text[i] == '+' or text[i] == '-')) { negative = text[i] == '-'; i += 1; } var n: i64 = 0; while (i < text.len and std.ascii.isDigit(text[i])) : (i += 1) { n = std.math.mul(i64, n, 10) catch return std.math.maxInt(i64); n = std.math.add(i64, n, text[i] - '0') catch return std.math.maxInt(i64); } return if (negative) -n else n;}
fn isCSpace(c: u8) bool { return c == ' ' or (c >= '\t' and c <= '\r');}
/// A repository opened for naming and showing object IDs.pub const Names = struct { ctx: Context, repo: *git.Repository, lookup: abbrev.Lookup, settings: Settings,
/// Names for `repo`, whose settings have been read. pub fn init(ctx: Context, repo: *git.Repository, s: Settings) Names { return .{ .ctx = ctx, .repo = repo, .lookup = .init(ctx.gpa, &repo.database), .settings = s }; }
pub fn deinit(n: *Names) void { n.lookup.deinit(); }
/// The fewest digits object IDs are shown with by default. pub fn defaultLength(n: *const Names) usize { return n.lookup.minimumLength(n.settings.length); }
/// Writes `id` shortened to at least `length` digits, or whole if /// `length` is null. pub fn write(n: *Names, w: *Io.Writer, id: ObjectId, length: ?usize) !void { const l = length orelse return w.print("{f}", .{id}); var buffer: [git.object_max_hex_length]u8 = undefined; try w.writeAll(try n.lookup.abbreviate(n.ctx.io, id, l, &buffer)); }};
// -- tests -----------------------------------------------------------------------
const testing = std.testing;const Repo = tool.Repo;const collide = git.test_collide;
fn same(repo: *Repo, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{});}
/// Usage errors, whose usage text is git's own.fn sameOutput(repo: *Repo, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{ .compare = .output });}
fn upper(gpa: Allocator, text: []const u8) ![]u8 { const out = try gpa.dupe(u8, text); _ = std.ascii.upperString(out, text); return out;}
test "abbreviated object IDs are expanded by rev-parse and cat-file as git expands them" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); var c = try collide.build(&repo); defer c.deinit(); for (c.prefixes.items) |p| { const caps = try upper(gpa, p); defer gpa.free(caps); const longer = try std.fmt.allocPrint(gpa, "{s}0", .{p}); defer gpa.free(longer); for ([_][]const u8{ p, caps, longer, p[0..3] }) |name| { try same(&repo, &.{ "rev-parse", name }); try same(&repo, &.{ "rev-parse", "--verify", name }); try same(&repo, &.{ "rev-parse", "--verify", "-q", name }); try same(&repo, &.{ "rev-parse", "-q", name }); try same(&repo, &.{ "rev-parse", "--short", name }); for ([_][]const u8{ "-t", "-e", "-s", "-p", "blob", "commit" }) |mode| try same(&repo, &.{ "cat-file", mode, name }); for ([_][]const u8{ "none", "commit", "committish", "tree", "treeish", "blob" }) |hint| { const setting = try std.fmt.allocPrint(gpa, "core.disambiguate={s}", .{hint}); defer gpa.free(setting); try same(&repo, &.{ "-c", setting, "rev-parse", "--verify", name }); try same(&repo, &.{ "-c", setting, "cat-file", "-t", name }); } for ([_][]const u8{ "4", "no", "12" }) |length| { const setting = try std.fmt.allocPrint(gpa, "core.abbrev={s}", .{length}); defer gpa.free(setting); try same(&repo, &.{ "-c", setting, "rev-parse", name }); } } for (2..p.len + 1) |n| { const arg = try std.fmt.allocPrint(gpa, "--disambiguate={s}", .{caps[0..n]}); defer gpa.free(arg); try same(&repo, &.{ "rev-parse", arg }); } } // Revisions and other questions together, in order. try same(&repo, &.{ "rev-parse", "--disambiguate=0000", c.prefixes.items[0], "--git-dir", c.prefixes.items[1] }); try same(&repo, &.{ "rev-parse", "--verify", c.prefixes.items[0], c.prefixes.items[0] }); try same(&repo, &.{ "rev-parse", "--verify" }); try same(&repo, &.{ "rev-parse", "--short" }); try same(&repo, &.{ "rev-parse", "--disambiguate=", "--disambiguate=zz", "--disambiguate=00" });}
test "object IDs are shortened by rev-parse --short as git shortens them" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); var c = try collide.build(&repo); defer c.deinit(); const all = try repo.output(&.{ "cat-file", "--batch-all-objects", "--batch-check=%(objectname)" }); defer gpa.free(all); var lines = std.mem.tokenizeScalar(u8, all, '\n'); var first = true; while (lines.next()) |id| { try same(&repo, &.{ "rev-parse", "--short", id }); try same(&repo, &.{ "rev-parse", "--short=4", id }); if (!first) continue; first = false; for ([_][]const u8{ "--short=0", "--short=3", "--short=010", "--short= 6", "--short=6x", "--short=abc", "--short=-1", "--short=99", "--short=" }) |opt| try same(&repo, &.{ "rev-parse", opt, id }); for ([_][]const u8{ "core.abbrev=5", "core.abbrev=auto", "core.abbrev=no", "core.abbrev=false", "core.abbrev=", "core.abbrev=99", "core.abbrev=0x8", "core.abbrev=1g" }) |setting| try same(&repo, &.{ "-c", setting, "rev-parse", "--short", id }); try same(&repo, &.{ "rev-parse", "--short", id, id }); } // An object ID of no object is shortened all the same. const zeros = "0000000000000000000000000000000000000000000000000000000000000000"; try same(&repo, &.{ "rev-parse", "--short", zeros[0..repo.object_format.hexLength()] });}
test "ls-files, show-ref and for-each-ref shorten object IDs as git does" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); var c = try collide.build(&repo); defer c.deinit(); for ([_][]const u8{ "--abbrev", "--abbrev=0", "--abbrev=3", "--abbrev=4", "--abbrev=5", "--abbrev=9", "--abbrev=41", "--abbrev=99", "--abbrev=-1", "--abbrev=010", "--abbrev= 5", "--no-abbrev" }) |opt| { try same(&repo, &.{ "ls-files", "-s", opt }); try same(&repo, &.{ "ls-files", "--format=%(objectname) %(path)", opt }); try same(&repo, &.{ "ls-files", "--abbrev", "-s", opt }); try same(&repo, &.{ "show-ref", opt }); try same(&repo, &.{ "show-ref", "-d", opt }); try same(&repo, &.{ "show-ref", "--hash", opt }); } for ([_][]const u8{ "--abbrev=x", "--abbrev=4k", "--abbrev=" }) |opt| { try sameOutput(&repo, &.{ "ls-files", "-s", opt }); try sameOutput(&repo, &.{ "show-ref", opt }); } for ([_][]const u8{ "--hash=5", "--hash=0", "--hash=3", "--hash=99" }) |opt| { try same(&repo, &.{ "show-ref", opt }); try same(&repo, &.{ "show-ref", "-d", opt }); try same(&repo, &.{ "show-ref", opt, "--abbrev=9" }); try same(&repo, &.{ "show-ref", opt, "--no-abbrev" }); } try sameOutput(&repo, &.{ "show-ref", "--hash=x" }); try same(&repo, &.{ "show-ref", "--verify", "--abbrev", "refs/tags/v1" }); for ([_][]const u8{ "%(objectname:short) %(refname)", "%(objectname:short=5) %(*objectname:short) %(*objectname:short=4)", "%(objectname:short=3)|%(objectname:short=010)|%(objectname:short= 6)|%(objectname:short=99)", "%(objectname:short=0)", "%(objectname:short=-1)", "%(objectname:short=)", "%(objectname:short=6x)", "%(objectname:shorts)", "%(*objectname:short=x)", }) |format| { const arg = try std.fmt.allocPrint(gpa, "--format={s}", .{format}); defer gpa.free(arg); try same(&repo, &.{ "for-each-ref", arg }); } for ([_][]const u8{ "core.abbrev=5", "core.abbrev=no", "core.abbrev=12", "core.abbrev=AUTO" }) |setting| { try same(&repo, &.{ "-c", setting, "ls-files", "-s" }); try same(&repo, &.{ "-c", setting, "ls-files", "-s", "--abbrev" }); try same(&repo, &.{ "-c", setting, "show-ref", "--abbrev", "-d" }); try same(&repo, &.{ "-c", setting, "for-each-ref", "--format=%(objectname:short) %(*objectname:short)" }); }}
test "a bad core.abbrev or core.disambiguate stops every command as it stops git" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commitFile("a", "one\n", "first"); const head = try collide.revParse(&repo, "HEAD"); defer gpa.free(head); const commands = [_][]const []const u8{ &.{ "rev-parse", "--short", head }, &.{ "cat-file", "-t", head }, &.{ "ls-files", "-s" }, &.{"show-ref"}, &.{"for-each-ref"}, &.{ "symbolic-ref", "HEAD" }, }; const bad = [_][]const u8{ "core.abbrev=3", "core.abbrev=-1", "core.abbrev=yes", "core.abbrev=3a", "core.abbrev=2147483648", "core.abbrev", "core.disambiguate=tag", "core.disambiguate=", "core.disambiguate" }; for (bad) |setting| for (commands) |args| { var with: std.ArrayList([]const u8) = .empty; defer with.deinit(gpa); try with.appendSlice(gpa, &.{ "-c", setting }); try with.appendSlice(gpa, args); try same(&repo, with.items); }; // The first bad value stops it, even if a later one would override it. try same(&repo, &.{ "-c", "core.abbrev=3", "-c", "core.abbrev=8", "rev-parse", "--short", head }); try same(&repo, &.{ "-c", "core.disambiguate=x", "-c", "core.abbrev=1", "cat-file", "-t", head }); // In the repository's own configuration, git names the file and line. for ([_][]const u8{ "[core]\n\tabbrev = 2\n", "[core]\n\tdisambiguate = bogus\n", "[core]\n\tabbrev = yes\n", "[core]\n\tabbrev\n" }) |text| { const config = try repo.tmp.dir.readFileAlloc(repo.io, ".git/config", gpa, .unlimited); defer gpa.free(config); const changed = try std.mem.concat(gpa, u8, &.{ config, text }); defer gpa.free(changed); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = ".git/config", .data = changed }); defer repo.tmp.dir.writeFile(repo.io, .{ .sub_path = ".git/config", .data = config }) catch {}; for (commands) |args| try same(&repo, args); }}
test "auto shortens to more digits as the packs grow, as git's does" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); // 16380 blobs, and a commit of a tree of one more: 16383 packed // objects, the most that seven digits are enough for. var input: std.ArrayList(u8) = .empty; defer input.deinit(gpa); for (0..16380) |i| { var buffer: [32]u8 = undefined; const data = try std.fmt.bufPrint(&buffer, "blob {d}\n", .{i}); try input.print(gpa, "blob\ndata {d}\n{s}\n", .{ data.len, data }); } try input.appendSlice(gpa, "commit refs/heads/main\ncommitter C <c@x> 1700000000 +0000\ndata 2\nc\nM 100644 inline f\ndata 2\nx\n\n"); const out = try repo.outputWithInput(&.{ "fast-import", "--quiet" }, input.items); gpa.free(out); try repo.git(&.{ "read-tree", "main" }); const head = try collide.revParse(&repo, "main"); defer gpa.free(head); for (0..2) |_| { try same(&repo, &.{ "rev-parse", "--short", head }); try same(&repo, &.{ "ls-files", "-s", "--abbrev" }); try same(&repo, &.{ "show-ref", "--abbrev" }); try same(&repo, &.{ "for-each-ref", "--format=%(objectname:short)" }); // One more, in a pack of its own: eight digits from here. gpa.free(try collide.writeBlob(&repo, null, "one more\n")); try repo.git(&.{ "repack", "-d", "-q" }); } // Loose objects do not count, and a multi-pack-index counts an object // held by two packs once. try repo.git(&.{ "repack", "-a", "-d", "-q" }); try same(&repo, &.{ "rev-parse", "--short", head });}
test "a hexadecimal name that is also a ref's or a path's is read as git reads it" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commitFile("a", "one\n", "first"); try repo.git(&.{ "branch", "abcd" }); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = "beef", .data = "" }); try same(&repo, &.{ "rev-parse", "abcd" }); try same(&repo, &.{ "cat-file", "-t", "abcd" }); try same(&repo, &.{ "rev-parse", "beef" }); // A single digit git answers by no rule found yet. const ours = try tool.runOurs(&repo, &.{ "rev-parse", "--disambiguate=9" }, .{}); defer ours.deinit(gpa); try testing.expectEqual(128, ours.code); try testing.expectEqualStrings("", ours.stdout); try testing.expect(std.mem.find(u8, ours.stderr, "not supported") != null);}