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
↓ koh-525088cd.tar.gzatlas at this save
files changed5 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 −2src/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.idsrc/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 falsesrc/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)!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+}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)!