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 55 for path in ['inner/.koh', 'inner/.koh-wal', 'inner/.koh-shm', 'a/b/.git', 'vendored/.git/HEAD',56 'old/.koh-v2/snapshots/x.json', 'koh2repo/.koh/objects/ab/cd'] {57 assert hard_ignored(path), '${path} should be hard ignored'58 }59 assert !hard_ignored('inner/.kohignore')60 assert !hard_ignored('docs/.github/workflow.yml')61 assert !hard_ignored('notes/.koh.md')62 63 ig := parse('')64 assert ig.matches('.koh', false)65 assert !ig.matches('.kohignore', false)66}6768fn test_walk_skips_ignored_files_directories_and_symlinks() {69 mut store := storage.create(temp_root('walk')!)!70 root := store.root71 defer {72 store.close()73 os.rmdir_all(root) or {}74 }75 put(root, 'keep.txt', 'keep\n')!76 put(root, 'nested/deep/file.txt', 'deep\n')!77 put(root, 'skip.log', 'noise\n')!78 put(root, 'build/output.o', 'binary\n')!79 put(root, '.kohignore', '*.log\nbuild/\n')!80 os.symlink('keep.txt', os.join_path(root, 'link.txt')) or {}8182 entries := walk(store, root, load(root))!83 mut paths := []string{}84 for entry in entries {85 paths << entry.path86 }87 assert paths == ['.kohignore', 'keep.txt', 'nested/deep/file.txt']88}8990fn test_walk_records_the_executable_bit() {91 mut store := storage.create(temp_root('exec')!)!92 root := store.root93 defer {94 store.close()95 os.rmdir_all(root) or {}96 }97 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!98 put(root, 'plain.txt', 'hello\n')!99 os.chmod(os.join_path(root, 'run.sh'), 0o755)!100101 mut executable := false102 mut plain_executable := true103 for entry in walk(store, root, Ignore{})! {104 if entry.path == 'run.sh' {105 executable = entry.executable106 }107 if entry.path == 'plain.txt' {108 plain_executable = entry.executable109 }110 }111 assert executable112 assert !plain_executable113}114115116117118fn test_cache_does_not_reuse_a_same_size_same_second_edit() {119 mut store := storage.create(temp_root('cache')!)!120 root := store.root121 defer {122 store.close()123 os.rmdir_all(root) or {}124 }125 put(root, 'file.txt', 'aaaa\n')!126 first := walk(store, root, Ignore{})!127 assert first[0].hash == storage.hash_hex('aaaa\n'.bytes())128129 put(root, 'file.txt', 'bbbb\n')!130 second := walk(store, root, Ignore{})!131 assert second[0].hash == storage.hash_hex('bbbb\n'.bytes())132 assert second[0].hash != first[0].hash133}134135136137138139140fn test_cache_skips_a_file_changed_moments_ago() {141 second := i64(1_000_000_000)142 now := 100 * second143 old := now - 10 * second144 assert cacheable(Stat{ mtime_ns: old, ctime_ns: old }, now)145 assert !cacheable(Stat{ mtime_ns: now - second, ctime_ns: now - second }, now)146 assert !cacheable(Stat{ mtime_ns: old, ctime_ns: now - second }, now)147148 mut store := storage.create(temp_root('racy')!)!149 root := store.root150 defer {151 store.close()152 os.rmdir_all(root) or {}153 }154 put(root, 'fresh.txt', 'new\n')!155 put(root, 'copied.txt', 'kept mtime\n')!156 hour_ago := time.now().unix() - 3600157 os.utime(os.join_path(root, 'copied.txt'), hour_ago, hour_ago)!158 walk(store, root, Ignore{})!159 assert store.load_cache()!.len == 0160}161162163164fn test_a_walk_does_not_wait_for_another_writer() {165 mut store := storage.create(temp_root('busy_walk')!)!166 root := store.root167 defer {168 store.close()169 os.rmdir_all(root) or {}170 }171 put(root, 'a.txt', 'a\n')!172 173 174 time.sleep(racy_window_ns * time.nanosecond + 100 * time.millisecond)175 writer := storage.open(root)!176 defer {177 writer.close()178 }179 writer.begin()!180 started := time.now()181 files := walk(store, root, Ignore{})!182 assert files.len == 1183 assert time.since(started) < time.second184 writer.rollback()185}186187fn test_restore_writes_removes_and_leaves_ignored_files_alone() {188 mut store := storage.create(temp_root('restore')!)!189 root := store.root190 defer {191 store.close()192 os.rmdir_all(root) or {}193 }194 put(root, 'keep.txt', 'recorded\n')!195 put(root, 'gone/inside.txt', 'also recorded\n')!196 put(root, 'noise.log', 'ignored, must survive\n')!197 put(root, '.kohignore', '*.log\n')!198 target := walk(store, root, load(root))!199 store_objects(store, root, target)!200201 202 203 os.write_file(os.join_path(root, 'keep.txt'), 'clobbered\n')!204 os.rm(full_path(root, 'gone/inside.txt'))!205 put(root, 'extra.txt', 'never saved\n')!206207 208 209 report := restore_files(store, root, load(root), target,210 ['extra.txt', '.kohignore', 'noise.log'])!211212 213 214 assert report.written == 2215 assert report.removed == 1216 assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n'217 assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n'218 assert !os.exists(full_path(root, 'extra.txt'))219 assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n'220 assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n'221}222223fn test_restore_keeps_the_executable_bit() {224 mut store := storage.create(temp_root('restore_exec')!)!225 root := store.root226 defer {227 store.close()228 os.rmdir_all(root) or {}229 }230 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!231 os.chmod(os.join_path(root, 'run.sh'), 0o755)!232 target := walk(store, root, Ignore{})!233 store_objects(store, root, target)!234 os.chmod(os.join_path(root, 'run.sh'), 0o644)!235236 restore_files(store, root, Ignore{}, target, [])!237 mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode238 assert is_executable(mode)239}240241fn test_diff_reports_kinds_counts_and_binary_files() {242 mut store := storage.create(temp_root('diff')!)!243 root := store.root244 defer {245 store.close()246 os.rmdir_all(root) or {}247 }248 put(root, 'text.txt', 'one\ntwo\n')!249 put(root, 'same.txt', 'unchanged\n')!250 put(root, 'gone.txt', 'bye\n')!251 before := walk(store, root, Ignore{})!252 store_objects(store, root, before)!253254 os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!255 os.rm(full_path(root, 'gone.txt'))!256 os.write_file(os.join_path(root, 'added.txt'), 'new\n')!257 os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])!258 after := walk(store, root, Ignore{})!259260 261 262 deltas := diff_trees(store, Tree{263 files: before264 }, Tree{265 root: root266 files: after267 })!268 mut by_path := map[string]Delta{}269 for delta in deltas {270 by_path[delta.path] = delta271 }272 assert deltas.len == 4273 assert by_path['text.txt'].kind == 'modified'274 assert by_path['text.txt'].added == 2275 assert by_path['text.txt'].removed == 1276 assert by_path['added.txt'].kind == 'added'277 assert by_path['gone.txt'].kind == 'removed'278 assert by_path['blob.bin'].binary279 assert by_path['blob.bin'].patch == binary_notice280 assert 'same.txt' !in by_path281}282283fn full_path(root string, rel string) string {284 return os.join_path(root, rel)285}