snapshot_test.v

7.5 KB · raw

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	// A trailing slash names a directory: the directory itself and everything33	// below it, but not a file that shares the name.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	// A pattern list cannot un-ignore them, and `.kohignore` itself is content.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}105106// The point of nanosecond timestamps: a file rewritten inside the same second107// with the same size must not reuse the cached hash, or a save would record108// content that is no longer on disk.109fn 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}125126// Linux stamps files from a clock that ticks every few milliseconds, so a127// same-size rewrite in the tick the file was hashed in leaves size, mtime and128// ctime unchanged. A hash taken that close to the last write is never cached;129// the next walk hashes again.130fn 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	// Drift the working tree: clobber one file, delete another, and add a file160	// the recorded tree has never seen.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	// The removal list names an ignored file (which survives), a file the166	// target still has (rewritten, not removed), and one genuinely extra file.167	report := restore_files(store, root, load(root), target,168		['extra.txt', '.kohignore', 'noise.log'])!169170	// The clobbered and the deleted file are written; the unchanged171	// .kohignore is left as it is.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	// A save's side of a diff is bytes from the object store; only the side219	// that is the working tree reads from disk.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}