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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Pathspecs, as gitglossary(7) describes them and `git ls-files` and//! `git log` were seen to read and match them: the patterns that limit a//! command to some paths.//!//! A pathspec is read with no I/O, from arguments as a command takes them,//! the directory the command runs in (its prefix) and the working tree's//! absolute path passed in. What was found by experiment, where the//! glossary is silent://!//! - **Magic.** `:(<word>,...)` names magic words — `top`, `literal`,//! `glob`, `icase`, `exclude` — spelled exactly so; empty words are//! passed over. In the short form, the letters after `:` are `/` (top)//! and `!` or `^` (exclude), ended by a second `:` or by the first byte//! that is not one of them: a letter, a digit, a byte past ASCII, a//! space, or one of `$)*+.?[\]{|}`. Any other ASCII punctuation there is//! "unimplemented" magic. `attr` is refused by name here, as is the//! internal `prefix`.//! - **Paths.** Without `top`, a pattern is joined to the prefix, empty//! and `.` components are dropped and `..` takes one away; going above//! the top is an error, and so is an absolute path outside the working//! tree. The result keeps a trailing `/` when the argument ends in one,//! in `.` or `..`, or is empty after its magic. With `top` the pattern is//! taken as written, from the top, with nothing normalized.//! - **The prefix is literal.** The part of a pattern that came from the//! current directory is matched byte for byte, wildcards and case//! included, even under `glob` or `icase`; what follows is matched as//! the magic says.//! - **Matching a path.** A pattern matches a path that it equals, and a//! path below it — below a directory it names, or below it as a prefix//! ending in `/`. Failing that, a pattern with a wildcard (`*`, `?`, `[`//! or `\`) and without `literal` is matched against the whole path with//! wildmatch, `*` crossing `/` unless `glob` says otherwise. A pattern//! ending in `/` matches a gitlink of that name, as it does a directory,//! and never a file. Wildcards match only whole paths, never a directory//! on the way to one: `*.c` matches no file below a directory `x.c`.//! - **Exclusion.** A path matches when some pattern without `exclude`//! matches it and no pattern with `exclude` does. With no pattern//! without `exclude`, every path is taken to match before the//! exclusions apply.//! - **Global settings.** `GIT_LITERAL_PATHSPECS` reads every argument as//! a literal path, magic and all; `GIT_GLOB_PATHSPECS` and//! `GIT_NOGLOB_PATHSPECS` give `glob` or `literal` to each pattern that//! names neither; `GIT_ICASE_PATHSPECS` gives `icase` to every one.
const std = @import("std");const Allocator = std.mem.Allocator;
const wildmatch = @import("wildmatch.zig");const config_value = @import("config/value.zig");const Diagnostics = @import("Diagnostics.zig");
const Pathspec = @This();
/// The patterns, in the order given.items: []Item,
/// The magic a pattern carries.pub const Magic = packed struct { /// Matched from the top of the working tree. top: bool = false, /// No wildcards. literal: bool = false, /// Wildcards other than `**` do not match `/`. glob: bool = false, /// ASCII letters match either case. icase: bool = false, /// Paths it matches are taken out. exclude: bool = false,};
/// One pattern.pub const Item = struct { /// The pattern as a path from the top of the working tree. pattern: []const u8, /// The argument it was read from. original: []const u8, magic: Magic, /// How much of `pattern` came from the current directory, and is /// matched byte for byte. prefix_len: usize, /// How much of `pattern` holds no wildcard: all of it when it has none /// or is literal. literal_len: usize,
/// Whether the pattern has a wildcard to match with. pub fn hasWildcard(item: Item) bool { return item.literal_len < item.pattern.len; }};
/// What the `GIT_*_PATHSPECS` environment variables say.pub const Settings = struct { literal: bool = false, glob: bool = false, noglob: bool = false, icase: bool = false,};
/// What a pathspec is read with.pub const Options = struct { /// The current directory, from the top of the working tree: empty, or /// ending in `/`. prefix: []const u8 = "", /// The working tree's absolute path, against which absolute paths are /// made relative and which git names when one is outside it. work_tree: ?[]const u8 = null, settings: Settings = .{},};
/// Why a pathspec could not be read. The diagnostic's detail holds git's/// message, without its `fatal: `:////// - `EmptyPathspec`: an empty argument./// - `InvalidMagic`: an unknown magic word./// - `UnterminatedMagic`: `:(` with no `)`./// - `UnimplementedMagic`: a short-form magic letter git does not have./// - `LiteralAndGlob`: both on one pattern./// - `OutsideRepository`: a path above the top or outside the working/// tree./// - `UnsupportedMagic`: `attr` or `prefix`, which git has and this does/// not; the detail names it./// - `BadEnvironmentBoolean`, `GlobAndNoglob`, `LiteralWithOthers`: what/// `settingsFromEnvironment` refuses.pub const Error = Allocator.Error || error{ EmptyPathspec, InvalidMagic, UnterminatedMagic, UnimplementedMagic, LiteralAndGlob, OutsideRepository, UnsupportedMagic,};
/// Why the environment's settings could not be read.pub const SettingsError = error{ BadEnvironmentBoolean, GlobAndNoglob, LiteralWithOthers };
/// The settings from the values of `GIT_LITERAL_PATHSPECS`,/// `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS` and `GIT_ICASE_PATHSPECS`,/// null where unset, read as git reads them: each a boolean, in that/// order, and then `glob` with `noglob`, and `literal` with any other,/// refused.pub fn settingsFromEnvironment(literal: ?[]const u8, glob: ?[]const u8, noglob: ?[]const u8, icase: ?[]const u8, diag: ?*Diagnostics) SettingsError!Settings { const names = [_][]const u8{ "GIT_LITERAL_PATHSPECS", "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS" }; const values = [_]?[]const u8{ literal, glob, noglob, icase }; var got: [4]bool = @splat(false); for (values, names, 0..) |v, n, i| { const text = v orelse continue; got[i] = config_value.parseBool(text) orelse { if (diag) |d| { d.reset(); d.setDetail("bad boolean environment value '{s}' for '{s}'", .{ text, n }); } return error.BadEnvironmentBoolean; }; } const s: Settings = .{ .literal = got[0], .glob = got[1], .noglob = got[2], .icase = got[3] }; if (s.glob and s.noglob) { if (diag) |d| { d.reset(); d.setDetail("global 'glob' and 'noglob' pathspec settings are incompatible", .{}); } return error.GlobAndNoglob; } if (s.literal and (s.glob or s.noglob or s.icase)) { if (diag) |d| { d.reset(); d.setDetail("global 'literal' pathspec setting is incompatible with all other global pathspec settings", .{}); } return error.LiteralWithOthers; } return s;}
/// Reads `args` as a pathspec. The pathspec keeps its own copies.pub fn parse(gpa: Allocator, args: []const []const u8, options: Options, diag: ?*Diagnostics) Error!Pathspec { var items: std.ArrayList(Item) = .empty; errdefer { for (items.items) |it| freeItem(gpa, it); items.deinit(gpa); } for (args) |arg| { const item = try parseOne(gpa, arg, options, diag); items.append(gpa, item) catch |err| { freeItem(gpa, item); return err; }; } return .{ .items = try items.toOwnedSlice(gpa) };}
/// A pathspec matching every path.pub const empty: Pathspec = .{ .items = &.{} };
/// Frees the pathspec.pub fn deinit(ps: *Pathspec, gpa: Allocator) void { for (ps.items) |it| freeItem(gpa, it); gpa.free(ps.items); ps.* = undefined;}
fn freeItem(gpa: Allocator, it: Item) void { gpa.free(it.pattern); gpa.free(it.original);}
fn fail(diag: ?*Diagnostics, err: Error, comptime fmt: []const u8, args: anytype) Error { if (diag) |d| { d.reset(); d.setDetail(fmt, args); } return err;}
/// Whether `c`, after a `:`, would be a magic letter: ASCII punctuation/// other than what may begin a pattern.fn isMagicLetter(c: u8) bool { if (c >= 0x80 or !std.ascii.isPrint(c) or std.ascii.isAlphanumeric(c) or c == ' ') return false; return std.mem.findScalar(u8, "$)*+.?[\\]{|}", c) == null;}
fn parseOne(gpa: Allocator, arg: []const u8, options: Options, diag: ?*Diagnostics) Error!Item { if (arg.len == 0) return fail(diag, error.EmptyPathspec, "empty string is not a valid pathspec. please use . instead if you meant to match all paths", .{}); var magic: Magic = .{}; var rest = arg; if (options.settings.literal) { magic.literal = true; } else if (arg[0] == ':') { if (arg.len > 1 and arg[1] == '(') { const close = std.mem.findScalarPos(u8, arg, 2, ')') orelse return fail(diag, error.UnterminatedMagic, "Missing ')' at the end of pathspec magic in '{s}'", .{arg}); var words = std.mem.splitScalar(u8, arg[2..close], ','); while (words.next()) |w| { if (w.len == 0) continue; if (std.mem.eql(u8, w, "top")) { magic.top = true; } else if (std.mem.eql(u8, w, "literal")) { magic.literal = true; } else if (std.mem.eql(u8, w, "glob")) { magic.glob = true; } else if (std.mem.eql(u8, w, "icase")) { magic.icase = true; } else if (std.mem.eql(u8, w, "exclude")) { magic.exclude = true; } else if (std.mem.eql(u8, w, "attr") or std.mem.startsWith(u8, w, "attr:")) { return fail(diag, error.UnsupportedMagic, "attr", .{}); } else if (std.mem.startsWith(u8, w, "prefix:")) { return fail(diag, error.UnsupportedMagic, "prefix", .{}); } else { return fail(diag, error.InvalidMagic, "Invalid pathspec magic '{s}' in '{s}'", .{ w, arg }); } } rest = arg[close + 1 ..]; } else { var i: usize = 1; while (i < arg.len) : (i += 1) { const c = arg[i]; if (c == ':') { i += 1; break; } switch (c) { '/' => magic.top = true, '!', '^' => magic.exclude = true, else => { if (isMagicLetter(c)) return fail(diag, error.UnimplementedMagic, "Unimplemented pathspec magic '{c}' in '{s}'", .{ c, arg }); break; }, } } rest = arg[i..]; } if (magic.literal and magic.glob) return fail(diag, error.LiteralAndGlob, "{s}: 'literal' and 'glob' are incompatible", .{arg}); } if (!magic.literal and !magic.glob) { if (options.settings.glob) magic.glob = true; if (options.settings.noglob) magic.literal = true; } if (options.settings.icase) magic.icase = true;
var pattern: []u8 = undefined; var prefix_len: usize = 0; if (magic.top) { pattern = try gpa.dupe(u8, rest); } else { const n = try normalize(gpa, arg, rest, options, diag); pattern = n.path; prefix_len = n.prefix_len; } errdefer gpa.free(pattern); var literal_len = pattern.len; if (!magic.literal) { for (pattern[prefix_len..], prefix_len..) |c, i| switch (c) { '*', '?', '[', '\\' => { literal_len = i; break; }, else => {}, }; } return .{ .pattern = pattern, .original = try gpa.dupe(u8, arg), .magic = magic, .prefix_len = prefix_len, .literal_len = literal_len, };}
const Normalized = struct { path: []u8, prefix_len: usize };
/// `rest` joined to the prefix and made a path from the top.fn normalize(gpa: Allocator, arg: []const u8, rest: []const u8, options: Options, diag: ?*Diagnostics) Error!Normalized { const top = options.work_tree orelse ""; var relative = rest; var use_prefix = true; if (rest.len != 0 and rest[0] == '/') { const wt = options.work_tree orelse return fail(diag, error.OutsideRepository, "{s}: '{s}' is outside repository at '{s}'", .{ arg, rest, top }); const trimmed = std.mem.trimEnd(u8, wt, "/"); if (std.mem.startsWith(u8, rest, trimmed) and (rest.len == trimmed.len or rest[trimmed.len] == '/')) { relative = rest[trimmed.len..]; use_prefix = false; } else { return fail(diag, error.OutsideRepository, "{s}: '{s}' is outside repository at '{s}'", .{ arg, rest, top }); } } const Part = struct { text: []const u8, from_prefix: bool }; var parts: std.ArrayList(Part) = .empty; defer parts.deinit(gpa); if (use_prefix) { var it = std.mem.tokenizeScalar(u8, options.prefix, '/'); while (it.next()) |p| try parts.append(gpa, .{ .text = p, .from_prefix = true }); } var last: []const u8 = ""; var it = std.mem.splitScalar(u8, relative, '/'); while (it.next()) |p| { last = p; if (p.len == 0 or std.mem.eql(u8, p, ".")) continue; if (std.mem.eql(u8, p, "..")) { if (parts.pop() == null) return fail(diag, error.OutsideRepository, "{s}: '{s}' is outside repository at '{s}'", .{ arg, rest, top }); continue; } try parts.append(gpa, .{ .text = p, .from_prefix = false }); } var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); var prefix_len: usize = 0; var in_prefix = true; for (parts.items) |p| { try out.appendSlice(gpa, p.text); try out.append(gpa, '/'); if (in_prefix and p.from_prefix) prefix_len = out.items.len else in_prefix = false; } // Keep the trailing slash only where the argument says it names a // directory. const directory = relative.len == 0 or std.mem.eql(u8, last, "") or std.mem.eql(u8, last, ".") or std.mem.eql(u8, last, ".."); if (out.items.len != 0 and !directory) out.items.len -= 1; if (prefix_len > out.items.len) prefix_len = out.items.len; return .{ .path = try out.toOwnedSlice(gpa), .prefix_len = prefix_len };}
// -- matching ----------------------------------------------------------------
fn lower(c: u8) u8 { return std.ascii.toLower(c);}
/// Whether `text`, at offset `at` in a path, equals the same bytes of the/// pattern: byte for byte within the prefix, and in either case after it/// under `icase`.fn same(item: *const Item, at: usize, pattern_part: []const u8, text: []const u8) bool { std.debug.assert(pattern_part.len == text.len); for (pattern_part, text, at..) |p, t, i| { if (p == t) continue; if (i >= item.prefix_len and item.magic.icase and lower(p) == lower(t)) continue; return false; } return true;}
/// Whether the pattern matches `path` without wildcards: equal to it, or/// naming a directory it is in. `directory` says `path` is a directory or/// a gitlink, which a pattern ending in `/` also names.fn literalMatch(item: *const Item, path: []const u8, directory: bool) bool { const pat = item.pattern; const n = pat.len; if (n == 0) return true; if (path.len >= n) { if (!same(item, 0, pat, path[0..n])) return false; return path.len == n or pat[n - 1] == '/' or path[n] == '/'; } return directory and path.len + 1 == n and pat[n - 1] == '/' and same(item, 0, pat[0 .. n - 1], path);}
fn itemMatches(item: *const Item, path: []const u8, directory: bool) bool { if (literalMatch(item, path, directory)) return true; if (!item.hasWildcard()) return false; const ll = item.literal_len; if (path.len < ll or !same(item, 0, item.pattern[0..ll], path[0..ll])) return false; const pl = item.prefix_len; return wildmatch.match(item.pattern[pl..], path[pl..], .{ .pathname = item.magic.glob, .casefold = item.magic.icase });}
/// Whether the pathspec matches `path`, a path from the top of the tree/// with no trailing `/`; `gitlink` says it is a gitlink, which a pattern/// ending in `/` matches as it would a directory. An empty pathspec/// matches every path.pub fn matches(ps: *const Pathspec, path: []const u8, gitlink: bool) bool { var any_positive = false; var positive = false; for (ps.items) |*it| { if (it.magic.exclude) continue; any_positive = true; if (itemMatches(it, path, gitlink)) { positive = true; break; } } if (any_positive and !positive) return false; for (ps.items) |*it| { if (it.magic.exclude and itemMatches(it, path, gitlink)) return false; } return true;}
/// Whether `git diff-tree` without `-r` lists directory `dir` itself, as/// git was seen to: when the pathspec matches it, or a pattern's part/// before any wildcard names something below it (`dir/sub/f`, `dir/*`),/// but not when only a wildcard could reach below it (`*.c`, `*/f`).////// Exclusions do not apply to it: git was seen to list `dir` under/// `:!dir`, and under `:!d*`, which hides a file `del`.pub fn showsDirectory(ps: *const Pathspec, dir: []const u8) bool { var any_positive = false; for (ps.items) |*it| { if (it.magic.exclude) continue; any_positive = true; if (itemMatches(it, dir, true)) return true; const lit = it.pattern[0..it.literal_len]; if (lit.len > dir.len and lit[dir.len] == '/' and same(it, 0, lit[0..dir.len], dir)) return true; } return !any_positive;}
/// Whether paths below `dir` may match the pathspec's positive patterns,/// its exclusions left out: what `git diff-tree -t` lists a tree by, as/// git was seen to list `dir` under `:!dir`.pub fn reachesIgnoringExclusions(ps: *const Pathspec, dir: []const u8) bool { var any_positive = false; for (ps.items) |*it| { if (it.magic.exclude) continue; any_positive = true; if (itemReach(it, dir) != .none) return true; } return !any_positive;}
/// How much of what lies below a directory the pathspec may match.pub const Reach = enum { /// Nothing below it. none, /// Perhaps some of it: each path has to be asked. some, /// Everything below it. all,};
/// Whether paths below `dir` may match, so that a walk through a tree can/// pass over what cannot. `dir` is a path from the top with no trailing/// `/`; the empty string is the top itself. The answer is never `none`/// for a directory below which `matches` would say yes to some path.pub fn reach(ps: *const Pathspec, dir: []const u8) Reach { var best: Reach = .none; var any_positive = false; for (ps.items) |*it| { if (it.magic.exclude) continue; any_positive = true; best = @fromBackingInt(@intCast(@max(@backingInt(best), @backingInt(itemReach(it, dir))))); if (best == .all) break; } if (!any_positive) best = .all; if (best == .none) return .none; for (ps.items) |*it| { if (!it.magic.exclude) continue; switch (itemReach(it, dir)) { .all => return .none, .some => best = .some, .none => {}, } } return best;}
fn itemReach(item: *const Item, dir: []const u8) Reach { if (dir.len == 0) return if (item.pattern.len == 0) .all else .some; if (literalMatch(item, dir, true)) return .all; const pat = item.pattern; // The pattern names something below the directory. if (pat.len > dir.len and pat[dir.len] == '/' and same(item, 0, pat[0..dir.len], dir)) return .some; if (!item.hasWildcard()) return .none; // A wildcard after the directory's part of the pattern, or within it, // may match below it. const ll = item.literal_len; if (ll <= dir.len) { if (same(item, 0, pat[0..ll], dir[0..ll])) return .some; return .none; } if (ll == dir.len + 1 and pat[dir.len] == '/' and same(item, 0, pat[0..dir.len], dir)) return .some; return .none;}
/// Whether every pattern is literal enough to be looked up in a/// changed-path Bloom filter by its leading directories, as git does: no/// wildcard, no `icase` and no `exclude`.pub fn bloomKeys(ps: *const Pathspec) bool { if (ps.items.len == 0) return false; for (ps.items) |it| { if (it.hasWildcard() or it.magic.icase or it.magic.exclude) return false; } return true;}
// -- tests -------------------------------------------------------------------
const testing = std.testing;const fixture = @import("testing/fixture.zig");const fuzz_input = @import("testing/fuzz.zig");
fn expectParseError(want: Error, arg: []const u8, options: Options, message: []const u8) !void { var d: Diagnostics = .{}; try testing.expectError(want, parse(testing.allocator, &.{arg}, options, &d)); try testing.expectEqualStrings(message, d.detail());}
test "magic is read as git reads it, and refused in git's words" { // What `git ls-files` said for each. const o: Options = .{ .work_tree = "/w" }; try expectParseError(error.EmptyPathspec, "", o, "empty string is not a valid pathspec. please use . instead if you meant to match all paths"); try expectParseError(error.InvalidMagic, ":(foo)x", o, "Invalid pathspec magic 'foo' in ':(foo)x'"); try expectParseError(error.InvalidMagic, ":( top)a", o, "Invalid pathspec magic ' top' in ':( top)a'"); try expectParseError(error.InvalidMagic, ":(TOP)a", o, "Invalid pathspec magic 'TOP' in ':(TOP)a'"); try expectParseError(error.UnterminatedMagic, ":(top", o, "Missing ')' at the end of pathspec magic in ':(top'"); try expectParseError(error.UnimplementedMagic, ":#x", o, "Unimplemented pathspec magic '#' in ':#x'"); try expectParseError(error.LiteralAndGlob, ":(literal,glob)x", o, ":(literal,glob)x: 'literal' and 'glob' are incompatible"); try expectParseError(error.OutsideRepository, "../x", o, "../x: '../x' is outside repository at '/w'"); try expectParseError(error.OutsideRepository, ":(icase)../../../x", .{ .work_tree = "/w", .prefix = "a/b/" }, ":(icase)../../../x: '../../../x' is outside repository at '/w'"); try expectParseError(error.OutsideRepository, "/abs", o, "/abs: '/abs' is outside repository at '/w'"); try expectParseError(error.UnsupportedMagic, ":(attr:foo)x", o, "attr");
const gpa = testing.allocator; const Case = struct { arg: []const u8, prefix: []const u8 = "", pattern: []const u8, magic: Magic = .{}, prefix_len: usize = 0 }; for ([_]Case{ .{ .arg = ":!/a", .pattern = "a", .magic = .{ .top = true, .exclude = true } }, .{ .arg = "::a", .pattern = "a" }, .{ .arg = ":!::a", .pattern = ":a", .magic = .{ .exclude = true } }, .{ .arg = ":(top,)a", .pattern = "a", .magic = .{ .top = true } }, .{ .arg = ":", .prefix = "a/b/", .pattern = "a/b/", .prefix_len = 4 }, .{ .arg = ".", .prefix = "a/b/", .pattern = "a/b/", .prefix_len = 4 }, .{ .arg = "../x/", .prefix = "a/b/", .pattern = "a/x/", .prefix_len = 2 }, .{ .arg = "../b/x/..", .prefix = "a/b/", .pattern = "a/b/", .prefix_len = 2 }, .{ .arg = ":(top)a/./b", .prefix = "a/b/", .pattern = "a/./b", .magic = .{ .top = true } }, .{ .arg = "/w/a//b", .prefix = "c/", .pattern = "a/b" }, .{ .arg = "/w", .prefix = "c/", .pattern = "" }, .{ .arg = ":$x", .pattern = "$x" }, }) |case| { var ps = try parse(gpa, &.{case.arg}, .{ .work_tree = "/w", .prefix = case.prefix }, null); defer ps.deinit(gpa); try testing.expectEqualStrings(case.pattern, ps.items[0].pattern); try testing.expectEqual(case.magic, ps.items[0].magic); try testing.expectEqual(case.prefix_len, ps.items[0].prefix_len); }}
test "the environment's settings are read as git reads them" { var d: Diagnostics = .{}; try testing.expectError(error.BadEnvironmentBoolean, settingsFromEnvironment("bad", null, null, "bad", &d)); try testing.expectEqualStrings("bad boolean environment value 'bad' for 'GIT_LITERAL_PATHSPECS'", d.detail()); try testing.expectError(error.GlobAndNoglob, settingsFromEnvironment("1", "1", "1", null, &d)); try testing.expectError(error.LiteralWithOthers, settingsFromEnvironment("yes", null, null, "1", &d)); try testing.expectEqual(Settings{}, try settingsFromEnvironment("", "0", "off", null, null));}
test "paths are matched as git ls-files matches them" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); const files = [_][]const u8{ "A/up", "Br[a]", "Bra", "Dir/Five", "a.c", "a/b.c", "a/b/c.c", "a/b/c/three", "a/b/two", "a/one", "a/up/f", "d/four", "q?", "st*r", "top", "w*/x", "wa", "wz/x", "x.y/z", "x.c/f", "a/b c/d", "deep/a/b", "y.c", }; for (files) |f| { if (std.fs.path.dirname(f)) |dir| try repo.tmp.dir.createDirPath(repo.io, dir); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = f, .data = f }); } try repo.git(&.{ "add", "-A" }); const Case = struct { cwd: []const u8 = "", specs: []const []const u8 }; const cases = [_]Case{ .{ .specs = &.{"*.c"} }, .{ .specs = &.{"a*"} }, .{ .specs = &.{"a/*"} }, .{ .specs = &.{":(glob)a/*"} }, .{ .specs = &.{":(glob)**/*.c"} }, .{ .specs = &.{":(glob)a/**"} }, .{ .specs = &.{"st*r"} }, .{ .specs = &.{":(literal)st*r"} }, .{ .specs = &.{"Br[a]"} }, .{ .specs = &.{":(icase)dir"} }, .{ .specs = &.{":(icase)A"} }, .{ .specs = &.{":!a"} }, .{ .specs = &.{ ":^a", ":(exclude)d" } }, .{ .specs = &.{ ":(exclude)a", "a/b" } }, .{ .specs = &.{":/top"} }, .{ .specs = &.{"a/"} }, .{ .specs = &.{"top/"} }, .{ .specs = &.{"?.c"} }, .{ .specs = &.{"\\*.c"} }, .{ .specs = &.{"x.*"} }, .{ .specs = &.{":(glob)x.*"} }, .{ .specs = &.{"w*"} }, .{ .specs = &.{":(glob)w*"} }, .{ .specs = &.{":(icase,glob)A/*"} }, .{ .specs = &.{ ":!top", ":!d" } }, .{ .specs = &.{":(exclude)"} }, .{ .specs = &.{":(glob)a/b*"} }, .{ .specs = &.{"a/b*"} }, .{ .specs = &.{":(glob)**/b"} }, .{ .specs = &.{":(icase)DEEP/A/B"} }, .{ .cwd = "a/b", .specs = &.{"*.c"} }, .{ .cwd = "a/b", .specs = &.{":/*.c"} }, .{ .cwd = "a/b", .specs = &.{"../*"} }, .{ .cwd = "a/b", .specs = &.{":(glob)../*"} }, .{ .cwd = "a/b", .specs = &.{":(icase)../B/C.C"} }, .{ .cwd = "a/b", .specs = &.{":(top)a/./one"} }, .{ .cwd = "w*", .specs = &.{"*"} }, .{ .cwd = "A", .specs = &.{":(icase)up"} }, .{ .cwd = "a/b", .specs = &.{".."} }, .{ .cwd = "a/b", .specs = &.{ ":(exclude)*", ":(top)top" } }, }; for (cases) |case| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "ls-files", "--full-name", "--" }); try args.appendSlice(gpa, case.specs); const result = try repo.runIn(case.cwd, args.items, ""); defer result.deinit(gpa); const prefix = if (case.cwd.len == 0) "" else try std.mem.concat(gpa, u8, &.{ case.cwd, "/" }); defer if (case.cwd.len != 0) gpa.free(prefix); var ps = try parse(gpa, case.specs, .{ .prefix = prefix, .work_tree = repo.path }, null); defer ps.deinit(gpa); var any_positive = false; for (ps.items) |it| if (!it.magic.exclude) { any_positive = true; }; for (files) |f| { var lines = std.mem.splitScalar(u8, result.stdout, '\n'); const want = while (lines.next()) |l| { if (std.mem.eql(u8, l, f)) break true; } else false; // ls-files keeps to the current directory when nothing but // exclusions is given. const got = ps.matches(f, false) and (any_positive or std.mem.startsWith(u8, f, prefix)); if (want != got) { std.debug.print("{s} in {s}: {s} git={} got={}\n", .{ case.specs[0], case.cwd, f, want, got }); return error.TestUnexpectedResult; } } }}
test "a pattern ending in / matches a gitlink, and reach never passes over a match" { const gpa = testing.allocator; var ps = try parse(gpa, &.{"sub/"}, .{}, null); defer ps.deinit(gpa); try testing.expect(ps.matches("sub", true)); try testing.expect(!ps.matches("sub", false)); try testing.expect(ps.matches("sub/f", false)); try testing.expectEqual(Reach.all, ps.reach("sub")); try testing.expectEqual(Reach.none, ps.reach("su"));}
fn fuzzOne(_: void, smith: *testing.Smith) anyerror!void { var buffer: [256]u8 = undefined; const input = fuzz_input.bytes(smith, &buffer); // Arguments separated by newlines, a NUL, the prefix, a NUL, and // paths separated by newlines. var sections = std.mem.splitScalar(u8, input, 0); const spec_text = sections.next() orelse ""; var prefix = sections.next() orelse ""; const paths_text = sections.rest(); if (prefix.len != 0 and prefix[prefix.len - 1] != '/') prefix = ""; var args: [8][]const u8 = undefined; var n: usize = 0; var it = std.mem.splitScalar(u8, spec_text, '\n'); while (it.next()) |a| { if (n == args.len) break; args[n] = a; n += 1; } const gpa = testing.allocator; var ps = parse(gpa, args[0..n], .{ .prefix = prefix, .work_tree = "/w" }, null) catch return; defer ps.deinit(gpa); var paths = std.mem.splitScalar(u8, paths_text, '\n'); while (paths.next()) |p| { if (p.len == 0) continue; const m = ps.matches(p, false); // A match below a directory must not be passed over, and what is // below a directory reached whole must match. try testing.expect(!m or ps.reach("") != .none); if (ps.reach("") == .all) try testing.expect(m); var i: usize = 0; while (std.mem.findScalarPos(u8, p, i, '/')) |slash| : (i = slash + 1) { if (slash == 0) continue; const r = ps.reach(p[0..slash]); if (m) try testing.expect(r != .none); if (r == .all) try testing.expect(m); } }}
test "fuzz: pathspec" { try fuzz_input.fuzz({}, fuzzOne, .{ .corpus = &.{ @embedFile("fuzz/pathspec-magic"), @embedFile("fuzz/pathspec-glob"), } });}