sync_test.v

37.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)!.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	// Nothing was restored, so nothing is left marked as half-restored.161	assert !snapshot.restore_pending(a)162	assert history.all_saves(a)!.len == 2163	offer_now(a, mut backend)!164	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id165}166167// A link is invisible to the walk, so a tree holding one is clean; the files168// a steal brings in must still never land in the folder it points at.169fn test_steal_never_writes_through_a_symlinked_folder() {170	base := scratch('steal_link')!171	defer {172		os.rmdir_all(base) or {}173	}174	mut backend := remote.new_folder(os.join_path(base, 'remote'))!175	mut a := open_repo(machine('steal_link_a')!)!176	defer {177		a.close()178	}179	mut b := open_repo(machine('steal_link_b')!)!180	defer {181		b.close()182	}183	put(a.root, 'a.txt', 'one\n')!184	commit(a, 'first')!185	offer_now(a, mut backend)!186	join(b, mut backend)!187	outside := os.join_path(base, 'outside')188	put(outside, 'a.md', 'precious\n')!189	os.symlink(outside, os.join_path(b.root, 'docs'))!190	put(a.root, 'docs/a.md', 'from A\n')!191	commit(a, 'A adds docs')!192	offer_now(a, mut backend)!193	head_before := history.head(b)!.id194	if _ := steal_now(b, mut backend) {195		assert false, 'a steal writing through a symlinked folder must refuse'196	} else {197		assert err.msg().contains('docs')198	}199	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'200	assert history.head(b)!.id == head_before201}202203// A steal onto a half-restored tree would replay it as somebody's work.204fn test_steal_refuses_after_a_restore_cut_short() {205	base := scratch('steal_cut')!206	defer {207		os.rmdir_all(base) or {}208	}209	mut backend := remote.new_folder(os.join_path(base, 'remote'))!210	mut a := open_repo(machine('steal_cut_a')!)!211	defer {212		a.close()213	}214	put(a.root, 'a.txt', 'one\n')!215	commit(a, 'first')!216	offer_now(a, mut backend)!217	a.set_state('restoring', 'yes')!218	if _ := steal_now(a, mut backend) {219		assert false, 'steal must refuse while a restore is unfinished'220	} else {221		assert err.msg().contains('koh revert')222	}223}224225// A replayed save keeps its original time but sits on a newer parent, so a226// clone cannot order saves by time.227fn test_a_clone_imports_parents_before_children() {228	base := scratch('clone_order')!229	defer {230		os.rmdir_all(base) or {}231	}232	mut backend := remote.new_folder(os.join_path(base, 'remote'))!233	mut a := open_repo(machine('order_a')!)!234	defer {235		a.close()236	}237	put(a.root, 'a.txt', 'one\n')!238	parent := commit(a, 'parent')!239	child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{240		message:    'replayed, older than its parent'241		created_at: parent.created_at - 3600242	})!243	offer_now(a, mut backend)!244	mut b := open_repo(machine('order_b')!)!245	defer {246		b.close()247	}248	join(b, mut backend)!249	assert history.head(b)!.id == child.id250}251252// A chmod is unsaved work too: a steal would restore the old mode over it.253fn test_steal_refuses_an_unsaved_executable_bit_change() {254	base := scratch('chmod')!255	defer {256		os.rmdir_all(base) or {}257	}258	mut backend := remote.new_folder(os.join_path(base, 'remote'))!259	mut a := open_repo(machine('chmod_a')!)!260	defer {261		a.close()262	}263	put(a.root, 'run.sh', 'echo hi\n')!264	commit(a, 'script')!265	offer_now(a, mut backend)!266	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!267	if _ := steal_now(a, mut backend) {268		assert false, 'a chmod-only change should make the tree dirty'269	} else {270		assert err.msg() == dirty_tree271	}272}273274fn test_divergence_without_clashes_keeps_both_machines_work() {275	base := scratch('diverge')!276	defer {277		os.rmdir_all(base) or {}278	}279	mut backend := remote.new_folder(os.join_path(base, 'remote'))!280	mut a := open_repo(machine('div_a')!)!281	defer {282		a.close()283	}284	mut b := open_repo(machine('div_b')!)!285	defer {286		b.close()287	}288	put(a.root, 'shared.txt', 'base\n')!289	commit(a, 'base')!290	offer_now(a, mut backend)!291292	// B joins, then both machines change a different file.293	join(b, mut backend)!294	put(a.root, 'fromA.txt', 'A\n')!295	commit(a, 'A adds a file')!296	put(b.root, 'fromB.txt', 'B\n')!297	commit(b, 'B adds a file')!298	offer_now(a, mut backend)!299300	// B's offer is refused: the remote has moved on.301	if _ := offer_now(b, mut backend) {302		assert false, 'offering on top of unknown remote saves should be refused'303	} else {304		assert err.msg().contains('run koh steal')305	}306307	report := steal_now(b, mut backend)!308	assert !report.forward309	assert report.replayed == 1310	assert report.clashes.len == 0311	// Both files are on B's line now, and on disk.312	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'313	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'314	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'315	// B's own save was replayed, not erased.316	mut saw_replay := false317	for save in history.all_saves(b)! {318		if save.replayed_from != '' {319			saw_replay = true320		}321	}322	assert saw_replay323	// And the remote now has B's work too.324	offer_now(b, mut backend)!325	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()326	assert line_head == history.head(b)!.id327}328329// An agent that edits a file and then undoes the edit leaves no net change, so330// the file is not a clash when the other machine changed it. Replaying the331// edit itself must not stop the steal: the remote's version simply stands.332// Likewise a file both machines ended up changing the same way.333fn test_a_local_change_undone_does_not_block_a_combine() {334	base := scratch('undone')!335	defer {336		os.rmdir_all(base) or {}337	}338	mut backend := remote.new_folder(os.join_path(base, 'remote'))!339	mut a := open_repo(machine('undone_a')!)!340	defer {341		a.close()342	}343	mut b := open_repo(machine('undone_b')!)!344	defer {345		b.close()346	}347	put(a.root, 'f.txt', 'base\n')!348	put(a.root, 'g.txt', 'base\n')!349	commit(a, 'base')!350	offer_now(a, mut backend)!351	join(b, mut backend)!352353	put(a.root, 'f.txt', 'from A\n')!354	put(a.root, 'g.txt', 'same\n')!355	commit(a, 'A edits both')!356	offer_now(a, mut backend)!357358	put(b.root, 'f.txt', 'tried on B\n')!359	put(b.root, 'g.txt', 'first try\n')!360	tried := commit(b, 'B tries')!361	put(b.root, 'f.txt', 'base\n')!362	put(b.root, 'g.txt', 'same\n')!363	put(b.root, 'h.txt', 'B\n')!364	undone := commit(b, 'B undoes f, lands on the same g, adds h')!365366	report := steal_now(b, mut backend)!367	assert report.clashes.len == 0368	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'369	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'370	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'371	// "B tries" only touched files A changed, so it has nothing left to372	// replay; the second save carries h.txt. Both originals stay in history.373	assert report.replayed == 1374	assert history.head(b)!.replayed_from == undone.id375	ids := history.all_saves(b)!.map(it.id)376	assert tried.id in ids && undone.id in ids377	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')378}379380// A person deciding a clash may take minutes; the question must come before381// the write lock, or every agent's save on this repo waits and then fails.382fn test_clash_questions_are_asked_before_the_write_lock() {383	base := scratch('ask_unlocked')!384	defer {385		os.rmdir_all(base) or {}386	}387	mut backend := remote.new_folder(os.join_path(base, 'remote'))!388	mut a := open_repo(machine('ask_a')!)!389	defer {390		a.close()391	}392	mut b := open_repo(machine('ask_b')!)!393	defer {394		b.close()395	}396	put(a.root, 'same.txt', 'base\n')!397	commit(a, 'base')!398	offer_now(a, mut backend)!399	join(b, mut backend)!400	put(a.root, 'same.txt', 'A version\n')!401	commit(a, 'A edits same')!402	offer_now(a, mut backend)!403	put(b.root, 'same.txt', 'B version\n')!404	commit(b, 'B edits same')!405	root := b.root406	// An agent saves while the person is still choosing.407	if _ := steal(b, mut backend, StealOptions{408		ask: fn [root] (clash Clash) Side {409			other := storage.open(root) or { panic(err) }410			defer {411				other.close()412			}413			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }414			history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{415				message: 'agent saves during the question'416			}) or { panic('steal held the write lock while asking: ${err}') }417			return .mine418		}419	})420	{421		assert false, 'a steal whose line moved during the question must refuse'422	} else {423		assert err.msg().contains('changed while')424	}425	// The agent's save is still head, and a second steal combines it too.426	assert history.head(b)!.message == 'agent saves during the question'427	report := steal(b, mut backend, StealOptions{428		mine: true429	})!430	assert report.kept_mine == 1431	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'432	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'433}434435fn test_clashing_files_are_chosen_one_at_a_time() {436	base := scratch('clash')!437	defer {438		os.rmdir_all(base) or {}439	}440	mut backend := remote.new_folder(os.join_path(base, 'remote'))!441	mut a := open_repo(machine('clash_a')!)!442	defer {443		a.close()444	}445	mut b := open_repo(machine('clash_b')!)!446	defer {447		b.close()448	}449	put(a.root, 'same.txt', 'base\n')!450	put(a.root, 'other.txt', 'quiet\n')!451	commit(a, 'base')!452	offer_now(a, mut backend)!453	join(b, mut backend)!454455	// Both machines edit the same file, differently.456	put(a.root, 'same.txt', 'A version\n')!457	commit(a, 'A edits same')!458	offer_now(a, mut backend)!459	put(b.root, 'same.txt', 'B version\n')!460	commit(b, 'B edits same')!461462	// Without a decision, steal refuses and says which files clash.463	if _ := steal(b, mut backend, StealOptions{}) {464		assert false, 'an undecided clash must be refused'465	} else {466		assert err.msg().contains('--mine') || err.msg().contains('--theirs')467	}468	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,469		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0470471	// Choosing the remote's version ends with A's content.472	report := steal_now(b, mut backend)!473	assert report.clashes == ['same.txt']474	assert report.kept_mine == 0475	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'476	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'477}478479fn test_choosing_mine_keeps_the_local_version() {480	base := scratch('mine')!481	defer {482		os.rmdir_all(base) or {}483	}484	mut backend := remote.new_folder(os.join_path(base, 'remote'))!485	mut a := open_repo(machine('mine_a')!)!486	defer {487		a.close()488	}489	mut b := open_repo(machine('mine_b')!)!490	defer {491		b.close()492	}493	put(a.root, 'same.txt', 'base\n')!494	commit(a, 'base')!495	offer_now(a, mut backend)!496	join(b, mut backend)!497	put(a.root, 'same.txt', 'A version\n')!498	commit(a, 'A edits')!499	offer_now(a, mut backend)!500	put(b.root, 'same.txt', 'B version\n')!501	commit(b, 'B edits')!502503	report := steal(b, mut backend, StealOptions{504		mine: true505	})!506	assert report.kept_mine == 1507	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'508	// The final save says what it kept.509	assert history.head(b)!.message.contains('combine: kept mine for 1 file')510	// The pre-combine tree is still reachable in history.511	assert history.head(b)!.restores == ''512	assert history.all_saves(b)!.len >= 4513}514515fn test_flags_travel_with_the_saves() {516	base := scratch('flags')!517	defer {518		os.rmdir_all(base) or {}519	}520	mut backend := remote.new_folder(os.join_path(base, 'remote'))!521	mut a := open_repo(machine('flag_a')!)!522	defer {523		a.close()524	}525	mut b := open_repo(machine('flag_b')!)!526	defer {527		b.close()528	}529	put(a.root, 'a.txt', 'one\n')!530	save := commit(a, 'suspect save')!531	events.flag(a, save.id, 'look at this')!532	offer_now(a, mut backend)!533534	// B has nothing yet, so a fetch brings the save and its flag across.535	fetched := fetch_back_to_known(b, mut backend, 'proj')!536	assert fetched.events_out.len == 1537	assert fetched.events_out[0].kind == events.kind_flag538	b.begin()!539	write_fetched(b, fetched)!540	history.set_head(b, fetched.head)!541	b.commit()!542	state := events.flag_state(b, save.id)!543	assert state.flagged544	assert state.reason == 'look at this'545}546547fn test_a_second_offer_racing_head_is_refused_not_forced() {548	base := scratch('race')!549	defer {550		os.rmdir_all(base) or {}551	}552	mut backend := remote.new_folder(os.join_path(base, 'remote'))!553	mut a := open_repo(machine('race_a')!)!554	defer {555		a.close()556	}557	put(a.root, 'a.txt', 'one\n')!558	commit(a, 'base')!559	offer_now(a, mut backend)!560561	// Someone else replaces head behind our back.562	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),563		backend.get(head_key('proj'))!.tag)!564565	put(a.root, 'a.txt', 'two\n')!566	commit(a, 'second')!567	if _ := offer_now(a, mut backend) {568		assert false, 'offering while the remote head moved must be refused'569	} else {570		assert err.msg().contains('run koh steal')571	}572}573574// join makes a brand new machine share the remote's line.575fn join(store &storage.Store, mut backend remote.Backend) ! {576	fetched := fetch_repo(mut backend, 'proj')!577	store.begin()!578	write_fetched(store, fetched)!579	history.set_head(store, fetched.head)!580	store.commit()!581	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,582		fetched.head)!, [])!583	store.set_state('remote_repo', 'proj')!584}585586struct FaultyBackend {587mut:588	inner      remote.Backend589	writes     int590	reads      int591	fail_write int // fail when the write counter reaches this, 0 never592	fail_read  int593	// fail every delete while set594	fail_delete bool595}596597fn (mut f FaultyBackend) get(key string) !remote.Object {598	f.reads++599	if f.fail_read > 0 && f.reads >= f.fail_read {600		return error('injected read failure')601	}602	return f.inner.get(key)!603}604605fn (mut f FaultyBackend) list(prefix string) ![]string {606	return f.inner.list(prefix)!607}608609fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {610	f.writes++611	if f.fail_write > 0 && f.writes >= f.fail_write {612		return error('injected write failure')613	}614	return f.inner.put_if_absent(key, body)!615}616617fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {618	f.writes++619	if f.fail_write > 0 && f.writes >= f.fail_write {620		return error('injected write failure')621	}622	return f.inner.put_if_match(key, body, tag)!623}624625fn (mut f FaultyBackend) delete(key string) ! {626	if f.fail_delete {627		return error('injected delete failure')628	}629	f.inner.delete(key)!630}631632// Every save offers after it is made, so a one-file change must cost one633// object, not one conditional write per file in the repo: on S3 each of those634// is a round trip.635fn test_an_offer_sends_only_the_objects_a_save_adds() {636	base := scratch('offer_diff')!637	defer {638		os.rmdir_all(base) or {}639	}640	mut a := open_repo(machine('offer_diff_a')!)!641	defer {642		a.close()643	}644	for i in 0 .. 20 {645		put(a.root, 'f${i}.txt', 'file ${i}\n')!646	}647	commit(a, 'twenty files')!648	mut backend := FaultyBackend{649		inner: remote.new_folder(os.join_path(base, 'remote'))!650	}651	offer(a, mut backend)!652	put(a.root, 'f3.txt', 'changed\n')!653	commit(a, 'one change')!654	before := backend.writes655	report := offer(a, mut backend)!656	assert report.objects == 1657	// One object, one save record, one head swap.658	assert backend.writes - before == 3659}660661fn test_a_failed_offer_is_safe_to_re_run() {662	base := scratch('fault_offer')!663	defer {664		os.rmdir_all(base) or {}665	}666	remote_dir := os.join_path(base, 'remote')667	mut a := open_repo(machine('fault_a')!)!668	defer {669		a.close()670	}671	put(a.root, 'a.txt', 'one\n')!672	commit(a, 'first')!673	put(a.root, 'a.txt', 'two\n')!674	commit(a, 'second')!675676	// The first offer dies after the first write.677	mut flaky := FaultyBackend{678		inner:      remote.new_folder(remote_dir)!679		fail_write: 2680	}681	if _ := offer(a, mut flaky) {682		assert false, 'the injected failure should have surfaced'683	}684685	// Re-running with a healthy remote finishes the job: "already exists" is686	// success, so nothing is lost and nothing is duplicated.687	mut healthy := remote.new_folder(remote_dir)!688	report := offer(a, mut healthy)!689	assert report.saves == 2690	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id691	assert healthy.list('proj/saves')!.len == 2692}693694fn test_a_failed_steal_leaves_nothing_behind() {695	base := scratch('fault_steal')!696	defer {697		os.rmdir_all(base) or {}698	}699	remote_dir := os.join_path(base, 'remote')700	mut a := open_repo(machine('fault_s_a')!)!701	defer {702		a.close()703	}704	mut b := open_repo(machine('fault_s_b')!)!705	defer {706		b.close()707	}708	put(a.root, 'a.txt', 'from A\n')!709	commit(a, 'A writes')!710	mut healthy := remote.new_folder(remote_dir)!711	offer(a, mut healthy)!712713	// B's fetch fails half way through reading the remote.714	mut flaky := FaultyBackend{715		inner:     remote.new_folder(remote_dir)!716		fail_read: 2717	}718	if _ := fetch_back_to_known(b, mut flaky, 'proj') {719		assert false, 'the injected read failure should have surfaced'720	}721	// Nothing was written: the store is untouched and still has no saves.722	assert history.head(b) == none723	assert b.object_count()! == 0724725	// A healthy retry works.726	fetched := fetch_back_to_known(b, mut healthy, 'proj')!727	b.begin()!728	write_fetched(b, fetched)!729	history.set_head(b, fetched.head)!730	b.commit()!731	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!732	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'733}734735fn test_an_interrupted_strip_is_idempotent() {736	// The local half of a strip is one transaction, so an interrupted strip is737	// simply a strip that did not happen; re-running it must land the same738	// result once, not twice.739	base := scratch('fault_strip')!740	defer {741		os.rmdir_all(base) or {}742	}743	mut a := open_repo(machine('fault_strip_a')!)!744	defer {745		a.close()746	}747	put(a.root, 'secret.txt', 'shh\n')!748	put(a.root, 'keep.txt', 'fine\n')!749	save := commit(a, 'add both')!750751	for _ in 0 .. 2 {752		strip_local(a, 'secret.txt', '')!753	}754	files := history.files_of(a, save.id)!755	// The repo's own `.kohignore` is tracked content, so it stays; only the756	// stripped path goes.757	assert files.map(it.path) == ['.kohignore', 'keep.txt']758	assert events.strip_events(a)!.len == 1759}760761// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into762// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.763// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,764// but a save made on Linux can hold both.765fn test_a_strip_matches_the_path_exactly() {766	mut a := open_repo(machine('strip_exact')!)!767	defer {768		a.close()769	}770	hash := a.put_object('x\n'.bytes())!771	mut files := []snapshot.FileEntry{}772	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {773		files << snapshot.FileEntry{774			path: path775			hash: hash776		}777	}778	save := history.commit_files(a, '', files, history.SaveInput{779		message: 'add all'780	})!781	assert strip_scope(a, 'docs', '')!.files == 1782	strip_local(a, 'docs', '')!783	strip_local(a, 'a_b', '')!784	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']785}786787fn test_a_strip_reaches_the_remote_and_other_machines() {788	base := scratch('strip_sync')!789	defer {790		os.rmdir_all(base) or {}791	}792	mut a := open_repo(machine('strip_a')!)!793	defer {794		a.close()795	}796	mut b := open_repo(machine('strip_b')!)!797	defer {798		b.close()799	}800	put(a.root, 'secrets.env', 'api_key=1\n')!801	put(a.root, 'keep.txt', 'fine\n')!802	save := commit(a, 'add both')!803	mut backend := remote.new_folder(os.join_path(base, 'remote'))!804	offer_now(a, mut backend)!805	join(b, mut backend)!806	assert b.object_count()! > 0807808	// A strips the path locally, the way `koh strip` does, then tells the809	// remote.810	mut secret_hash := ''811	for file in history.files_of(a, save.id)! {812		if file.path == 'secrets.env' {813			secret_hash = file.hash814		}815	}816	assert secret_hash != ''817	strip_local(a, 'secrets.env', '')!818	report := push_strip(a, mut backend, 'secrets.env')!819	assert report.event_sent820	assert report.records == 1821822	// The remote's copy of the save no longer lists the file, and the object is823	// gone from the remote.824	record := decode_save(backend.get(save_key('proj', save.id))!.body)!825	for file in record.files {826		assert file.path != 'secrets.env'827	}828	// The stripped object is gone from the remote; the ones still referenced829	// are not.830	assert !backend.get(object_key('proj', secret_hash))!.found831	assert backend.list('proj/objects')!.len == 2832833	// Another machine hears about it on its next steal and applies it.834	b.begin()!835	fetched := fetch_back_to_known(b, mut backend, 'proj')!836	write_fetched(b, fetched)!837	b.commit()!838	mut paths := []string{}839	for file in history.files_of(b, save.id)! {840		paths << file.path841	}842	assert 'secrets.env' !in paths843	assert 'keep.txt' in paths844845	// The event arrives under its own ID, so it is applied once: B's offer adds846	// no second copy, and a file B saves again after the strip stays saved847	// through B's next steal.848	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)849	put(b.root, 'secrets.env', 'rotated\n')!850	readded := commit(b, 'add the rotated key back')!851	offer_now(b, mut backend)!852	assert backend.list('proj/events')!.len == 1853	b.begin()!854	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!855	b.commit()!856	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)857}858859// A strip removes a path from the saves that existed when it was made. A key860// rotated and saved again afterwards is new work: a machine that clones the861// repo later must get it, not strip it on arrival.862fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {863	base := scratch('strip_clone')!864	defer {865		os.rmdir_all(base) or {}866	}867	mut backend := remote.new_folder(os.join_path(base, 'remote'))!868	mut a := open_repo(machine('strip_clone_a')!)!869	defer {870		a.close()871	}872	put(a.root, 'secrets.env', 'api_key=1\n')!873	old := commit(a, 'leak the key')!874	offer_now(a, mut backend)!875	strip_local(a, 'secrets.env', '')!876	push_strip(a, mut backend, 'secrets.env')!877	// Saves after the strip must be later in time, as they would be for real.878	put(a.root, 'secrets.env', 'api_key=2\n')!879	rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{880		message:    'rotate the key'881		created_at: events.strip_events(a)!.last().created_at + 60882	})!883	offer_now(a, mut backend)!884885	mut c := open_repo(machine('strip_clone_c')!)!886	defer {887		c.close()888	}889	join(c, mut backend)!890	assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)891	assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)892	assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'893}894895// The local half commits before the remote hears anything, so a remote failure896// leaves nothing to find locally; the pending note is what lets a re-run897// finish. Objects a remote-only save still needs must survive the strip.898fn test_an_interrupted_remote_strip_finishes_when_re_run() {899	base := scratch('strip_resume')!900	defer {901		os.rmdir_all(base) or {}902	}903	remote_dir := os.join_path(base, 'remote')904	mut backend := remote.new_folder(remote_dir)!905	mut a := open_repo(machine('resume_a')!)!906	defer {907		a.close()908	}909	mut b := open_repo(machine('resume_b')!)!910	defer {911		b.close()912	}913	put(a.root, 'secrets.env', 'api_key=1\n')!914	put(a.root, 'shared.env', 'same bytes on B\n')!915	save := commit(a, 'add secrets')!916	offer_now(a, mut backend)!917	join(b, mut backend)!918	put(b.root, 'copy.txt', 'same bytes on B\n')!919	b_save := commit(b, 'B keeps a copy')!920	offer_now(b, mut backend)!921	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())922	secret_hash := storage.hash_hex('api_key=1\n'.bytes())923924	strip_local(a, 'secrets.env', '')!925	// Write 1 uploads the event and write 2 rewrites the first record; B's save,926	// made from A's tree, lists the file too, and its rewrite (write 3) fails.927	mut flaky := FaultyBackend{928		inner:      remote.new_folder(remote_dir)!929		fail_write: 3930	}931	if _ := push_strip(a, mut flaky, 'secrets.env') {932		assert false, 'the injected failure should have surfaced'933	}934	assert strip_pending(a, 'secrets.env')935	assert strip_local(a, 'secrets.env', '')!.files_removed == 0936	report := push_strip(a, mut backend, 'secrets.env')!937	assert report.records == 1938	assert !strip_pending(a, 'secrets.env')939	assert !backend.get(object_key('proj', secret_hash))!.found940	record := decode_save(backend.get(save_key('proj', save.id))!.body)!941	assert 'secrets.env' !in record.files.map(it.path)942	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!943	assert 'secrets.env' !in b_record.files.map(it.path)944945	// B's save still names the shared bytes, so stripping A's copy keeps them.946	strip_local(a, 'shared.env', '')!947	push_strip(a, mut backend, 'shared.env')!948	assert backend.get(object_key('proj', shared_hash))!.found949}950951// A machine that has not heard of a strip must not offer: its records still952// list the stripped path, and nothing would rewrite them on the remote again.953fn test_offer_waits_for_an_unapplied_strip() {954	base := scratch('strip_first')!955	defer {956		os.rmdir_all(base) or {}957	}958	mut backend := remote.new_folder(os.join_path(base, 'remote'))!959	mut a := open_repo(machine('strip_first_a')!)!960	defer {961		a.close()962	}963	mut b := open_repo(machine('strip_first_b')!)!964	defer {965		b.close()966	}967	put(a.root, 'secrets.env', 'api_key=1\n')!968	commit(a, 'add secrets')!969	offer_now(a, mut backend)!970	join(b, mut backend)!971	strip_local(a, 'secrets.env', '')!972	push_strip(a, mut backend, 'secrets.env')!973	put(b.root, 'more.txt', 'B keeps working\n')!974	later := commit(b, 'B saves with the secret still in its tree')!975	if _ := offer_now(b, mut backend) {976		assert false, 'B offered records that still list the stripped path'977	}978	steal_now(b, mut backend)!979	offer_now(b, mut backend)!980	record := decode_save(backend.get(save_key('proj', later.id))!.body)!981	assert 'secrets.env' !in record.files.map(it.path)982}983984// A strip whose object deletions failed, followed by a second strip of the same985// path, must still delete the first strip's objects when it finishes.986fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {987	base := scratch('strip_twice')!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_twice_a')!)!994	defer {995		a.close()996	}997	put(a.root, 'secrets.env', 'first key\n')!998	commit(a, 'first key')!999	offer_now(a, mut backend)!1000	strip_local(a, 'secrets.env', '')!1001	mut flaky := FaultyBackend{1002		inner:       remote.new_folder(remote_dir)!1003		fail_delete: true1004	}1005	if _ := push_strip(a, mut flaky, 'secrets.env') {1006		assert false, 'the injected delete failure should have surfaced'1007	}1008	put(a.root, 'secrets.env', 'second key\n')!1009	commit(a, 'second key')!1010	offer_now(a, mut backend)!1011	strip_local(a, 'secrets.env', '')!1012	push_strip(a, mut backend, 'secrets.env')!1013	for key in ['first key\n', 'second key\n'] {1014		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1015	}1016}10171018// Unfinished strips of one path in different scopes finish together.1019fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1020	base := scratch('strip_scopes')!1021	defer {1022		os.rmdir_all(base) or {}1023	}1024	remote_dir := os.join_path(base, 'remote')1025	mut backend := remote.new_folder(remote_dir)!1026	mut a := open_repo(machine('strip_scopes_a')!)!1027	defer {1028		a.close()1029	}1030	put(a.root, 'secrets.env', 'first key\n')!1031	first := commit(a, 'first key')!1032	put(a.root, 'secrets.env', 'second key\n')!1033	second := commit(a, 'second key')!1034	offer_now(a, mut backend)!1035	strip_local(a, 'secrets.env', first.id)!1036	// Write 1 uploads the event; write 2, the record rewrite, fails.1037	mut flaky := FaultyBackend{1038		inner:      remote.new_folder(remote_dir)!1039		fail_write: 21040	}1041	if _ := push_strip(a, mut flaky, 'secrets.env') {1042		assert false, 'the injected failure should have surfaced'1043	}1044	strip_local(a, 'secrets.env', second.id)!1045	push_strip(a, mut backend, 'secrets.env')!1046	assert !strip_pending(a, 'secrets.env')1047	for save in [first, second] {1048		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1049		assert 'secrets.env' !in record.files.map(it.path)1050	}1051	for key in ['first key\n', 'second key\n'] {1052		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1053	}1054}10551056// Stripping a path, re-adding it, and stripping it again is two strips; a1057// machine that heard about the first must still apply the second.1058fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1059	base := scratch('strip_again')!1060	defer {1061		os.rmdir_all(base) or {}1062	}1063	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1064	mut a := open_repo(machine('strip_again_a')!)!1065	defer {1066		a.close()1067	}1068	mut b := open_repo(machine('strip_again_b')!)!1069	defer {1070		b.close()1071	}1072	put(a.root, 'secrets.env', 'first key\n')!1073	commit(a, 'first key')!1074	offer_now(a, mut backend)!1075	join(b, mut backend)!1076	strip_local(a, 'secrets.env', '')!1077	push_strip(a, mut backend, 'secrets.env')!1078	steal_now(b, mut backend)!1079	// Strip leaves the file on disk; deleting it is the user's part.1080	os.rm(os.join_path(b.root, 'secrets.env'))!1081	put(a.root, 'secrets.env', 'second key\n')!1082	second := commit(a, 'second key')!1083	offer_now(a, mut backend)!1084	steal_now(b, mut backend)!1085	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1086	strip_local(a, 'secrets.env', '')!1087	push_strip(a, mut backend, 'secrets.env')!1088	steal_now(b, mut backend)!1089	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1090}10911092// A clash on a path the other machine stripped is no clash: the strip removes1093// the path from every save, so there is nothing to choose.1094fn test_a_stripped_path_is_not_asked_about() {1095	base := scratch('clash_strip')!1096	defer {1097		os.rmdir_all(base) or {}1098	}1099	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1100	mut a := open_repo(machine('clash_strip_a')!)!1101	defer {1102		a.close()1103	}1104	mut b := open_repo(machine('clash_strip_b')!)!1105	defer {1106		b.close()1107	}1108	put(a.root, 'secrets.env', 'key one\n')!1109	commit(a, 'base')!1110	offer_now(a, mut backend)!1111	join(b, mut backend)!1112	put(a.root, 'secrets.env', 'key two\n')!1113	commit(a, 'A rotates')!1114	offer_now(a, mut backend)!1115	strip_local(a, 'secrets.env', '')!1116	push_strip(a, mut backend, 'secrets.env')!1117	put(b.root, 'secrets.env', 'key three\n')!1118	mine := commit(b, 'B rotates')!1119	report := steal(b, mut backend, StealOptions{})!1120	assert report.clashes.len == 01121	for save in history.all_saves(b)! {1122		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1123	}1124	assert history.by_id(b, mine.id) != none1125}11261127// A strip scoped to the shared base save changes what the replay sees; the1128// clashes must be found on the same trees, or the replay meets a conflict.1129fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1130	base := scratch('clash_base_strip')!1131	defer {1132		os.rmdir_all(base) or {}1133	}1134	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1135	mut a := open_repo(machine('base_strip_a')!)!1136	defer {1137		a.close()1138	}1139	mut b := open_repo(machine('base_strip_b')!)!1140	defer {1141		b.close()1142	}1143	put(a.root, 'notes.txt', 'shared\n')!1144	common := commit(a, 'base')!1145	offer_now(a, mut backend)!1146	join(b, mut backend)!1147	put(a.root, 'other.txt', 'A moves on\n')!1148	commit(a, 'A moves on')!1149	offer_now(a, mut backend)!1150	strip_local(a, 'notes.txt', common.id)!1151	push_strip(a, mut backend, 'notes.txt')!1152	put(b.root, 'notes.txt', 'B edits\n')!1153	commit(b, 'B edits notes')!1154	if _ := steal(b, mut backend, StealOptions{}) {1155		assert false, 'with the base stripped, notes.txt differs on both sides'1156	} else {1157		assert err.msg().contains('changed on both machines')1158	}1159	report := steal(b, mut backend, StealOptions{1160		mine: true1161	})!1162	assert report.clashes == ['notes.txt']1163	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1164}11651166// A folder with files but no saves has nothing to combine with; a steal that1167// fast-forwarded into it would overwrite whatever shares a name.1168fn test_steal_into_a_folder_with_unsaved_files_refuses() {1169	base := scratch('steal_unsaved')!1170	defer {1171		os.rmdir_all(base) or {}1172	}1173	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1174	mut a := open_repo(machine('unsaved_a')!)!1175	defer {1176		a.close()1177	}1178	put(a.root, 'notes.txt', 'from A\n')!1179	commit(a, 'A writes')!1180	offer_now(a, mut backend)!1181	b_root := machine('unsaved_b')!1182	mut b := storage.create(b_root)!1183	defer {1184		b.close()1185	}1186	put(b_root, 'notes.txt', 'never saved\n')!1187	// Only a repo that already knows its remote name gets this far.1188	b.set_state('remote_repo', 'proj')!1189	if _ := steal(b, mut backend, StealOptions{}) {1190		assert false, 'steal overwrote a folder with unsaved files'1191	} else {1192		assert err.msg() == no_saves_here1193	}1194	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1195	// A fresh `koh init` folder, holding only the default ignore list, is fine.1196	os.rm(os.join_path(b_root, 'notes.txt'))!1197	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1198	steal(b, mut backend, StealOptions{})!1199	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1200}12011202// The document asks steal to warn about every incoming flagged save, so the1203// report names them.1204fn test_steal_reports_incoming_flagged_saves() {1205	base := scratch('flag_warn')!1206	defer {1207		os.rmdir_all(base) or {}1208	}1209	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1210	mut a := open_repo(machine('flag_warn_a')!)!1211	defer {1212		a.close()1213	}1214	mut b := open_repo(machine('flag_warn_b')!)!1215	defer {1216		b.close()1217	}1218	put(a.root, 'a.txt', 'one\n')!1219	commit(a, 'fine')!1220	offer_now(a, mut backend)!1221	join(b, mut backend)!1222	put(a.root, 'a.txt', 'two\n')!1223	suspect := commit(a, 'suspect')!1224	put(a.root, 'a.txt', 'three\n')!1225	commit(a, 'also fine')!1226	events.flag(a, suspect.id, 'broke the build')!1227	offer_now(a, mut backend)!1228	report := steal_now(b, mut backend)!1229	assert report.flagged.map(it.save_id) == [suspect.id]1230	assert report.flagged[0].reason == 'broke the build'1231}