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 151 152 assert report.written == 2153 assert report.removed == 1154 assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n'155 assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n'156 assert !os.exists(full_path(root, 'extra.txt'))157 assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n'158 assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n'159}160161fn test_restore_keeps_the_executable_bit() {162 mut store := storage.create(temp_root('restore_exec')!)!163 root := store.root164 defer {165 store.close()166 os.rmdir_all(root) or {}167 }168 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!169 os.chmod(os.join_path(root, 'run.sh'), 0o755)!170 target := walk(store, root, Ignore{})!171 store_objects(store, root, target)!172 os.chmod(os.join_path(root, 'run.sh'), 0o644)!173174 restore_files(store, root, Ignore{}, target, [])!175 mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode176 assert is_executable(mode)177}178179fn test_diff_reports_kinds_counts_and_binary_files() {180 mut store := storage.create(temp_root('diff')!)!181 root := store.root182 defer {183 store.close()184 os.rmdir_all(root) or {}185 }186 put(root, 'text.txt', 'one\ntwo\n')!187 put(root, 'same.txt', 'unchanged\n')!188 put(root, 'gone.txt', 'bye\n')!189 before := walk(store, root, Ignore{})!190 store_objects(store, root, before)!191192 os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!193 os.rm(full_path(root, 'gone.txt'))!194 os.write_file(os.join_path(root, 'added.txt'), 'new\n')!195 os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])!196 after := walk(store, root, Ignore{})!197198 199 200 deltas := diff_trees(store, Tree{201 files: before202 }, Tree{203 root: root204 files: after205 })!206 mut by_path := map[string]Delta{}207 for delta in deltas {208 by_path[delta.path] = delta209 }210 assert deltas.len == 4211 assert by_path['text.txt'].kind == 'modified'212 assert by_path['text.txt'].added == 2213 assert by_path['text.txt'].removed == 1214 assert by_path['added.txt'].kind == 'added'215 assert by_path['gone.txt'].kind == 'removed'216 assert by_path['blob.bin'].binary217 assert by_path['blob.bin'].patch == binary_notice218 assert 'same.txt' !in by_path219}220221fn full_path(root string, rel string) string {222 return os.join_path(root, rel)223}