Artifact e6948998d8bc36d64d2bd56e613719d2ef09dbf2bd757be5266ce52fd40b392f:
- File
src/ignore.zig
— part of check-in
[b7e077e430]
at
2026-09-07 01:37:49
on branch trunk
— settings: read the checkout the way fossil does (zit#175)
Fossil-dialect globs treat ! as a class member; only ^ negates. open reads a versioned-only manifest setting out of the check-in and remembers --manifest=off so update cannot re-enable stamps. add --force adds a named ignore-glob match; directory expansion always honours ignore and clean globs. .fos root names compare case-sensitively on Linux. (user: dmestas, size: 24175) [annotate] [blame] [check-ins using]
//! #64 — the ignore mechanism: which untracked working files `extras`, //! `addremove` and `clean` leave alone. //! //! The fossil-dialect patterns come from the `ignore-glob` SETTING, resolved //! by the settings reader through fossil's own precedence (a versioned //! `.fossil-settings/ignore-glob` or `.zit-settings/ignore-glob` file, then //! this repository's config frame, then the operator's global file). This //! module is handed the winning text; it never reads a settings file itself, //! so the listing verbs and `zit settings` cannot disagree about the value. //! //! A pattern list is tokenized by fossil's `glob_create` rules -- see //! `PatternIter`: commas AND whitespace separate patterns, `'`/`"` quote one //! that needs either, and empty patterns are skipped (which is also what //! makes the config frame's collision pad a no-op). A glob matches the whole //! root-relative path: `*` matches any run //! of bytes INCLUDING `/` (fossil's rule, and the reason `zig-out/*` is the //! idiom for a directory), `?` matches one byte, `[a-z]` and `[^a-z]` match //! classes -- sqlite's GLOB, which fossil's `sqlite3_strglob` is, so a `!` //! inside a class is one more member, never a negation (zit#175 item 1). //! Matching is byte-wise and case-sensitive. //! //! A root `.zitignore` speaks GITIGNORE instead: one pattern per line, `#` //! comments, `!` negation with last-match-wins, `*`/`?` stopping at `/`, //! `**` crossing it, a trailing `/` restricting to directories, a leading //! `/` (or any interior one) anchoring to the root, and a slash-free //! pattern matching any path segment. One deliberate superset: `**` crosses //! `/` wherever it appears, not only between separators. Root-level only — //! nested `.zitignore` files are not consulted. //! //! The two dialects UNION (either may ignore a path; each is evaluated //! self-contained, so a `.zitignore` negation cannot re-include what a //! fossil-style pattern ignored). `.zitignore` ADDS to the setting rather //! than replacing it: an absent or unreadable source must narrow the ignore //! set, never widen it. const std = @import("std"); /// The versioned override's path inside the checkout, fossil's /// `.fossil-settings/ignore-glob` analog. pub const versioned_dir: []const u8 = ".zit-settings"; pub const versioned_path: []const u8 = ".zit-settings/ignore-glob"; /// Fossil's spelling (#117): a repository cloned from a Fossil server carries /// its ignore policy here, and it outranks the zit-spelled file when both /// exist -- the same precedence the settings resolver applies. pub const fossil_versioned_path: []const u8 = ".fossil-settings/ignore-glob"; /// The gitignore-dialect sibling, git's muscle memory honored at the root. pub const gitignore_path: []const u8 = ".zitignore"; /// An upper bound on the versioned file; a pattern list beyond this is not a /// pattern list. pub const max_versioned_bytes: usize = 64 * 1024; /// One resolved pattern list plus the storage backing it. Value semantics: /// the caller deinits. pub const Set = struct { /// Fossil-dialect pattern text (the versioned file or the setting), owned. /// Empty means no fossil-dialect patterns. text: []u8, /// Gitignore-dialect text (`.zitignore`), owned. Empty means absent. git_text: []u8, gpa: std.mem.Allocator, pub fn deinit(self: *Set) void { self.gpa.free(self.text); self.gpa.free(self.git_text); self.* = undefined; } /// Whether `path` (clean, root-relative) is ignored by either dialect. pub fn matches(self: *const Set, path: []const u8) bool { if (listMatches(self.text, path)) return true; if (self.git_text.len != 0 and gitignoreMatches(self.git_text, path)) return true; return false; } }; /// Build a matcher from a bare fossil-dialect pattern list, with no gitignore /// half. #83's `keep-glob` is exactly this: one list, one dialect, and /// deliberately NOT `.zitignore` — a file the operator wants git to ignore says /// nothing about whether `zit clean` may delete it. /// /// Null for an empty list, so "no patterns" and "match nothing" are the same /// object and every caller's null check means one thing. pub fn fromPatterns(gpa: std.mem.Allocator, patterns: []const u8) ?Set { if (patterns.len == 0) return null; const text = gpa.dupe(u8, patterns) catch return null; const empty = gpa.dupe(u8, "") catch { gpa.free(text); return null; }; return .{ .text = text, .git_text = empty, .gpa = gpa }; } /// The effective ignore set for one checkout: the already-resolved /// `ignore-glob` value COMBINED with a root `.zitignore`, if the tree carries /// one. Both apply -- they are two dialects of one intent, and a `.zitignore` /// that shadowed the setting entirely made an absent or unreadable source /// WIDEN what gets hidden, which is the wrong direction for a listing. /// /// The setting itself is resolved by the caller through the one settings /// reader (versioned file, repository frame, global file), so this function /// never reads a settings file and the two cannot drift. /// /// Never fails the caller's verb -- an unreadable `.zitignore` degrades to /// the setting alone, because "could not read an optional file" must not make /// `extras` a new way to fail. Returns null when neither source has patterns. pub fn fromResolved( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, setting: []const u8, ) ?Set { const git_text: []u8 = root_dir.readFileAlloc(io, gitignore_path, gpa, .limited(max_versioned_bytes)) catch gpa.dupe(u8, "") catch return null; if (setting.len == 0 and git_text.len == 0) { gpa.free(git_text); return null; } const text = gpa.dupe(u8, setting) catch { gpa.free(git_text); return null; }; return .{ .text = text, .git_text = git_text, .gpa = gpa }; } /// Whether a byte separates two patterns in an unquoted run: fossil's /// `fossil_isspace` (space plus the `\t`..`\r` block) or a comma. fn isListSpace(c: u8) bool { return c == ' ' or (c >= '\t' and c <= '\r'); } /// THE glob-list tokenizer, fossil's `glob.c glob_create` rules exactly, so /// every `*-glob` setting and `empty-dirs` split their text the same way: /// /// * leading commas and whitespace are skipped; /// * a pattern opening with `'` or `"` runs to the MATCHING quote, so /// commas and spaces inside it are literal and the other quote is an /// ordinary byte; an unterminated quote runs to the end of the list; /// * any other pattern runs to the next comma or whitespace byte. /// /// One deliberate superset: fossil's `''` yields an EMPTY pattern, which /// matches only the empty path; zit skips empties, so an empty pattern can /// never match anything. That is also what makes the config frame's /// collision pad a no-op. /// /// Exported because callers need the PATTERNS, not only a match verdict: /// `empty-dirs` creates a directory per pattern. pub const PatternIter = struct { list: []const u8, i: usize = 0, pub fn init(list: []const u8) PatternIter { return .{ .list = list }; } /// The next non-empty pattern, borrowed from `list`. Every call advances /// the cursor by at least one byte, so a `while (it.next())` loop is /// bounded by `list.len`. pub fn next(self: *PatternIter) ?[]const u8 { while (self.i < self.list.len) { const before = self.i; while (self.i < self.list.len and (isListSpace(self.list[self.i]) or self.list[self.i] == ',')) self.i += 1; if (self.i == self.list.len) return null; var delim: u8 = ','; var quoted = false; const open = self.list[self.i]; if (open == '\'' or open == '"') { delim = open; quoted = true; self.i += 1; } const start = self.i; while (self.i < self.list.len) : (self.i += 1) { const c = self.list[self.i]; if (c == delim) break; if (!quoted and isListSpace(c)) break; } const pattern = self.list[start..self.i]; // Consume the closing delimiter; end-of-list leaves the cursor put. if (self.i < self.list.len) self.i += 1; std.debug.assert(self.i > before); // no call may stall the loop if (pattern.len != 0) return pattern; } return null; } }; /// Whether `path` matches any glob in `list`, in the fossil dialect this file /// documents: `PatternIter`'s separators and quoting, `*` crossing `/`. /// Shared with settings that are glob LISTS but are not the ignore set: #81's /// `binary-glob` and `crlf-glob` decide which files may carry NUL bytes or /// CR/LF without the commit refusing, and must read their patterns exactly as /// `ignore-glob` does or the two would drift into separate dialects. pub fn listMatches(list: []const u8, path: []const u8) bool { var it: PatternIter = .init(list); while (it.next()) |pattern| { if (globMatch(pattern, path)) return true; } return false; } /// One gitignore evaluation: every line in order, last match wins, `!` /// negates. Returns whether the final verdict is "ignored". fn gitignoreMatches(text: []const u8, path: []const u8) bool { var verdict = false; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |raw_line| { var line = std.mem.trimEnd(u8, raw_line, " \t\r"); if (line.len == 0 or line[0] == '#') continue; var negate = false; if (line[0] == '!') { negate = true; line = line[1..]; if (line.len == 0) continue; } if (gitPatternMatch(line, path)) verdict = !negate; } return verdict; } /// One gitignore pattern against one root-relative file path. /// /// A trailing `/` means directories only — for a FILE path that reads as /// "some proper ancestor directory matches". A pattern with an interior or /// leading `/` is anchored at the root; slash-free patterns match any path /// segment. A directory match ignores everything beneath it, exactly git's /// "not worth enumerating" rule. fn gitPatternMatch(pattern_in: []const u8, path: []const u8) bool { var pattern = pattern_in; var dir_only = false; if (pattern.len != 0 and pattern[pattern.len - 1] == '/') { dir_only = true; pattern = pattern[0 .. pattern.len - 1]; } if (pattern.len == 0) return false; const anchored = pattern[0] == '/' or std.mem.indexOfScalar(u8, pattern, '/') != null; if (pattern[0] == '/') pattern = pattern[1..]; if (pattern.len == 0) return false; if (anchored) { // Anchored: the pattern must cover the whole path, or a directory // prefix of it (a matched directory ignores its contents). Dir-only // must NOT take the whole-path case — a file is not a directory. if (!dir_only and wildPath(pattern, path)) return true; var i: usize = 0; while (std.mem.indexOfScalarPos(u8, path, i, '/')) |slash| { if (wildPath(pattern, path[0..slash])) return true; i = slash + 1; } return false; } // Slash-free: match any segment — the final one unless directories only. var it = std.mem.splitScalar(u8, path, '/'); var segment = it.next(); while (segment) |seg| { const next = it.next(); const is_final = next == null; if ((!is_final or !dir_only) and wildPath(pattern, seg)) return true; segment = next; } return false; } /// Gitignore's wildmatch core: `*` and `?` stop at `/`, `**` crosses it /// (everywhere — the documented superset), classes as in the fossil matcher. /// Iterative with the same single-star backtrack; a backtrack never carries /// a non-crossing star past a `/`. fn wildPath(pattern: []const u8, path: []const u8) bool { var p: usize = 0; var t: usize = 0; var star_p: ?usize = null; var star_t: usize = 0; var star_crosses = false; while (t < path.len) { if (p < pattern.len) { switch (pattern[p]) { '*' => { const double = p + 1 < pattern.len and pattern[p + 1] == '*'; star_crosses = double; p += if (double) @as(usize, 2) else 1; star_p = p; star_t = t; continue; }, '?' => { if (path[t] != '/') { p += 1; t += 1; continue; } }, '[' => { if (path[t] != '/') { if (classMatch(pattern[p..], path[t], true)) |consumed| { p += consumed; t += 1; continue; } } }, else => { if (pattern[p] == path[t]) { p += 1; t += 1; continue; } }, } } if (star_p) |sp| { if (!star_crosses and path[star_t] == '/') return false; star_t += 1; p = sp; t = star_t; continue; } return false; } while (p + 1 < pattern.len and pattern[p] == '*' and pattern[p + 1] == '*') p += 2; while (p < pattern.len and pattern[p] == '*') p += 1; return p == pattern.len; } /// One glob against one path, iterative with explicit star backtracking — /// linear in `path.len * star_count`, no recursion (TigerStyle). `*` crosses /// `/`; an unterminated `[` class matches a literal `[`. fn globMatch(pattern: []const u8, path: []const u8) bool { var p: usize = 0; // pattern cursor var t: usize = 0; // path cursor var star_p: ?usize = null; // pattern position after the last `*` var star_t: usize = 0; // path position that star last consumed to while (t < path.len) { if (p < pattern.len) { switch (pattern[p]) { '*' => { star_p = p + 1; star_t = t; p += 1; continue; }, '?' => { p += 1; t += 1; continue; }, '[' => { if (classMatch(pattern[p..], path[t], false)) |consumed| { p += consumed; t += 1; continue; } }, else => { if (pattern[p] == path[t]) { p += 1; t += 1; continue; } }, } } // Mismatch: give the last star one more byte, or fail. if (star_p) |sp| { star_t += 1; p = sp; t = star_t; continue; } return false; } // Path consumed; only trailing stars may remain. while (p < pattern.len and pattern[p] == '*') p += 1; return p == pattern.len; } /// Match one byte against the `[...]` class at the head of `pattern`. /// Returns the class's width in pattern bytes on a match, null on a /// non-match. An unterminated class is a literal `[`: match that byte, /// width 1 — refusing would make one typo'd pattern unmatchable forever /// with no channel to say why. `bang_negates` is the dialect switch: gitignore /// negates on `!` or `^`, sqlite's GLOB (fossil's matcher) on `^` alone. fn classMatch(pattern: []const u8, byte: u8, bang_negates: bool) ?usize { std.debug.assert(pattern[0] == '['); const end = std.mem.indexOfScalarPos(u8, pattern, 1, ']') orelse { return if (byte == '[') 1 else null; }; // `[]` has no members to match; treat the bracket as a literal byte so // the pattern stays expressible rather than silently dead. if (end == 1) return if (byte == '[') 1 else null; var body = pattern[1..end]; var negate = false; if (body.len != 0 and (body[0] == '^' or (bang_negates and body[0] == '!'))) { negate = true; body = body[1..]; } var hit = false; var i: usize = 0; while (i < body.len) { if (i + 2 < body.len and body[i + 1] == '-') { if (byte >= body[i] and byte <= body[i + 2]) hit = true; i += 3; } else { if (byte == body[i]) hit = true; i += 1; } } if (hit != negate) return end + 1; return null; } test "#174 item 1: the glob list tokenizes by fossil's rules — whitespace, commas, quotes" { // Expectations derived from fossil `glob.c glob_create` (skip leading // commas and whitespace; a leading quote runs to the matching quote; // otherwise the pattern ends at the next comma OR whitespace byte). const cases = [_]struct { list: []const u8, want: []const []const u8 }{ .{ .list = "*.o *.a", .want = &.{ "*.o", "*.a" } }, .{ .list = "*.o,*.a", .want = &.{ "*.o", "*.a" } }, .{ .list = " ,, *.o \t,\n *.a ", .want = &.{ "*.o", "*.a" } }, .{ .list = "\"a b.txt\"", .want = &.{"a b.txt"} }, .{ .list = "'a,b'", .want = &.{"a,b"} }, // The other quote is an ordinary byte inside a quoted pattern. .{ .list = "'x\"y'", .want = &.{"x\"y"} }, .{ .list = "\"one two\" three", .want = &.{ "one two", "three" } }, // A CRLF-terminated settings file is a list of two patterns, not one. .{ .list = "*.o\r\n*.a\r\n", .want = &.{ "*.o", "*.a" } }, .{ .list = "", .want = &.{} }, .{ .list = " ", .want = &.{} }, // fossil yields one EMPTY pattern here; zit skips empties (see the // PatternIter doc comment). .{ .list = "''", .want = &.{} }, .{ .list = "a,'',b", .want = &.{ "a", "b" } }, // An unterminated quote runs to the end of the list. .{ .list = "'a b", .want = &.{"a b"} }, .{ .list = "#comment", .want = &.{"#comment"} }, }; for (cases) |c| { var it: PatternIter = .init(c.list); var n: usize = 0; while (it.next()) |p| : (n += 1) { try std.testing.expect(n < c.want.len); try std.testing.expectEqualStrings(c.want[n], p); } try std.testing.expectEqual(c.want.len, n); } // The whole point: a space-separated list matches both of its patterns. try std.testing.expect(listMatches("*.o *.a", "sub/x.a")); try std.testing.expect(listMatches("\"a b.txt\"", "a b.txt")); try std.testing.expect(!listMatches("\"a b.txt\"", "a")); } test "ignore: literal, star crosses slash, question, classes, list separators" { const cases = [_]struct { pattern: []const u8, path: []const u8, want: bool }{ .{ .pattern = "HANDOFF.md", .path = "HANDOFF.md", .want = true }, .{ .pattern = "HANDOFF.md", .path = "sub/HANDOFF.md", .want = false }, .{ .pattern = "*.tmp", .path = "a.tmp", .want = true }, .{ .pattern = "*.tmp", .path = "deep/dir/b.tmp", .want = true }, // `*` crosses `/` .{ .pattern = "*.tmp", .path = "a.tmpx", .want = false }, .{ .pattern = "zig-out/*", .path = "zig-out/bin/zit", .want = true }, .{ .pattern = "zig-out/*", .path = "zig-out", .want = false }, .{ .pattern = "docs/plans/*", .path = "docs/plans/x.md", .want = true }, .{ .pattern = "a?c", .path = "abc", .want = true }, .{ .pattern = "a?c", .path = "a/c", .want = true }, // `?` is any byte, fossil's rule .{ .pattern = "a?c", .path = "ac", .want = false }, .{ .pattern = "[a-c]x", .path = "bx", .want = true }, .{ .pattern = "[a-c]x", .path = "dx", .want = false }, .{ .pattern = "[^a-c]x", .path = "dx", .want = true }, .{ .pattern = "[^a-c]x", .path = "bx", .want = false }, // zit#175 item 1: fossil's `!` is a class member, not a negation. .{ .pattern = "[!a-c]x", .path = "dx", .want = false }, .{ .pattern = "[!a-c]x", .path = "!x", .want = true }, .{ .pattern = "*[0-9].log", .path = "run7.log", .want = true }, .{ .pattern = "**", .path = "anything/at/all", .want = true }, .{ .pattern = "[unterminated", .path = "[unterminated", .want = true }, }; for (cases) |c| { try std.testing.expectEqual(c.want, globMatch(c.pattern, c.path)); } var set: Set = .{ .text = try std.testing.allocator.dupe(u8, " zig-out/* , *.tmp \n#no-comment-syntax\n"), .git_text = try std.testing.allocator.dupe(u8, ""), .gpa = std.testing.allocator, }; defer set.deinit(); try std.testing.expect(set.matches("zig-out/lib/a.a")); try std.testing.expect(set.matches("x/y/z.tmp")); try std.testing.expect(!set.matches("src/main.zig")); // No comment syntax: a `#` line is a (never-matching) pattern, not magic. try std.testing.expect(!set.matches("no-comment-syntax")); } test "ignore #64: gitignore dialect — anchoring, segments, dir-only, negation, star scoping" { const cases = [_]struct { text: []const u8, path: []const u8, want: bool }{ // Slash-free matches any segment, including directories. .{ .text = "*.log", .path = "a/b/x.log", .want = true }, .{ .text = "build", .path = "build/out.o", .want = true }, .{ .text = "build", .path = "x/build/out.o", .want = true }, .{ .text = "build", .path = "rebuild/out.o", .want = false }, // `*` does not cross `/` in this dialect; `**` does. .{ .text = "src/*.zig", .path = "src/main.zig", .want = true }, .{ .text = "src/*.zig", .path = "src/git/x.zig", .want = false }, .{ .text = "src/**/*.zig", .path = "src/git/x.zig", .want = true }, // Interior slash anchors to the root. .{ .text = "docs/plans", .path = "docs/plans/x.md", .want = true }, .{ .text = "docs/plans", .path = "sub/docs/plans/x.md", .want = false }, // Leading slash anchors a slash-free name. .{ .text = "/scratch.tmp", .path = "scratch.tmp", .want = true }, .{ .text = "/scratch.tmp", .path = "sub/scratch.tmp", .want = false }, // Trailing slash means directories only. .{ .text = "cache/", .path = "cache/x", .want = true }, .{ .text = "cache/", .path = "cache", .want = false }, // Negation, last match wins. .{ .text = "*.log\n!keep.log", .path = "keep.log", .want = false }, .{ .text = "*.log\n!keep.log", .path = "other.log", .want = true }, .{ .text = "!keep.log\n*.log", .path = "keep.log", .want = true }, // A class negates on `!` here (gitignore), unlike the fossil dialect. .{ .text = "[!a-c]x", .path = "dx", .want = true }, .{ .text = "[!a-c]x", .path = "bx", .want = false }, // Comments and blanks are inert. .{ .text = "# a comment\n\n*.tmp", .path = "x.tmp", .want = true }, .{ .text = "# a comment", .path = "# a comment", .want = false }, }; for (cases) |c| { try std.testing.expectEqual(c.want, gitignoreMatches(c.text, c.path)); } // Union with the fossil dialect: either file may ignore; a `.zitignore` // negation cannot re-include what the fossil-style list ignored. var set: Set = .{ .text = try std.testing.allocator.dupe(u8, "*.tmp"), .git_text = try std.testing.allocator.dupe(u8, "*.log\n!keep.tmp\n!keep.log"), .gpa = std.testing.allocator, }; defer set.deinit(); try std.testing.expect(set.matches("a.tmp")); try std.testing.expect(set.matches("a.log")); try std.testing.expect(!set.matches("keep.log")); try std.testing.expect(set.matches("keep.tmp")); // fossil side ignored it; negation is dialect-local }