525088cda79c

make ignore rules directory-aware and pin snapshot behaviour with tests

time
2026-09-23 19:54 UTC · 2 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
koh 3 implementation
parent
76be2198e257
5 files src/cli/cmd_back.v+2 −3src/snapshot/ignore.v+33 −15src/snapshot/restore.v+11 −2src/snapshot/snapshot_test.v+221 −0src/snapshot/tree.v+4 −2
src/cli/cmd_back.v+2 −3
@@ -128,9 +128,8 @@128128 		message:  '${verb}: ${target.id}'129129 		restores: target.id130130 	}) or { return refuse(as_json, err.msg()) }131-	report := snapshot.restore_files(store, store.root, plan.files, plan.remove) or {132-		return refuse(as_json, err.msg())133-	}131+	report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files,132+		plan.remove) or { return refuse(as_json, err.msg()) }134133 	if as_json {135134 		emit_json(json.encode(BackJson{136135 			id:        save.id

view file

src/snapshot/ignore.v+33 −15
@@ -119,38 +119,56 @@119119 	return false120120 }121121 122-pub fn (ig Ignore) matches(rel string) bool {122+// matches reports whether a repo-relative path is ignored. `is_dir` matters123+// because a pattern ending in `/` names a directory: it covers the directory124+// itself and everything below it, but not a file that happens to share the125+// name.126+pub fn (ig Ignore) matches(rel string, is_dir bool) bool {123127 	if hard_ignored(rel) {124128 		return true125129 	}126130 	parts := rel.split('/')127131 	for pat in ig.patterns {128-		if ig.matches_pattern(parts, pat) {132+		if ig.matches_pattern(parts, is_dir, pat) {129133 			return true130134 		}131135 	}132136 	return false133137 }134138 135-fn (ig Ignore) matches_pattern(parts []string, pat Pattern) bool {136-	if pat.anchored {137-		// A directory pattern matches any path below that directory.138-		if pat.dir_only {139-			return parts.len >= 2 && match_segments(parts, 0, pat.segments, 0, true)140-		}141-		return match_segments(parts, 0, pat.segments, 0, false)142-	}139+fn (ig Ignore) matches_pattern(parts []string, is_dir bool, pat Pattern) bool {143140 	if pat.dir_only {144-		for start in 0 .. parts.len {145-			if parts.len - start >= 2 && match_segments(parts, start, pat.segments, 0, true) {146-				return true147-			}141+		// Anything inside an ignored directory is ignored too.142+		if parts.len > 1 && match_any_prefix(parts[..parts.len - 1], pat.segments, pat.anchored) {143+			return true148144 		}149-		return false145+		if !is_dir {146+			return false147+		}150148 	}149+	// Otherwise the pattern has to match the whole path.151150 	for start in 0 .. parts.len {151+		if pat.anchored && start != 0 {152+			break153+		}152154 		if match_segments(parts, start, pat.segments, 0, false) {153155 			return true156+		}157+	}158+	return false159+}160+161+// match_any_prefix asks whether the pattern matches a leading run of segments162+// starting at any point in the path.163+fn match_any_prefix(parts []string, pat []string, anchored bool) bool {164+	for start in 0 .. parts.len {165+		if anchored && start != 0 {166+			break167+		}168+		for end in start + 1 .. parts.len + 1 {169+			if match_segments(parts[start..end], 0, pat, 0, false) {170+				return true171+			}154172 		}155173 	}156174 	return false

view file

src/snapshot/restore.v+11 −2
@@ -16,11 +16,20 @@1616 	removed int1717 }1818 19-pub fn restore_files(store &storage.Store, root string, target []FileEntry, remove []string) !RestoreReport {19+// restore_files writes exactly `target` into the working tree and deletes the20+// paths in `remove` (tracked files the target does not have). Ignored paths are21+// never touched — not written, not deleted — even if a caller asks. Every file22+// lands through a temp file and a rename, so an interrupted restore leaves a23+// readable tree rather than half-written files.24+pub fn restore_files(store &storage.Store, root string, ig Ignore, target []FileEntry, remove []string) !RestoreReport {25+	mut wanted := map[string]bool{}26+	for entry in target {27+		wanted[entry.path] = true28+	}2029 	mut removed := 02130 	for path in remove {2231 		full := os.join_path(root, path)23-		if !os.is_file(full) {32+		if !os.is_file(full) || ig.matches(path, false) || path in wanted {2433 			continue2534 		}2635 		os.rm(full)!

view file

src/snapshot/snapshot_test.v+221 −0
@@ -0,0 +1,221 @@1+module snapshot2+3+import os4+import src.storage5+6+fn temp_root(name string) !string {7+	dir := os.join_path(os.temp_dir(), 'koh3_snap_${os.getpid()}_${name}')8+	os.rmdir_all(dir) or {}9+	os.mkdir_all(dir, os.MkdirParams{})!10+	return dir11+}12+13+fn put(root string, rel string, text string) ! {14+	full := os.join_path(root, rel)15+	os.mkdir_all(os.dir(full), os.MkdirParams{})!16+	os.write_file(full, text)!17+}18+19+fn test_ignore_matches_the_documented_shapes() {20+	ig := parse('21+# a comment22+*.log23+build/24+/anchored.txt25+out/**26+docs/*.md27+')28+	assert ig.matches('error.log', false)29+	assert ig.matches('deep/nested/error.log', false)30+	assert !ig.matches('error.txt', false)31+	// A trailing slash names a directory: the directory itself and everything32+	// below it, but not a file that shares the name.33+	assert ig.matches('build', true)34+	assert ig.matches('build/thing.o', false)35+	assert ig.matches('src/build/nested.o', false)36+	assert !ig.matches('build', false)37+	assert ig.matches('anchored.txt', false)38+	assert !ig.matches('sub/anchored.txt', false)39+	assert ig.matches('out/anything/at/all', false)40+	assert ig.matches('docs/readme.md', false)41+	assert !ig.matches('docs/readme.txt', false)42+	assert !ig.matches('src/main.v', false)43+}44+45+fn test_hard_ignores_are_not_overridable() {46+	for path in ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.koh-v2/snapshots/x.json', '.git',47+		'.git/config', 'LOG.md'] {48+		assert hard_ignored(path), '${path} should be hard ignored'49+	}50+	assert !hard_ignored('.kohignore')51+	assert !hard_ignored('src/log.md')52+	// A pattern list cannot un-ignore them, and `.kohignore` itself is content.53+	ig := parse('')54+	assert ig.matches('.koh', false)55+	assert !ig.matches('.kohignore', false)56+}57+58+fn test_walk_skips_ignored_files_directories_and_symlinks() {59+	mut store := storage.create(temp_root('walk')!)!60+	root := store.root61+	defer {62+		store.close()63+		os.rmdir_all(root) or {}64+	}65+	put(root, 'keep.txt', 'keep\n')!66+	put(root, 'nested/deep/file.txt', 'deep\n')!67+	put(root, 'skip.log', 'noise\n')!68+	put(root, 'build/output.o', 'binary\n')!69+	put(root, '.kohignore', '*.log\nbuild/\n')!70+	os.symlink('keep.txt', os.join_path(root, 'link.txt')) or {}71+72+	entries := walk(store, root, load(root))!73+	mut paths := []string{}74+	for entry in entries {75+		paths << entry.path76+	}77+	assert paths == ['.kohignore', 'keep.txt', 'nested/deep/file.txt']78+}79+80+fn test_walk_records_the_executable_bit() {81+	mut store := storage.create(temp_root('exec')!)!82+	root := store.root83+	defer {84+		store.close()85+		os.rmdir_all(root) or {}86+	}87+	put(root, 'run.sh', '#!/bin/sh\necho hi\n')!88+	put(root, 'plain.txt', 'hello\n')!89+	os.chmod(os.join_path(root, 'run.sh'), 0o755)!90+91+	mut executable := false92+	mut plain_executable := true93+	for entry in walk(store, root, Ignore{})! {94+		if entry.path == 'run.sh' {95+			executable = entry.executable96+		}97+		if entry.path == 'plain.txt' {98+			plain_executable = entry.executable99+		}100+	}101+	assert executable102+	assert !plain_executable103+}104+105+// The point of nanosecond timestamps: a file rewritten inside the same second106+// with the same size must not reuse the cached hash, or a save would record107+// content that is no longer on disk.108+fn test_cache_does_not_reuse_a_same_size_same_second_edit() {109+	mut store := storage.create(temp_root('cache')!)!110+	root := store.root111+	defer {112+		store.close()113+		os.rmdir_all(root) or {}114+	}115+	put(root, 'file.txt', 'aaaa\n')!116+	first := walk(store, root, Ignore{})!117+	assert first[0].hash == storage.hash_hex('aaaa\n'.bytes())118+119+	put(root, 'file.txt', 'bbbb\n')!120+	second := walk(store, root, Ignore{})!121+	assert second[0].hash == storage.hash_hex('bbbb\n'.bytes())122+	assert second[0].hash != first[0].hash123+}124+125+fn test_restore_writes_removes_and_leaves_ignored_files_alone() {126+	mut store := storage.create(temp_root('restore')!)!127+	root := store.root128+	defer {129+		store.close()130+		os.rmdir_all(root) or {}131+	}132+	put(root, 'keep.txt', 'recorded\n')!133+	put(root, 'gone/inside.txt', 'also recorded\n')!134+	put(root, 'noise.log', 'ignored, must survive\n')!135+	put(root, '.kohignore', '*.log\n')!136+	target := walk(store, root, load(root))!137+	store_objects(store, root, target)!138+139+	// Drift the working tree: clobber one file, delete another, and add a file140+	// the recorded tree has never seen.141+	os.write_file(os.join_path(root, 'keep.txt'), 'clobbered\n')!142+	os.rm(full_path(root, 'gone/inside.txt'))!143+	put(root, 'extra.txt', 'never saved\n')!144+145+	// The removal list names an ignored file (which survives), a file the146+	// target still has (rewritten, not removed), and one genuinely extra file.147+	report := restore_files(store, root, load(root), target,148+		['extra.txt', '.kohignore', 'noise.log'])!149+150+	assert report.written == target.len151+	assert report.removed == 1152+	assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n'153+	assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n'154+	assert !os.exists(full_path(root, 'extra.txt'))155+	assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n'156+	assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n'157+}158+159+fn test_restore_keeps_the_executable_bit() {160+	mut store := storage.create(temp_root('restore_exec')!)!161+	root := store.root162+	defer {163+		store.close()164+		os.rmdir_all(root) or {}165+	}166+	put(root, 'run.sh', '#!/bin/sh\necho hi\n')!167+	os.chmod(os.join_path(root, 'run.sh'), 0o755)!168+	target := walk(store, root, Ignore{})!169+	store_objects(store, root, target)!170+	os.chmod(os.join_path(root, 'run.sh'), 0o644)!171+172+	restore_files(store, root, Ignore{}, target, [])!173+	mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode174+	assert is_executable(mode)175+}176+177+fn test_diff_reports_kinds_counts_and_binary_files() {178+	mut store := storage.create(temp_root('diff')!)!179+	root := store.root180+	defer {181+		store.close()182+		os.rmdir_all(root) or {}183+	}184+	put(root, 'text.txt', 'one\ntwo\n')!185+	put(root, 'same.txt', 'unchanged\n')!186+	put(root, 'gone.txt', 'bye\n')!187+	before := walk(store, root, Ignore{})!188+	store_objects(store, root, before)!189+190+	os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!191+	os.rm(full_path(root, 'gone.txt'))!192+	os.write_file(os.join_path(root, 'added.txt'), 'new\n')!193+	os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])!194+	after := walk(store, root, Ignore{})!195+196+	// A save's side of a diff is bytes from the object store; only the side197+	// that is the working tree reads from disk.198+	deltas := diff_trees(store, Tree{199+		files: before200+	}, Tree{201+		root:  root202+		files: after203+	})!204+	mut by_path := map[string]Delta{}205+	for delta in deltas {206+		by_path[delta.path] = delta207+	}208+	assert deltas.len == 4209+	assert by_path['text.txt'].kind == 'modified'210+	assert by_path['text.txt'].added == 2211+	assert by_path['text.txt'].removed == 1212+	assert by_path['added.txt'].kind == 'added'213+	assert by_path['gone.txt'].kind == 'removed'214+	assert by_path['blob.bin'].binary215+	assert by_path['blob.bin'].patch == binary_notice216+	assert 'same.txt' !in by_path217+}218+219+fn full_path(root string, rel string) string {220+	return os.join_path(root, rel)221+}

view file

src/snapshot/tree.v+4 −2
@@ -28,10 +28,12 @@2828 	dir := if rel == '' { root } else { os.join_path(root, rel) }2929 	for name in os.ls(dir)! {3030 		child := if rel == '' { name } else { '${rel}/${name}' }31-		if ig.matches(child) {31+		st := lstat(os.join_path(root, child)) or { continue }32+		// The kind is known by now, so a pattern ending in `/` can be applied33+		// to directories only.34+		if ig.matches(child, st.kind == .dir) {3235 			continue3336 		}34-		st := lstat(os.join_path(root, child)) or { continue }3537 		match st.kind {3638 			.dir {3739 				walk_dir(store, root, child, ig, cache, mut out)!

view file