abb551b2f221
ban a stripped path as itself: its * and ? are escaped in .kohignore and the walk's ban, and a backslash makes a rule's next character literal (3.1.5)
- time
- 2026-09-28 12:45 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- finalize the queue
- parent
- c5d37aafc8b8
↓ koh-abb551b2.tar.gzatlas at this save
files changed4 files
VERSION+1 −1src/snapshot/ignore.v+15 −6src/snapshot/snapshot_test.v+17 −0v.mod+1 −1src/snapshot/ignore.v+15 −6
@@ -3,8 +3,8 @@33 //! `.kohignore` lives at the repo root and is itself snapshotted, so the rules44 //! travel between machines with the history. Patterns are matched against55 //! repo-relative paths: `*` matches inside one path segment, `**` spans6-//! segments, a trailing `/` restricts a pattern to directories, and a leading7-//! `/` anchors it to the repo root.6+//! segments, a trailing `/` restricts a pattern to directories, a leading7+//! `/` anchors it to the repo root, and `\` makes the next character literal.88 99 module snapshot1010 @@ -71,7 +71,7 @@7171 mut patterns := ig.patterns.clone()7272 for path in paths {7373 patterns << Pattern{74- segments: path.split('/')74+ segments: literal(path).split('/')7575 anchored: true7676 }7777 }@@ -85,7 +85,7 @@8585 pub fn add_ignore(root string, path string) !bool {8686 file := os.join_path(root, ignore_file)8787 text := if os.is_file(file) { os.read_file(file)! } else { '' }88- rule := '/${path}'88+ rule := '/${literal(path)}'8989 if text.split_into_lines().any(it.trim_space() == rule) {9090 return false9191 }@@ -94,6 +94,11 @@9494 return true9595 }9696 97+// literal escapes a path so a rule names it and nothing else.98+fn literal(path string) string {99+ return path.replace('\\', '\\\\').replace('*', '\\*').replace('?', '\\?')100+}101+97102 pub fn parse(text string) Ignore {98103 mut patterns := []Pattern{}99104 for raw in text.split_into_lines() {@@ -230,10 +235,14 @@230235 mut star := -1231236 mut mark := 0232237 for n < name.len {233- if p < pat.len && (pat[p] == name[n] || pat[p] == `?`) {238+ escaped := p + 1 < pat.len && pat[p] == `\\`239+ if escaped && pat[p + 1] == name[n] {234240 n++241+ p += 2242+ } else if !escaped && p < pat.len && (pat[p] == name[n] || pat[p] == `?`) {243+ n++235244 p++236- } else if p < pat.len && pat[p] == `*` {245+ } else if !escaped && p < pat.len && pat[p] == `*` {237246 star = p238247 mark = n239248 p++src/snapshot/snapshot_test.v+17 −0
@@ -43,6 +43,23 @@4343 assert !ig.matches('src/main.v', false)4444 }4545 46+// A stripped path is banned as itself: its `*` and `?` must not become47+// wildcards, in `.kohignore` or in the walk's ban.48+fn test_a_stripped_path_with_wildcard_characters_bans_only_itself() {49+ root := temp_root('literal_ban')!50+ defer {51+ os.rmdir_all(root) or {}52+ }53+ assert add_ignore(root, 'keys/a*b?.env')!54+ assert os.read_file(os.join_path(root, ignore_file))! == '/keys/a\\*b\\?.env\n'55+ for ig in [load(root), Ignore{}.banning(['keys/a*b?.env'])] {56+ assert ig.matches('keys/a*b?.env', false)57+ assert !ig.matches('keys/axxbz.env', false)58+ assert !ig.matches('keys/a*b?.env.bak', false)59+ }60+ assert parse('\\\\x').matches('\\x', false)61+}62+4663 fn test_hard_ignores_are_not_overridable() {4764 for path in ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.koh-v2/snapshots/x.json', '.git',4865 '.git/config', 'LOG.md'] {v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.1.4'3+ version: '3.1.5'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }