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
4 files VERSION+1 −1src/snapshot/ignore.v+15 −6src/snapshot/snapshot_test.v+17 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.1.41+3.1.5

view file

src/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++

view file

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'] {

view file

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 }

view file