Skip to content
zit Artifact [f6be88ac38]
Login

Artifact [f6be88ac38]

Artifact f6be88ac380fa3c6ee3557bff621c44b578e55e58b63c767d616591c7e82cf9e:


//! 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.
//! 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: 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. `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:
/// `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 "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 },
        // 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: 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
}