sync_test.v

16.2 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 chmod is unsaved work too: a steal would restore the old mode over it.139fn test_steal_refuses_an_unsaved_executable_bit_change() {140	base := scratch('chmod')!141	defer {142		os.rmdir_all(base) or {}143	}144	mut backend := remote.new_folder(os.join_path(base, 'remote'))!145	mut a := open_repo(machine('chmod_a')!)!146	defer {147		a.close()148	}149	put(a.root, 'run.sh', 'echo hi\n')!150	commit(a, 'script')!151	offer_now(a, mut backend)!152	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!153	if _ := steal_now(a, mut backend) {154		assert false, 'a chmod-only change should make the tree dirty'155	} else {156		assert err.msg() == dirty_tree157	}158}159160fn test_divergence_without_clashes_keeps_both_machines_work() {161	base := scratch('diverge')!162	defer {163		os.rmdir_all(base) or {}164	}165	mut backend := remote.new_folder(os.join_path(base, 'remote'))!166	mut a := open_repo(machine('div_a')!)!167	defer {168		a.close()169	}170	mut b := open_repo(machine('div_b')!)!171	defer {172		b.close()173	}174	put(a.root, 'shared.txt', 'base\n')!175	commit(a, 'base')!176	offer_now(a, mut backend)!177178	// B joins, then both machines change a different file.179	join(b, mut backend)!180	put(a.root, 'fromA.txt', 'A\n')!181	commit(a, 'A adds a file')!182	put(b.root, 'fromB.txt', 'B\n')!183	commit(b, 'B adds a file')!184	offer_now(a, mut backend)!185186	// B's offer is refused: the remote has moved on.187	if _ := offer_now(b, mut backend) {188		assert false, 'offering on top of unknown remote saves should be refused'189	} else {190		assert err.msg().contains('run koh steal')191	}192193	report := steal_now(b, mut backend)!194	assert !report.forward195	assert report.replayed == 1196	assert report.clashes.len == 0197	// Both files are on B's line now, and on disk.198	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'199	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'200	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'201	// B's own save was replayed, not erased.202	mut saw_replay := false203	for save in history.all_saves(b)! {204		if save.replayed_from != '' {205			saw_replay = true206		}207	}208	assert saw_replay209	// And the remote now has B's work too.210	offer_now(b, mut backend)!211	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()212	assert line_head == history.head(b)!.id213}214215fn test_clashing_files_are_chosen_one_at_a_time() {216	base := scratch('clash')!217	defer {218		os.rmdir_all(base) or {}219	}220	mut backend := remote.new_folder(os.join_path(base, 'remote'))!221	mut a := open_repo(machine('clash_a')!)!222	defer {223		a.close()224	}225	mut b := open_repo(machine('clash_b')!)!226	defer {227		b.close()228	}229	put(a.root, 'same.txt', 'base\n')!230	put(a.root, 'other.txt', 'quiet\n')!231	commit(a, 'base')!232	offer_now(a, mut backend)!233	join(b, mut backend)!234235	// Both machines edit the same file, differently.236	put(a.root, 'same.txt', 'A version\n')!237	commit(a, 'A edits same')!238	offer_now(a, mut backend)!239	put(b.root, 'same.txt', 'B version\n')!240	commit(b, 'B edits same')!241242	// Without a decision, steal refuses and says which files clash.243	if _ := steal(b, mut backend, StealOptions{}) {244		assert false, 'an undecided clash must be refused'245	} else {246		assert err.msg().contains('--mine') || err.msg().contains('--theirs')247	}248	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,249		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0250251	// Choosing the remote's version ends with A's content.252	report := steal_now(b, mut backend)!253	assert report.clashes == ['same.txt']254	assert report.kept_mine == 0255	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'256	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'257}258259fn test_choosing_mine_keeps_the_local_version() {260	base := scratch('mine')!261	defer {262		os.rmdir_all(base) or {}263	}264	mut backend := remote.new_folder(os.join_path(base, 'remote'))!265	mut a := open_repo(machine('mine_a')!)!266	defer {267		a.close()268	}269	mut b := open_repo(machine('mine_b')!)!270	defer {271		b.close()272	}273	put(a.root, 'same.txt', 'base\n')!274	commit(a, 'base')!275	offer_now(a, mut backend)!276	join(b, mut backend)!277	put(a.root, 'same.txt', 'A version\n')!278	commit(a, 'A edits')!279	offer_now(a, mut backend)!280	put(b.root, 'same.txt', 'B version\n')!281	commit(b, 'B edits')!282283	report := steal(b, mut backend, StealOptions{284		mine: true285	})!286	assert report.kept_mine == 1287	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'288	// The final save says what it kept.289	assert history.head(b)!.message.contains('combine: kept mine for 1 file')290	// The pre-combine tree is still reachable in history.291	assert history.head(b)!.restores == ''292	assert history.all_saves(b)!.len >= 4293}294295fn test_flags_travel_with_the_saves() {296	base := scratch('flags')!297	defer {298		os.rmdir_all(base) or {}299	}300	mut backend := remote.new_folder(os.join_path(base, 'remote'))!301	mut a := open_repo(machine('flag_a')!)!302	defer {303		a.close()304	}305	mut b := open_repo(machine('flag_b')!)!306	defer {307		b.close()308	}309	put(a.root, 'a.txt', 'one\n')!310	save := commit(a, 'suspect save')!311	events.flag(a, save.id, 'look at this')!312	offer_now(a, mut backend)!313314	// B has nothing yet, so a fetch brings the save and its flag across.315	fetched := fetch_back_to_known(b, mut backend, 'proj')!316	assert fetched.events_out.len == 1317	assert fetched.events_out[0].kind == events.kind_flag318	b.begin()!319	write_fetched(b, fetched)!320	history.set_head(b, fetched.head)!321	b.commit()!322	state := events.flag_state(b, save.id)!323	assert state.flagged324	assert state.reason == 'look at this'325}326327fn test_a_second_offer_racing_head_is_refused_not_forced() {328	base := scratch('race')!329	defer {330		os.rmdir_all(base) or {}331	}332	mut backend := remote.new_folder(os.join_path(base, 'remote'))!333	mut a := open_repo(machine('race_a')!)!334	defer {335		a.close()336	}337	put(a.root, 'a.txt', 'one\n')!338	commit(a, 'base')!339	offer_now(a, mut backend)!340341	// Someone else replaces head behind our back.342	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),343		backend.get(head_key('proj'))!.tag)!344345	put(a.root, 'a.txt', 'two\n')!346	commit(a, 'second')!347	if _ := offer_now(a, mut backend) {348		assert false, 'offering while the remote head moved must be refused'349	} else {350		assert err.msg().contains('run koh steal')351	}352}353354// join makes a brand new machine share the remote's line.355fn join(store &storage.Store, mut backend remote.Backend) ! {356	fetched := fetch_repo(mut backend, 'proj')!357	store.begin()!358	write_fetched(store, fetched)!359	history.set_head(store, fetched.head)!360	store.commit()!361	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,362		fetched.head)!, [])!363	store.set_state('remote_repo', 'proj')!364}365366struct FaultyBackend {367mut:368	inner      remote.Backend369	writes     int370	reads      int371	fail_write int // fail when the write counter reaches this, 0 never372	fail_read  int373}374375fn (mut f FaultyBackend) get(key string) !remote.Object {376	f.reads++377	if f.fail_read > 0 && f.reads >= f.fail_read {378		return error('injected read failure')379	}380	return f.inner.get(key)!381}382383fn (mut f FaultyBackend) list(prefix string) ![]string {384	return f.inner.list(prefix)!385}386387fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {388	f.writes++389	if f.fail_write > 0 && f.writes >= f.fail_write {390		return error('injected write failure')391	}392	return f.inner.put_if_absent(key, body)!393}394395fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {396	f.writes++397	if f.fail_write > 0 && f.writes >= f.fail_write {398		return error('injected write failure')399	}400	return f.inner.put_if_match(key, body, tag)!401}402403fn (mut f FaultyBackend) delete(key string) ! {404	f.inner.delete(key)!405}406407fn test_a_failed_offer_is_safe_to_re_run() {408	base := scratch('fault_offer')!409	defer {410		os.rmdir_all(base) or {}411	}412	remote_dir := os.join_path(base, 'remote')413	mut a := open_repo(machine('fault_a')!)!414	defer {415		a.close()416	}417	put(a.root, 'a.txt', 'one\n')!418	commit(a, 'first')!419	put(a.root, 'a.txt', 'two\n')!420	commit(a, 'second')!421422	// The first offer dies after the first write.423	mut flaky := FaultyBackend{424		inner:      remote.new_folder(remote_dir)!425		fail_write: 2426	}427	if _ := offer(a, mut flaky) {428		assert false, 'the injected failure should have surfaced'429	}430431	// Re-running with a healthy remote finishes the job: "already exists" is432	// success, so nothing is lost and nothing is duplicated.433	mut healthy := remote.new_folder(remote_dir)!434	report := offer(a, mut healthy)!435	assert report.saves == 2436	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id437	assert healthy.list('proj/saves')!.len == 2438}439440fn test_a_failed_steal_leaves_nothing_behind() {441	base := scratch('fault_steal')!442	defer {443		os.rmdir_all(base) or {}444	}445	remote_dir := os.join_path(base, 'remote')446	mut a := open_repo(machine('fault_s_a')!)!447	defer {448		a.close()449	}450	mut b := open_repo(machine('fault_s_b')!)!451	defer {452		b.close()453	}454	put(a.root, 'a.txt', 'from A\n')!455	commit(a, 'A writes')!456	mut healthy := remote.new_folder(remote_dir)!457	offer(a, mut healthy)!458459	// B's fetch fails half way through reading the remote.460	mut flaky := FaultyBackend{461		inner:     remote.new_folder(remote_dir)!462		fail_read: 2463	}464	if _ := fetch_back_to_known(b, mut flaky, 'proj') {465		assert false, 'the injected read failure should have surfaced'466	}467	// Nothing was written: the store is untouched and still has no saves.468	assert history.head(b) == none469	assert b.object_count()! == 0470471	// A healthy retry works.472	fetched := fetch_back_to_known(b, mut healthy, 'proj')!473	b.begin()!474	write_fetched(b, fetched)!475	history.set_head(b, fetched.head)!476	b.commit()!477	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!478	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'479}480481fn test_an_interrupted_strip_is_idempotent() {482	// The local half of a strip is one transaction, so an interrupted strip is483	// simply a strip that did not happen; re-running it must land the same484	// result once, not twice.485	base := scratch('fault_strip')!486	defer {487		os.rmdir_all(base) or {}488	}489	mut a := open_repo(machine('fault_strip_a')!)!490	defer {491		a.close()492	}493	put(a.root, 'secret.txt', 'shh\n')!494	put(a.root, 'keep.txt', 'fine\n')!495	save := commit(a, 'add both')!496497	// What `koh strip` does locally, twice over.498	for _ in 0 .. 2 {499		a.begin()!500		mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!501		stmt.bind_text(1, 'secret.txt')!502		stmt.step()!503		stmt.finalize()504		a.drop_unreferenced_objects()!505		events.strip(a, '', 'secret.txt')!506		a.commit()!507	}508	files := history.files_of(a, save.id)!509	// The repo's own `.kohignore` is tracked content, so it stays; only the510	// stripped path goes.511	assert files.map(it.path) == ['.kohignore', 'keep.txt']512	assert events.strip_events(a)!.len == 1513}514515fn test_a_strip_reaches_the_remote_and_other_machines() {516	base := scratch('strip_sync')!517	defer {518		os.rmdir_all(base) or {}519	}520	mut a := open_repo(machine('strip_a')!)!521	defer {522		a.close()523	}524	mut b := open_repo(machine('strip_b')!)!525	defer {526		b.close()527	}528	put(a.root, 'secrets.env', 'api_key=1\n')!529	put(a.root, 'keep.txt', 'fine\n')!530	save := commit(a, 'add both')!531	mut backend := remote.new_folder(os.join_path(base, 'remote'))!532	offer_now(a, mut backend)!533	join(b, mut backend)!534	assert b.object_count()! > 0535536	// A strips the path locally, the way `koh strip` does, then tells the537	// remote.538	mut secret_hash := ''539	for file in history.files_of(a, save.id)! {540		if file.path == 'secrets.env' {541			secret_hash = file.hash542		}543	}544	assert secret_hash != ''545	a.begin()!546	mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!547	stmt.bind_text(1, 'secrets.env')!548	stmt.step()!549	stmt.finalize()550	a.drop_unreferenced_objects()!551	events.strip(a, '', 'secrets.env')!552	a.commit()!553	report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!554	assert report.event_sent555	assert report.records == 1556557	// The remote's copy of the save no longer lists the file, and the object is558	// gone from the remote.559	record := decode_save(backend.get(save_key('proj', save.id))!.body)!560	for file in record.files {561		assert file.path != 'secrets.env'562	}563	// The stripped object is gone from the remote; the ones still referenced564	// are not.565	assert !backend.get(object_key('proj', secret_hash))!.found566	assert backend.list('proj/objects')!.len == 2567568	// Another machine hears about it on its next steal and applies it.569	b.begin()!570	fetched := fetch_back_to_known(b, mut backend, 'proj')!571	write_fetched(b, fetched)!572	b.commit()!573	mut paths := []string{}574	for file in history.files_of(b, save.id)! {575		paths << file.path576	}577	assert 'secrets.env' !in paths578	assert 'keep.txt' in paths579}