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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Configuration values read as the types git-config(1) describes://! booleans, integers with their unit suffixes, and paths with `~` and//! `%(prefix)` expanded. What the documentation leaves out was found by//! asking `git config --type=...` about each spelling.//!//! - An integer is read as C's `strtoimax` reads one in base 0: blank//! space first is skipped (vertical tab and form feed included), a sign//! may follow, then `0x` for hexadecimal, `0b` for binary or a leading//! `0` for octal. A single `k`, `m` or `g` in either case may follow;//! anything else after the digits, or no digits at all, is an invalid//! unit. Integers are 64-bit, but a boolean spelled as a number, and//! the integer half of `bool-or-int`, must fit in 32.//! - A boolean is `true`, `yes`, `on`, `false`, `no` or `off` in any case,//! the empty string (false), no value at all (true), or a number, true//! unless it is zero.
const std = @import("std");const Allocator = std.mem.Allocator;
/// Why a value is not the integer it was asked to be, in git's words.pub const IntError = error{ /// Not a number, or a number followed by something other than a unit. InvalidUnit, /// A number too large or too small for the type, before or after its /// unit is applied. OutOfRange,};
/// Reads `text` as an integer of type `T` (`i64` for `--type=int`, `i32`/// where git uses a C `int`), with its unit applied.pub fn parseInt(comptime T: type, text: []const u8) IntError!T { 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 base: u8 = 10; if (i + 1 < text.len and text[i] == '0' and (text[i + 1] == 'x' or text[i + 1] == 'X') and i + 2 < text.len and std.ascii.isHex(text[i + 2])) { base = 16; i += 2; } else if (i + 1 < text.len and text[i] == '0' and (text[i + 1] == 'b' or text[i + 1] == 'B') and i + 2 < text.len and (text[i + 2] == '0' or text[i + 2] == '1')) { base = 2; i += 2; } else if (i < text.len and text[i] == '0') { base = 8; } const digits_start = i; // Accumulated as a magnitude, saturating, so that overflow is known // whatever the number of digits. var magnitude: u128 = 0; var overflow = false; while (i < text.len) : (i += 1) { const d = std.fmt.charToDigit(text[i], base) catch break; magnitude = magnitude * base + d; if (magnitude > std.math.maxInt(u64)) { overflow = true; magnitude = std.math.maxInt(u64); } } if (i == digits_start) return error.InvalidUnit; // strtoimax's own range comes first. const limit: u128 = if (negative) @as(u128, std.math.maxInt(i64)) + 1 else std.math.maxInt(i64); if (overflow or magnitude > limit) return error.OutOfRange; const factor: u128 = switch (text.len - i) { 0 => 1, 1 => switch (text[i]) { 'k', 'K' => 1 << 10, 'm', 'M' => 1 << 20, 'g', 'G' => 1 << 30, else => return error.InvalidUnit, }, else => return error.InvalidUnit, }; const scaled = magnitude * factor; const type_limit: u128 = if (negative) @as(u128, std.math.maxInt(T)) + 1 else std.math.maxInt(T); if (scaled > type_limit) return error.OutOfRange; const signed: i128 = if (negative) -@as(i128, @intCast(scaled)) else @intCast(scaled); return @intCast(signed);}
fn isCSpace(c: u8) bool { return c == ' ' or (c >= '\t' and c <= '\r');}
/// Whether `value` is one of the words for true or false, or no value at/// all, which is true; null if it is neither.pub fn boolWord(value: ?[]const u8) ?bool { const v = value orelse return true; if (v.len == 0) return false; for ([_][]const u8{ "true", "yes", "on" }) |w| if (std.ascii.eqlIgnoreCase(v, w)) return true; for ([_][]const u8{ "false", "no", "off" }) |w| if (std.ascii.eqlIgnoreCase(v, w)) return false; return null;}
/// Reads `value` as a boolean, as `--type=bool` does; null if it is not/// one: neither a word for one nor a number that fits a C `int`.pub fn parseBool(value: ?[]const u8) ?bool { if (boolWord(value)) |b| return b; const n = parseInt(i32, value.?) catch return null; return n != 0;}
/// A value read as `--type=bool-or-int` reads it.pub const BoolOrInt = union(enum) { boolean: bool, int: i32,};
/// Reads `value` as a word for true or false if it is one, and otherwise/// as an integer that fits a C `int`.pub fn parseBoolOrInt(value: ?[]const u8) IntError!BoolOrInt { if (boolWord(value)) |b| return .{ .boolean = b }; return .{ .int = try parseInt(i32, value.?) };}
/// What a path's `~` and `%(prefix)` are expanded with, passed in since/// the library reads no environment.pub const PathContext = struct { /// `$HOME`, for `~` and `~/`; null if it is unset. home: ?[]const u8 = null, /// The directory git is installed in, for `%(prefix)/`; null if /// unknown. prefix: ?[]const u8 = null, /// Finds the home directory of a named user, for `~user/`; null if /// there is no such user or nothing to ask. userHome: ?*const fn (context: ?*anyopaque, user: []const u8) ?[]const u8 = null, /// Passed to `userHome`. user_context: ?*anyopaque = null,};
/// Why a path could not be expanded.pub const PathError = error{ /// `~` or `~user` named a home directory that could not be found: /// git's "failed to expand user dir". UserHomeUnknown, /// `%(prefix)/` with no prefix to expand it with. PrefixUnknown,} || Allocator.Error;
/// A path value, expanded.pub const Path = struct { /// The path, allocated with the allocator `expandPath` was given. path: []u8, /// Whether it was written `:(optional)...`, meaning the variable is to /// be taken as unset if nothing is at the path. optional: bool,};
/// Expands a path value as `--type=path` does: a leading `:(optional)`/// noted and removed, then `~/` or `~` alone made `$HOME`, `~user/` that/// user's home directory, and `%(prefix)/` the installation prefix./// Nothing else is touched; `./%(prefix)/` is how a literal one is/// written.pub fn expandPath(gpa: Allocator, value: []const u8, context: PathContext) PathError!Path { var v = value; const optional_prefix = ":(optional)"; const optional = std.mem.startsWith(u8, v, optional_prefix); if (optional) v = v[optional_prefix.len..]; const path_prefix = "%(prefix)/"; if (std.mem.startsWith(u8, v, path_prefix)) { const prefix = context.prefix orelse return error.PrefixUnknown; return .{ .path = try std.mem.concat(gpa, u8, &.{ prefix, v[path_prefix.len - 1 ..] }), .optional = optional }; } if (v.len > 0 and v[0] == '~') { const slash = std.mem.findScalar(u8, v, '/') orelse v.len; const user = v[1..slash]; const home = if (user.len == 0) context.home orelse return error.UserHomeUnknown else if (context.userHome) |f| f(context.user_context, user) orelse return error.UserHomeUnknown else return error.UserHomeUnknown; return .{ .path = try std.mem.concat(gpa, u8, &.{ home, v[slash..] }), .optional = optional }; } return .{ .path = try gpa.dupe(u8, v), .optional = optional };}
const testing = std.testing;
test "integers are read as git reads them" { try testing.expectEqual(16, try parseInt(i64, "0x10")); try testing.expectEqual(8, try parseInt(i64, "010")); try testing.expectEqual(1, try parseInt(i64, "0b1")); try testing.expectEqual(31, try parseInt(i64, "0X1F")); try testing.expectEqual(1, try parseInt(i64, "\x0b+1")); try testing.expectEqual(-1024, try parseInt(i64, "-1k")); try testing.expectEqual(1 << 30, try parseInt(i64, "1G")); try testing.expectEqual(std.math.minInt(i64), try parseInt(i64, "-9223372036854775808")); try testing.expectEqual(std.math.minInt(i64), try parseInt(i64, "-8589934592g")); try testing.expectEqual(1 << 53, try parseInt(i64, "8796093022208k")); try testing.expectError(error.OutOfRange, parseInt(i64, "9223372036854775808")); try testing.expectError(error.OutOfRange, parseInt(i64, "8589934592g")); try testing.expectError(error.OutOfRange, parseInt(i64, "99999999999999999999x")); try testing.expectError(error.OutOfRange, parseInt(i32, "2147483648")); try testing.expectEqual(std.math.minInt(i32), try parseInt(i32, "-2097152k")); for ([_][]const u8{ "", "k", "-", "+", "1 ", "1t", "1kb", "08", "1.5", "0x", "true" }) |bad| { try testing.expectError(error.InvalidUnit, parseInt(i64, bad)); }}
test "booleans are read as git reads them" { try testing.expectEqual(true, parseBool(null)); try testing.expectEqual(false, parseBool("")); try testing.expectEqual(true, parseBool("tRuE")); try testing.expectEqual(false, parseBool("Off")); try testing.expectEqual(true, parseBool("-1k")); try testing.expectEqual(false, parseBool("0k")); try testing.expectEqual(null, parseBool("2147483648")); try testing.expectEqual(null, parseBool("y")); try testing.expectEqual(BoolOrInt{ .int = 1 }, try parseBoolOrInt("1")); try testing.expectEqual(BoolOrInt{ .boolean = false }, try parseBoolOrInt(""));}
test "paths are expanded as git expands them" { const gpa = testing.allocator; const context: PathContext = .{ .home = "/home/me", .prefix = "/usr" }; for ([_][2][]const u8{ .{ "~", "/home/me" }, .{ "~/x", "/home/me/x" }, .{ "~//x", "/home/me//x" }, .{ "x/~/y", "x/~/y" }, .{ "%(prefix)/bin", "/usr/bin" }, .{ "%(prefix)", "%(prefix)" }, .{ "./%(prefix)/b", "./%(prefix)/b" }, .{ " ~/x", " ~/x" }, }) |case| { const p = try expandPath(gpa, case[0], context); defer gpa.free(p.path); try testing.expectEqualStrings(case[1], p.path); try testing.expect(!p.optional); } const p = try expandPath(gpa, ":(optional)~/x", context); defer gpa.free(p.path); try testing.expectEqualStrings("/home/me/x", p.path); try testing.expect(p.optional); try testing.expectError(error.UserHomeUnknown, expandPath(gpa, "~x", context)); try testing.expectError(error.UserHomeUnknown, expandPath(gpa, "~/x", .{}));}