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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `zig-git check-attr`: attributes reported as `git check-attr` reports//! them, and the attributes every other command reads, set up as git sets//! them up for a repository.//!//! zig-git check-attr [--source <tree-ish>] [--cached] [-z] [-a | --all | <attr>...] [--] <pathname>...//! zig-git check-attr --stdin [-z] [--source <tree-ish>] [--cached] [-a | --all | <attr>...]//!//! The in-tree files come from the working tree with the index behind it,//! from the index alone (`--cached`), or from a tree (`--source`,//! `GIT_ATTR_SOURCE`, `attr.tree`); a bare repository reads none unless//! given a tree. Long options cannot be abbreviated, nor short ones//! bundled, as git allows.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const git = @import("git");const Attributes = git.Attributes;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 ls_files = @import("ls_files.zig");
const usage = @embedFile("check_attr_usage.txt");
/// Where in-tree attribute files are read from.pub const From = union(enum) { /// The working tree, the index behind it. worktree, /// The index alone. index, /// A tree; null for none at all. tree: ?git.ObjectId,};
/// The attributes of a repository's paths, and what reading them needs/// kept alive.pub const Setup = struct { gpa: Allocator, attributes: Attributes, index: ?git.Index = null, tree_source: Attributes.TreeSource = undefined, index_source: Attributes.IndexSource = undefined, worktree_source: Attributes.WorktreeSource = undefined, outside: std.ArrayList([]u8) = .empty,
/// Frees everything. pub fn deinit(s: *Setup) void { s.attributes.deinit(); if (s.index) |*ix| ix.deinit(); for (s.outside.items) |b| s.gpa.free(b); s.outside.deinit(s.gpa); s.gpa.destroy(s); }
/// Writes the warnings reading has given since the last call. pub fn flush(s: *Setup, stderr: *Io.Writer) !void { try stderr.writeAll(s.attributes.takeWarnings()); }
/// Checks `path`, saying what reading it found to `stderr`. pub fn check(s: *Setup, path: []const u8, stderr: *Io.Writer) !Attributes.Result { const r = s.attributes.check(path) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.AttributesUnreadable => return tool.fatalMessage(stderr, "zig-git: cannot read the attributes for '{s}'", .{path}), }; try s.flush(stderr); return r; }};
fn readOutside(s: *Setup, ctx: Context, path: []const u8, origin: []const u8) !?Attributes.Outside { const bytes = Io.Dir.cwd().readFileAlloc(ctx.io, path, s.gpa, .unlimited) catch return null; errdefer s.gpa.free(bytes); try s.outside.append(s.gpa, bytes); const o = try s.gpa.dupe(u8, origin); try s.outside.append(s.gpa, o); return .{ .contents = bytes, .origin = o };}
/// The tree attributes come from when nothing on the command line says:/// `GIT_ATTR_SOURCE`, then `attr.tree` (ignored when it names no tree).pub fn configuredTree(ctx: Context, repo: *git.Repository, revs: *revisions.Revisions, stderr: *Io.Writer) !?From { if (ctx.env.get("GIT_ATTR_SOURCE")) |text| { const id = resolveTree(revs, text) orelse return tool.fatalMessage(stderr, "bad --attr-source or GIT_ATTR_SOURCE", .{}); return .{ .tree = id }; } const e = (repo.config.get("attr.tree") catch null) orelse return null; const text = e.value orelse return null; if (resolveTree(revs, text)) |id| return .{ .tree = id }; return null;}
/// What a revision names for attributes: null when it names nothing,/// and otherwise its tree, null again for an object that has none, which/// git reads as holding no files.pub fn resolveTree(revs: *revisions.Revisions, text: []const u8) ??git.ObjectId { var diag: Diagnostics = .{}; const found = revs.resolver.resolve(text, &diag) catch { revs.resolver.clearMessages(); return null; }; revs.resolver.clearMessages(); return @as(?git.ObjectId, (revs.resolver.peelToTree(found.id) catch null) orelse null);}
/// Sets up attributes for `repo`, as git reads them there.pub fn setup(ctx: Context, repo: *git.Repository, from: From, stderr: *Io.Writer) !*Setup { const gpa = ctx.gpa; const s = try gpa.create(Setup); s.* = .{ .gpa = gpa, .attributes = undefined }; var attributes_ready = false; errdefer if (attributes_ready) s.deinit() else gpa.destroy(s);
var options: Attributes.Options = .{ .ignore_case = try ls_files.boolSetting(repo, "core.ignoreCase", stderr) orelse false, }; const no_system = if (ctx.env.get("GIT_ATTR_NOSYSTEM")) |v| git.Config.value.parseBool(v) orelse false else false; if (!no_system) options.system = try readOutside(s, ctx, "/etc/gitattributes", "/etc/gitattributes"); const home = ctx.env.get("HOME"); if (repo.config.getPath(gpa, "core.attributesFile", .{ .home = home }) catch null) |p| { defer gpa.free(p.path); const abs = try std.fs.path.resolve(gpa, &.{ repo.location.work_dir, p.path }); defer gpa.free(abs); options.global = try readOutside(s, ctx, abs, p.path); } else { const xdg = ctx.env.get("XDG_CONFIG_HOME"); const path = if (xdg != null and xdg.?.len != 0) try std.fmt.allocPrint(gpa, "{s}/git/attributes", .{xdg.?}) else if (home) |h| try std.fmt.allocPrint(gpa, "{s}/.config/git/attributes", .{h}) else null; if (path) |p| { defer gpa.free(p); const abs = try std.fs.path.resolve(gpa, &.{ repo.location.work_dir, p }); defer gpa.free(abs); options.global = try readOutside(s, ctx, abs, p); } } { const info = try std.fmt.allocPrint(gpa, "{s}/info/attributes", .{repo.location.common_dir}); defer gpa.free(info); const abs = try std.fs.path.resolve(gpa, &.{ repo.location.work_dir, info }); defer gpa.free(abs); options.info = try readOutside(s, ctx, abs, info); }
const bare = repo.location.work_tree == null; var source: Attributes.Source = undefined; switch (from) { .tree => |t| { s.tree_source = .{ .io = ctx.io, .db = repo.objects(), .format = repo.objectFormat(), .root = t }; source = s.tree_source.source(); }, .index, .worktree => if (bare) { options.in_tree = false; s.tree_source = .{ .io = ctx.io, .db = repo.objects(), .format = repo.objectFormat(), .root = null }; source = s.tree_source.source(); } else { var diag: Diagnostics = .{}; s.index = repo.readIndex(ctx.io, .{ .index_file = ctx.env.get("GIT_INDEX_FILE") }, &diag) catch |err| return ls_files.describe(stderr, err, &diag); if (s.index.?.hasSparseDirectories()) return tool.fatalMessage(stderr, "zig-git: attributes in a sparse index are not supported", .{}); s.index_source = .{ .io = ctx.io, .db = repo.objects(), .index = &s.index.? }; if (from == .index) { source = s.index_source.source(); } else { s.worktree_source = .{ .io = ctx.io, .dir = repo.work_dir, .index = s.index_source }; source = s.worktree_source.source(); } }, } s.attributes = try Attributes.init(gpa, source, options); attributes_ready = true; return s;}
fn usageError(stderr: *Io.Writer, comptime fmt: []const u8, args: anytype) error{UsageQuiet} { stderr.print("error: " ++ fmt ++ "\n", args) catch {}; stderr.writeAll(usage) catch {}; return error.UsageQuiet;}
/// A path given on the command line, made relative to the top of the/// working tree from `prefix`; null when it leaves the tree. A trailing/// `/` is kept.pub fn normalize(gpa: Allocator, prefix: []const u8, work_tree: ?[]const u8, arg: []const u8) !?[]u8 { var parts: std.ArrayList([]const u8) = .empty; defer parts.deinit(gpa); var rel = arg; if (std.fs.path.isAbsolute(arg)) { const top = work_tree orelse return null; if (!std.mem.startsWith(u8, arg, top)) return null; rel = arg[top.len..]; if (rel.len != 0 and rel[0] != '/') return null; } else { var it = std.mem.tokenizeScalar(u8, prefix, '/'); while (it.next()) |p| try parts.append(gpa, p); } var it = std.mem.tokenizeScalar(u8, rel, '/'); while (it.next()) |p| { if (std.mem.eql(u8, p, ".")) continue; if (std.mem.eql(u8, p, "..")) { if (parts.items.len == 0) return null; _ = parts.pop(); continue; } try parts.append(gpa, p); } var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); for (parts.items, 0..) |p, i| { if (i != 0) try out.append(gpa, '/'); try out.appendSlice(gpa, p); } if (arg.len != 0 and arg[arg.len - 1] == '/' and out.items.len != 0) try out.append(gpa, '/'); return try out.toOwnedSlice(gpa);}
/// Runs `zig-git check-attr`.pub fn run(ctx: Context, args: []const []const u8, stdin: *Io.Reader, stdout: *Io.Writer, stderr: *Io.Writer) !void { const gpa = ctx.gpa; var all = false; var cached = false; var read_stdin = false; var nul = false; var source: ?[]const u8 = null; var rest: std.ArrayList([]const u8) = .empty; defer rest.deinit(gpa); var dashdash: ?usize = null; var i: usize = 0; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--")) { dashdash = rest.items.len; try rest.appendSlice(gpa, args[i + 1 ..]); break; } if (a.len < 2 or a[0] != '-') { try rest.append(gpa, a); continue; } if (std.mem.eql(u8, a, "-a") or std.mem.eql(u8, a, "--all")) { all = true; } else if (std.mem.eql(u8, a, "--no-all")) { all = false; } else if (std.mem.eql(u8, a, "--cached")) { cached = true; } else if (std.mem.eql(u8, a, "--no-cached")) { cached = false; } else if (std.mem.eql(u8, a, "--stdin")) { read_stdin = true; } else if (std.mem.eql(u8, a, "--no-stdin")) { read_stdin = false; } else if (std.mem.eql(u8, a, "-z")) { nul = true; } else if (std.mem.eql(u8, a, "--source")) { i += 1; if (i >= args.len) { stderr.writeAll("error: option `source' requires a value\n") catch {}; return error.UsageQuiet; } source = args[i]; } else if (std.mem.startsWith(u8, a, "--source=")) { source = a["--source=".len..]; } else if (std.mem.eql(u8, a, "--no-source")) { source = null; } else if (std.mem.startsWith(u8, a, "--")) { return usageError(stderr, "unknown option `{s}'", .{a[2..]}); } else { return usageError(stderr, "unknown switch `{c}'", .{a[1]}); } }
// Which arguments are attributes and which paths. var attr_names: []const []const u8 = &.{}; var paths: []const []const u8 = &.{}; if (read_stdin) { if (dashdash != null) return usageError(stderr, "Can't specify files with --stdin", .{}); attr_names = rest.items; } else if (dashdash) |d| { attr_names = rest.items[0..d]; paths = rest.items[d..]; } else if (all) { paths = rest.items; } else if (rest.items.len != 0) { attr_names = rest.items[0..1]; paths = rest.items[1..]; } if (all and attr_names.len != 0) return usageError(stderr, "Attributes and --all both specified", .{}); if (!all and attr_names.len == 0) return usageError(stderr, "No attribute specified", .{}); if (!read_stdin and paths.len == 0) { if (read_stdin) {} else return usageError(stderr, "No file specified", .{}); } if (read_stdin and paths.len != 0) return usageError(stderr, "Can't specify files with --stdin", .{});
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 from: From = if (cached) .index else .worktree; if (source) |text| { const id = resolveTree(&revs, text) orelse return tool.fatalMessage(stderr, "{s}: not a valid tree-ish source", .{text}); from = .{ .tree = id }; } else if (try configuredTree(ctx, &repo, &revs, stderr)) |f| from = f; const s = try setup(ctx, &repo, from, stderr); defer s.deinit();
// The names asked about, numbered before any file is read. var ids: std.ArrayList(?u32) = .empty; defer ids.deinit(gpa); for (attr_names) |n| { if (!Attributes.validQueryName(n)) { stderr.print("error: {s}: not a valid attribute name\n", .{n}) catch {}; return error.Status255; } try ids.append(gpa, if (std.mem.eql(u8, n, "builtin_objectmode")) null else try s.attributes.intern(n)); }
const quote_path = try ls_files.boolSetting(&repo, "core.quotePath", stderr) orelse true; const prefix = repo.location.prefix orelse ""; var c: Checker = .{ .ctx = ctx, .repo = &repo, .setup = s, .from = from, .all = all, .nul = nul, .quote_path = quote_path, .prefix = prefix, .names = attr_names, .ids = ids.items, .stdout = stdout, .stderr = stderr, }; if (!read_stdin) { for (paths) |p| try c.one(p); return; } var line: std.ArrayList(u8) = .empty; defer line.deinit(gpa); var unquoted: std.ArrayList(u8) = .empty; defer unquoted.deinit(gpa); while (true) { line.clearRetainingCapacity(); const delim: u8 = if (nul) 0 else '\n'; var w: Io.Writer.Allocating = .fromArrayList(gpa, &line); const n = stdin.streamDelimiterEnding(&w.writer, delim) catch |err| { line = w.toArrayList(); return err; }; line = w.toArrayList(); const ended = stdin.takeByte() catch |err| switch (err) { error.EndOfStream => null, else => |e| return e, }; if (n == 0 and ended == null) break; var p: []const u8 = line.items; if (!nul and p.len != 0 and p[0] == '"') { unquoted.clearRetainingCapacity(); if (try git.quote.unquote(gpa, p, &unquoted)) |got| { if (got.len != p.len) return tool.fatalMessage(stderr, "line is badly quoted", .{}); p = unquoted.items; } else return tool.fatalMessage(stderr, "line is badly quoted", .{}); } try c.one(p); if (ended == null) break; }}
const Checker = struct { ctx: Context, repo: *git.Repository, setup: *Setup, from: From, all: bool, nul: bool, quote_path: bool, prefix: []const u8, names: []const []const u8, ids: []const ?u32, stdout: *Io.Writer, stderr: *Io.Writer,
fn one(c: *Checker, arg: []const u8) !void { const gpa = c.ctx.gpa; const full = try normalize(gpa, c.prefix, c.repo.location.work_tree, arg) orelse return tool.fatalMessage(c.stderr, "'{s}' is outside repository at '{s}'", .{ arg, c.repo.location.work_tree orelse c.repo.location.work_dir }); defer gpa.free(full); const r = try c.setup.check(full, c.stderr); if (c.all) { for (r.states, 0..) |st, id| { const state = st orelse continue; if (state == .unspecified) continue; try c.write(arg, c.setup.attributes.names.name(@intCast(id)), state.text()); } return; } for (c.names, c.ids) |n, maybe_id| { if (maybe_id) |id| { try c.write(arg, n, r.get(id).text()); } else { var buf: [8]u8 = undefined; try c.write(arg, n, try c.objectMode(full, &buf)); } } }
/// `builtin_objectmode`: the mode in the working tree, the index or /// the tree, as attributes come from. fn objectMode(c: *Checker, full: []const u8, buf: *[8]u8) ![]const u8 { const path = std.mem.trimEnd(u8, full, "/"); switch (c.from) { .worktree => { if (c.repo.location.work_tree == null) return "unspecified"; const st = c.repo.work_dir.statFile(c.ctx.io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => return tool.fatalMessage(c.stderr, "unable to stat '{s}': No such file or directory", .{path}), else => return tool.fatalMessage(c.stderr, "unable to stat '{s}': {t}", .{ path, err }), }; return switch (st.kind) { .directory => "040000", .sym_link => "120000", .file => if (st.permissions.toMode() & 0o100 != 0) "100755" else "100644", else => return tool.fatalMessage(c.stderr, "zig-git: builtin_objectmode of '{s}' is not supported", .{path}), }; }, .index => { const ix = &(c.setup.index orelse return "unspecified"); const e = ix.get(path, 0) orelse return "unspecified"; return std.fmt.bufPrint(buf, "{o:0>6}", .{e.mode}); }, .tree => |t| { const root = t orelse return "unspecified"; const mode = try findMode(c.ctx, c.repo, root, path) orelse return "unspecified"; return std.fmt.bufPrint(buf, "{o:0>6}", .{mode}); }, } }
fn write(c: *Checker, path: []const u8, name: []const u8, info: []const u8) !void { if (c.nul) { try c.stdout.print("{s}\x00{s}\x00{s}\x00", .{ path, name, info }); return; } try git.quote.writePathWith(c.stdout, path, .{ .quote_path = c.quote_path }); try c.stdout.print(": {s}: {s}\n", .{ name, info }); }};
/// The mode of `path` in tree `root`, or null when it is not there.fn findMode(ctx: Context, repo: *git.Repository, root: git.ObjectId, path: []const u8) !?u32 { const gpa = ctx.gpa; var id = root; var rest = path; while (true) { var o = repo.objects().read(ctx.io, gpa, id, null) catch return null; defer o.deinit(gpa); if (o.kind != .tree) return null; const slash = std.mem.findScalar(u8, rest, '/'); const want = if (slash) |sl| rest[0..sl] else rest; var it = git.tree.iterate(repo.objectFormat(), o.data); const found = while (it.next() catch return null) |e| { if (std.mem.eql(u8, e.name, want)) break e; } else return null; if (slash) |sl| { id = found.id; rest = rest[sl + 1 ..]; continue; } return @backingInt(found.mode()); }}