// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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=`: 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=` 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 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); }