snapshot_test.v

9.2 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 repo inside the repo keeps its own history: restoring an old copy of54	// its .koh or .git would roll that history back.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	// A pattern list cannot un-ignore them, and `.kohignore` itself is content.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}114115// The point of nanosecond timestamps: a file rewritten inside the same second116// with the same size must not reuse the cached hash, or a save would record117// content that is no longer on disk.118fn 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}134135// Linux stamps files from a clock that ticks every few milliseconds, so a136// same-size rewrite in the tick the file was hashed in leaves size, mtime and137// ctime unchanged. A hash taken that close to the last change is never cached;138// the next walk hashes again. Tools such as `cp -p` keep an old mtime while139// rewriting, so the newer of the two times decides.140fn 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}161162// `status` must work while another agent's save holds the write lock: the163// cache only saves time, so a busy repo hashes without caching, at once.164fn 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	// Only a file whose last change is older than the racy window is cached,173	// and ctime cannot be set back, so the test waits it out.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	// Drift the working tree: clobber one file, delete another, and add a file202	// the recorded tree has never seen.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	// The removal list names an ignored file (which survives), a file the208	// target still has (rewritten, not removed), and one genuinely extra file.209	report := restore_files(store, root, load(root), target,210		['extra.txt', '.kohignore', 'noise.log'])!211212	// The clobbered and the deleted file are written; the unchanged213	// .kohignore is left as it is.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	// A save's side of a diff is bytes from the object store; only the side261	// that is the working tree reads from disk.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}