sync_test.v

36.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_is_remembered_and_head_offered() {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 backend.get(head_key('proj'))!.body.bytestr() == 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 backend.get(head_key('proj'))!.body.bytestr() == 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 link is invisible to the walk, so a tree holding one is clean; the files164// a steal brings in must still never land in the folder it points at.165fn test_steal_never_writes_through_a_symlinked_folder() {166	base := scratch('steal_link')!167	defer {168		os.rmdir_all(base) or {}169	}170	mut backend := remote.new_folder(os.join_path(base, 'remote'))!171	mut a := open_repo(machine('steal_link_a')!)!172	defer {173		a.close()174	}175	mut b := open_repo(machine('steal_link_b')!)!176	defer {177		b.close()178	}179	put(a.root, 'a.txt', 'one\n')!180	commit(a, 'first')!181	offer_now(a, mut backend)!182	join(b, mut backend)!183	outside := os.join_path(base, 'outside')184	put(outside, 'a.md', 'precious\n')!185	os.symlink(outside, os.join_path(b.root, 'docs'))!186	put(a.root, 'docs/a.md', 'from A\n')!187	commit(a, 'A adds docs')!188	offer_now(a, mut backend)!189	head_before := history.head(b)!.id190	if _ := steal_now(b, mut backend) {191		assert false, 'a steal writing through a symlinked folder must refuse'192	} else {193		assert err.msg().contains('docs')194	}195	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'196	assert history.head(b)!.id == head_before197}198199// A steal onto a half-restored tree would replay it as somebody's work.200fn test_steal_refuses_after_a_restore_cut_short() {201	base := scratch('steal_cut')!202	defer {203		os.rmdir_all(base) or {}204	}205	mut backend := remote.new_folder(os.join_path(base, 'remote'))!206	mut a := open_repo(machine('steal_cut_a')!)!207	defer {208		a.close()209	}210	put(a.root, 'a.txt', 'one\n')!211	commit(a, 'first')!212	offer_now(a, mut backend)!213	a.set_state('restoring', 'yes')!214	if _ := steal_now(a, mut backend) {215		assert false, 'steal must refuse while a restore is unfinished'216	} else {217		assert err.msg().contains('koh revert')218	}219}220221// A replayed save keeps its original time but sits on a newer parent, so a222// clone cannot order saves by time.223fn test_a_clone_imports_parents_before_children() {224	base := scratch('clone_order')!225	defer {226		os.rmdir_all(base) or {}227	}228	mut backend := remote.new_folder(os.join_path(base, 'remote'))!229	mut a := open_repo(machine('order_a')!)!230	defer {231		a.close()232	}233	put(a.root, 'a.txt', 'one\n')!234	parent := commit(a, 'parent')!235	child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{236		message:    'replayed, older than its parent'237		created_at: parent.created_at - 3600238	})!239	offer_now(a, mut backend)!240	mut b := open_repo(machine('order_b')!)!241	defer {242		b.close()243	}244	join(b, mut backend)!245	assert history.head(b)!.id == child.id246}247248// A chmod is unsaved work too: a steal would restore the old mode over it.249fn test_steal_refuses_an_unsaved_executable_bit_change() {250	base := scratch('chmod')!251	defer {252		os.rmdir_all(base) or {}253	}254	mut backend := remote.new_folder(os.join_path(base, 'remote'))!255	mut a := open_repo(machine('chmod_a')!)!256	defer {257		a.close()258	}259	put(a.root, 'run.sh', 'echo hi\n')!260	commit(a, 'script')!261	offer_now(a, mut backend)!262	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!263	if _ := steal_now(a, mut backend) {264		assert false, 'a chmod-only change should make the tree dirty'265	} else {266		assert err.msg() == dirty_tree267	}268}269270fn test_divergence_without_clashes_keeps_both_machines_work() {271	base := scratch('diverge')!272	defer {273		os.rmdir_all(base) or {}274	}275	mut backend := remote.new_folder(os.join_path(base, 'remote'))!276	mut a := open_repo(machine('div_a')!)!277	defer {278		a.close()279	}280	mut b := open_repo(machine('div_b')!)!281	defer {282		b.close()283	}284	put(a.root, 'shared.txt', 'base\n')!285	commit(a, 'base')!286	offer_now(a, mut backend)!287288	// B joins, then both machines change a different file.289	join(b, mut backend)!290	put(a.root, 'fromA.txt', 'A\n')!291	commit(a, 'A adds a file')!292	put(b.root, 'fromB.txt', 'B\n')!293	commit(b, 'B adds a file')!294	offer_now(a, mut backend)!295296	// B's offer is refused: the remote has moved on.297	if _ := offer_now(b, mut backend) {298		assert false, 'offering on top of unknown remote saves should be refused'299	} else {300		assert err.msg().contains('run koh steal')301	}302303	report := steal_now(b, mut backend)!304	assert !report.forward305	assert report.replayed == 1306	assert report.clashes.len == 0307	// Both files are on B's line now, and on disk.308	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'309	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'310	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'311	// B's own save was replayed, not erased.312	mut saw_replay := false313	for save in history.all_saves(b)! {314		if save.replayed_from != '' {315			saw_replay = true316		}317	}318	assert saw_replay319	// And the remote now has B's work too.320	offer_now(b, mut backend)!321	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()322	assert line_head == history.head(b)!.id323}324325// An agent that edits a file and then undoes the edit leaves no net change, so326// the file is not a clash when the other machine changed it. Replaying the327// edit itself must not stop the steal: the remote's version simply stands.328// Likewise a file both machines ended up changing the same way.329fn test_a_local_change_undone_does_not_block_a_combine() {330	base := scratch('undone')!331	defer {332		os.rmdir_all(base) or {}333	}334	mut backend := remote.new_folder(os.join_path(base, 'remote'))!335	mut a := open_repo(machine('undone_a')!)!336	defer {337		a.close()338	}339	mut b := open_repo(machine('undone_b')!)!340	defer {341		b.close()342	}343	put(a.root, 'f.txt', 'base\n')!344	put(a.root, 'g.txt', 'base\n')!345	commit(a, 'base')!346	offer_now(a, mut backend)!347	join(b, mut backend)!348349	put(a.root, 'f.txt', 'from A\n')!350	put(a.root, 'g.txt', 'same\n')!351	commit(a, 'A edits both')!352	offer_now(a, mut backend)!353354	put(b.root, 'f.txt', 'tried on B\n')!355	put(b.root, 'g.txt', 'first try\n')!356	tried := commit(b, 'B tries')!357	put(b.root, 'f.txt', 'base\n')!358	put(b.root, 'g.txt', 'same\n')!359	put(b.root, 'h.txt', 'B\n')!360	undone := commit(b, 'B undoes f, lands on the same g, adds h')!361362	report := steal_now(b, mut backend)!363	assert report.clashes.len == 0364	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'365	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'366	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'367	// "B tries" only touched files A changed, so it has nothing left to368	// replay; the second save carries h.txt. Both originals stay in history.369	assert report.replayed == 1370	assert history.head(b)!.replayed_from == undone.id371	ids := history.all_saves(b)!.map(it.id)372	assert tried.id in ids && undone.id in ids373	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')374}375376// A person deciding a clash may take minutes; the question must come before377// the write lock, or every agent's save on this repo waits and then fails.378fn test_clash_questions_are_asked_before_the_write_lock() {379	base := scratch('ask_unlocked')!380	defer {381		os.rmdir_all(base) or {}382	}383	mut backend := remote.new_folder(os.join_path(base, 'remote'))!384	mut a := open_repo(machine('ask_a')!)!385	defer {386		a.close()387	}388	mut b := open_repo(machine('ask_b')!)!389	defer {390		b.close()391	}392	put(a.root, 'same.txt', 'base\n')!393	commit(a, 'base')!394	offer_now(a, mut backend)!395	join(b, mut backend)!396	put(a.root, 'same.txt', 'A version\n')!397	commit(a, 'A edits same')!398	offer_now(a, mut backend)!399	put(b.root, 'same.txt', 'B version\n')!400	commit(b, 'B edits same')!401	root := b.root402	// An agent saves while the person is still choosing.403	if _ := steal(b, mut backend, StealOptions{404		ask: fn [root] (clash Clash) Side {405			other := storage.open(root) or { panic(err) }406			defer {407				other.close()408			}409			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }410			history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{411				message: 'agent saves during the question'412			}) or { panic('steal held the write lock while asking: ${err}') }413			return .mine414		}415	})416	{417		assert false, 'a steal whose line moved during the question must refuse'418	} else {419		assert err.msg().contains('changed while')420	}421	// The agent's save is still head, and a second steal combines it too.422	assert history.head(b)!.message == 'agent saves during the question'423	report := steal(b, mut backend, StealOptions{424		mine: true425	})!426	assert report.kept_mine == 1427	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'428	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'429}430431fn test_clashing_files_are_chosen_one_at_a_time() {432	base := scratch('clash')!433	defer {434		os.rmdir_all(base) or {}435	}436	mut backend := remote.new_folder(os.join_path(base, 'remote'))!437	mut a := open_repo(machine('clash_a')!)!438	defer {439		a.close()440	}441	mut b := open_repo(machine('clash_b')!)!442	defer {443		b.close()444	}445	put(a.root, 'same.txt', 'base\n')!446	put(a.root, 'other.txt', 'quiet\n')!447	commit(a, 'base')!448	offer_now(a, mut backend)!449	join(b, mut backend)!450451	// Both machines edit the same file, differently.452	put(a.root, 'same.txt', 'A version\n')!453	commit(a, 'A edits same')!454	offer_now(a, mut backend)!455	put(b.root, 'same.txt', 'B version\n')!456	commit(b, 'B edits same')!457458	// Without a decision, steal refuses and says which files clash.459	if _ := steal(b, mut backend, StealOptions{}) {460		assert false, 'an undecided clash must be refused'461	} else {462		assert err.msg().contains('--mine') || err.msg().contains('--theirs')463	}464	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,465		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0466467	// Choosing the remote's version ends with A's content.468	report := steal_now(b, mut backend)!469	assert report.clashes == ['same.txt']470	assert report.kept_mine == 0471	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'472	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'473}474475fn test_choosing_mine_keeps_the_local_version() {476	base := scratch('mine')!477	defer {478		os.rmdir_all(base) or {}479	}480	mut backend := remote.new_folder(os.join_path(base, 'remote'))!481	mut a := open_repo(machine('mine_a')!)!482	defer {483		a.close()484	}485	mut b := open_repo(machine('mine_b')!)!486	defer {487		b.close()488	}489	put(a.root, 'same.txt', 'base\n')!490	commit(a, 'base')!491	offer_now(a, mut backend)!492	join(b, mut backend)!493	put(a.root, 'same.txt', 'A version\n')!494	commit(a, 'A edits')!495	offer_now(a, mut backend)!496	put(b.root, 'same.txt', 'B version\n')!497	commit(b, 'B edits')!498499	report := steal(b, mut backend, StealOptions{500		mine: true501	})!502	assert report.kept_mine == 1503	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'504	// The final save says what it kept.505	assert history.head(b)!.message.contains('combine: kept mine for 1 file')506	// The pre-combine tree is still reachable in history.507	assert history.head(b)!.restores == ''508	assert history.all_saves(b)!.len >= 4509}510511fn test_flags_travel_with_the_saves() {512	base := scratch('flags')!513	defer {514		os.rmdir_all(base) or {}515	}516	mut backend := remote.new_folder(os.join_path(base, 'remote'))!517	mut a := open_repo(machine('flag_a')!)!518	defer {519		a.close()520	}521	mut b := open_repo(machine('flag_b')!)!522	defer {523		b.close()524	}525	put(a.root, 'a.txt', 'one\n')!526	save := commit(a, 'suspect save')!527	events.flag(a, save.id, 'look at this')!528	offer_now(a, mut backend)!529530	// B has nothing yet, so a fetch brings the save and its flag across.531	fetched := fetch_back_to_known(b, mut backend, 'proj')!532	assert fetched.events_out.len == 1533	assert fetched.events_out[0].kind == events.kind_flag534	b.begin()!535	write_fetched(b, fetched)!536	history.set_head(b, fetched.head)!537	b.commit()!538	state := events.flag_state(b, save.id)!539	assert state.flagged540	assert state.reason == 'look at this'541}542543fn test_a_second_offer_racing_head_is_refused_not_forced() {544	base := scratch('race')!545	defer {546		os.rmdir_all(base) or {}547	}548	mut backend := remote.new_folder(os.join_path(base, 'remote'))!549	mut a := open_repo(machine('race_a')!)!550	defer {551		a.close()552	}553	put(a.root, 'a.txt', 'one\n')!554	commit(a, 'base')!555	offer_now(a, mut backend)!556557	// Someone else replaces head behind our back.558	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),559		backend.get(head_key('proj'))!.tag)!560561	put(a.root, 'a.txt', 'two\n')!562	commit(a, 'second')!563	if _ := offer_now(a, mut backend) {564		assert false, 'offering while the remote head moved must be refused'565	} else {566		assert err.msg().contains('run koh steal')567	}568}569570// join makes a brand new machine share the remote's line.571fn join(store &storage.Store, mut backend remote.Backend) ! {572	fetched := fetch_repo(mut backend, 'proj')!573	store.begin()!574	write_fetched(store, fetched)!575	history.set_head(store, fetched.head)!576	store.commit()!577	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,578		fetched.head)!, [])!579	store.set_state('remote_repo', 'proj')!580}581582struct FaultyBackend {583mut:584	inner      remote.Backend585	writes     int586	reads      int587	fail_write int // fail when the write counter reaches this, 0 never588	fail_read  int589	// fail every delete while set590	fail_delete bool591}592593fn (mut f FaultyBackend) get(key string) !remote.Object {594	f.reads++595	if f.fail_read > 0 && f.reads >= f.fail_read {596		return error('injected read failure')597	}598	return f.inner.get(key)!599}600601fn (mut f FaultyBackend) list(prefix string) ![]string {602	return f.inner.list(prefix)!603}604605fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {606	f.writes++607	if f.fail_write > 0 && f.writes >= f.fail_write {608		return error('injected write failure')609	}610	return f.inner.put_if_absent(key, body)!611}612613fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {614	f.writes++615	if f.fail_write > 0 && f.writes >= f.fail_write {616		return error('injected write failure')617	}618	return f.inner.put_if_match(key, body, tag)!619}620621fn (mut f FaultyBackend) delete(key string) ! {622	if f.fail_delete {623		return error('injected delete failure')624	}625	f.inner.delete(key)!626}627628fn test_a_failed_offer_is_safe_to_re_run() {629	base := scratch('fault_offer')!630	defer {631		os.rmdir_all(base) or {}632	}633	remote_dir := os.join_path(base, 'remote')634	mut a := open_repo(machine('fault_a')!)!635	defer {636		a.close()637	}638	put(a.root, 'a.txt', 'one\n')!639	commit(a, 'first')!640	put(a.root, 'a.txt', 'two\n')!641	commit(a, 'second')!642643	// The first offer dies after the first write.644	mut flaky := FaultyBackend{645		inner:      remote.new_folder(remote_dir)!646		fail_write: 2647	}648	if _ := offer(a, mut flaky) {649		assert false, 'the injected failure should have surfaced'650	}651652	// Re-running with a healthy remote finishes the job: "already exists" is653	// success, so nothing is lost and nothing is duplicated.654	mut healthy := remote.new_folder(remote_dir)!655	report := offer(a, mut healthy)!656	assert report.saves == 2657	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id658	assert healthy.list('proj/saves')!.len == 2659}660661fn test_a_failed_steal_leaves_nothing_behind() {662	base := scratch('fault_steal')!663	defer {664		os.rmdir_all(base) or {}665	}666	remote_dir := os.join_path(base, 'remote')667	mut a := open_repo(machine('fault_s_a')!)!668	defer {669		a.close()670	}671	mut b := open_repo(machine('fault_s_b')!)!672	defer {673		b.close()674	}675	put(a.root, 'a.txt', 'from A\n')!676	commit(a, 'A writes')!677	mut healthy := remote.new_folder(remote_dir)!678	offer(a, mut healthy)!679680	// B's fetch fails half way through reading the remote.681	mut flaky := FaultyBackend{682		inner:     remote.new_folder(remote_dir)!683		fail_read: 2684	}685	if _ := fetch_back_to_known(b, mut flaky, 'proj') {686		assert false, 'the injected read failure should have surfaced'687	}688	// Nothing was written: the store is untouched and still has no saves.689	assert history.head(b) == none690	assert b.object_count()! == 0691692	// A healthy retry works.693	fetched := fetch_back_to_known(b, mut healthy, 'proj')!694	b.begin()!695	write_fetched(b, fetched)!696	history.set_head(b, fetched.head)!697	b.commit()!698	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!699	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'700}701702fn test_an_interrupted_strip_is_idempotent() {703	// The local half of a strip is one transaction, so an interrupted strip is704	// simply a strip that did not happen; re-running it must land the same705	// result once, not twice.706	base := scratch('fault_strip')!707	defer {708		os.rmdir_all(base) or {}709	}710	mut a := open_repo(machine('fault_strip_a')!)!711	defer {712		a.close()713	}714	put(a.root, 'secret.txt', 'shh\n')!715	put(a.root, 'keep.txt', 'fine\n')!716	save := commit(a, 'add both')!717718	for _ in 0 .. 2 {719		strip_local(a, 'secret.txt', '')!720	}721	files := history.files_of(a, save.id)!722	// The repo's own `.kohignore` is tracked content, so it stays; only the723	// stripped path goes.724	assert files.map(it.path) == ['.kohignore', 'keep.txt']725	assert events.strip_events(a)!.len == 1726}727728// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into729// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.730// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,731// but a save made on Linux can hold both.732fn test_a_strip_matches_the_path_exactly() {733	mut a := open_repo(machine('strip_exact')!)!734	defer {735		a.close()736	}737	hash := a.put_object('x\n'.bytes())!738	mut files := []snapshot.FileEntry{}739	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {740		files << snapshot.FileEntry{741			path: path742			hash: hash743		}744	}745	save := history.commit_files(a, '', files, history.SaveInput{746		message: 'add all'747	})!748	assert strip_scope(a, 'docs', '')!.files == 1749	strip_local(a, 'docs', '')!750	strip_local(a, 'a_b', '')!751	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']752}753754fn test_a_strip_reaches_the_remote_and_other_machines() {755	base := scratch('strip_sync')!756	defer {757		os.rmdir_all(base) or {}758	}759	mut a := open_repo(machine('strip_a')!)!760	defer {761		a.close()762	}763	mut b := open_repo(machine('strip_b')!)!764	defer {765		b.close()766	}767	put(a.root, 'secrets.env', 'api_key=1\n')!768	put(a.root, 'keep.txt', 'fine\n')!769	save := commit(a, 'add both')!770	mut backend := remote.new_folder(os.join_path(base, 'remote'))!771	offer_now(a, mut backend)!772	join(b, mut backend)!773	assert b.object_count()! > 0774775	// A strips the path locally, the way `koh strip` does, then tells the776	// remote.777	mut secret_hash := ''778	for file in history.files_of(a, save.id)! {779		if file.path == 'secrets.env' {780			secret_hash = file.hash781		}782	}783	assert secret_hash != ''784	strip_local(a, 'secrets.env', '')!785	report := push_strip(a, mut backend, 'secrets.env')!786	assert report.event_sent787	assert report.records == 1788789	// The remote's copy of the save no longer lists the file, and the object is790	// gone from the remote.791	record := decode_save(backend.get(save_key('proj', save.id))!.body)!792	for file in record.files {793		assert file.path != 'secrets.env'794	}795	// The stripped object is gone from the remote; the ones still referenced796	// are not.797	assert !backend.get(object_key('proj', secret_hash))!.found798	assert backend.list('proj/objects')!.len == 2799800	// Another machine hears about it on its next steal and applies it.801	b.begin()!802	fetched := fetch_back_to_known(b, mut backend, 'proj')!803	write_fetched(b, fetched)!804	b.commit()!805	mut paths := []string{}806	for file in history.files_of(b, save.id)! {807		paths << file.path808	}809	assert 'secrets.env' !in paths810	assert 'keep.txt' in paths811812	// The event arrives under its own ID, so it is applied once: B's offer adds813	// no second copy, and a file B saves again after the strip stays saved814	// through B's next steal.815	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)816	put(b.root, 'secrets.env', 'rotated\n')!817	readded := commit(b, 'add the rotated key back')!818	offer_now(b, mut backend)!819	assert backend.list('proj/events')!.len == 1820	b.begin()!821	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!822	b.commit()!823	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)824}825826// A strip removes a path from the saves that existed when it was made. A key827// rotated and saved again afterwards is new work: a machine that clones the828// repo later must get it, not strip it on arrival.829fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {830	base := scratch('strip_clone')!831	defer {832		os.rmdir_all(base) or {}833	}834	mut backend := remote.new_folder(os.join_path(base, 'remote'))!835	mut a := open_repo(machine('strip_clone_a')!)!836	defer {837		a.close()838	}839	put(a.root, 'secrets.env', 'api_key=1\n')!840	old := commit(a, 'leak the key')!841	offer_now(a, mut backend)!842	strip_local(a, 'secrets.env', '')!843	push_strip(a, mut backend, 'secrets.env')!844	// Saves after the strip must be later in time, as they would be for real.845	put(a.root, 'secrets.env', 'api_key=2\n')!846	rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{847		message:    'rotate the key'848		created_at: events.strip_events(a)!.last().created_at + 60849	})!850	offer_now(a, mut backend)!851852	mut c := open_repo(machine('strip_clone_c')!)!853	defer {854		c.close()855	}856	join(c, mut backend)!857	assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)858	assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)859	assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'860}861862// The local half commits before the remote hears anything, so a remote failure863// leaves nothing to find locally; the pending note is what lets a re-run864// finish. Objects a remote-only save still needs must survive the strip.865fn test_an_interrupted_remote_strip_finishes_when_re_run() {866	base := scratch('strip_resume')!867	defer {868		os.rmdir_all(base) or {}869	}870	remote_dir := os.join_path(base, 'remote')871	mut backend := remote.new_folder(remote_dir)!872	mut a := open_repo(machine('resume_a')!)!873	defer {874		a.close()875	}876	mut b := open_repo(machine('resume_b')!)!877	defer {878		b.close()879	}880	put(a.root, 'secrets.env', 'api_key=1\n')!881	put(a.root, 'shared.env', 'same bytes on B\n')!882	save := commit(a, 'add secrets')!883	offer_now(a, mut backend)!884	join(b, mut backend)!885	put(b.root, 'copy.txt', 'same bytes on B\n')!886	b_save := commit(b, 'B keeps a copy')!887	offer_now(b, mut backend)!888	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())889	secret_hash := storage.hash_hex('api_key=1\n'.bytes())890891	strip_local(a, 'secrets.env', '')!892	// Write 1 uploads the event and write 2 rewrites the first record; B's save,893	// made from A's tree, lists the file too, and its rewrite (write 3) fails.894	mut flaky := FaultyBackend{895		inner:      remote.new_folder(remote_dir)!896		fail_write: 3897	}898	if _ := push_strip(a, mut flaky, 'secrets.env') {899		assert false, 'the injected failure should have surfaced'900	}901	assert strip_pending(a, 'secrets.env')902	assert strip_local(a, 'secrets.env', '')!.files_removed == 0903	report := push_strip(a, mut backend, 'secrets.env')!904	assert report.records == 1905	assert !strip_pending(a, 'secrets.env')906	assert !backend.get(object_key('proj', secret_hash))!.found907	record := decode_save(backend.get(save_key('proj', save.id))!.body)!908	assert 'secrets.env' !in record.files.map(it.path)909	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!910	assert 'secrets.env' !in b_record.files.map(it.path)911912	// B's save still names the shared bytes, so stripping A's copy keeps them.913	strip_local(a, 'shared.env', '')!914	push_strip(a, mut backend, 'shared.env')!915	assert backend.get(object_key('proj', shared_hash))!.found916}917918// A machine that has not heard of a strip must not offer: its records still919// list the stripped path, and nothing would rewrite them on the remote again.920fn test_offer_waits_for_an_unapplied_strip() {921	base := scratch('strip_first')!922	defer {923		os.rmdir_all(base) or {}924	}925	mut backend := remote.new_folder(os.join_path(base, 'remote'))!926	mut a := open_repo(machine('strip_first_a')!)!927	defer {928		a.close()929	}930	mut b := open_repo(machine('strip_first_b')!)!931	defer {932		b.close()933	}934	put(a.root, 'secrets.env', 'api_key=1\n')!935	commit(a, 'add secrets')!936	offer_now(a, mut backend)!937	join(b, mut backend)!938	strip_local(a, 'secrets.env', '')!939	push_strip(a, mut backend, 'secrets.env')!940	put(b.root, 'more.txt', 'B keeps working\n')!941	later := commit(b, 'B saves with the secret still in its tree')!942	if _ := offer_now(b, mut backend) {943		assert false, 'B offered records that still list the stripped path'944	}945	steal_now(b, mut backend)!946	offer_now(b, mut backend)!947	record := decode_save(backend.get(save_key('proj', later.id))!.body)!948	assert 'secrets.env' !in record.files.map(it.path)949}950951// A strip whose object deletions failed, followed by a second strip of the same952// path, must still delete the first strip's objects when it finishes.953fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {954	base := scratch('strip_twice')!955	defer {956		os.rmdir_all(base) or {}957	}958	remote_dir := os.join_path(base, 'remote')959	mut backend := remote.new_folder(remote_dir)!960	mut a := open_repo(machine('strip_twice_a')!)!961	defer {962		a.close()963	}964	put(a.root, 'secrets.env', 'first key\n')!965	commit(a, 'first key')!966	offer_now(a, mut backend)!967	strip_local(a, 'secrets.env', '')!968	mut flaky := FaultyBackend{969		inner:       remote.new_folder(remote_dir)!970		fail_delete: true971	}972	if _ := push_strip(a, mut flaky, 'secrets.env') {973		assert false, 'the injected delete failure should have surfaced'974	}975	put(a.root, 'secrets.env', 'second key\n')!976	commit(a, 'second key')!977	offer_now(a, mut backend)!978	strip_local(a, 'secrets.env', '')!979	push_strip(a, mut backend, 'secrets.env')!980	for key in ['first key\n', 'second key\n'] {981		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found982	}983}984985// Unfinished strips of one path in different scopes finish together.986fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {987	base := scratch('strip_scopes')!988	defer {989		os.rmdir_all(base) or {}990	}991	remote_dir := os.join_path(base, 'remote')992	mut backend := remote.new_folder(remote_dir)!993	mut a := open_repo(machine('strip_scopes_a')!)!994	defer {995		a.close()996	}997	put(a.root, 'secrets.env', 'first key\n')!998	first := commit(a, 'first key')!999	put(a.root, 'secrets.env', 'second key\n')!1000	second := commit(a, 'second key')!1001	offer_now(a, mut backend)!1002	strip_local(a, 'secrets.env', first.id)!1003	// Write 1 uploads the event; write 2, the record rewrite, fails.1004	mut flaky := FaultyBackend{1005		inner:      remote.new_folder(remote_dir)!1006		fail_write: 21007	}1008	if _ := push_strip(a, mut flaky, 'secrets.env') {1009		assert false, 'the injected failure should have surfaced'1010	}1011	strip_local(a, 'secrets.env', second.id)!1012	push_strip(a, mut backend, 'secrets.env')!1013	assert !strip_pending(a, 'secrets.env')1014	for save in [first, second] {1015		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1016		assert 'secrets.env' !in record.files.map(it.path)1017	}1018	for key in ['first key\n', 'second key\n'] {1019		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1020	}1021}10221023// Stripping a path, re-adding it, and stripping it again is two strips; a1024// machine that heard about the first must still apply the second.1025fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1026	base := scratch('strip_again')!1027	defer {1028		os.rmdir_all(base) or {}1029	}1030	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1031	mut a := open_repo(machine('strip_again_a')!)!1032	defer {1033		a.close()1034	}1035	mut b := open_repo(machine('strip_again_b')!)!1036	defer {1037		b.close()1038	}1039	put(a.root, 'secrets.env', 'first key\n')!1040	commit(a, 'first key')!1041	offer_now(a, mut backend)!1042	join(b, mut backend)!1043	strip_local(a, 'secrets.env', '')!1044	push_strip(a, mut backend, 'secrets.env')!1045	steal_now(b, mut backend)!1046	// Strip leaves the file on disk; deleting it is the user's part.1047	os.rm(os.join_path(b.root, 'secrets.env'))!1048	put(a.root, 'secrets.env', 'second key\n')!1049	second := commit(a, 'second key')!1050	offer_now(a, mut backend)!1051	steal_now(b, mut backend)!1052	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1053	strip_local(a, 'secrets.env', '')!1054	push_strip(a, mut backend, 'secrets.env')!1055	steal_now(b, mut backend)!1056	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1057}10581059// A clash on a path the other machine stripped is no clash: the strip removes1060// the path from every save, so there is nothing to choose.1061fn test_a_stripped_path_is_not_asked_about() {1062	base := scratch('clash_strip')!1063	defer {1064		os.rmdir_all(base) or {}1065	}1066	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1067	mut a := open_repo(machine('clash_strip_a')!)!1068	defer {1069		a.close()1070	}1071	mut b := open_repo(machine('clash_strip_b')!)!1072	defer {1073		b.close()1074	}1075	put(a.root, 'secrets.env', 'key one\n')!1076	commit(a, 'base')!1077	offer_now(a, mut backend)!1078	join(b, mut backend)!1079	put(a.root, 'secrets.env', 'key two\n')!1080	commit(a, 'A rotates')!1081	offer_now(a, mut backend)!1082	strip_local(a, 'secrets.env', '')!1083	push_strip(a, mut backend, 'secrets.env')!1084	put(b.root, 'secrets.env', 'key three\n')!1085	mine := commit(b, 'B rotates')!1086	report := steal(b, mut backend, StealOptions{})!1087	assert report.clashes.len == 01088	for save in history.all_saves(b)! {1089		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1090	}1091	assert history.by_id(b, mine.id) != none1092}10931094// A strip scoped to the shared base save changes what the replay sees; the1095// clashes must be found on the same trees, or the replay meets a conflict.1096fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1097	base := scratch('clash_base_strip')!1098	defer {1099		os.rmdir_all(base) or {}1100	}1101	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1102	mut a := open_repo(machine('base_strip_a')!)!1103	defer {1104		a.close()1105	}1106	mut b := open_repo(machine('base_strip_b')!)!1107	defer {1108		b.close()1109	}1110	put(a.root, 'notes.txt', 'shared\n')!1111	common := commit(a, 'base')!1112	offer_now(a, mut backend)!1113	join(b, mut backend)!1114	put(a.root, 'other.txt', 'A moves on\n')!1115	commit(a, 'A moves on')!1116	offer_now(a, mut backend)!1117	strip_local(a, 'notes.txt', common.id)!1118	push_strip(a, mut backend, 'notes.txt')!1119	put(b.root, 'notes.txt', 'B edits\n')!1120	commit(b, 'B edits notes')!1121	if _ := steal(b, mut backend, StealOptions{}) {1122		assert false, 'with the base stripped, notes.txt differs on both sides'1123	} else {1124		assert err.msg().contains('changed on both machines')1125	}1126	report := steal(b, mut backend, StealOptions{1127		mine: true1128	})!1129	assert report.clashes == ['notes.txt']1130	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1131}11321133// A folder with files but no saves has nothing to combine with; a steal that1134// fast-forwarded into it would overwrite whatever shares a name.1135fn test_steal_into_a_folder_with_unsaved_files_refuses() {1136	base := scratch('steal_unsaved')!1137	defer {1138		os.rmdir_all(base) or {}1139	}1140	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1141	mut a := open_repo(machine('unsaved_a')!)!1142	defer {1143		a.close()1144	}1145	put(a.root, 'notes.txt', 'from A\n')!1146	commit(a, 'A writes')!1147	offer_now(a, mut backend)!1148	b_root := machine('unsaved_b')!1149	mut b := storage.create(b_root)!1150	defer {1151		b.close()1152	}1153	put(b_root, 'notes.txt', 'never saved\n')!1154	// Only a repo that already knows its remote name gets this far.1155	b.set_state('remote_repo', 'proj')!1156	if _ := steal(b, mut backend, StealOptions{}) {1157		assert false, 'steal overwrote a folder with unsaved files'1158	} else {1159		assert err.msg() == no_saves_here1160	}1161	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1162	// A fresh `koh init` folder, holding only the default ignore list, is fine.1163	os.rm(os.join_path(b_root, 'notes.txt'))!1164	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1165	steal(b, mut backend, StealOptions{})!1166	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1167}11681169// The document asks steal to warn about every incoming flagged save, so the1170// report names them.1171fn test_steal_reports_incoming_flagged_saves() {1172	base := scratch('flag_warn')!1173	defer {1174		os.rmdir_all(base) or {}1175	}1176	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1177	mut a := open_repo(machine('flag_warn_a')!)!1178	defer {1179		a.close()1180	}1181	mut b := open_repo(machine('flag_warn_b')!)!1182	defer {1183		b.close()1184	}1185	put(a.root, 'a.txt', 'one\n')!1186	commit(a, 'fine')!1187	offer_now(a, mut backend)!1188	join(b, mut backend)!1189	put(a.root, 'a.txt', 'two\n')!1190	suspect := commit(a, 'suspect')!1191	put(a.root, 'a.txt', 'three\n')!1192	commit(a, 'also fine')!1193	events.flag(a, suspect.id, 'broke the build')!1194	offer_now(a, mut backend)!1195	report := steal_now(b, mut backend)!1196	assert report.flagged.map(it.save_id) == [suspect.id]1197	assert report.flagged[0].reason == 'broke the build'1198}