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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A repository's configuration: every variable from every source git//! reads, in the order it reads them, each with where it came from, and//! lookups by name that answer as `git config` answers.//!//! The sources and their order, found by asking `git config --list//! --show-scope --show-origin` (git-config(1) gives most of it)://!//! 1. system: `GIT_CONFIG_SYSTEM`, or `$(prefix)/etc/gitconfig`, unless//! `GIT_CONFIG_NOSYSTEM` is true; an empty `GIT_CONFIG_SYSTEM` is none;//! 2. global: `GIT_CONFIG_GLOBAL` alone if it is set (empty is none), or//! else `$XDG_CONFIG_HOME/git/config` (`$HOME/.config/git/config` when//! that is unset or empty) and then `$HOME/.gitconfig`;//! 3. local: the repository's `config`;//! 4. worktree: `config.worktree` in the worktree's git directory, if the//! repository's own `config` — not anything it includes — sets//! `extensions.worktreeConfig`, whatever the repository format version;//! 5. command: the `GIT_CONFIG_COUNT` pairs, then `GIT_CONFIG_PARAMETERS`//! (which is where `git -c` puts its settings), then any `-c` settings//! the caller has itself.//!//! A missing file is skipped, and so is a global or system file that//! cannot be read. Later values take precedence over earlier ones.//!//! Includes (`include.path`, `includeIf.<condition>.path`) are followed as//! git follows them: the included file's variables come straight after//! the variable that included it, a relative path is relative to the//! directory of the including file, and nesting stops with an error ten//! files deep, which is how a cycle ends. The conditions that need a//! repository — its git directory, its current branch, its working tree —//! are answered by the caller through `Repository`, since finding a//! repository and reading its refs are not this file's business.//!//! Nothing here reads the environment, the current directory or the//! clock: every path and environment value is passed in.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const Diagnostics = @import("Diagnostics.zig");const wildmatch = @import("wildmatch.zig");/// The syntax of a configuration file.pub const syntax = @import("config/syntax.zig");/// Values read as booleans, integers and paths.pub const value = @import("config/value.zig");/// `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT`.pub const parameters = @import("config/parameters.zig");
const Config = @This();
gpa: Allocator,/// Holds every key, value and origin name.arena: std.heap.ArenaAllocator,/// Every variable, in the order git reads them.entries: std.ArrayList(Entry) = .empty,/// Where each key's entries are in `entries`, in order.index: std.StringHashMapUnmanaged(std.ArrayList(u32)) = .empty,/// What git says about the configuration but carries on after, in the/// order it would say them.messages: std.ArrayList(Message) = .empty,
/// Which group of sources a variable came from, as `--show-scope` names/// them.pub const Scope = enum { system, global, local, worktree, command,
/// The scope as git prints it. pub fn name(s: Scope) []const u8 { return @tagName(s); }};
/// Where a variable came from, as `--show-origin` names it.pub const Origin = struct { kind: Kind, /// The file's path as it was opened, or the blob's name; empty for /// the command line and standard input. name: []const u8 = "",
/// The kinds of source. pub const Kind = enum { file, command_line, standard_input, blob };
pub const command_line: Origin = .{ .kind = .command_line };
/// Writes the origin as `--show-origin` does: `file:<path>`, /// `command line:`, `standard input:` or `blob:<name>`. pub fn format(o: Origin, w: *Io.Writer) Io.Writer.Error!void { switch (o.kind) { .file => try w.print("file:{s}", .{o.name}), .command_line => try w.writeAll("command line:"), .standard_input => try w.writeAll("standard input:"), .blob => try w.print("blob:{s}", .{o.name}), } }
/// How git names the source in its error messages: `file <path>`, /// `standard input`, `blob <name>`, or the empty string for the /// command line, which git does not name. pub fn describe(o: Origin, w: *Io.Writer) Io.Writer.Error!void { switch (o.kind) { .file => try w.print("file {s}", .{o.name}), .command_line => {}, .standard_input => try w.writeAll("standard input"), .blob => try w.print("blob {s}", .{o.name}), } }};
/// One variable, as it was set by one source.pub const Entry = struct { /// The variable's name, its section and name lowered and its /// subsection as written. key: []const u8, /// Its value; null when it was written without one, which is true. value: ?[]const u8, scope: Scope, origin: Origin, /// The line it ends on in its file; 0 for the command line. line: u32,
/// The key's section, subsection and name. pub fn parts(e: Entry) syntax.KeyParts { return syntax.splitKey(e.key); }};
/// Something git reports about the configuration without stopping.pub const Message = struct { kind: Kind, /// Where it was found. origin: Origin, line: u32,
/// What git reports. pub const Kind = enum { /// `includeIf` with a `./` condition outside a file: "relative /// config include conditionals must come from files", after which /// git takes the condition as met. relative_condition_outside_file, };};
/// What a load needs besides the sources themselves.pub const Options = struct { /// Whether to follow `include.path` and `includeIf.*.path`. includes: bool = true, /// What `~` and `%(prefix)` mean in include paths and `gitdir:` /// patterns. path: value.PathContext = .{}, /// The repository the configuration is for, which answers `includeIf` /// conditions; with none, every condition that needs one is unmet. repository: ?Repository = null,};
/// The repository behind `includeIf` conditions, answered by the caller.pub const Repository = struct { context: *const anyopaque, vtable: *const VTable,
/// Why the branch cannot be told: the repository's refs could not be /// read. pub const BranchError = error{RefStorageNotReadable};
pub const VTable = struct { /// The git directory's absolute path as it was found, for /// `gitdir:`; null if not known. gitDir: *const fn (context: *const anyopaque) ?[]const u8, /// The same with symbolic links resolved, which `gitdir:` also /// tries; null if not known or the same. realGitDir: *const fn (context: *const anyopaque) ?[]const u8, /// The checked-out branch without `refs/heads/`, for `onbranch:`; /// null on a detached `HEAD`, or an error when it cannot be told. branch: *const fn (context: *const anyopaque) BranchError!?[]const u8, /// The working tree's real path, for `worktree:`; null for a bare /// repository. workTree: *const fn (context: *const anyopaque) ?[]const u8, };
/// Answers known in advance. pub const Fixed = struct { git_dir: ?[]const u8 = null, real_git_dir: ?[]const u8 = null, branch: ?[]const u8 = null, work_tree: ?[]const u8 = null,
/// A `Repository` giving these answers. pub fn repository(f: *const Fixed) Repository { return .{ .context = f, .vtable = &fixed_vtable }; }
const fixed_vtable: VTable = .{ .gitDir = struct { fn f(c: *const anyopaque) ?[]const u8 { return @as(*const Fixed, @ptrCast(@alignCast(c))).git_dir; } }.f, .realGitDir = struct { fn f(c: *const anyopaque) ?[]const u8 { return @as(*const Fixed, @ptrCast(@alignCast(c))).real_git_dir; } }.f, .branch = struct { fn f(c: *const anyopaque) BranchError!?[]const u8 { return @as(*const Fixed, @ptrCast(@alignCast(c))).branch; } }.f, .workTree = struct { fn f(c: *const anyopaque) ?[]const u8 { return @as(*const Fixed, @ptrCast(@alignCast(c))).work_tree; } }.f, }; };};
/// The files and settings a configuration is read from, in the order git/// reads them. Every path is as the caller would open it: absolute, or/// relative to the directory given to `addSources`.pub const Sources = struct { system: ?[]const u8 = null, /// `$XDG_CONFIG_HOME/git/config`, or `GIT_CONFIG_GLOBAL`. xdg_global: ?[]const u8 = null, /// `$HOME/.gitconfig`. home_global: ?[]const u8 = null, /// The repository's `config`. local: ?[]const u8 = null, /// `config.worktree` in the worktree's git directory, read only if /// `local` sets `extensions.worktreeConfig`. worktree: ?[]const u8 = null, /// The environment `GIT_CONFIG_COUNT` and its pairs are looked up in. environment: ?*const std.process.Environ.Map = null, /// `GIT_CONFIG_PARAMETERS`. parameters: ?[]const u8 = null, /// `-c` settings, `key=value` or `key`, after everything else. command_line: []const []const u8 = &.{}, /// Paths `fromEnvironment` allocated. owned: [4]?[]u8 = @splat(null),
/// Where `fromEnvironment` finds what is not in the environment. pub const Defaults = struct { /// The system file when `GIT_CONFIG_SYSTEM` is unset. system: []const u8 = "/etc/gitconfig", /// The git directory, whose `config` is the local file; null /// outside a repository. git_dir: ?[]const u8 = null, /// The worktree's own git directory, if not `git_dir`. worktree_git_dir: ?[]const u8 = null, };
/// The sources git would read with the environment `env`. /// `error.BadNoSystem` is git's "bad boolean environment value" for /// `GIT_CONFIG_NOSYSTEM`. pub fn fromEnvironment(gpa: Allocator, env: *const std.process.Environ.Map, defaults: Defaults) (error{BadNoSystem} || Allocator.Error)!Sources { var s: Sources = .{ .environment = env }; errdefer s.deinit(gpa); const no_system = if (env.get("GIT_CONFIG_NOSYSTEM")) |v| value.parseBool(v) orelse return error.BadNoSystem else false; if (!no_system) { if (env.get("GIT_CONFIG_SYSTEM")) |p| { if (p.len != 0) s.system = p; } else s.system = defaults.system; } if (env.get("GIT_CONFIG_GLOBAL")) |p| { if (p.len != 0) s.xdg_global = p; } else { const home = env.get("HOME"); if (env.get("XDG_CONFIG_HOME")) |x| if (x.len != 0) { s.owned[0] = try std.fs.path.join(gpa, &.{ x, "git", "config" }); }; if (s.owned[0] == null) if (home) |h| { s.owned[0] = try std.fs.path.join(gpa, &.{ h, ".config", "git", "config" }); }; s.xdg_global = s.owned[0]; if (home) |h| { s.owned[1] = try std.fs.path.join(gpa, &.{ h, ".gitconfig" }); s.home_global = s.owned[1]; } } if (defaults.git_dir) |g| { s.owned[2] = try std.fs.path.join(gpa, &.{ g, "config" }); s.local = s.owned[2]; const wt = defaults.worktree_git_dir orelse g; s.owned[3] = try std.fs.path.join(gpa, &.{ wt, "config.worktree" }); s.worktree = s.owned[3]; } s.parameters = env.get("GIT_CONFIG_PARAMETERS"); return s; }
/// Frees the paths `fromEnvironment` made. pub fn deinit(s: *Sources, gpa: Allocator) void { for (s.owned) |o| if (o) |p| gpa.free(p); s.* = undefined; }};
/// Everything that can go wrong loading a configuration, beyond reading/// files. `Diagnostics` says where, and in its detail the key, path or/// count git names in its message.pub const LoadError = error{ /// A malformed file: "bad config line N in <origin>". BadConfigLine, /// A file named on its own could not be read: "unable to read config /// file '<path>': <reason>"; the detail is the reason. ConfigFileUnreadable, /// An included file exists but could not be opened: "unable to access /// '<path>': <reason>". IncludeUnreadable, /// An included path is a directory: git warns "unable to access /// '<path>': Is a directory" (the path is the detail) and fails with /// "bad config line" at the include. IncludeIsDirectory, /// "exceeded maximum include depth (10)": the path is the file being /// included, and the detail the file including it. IncludeDepthExceeded, /// An include, or another variable that needs one, with no value: /// "missing value for '<key>'"; the detail is the key. MissingValue, /// "relative config includes must come from files". RelativeIncludeOutsideFile, /// "remote URLs cannot be configured in file directly or indirectly /// included by includeIf.hasconfig:remote.*.url". RemoteUrlInHasconfigInclude, /// An include path with a `~user` that names nobody known: "failed to /// expand user dir in: '<path>'"; the detail is the path. UserHomeUnknown, /// An include path with `%(prefix)/` and no prefix to expand. PrefixUnknown, /// "bogus format in GIT_CONFIG_PARAMETERS". BogusParameters, /// "bogus count in GIT_CONFIG_COUNT". BogusCount, /// "too many entries in GIT_CONFIG_COUNT". TooManyEntries, /// "missing config key GIT_CONFIG_KEY_<n>"; the detail is the name. MissingCountKey, /// "missing config value GIT_CONFIG_VALUE_<n>"; the detail is the name. MissingCountValue, /// "empty config key". EmptyKey, /// An `onbranch:` condition in a repository whose refs cannot yet be /// read. RefStorageNotReadable,} || syntax.KeyError || Allocator.Error || Io.Cancelable;
/// An empty configuration, to be loaded into.pub fn init(gpa: Allocator) Config { return .{ .gpa = gpa, .arena = .init(gpa) };}
/// Frees everything.pub fn deinit(c: *Config) void { var it = c.index.valueIterator(); while (it.next()) |list| list.deinit(c.gpa); c.index.deinit(c.gpa); c.entries.deinit(c.gpa); c.messages.deinit(c.gpa); c.arena.deinit(); c.* = undefined;}
/// The configuration git would read from `sources`, with relative paths/// relative to `dir`.pub fn load(gpa: Allocator, io: Io, dir: Io.Dir, sources: Sources, options: Options, diag: ?*Diagnostics) LoadError!Config { var c: Config = .init(gpa); errdefer c.deinit(); try c.addSources(io, dir, sources, options, diag); return c;}
/// Adds the variables of every source, in git's order. On failure the/// variables read before it stay, as `git config --list` prints those/// before saying what went wrong.pub fn addSources(c: *Config, io: Io, dir: Io.Dir, sources: Sources, options: Options, diag: ?*Diagnostics) LoadError!void { var l: Loader = .{ .config = c, .io = io, .dir = dir, .options = options, .diag = diag, .plan = .{ .sources = sources }, .scratch = .init(c.gpa) }; defer l.deinit(); try l.loadPlan(.load);}
/// Adds the variables of one file, as `git config --file` reads it, with/// `scope`. A missing file is `ConfigFileUnreadable`.pub fn addFile(c: *Config, io: Io, dir: Io.Dir, path: []const u8, scope: Scope, options: Options, diag: ?*Diagnostics) LoadError!void { var l: Loader = .{ .config = c, .io = io, .dir = dir, .options = options, .diag = diag, .plan = .{ .file = .{ .path = path, .scope = scope } }, .scratch = .init(c.gpa) }; defer l.deinit(); try l.loadPlan(.load);}
/// Adds the variables of a file already read, as `git config --file -`/// reads standard input or `--blob` a blob.pub fn addContents(c: *Config, io: Io, dir: Io.Dir, contents: []const u8, origin: Origin, scope: Scope, options: Options, diag: ?*Diagnostics) LoadError!void { var l: Loader = .{ .config = c, .io = io, .dir = dir, .options = options, .diag = diag, .plan = .{ .contents = .{ .contents = contents, .origin = origin, .scope = scope } }, .scratch = .init(c.gpa) }; defer l.deinit(); try l.loadPlan(.load);}
/// Whether `extensions.worktreeConfig` is set in the file `contents`/// itself, as git decides whether to read `config.worktree`.pub fn worktreeConfigEnabled(gpa: Allocator, contents: []const u8) Allocator.Error!bool { var p: syntax.Parser = .init(contents); defer p.deinit(gpa); var enabled = false; while (p.next(gpa) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.BadConfigLine => return enabled, }) |e| { if (std.mem.eql(u8, e.key, "extensions.worktreeconfig")) enabled = value.parseBool(e.value) orelse false; } return enabled;}
fn append(c: *Config, key: []const u8, val: ?[]const u8, scope: Scope, origin: Origin, line: u32) Allocator.Error!*const Entry { const a = c.arena.allocator(); const gop = try c.index.getOrPut(c.gpa, key); if (!gop.found_existing) { gop.key_ptr.* = try a.dupe(u8, key); gop.value_ptr.* = .empty; } try gop.value_ptr.append(c.gpa, @intCast(c.entries.items.len)); try c.entries.append(c.gpa, .{ .key = gop.key_ptr.*, .value = if (val) |v| try a.dupe(u8, v) else null, .scope = scope, .origin = origin, .line = line, }); return &c.entries.items[c.entries.items.len - 1];}
/// What was planned to be read: everything, one file, or contents in/// hand. `hasconfig:` conditions read the same plan again to find the/// remote URLs.const Plan = union(enum) { sources: Sources, file: struct { path: []const u8, scope: Scope }, contents: struct { contents: []const u8, origin: Origin, scope: Scope },};
const max_include_depth = 10;
const Loader = struct { config: *Config, io: Io, dir: Io.Dir, options: Options, diag: ?*Diagnostics, plan: Plan, /// Whether this is the pass that only looks for remote URLs. mode: Mode = .load, /// Inside a file `hasconfig:` included. in_hasconfig: bool = false, /// The remote URLs, once a `hasconfig:` condition has asked. remote_urls: ?std.ArrayList([]const u8) = null, /// Holds what the loader makes along the way. scratch: std.heap.ArenaAllocator,
const Mode = enum { load, scan };
fn deinit(l: *Loader) void { if (l.remote_urls) |*u| u.deinit(l.config.gpa); l.scratch.deinit(); }
fn setDiag(l: *Loader, path: ?[]const u8, line: ?u32, comptime fmt: []const u8, args: anytype) void { const d = l.diag orelse return; d.reset(); if (path) |p| d.setPath(&.{p}); d.line = line; d.setDetail(fmt, args); }
fn loadPlan(l: *Loader, mode: Mode) LoadError!void { l.mode = mode; switch (l.plan) { .sources => |s| { if (s.system) |p| try l.loadFile(p, .system, .optional); if (s.xdg_global) |p| try l.loadFile(p, .global, .optional); if (s.home_global) |p| try l.loadFile(p, .global, .optional); if (s.local) |p| { const contents = try l.readTopLevel(p, .optional); if (contents) |bytes| { try l.loadContents(bytes, .{ .kind = .file, .name = p }, .local, 0); if (s.worktree) |w| if (try worktreeConfigEnabled(l.config.gpa, bytes)) { try l.loadFile(w, .worktree, .optional); }; } } if (s.environment) |env| try l.loadCount(env); if (s.parameters) |p| try l.loadParameters(p); for (s.command_line) |arg| { const eq = std.mem.findScalar(u8, arg, '='); const key = if (eq) |e| arg[0..e] else arg; try l.addCommand(key, if (eq) |e| arg[e + 1 ..] else null); } }, .file => |f| try l.loadFile(f.path, f.scope, .required), .contents => |c| try l.loadContents(c.contents, c.origin, c.scope, 0), } }
const Need = enum { optional, required };
fn readTopLevel(l: *Loader, path: []const u8, need: Need) LoadError!?[]u8 { return l.dir.readFileAlloc(l.io, path, l.scratch.allocator(), .unlimited) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.Canceled => |e| return e, error.FileNotFound => if (need == .optional) return null else { l.setDiag(path, null, "{s}", .{reason(err)}); return error.ConfigFileUnreadable; }, else => if (need == .optional) return null else { l.setDiag(path, null, "{s}", .{reason(err)}); return error.ConfigFileUnreadable; }, }; }
fn loadFile(l: *Loader, path: []const u8, scope: Scope, need: Need) LoadError!void { const contents = try l.readTopLevel(path, need) orelse return; try l.loadContents(contents, .{ .kind = .file, .name = path }, scope, 0); }
fn loadContents(l: *Loader, contents: []const u8, given_origin: Origin, scope: Scope, depth: u32) LoadError!void { const gpa = l.config.gpa; // The origin's name must outlive the file it came from. var origin = given_origin; if (l.mode == .load and origin.name.len != 0) origin.name = try l.config.arena.allocator().dupe(u8, origin.name); var p: syntax.Parser = .init(contents); defer p.deinit(gpa); while (p.next(gpa) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.BadConfigLine => { l.setDiag(origin.name, p.line, "", .{}); return error.BadConfigLine; }, }) |e| { try l.entry(e.key, e.value, scope, origin, e.line, depth); } }
/// Takes one variable: keeps it, or in the scanning pass notes a /// remote URL, and follows it if it is an include. fn entry(l: *Loader, key: []const u8, val: ?[]const u8, scope: Scope, origin: Origin, line: u32, depth: u32) LoadError!void { const remote_url = isRemoteUrl(key); if (remote_url and l.in_hasconfig) { l.setDiag(origin.name, line, "", .{}); return error.RemoteUrlInHasconfigInclude; } switch (l.mode) { .load => _ = try l.config.append(key, val, scope, origin, line), .scan => if (remote_url) if (val) |v| { try l.remote_urls.?.append(l.config.gpa, try l.scratch.allocator().dupe(u8, v)); }, } if (l.options.includes) try l.include(key, val, scope, origin, line, depth); }
fn include(l: *Loader, key: []const u8, val: ?[]const u8, scope: Scope, origin: Origin, line: u32, depth: u32) LoadError!void { var hasconfig = false; if (!std.mem.eql(u8, key, "include.path")) { const parts = syntax.splitKey(key); if (!std.mem.eql(u8, parts.section, "includeif") or !std.mem.eql(u8, parts.name, "path")) return; const condition = parts.subsection orelse return; switch (try l.conditionMet(condition, origin, line)) { .unmet => return, .met => {}, .met_hasconfig => hasconfig = true, } } // git names every include so, whatever its condition. const raw = val orelse { l.setDiag(origin.name, line, "include.path", .{}); return error.MissingValue; }; const a = l.scratch.allocator(); const expanded = value.expandPath(a, raw, l.options.path) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.UserHomeUnknown => { l.setDiag(origin.name, line, "{s}", .{raw}); return error.UserHomeUnknown; }, error.PrefixUnknown => { l.setDiag(origin.name, line, "{s}", .{raw}); return error.PrefixUnknown; }, }; var path: []const u8 = expanded.path; if (!std.fs.path.isAbsolute(path)) { if (origin.kind != .file) { l.setDiag(null, null, "", .{}); return error.RelativeIncludeOutsideFile; } path = try joinRelative(a, origin.name, path); } if (depth >= max_include_depth) { l.setDiag(path, line, "{s}", .{origin.name}); return error.IncludeDepthExceeded; } const contents = l.dir.readFileAlloc(l.io, path, a, .unlimited) catch |err| switch (err) { error.OutOfMemory, error.Canceled => |e| return e, error.FileNotFound => return, error.IsDir => { l.setDiag(origin.name, line, "{s}", .{path}); return error.IncludeIsDirectory; }, else => { l.setDiag(path, line, "{s}", .{reason(err)}); return error.IncludeUnreadable; }, }; const was = l.in_hasconfig; defer l.in_hasconfig = was; if (hasconfig) l.in_hasconfig = true; try l.loadContents(contents, .{ .kind = .file, .name = path }, scope, depth + 1); }
const Condition = enum { met, met_hasconfig, unmet };
fn conditionMet(l: *Loader, condition: []const u8, origin: Origin, line: u32) LoadError!Condition { const colon = std.mem.findScalar(u8, condition, ':') orelse return .unmet; const keyword = condition[0..colon]; const data = condition[colon + 1 ..]; const Keyword = enum { gitdir, @"gitdir/i", onbranch, hasconfig, worktree, @"worktree/i" }; const kw = std.meta.stringToEnum(Keyword, keyword) orelse return .unmet; switch (kw) { .gitdir, .@"gitdir/i", .worktree, .@"worktree/i" => { const repo = l.options.repository; const casefold = kw == .@"gitdir/i" or kw == .@"worktree/i"; const pattern = (try l.dirPattern(data, origin, line)) orelse return .met; const flags: wildmatch.Flags = .{ .pathname = true, .casefold = casefold }; const r = repo orelse return .unmet; if (kw == .gitdir or kw == .@"gitdir/i") { if (r.vtable.gitDir(r.context)) |g| if (wildmatch.match(pattern, g, flags)) return .met; if (r.vtable.realGitDir(r.context)) |g| if (wildmatch.match(pattern, g, flags)) return .met; } else { if (r.vtable.workTree(r.context)) |w| if (wildmatch.match(pattern, w, flags)) return .met; } return .unmet; }, .onbranch => { const r = l.options.repository orelse return .unmet; const branch = (r.vtable.branch(r.context) catch |err| { l.setDiag(origin.name, line, "", .{}); return err; }) orelse return .unmet; const a = l.scratch.allocator(); const pattern = if (std.mem.endsWith(u8, data, "/")) try std.mem.concat(a, u8, &.{ data, "**" }) else data; return if (wildmatch.match(pattern, branch, .{ .pathname = true })) .met else .unmet; }, .hasconfig => { const prefix = "remote.*.url:"; if (!std.mem.startsWith(u8, data, prefix)) return .unmet; // Never while looking for the URLs themselves. if (l.mode == .scan) return .unmet; const pattern = data[prefix.len..]; const urls = try l.remoteUrls(); for (urls) |url| if (wildmatch.match(pattern, url, .{ .pathname = true })) return .met_hasconfig; return .unmet; }, } }
/// A `gitdir:` or `worktree:` pattern made whole: `~` and `./` /// expanded, `**/` put before a relative one and `**` after one /// ending in a slash. Null when git takes the condition as met /// without matching: a `./` pattern outside a file, which it also /// reports. fn dirPattern(l: *Loader, data: []const u8, origin: Origin, line: u32) LoadError!?[]const u8 { const a = l.scratch.allocator(); var pattern: []const u8 = data; if (std.mem.startsWith(u8, data, "~")) { // `~` alone as well as `~/`, as git expands any path. const expanded = value.expandPath(a, data, l.options.path) catch |err| switch (err) { error.OutOfMemory => |e| return e, else => { l.setDiag(origin.name, line, "{s}", .{data}); return error.UserHomeUnknown; }, }; pattern = expanded.path; } else if (std.mem.startsWith(u8, data, "./")) { if (origin.kind != .file) { if (l.mode == .load) try l.config.messages.append(l.config.gpa, .{ .kind = .relative_condition_outside_file, .origin = origin, .line = line, }); return null; } const dir = std.fs.path.dirname(origin.name) orelse ""; pattern = try std.mem.concat(a, u8, &.{ dir, data[1..] }); } const anchored = std.mem.startsWith(u8, pattern, "/") or std.mem.startsWith(u8, data, "./"); if (!anchored) pattern = try std.mem.concat(a, u8, &.{ "**/", pattern }); if (std.mem.endsWith(u8, pattern, "/")) pattern = try std.mem.concat(a, u8, &.{ pattern, "**" }); return pattern; }
/// Every remote URL in the configuration, found by reading the whole /// plan once more without applying anything. fn remoteUrls(l: *Loader) LoadError![]const []const u8 { if (l.remote_urls) |u| return u.items; var scan: Loader = .{ .config = l.config, .io = l.io, .dir = l.dir, .options = l.options, .diag = l.diag, .plan = l.plan, .remote_urls = .empty, .scratch = .init(l.config.gpa), }; defer scan.scratch.deinit(); errdefer scan.remote_urls.?.deinit(l.config.gpa); try scan.loadPlan(.scan); var urls: std.ArrayList([]const u8) = .empty; errdefer urls.deinit(l.config.gpa); for (scan.remote_urls.?.items) |u| try urls.append(l.config.gpa, try l.scratch.allocator().dupe(u8, u)); scan.remote_urls.?.deinit(l.config.gpa); l.remote_urls = urls; return urls.items; }
fn addCommand(l: *Loader, key: []const u8, val: ?[]const u8) LoadError!void { if (key.len == 0) { l.setDiag(null, null, "", .{}); return error.EmptyKey; } const buffer = try l.scratch.allocator().alloc(u8, key.len); const canonical = syntax.canonicalKey(key, buffer) catch |err| { l.setDiag(null, null, "{s}", .{key}); return err; }; try l.entry(canonical, val, .command, .command_line, 0, 0); }
fn loadCount(l: *Loader, env: *const std.process.Environ.Map) LoadError!void { const text = env.get("GIT_CONFIG_COUNT") orelse return; const count = parameters.parseCount(text) catch |err| { l.setDiag(null, null, "", .{}); return switch (err) { error.BogusCount => error.BogusCount, error.TooManyEntries => error.TooManyEntries, }; }; var name_buffer: [64]u8 = undefined; for (0..count) |i| { const key_name = std.fmt.bufPrint(&name_buffer, "GIT_CONFIG_KEY_{d}", .{i}) catch unreachable; const key = env.get(key_name) orelse { l.setDiag(null, null, "{s}", .{key_name}); return error.MissingCountKey; }; const value_name = std.fmt.bufPrint(&name_buffer, "GIT_CONFIG_VALUE_{d}", .{i}) catch unreachable; const val = env.get(value_name) orelse { l.setDiag(null, null, "{s}", .{value_name}); return error.MissingCountValue; }; try l.addCommand(key, val); } }
fn loadParameters(l: *Loader, text: []const u8) LoadError!void { const gpa = l.config.gpa; var it = parameters.iterate(text); defer it.deinit(gpa); while (it.next(gpa) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.BogusFormat => { l.setDiag(null, null, "", .{}); return error.BogusParameters; }, }) |p| { try l.addCommand(p.key, p.value); } }};
fn isRemoteUrl(key: []const u8) bool { const parts = syntax.splitKey(key); return parts.subsection != null and std.mem.eql(u8, parts.section, "remote") and std.mem.eql(u8, parts.name, "url");}
/// `path` relative to the directory of the file `from`, joined as git/// joins them: with no tidying, so that `a/../b` stays as it is.fn joinRelative(a: Allocator, from: []const u8, path: []const u8) Allocator.Error![]u8 { const slash = std.mem.findScalarLast(u8, from, '/') orelse return a.dupe(u8, path); return std.mem.concat(a, u8, &.{ from[0 .. slash + 1], path });}
/// The words the C library uses for a file error, which git prints.pub fn reason(err: anyerror) []const u8 { return switch (err) { error.FileNotFound => "No such file or directory", error.AccessDenied, error.PermissionDenied => "Permission denied", error.IsDir => "Is a directory", error.NotDir => "Not a directory", error.SymLinkLoop => "Too many levels of symbolic links", error.NameTooLong => "File name too long", else => @errorName(err), };}
// -- lookups -------------------------------------------------------------------
/// Why a lookup failed: a key git would refuse, or a value that is not of/// the type asked for.pub const LookupError = syntax.KeyError || Allocator.Error;
/// The entries for `key` (`section.subsection.name`, in any case but the/// subsection's), in the order they were read.pub fn all(c: *const Config, key: []const u8) LookupError!EntryIterator { const buffer = try c.gpa.alloc(u8, key.len); defer c.gpa.free(buffer); const canonical = try syntax.canonicalKey(key, buffer); const list = c.index.get(canonical) orelse return .{ .config = c, .indices = &.{} }; return .{ .config = c, .indices = list.items };}
/// Steps through some of a configuration's entries.pub const EntryIterator = struct { config: *const Config, indices: []const u32, pos: usize = 0,
/// The next entry, or null after the last. pub fn next(it: *EntryIterator) ?*const Entry { if (it.pos == it.indices.len) return null; defer it.pos += 1; return &it.config.entries.items[it.indices[it.pos]]; }
/// How many entries there are in all. pub fn count(it: *const EntryIterator) usize { return it.indices.len; }};
/// The last entry for `key`, which is the one that counts; null if it is/// not set.pub fn get(c: *const Config, key: []const u8) LookupError!?*const Entry { const it = try c.all(key); if (it.indices.len == 0) return null; return &c.entries.items[it.indices[it.indices.len - 1]];}
/// What a typed lookup can find wrong with a value.pub const ValueError = error{ /// A variable that needs a value was written without one. MissingValue, /// Not a boolean: git's "bad boolean config value". BadBool,} || value.IntError || LookupError;
/// `key`'s value as a string; null if unset, `MissingValue` if it was/// written without `=`.pub fn getString(c: *const Config, key: []const u8) ValueError!?[]const u8 { const e = try c.get(key) orelse return null; return e.value orelse error.MissingValue;}
/// `key`'s value as a boolean; null if unset.pub fn getBool(c: *const Config, key: []const u8) ValueError!?bool { const e = try c.get(key) orelse return null; return value.parseBool(e.value) orelse error.BadBool;}
/// `key`'s value as a 64-bit integer, units applied; null if unset.pub fn getInt(c: *const Config, key: []const u8) ValueError!?i64 { const e = try c.get(key) orelse return null; return try value.parseInt(i64, e.value orelse "");}
/// `key`'s value as a path, expanded with `context` and allocated with/// `gpa`; null if unset. A `:(optional)` path is returned with `optional`/// set, for the caller to drop if nothing is there.pub fn getPath(c: *const Config, gpa: Allocator, key: []const u8, context: value.PathContext) (ValueError || value.PathError)!?value.Path { const e = try c.get(key) orelse return null; return try value.expandPath(gpa, e.value orelse return error.MissingValue, context);}
const testing = std.testing;const fixture = @import("testing/fixture.zig");
test { _ = syntax; _ = value; _ = parameters;}
/// The configuration of `repo` as the library reads it, with the/// fixture's environment.fn loadRepo(repo: *fixture.Repo, branch: ?[]const u8) !Config { var dir = try Io.Dir.cwd().openDir(repo.io, repo.path, .{}); defer dir.close(repo.io); var sources = try Sources.fromEnvironment(repo.gpa, &repo.env, .{ .git_dir = ".git" }); defer sources.deinit(repo.gpa); const git_dir = try std.fs.path.join(repo.gpa, &.{ repo.path, ".git" }); defer repo.gpa.free(git_dir); const fixed: Repository.Fixed = .{ .git_dir = git_dir, .branch = branch, .work_tree = repo.path }; return load(repo.gpa, repo.io, dir, sources, .{ .path = .{ .home = repo.env.get("HOME") }, .repository = fixed.repository() }, null);}
test "every entry is read in git's order, with its scope and origin" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); _ = repo.env.swapRemove("GIT_CONFIG_GLOBAL"); try repo.env.put("XDG_CONFIG_HOME", "/nonexistent"); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = ".gitconfig", .data = "[user]\n\tname = Global\n[include]\n\tpath = inc\n[includeIf \"onbranch:main\"]\n\tpath = branch.inc\n" }); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = "inc", .data = "[core]\n\tabbrev = 12\n" }); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = "branch.inc", .data = "[on]\n\tmain = yes\n" }); try repo.git(&.{ "config", "Remote.Origin.URL", "https://example.com/x.git" }); try repo.git(&.{ "config", "--add", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*" }); try repo.git(&.{ "config", "--add", "remote.origin.fetch", "^refs/heads/secret" }); try repo.git(&.{ "config", "x.size", "1k" }); try repo.git(&.{ "config", "x.flag", "on" }); try repo.git(&.{ "config", "x.path", "~/somewhere" }); try repo.env.put("GIT_CONFIG_PARAMETERS", "'x.Cmd'='line'");
var c = try loadRepo(&repo, "main"); defer c.deinit(); const listed = try repo.output(&.{ "config", "--list", "--show-scope", "--show-origin", "-z" }); defer gpa.free(listed); var ours: Io.Writer.Allocating = .init(gpa); defer ours.deinit(); for (c.entries.items) |e| { try ours.writer.print("{s}\x00{f}\x00{s}", .{ e.scope.name(), e.origin, e.key }); if (e.value) |v| try ours.writer.print("\n{s}", .{v}); try ours.writer.writeByte(0); } try testing.expectEqualStrings(listed, ours.written());
// Lookups, as `git config --get` and its types answer them. try testing.expectEqualStrings("Global", (try c.getString("USER.NAME")).?); // The subsection keeps the case it was written in. try testing.expectEqualStrings("https://example.com/x.git", (try c.getString("REMOTE.Origin.url")).?); try testing.expectEqual(null, try c.get("remote.origin.url")); var fetch = try c.all("remote.origin.fetch"); try testing.expectEqual(2, fetch.count()); try testing.expectEqualStrings("+refs/heads/*:refs/remotes/origin/*", fetch.next().?.value.?); try testing.expectEqual(1024, (try c.getInt("x.size")).?); try testing.expectEqual(12, (try c.getInt("core.abbrev")).?); try testing.expectEqual(true, (try c.getBool("x.flag")).?); try testing.expectEqual(true, (try c.getBool("on.main")).?); try testing.expectEqual(null, try c.getBool("x.unset")); try testing.expectError(error.BadBool, c.getBool("user.name")); try testing.expectError(error.InvalidUnit, c.getInt("user.name")); try testing.expectError(error.KeyHasNoSection, c.get("nosection")); const p = (try c.getPath(gpa, "x.path", .{ .home = repo.path })).?; defer gpa.free(p.path); const want_path = try repo.output(&.{ "config", "--type=path", "--get", "x.path" }); defer gpa.free(want_path); try testing.expectEqualStrings(std.mem.trimEnd(u8, want_path, "\n"), p.path); try testing.expectEqual(Scope.command, (try c.get("x.cmd")).?.scope);
// On another branch, the condition is not met. var other = try loadRepo(&repo, "other"); defer other.deinit(); try testing.expectEqual(null, try other.get("on.main"));}
test "a hasconfig condition sees remote URLs read after it" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); _ = repo.env.swapRemove("GIT_CONFIG_GLOBAL"); try repo.env.put("XDG_CONFIG_HOME", "/nonexistent"); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = ".gitconfig", .data = "[includeIf \"hasconfig:remote.*.url:https://example.com/**\"]\n\tpath = hit.inc\n" }); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = "hit.inc", .data = "[t]\n\tv = hit\n" }); var before = try loadRepo(&repo, null); defer before.deinit(); try testing.expectEqual(null, try before.get("t.v")); try repo.git(&.{ "config", "remote.origin.url", "https://example.com/a/b.git" }); var after = try loadRepo(&repo, null); defer after.deinit(); try testing.expectEqualStrings("hit", (try after.getString("t.v")).?); const want = try repo.output(&.{ "config", "--get", "t.v" }); defer gpa.free(want); try testing.expectEqualStrings("hit\n", want);
// A file it includes may not name a remote URL. try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = "hit.inc", .data = "[remote \"x\"]\n\turl = y\n" }); var diag: Diagnostics = .{}; var dir = try Io.Dir.cwd().openDir(repo.io, repo.path, .{}); defer dir.close(repo.io); var sources = try Sources.fromEnvironment(gpa, &repo.env, .{ .git_dir = ".git" }); defer sources.deinit(gpa); try testing.expectError(error.RemoteUrlInHasconfigInclude, load(gpa, repo.io, dir, sources, .{}, &diag));}