sync_test.v

36.6 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)!.id100	// Offer's step 3: the new remote head is recorded in state.101	assert a.state('remote_head') or { '' } == history.head(a)!.id102103	// A second offer, with the remote already holding our head, must not104	// refuse: the name is remembered, so the remote is ours.105	put(a.root, 'a.txt', 'two\n')!106	commit(a, 'second')!107	offer_now(a, mut backend)!108	assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id109}110111fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {112	base := scratch('forward')!113	defer {114		os.rmdir_all(base) or {}115	}116	mut backend := remote.new_folder(os.join_path(base, 'remote'))!117	mut a := open_repo(machine('fwd_a')!)!118	defer {119		a.close()120	}121	put(a.root, 'a.txt', 'from A\n')!122	commit(a, 'A writes')!123	offer_now(a, mut backend)!124125	// B starts empty and pulls the whole repo.126	mut b := open_repo(machine('fwd_b')!)!127	defer {128		b.close()129	}130	fetched := fetch_repo(mut backend, 'proj')!131	b.begin()!132	write_fetched(b, fetched)!133	history.set_head(b, fetched.head)!134	b.commit()!135	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!136	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'137	assert history.head(b)!.id == fetched.head138}139140// A machine that is only ahead has nothing to combine: stealing must leave its141// saves, and their IDs, exactly where they are.142fn test_steal_while_only_ahead_leaves_the_line_alone() {143	base := scratch('ahead')!144	defer {145		os.rmdir_all(base) or {}146	}147	mut backend := remote.new_folder(os.join_path(base, 'remote'))!148	mut a := open_repo(machine('ahead_a')!)!149	defer {150		a.close()151	}152	put(a.root, 'a.txt', 'one\n')!153	commit(a, 'offered')!154	offer_now(a, mut backend)!155	put(a.root, 'a.txt', 'two\n')!156	ahead := commit(a, 'not offered yet')!157	report := steal_now(a, mut backend)!158	assert report.replayed == 0159	assert history.head(a)!.id == ahead.id160	assert history.all_saves(a)!.len == 2161	offer_now(a, mut backend)!162	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id163}164165// A link is invisible to the walk, so a tree holding one is clean; the files166// a steal brings in must still never land in the folder it points at.167fn test_steal_never_writes_through_a_symlinked_folder() {168	base := scratch('steal_link')!169	defer {170		os.rmdir_all(base) or {}171	}172	mut backend := remote.new_folder(os.join_path(base, 'remote'))!173	mut a := open_repo(machine('steal_link_a')!)!174	defer {175		a.close()176	}177	mut b := open_repo(machine('steal_link_b')!)!178	defer {179		b.close()180	}181	put(a.root, 'a.txt', 'one\n')!182	commit(a, 'first')!183	offer_now(a, mut backend)!184	join(b, mut backend)!185	outside := os.join_path(base, 'outside')186	put(outside, 'a.md', 'precious\n')!187	os.symlink(outside, os.join_path(b.root, 'docs'))!188	put(a.root, 'docs/a.md', 'from A\n')!189	commit(a, 'A adds docs')!190	offer_now(a, mut backend)!191	head_before := history.head(b)!.id192	if _ := steal_now(b, mut backend) {193		assert false, 'a steal writing through a symlinked folder must refuse'194	} else {195		assert err.msg().contains('docs')196	}197	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'198	assert history.head(b)!.id == head_before199}200201// A steal onto a half-restored tree would replay it as somebody's work.202fn test_steal_refuses_after_a_restore_cut_short() {203	base := scratch('steal_cut')!204	defer {205		os.rmdir_all(base) or {}206	}207	mut backend := remote.new_folder(os.join_path(base, 'remote'))!208	mut a := open_repo(machine('steal_cut_a')!)!209	defer {210		a.close()211	}212	put(a.root, 'a.txt', 'one\n')!213	commit(a, 'first')!214	offer_now(a, mut backend)!215	a.set_state('restoring', 'yes')!216	if _ := steal_now(a, mut backend) {217		assert false, 'steal must refuse while a restore is unfinished'218	} else {219		assert err.msg().contains('koh revert')220	}221}222223// A replayed save keeps its original time but sits on a newer parent, so a224// clone cannot order saves by time.225fn test_a_clone_imports_parents_before_children() {226	base := scratch('clone_order')!227	defer {228		os.rmdir_all(base) or {}229	}230	mut backend := remote.new_folder(os.join_path(base, 'remote'))!231	mut a := open_repo(machine('order_a')!)!232	defer {233		a.close()234	}235	put(a.root, 'a.txt', 'one\n')!236	parent := commit(a, 'parent')!237	child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{238		message:    'replayed, older than its parent'239		created_at: parent.created_at - 3600240	})!241	offer_now(a, mut backend)!242	mut b := open_repo(machine('order_b')!)!243	defer {244		b.close()245	}246	join(b, mut backend)!247	assert history.head(b)!.id == child.id248}249250// A chmod is unsaved work too: a steal would restore the old mode over it.251fn test_steal_refuses_an_unsaved_executable_bit_change() {252	base := scratch('chmod')!253	defer {254		os.rmdir_all(base) or {}255	}256	mut backend := remote.new_folder(os.join_path(base, 'remote'))!257	mut a := open_repo(machine('chmod_a')!)!258	defer {259		a.close()260	}261	put(a.root, 'run.sh', 'echo hi\n')!262	commit(a, 'script')!263	offer_now(a, mut backend)!264	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!265	if _ := steal_now(a, mut backend) {266		assert false, 'a chmod-only change should make the tree dirty'267	} else {268		assert err.msg() == dirty_tree269	}270}271272fn test_divergence_without_clashes_keeps_both_machines_work() {273	base := scratch('diverge')!274	defer {275		os.rmdir_all(base) or {}276	}277	mut backend := remote.new_folder(os.join_path(base, 'remote'))!278	mut a := open_repo(machine('div_a')!)!279	defer {280		a.close()281	}282	mut b := open_repo(machine('div_b')!)!283	defer {284		b.close()285	}286	put(a.root, 'shared.txt', 'base\n')!287	commit(a, 'base')!288	offer_now(a, mut backend)!289290	// B joins, then both machines change a different file.291	join(b, mut backend)!292	put(a.root, 'fromA.txt', 'A\n')!293	commit(a, 'A adds a file')!294	put(b.root, 'fromB.txt', 'B\n')!295	commit(b, 'B adds a file')!296	offer_now(a, mut backend)!297298	// B's offer is refused: the remote has moved on.299	if _ := offer_now(b, mut backend) {300		assert false, 'offering on top of unknown remote saves should be refused'301	} else {302		assert err.msg().contains('run koh steal')303	}304305	report := steal_now(b, mut backend)!306	assert !report.forward307	assert report.replayed == 1308	assert report.clashes.len == 0309	// Both files are on B's line now, and on disk.310	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'311	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'312	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'313	// B's own save was replayed, not erased.314	mut saw_replay := false315	for save in history.all_saves(b)! {316		if save.replayed_from != '' {317			saw_replay = true318		}319	}320	assert saw_replay321	// And the remote now has B's work too.322	offer_now(b, mut backend)!323	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()324	assert line_head == history.head(b)!.id325}326327// An agent that edits a file and then undoes the edit leaves no net change, so328// the file is not a clash when the other machine changed it. Replaying the329// edit itself must not stop the steal: the remote's version simply stands.330// Likewise a file both machines ended up changing the same way.331fn test_a_local_change_undone_does_not_block_a_combine() {332	base := scratch('undone')!333	defer {334		os.rmdir_all(base) or {}335	}336	mut backend := remote.new_folder(os.join_path(base, 'remote'))!337	mut a := open_repo(machine('undone_a')!)!338	defer {339		a.close()340	}341	mut b := open_repo(machine('undone_b')!)!342	defer {343		b.close()344	}345	put(a.root, 'f.txt', 'base\n')!346	put(a.root, 'g.txt', 'base\n')!347	commit(a, 'base')!348	offer_now(a, mut backend)!349	join(b, mut backend)!350351	put(a.root, 'f.txt', 'from A\n')!352	put(a.root, 'g.txt', 'same\n')!353	commit(a, 'A edits both')!354	offer_now(a, mut backend)!355356	put(b.root, 'f.txt', 'tried on B\n')!357	put(b.root, 'g.txt', 'first try\n')!358	tried := commit(b, 'B tries')!359	put(b.root, 'f.txt', 'base\n')!360	put(b.root, 'g.txt', 'same\n')!361	put(b.root, 'h.txt', 'B\n')!362	undone := commit(b, 'B undoes f, lands on the same g, adds h')!363364	report := steal_now(b, mut backend)!365	assert report.clashes.len == 0366	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'367	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'368	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'369	// "B tries" only touched files A changed, so it has nothing left to370	// replay; the second save carries h.txt. Both originals stay in history.371	assert report.replayed == 1372	assert history.head(b)!.replayed_from == undone.id373	ids := history.all_saves(b)!.map(it.id)374	assert tried.id in ids && undone.id in ids375	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')376}377378// A person deciding a clash may take minutes; the question must come before379// the write lock, or every agent's save on this repo waits and then fails.380fn test_clash_questions_are_asked_before_the_write_lock() {381	base := scratch('ask_unlocked')!382	defer {383		os.rmdir_all(base) or {}384	}385	mut backend := remote.new_folder(os.join_path(base, 'remote'))!386	mut a := open_repo(machine('ask_a')!)!387	defer {388		a.close()389	}390	mut b := open_repo(machine('ask_b')!)!391	defer {392		b.close()393	}394	put(a.root, 'same.txt', 'base\n')!395	commit(a, 'base')!396	offer_now(a, mut backend)!397	join(b, mut backend)!398	put(a.root, 'same.txt', 'A version\n')!399	commit(a, 'A edits same')!400	offer_now(a, mut backend)!401	put(b.root, 'same.txt', 'B version\n')!402	commit(b, 'B edits same')!403	root := b.root404	// An agent saves while the person is still choosing.405	if _ := steal(b, mut backend, StealOptions{406		ask: fn [root] (clash Clash) Side {407			other := storage.open(root) or { panic(err) }408			defer {409				other.close()410			}411			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }412			history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{413				message: 'agent saves during the question'414			}) or { panic('steal held the write lock while asking: ${err}') }415			return .mine416		}417	})418	{419		assert false, 'a steal whose line moved during the question must refuse'420	} else {421		assert err.msg().contains('changed while')422	}423	// The agent's save is still head, and a second steal combines it too.424	assert history.head(b)!.message == 'agent saves during the question'425	report := steal(b, mut backend, StealOptions{426		mine: true427	})!428	assert report.kept_mine == 1429	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'430	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'431}432433fn test_clashing_files_are_chosen_one_at_a_time() {434	base := scratch('clash')!435	defer {436		os.rmdir_all(base) or {}437	}438	mut backend := remote.new_folder(os.join_path(base, 'remote'))!439	mut a := open_repo(machine('clash_a')!)!440	defer {441		a.close()442	}443	mut b := open_repo(machine('clash_b')!)!444	defer {445		b.close()446	}447	put(a.root, 'same.txt', 'base\n')!448	put(a.root, 'other.txt', 'quiet\n')!449	commit(a, 'base')!450	offer_now(a, mut backend)!451	join(b, mut backend)!452453	// Both machines edit the same file, differently.454	put(a.root, 'same.txt', 'A version\n')!455	commit(a, 'A edits same')!456	offer_now(a, mut backend)!457	put(b.root, 'same.txt', 'B version\n')!458	commit(b, 'B edits same')!459460	// Without a decision, steal refuses and says which files clash.461	if _ := steal(b, mut backend, StealOptions{}) {462		assert false, 'an undecided clash must be refused'463	} else {464		assert err.msg().contains('--mine') || err.msg().contains('--theirs')465	}466	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,467		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0468469	// Choosing the remote's version ends with A's content.470	report := steal_now(b, mut backend)!471	assert report.clashes == ['same.txt']472	assert report.kept_mine == 0473	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'474	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'475}476477fn test_choosing_mine_keeps_the_local_version() {478	base := scratch('mine')!479	defer {480		os.rmdir_all(base) or {}481	}482	mut backend := remote.new_folder(os.join_path(base, 'remote'))!483	mut a := open_repo(machine('mine_a')!)!484	defer {485		a.close()486	}487	mut b := open_repo(machine('mine_b')!)!488	defer {489		b.close()490	}491	put(a.root, 'same.txt', 'base\n')!492	commit(a, 'base')!493	offer_now(a, mut backend)!494	join(b, mut backend)!495	put(a.root, 'same.txt', 'A version\n')!496	commit(a, 'A edits')!497	offer_now(a, mut backend)!498	put(b.root, 'same.txt', 'B version\n')!499	commit(b, 'B edits')!500501	report := steal(b, mut backend, StealOptions{502		mine: true503	})!504	assert report.kept_mine == 1505	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'506	// The final save says what it kept.507	assert history.head(b)!.message.contains('combine: kept mine for 1 file')508	// The pre-combine tree is still reachable in history.509	assert history.head(b)!.restores == ''510	assert history.all_saves(b)!.len >= 4511}512513fn test_flags_travel_with_the_saves() {514	base := scratch('flags')!515	defer {516		os.rmdir_all(base) or {}517	}518	mut backend := remote.new_folder(os.join_path(base, 'remote'))!519	mut a := open_repo(machine('flag_a')!)!520	defer {521		a.close()522	}523	mut b := open_repo(machine('flag_b')!)!524	defer {525		b.close()526	}527	put(a.root, 'a.txt', 'one\n')!528	save := commit(a, 'suspect save')!529	events.flag(a, save.id, 'look at this')!530	offer_now(a, mut backend)!531532	// B has nothing yet, so a fetch brings the save and its flag across.533	fetched := fetch_back_to_known(b, mut backend, 'proj')!534	assert fetched.events_out.len == 1535	assert fetched.events_out[0].kind == events.kind_flag536	b.begin()!537	write_fetched(b, fetched)!538	history.set_head(b, fetched.head)!539	b.commit()!540	state := events.flag_state(b, save.id)!541	assert state.flagged542	assert state.reason == 'look at this'543}544545fn test_a_second_offer_racing_head_is_refused_not_forced() {546	base := scratch('race')!547	defer {548		os.rmdir_all(base) or {}549	}550	mut backend := remote.new_folder(os.join_path(base, 'remote'))!551	mut a := open_repo(machine('race_a')!)!552	defer {553		a.close()554	}555	put(a.root, 'a.txt', 'one\n')!556	commit(a, 'base')!557	offer_now(a, mut backend)!558559	// Someone else replaces head behind our back.560	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),561		backend.get(head_key('proj'))!.tag)!562563	put(a.root, 'a.txt', 'two\n')!564	commit(a, 'second')!565	if _ := offer_now(a, mut backend) {566		assert false, 'offering while the remote head moved must be refused'567	} else {568		assert err.msg().contains('run koh steal')569	}570}571572// join makes a brand new machine share the remote's line.573fn join(store &storage.Store, mut backend remote.Backend) ! {574	fetched := fetch_repo(mut backend, 'proj')!575	store.begin()!576	write_fetched(store, fetched)!577	history.set_head(store, fetched.head)!578	store.commit()!579	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,580		fetched.head)!, [])!581	store.set_state('remote_repo', 'proj')!582}583584struct FaultyBackend {585mut:586	inner      remote.Backend587	writes     int588	reads      int589	fail_write int // fail when the write counter reaches this, 0 never590	fail_read  int591	// fail every delete while set592	fail_delete bool593}594595fn (mut f FaultyBackend) get(key string) !remote.Object {596	f.reads++597	if f.fail_read > 0 && f.reads >= f.fail_read {598		return error('injected read failure')599	}600	return f.inner.get(key)!601}602603fn (mut f FaultyBackend) list(prefix string) ![]string {604	return f.inner.list(prefix)!605}606607fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {608	f.writes++609	if f.fail_write > 0 && f.writes >= f.fail_write {610		return error('injected write failure')611	}612	return f.inner.put_if_absent(key, body)!613}614615fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {616	f.writes++617	if f.fail_write > 0 && f.writes >= f.fail_write {618		return error('injected write failure')619	}620	return f.inner.put_if_match(key, body, tag)!621}622623fn (mut f FaultyBackend) delete(key string) ! {624	if f.fail_delete {625		return error('injected delete failure')626	}627	f.inner.delete(key)!628}629630fn test_a_failed_offer_is_safe_to_re_run() {631	base := scratch('fault_offer')!632	defer {633		os.rmdir_all(base) or {}634	}635	remote_dir := os.join_path(base, 'remote')636	mut a := open_repo(machine('fault_a')!)!637	defer {638		a.close()639	}640	put(a.root, 'a.txt', 'one\n')!641	commit(a, 'first')!642	put(a.root, 'a.txt', 'two\n')!643	commit(a, 'second')!644645	// The first offer dies after the first write.646	mut flaky := FaultyBackend{647		inner:      remote.new_folder(remote_dir)!648		fail_write: 2649	}650	if _ := offer(a, mut flaky) {651		assert false, 'the injected failure should have surfaced'652	}653654	// Re-running with a healthy remote finishes the job: "already exists" is655	// success, so nothing is lost and nothing is duplicated.656	mut healthy := remote.new_folder(remote_dir)!657	report := offer(a, mut healthy)!658	assert report.saves == 2659	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id660	assert healthy.list('proj/saves')!.len == 2661}662663fn test_a_failed_steal_leaves_nothing_behind() {664	base := scratch('fault_steal')!665	defer {666		os.rmdir_all(base) or {}667	}668	remote_dir := os.join_path(base, 'remote')669	mut a := open_repo(machine('fault_s_a')!)!670	defer {671		a.close()672	}673	mut b := open_repo(machine('fault_s_b')!)!674	defer {675		b.close()676	}677	put(a.root, 'a.txt', 'from A\n')!678	commit(a, 'A writes')!679	mut healthy := remote.new_folder(remote_dir)!680	offer(a, mut healthy)!681682	// B's fetch fails half way through reading the remote.683	mut flaky := FaultyBackend{684		inner:     remote.new_folder(remote_dir)!685		fail_read: 2686	}687	if _ := fetch_back_to_known(b, mut flaky, 'proj') {688		assert false, 'the injected read failure should have surfaced'689	}690	// Nothing was written: the store is untouched and still has no saves.691	assert history.head(b) == none692	assert b.object_count()! == 0693694	// A healthy retry works.695	fetched := fetch_back_to_known(b, mut healthy, 'proj')!696	b.begin()!697	write_fetched(b, fetched)!698	history.set_head(b, fetched.head)!699	b.commit()!700	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!701	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'702}703704fn test_an_interrupted_strip_is_idempotent() {705	// The local half of a strip is one transaction, so an interrupted strip is706	// simply a strip that did not happen; re-running it must land the same707	// result once, not twice.708	base := scratch('fault_strip')!709	defer {710		os.rmdir_all(base) or {}711	}712	mut a := open_repo(machine('fault_strip_a')!)!713	defer {714		a.close()715	}716	put(a.root, 'secret.txt', 'shh\n')!717	put(a.root, 'keep.txt', 'fine\n')!718	save := commit(a, 'add both')!719720	for _ in 0 .. 2 {721		strip_local(a, 'secret.txt', '')!722	}723	files := history.files_of(a, save.id)!724	// The repo's own `.kohignore` is tracked content, so it stays; only the725	// stripped path goes.726	assert files.map(it.path) == ['.kohignore', 'keep.txt']727	assert events.strip_events(a)!.len == 1728}729730// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into731// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.732// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,733// but a save made on Linux can hold both.734fn test_a_strip_matches_the_path_exactly() {735	mut a := open_repo(machine('strip_exact')!)!736	defer {737		a.close()738	}739	hash := a.put_object('x\n'.bytes())!740	mut files := []snapshot.FileEntry{}741	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {742		files << snapshot.FileEntry{743			path: path744			hash: hash745		}746	}747	save := history.commit_files(a, '', files, history.SaveInput{748		message: 'add all'749	})!750	assert strip_scope(a, 'docs', '')!.files == 1751	strip_local(a, 'docs', '')!752	strip_local(a, 'a_b', '')!753	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']754}755756fn test_a_strip_reaches_the_remote_and_other_machines() {757	base := scratch('strip_sync')!758	defer {759		os.rmdir_all(base) or {}760	}761	mut a := open_repo(machine('strip_a')!)!762	defer {763		a.close()764	}765	mut b := open_repo(machine('strip_b')!)!766	defer {767		b.close()768	}769	put(a.root, 'secrets.env', 'api_key=1\n')!770	put(a.root, 'keep.txt', 'fine\n')!771	save := commit(a, 'add both')!772	mut backend := remote.new_folder(os.join_path(base, 'remote'))!773	offer_now(a, mut backend)!774	join(b, mut backend)!775	assert b.object_count()! > 0776777	// A strips the path locally, the way `koh strip` does, then tells the778	// remote.779	mut secret_hash := ''780	for file in history.files_of(a, save.id)! {781		if file.path == 'secrets.env' {782			secret_hash = file.hash783		}784	}785	assert secret_hash != ''786	strip_local(a, 'secrets.env', '')!787	report := push_strip(a, mut backend, 'secrets.env')!788	assert report.event_sent789	assert report.records == 1790791	// The remote's copy of the save no longer lists the file, and the object is792	// gone from the remote.793	record := decode_save(backend.get(save_key('proj', save.id))!.body)!794	for file in record.files {795		assert file.path != 'secrets.env'796	}797	// The stripped object is gone from the remote; the ones still referenced798	// are not.799	assert !backend.get(object_key('proj', secret_hash))!.found800	assert backend.list('proj/objects')!.len == 2801802	// Another machine hears about it on its next steal and applies it.803	b.begin()!804	fetched := fetch_back_to_known(b, mut backend, 'proj')!805	write_fetched(b, fetched)!806	b.commit()!807	mut paths := []string{}808	for file in history.files_of(b, save.id)! {809		paths << file.path810	}811	assert 'secrets.env' !in paths812	assert 'keep.txt' in paths813814	// The event arrives under its own ID, so it is applied once: B's offer adds815	// no second copy, and a file B saves again after the strip stays saved816	// through B's next steal.817	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)818	put(b.root, 'secrets.env', 'rotated\n')!819	readded := commit(b, 'add the rotated key back')!820	offer_now(b, mut backend)!821	assert backend.list('proj/events')!.len == 1822	b.begin()!823	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!824	b.commit()!825	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)826}827828// A strip removes a path from the saves that existed when it was made. A key829// rotated and saved again afterwards is new work: a machine that clones the830// repo later must get it, not strip it on arrival.831fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {832	base := scratch('strip_clone')!833	defer {834		os.rmdir_all(base) or {}835	}836	mut backend := remote.new_folder(os.join_path(base, 'remote'))!837	mut a := open_repo(machine('strip_clone_a')!)!838	defer {839		a.close()840	}841	put(a.root, 'secrets.env', 'api_key=1\n')!842	old := commit(a, 'leak the key')!843	offer_now(a, mut backend)!844	strip_local(a, 'secrets.env', '')!845	push_strip(a, mut backend, 'secrets.env')!846	// Saves after the strip must be later in time, as they would be for real.847	put(a.root, 'secrets.env', 'api_key=2\n')!848	rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{849		message:    'rotate the key'850		created_at: events.strip_events(a)!.last().created_at + 60851	})!852	offer_now(a, mut backend)!853854	mut c := open_repo(machine('strip_clone_c')!)!855	defer {856		c.close()857	}858	join(c, mut backend)!859	assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)860	assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)861	assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'862}863864// The local half commits before the remote hears anything, so a remote failure865// leaves nothing to find locally; the pending note is what lets a re-run866// finish. Objects a remote-only save still needs must survive the strip.867fn test_an_interrupted_remote_strip_finishes_when_re_run() {868	base := scratch('strip_resume')!869	defer {870		os.rmdir_all(base) or {}871	}872	remote_dir := os.join_path(base, 'remote')873	mut backend := remote.new_folder(remote_dir)!874	mut a := open_repo(machine('resume_a')!)!875	defer {876		a.close()877	}878	mut b := open_repo(machine('resume_b')!)!879	defer {880		b.close()881	}882	put(a.root, 'secrets.env', 'api_key=1\n')!883	put(a.root, 'shared.env', 'same bytes on B\n')!884	save := commit(a, 'add secrets')!885	offer_now(a, mut backend)!886	join(b, mut backend)!887	put(b.root, 'copy.txt', 'same bytes on B\n')!888	b_save := commit(b, 'B keeps a copy')!889	offer_now(b, mut backend)!890	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())891	secret_hash := storage.hash_hex('api_key=1\n'.bytes())892893	strip_local(a, 'secrets.env', '')!894	// Write 1 uploads the event and write 2 rewrites the first record; B's save,895	// made from A's tree, lists the file too, and its rewrite (write 3) fails.896	mut flaky := FaultyBackend{897		inner:      remote.new_folder(remote_dir)!898		fail_write: 3899	}900	if _ := push_strip(a, mut flaky, 'secrets.env') {901		assert false, 'the injected failure should have surfaced'902	}903	assert strip_pending(a, 'secrets.env')904	assert strip_local(a, 'secrets.env', '')!.files_removed == 0905	report := push_strip(a, mut backend, 'secrets.env')!906	assert report.records == 1907	assert !strip_pending(a, 'secrets.env')908	assert !backend.get(object_key('proj', secret_hash))!.found909	record := decode_save(backend.get(save_key('proj', save.id))!.body)!910	assert 'secrets.env' !in record.files.map(it.path)911	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!912	assert 'secrets.env' !in b_record.files.map(it.path)913914	// B's save still names the shared bytes, so stripping A's copy keeps them.915	strip_local(a, 'shared.env', '')!916	push_strip(a, mut backend, 'shared.env')!917	assert backend.get(object_key('proj', shared_hash))!.found918}919920// A machine that has not heard of a strip must not offer: its records still921// list the stripped path, and nothing would rewrite them on the remote again.922fn test_offer_waits_for_an_unapplied_strip() {923	base := scratch('strip_first')!924	defer {925		os.rmdir_all(base) or {}926	}927	mut backend := remote.new_folder(os.join_path(base, 'remote'))!928	mut a := open_repo(machine('strip_first_a')!)!929	defer {930		a.close()931	}932	mut b := open_repo(machine('strip_first_b')!)!933	defer {934		b.close()935	}936	put(a.root, 'secrets.env', 'api_key=1\n')!937	commit(a, 'add secrets')!938	offer_now(a, mut backend)!939	join(b, mut backend)!940	strip_local(a, 'secrets.env', '')!941	push_strip(a, mut backend, 'secrets.env')!942	put(b.root, 'more.txt', 'B keeps working\n')!943	later := commit(b, 'B saves with the secret still in its tree')!944	if _ := offer_now(b, mut backend) {945		assert false, 'B offered records that still list the stripped path'946	}947	steal_now(b, mut backend)!948	offer_now(b, mut backend)!949	record := decode_save(backend.get(save_key('proj', later.id))!.body)!950	assert 'secrets.env' !in record.files.map(it.path)951}952953// A strip whose object deletions failed, followed by a second strip of the same954// path, must still delete the first strip's objects when it finishes.955fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {956	base := scratch('strip_twice')!957	defer {958		os.rmdir_all(base) or {}959	}960	remote_dir := os.join_path(base, 'remote')961	mut backend := remote.new_folder(remote_dir)!962	mut a := open_repo(machine('strip_twice_a')!)!963	defer {964		a.close()965	}966	put(a.root, 'secrets.env', 'first key\n')!967	commit(a, 'first key')!968	offer_now(a, mut backend)!969	strip_local(a, 'secrets.env', '')!970	mut flaky := FaultyBackend{971		inner:       remote.new_folder(remote_dir)!972		fail_delete: true973	}974	if _ := push_strip(a, mut flaky, 'secrets.env') {975		assert false, 'the injected delete failure should have surfaced'976	}977	put(a.root, 'secrets.env', 'second key\n')!978	commit(a, 'second key')!979	offer_now(a, mut backend)!980	strip_local(a, 'secrets.env', '')!981	push_strip(a, mut backend, 'secrets.env')!982	for key in ['first key\n', 'second key\n'] {983		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found984	}985}986987// Unfinished strips of one path in different scopes finish together.988fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {989	base := scratch('strip_scopes')!990	defer {991		os.rmdir_all(base) or {}992	}993	remote_dir := os.join_path(base, 'remote')994	mut backend := remote.new_folder(remote_dir)!995	mut a := open_repo(machine('strip_scopes_a')!)!996	defer {997		a.close()998	}999	put(a.root, 'secrets.env', 'first key\n')!1000	first := commit(a, 'first key')!1001	put(a.root, 'secrets.env', 'second key\n')!1002	second := commit(a, 'second key')!1003	offer_now(a, mut backend)!1004	strip_local(a, 'secrets.env', first.id)!1005	// Write 1 uploads the event; write 2, the record rewrite, fails.1006	mut flaky := FaultyBackend{1007		inner:      remote.new_folder(remote_dir)!1008		fail_write: 21009	}1010	if _ := push_strip(a, mut flaky, 'secrets.env') {1011		assert false, 'the injected failure should have surfaced'1012	}1013	strip_local(a, 'secrets.env', second.id)!1014	push_strip(a, mut backend, 'secrets.env')!1015	assert !strip_pending(a, 'secrets.env')1016	for save in [first, second] {1017		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1018		assert 'secrets.env' !in record.files.map(it.path)1019	}1020	for key in ['first key\n', 'second key\n'] {1021		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1022	}1023}10241025// Stripping a path, re-adding it, and stripping it again is two strips; a1026// machine that heard about the first must still apply the second.1027fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1028	base := scratch('strip_again')!1029	defer {1030		os.rmdir_all(base) or {}1031	}1032	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1033	mut a := open_repo(machine('strip_again_a')!)!1034	defer {1035		a.close()1036	}1037	mut b := open_repo(machine('strip_again_b')!)!1038	defer {1039		b.close()1040	}1041	put(a.root, 'secrets.env', 'first key\n')!1042	commit(a, 'first key')!1043	offer_now(a, mut backend)!1044	join(b, mut backend)!1045	strip_local(a, 'secrets.env', '')!1046	push_strip(a, mut backend, 'secrets.env')!1047	steal_now(b, mut backend)!1048	// Strip leaves the file on disk; deleting it is the user's part.1049	os.rm(os.join_path(b.root, 'secrets.env'))!1050	put(a.root, 'secrets.env', 'second key\n')!1051	second := commit(a, 'second key')!1052	offer_now(a, mut backend)!1053	steal_now(b, mut backend)!1054	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1055	strip_local(a, 'secrets.env', '')!1056	push_strip(a, mut backend, 'secrets.env')!1057	steal_now(b, mut backend)!1058	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1059}10601061// A clash on a path the other machine stripped is no clash: the strip removes1062// the path from every save, so there is nothing to choose.1063fn test_a_stripped_path_is_not_asked_about() {1064	base := scratch('clash_strip')!1065	defer {1066		os.rmdir_all(base) or {}1067	}1068	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1069	mut a := open_repo(machine('clash_strip_a')!)!1070	defer {1071		a.close()1072	}1073	mut b := open_repo(machine('clash_strip_b')!)!1074	defer {1075		b.close()1076	}1077	put(a.root, 'secrets.env', 'key one\n')!1078	commit(a, 'base')!1079	offer_now(a, mut backend)!1080	join(b, mut backend)!1081	put(a.root, 'secrets.env', 'key two\n')!1082	commit(a, 'A rotates')!1083	offer_now(a, mut backend)!1084	strip_local(a, 'secrets.env', '')!1085	push_strip(a, mut backend, 'secrets.env')!1086	put(b.root, 'secrets.env', 'key three\n')!1087	mine := commit(b, 'B rotates')!1088	report := steal(b, mut backend, StealOptions{})!1089	assert report.clashes.len == 01090	for save in history.all_saves(b)! {1091		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1092	}1093	assert history.by_id(b, mine.id) != none1094}10951096// A strip scoped to the shared base save changes what the replay sees; the1097// clashes must be found on the same trees, or the replay meets a conflict.1098fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1099	base := scratch('clash_base_strip')!1100	defer {1101		os.rmdir_all(base) or {}1102	}1103	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1104	mut a := open_repo(machine('base_strip_a')!)!1105	defer {1106		a.close()1107	}1108	mut b := open_repo(machine('base_strip_b')!)!1109	defer {1110		b.close()1111	}1112	put(a.root, 'notes.txt', 'shared\n')!1113	common := commit(a, 'base')!1114	offer_now(a, mut backend)!1115	join(b, mut backend)!1116	put(a.root, 'other.txt', 'A moves on\n')!1117	commit(a, 'A moves on')!1118	offer_now(a, mut backend)!1119	strip_local(a, 'notes.txt', common.id)!1120	push_strip(a, mut backend, 'notes.txt')!1121	put(b.root, 'notes.txt', 'B edits\n')!1122	commit(b, 'B edits notes')!1123	if _ := steal(b, mut backend, StealOptions{}) {1124		assert false, 'with the base stripped, notes.txt differs on both sides'1125	} else {1126		assert err.msg().contains('changed on both machines')1127	}1128	report := steal(b, mut backend, StealOptions{1129		mine: true1130	})!1131	assert report.clashes == ['notes.txt']1132	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1133}11341135// A folder with files but no saves has nothing to combine with; a steal that1136// fast-forwarded into it would overwrite whatever shares a name.1137fn test_steal_into_a_folder_with_unsaved_files_refuses() {1138	base := scratch('steal_unsaved')!1139	defer {1140		os.rmdir_all(base) or {}1141	}1142	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1143	mut a := open_repo(machine('unsaved_a')!)!1144	defer {1145		a.close()1146	}1147	put(a.root, 'notes.txt', 'from A\n')!1148	commit(a, 'A writes')!1149	offer_now(a, mut backend)!1150	b_root := machine('unsaved_b')!1151	mut b := storage.create(b_root)!1152	defer {1153		b.close()1154	}1155	put(b_root, 'notes.txt', 'never saved\n')!1156	// Only a repo that already knows its remote name gets this far.1157	b.set_state('remote_repo', 'proj')!1158	if _ := steal(b, mut backend, StealOptions{}) {1159		assert false, 'steal overwrote a folder with unsaved files'1160	} else {1161		assert err.msg() == no_saves_here1162	}1163	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1164	// A fresh `koh init` folder, holding only the default ignore list, is fine.1165	os.rm(os.join_path(b_root, 'notes.txt'))!1166	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1167	steal(b, mut backend, StealOptions{})!1168	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1169}11701171// The document asks steal to warn about every incoming flagged save, so the1172// report names them.1173fn test_steal_reports_incoming_flagged_saves() {1174	base := scratch('flag_warn')!1175	defer {1176		os.rmdir_all(base) or {}1177	}1178	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1179	mut a := open_repo(machine('flag_warn_a')!)!1180	defer {1181		a.close()1182	}1183	mut b := open_repo(machine('flag_warn_b')!)!1184	defer {1185		b.close()1186	}1187	put(a.root, 'a.txt', 'one\n')!1188	commit(a, 'fine')!1189	offer_now(a, mut backend)!1190	join(b, mut backend)!1191	put(a.root, 'a.txt', 'two\n')!1192	suspect := commit(a, 'suspect')!1193	put(a.root, 'a.txt', 'three\n')!1194	commit(a, 'also fine')!1195	events.flag(a, suspect.id, 'broke the build')!1196	offer_now(a, mut backend)!1197	report := steal_now(b, mut backend)!1198	assert report.flagged.map(it.save_id) == [suspect.id]1199	assert report.flagged[0].reason == 'broke the build'1200}