//! Ignore rules. //! //! `.kohignore` lives at the repo root and is itself snapshotted, so the rules //! travel between machines with the history. Patterns are matched against //! repo-relative paths: `*` matches inside one path segment, `**` spans //! segments, a trailing `/` restricts a pattern to directories, a leading //! `/` anchors it to the repo root, and `\` makes the next character literal. module snapshot import os // The seed written by `koh init`, carried over from Koh 2's default list. pub const default_ignore = '# koh ignore rules (.kohignore) # Generated by koh init. Edit to fit the project. # Patterns are matched against repo-relative paths. Suffix `/` restricts # to directories. Leading `/` anchors to the repo root. A pattern like # `*.log` matches any file with that extension anywhere in the tree. # # koh already ignores .koh, .koh-wal, .koh-shm, .koh-v2/ and .git/ automatically. # Build output zig-out/ .zig-cache/ zig-cache/ target/ build/ dist/ out/ # Dependencies node_modules/ # OS noise .DS_Store Icon\\r Thumbs.db # Logs *.log # Koh artifacts *.bundle *.koh.offer ' pub const ignore_file = '.kohignore' struct Pattern { segments []string dir_only bool anchored bool } pub struct Ignore { patterns []Pattern } /// Machine-wide ignore rules, excluded from repository history. pub fn global_ignore_path() string { return os.join_path(os.home_dir(), ignore_file) } /// Load both scopes; either can exclude a path. pub fn load(root string) Ignore { local := os.read_file(os.join_path(root, ignore_file)) or { '' } global := os.read_file(global_ignore_path()) or { '' } return parse(global + '\n' + local) } /// Rules that exclude a literal path or an ancestor directory. pub fn matching_rules(text string, rel string, is_dir bool) []string { mut rules := []string{} for raw in text.split_into_lines() { rule := raw.trim_space() if rule == '' || rule.starts_with('#') { continue } ig := parse(rule) mut matched := ig.matches(rel, is_dir) parts := rel.split('/') for end in 1 .. parts.len { matched = matched || ig.matches(parts[..end].join('/'), true) } if matched && rule !in rules { rules << rule } } return rules } /// Escape a literal path; anchor local rules and leave global rules unanchored. pub fn ignore_rule(path string, is_dir bool, global bool) string { prefix := if global { '' } else { '/' } suffix := if is_dir { '/' } else { '' } return prefix + literal(path) + suffix } // banning adds a whole-history strip's paths as anchored rules, so the tree agrees with the // history on every machine that holds the strip, before `.kohignore` names them there too. fn (ig Ignore) banning(paths []string) Ignore { mut patterns := ig.patterns.clone() for path in paths { patterns << Pattern{ segments: literal(path).split('/') anchored: true } } return Ignore{ patterns: patterns } } // add_ignore appends an anchored `/` rule to `.kohignore`, unless that line is already // there, and reports whether it wrote one. pub fn add_ignore(root string, path string) !bool { file := os.join_path(root, ignore_file) text := if os.is_file(file) { os.read_file(file)! } else { '' } rule := '/${literal(path)}' if text.split_into_lines().any(it.trim_space() == rule) { return false } separator := if text == '' || text.ends_with('\n') { '' } else { '\n' } os.write_file(file, '${text}${separator}${rule}\n')! return true } // literal escapes a path so a rule names it and nothing else. fn literal(path string) string { return path.replace('\\', '\\\\').replace('*', '\\*').replace('?', '\\?').replace('#', '\\#') } pub fn parse(text string) Ignore { mut patterns := []Pattern{} for raw in text.split_into_lines() { line := raw.trim_space() if line == '' || line.starts_with('#') { continue } mut body := line mut dir_only := false if body.ends_with('/') { dir_only = true body = body.trim_right('/') } mut anchored := false if body.starts_with('/') { anchored = true body = body.trim_left('/') } if body == '' { continue } patterns << Pattern{ segments: body.split('/') dir_only: dir_only anchored: anchored } } return Ignore{ patterns: patterns } } // hard_ignored covers the files Koh writes into a repo as part of working // there. They are never snapshotted whatever `.kohignore` says, at any depth: // a repo inside the repo keeps its own history, and restoring an old copy of // its `.koh` or `.git` would roll that history back. // // LOG.md is rewritten after every save from the history itself. Tracking it // would make the tree dirty the moment a save lands, so `status` could never // report a clean tree and every diff would carry the log's own churn. pub fn hard_ignored(rel string) bool { if rel == 'Icon\r' { return true } if rel == 'LOG.md' { return true } return rel.split('/').any(it in hard_ignored_names) } const hard_ignored_names = ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.git'] // matches reports whether a repo-relative path is ignored. `is_dir` matters // because a pattern ending in `/` names a directory: it covers the directory // itself and everything below it, but not a file that happens to share the // name. pub fn (ig Ignore) matches(rel string, is_dir bool) bool { if hard_ignored(rel) { return true } parts := rel.split('/') for pat in ig.patterns { if ig.matches_pattern(parts, is_dir, pat) { return true } } return false } fn (ig Ignore) matches_pattern(parts []string, is_dir bool, pat Pattern) bool { if pat.dir_only { // Anything inside an ignored directory is ignored too. if parts.len > 1 && match_any_prefix(parts[..parts.len - 1], pat.segments, pat.anchored) { return true } if !is_dir { return false } } // Otherwise the pattern has to match the whole path. for start in 0 .. parts.len { if pat.anchored && start != 0 { break } if match_segments(parts, start, pat.segments, 0, false) { return true } } return false } // match_any_prefix asks whether the pattern matches a leading run of segments // starting at any point in the path. fn match_any_prefix(parts []string, pat []string, anchored bool) bool { for start in 0 .. parts.len { if anchored && start != 0 { break } for end in start + 1 .. parts.len + 1 { if match_segments(parts[start..end], 0, pat, 0, false) { return true } } } return false } // match_segments consumes `pat` from `gi` over `path` from `pi`. // `allow_trailing` lets the path keep segments the pattern does not name, // which is what makes a directory pattern cover everything beneath it. fn match_segments(path []string, pi int, pat []string, gi int, allow_trailing bool) bool { if gi == pat.len { return allow_trailing || pi == path.len } seg := pat[gi] if seg == '**' { for skip in pi .. path.len + 1 { if match_segments(path, skip, pat, gi + 1, allow_trailing) { return true } } return false } if pi >= path.len { return false } if !match_segment(path[pi], seg) { return false } return match_segments(path, pi + 1, pat, gi + 1, allow_trailing) } // match_segment is shell-style matching within one path segment. fn match_segment(name string, pat string) bool { mut n := 0 mut p := 0 mut star := -1 mut mark := 0 for n < name.len { escaped := p + 1 < pat.len && pat[p] == `\\` if escaped && pat[p + 1] == name[n] { n++ p += 2 } else if !escaped && p < pat.len && (pat[p] == name[n] || pat[p] == `?`) { n++ p++ } else if !escaped && p < pat.len && pat[p] == `*` { star = p mark = n p++ } else if star >= 0 { p = star + 1 mark++ n = mark } else { return false } } for p < pat.len && pat[p] == `*` { p++ } return p == pat.len }