1module snapshot23import os4import src.storage56fn 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}1213fn 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}1819fn test_ignore_matches_the_documented_shapes() {20 ig := parse('21# a comment22*.log23build/24/anchored.txt25out/**26docs/*.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 32 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}4445fn 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 53 ig := parse('')54 assert ig.matches('.koh', false)55 assert !ig.matches('.kohignore', false)56}5758fn 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 {}7172 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}7980fn 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)!9091 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}104105106107108fn 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())118119 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}124125fn 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)!138139 140 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')!144145 146 147 report := restore_files(store, root, load(root), target,148 ['extra.txt', '.kohignore', 'noise.log'])!149150 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}158159fn 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)!171172 restore_files(store, root, Ignore{}, target, [])!173 mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode174 assert is_executable(mode)175}176177fn 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)!189190 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{})!195196 197 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}218219fn full_path(root string, rel string) string {220 return os.join_path(root, rel)221}