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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Attributes, as gitattributes(5) gives them to paths and `git//! check-attr` reports them: the files read, the patterns matched, macros//! expanded, and each attribute's state for a path.//!//! The files are the system file, the global one (`core.attributesFile`),//! the `.gitattributes` at the top and in each directory down to the//! path's, and `$GIT_DIR/info/attributes`. They are read in that order,//! lazily — a directory's file only once a path below it is asked about —//! and a later one overrides an earlier one, except that//! `info/attributes` is read before the directories' files and still//! overrides them all. Within a file a later line overrides an earlier//! one, and within a line a later attribute an earlier one. Where the//! in-tree files come from — the working tree with the index behind it,//! the index alone, or a tree — is a `Source` the caller supplies.//!//! What the documentation leaves out was found by asking `git check-attr`://!//! - A line is a pattern and then attributes, split at spaces, tabs,//! carriage returns and line feeds; leading blank space is dropped, and//! a line then empty or beginning with `#` says nothing. In a file read//! from disk, but not in one read from a blob, a carriage return before//! a line feed ends the line. A line of 2048 bytes or more//! is passed over with a warning; a UTF-8 byte order mark at the start//! of a file is skipped.//! - A pattern beginning with `"` is read as a C-quoted string, the//! attributes starting right after its closing quote; one that is not a//! well-formed quoted string is taken as it stands, up to blank space.//! - An attribute is `name`, `-name` (unset), `!name` (unspecified) or//! `name=value` (`-name=value` is unset). A name is letters, digits,//! `-`, `.` and `_`, not beginning with `-` nor with `builtin_`. One bad//! name drops the whole line, with a warning naming the first, and//! registers none of its names.//! - A pattern beginning with `!` (after unquoting) drops the line with a//! warning; `\!` is a literal `!`.//! - `[attr]<name>` defines a macro, but only in a top-level file (the//! system, global and `info/attributes` files and the top//! `.gitattributes`); anywhere else the line is dropped with a warning//! quoting it. A macro is expanded only where it is set — unset, given//! a value or made unspecified, it is just an attribute — and only for//! the attributes a higher-precedence assignment has not already//! decided; it may be used before it is defined, and the last//! definition read is the one used, `binary` included (built in as//! `-diff -merge -text`).//! - Patterns follow gitignore(5): one without a `/`, a trailing one//! aside, matches a path's last component at any depth below the//! file's directory; one with a `/` matches the path from that//! directory, a leading `/` dropped; a trailing `/` matches only//! directories, and a directory matches nothing below it.//! `core.ignoreCase` folds case in patterns, but not in attribute names//! nor in which directories' files are read.//! - `git check-attr --all` lists attributes in the order their names//! were first read: `binary`, `diff`, `merge` and `text` first, then//! every name of every file in the order the files were read, including//! lines that matched nothing. The order depends on which paths were//! asked about before.//! - A file is parsed again, its warnings with it, each time the//! directory it is in is entered again after a path elsewhere.
const std = @import("std");const Allocator = std.mem.Allocator;
const Io = std.Io;
const object = @import("object.zig");const tree = @import("tree.zig");const ObjectDatabase = @import("ObjectDatabase.zig");const Index = @import("Index.zig");const quote = @import("quote.zig");const wildmatch = @import("wildmatch.zig");
const Attributes = @This();
/// An attribute's state for a path.pub const State = union(enum) { /// Nothing says. unspecified, /// `name`. set, /// `-name`. unset, /// `name=value`. value: []const u8,
/// How `git check-attr` writes it. pub fn text(s: State) []const u8 { return switch (s) { .unspecified => "unspecified", .set => "set", .unset => "unset", .value => |v| v, }; }};
/// The names of attributes, numbered in the order they were first read.pub const Names = struct { map: std.StringArrayHashMapUnmanaged(void) = .empty,
/// The number a name has, numbering it if it is new. `name` must /// outlive the table. pub fn intern(n: *Names, gpa: Allocator, text: []const u8) Allocator.Error!u32 { const gop = try n.map.getOrPut(gpa, text); return @intCast(gop.index); }
/// The number a name has, if it has one. pub fn find(n: *const Names, text: []const u8) ?u32 { return if (n.map.getIndex(text)) |i| @intCast(i) else null; }
/// The name numbered `id`. pub fn name(n: *const Names, id: u32) []const u8 { return n.map.keys()[id]; }
/// How many names there are. pub fn count(n: *const Names) usize { return n.map.count(); }
/// Frees the table. pub fn deinit(n: *Names, gpa: Allocator) void { n.map.deinit(gpa); }};
/// One attribute given on a line.pub const Assignment = struct { name: u32, /// `.unspecified` for `!name`. state: State,};
/// A line's pattern, as gitignore(5) reads one.pub const Pattern = struct { /// The glob, without a leading or trailing `/`. glob: []const u8, /// No `/` in it: matched against a path's last component. base_only: bool, /// A trailing `/`: matches only directories. directory_only: bool,};
/// A line that says something.pub const Line = struct { /// What paths it applies to; null for a macro's definition. pattern: ?Pattern, /// The macro it defines, for `[attr]<name>`. macro: ?u32, assignments: []const Assignment,};
/// What a file holds, once parsed.pub const File = struct { lines: []const Line, /// What was said about it, each line ending in a newline, in git's /// words. warnings: []const u8,};
/// Lines of this many bytes or more are passed over.pub const max_line_length = 2048;
fn isBlank(c: u8) bool { return switch (c) { ' ', '\t', '\r', '\n' => true, else => false, };}
/// Whether `name` may name an attribute in a file.pub fn validName(name: []const u8) bool { if (name.len == 0 or name[0] == '-') return false; if (std.mem.startsWith(u8, name, "builtin_")) return false; for (name) |c| switch (c) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '.', '_' => {}, else => return false, }; return true;}
/// Whether `name` may be asked about on the command line: a name a file/// may use, or one of the reserved `builtin_` names.pub fn validQueryName(name: []const u8) bool { if (std.mem.startsWith(u8, name, "builtin_")) { for (name) |c| switch (c) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '.', '_' => {}, else => return false, }; return true; } return validName(name);}
/// How a file's lines end: git reads a file on disk line by line, a/// carriage return before a line feed ending the line, and a blob split/// at line feeds alone.pub const LineEnds = enum { file, blob };
/// Parses an attributes file. Names, patterns and values are allocated/// in `arena`, and names are numbered in `names`, which must use the same/// arena; `origin` is the file's name in warnings, and `top_level` says/// whether it may define macros.pub fn parse(arena: Allocator, names: *Names, bytes: []const u8, origin: []const u8, top_level: bool, ends: LineEnds) Allocator.Error!File { var lines: std.ArrayList(Line) = .empty; var warnings: std.Io.Writer.Allocating = .init(arena); const w = &warnings.writer; var text = bytes; if (std.mem.startsWith(u8, text, "\xef\xbb\xbf")) text = text[3..]; var number: usize = 0; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |whole| { if (it.index == null and whole.len == 0) break; number += 1; // A carriage return before the line feed ends the line with it. const raw = if (ends == .file and it.index != null and whole.len != 0 and whole[whole.len - 1] == '\r') whole[0 .. whole.len - 1] else whole; if (raw.len >= max_line_length) { w.print("warning: ignoring overly long attributes line {d}\n", .{number}) catch return error.OutOfMemory; continue; } if (try parseLine(arena, names, raw, origin, number, top_level, w)) |line| try lines.append(arena, line); } return .{ .lines = lines.items, .warnings = warnings.written() };}
fn parseLine(arena: Allocator, names: *Names, raw: []const u8, origin: []const u8, number: usize, top_level: bool, w: *std.Io.Writer) Allocator.Error!?Line { var i: usize = 0; while (i < raw.len and isBlank(raw[i])) i += 1; const line = raw[i..]; if (line.len == 0 or line[0] == '#') return null;
var pattern_text: []const u8 = ""; var rest: []const u8 = ""; var macro_name: ?[]const u8 = null; var quoted = false; if (std.mem.startsWith(u8, line, "[attr]")) { if (!top_level) { w.print("{s} not allowed: {s}:{d}\n", .{ line, origin, number }) catch return error.OutOfMemory; return null; } var end: usize = 6; while (end < line.len and !isBlank(line[end])) end += 1; macro_name = line[6..end]; rest = line[end..]; if (!validName(macro_name.?)) { w.print("{s} is not a valid attribute name: {s}:{d}\n", .{ macro_name.?, origin, number }) catch return error.OutOfMemory; return null; } } else if (line[0] == '"') { var out: std.ArrayList(u8) = .empty; if (try quote.unquote(arena, line, &out)) |got| { pattern_text = out.items; rest = line[got.len..]; quoted = true; } else out.deinit(arena); } if (macro_name == null and !quoted) { var end: usize = 0; while (end < line.len and !isBlank(line[end])) end += 1; pattern_text = line[0..end]; rest = line[end..]; }
// Every name is checked before any is numbered. var tokens = std.mem.tokenizeAny(u8, rest, " \t\r\n"); while (tokens.next()) |tok| { const name = assignmentName(tok); if (!validName(name)) { w.print("{s} is not a valid attribute name: {s}:{d}\n", .{ name, origin, number }) catch return error.OutOfMemory; return null; } } if (macro_name == null and pattern_text.len != 0 and pattern_text[0] == '!') { w.writeAll("warning: Negative patterns are ignored in git attributes\nUse '\\!' for literal leading exclamation.\n") catch return error.OutOfMemory; return null; }
var result: Line = .{ .pattern = null, .macro = null, .assignments = &.{} }; if (macro_name) |m| { result.macro = try names.intern(arena, try arena.dupe(u8, m)); } else { var glob = pattern_text; var directory_only = false; if (glob.len != 0 and glob[glob.len - 1] == '/') { glob = glob[0 .. glob.len - 1]; directory_only = true; } const base_only = std.mem.findScalar(u8, glob, '/') == null; if (glob.len != 0 and glob[0] == '/') glob = glob[1..]; result.pattern = .{ .glob = try arena.dupe(u8, glob), .base_only = base_only, .directory_only = directory_only }; } var assignments: std.ArrayList(Assignment) = .empty; tokens.reset(); while (tokens.next()) |tok| { const name = try arena.dupe(u8, assignmentName(tok)); const id = try names.intern(arena, name); const state: State = switch (tok[0]) { '-' => .unset, '!' => .unspecified, else => if (std.mem.findScalar(u8, tok, '=')) |eq| .{ .value = try arena.dupe(u8, tok[eq + 1 ..]) } else .set, }; try assignments.append(arena, .{ .name = id, .state = state }); } result.assignments = assignments.items; return result;}
fn assignmentName(tok: []const u8) []const u8 { var name = tok; if (name.len != 0 and (name[0] == '-' or name[0] == '!')) name = name[1..]; if (std.mem.findScalar(u8, name, '=')) |eq| name = name[0..eq]; return name;}
/// Whether `pattern`, from the file in directory `base` (empty at the/// top, otherwise ending in `/`), matches `path` (from the top, without a/// trailing `/`), a directory if `is_dir`.pub fn matches(pattern: Pattern, base: []const u8, path: []const u8, is_dir: bool, ignore_case: bool) bool { if (pattern.directory_only and !is_dir) return false; if (!std.mem.startsWith(u8, path, base)) return false; const rel = path[base.len..]; const flags: wildmatch.Flags = .{ .pathname = true, .casefold = ignore_case }; if (pattern.base_only) { const slash = std.mem.findScalarLast(u8, rel, '/'); const last = if (slash) |s| rel[s + 1 ..] else rel; return wildmatch.match(pattern.glob, last, flags); } return wildmatch.match(pattern.glob, rel, flags);}
// -- checking paths ----------------------------------------------------------
/// Where the in-tree `.gitattributes` files come from.pub const Source = struct { context: *anyopaque, /// Reads the `.gitattributes` file in directory `dir` (empty at the /// top, otherwise ending in `/`), allocated with `gpa`, or null when /// there is none. May add warnings, each ending in a newline, to /// `warnings`. read: *const fn (context: *anyopaque, gpa: Allocator, dir: []const u8, warnings: *std.ArrayList(u8)) ReadError!?Contents,};
/// A file's contents, and where they were read from.pub const Contents = struct { bytes: []u8, ends: LineEnds,};
/// What reading an attributes file can fail with.pub const ReadError = Allocator.Error || error{ /// The file, or the object holding it, could not be read. AttributesUnreadable,};
/// A file outside the tree, read by the caller.pub const Outside = struct { contents: []const u8, /// Its name in warnings. origin: []const u8,};
/// How paths are checked.pub const Options = struct { /// `core.ignoreCase`. ignore_case: bool = false, /// The system file, unless `GIT_ATTR_NOSYSTEM`. system: ?Outside = null, /// `core.attributesFile`, or its XDG default. global: ?Outside = null, /// `$GIT_DIR/info/attributes`. info: ?Outside = null, /// Whether to read in-tree files at all: false in a bare repository /// with no tree to read them from. in_tree: bool = true,};
const Frame = struct { /// The directory, empty at the top or ending in `/`. dir: []const u8, file: File,};
gpa: Allocator,arena_state: std.heap.ArenaAllocator,source: Source,options: Options,names: Names = .{},/// Each macro's latest definition.macros: std.AutoHashMapUnmanaged(u32, []const Assignment) = .empty,/// The files outside the tree, lowest precedence first: system, global.outside: std.ArrayList(File) = .empty,info: ?File = null,/// The in-tree files of the directories entered, the top's first.stack: std.ArrayList(Frame) = .empty,started: bool = false,/// Warnings not yet taken by `takeWarnings`.warnings: std.ArrayList(u8) = .empty,/// What `takeWarnings` last returned.taken: std.ArrayList(u8) = .empty,decided: std.ArrayList(?State) = .empty,
/// A checker reading in-tree files from `source`. Nothing is read until/// the first path is checked.pub fn init(gpa: Allocator, source: Source, options: Options) Allocator.Error!Attributes { var a: Attributes = .{ .gpa = gpa, .arena_state = .init(gpa), .source = source, .options = options }; errdefer a.deinit(); const arena = a.arena_state.allocator(); // The built-in macro, whose names come first of all. const binary = try a.names.intern(arena, "binary"); const members = try arena.alloc(Assignment, 3); members[0] = .{ .name = try a.names.intern(arena, "diff"), .state = .unset }; members[1] = .{ .name = try a.names.intern(arena, "merge"), .state = .unset }; members[2] = .{ .name = try a.names.intern(arena, "text"), .state = .unset }; try a.macros.put(gpa, binary, members); return a;}
/// Frees everything.pub fn deinit(a: *Attributes) void { a.macros.deinit(a.gpa); a.outside.deinit(a.gpa); a.stack.deinit(a.gpa); a.warnings.deinit(a.gpa); a.taken.deinit(a.gpa); a.decided.deinit(a.gpa); a.names.deinit(a.arena_state.allocator()); a.arena_state.deinit(); a.* = undefined;}
/// The number for an attribute name, numbering it if it is new, as git/// numbers the names asked about on its command line.pub fn intern(a: *Attributes, name: []const u8) Allocator.Error!u32 { const arena = a.arena_state.allocator(); if (a.names.find(name)) |id| return id; return a.names.intern(arena, try arena.dupe(u8, name));}
/// The warnings written since the last call, each ending in a newline;/// valid until the next check.pub fn takeWarnings(a: *Attributes) []const u8 { std.mem.swap(std.ArrayList(u8), &a.warnings, &a.taken); a.warnings.clearRetainingCapacity(); return a.taken.items;}
fn addFile(a: *Attributes, contents: []const u8, origin: []const u8, top_level: bool, ends: LineEnds) Allocator.Error!File { const f = try parse(a.arena_state.allocator(), &a.names, contents, origin, top_level, ends); try a.warnings.appendSlice(a.gpa, f.warnings); for (f.lines) |line| if (line.macro) |m| try a.macros.put(a.gpa, m, line.assignments); return f;}
fn readDir(a: *Attributes, dir: []const u8) ReadError!Frame { const arena = a.arena_state.allocator(); const owned_dir = try arena.dupe(u8, dir); if (!a.options.in_tree) return .{ .dir = owned_dir, .file = .{ .lines = &.{}, .warnings = "" } }; const contents = try a.source.read(a.source.context, a.gpa, dir, &a.warnings) orelse return .{ .dir = owned_dir, .file = .{ .lines = &.{}, .warnings = "" } }; defer a.gpa.free(contents.bytes); const origin = try std.mem.concat(arena, u8, &.{ dir, ".gitattributes" }); return .{ .dir = owned_dir, .file = try a.addFile(contents.bytes, origin, dir.len == 0, contents.ends) };}
fn start(a: *Attributes) ReadError!void { if (a.started) return; a.started = true; if (a.options.system) |s| try a.outside.append(a.gpa, try a.addFile(s.contents, s.origin, true, .file)); if (a.options.global) |g| try a.outside.append(a.gpa, try a.addFile(g.contents, g.origin, true, .file)); try a.stack.append(a.gpa, try a.readDir("")); if (a.options.info) |i| a.info = try a.addFile(i.contents, i.origin, true, .file);}
/// Enters the directories `path` is in, leaving those it is not.fn enter(a: *Attributes, path: []const u8) ReadError!void { // Leave every directory the path is not below. while (a.stack.items.len > 1) { const top = a.stack.items[a.stack.items.len - 1]; if (std.mem.startsWith(u8, path, top.dir)) break; _ = a.stack.pop(); } var end = a.stack.items[a.stack.items.len - 1].dir.len; while (std.mem.findScalarPos(u8, path, end, '/')) |slash| { end = slash + 1; try a.stack.append(a.gpa, try a.readDir(path[0..end])); }}
/// The states of every attribute for one path, by name number.pub const Result = struct { states: []const ?State,
/// The state of the attribute numbered `id`. pub fn get(r: Result, id: u32) State { if (id >= r.states.len) return .unspecified; return r.states[id] orelse .unspecified; }};
/// Checks `path`, from the top of the working tree; a trailing `/` makes/// it a directory. The result is valid until the next check.pub fn check(a: *Attributes, path: []const u8) ReadError!Result { try a.start(); var p = path; var is_dir = false; if (p.len != 0 and p[p.len - 1] == '/') { p = p[0 .. p.len - 1]; is_dir = true; } try a.enter(p); // Names may have been numbered while reading. try a.decided.resize(a.gpa, a.names.count()); @memset(a.decided.items, null);
if (a.info) |f| a.fillFile(f, "", p, is_dir); var k = a.stack.items.len; while (k > 0) { k -= 1; const frame = a.stack.items[k]; a.fillFile(frame.file, frame.dir, p, is_dir); } k = a.outside.items.len; while (k > 0) { k -= 1; a.fillFile(a.outside.items[k], "", p, is_dir); } return .{ .states = a.decided.items };}
fn fillFile(a: *Attributes, f: File, base: []const u8, path: []const u8, is_dir: bool) void { var i = f.lines.len; while (i > 0) { i -= 1; const line = f.lines[i]; const pattern = line.pattern orelse continue; if (!matches(pattern, base, path, is_dir, a.options.ignore_case)) continue; a.fillAssignments(line.assignments); }}
fn fillAssignments(a: *Attributes, assignments: []const Assignment) void { var j = assignments.len; while (j > 0) { j -= 1; const as = assignments[j]; if (a.decided.items[as.name] != null) continue; a.decided.items[as.name] = as.state; if (as.state == .set) { if (a.macros.get(as.name)) |expansion| a.fillAssignments(expansion); } }}
// -- sources -----------------------------------------------------------------
/// In-tree files from a tree, as `--source`, `attr.tree` and/// `GIT_ATTR_SOURCE` read them. A path that is not a blob in it is no file.pub const TreeSource = struct { io: Io, db: ObjectDatabase, format: object.Format, /// The tree; null reads nothing. root: ?object.Id,
/// The `Source` reading from it. pub fn source(t: *TreeSource) Source { return .{ .context = t, .read = readTree }; }
fn readTree(context: *anyopaque, gpa: Allocator, dir: []const u8, warnings: *std.ArrayList(u8)) ReadError!?Contents { _ = warnings; const t: *TreeSource = @ptrCast(@alignCast(context)); var id = t.root orelse return null; var rest: []const u8 = dir; var name: []const u8 = ".gitattributes"; while (true) { var o = t.db.read(t.io, gpa, id, null) catch |err| switch (err) { error.OutOfMemory => |e| return e, else => return error.AttributesUnreadable, }; 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 name; var it = tree.iterate(t.format, o.data); const found = while (it.next() catch return error.AttributesUnreadable) |e| { if (std.mem.eql(u8, e.name, want)) break e; } else return null; if (slash) |sl| { if (found.mode() != .directory) return null; id = found.id; rest = rest[sl + 1 ..]; continue; } if (found.mode() != .file and found.mode() != .executable) return null; name = ""; return try readBlob(t.io, t.db, gpa, found.id); } }};
fn readBlob(io: Io, db: ObjectDatabase, gpa: Allocator, id: object.Id) ReadError!Contents { var o = db.read(io, gpa, id, null) catch |err| switch (err) { error.OutOfMemory => |e| return e, else => return error.AttributesUnreadable, }; defer o.deinit(gpa); if (o.kind != .blob) return error.AttributesUnreadable; return .{ .bytes = try gpa.dupe(u8, o.data), .ends = .blob };}
/// In-tree files from the index, as `--cached` reads them, and as the/// working tree falls back to.pub const IndexSource = struct { io: Io, db: ObjectDatabase, index: *const Index,
/// The `Source` reading from it. pub fn source(s: *IndexSource) Source { return .{ .context = s, .read = readIndexed }; }
fn readIndexed(context: *anyopaque, gpa: Allocator, dir: []const u8, warnings: *std.ArrayList(u8)) ReadError!?Contents { _ = warnings; const s: *IndexSource = @ptrCast(@alignCast(context)); return s.lookup(gpa, dir); }
fn lookup(s: *IndexSource, gpa: Allocator, dir: []const u8) ReadError!?Contents { var buf: [4096]u8 = undefined; const path = std.fmt.bufPrint(&buf, "{s}.gitattributes", .{dir}) catch return null; const e = s.index.get(path, 0) orelse return null; if (e.mode != 0o100644 and e.mode != 0o100755) return null; return try readBlob(s.io, s.db, gpa, e.id); }};
/// In-tree files from the working tree, with the index behind them for a/// directory whose file is missing. A symbolic link is not followed: git/// warns that it cannot access it and falls back to the index, as it does/// for a missing file; a directory where the file should be is an empty/// file.pub const WorktreeSource = struct { io: Io, /// The top of the working tree. dir: Io.Dir, /// The index to fall back to, if there is one. index: ?IndexSource,
/// The `Source` reading from it. pub fn source(s: *WorktreeSource) Source { return .{ .context = s, .read = readWorktree }; }
fn readWorktree(context: *anyopaque, gpa: Allocator, dir: []const u8, warnings: *std.ArrayList(u8)) ReadError!?Contents { const s: *WorktreeSource = @ptrCast(@alignCast(context)); var buf: [4096]u8 = undefined; const path = std.fmt.bufPrint(&buf, "{s}.gitattributes", .{dir}) catch return null; var file = s.dir.openFile(s.io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound, error.NotDir => return s.fallback(gpa, dir), error.SymLinkLoop => { var w: std.Io.Writer.Allocating = .fromArrayList(gpa, warnings); defer warnings.* = w.toArrayList(); w.writer.print("warning: unable to access '{s}': Too many levels of symbolic links\n", .{path}) catch return error.OutOfMemory; return s.fallback(gpa, dir); }, else => return error.AttributesUnreadable, }; defer file.close(s.io); var reader = file.reader(s.io, &.{}); const bytes = reader.interface.allocRemaining(gpa, .unlimited) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.ReadFailed => if (reader.err) |e| switch (e) { error.IsDir => return .{ .bytes = try gpa.dupe(u8, ""), .ends = .file }, else => return error.AttributesUnreadable, } else return error.AttributesUnreadable, else => return error.AttributesUnreadable, }; return .{ .bytes = bytes, .ends = .file }; }
fn fallback(s: *WorktreeSource, gpa: Allocator, dir: []const u8) ReadError!?Contents { var ix = s.index orelse return null; return ix.lookup(gpa, dir); }};
// -- tests -------------------------------------------------------------------
const testing = std.testing;const fuzz_input = @import("testing/fuzz.zig");
const MapSource = struct { files: []const [2][]const u8,
fn read(context: *anyopaque, gpa: Allocator, dir: []const u8, warnings: *std.ArrayList(u8)) ReadError!?Contents { _ = warnings; const s: *MapSource = @ptrCast(@alignCast(context)); for (s.files) |f| if (std.mem.eql(u8, f[0], dir)) return .{ .bytes = try gpa.dupe(u8, f[1]), .ends = .file }; return null; }};
fn expectState(r: Result, a: *Attributes, name: []const u8, want: []const u8) !void { const id = a.names.find(name) orelse return testing.expectEqualStrings(want, "unspecified"); try testing.expectEqualStrings(want, r.get(id).text());}
test "the example in gitattributes(5)" { var src: MapSource = .{ .files = &.{ .{ "", "abc\tfoo bar baz\n" }, .{ "t/", "ab*\tmerge=filfre\nabc\t-foo -bar\n*.c\tfrotz\n" }, } }; var a = try Attributes.init(testing.allocator, .{ .context = &src, .read = MapSource.read }, .{ .info = .{ .contents = "a*\tfoo !bar -baz\n", .origin = ".git/info/attributes" }, }); defer a.deinit(); const r = try a.check("t/abc"); try expectState(r, &a, "foo", "set"); try expectState(r, &a, "bar", "unspecified"); try expectState(r, &a, "baz", "unset"); try expectState(r, &a, "merge", "filfre"); try expectState(r, &a, "frotz", "unspecified");}
test "macros, bad names, negative patterns and quoting" { var src: MapSource = .{ .files = &.{ .{ "", "x diff binary\ny binary diff\n[attr]m1 m2 x1\n[attr]m2 m1 x2\nf m1\nbad ok b@d\n!neg n\n\"sp ace\"q qq\n" }, .{ "sub/", "[attr]sm s\nz sm\n" }, } }; var a = try Attributes.init(testing.allocator, .{ .context = &src, .read = MapSource.read }, .{}); defer a.deinit(); var r = try a.check("x"); try expectState(r, &a, "diff", "unset"); try expectState(r, &a, "text", "unset"); r = try a.check("y"); try expectState(r, &a, "diff", "set"); r = try a.check("f"); for ([_][]const u8{ "m1", "m2", "x1", "x2" }) |n| try expectState(r, &a, n, "set"); r = try a.check("sp ace"); try expectState(r, &a, "q", "set"); try expectState(r, &a, "qq", "set"); try testing.expect(a.names.find("ok") == null); try testing.expect(std.mem.find(u8, a.takeWarnings(), "b@d is not a valid attribute name: .gitattributes:6\n") != null); r = try a.check("sub/z"); try expectState(r, &a, "sm", "set"); try expectState(r, &a, "s", "unspecified"); try testing.expectEqualStrings("[attr]sm s not allowed: sub/.gitattributes:1\n", a.takeWarnings());}
test "patterns as gitignore reads them" { const p = struct { fn pat(text: []const u8) Pattern { var glob = text; var dir_only = false; if (glob[glob.len - 1] == '/') { glob = glob[0 .. glob.len - 1]; dir_only = true; } const base_only = std.mem.findScalar(u8, glob, '/') == null; if (glob[0] == '/') glob = glob[1..]; return .{ .glob = glob, .base_only = base_only, .directory_only = dir_only }; } }; try testing.expect(matches(p.pat("*.c"), "", "a/b/x.c", false, false)); try testing.expect(!matches(p.pat("sub/*.c"), "", "a/sub/x.c", false, false)); try testing.expect(matches(p.pat("sub/*.c"), "", "sub/x.c", false, false)); try testing.expect(matches(p.pat("/top"), "", "top", false, false)); try testing.expect(!matches(p.pat("/top"), "", "a/top", false, false)); try testing.expect(!matches(p.pat("d/"), "", "d", false, false)); try testing.expect(matches(p.pat("d/"), "", "d", true, false)); try testing.expect(!matches(p.pat("d/"), "", "d/x", false, false)); try testing.expect(matches(p.pat("d/**"), "", "d/x/y", false, false)); try testing.expect(matches(p.pat("x"), "s/", "s/x", false, false)); try testing.expect(!matches(p.pat("x"), "s/", "t/x", false, false)); try testing.expect(matches(p.pat("*.C"), "", "a.c", false, true));}
test "fuzz: attributes files and patterns" { try fuzz_input.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("fuzz/attributes-macros"), @embedFile("fuzz/attributes-patterns"), } });}
/// An attributes file, a NUL, and paths separated by newlines.fn fuzzOne(_: void, smith: *std.testing.Smith) anyerror!void { var buffer: [4096]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); const nul = std.mem.findScalar(u8, input, 0) orelse input.len; const bytes = input[0..nul]; const paths = if (nul < input.len) input[nul + 1 ..] else ""; var src: MapSource = .{ .files = &.{ .{ "", bytes }, .{ "sub/", bytes } } }; var a = try Attributes.init(testing.allocator, .{ .context = &src, .read = MapSource.read }, .{ .info = .{ .contents = bytes, .origin = "info" } }); defer a.deinit(); var it = std.mem.splitScalar(u8, paths, '\n'); while (it.next()) |path| { const r = try a.check(path); try testing.expectEqual(a.names.count(), r.states.len); for (r.states) |st| if (st) |x| { _ = x.text(); }; _ = a.takeWarnings(); }}