sync_test.v

17.5 KB · raw

1//! Two-machine tests: two repos and one folder remote standing in for the Mac2//! and the PC. This is where offer, steal and combine are actually proven —3//! the folder backend exists so these run offline.45module sync67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage1314// Machines keep their repo in a folder of the same NAME, because the remote key15// is the folder's name; that is what makes them the same repo to the remote.16fn machine(name string) !string {17	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')18	os.rmdir_all(os.dir(dir)) or {}19	os.mkdir_all(dir, os.MkdirParams{})!20	return dir21}2223fn scratch(name string) !string {24	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')25	os.rmdir_all(dir) or {}26	os.mkdir_all(dir, os.MkdirParams{})!27	return dir28}2930fn open_repo(dir string) !storage.Store {31	mut store := storage.create(dir)!32	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!33	return store34}3536fn put(root string, rel string, text string) ! {37	full := os.join_path(root, rel)38	os.mkdir_all(os.dir(full), os.MkdirParams{})!39	os.write_file(full, text)!40}4142fn commit(store &storage.Store, message string) !history.Save {43	return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{44		message: message45	})!46}4748fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {49	return offer(store, mut backend)!50}5152fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {53	return steal(store, mut backend, StealOptions{54		theirs: true55	})!56}5758fn test_first_offer_publishes_the_whole_line() {59	base := scratch('first')!60	defer {61		os.rmdir_all(base) or {}62	}63	mut a := open_repo(machine('first_a')!)!64	defer {65		a.close()66	}67	put(a.root, 'a.txt', 'one\n')!68	commit(a, 'first')!69	put(a.root, 'a.txt', 'two\n')!70	commit(a, 'second')!71	mut backend := remote.new_folder(os.join_path(base, 'remote'))!7273	report := offer_now(a, mut backend)!74	assert report.repo == 'proj'75	assert report.saves == 276	assert report.objects >= 277	assert backend.get(head_key('proj'))!.found78	assert backend.list('proj/saves')!.len == 27980	// A second offer with nothing new uploads nothing and still succeeds.81	again := offer_now(a, mut backend)!82	assert again.saves == 083}8485fn test_the_repo_name_and_offered_head_are_remembered() {86	base := scratch('remember')!87	defer {88		os.rmdir_all(base) or {}89	}90	mut a := open_repo(machine('remember_a')!)!91	defer {92		a.close()93	}94	put(a.root, 'a.txt', 'one\n')!95	commit(a, 'first')!96	mut backend := remote.new_folder(os.join_path(base, 'remote'))!97	offer_now(a, mut backend)!98	assert a.state('remote_repo') or { '' } == 'proj'99	assert a.state('remote_head') or { '' } == history.head(a)!.id100101	// A second offer, with the remote already holding our head, must not102	// refuse: the name is remembered, so the remote is ours.103	put(a.root, 'a.txt', 'two\n')!104	commit(a, 'second')!105	offer_now(a, mut backend)!106	assert a.state('remote_head') or { '' } == history.head(a)!.id107}108109fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {110	base := scratch('forward')!111	defer {112		os.rmdir_all(base) or {}113	}114	mut backend := remote.new_folder(os.join_path(base, 'remote'))!115	mut a := open_repo(machine('fwd_a')!)!116	defer {117		a.close()118	}119	put(a.root, 'a.txt', 'from A\n')!120	commit(a, 'A writes')!121	offer_now(a, mut backend)!122123	// B starts empty and pulls the whole repo.124	mut b := open_repo(machine('fwd_b')!)!125	defer {126		b.close()127	}128	fetched := fetch_repo(mut backend, 'proj')!129	b.begin()!130	write_fetched(b, fetched)!131	history.set_head(b, fetched.head)!132	b.commit()!133	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!134	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'135	assert history.head(b)!.id == fetched.head136}137138// A machine that is only ahead has nothing to combine: stealing must leave its139// saves, and their IDs, exactly where they are.140fn test_steal_while_only_ahead_leaves_the_line_alone() {141	base := scratch('ahead')!142	defer {143		os.rmdir_all(base) or {}144	}145	mut backend := remote.new_folder(os.join_path(base, 'remote'))!146	mut a := open_repo(machine('ahead_a')!)!147	defer {148		a.close()149	}150	put(a.root, 'a.txt', 'one\n')!151	commit(a, 'offered')!152	offer_now(a, mut backend)!153	put(a.root, 'a.txt', 'two\n')!154	ahead := commit(a, 'not offered yet')!155	report := steal_now(a, mut backend)!156	assert report.replayed == 0157	assert history.head(a)!.id == ahead.id158	assert history.all_saves(a)!.len == 2159	offer_now(a, mut backend)!160	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id161}162163// A chmod is unsaved work too: a steal would restore the old mode over it.164fn test_steal_refuses_an_unsaved_executable_bit_change() {165	base := scratch('chmod')!166	defer {167		os.rmdir_all(base) or {}168	}169	mut backend := remote.new_folder(os.join_path(base, 'remote'))!170	mut a := open_repo(machine('chmod_a')!)!171	defer {172		a.close()173	}174	put(a.root, 'run.sh', 'echo hi\n')!175	commit(a, 'script')!176	offer_now(a, mut backend)!177	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!178	if _ := steal_now(a, mut backend) {179		assert false, 'a chmod-only change should make the tree dirty'180	} else {181		assert err.msg() == dirty_tree182	}183}184185fn test_divergence_without_clashes_keeps_both_machines_work() {186	base := scratch('diverge')!187	defer {188		os.rmdir_all(base) or {}189	}190	mut backend := remote.new_folder(os.join_path(base, 'remote'))!191	mut a := open_repo(machine('div_a')!)!192	defer {193		a.close()194	}195	mut b := open_repo(machine('div_b')!)!196	defer {197		b.close()198	}199	put(a.root, 'shared.txt', 'base\n')!200	commit(a, 'base')!201	offer_now(a, mut backend)!202203	// B joins, then both machines change a different file.204	join(b, mut backend)!205	put(a.root, 'fromA.txt', 'A\n')!206	commit(a, 'A adds a file')!207	put(b.root, 'fromB.txt', 'B\n')!208	commit(b, 'B adds a file')!209	offer_now(a, mut backend)!210211	// B's offer is refused: the remote has moved on.212	if _ := offer_now(b, mut backend) {213		assert false, 'offering on top of unknown remote saves should be refused'214	} else {215		assert err.msg().contains('run koh steal')216	}217218	report := steal_now(b, mut backend)!219	assert !report.forward220	assert report.replayed == 1221	assert report.clashes.len == 0222	// Both files are on B's line now, and on disk.223	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'224	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'225	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'226	// B's own save was replayed, not erased.227	mut saw_replay := false228	for save in history.all_saves(b)! {229		if save.replayed_from != '' {230			saw_replay = true231		}232	}233	assert saw_replay234	// And the remote now has B's work too.235	offer_now(b, mut backend)!236	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()237	assert line_head == history.head(b)!.id238}239240fn test_clashing_files_are_chosen_one_at_a_time() {241	base := scratch('clash')!242	defer {243		os.rmdir_all(base) or {}244	}245	mut backend := remote.new_folder(os.join_path(base, 'remote'))!246	mut a := open_repo(machine('clash_a')!)!247	defer {248		a.close()249	}250	mut b := open_repo(machine('clash_b')!)!251	defer {252		b.close()253	}254	put(a.root, 'same.txt', 'base\n')!255	put(a.root, 'other.txt', 'quiet\n')!256	commit(a, 'base')!257	offer_now(a, mut backend)!258	join(b, mut backend)!259260	// Both machines edit the same file, differently.261	put(a.root, 'same.txt', 'A version\n')!262	commit(a, 'A edits same')!263	offer_now(a, mut backend)!264	put(b.root, 'same.txt', 'B version\n')!265	commit(b, 'B edits same')!266267	// Without a decision, steal refuses and says which files clash.268	if _ := steal(b, mut backend, StealOptions{}) {269		assert false, 'an undecided clash must be refused'270	} else {271		assert err.msg().contains('--mine') || err.msg().contains('--theirs')272	}273	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,274		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0275276	// Choosing the remote's version ends with A's content.277	report := steal_now(b, mut backend)!278	assert report.clashes == ['same.txt']279	assert report.kept_mine == 0280	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'281	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'282}283284fn test_choosing_mine_keeps_the_local_version() {285	base := scratch('mine')!286	defer {287		os.rmdir_all(base) or {}288	}289	mut backend := remote.new_folder(os.join_path(base, 'remote'))!290	mut a := open_repo(machine('mine_a')!)!291	defer {292		a.close()293	}294	mut b := open_repo(machine('mine_b')!)!295	defer {296		b.close()297	}298	put(a.root, 'same.txt', 'base\n')!299	commit(a, 'base')!300	offer_now(a, mut backend)!301	join(b, mut backend)!302	put(a.root, 'same.txt', 'A version\n')!303	commit(a, 'A edits')!304	offer_now(a, mut backend)!305	put(b.root, 'same.txt', 'B version\n')!306	commit(b, 'B edits')!307308	report := steal(b, mut backend, StealOptions{309		mine: true310	})!311	assert report.kept_mine == 1312	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'313	// The final save says what it kept.314	assert history.head(b)!.message.contains('combine: kept mine for 1 file')315	// The pre-combine tree is still reachable in history.316	assert history.head(b)!.restores == ''317	assert history.all_saves(b)!.len >= 4318}319320fn test_flags_travel_with_the_saves() {321	base := scratch('flags')!322	defer {323		os.rmdir_all(base) or {}324	}325	mut backend := remote.new_folder(os.join_path(base, 'remote'))!326	mut a := open_repo(machine('flag_a')!)!327	defer {328		a.close()329	}330	mut b := open_repo(machine('flag_b')!)!331	defer {332		b.close()333	}334	put(a.root, 'a.txt', 'one\n')!335	save := commit(a, 'suspect save')!336	events.flag(a, save.id, 'look at this')!337	offer_now(a, mut backend)!338339	// B has nothing yet, so a fetch brings the save and its flag across.340	fetched := fetch_back_to_known(b, mut backend, 'proj')!341	assert fetched.events_out.len == 1342	assert fetched.events_out[0].kind == events.kind_flag343	b.begin()!344	write_fetched(b, fetched)!345	history.set_head(b, fetched.head)!346	b.commit()!347	state := events.flag_state(b, save.id)!348	assert state.flagged349	assert state.reason == 'look at this'350}351352fn test_a_second_offer_racing_head_is_refused_not_forced() {353	base := scratch('race')!354	defer {355		os.rmdir_all(base) or {}356	}357	mut backend := remote.new_folder(os.join_path(base, 'remote'))!358	mut a := open_repo(machine('race_a')!)!359	defer {360		a.close()361	}362	put(a.root, 'a.txt', 'one\n')!363	commit(a, 'base')!364	offer_now(a, mut backend)!365366	// Someone else replaces head behind our back.367	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),368		backend.get(head_key('proj'))!.tag)!369370	put(a.root, 'a.txt', 'two\n')!371	commit(a, 'second')!372	if _ := offer_now(a, mut backend) {373		assert false, 'offering while the remote head moved must be refused'374	} else {375		assert err.msg().contains('run koh steal')376	}377}378379// join makes a brand new machine share the remote's line.380fn join(store &storage.Store, mut backend remote.Backend) ! {381	fetched := fetch_repo(mut backend, 'proj')!382	store.begin()!383	write_fetched(store, fetched)!384	history.set_head(store, fetched.head)!385	store.commit()!386	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,387		fetched.head)!, [])!388	store.set_state('remote_repo', 'proj')!389}390391struct FaultyBackend {392mut:393	inner      remote.Backend394	writes     int395	reads      int396	fail_write int // fail when the write counter reaches this, 0 never397	fail_read  int398}399400fn (mut f FaultyBackend) get(key string) !remote.Object {401	f.reads++402	if f.fail_read > 0 && f.reads >= f.fail_read {403		return error('injected read failure')404	}405	return f.inner.get(key)!406}407408fn (mut f FaultyBackend) list(prefix string) ![]string {409	return f.inner.list(prefix)!410}411412fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {413	f.writes++414	if f.fail_write > 0 && f.writes >= f.fail_write {415		return error('injected write failure')416	}417	return f.inner.put_if_absent(key, body)!418}419420fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {421	f.writes++422	if f.fail_write > 0 && f.writes >= f.fail_write {423		return error('injected write failure')424	}425	return f.inner.put_if_match(key, body, tag)!426}427428fn (mut f FaultyBackend) delete(key string) ! {429	f.inner.delete(key)!430}431432fn test_a_failed_offer_is_safe_to_re_run() {433	base := scratch('fault_offer')!434	defer {435		os.rmdir_all(base) or {}436	}437	remote_dir := os.join_path(base, 'remote')438	mut a := open_repo(machine('fault_a')!)!439	defer {440		a.close()441	}442	put(a.root, 'a.txt', 'one\n')!443	commit(a, 'first')!444	put(a.root, 'a.txt', 'two\n')!445	commit(a, 'second')!446447	// The first offer dies after the first write.448	mut flaky := FaultyBackend{449		inner:      remote.new_folder(remote_dir)!450		fail_write: 2451	}452	if _ := offer(a, mut flaky) {453		assert false, 'the injected failure should have surfaced'454	}455456	// Re-running with a healthy remote finishes the job: "already exists" is457	// success, so nothing is lost and nothing is duplicated.458	mut healthy := remote.new_folder(remote_dir)!459	report := offer(a, mut healthy)!460	assert report.saves == 2461	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id462	assert healthy.list('proj/saves')!.len == 2463}464465fn test_a_failed_steal_leaves_nothing_behind() {466	base := scratch('fault_steal')!467	defer {468		os.rmdir_all(base) or {}469	}470	remote_dir := os.join_path(base, 'remote')471	mut a := open_repo(machine('fault_s_a')!)!472	defer {473		a.close()474	}475	mut b := open_repo(machine('fault_s_b')!)!476	defer {477		b.close()478	}479	put(a.root, 'a.txt', 'from A\n')!480	commit(a, 'A writes')!481	mut healthy := remote.new_folder(remote_dir)!482	offer(a, mut healthy)!483484	// B's fetch fails half way through reading the remote.485	mut flaky := FaultyBackend{486		inner:     remote.new_folder(remote_dir)!487		fail_read: 2488	}489	if _ := fetch_back_to_known(b, mut flaky, 'proj') {490		assert false, 'the injected read failure should have surfaced'491	}492	// Nothing was written: the store is untouched and still has no saves.493	assert history.head(b) == none494	assert b.object_count()! == 0495496	// A healthy retry works.497	fetched := fetch_back_to_known(b, mut healthy, 'proj')!498	b.begin()!499	write_fetched(b, fetched)!500	history.set_head(b, fetched.head)!501	b.commit()!502	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!503	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'504}505506fn test_an_interrupted_strip_is_idempotent() {507	// The local half of a strip is one transaction, so an interrupted strip is508	// simply a strip that did not happen; re-running it must land the same509	// result once, not twice.510	base := scratch('fault_strip')!511	defer {512		os.rmdir_all(base) or {}513	}514	mut a := open_repo(machine('fault_strip_a')!)!515	defer {516		a.close()517	}518	put(a.root, 'secret.txt', 'shh\n')!519	put(a.root, 'keep.txt', 'fine\n')!520	save := commit(a, 'add both')!521522	// What `koh strip` does locally, twice over.523	for _ in 0 .. 2 {524		a.begin()!525		mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!526		stmt.bind_text(1, 'secret.txt')!527		stmt.step()!528		stmt.finalize()529		a.drop_unreferenced_objects()!530		events.strip(a, '', 'secret.txt')!531		a.commit()!532	}533	files := history.files_of(a, save.id)!534	// The repo's own `.kohignore` is tracked content, so it stays; only the535	// stripped path goes.536	assert files.map(it.path) == ['.kohignore', 'keep.txt']537	assert events.strip_events(a)!.len == 1538}539540fn test_a_strip_reaches_the_remote_and_other_machines() {541	base := scratch('strip_sync')!542	defer {543		os.rmdir_all(base) or {}544	}545	mut a := open_repo(machine('strip_a')!)!546	defer {547		a.close()548	}549	mut b := open_repo(machine('strip_b')!)!550	defer {551		b.close()552	}553	put(a.root, 'secrets.env', 'api_key=1\n')!554	put(a.root, 'keep.txt', 'fine\n')!555	save := commit(a, 'add both')!556	mut backend := remote.new_folder(os.join_path(base, 'remote'))!557	offer_now(a, mut backend)!558	join(b, mut backend)!559	assert b.object_count()! > 0560561	// A strips the path locally, the way `koh strip` does, then tells the562	// remote.563	mut secret_hash := ''564	for file in history.files_of(a, save.id)! {565		if file.path == 'secrets.env' {566			secret_hash = file.hash567		}568	}569	assert secret_hash != ''570	a.begin()!571	mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!572	stmt.bind_text(1, 'secrets.env')!573	stmt.step()!574	stmt.finalize()575	a.drop_unreferenced_objects()!576	events.strip(a, '', 'secrets.env')!577	a.commit()!578	report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!579	assert report.event_sent580	assert report.records == 1581582	// The remote's copy of the save no longer lists the file, and the object is583	// gone from the remote.584	record := decode_save(backend.get(save_key('proj', save.id))!.body)!585	for file in record.files {586		assert file.path != 'secrets.env'587	}588	// The stripped object is gone from the remote; the ones still referenced589	// are not.590	assert !backend.get(object_key('proj', secret_hash))!.found591	assert backend.list('proj/objects')!.len == 2592593	// Another machine hears about it on its next steal and applies it.594	b.begin()!595	fetched := fetch_back_to_known(b, mut backend, 'proj')!596	write_fetched(b, fetched)!597	b.commit()!598	mut paths := []string{}599	for file in history.files_of(b, save.id)! {600		paths << file.path601	}602	assert 'secrets.env' !in paths603	assert 'keep.txt' in paths604605	// The event arrives under its own ID, so it is applied once: B's offer adds606	// no second copy, and a file B saves again after the strip stays saved607	// through B's next steal.608	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)609	put(b.root, 'secrets.env', 'rotated\n')!610	readded := commit(b, 'add the rotated key back')!611	offer_now(b, mut backend)!612	assert backend.list('proj/events')!.len == 1613	b.begin()!614	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!615	b.commit()!616	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)617}