Skip to content
zit Artifact [9ad779d033]
Login

Artifact [9ad779d033]

Artifact 9ad779d03378b1bb910f0ea53e9b88fc18c184fa6655ffa6f2d2bc7f61fdf1f1:


//! #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 {
        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 };
}

/// 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 };
}

/// Whether `path` matches any glob in `list`, in the fossil dialect this file
/// documents: comma- or newline-separated, empty patterns skipped, `*` 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 = std.mem.tokenizeAny(u8, list, ",\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;
    }
    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])) |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
}