1module snapshot23import os4import time5import src.storage67fn temp_root(name string) !string {8 dir := os.join_path(os.temp_dir(), 'koh3_snap_${os.getpid()}_${name}')9 os.rmdir_all(dir) or {}10 os.mkdir_all(dir, os.MkdirParams{})!11 return dir12}1314fn put(root string, rel string, text string) ! {15 full := os.join_path(root, rel)16 os.mkdir_all(os.dir(full), os.MkdirParams{})!17 os.write_file(full, text)!18}1920fn test_ignore_matches_the_documented_shapes() {21 ig := parse('22# a comment23*.log24build/25/anchored.txt26out/**27docs/*.md28')29 assert ig.matches('error.log', false)30 assert ig.matches('deep/nested/error.log', false)31 assert !ig.matches('error.txt', false)32 33 34 assert ig.matches('build', true)35 assert ig.matches('build/thing.o', false)36 assert ig.matches('src/build/nested.o', false)37 assert !ig.matches('build', false)38 assert ig.matches('anchored.txt', false)39 assert !ig.matches('sub/anchored.txt', false)40 assert ig.matches('out/anything/at/all', false)41 assert ig.matches('docs/readme.md', false)42 assert !ig.matches('docs/readme.txt', false)43 assert !ig.matches('src/main.v', false)44}4546fn test_hard_ignores_are_not_overridable() {47 for path in ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.koh-v2/snapshots/x.json', '.git',48 '.git/config', 'LOG.md'] {49 assert hard_ignored(path), '${path} should be hard ignored'50 }51 assert !hard_ignored('.kohignore')52 assert !hard_ignored('src/log.md')53 54 ig := parse('')55 assert ig.matches('.koh', false)56 assert !ig.matches('.kohignore', false)57}5859fn test_walk_skips_ignored_files_directories_and_symlinks() {60 mut store := storage.create(temp_root('walk')!)!61 root := store.root62 defer {63 store.close()64 os.rmdir_all(root) or {}65 }66 put(root, 'keep.txt', 'keep\n')!67 put(root, 'nested/deep/file.txt', 'deep\n')!68 put(root, 'skip.log', 'noise\n')!69 put(root, 'build/output.o', 'binary\n')!70 put(root, '.kohignore', '*.log\nbuild/\n')!71 os.symlink('keep.txt', os.join_path(root, 'link.txt')) or {}7273 entries := walk(store, root, load(root))!74 mut paths := []string{}75 for entry in entries {76 paths << entry.path77 }78 assert paths == ['.kohignore', 'keep.txt', 'nested/deep/file.txt']79}8081fn test_walk_records_the_executable_bit() {82 mut store := storage.create(temp_root('exec')!)!83 root := store.root84 defer {85 store.close()86 os.rmdir_all(root) or {}87 }88 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!89 put(root, 'plain.txt', 'hello\n')!90 os.chmod(os.join_path(root, 'run.sh'), 0o755)!9192 mut executable := false93 mut plain_executable := true94 for entry in walk(store, root, Ignore{})! {95 if entry.path == 'run.sh' {96 executable = entry.executable97 }98 if entry.path == 'plain.txt' {99 plain_executable = entry.executable100 }101 }102 assert executable103 assert !plain_executable104}105106107108109fn test_cache_does_not_reuse_a_same_size_same_second_edit() {110 mut store := storage.create(temp_root('cache')!)!111 root := store.root112 defer {113 store.close()114 os.rmdir_all(root) or {}115 }116 put(root, 'file.txt', 'aaaa\n')!117 first := walk(store, root, Ignore{})!118 assert first[0].hash == storage.hash_hex('aaaa\n'.bytes())119120 put(root, 'file.txt', 'bbbb\n')!121 second := walk(store, root, Ignore{})!122 assert second[0].hash == storage.hash_hex('bbbb\n'.bytes())123 assert second[0].hash != first[0].hash124}125126127128129130fn test_cache_skips_a_file_written_moments_ago() {131 mut store := storage.create(temp_root('racy')!)!132 root := store.root133 defer {134 store.close()135 os.rmdir_all(root) or {}136 }137 put(root, 'fresh.txt', 'new\n')!138 put(root, 'old.txt', 'old\n')!139 hour_ago := time.now().unix() - 3600140 os.utime(os.join_path(root, 'old.txt'), hour_ago, hour_ago)!141 walk(store, root, Ignore{})!142 assert store.load_cache()!.keys() == ['old.txt']143}144145fn test_restore_writes_removes_and_leaves_ignored_files_alone() {146 mut store := storage.create(temp_root('restore')!)!147 root := store.root148 defer {149 store.close()150 os.rmdir_all(root) or {}151 }152 put(root, 'keep.txt', 'recorded\n')!153 put(root, 'gone/inside.txt', 'also recorded\n')!154 put(root, 'noise.log', 'ignored, must survive\n')!155 put(root, '.kohignore', '*.log\n')!156 target := walk(store, root, load(root))!157 store_objects(store, root, target)!158159 160 161 os.write_file(os.join_path(root, 'keep.txt'), 'clobbered\n')!162 os.rm(full_path(root, 'gone/inside.txt'))!163 put(root, 'extra.txt', 'never saved\n')!164165 166 167 report := restore_files(store, root, load(root), target,168 ['extra.txt', '.kohignore', 'noise.log'])!169170 171 172 assert report.written == 2173 assert report.removed == 1174 assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n'175 assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n'176 assert !os.exists(full_path(root, 'extra.txt'))177 assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n'178 assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n'179}180181fn test_restore_keeps_the_executable_bit() {182 mut store := storage.create(temp_root('restore_exec')!)!183 root := store.root184 defer {185 store.close()186 os.rmdir_all(root) or {}187 }188 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!189 os.chmod(os.join_path(root, 'run.sh'), 0o755)!190 target := walk(store, root, Ignore{})!191 store_objects(store, root, target)!192 os.chmod(os.join_path(root, 'run.sh'), 0o644)!193194 restore_files(store, root, Ignore{}, target, [])!195 mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode196 assert is_executable(mode)197}198199fn test_diff_reports_kinds_counts_and_binary_files() {200 mut store := storage.create(temp_root('diff')!)!201 root := store.root202 defer {203 store.close()204 os.rmdir_all(root) or {}205 }206 put(root, 'text.txt', 'one\ntwo\n')!207 put(root, 'same.txt', 'unchanged\n')!208 put(root, 'gone.txt', 'bye\n')!209 before := walk(store, root, Ignore{})!210 store_objects(store, root, before)!211212 os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!213 os.rm(full_path(root, 'gone.txt'))!214 os.write_file(os.join_path(root, 'added.txt'), 'new\n')!215 os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])!216 after := walk(store, root, Ignore{})!217218 219 220 deltas := diff_trees(store, Tree{221 files: before222 }, Tree{223 root: root224 files: after225 })!226 mut by_path := map[string]Delta{}227 for delta in deltas {228 by_path[delta.path] = delta229 }230 assert deltas.len == 4231 assert by_path['text.txt'].kind == 'modified'232 assert by_path['text.txt'].added == 2233 assert by_path['text.txt'].removed == 1234 assert by_path['added.txt'].kind == 'added'235 assert by_path['gone.txt'].kind == 'removed'236 assert by_path['blob.bin'].binary237 assert by_path['blob.bin'].patch == binary_notice238 assert 'same.txt' !in by_path239}240241fn full_path(root string, rel string) string {242 return os.join_path(root, rel)243}