Artifact 5a153caf65965db6430c5aa93429da3d170bacbb4c633cdeaba25c76bcdf9c7f:
- File
src/ignore.zig
— part of check-in
[998dbf9d43]
at
2026-08-16 18:57:59
on branch trunk
— settings: keep-glob, manifest, before-commit consent, config pull (#82, #83)
keep-glob protects files clean must never delete while extras still lists them; manifest becomes a project setting driving build stamps. Both resolve across the tier chain, so a tracked .zit-settings file clones with the code.
before-commit splits declaration from consent (#82): the project ships the check as a tracked file, zit trust records the SHA-256 this checkout agreed to run. Cloning executes nothing, and an edit revokes consent by itself because the record pins content, not a flag. Non-zero exit aborts before anything is minted.
zit config pull shun is fossil's explicit request side; carriage stays clone-time otherwise so no peer can suppress content here on its own say-so.
Storage gains one keyed-string block: unknown keys are skipped by length, so the next string setting needs a key rather than another shape tag. Existing frames are unchanged.
clean-glob is deliberately absent: it means never-prompt and no-undo, which zit clean already is. (user: dmestas, size: 17656) [annotate] [blame] [check-ins using]
//! #64 — the ignore mechanism: which untracked working files `extras`, //! `addremove` and `clean` leave alone. //! //! Two sources, fossil's shape exactly. The LOCAL setting (`zit settings //! ignore-glob ...`) lives in the store's config frame and never syncs. The //! VERSIONED file `.zit-settings/ignore-glob` is an ordinary tracked file, so //! it clones with the repository — and when the working tree carries one, it //! WINS over the local setting, because a value the whole project shares must //! not be silently shadowed by one machine's leftover. //! //! A pattern list is comma- or newline-separated globs; 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. 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. //! //! When both files exist their verdicts UNION (either may ignore a path; //! each is evaluated self-contained, so a `.zitignore` negation cannot //! re-include what the fossil-style file ignored). The local setting is the //! fallback when neither file exists. 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"; /// 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 { var it = std.mem.tokenizeAny(u8, self.text, ",\n\r"); while (it.next()) |raw| { const pattern = std.mem.trim(u8, raw, " \t"); if (pattern.len == 0) continue; if (globMatch(pattern, 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 }; } /// Resolve the effective ignore set for one checkout: the versioned file if /// the working tree carries one, else `setting` (the config frame's value). /// Never fails the caller's verb — an unreadable versioned file degrades to /// the setting, 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 resolve( 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; var fossil_text: ?[]u8 = root_dir.readFileAlloc(io, versioned_path, gpa, .limited(max_versioned_bytes)) catch null; if (fossil_text == null and git_text.len == 0) { // Neither file: the local setting is the fallback. if (setting.len == 0) { gpa.free(git_text); return null; } fossil_text = gpa.dupe(u8, setting) catch { gpa.free(git_text); return null; }; } const text = fossil_text orelse (gpa.dupe(u8, "") catch { gpa.free(git_text); return null; }); return .{ .text = text, .git_text = git_text, .gpa = gpa }; } /// 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])) |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])) |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. fn classMatch(pattern: []const u8, byte: u8) ?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 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 "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 = "*[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 }, // 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 }