exchange_test.v

45.9 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 exchange67import 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 make_save(store &storage.Store, message string) !history.Save {43	return history.save_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	make_save(a, 'first')!69	put(a.root, 'a.txt', 'two\n')!70	make_save(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	make_save(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() == head_of(a).id100	// Offer's step 3: the new remote head is recorded in state.101	assert a.state('remote_head') or { '' } == head_of(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	make_save(a, 'second')!107	offer_now(a, mut backend)!108	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(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	make_save(a, 'A writes')!123	offer_now(a, mut backend)!124125	// B starts empty and takes 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.end()!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 head_of(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	make_save(a, 'offered')!154	offer_now(a, mut backend)!155	put(a.root, 'a.txt', 'two\n')!156	ahead := make_save(a, 'not offered yet')!157	report := steal_now(a, mut backend)!158	assert report.replayed == 0159	assert head_of(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	make_save(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	make_save(a, 'A adds docs')!192	offer_now(a, mut backend)!193	head_before := head_of(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 head_of(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	make_save(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// steal cannot order saves by time.227fn test_a_steal_imports_parents_before_children() {228	base := scratch('steal_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 := make_save(a, 'parent')!239	child := history.save_tree(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 head_of(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	make_save(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	make_save(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	make_save(a, 'A adds a file')!296	put(b.root, 'fromB.txt', 'B\n')!297	make_save(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 == head_of(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	make_save(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	make_save(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 := make_save(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 := make_save(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 head_of(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	make_save(a, 'base')!398	offer_now(a, mut backend)!399	join(b, mut backend)!400	put(a.root, 'same.txt', 'A version\n')!401	make_save(a, 'A edits same')!402	offer_now(a, mut backend)!403	put(b.root, 'same.txt', 'B version\n')!404	make_save(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		guide: fn [root] (preview StealPreview) !map[string]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.save_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 {418				preview.clashes[0].path: Side.mine419			}420		}421	})422	{423		assert false, 'a steal whose line moved during the question must refuse'424	} else {425		assert err.msg().contains('changed while')426	}427	// The agent's save is still head, and a second steal combines it too.428	assert head_of(b).message == 'agent saves during the question'429	report := steal(b, mut backend, StealOptions{430		mine: true431	})!432	assert report.kept_mine == 1433	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'434	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'435}436437fn test_clashing_files_are_chosen_one_at_a_time() {438	base := scratch('clash')!439	defer {440		os.rmdir_all(base) or {}441	}442	mut backend := remote.new_folder(os.join_path(base, 'remote'))!443	mut a := open_repo(machine('clash_a')!)!444	defer {445		a.close()446	}447	mut b := open_repo(machine('clash_b')!)!448	defer {449		b.close()450	}451	put(a.root, 'same.txt', 'base\n')!452	put(a.root, 'other.txt', 'quiet\n')!453	make_save(a, 'base')!454	offer_now(a, mut backend)!455	join(b, mut backend)!456457	// Both machines edit the same file, differently.458	put(a.root, 'same.txt', 'A version\n')!459	make_save(a, 'A edits same')!460	offer_now(a, mut backend)!461	put(b.root, 'same.txt', 'B version\n')!462	make_save(b, 'B edits same')!463464	// Without a decision, steal refuses and says which files clash.465	if _ := steal(b, mut backend, StealOptions{}) {466		assert false, 'an undecided clash must be refused'467	} else {468		assert err.msg().contains('--mine') || err.msg().contains('--theirs')469		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.470		if err is ClashError {471			assert err.paths == ['same.txt']472		} else {473			assert false, 'an undecided clash must be a ClashError, not ${err}'474		}475	}476	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,477		head_of(b).id)!).len == 0478479	// Choosing the remote's version ends with A's content.480	report := steal_now(b, mut backend)!481	assert report.clashes == ['same.txt']482	assert report.kept_mine == 0483	assert report.kept_versions == ['same.txt:${report.previous_head}']484	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'485	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'486}487488fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {489	base := scratch('guided_cancel')!490	defer { os.rmdir_all(base) or {} }491	mut backend := remote.new_folder(os.join_path(base, 'remote'))!492	mut a := open_repo(machine('guided_cancel_a')!)!493	defer { a.close() }494	mut b := open_repo(machine('guided_cancel_b')!)!495	defer { b.close() }496	put(a.root, 'same.txt', 'base\n')!497	make_save(a, 'base')!498	offer_now(a, mut backend)!499	join(b, mut backend)!500	put(a.root, 'same.txt', 'remote\n')!501	remote_head := make_save(a, 'remote change')!502	offer_now(a, mut backend)!503	put(b.root, 'same.txt', 'mine\n')!504	local_head := make_save(b, 'local change')!505506	if _ := steal(b, mut backend, StealOptions{507		guide: fn (preview StealPreview) !map[string]Side {508			assert preview.clashes.len == 1509			return error('cancelled')510		}511	}) {512		assert false, 'the guide cancelled'513	} else {514		assert err.msg() == 'cancelled'515	}516	assert head_of(b).id == local_head.id517	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'518	assert history.by_id(b, remote_head.id) != none519	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1520}521522fn test_choosing_mine_keeps_the_local_version() {523	base := scratch('mine')!524	defer {525		os.rmdir_all(base) or {}526	}527	mut backend := remote.new_folder(os.join_path(base, 'remote'))!528	mut a := open_repo(machine('mine_a')!)!529	defer {530		a.close()531	}532	mut b := open_repo(machine('mine_b')!)!533	defer {534		b.close()535	}536	put(a.root, 'same.txt', 'base\n')!537	make_save(a, 'base')!538	offer_now(a, mut backend)!539	join(b, mut backend)!540	put(a.root, 'same.txt', 'A version\n')!541	make_save(a, 'A edits')!542	offer_now(a, mut backend)!543	put(b.root, 'same.txt', 'B version\n')!544	make_save(b, 'B edits')!545546	report := steal(b, mut backend, StealOptions{547		mine: true548	})!549	assert report.kept_mine == 1550	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'551	// The final save says what it kept.552	assert head_of(b).message.contains('combine: kept mine for 1 file')553	// The pre-combine tree is still reachable in history.554	assert head_of(b).restores == ''555	assert history.all_saves(b)!.len >= 4556}557558fn test_flags_travel_with_the_saves() {559	base := scratch('flags')!560	defer {561		os.rmdir_all(base) or {}562	}563	mut backend := remote.new_folder(os.join_path(base, 'remote'))!564	mut a := open_repo(machine('flag_a')!)!565	defer {566		a.close()567	}568	mut b := open_repo(machine('flag_b')!)!569	defer {570		b.close()571	}572	put(a.root, 'a.txt', 'one\n')!573	save := make_save(a, 'suspect save')!574	events.flag(a, save.id, 'look at this')!575	offer_now(a, mut backend)!576577	// B has nothing yet, so a fetch brings the save and its flag across.578	fetched := fetch_back_to_known(b, mut backend, 'proj')!579	assert fetched.events_out.len == 1580	assert fetched.events_out[0].kind == events.kind_flag581	b.begin()!582	write_fetched(b, fetched)!583	history.set_head(b, fetched.head)!584	b.end()!585	state := events.flag_state(b, save.id)!586	assert state.flagged587	assert state.reason == 'look at this'588}589590fn test_a_second_offer_racing_head_is_refused_not_forced() {591	base := scratch('race')!592	defer {593		os.rmdir_all(base) or {}594	}595	mut backend := remote.new_folder(os.join_path(base, 'remote'))!596	mut a := open_repo(machine('race_a')!)!597	defer {598		a.close()599	}600	put(a.root, 'a.txt', 'one\n')!601	make_save(a, 'base')!602	offer_now(a, mut backend)!603604	// Someone else replaces head behind our back.605	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),606		backend.get(head_key('proj'))!.tag)!607608	put(a.root, 'a.txt', 'two\n')!609	make_save(a, 'second')!610	if _ := offer_now(a, mut backend) {611		assert false, 'offering while the remote head moved must be refused'612	} else {613		assert err.msg().contains('run koh steal')614	}615}616617// join makes a brand new machine share the remote's line.618fn join(store &storage.Store, mut backend remote.Backend) ! {619	fetched := fetch_repo(mut backend, 'proj')!620	store.begin()!621	write_fetched(store, fetched)!622	history.set_head(store, fetched.head)!623	store.end()!624	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,625		fetched.head)!, [])!626	store.set_state('remote_repo', 'proj')!627}628629struct FaultyBackend {630mut:631	inner      remote.Backend632	writes     int633	reads      int634	fail_write int // fail when the write counter reaches this, 0 never635	fail_read  int636	// fail every delete while set637	fail_delete bool638	// race_format stands in for another machine writing the format key first:639	// the first `format` write loses, to a value this koh does not speak.640	race_format bool641	raced       bool642}643644fn (mut f FaultyBackend) get(key string) !remote.Object {645	f.reads++646	if f.fail_read > 0 && f.reads >= f.fail_read {647		return error('injected read failure')648	}649	return f.inner.get(key)!650}651652fn (mut f FaultyBackend) list(prefix string) ![]string {653	return f.inner.list(prefix)!654}655656fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {657	f.writes++658	if f.fail_write > 0 && f.writes >= f.fail_write {659		return error('injected write failure')660	}661	if f.race_format && !f.raced && key.ends_with('/format') {662		f.raced = true663		f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!664		return false665	}666	return f.inner.put_if_absent(key, body)!667}668669fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {670	f.writes++671	if f.fail_write > 0 && f.writes >= f.fail_write {672		return error('injected write failure')673	}674	return f.inner.put_if_match(key, body, tag)!675}676677fn (mut f FaultyBackend) delete(key string) ! {678	if f.fail_delete {679		return error('injected delete failure')680	}681	f.inner.delete(key)!682}683684// Every save offers after it is made, so a one-file change must cost one685// object, not one conditional write per file in the repo: on S3 each of those686// is a round trip.687fn test_an_offer_sends_only_the_objects_a_save_adds() {688	base := scratch('offer_diff')!689	defer {690		os.rmdir_all(base) or {}691	}692	mut a := open_repo(machine('offer_diff_a')!)!693	defer {694		a.close()695	}696	for i in 0 .. 20 {697		put(a.root, 'f${i}.txt', 'file ${i}\n')!698	}699	make_save(a, 'twenty files')!700	mut backend := FaultyBackend{701		inner: remote.new_folder(os.join_path(base, 'remote'))!702	}703	offer(a, mut backend)!704	put(a.root, 'f3.txt', 'changed\n')!705	make_save(a, 'one change')!706	before := backend.writes707	report := offer(a, mut backend)!708	assert report.objects == 1709	// One object, one save record, one head swap.710	assert backend.writes - before == 3711}712713fn test_a_failed_offer_is_safe_to_re_run() {714	base := scratch('fault_offer')!715	defer {716		os.rmdir_all(base) or {}717	}718	remote_dir := os.join_path(base, 'remote')719	mut a := open_repo(machine('fault_a')!)!720	defer {721		a.close()722	}723	put(a.root, 'a.txt', 'one\n')!724	make_save(a, 'first')!725	put(a.root, 'a.txt', 'two\n')!726	make_save(a, 'second')!727728	// The first offer dies after the first write.729	mut flaky := FaultyBackend{730		inner:      remote.new_folder(remote_dir)!731		fail_write: 2732	}733	if _ := offer(a, mut flaky) {734		assert false, 'the injected failure should have surfaced'735	}736737	// Re-running with a healthy remote finishes the job: "already exists" is738	// success, so nothing is lost and nothing is duplicated.739	mut healthy := remote.new_folder(remote_dir)!740	report := offer(a, mut healthy)!741	assert report.saves == 2742	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id743	assert healthy.list('proj/saves')!.len == 2744}745746fn test_a_failed_steal_leaves_nothing_behind() {747	base := scratch('fault_steal')!748	defer {749		os.rmdir_all(base) or {}750	}751	remote_dir := os.join_path(base, 'remote')752	mut a := open_repo(machine('fault_s_a')!)!753	defer {754		a.close()755	}756	mut b := open_repo(machine('fault_s_b')!)!757	defer {758		b.close()759	}760	put(a.root, 'a.txt', 'from A\n')!761	make_save(a, 'A writes')!762	mut healthy := remote.new_folder(remote_dir)!763	offer(a, mut healthy)!764765	// B's fetch fails half way through reading the remote.766	mut flaky := FaultyBackend{767		inner:     remote.new_folder(remote_dir)!768		fail_read: 2769	}770	if _ := fetch_back_to_known(b, mut flaky, 'proj') {771		assert false, 'the injected read failure should have surfaced'772	}773	// Nothing was written: the store is untouched and still has no saves.774	assert history.head(b) == none775	assert b.object_count()! == 0776777	// A healthy retry works.778	fetched := fetch_back_to_known(b, mut healthy, 'proj')!779	b.begin()!780	write_fetched(b, fetched)!781	history.set_head(b, fetched.head)!782	b.end()!783	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!784	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'785}786787fn test_an_interrupted_strip_is_idempotent() {788	// The local half of a strip is one transaction, so an interrupted strip is789	// simply a strip that did not happen; re-running it must land the same790	// result once, not twice.791	base := scratch('fault_strip')!792	defer {793		os.rmdir_all(base) or {}794	}795	mut a := open_repo(machine('fault_strip_a')!)!796	defer {797		a.close()798	}799	put(a.root, 'secret.txt', 'shh\n')!800	put(a.root, 'keep.txt', 'fine\n')!801	save := make_save(a, 'add both')!802803	for _ in 0 .. 2 {804		strip_local(a, 'secret.txt', '')!805	}806	files := history.files_of(a, save.id)!807	// The repo's own `.kohignore` is tracked content, so it stays; only the808	// stripped path goes.809	assert files.map(it.path) == ['.kohignore', 'keep.txt']810	assert events.strip_events(a)!.len == 1811}812813// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into814// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.815// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,816// but a save made on Linux can hold both.817fn test_a_strip_matches_the_path_exactly() {818	mut a := open_repo(machine('strip_exact')!)!819	defer {820		a.close()821	}822	hash := a.put_object('x\n'.bytes())!823	mut files := []snapshot.FileEntry{}824	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {825		files << snapshot.FileEntry{826			path: path827			hash: hash828		}829	}830	save := history.save_tree(a, '', files, history.SaveInput{831		message: 'add all'832	})!833	assert strip_scope(a, 'docs', '')!.files == 1834	strip_local(a, 'docs', '')!835	strip_local(a, 'a_b', '')!836	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']837}838839fn test_a_strip_reaches_the_remote_and_other_machines() {840	base := scratch('strip_sync')!841	defer {842		os.rmdir_all(base) or {}843	}844	mut a := open_repo(machine('strip_a')!)!845	defer {846		a.close()847	}848	mut b := open_repo(machine('strip_b')!)!849	defer {850		b.close()851	}852	put(a.root, 'secrets.env', 'api_key=1\n')!853	put(a.root, 'keep.txt', 'fine\n')!854	save := make_save(a, 'add both')!855	mut backend := remote.new_folder(os.join_path(base, 'remote'))!856	offer_now(a, mut backend)!857	join(b, mut backend)!858	assert b.object_count()! > 0859860	// A strips the path locally, the way `koh strip` does, then tells the861	// remote.862	mut secret_hash := ''863	for file in history.files_of(a, save.id)! {864		if file.path == 'secrets.env' {865			secret_hash = file.hash866		}867	}868	assert secret_hash != ''869	strip_local(a, 'secrets.env', '')!870	report := send_strip(a, mut backend, 'secrets.env')!871	assert report.event_sent872	assert report.records == 1873874	// The remote's copy of the save no longer lists the file, and the object is875	// gone from the remote.876	record := decode_save(backend.get(save_key('proj', save.id))!.body)!877	for file in record.files {878		assert file.path != 'secrets.env'879	}880	// The stripped object is gone from the remote; the ones still referenced881	// are not.882	assert !backend.get(object_key('proj', secret_hash))!.found883	assert backend.list('proj/objects')!.len == 2884885	// Another machine hears about it on its next steal and applies it.886	b.begin()!887	fetched := fetch_back_to_known(b, mut backend, 'proj')!888	write_fetched(b, fetched)!889	b.end()!890	mut paths := []string{}891	for file in history.files_of(b, save.id)! {892		paths << file.path893	}894	assert 'secrets.env' !in paths895	assert 'keep.txt' in paths896897	// The event arrives under its own ID, so it is applied once: B's offer adds898	// no second copy. The path is banned for good: written again, the file is899	// neither unsaved work nor ever walked into a save.900	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)901	put(b.root, 'secrets.env', 'rotated\n')!902	assert !dirty(b, history.head(b))!903	put(b.root, 'keep.txt', 'still fine\n')!904	later := make_save(b, 'edit keep')!905	offer_now(b, mut backend)!906	assert backend.list('proj/events')!.len == 1907	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)908}909910// A strip removes a path from all of history, whenever a save was made. A911// machine that saved the file after the strip, before it heard of it, loses912// it from that save too on its next steal, so no copy anywhere keeps it.913fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {914	base := scratch('strip_later')!915	defer {916		os.rmdir_all(base) or {}917	}918	mut backend := remote.new_folder(os.join_path(base, 'remote'))!919	mut a := open_repo(machine('strip_later_a')!)!920	defer {921		a.close()922	}923	mut b := open_repo(machine('strip_later_b')!)!924	defer {925		b.close()926	}927	put(a.root, 'secrets.env', 'api_key=1\n')!928	old := make_save(a, 'leak the key')!929	offer_now(a, mut backend)!930	join(b, mut backend)!931	strip_local(a, 'secrets.env', '')!932	send_strip(a, mut backend, 'secrets.env')!933	// B has not heard, and saves the file again an hour after the strip.934	put(b.root, 'secrets.env', 'api_key=2\n')!935	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{936		message:    'rotate the key'937		created_at: events.strip_events(a)!.last().created_at + 3600938	})!939	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)940	b.begin()!941	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!942	b.end()!943	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)944	offer_now(b, mut backend)!945946	mut c := open_repo(machine('strip_later_c')!)!947	defer {948		c.close()949	}950	join(c, mut backend)!951	for save in [old.id, later.id] {952		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)953	}954	assert !os.exists(os.join_path(c.root, 'secrets.env'))955}956957// A strip this machine already holds bans its path from saves that arrive958// later too, not only from those a fetch brings with the strip.959fn test_a_held_strip_reaches_saves_arriving_after_it() {960	base := scratch('strip_held')!961	defer {962		os.rmdir_all(base) or {}963	}964	mut backend := remote.new_folder(os.join_path(base, 'remote'))!965	mut a := open_repo(machine('strip_held_a')!)!966	defer {967		a.close()968	}969	mut b := open_repo(machine('strip_held_b')!)!970	defer {971		b.close()972	}973	put(a.root, 'secrets.env', 'api_key=1\n')!974	make_save(a, 'leak the key')!975	offer_now(a, mut backend)!976	join(b, mut backend)!977	strip_local(b, 'secrets.env', '')!978	put(a.root, 'keep.txt', 'fine\n')!979	later := make_save(a, 'add keep')!980	offer_now(a, mut backend)!981	b.begin()!982	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!983	b.end()!984	paths := history.files_of(b, later.id)!.map(it.path)985	assert 'secrets.env' !in paths986	assert 'keep.txt' in paths987}988989// StripRace stands in for another machine that strips `path` completely, event990// and rewrite, just before this machine's first save record lands.991struct StripRace {992mut:993	inner remote.Backend994	other &storage.Store995	path  string996	done  bool997}998999fn (mut r StripRace) get(key string) !remote.Object {1000	return r.inner.get(key)!1001}10021003fn (mut r StripRace) list(prefix string) ![]string {1004	return r.inner.list(prefix)!1005}10061007fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1008	if !r.done && key.contains('/saves/') {1009		r.done = true1010		strip_local(r.other, r.path, '')!1011		send_strip(r.other, mut r.inner, r.path)!1012	}1013	return r.inner.put_if_absent(key, body)!1014}10151016fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1017	return r.inner.put_if_match(key, body, tag)!1018}10191020fn (mut r StripRace) delete(key string) ! {1021	r.inner.delete(key)!1022}10231024// A strip that finishes on another machine while an offer uploads has already1025// rewritten the remote without the offer's records, so the offer takes back1026// what the strip reaches and leaves head where it was.1027fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1028	base := scratch('strip_race')!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_race_a')!)!1034	defer {1035		a.close()1036	}1037	mut b := open_repo(machine('strip_race_b')!)!1038	defer {1039		b.close()1040	}1041	put(a.root, 'secrets.env', 'api_key=1\n')!1042	first := make_save(a, 'leak the key')!1043	offer_now(a, mut backend)!1044	join(b, mut backend)!1045	put(a.root, 'secrets.env', 'api_key=2\n')!1046	later := make_save(a, 'rotate the key')!1047	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1048	mut race := StripRace{1049		inner: backend1050		other: &b1051		path:  'secrets.env'1052	}1053	if _ := offer(a, mut race) {1054		assert false, 'an offer that raced a strip must refuse'1055	} else {1056		assert err.msg().contains('koh steal'), err.msg()1057	}1058	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1059	assert !backend.get(object_key('proj', secret))!.found1060	for key in backend.list('proj/saves')! {1061		record := decode_save(backend.get(key)!.body)!1062		assert 'secrets.env' !in record.files.map(it.path), record.id1063	}1064}10651066// The local half lands before the remote hears anything, so a remote failure1067// leaves nothing to find locally; the pending note is what lets a re-run1068// finish. Objects a remote-only save still needs must survive the strip.1069fn test_an_interrupted_remote_strip_finishes_when_re_run() {1070	base := scratch('strip_resume')!1071	defer {1072		os.rmdir_all(base) or {}1073	}1074	remote_dir := os.join_path(base, 'remote')1075	mut backend := remote.new_folder(remote_dir)!1076	mut a := open_repo(machine('resume_a')!)!1077	defer {1078		a.close()1079	}1080	mut b := open_repo(machine('resume_b')!)!1081	defer {1082		b.close()1083	}1084	put(a.root, 'secrets.env', 'api_key=1\n')!1085	put(a.root, 'shared.env', 'same bytes on B\n')!1086	save := make_save(a, 'add secrets')!1087	offer_now(a, mut backend)!1088	join(b, mut backend)!1089	put(b.root, 'copy.txt', 'same bytes on B\n')!1090	b_save := make_save(b, 'B keeps a copy')!1091	offer_now(b, mut backend)!1092	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1093	secret_hash := storage.hash_hex('api_key=1\n'.bytes())10941095	strip_local(a, 'secrets.env', '')!1096	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1097	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1098	mut flaky := FaultyBackend{1099		inner:      remote.new_folder(remote_dir)!1100		fail_write: 31101	}1102	if _ := send_strip(a, mut flaky, 'secrets.env') {1103		assert false, 'the injected failure should have surfaced'1104	}1105	assert strip_pending(a, 'secrets.env')1106	assert strip_local(a, 'secrets.env', '')!.files_removed == 01107	report := send_strip(a, mut backend, 'secrets.env')!1108	assert report.records == 11109	assert !strip_pending(a, 'secrets.env')1110	assert !backend.get(object_key('proj', secret_hash))!.found1111	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1112	assert 'secrets.env' !in record.files.map(it.path)1113	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1114	assert 'secrets.env' !in b_record.files.map(it.path)11151116	// B's save still names the shared bytes, so stripping A's copy keeps them.1117	strip_local(a, 'shared.env', '')!1118	send_strip(a, mut backend, 'shared.env')!1119	assert backend.get(object_key('proj', shared_hash))!.found1120}11211122// A machine that has not heard of a strip must not offer: its records still1123// list the stripped path, and nothing would rewrite them on the remote again.1124fn test_offer_waits_for_an_unapplied_strip() {1125	base := scratch('strip_first')!1126	defer {1127		os.rmdir_all(base) or {}1128	}1129	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1130	mut a := open_repo(machine('strip_first_a')!)!1131	defer {1132		a.close()1133	}1134	mut b := open_repo(machine('strip_first_b')!)!1135	defer {1136		b.close()1137	}1138	put(a.root, 'secrets.env', 'api_key=1\n')!1139	make_save(a, 'add secrets')!1140	offer_now(a, mut backend)!1141	join(b, mut backend)!1142	strip_local(a, 'secrets.env', '')!1143	send_strip(a, mut backend, 'secrets.env')!1144	put(b.root, 'more.txt', 'B keeps working\n')!1145	later := make_save(b, 'B saves with the secret still in its tree')!1146	if _ := offer_now(b, mut backend) {1147		assert false, 'B offered records that still list the stripped path'1148	}1149	steal_now(b, mut backend)!1150	offer_now(b, mut backend)!1151	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1152	assert 'secrets.env' !in record.files.map(it.path)1153}11541155// A strip whose object deletions failed must still delete them when it is run1156// again, and meanwhile the banned path cannot come back in through a save.1157fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1158	base := scratch('strip_twice')!1159	defer {1160		os.rmdir_all(base) or {}1161	}1162	remote_dir := os.join_path(base, 'remote')1163	mut backend := remote.new_folder(remote_dir)!1164	mut a := open_repo(machine('strip_twice_a')!)!1165	defer {1166		a.close()1167	}1168	put(a.root, 'secrets.env', 'first key\n')!1169	make_save(a, 'first key')!1170	offer_now(a, mut backend)!1171	strip_local(a, 'secrets.env', '')!1172	mut flaky := FaultyBackend{1173		inner:       remote.new_folder(remote_dir)!1174		fail_delete: true1175	}1176	if _ := send_strip(a, mut flaky, 'secrets.env') {1177		assert false, 'the injected delete failure should have surfaced'1178	}1179	put(a.root, 'secrets.env', 'second key\n')!1180	put(a.root, 'other.txt', 'more work\n')!1181	make_save(a, 'more work')!1182	offer_now(a, mut backend)!1183	send_strip(a, mut backend, 'secrets.env')!1184	for key in ['first key\n', 'second key\n'] {1185		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1186	}1187}11881189// Unfinished strips of one path in different scopes finish together.1190fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1191	base := scratch('strip_scopes')!1192	defer {1193		os.rmdir_all(base) or {}1194	}1195	remote_dir := os.join_path(base, 'remote')1196	mut backend := remote.new_folder(remote_dir)!1197	mut a := open_repo(machine('strip_scopes_a')!)!1198	defer {1199		a.close()1200	}1201	put(a.root, 'secrets.env', 'first key\n')!1202	first := make_save(a, 'first key')!1203	put(a.root, 'secrets.env', 'second key\n')!1204	second := make_save(a, 'second key')!1205	offer_now(a, mut backend)!1206	strip_local(a, 'secrets.env', first.id)!1207	// Write 1 uploads the event; write 2, the record rewrite, fails.1208	mut flaky := FaultyBackend{1209		inner:      remote.new_folder(remote_dir)!1210		fail_write: 21211	}1212	if _ := send_strip(a, mut flaky, 'secrets.env') {1213		assert false, 'the injected failure should have surfaced'1214	}1215	strip_local(a, 'secrets.env', second.id)!1216	send_strip(a, mut backend, 'secrets.env')!1217	assert !strip_pending(a, 'secrets.env')1218	for save in [first, second] {1219		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1220		assert 'secrets.env' !in record.files.map(it.path)1221	}1222	for key in ['first key\n', 'second key\n'] {1223		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1224	}1225}12261227// A strip bans its path on every machine that has heard of it: written again,1228// the file makes no save, here or there, so there is never a second copy to strip.1229fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1230	base := scratch('strip_again')!1231	defer {1232		os.rmdir_all(base) or {}1233	}1234	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1235	mut a := open_repo(machine('strip_again_a')!)!1236	defer {1237		a.close()1238	}1239	mut b := open_repo(machine('strip_again_b')!)!1240	defer {1241		b.close()1242	}1243	put(a.root, 'secrets.env', 'first key\n')!1244	make_save(a, 'first key')!1245	offer_now(a, mut backend)!1246	join(b, mut backend)!1247	strip_local(a, 'secrets.env', '')!1248	send_strip(a, mut backend, 'secrets.env')!1249	steal_now(b, mut backend)!1250	for repo in [a, b] {1251		put(repo.root, 'secrets.env', 'second key\n')!1252		if _ := make_save(repo, 'second key') {1253			assert false, 'a banned path must not make a save'1254		}1255	}1256}12571258// A clash on a path the other machine stripped is no clash: the strip removes1259// the path from every save, so there is nothing to choose.1260fn test_a_stripped_path_is_not_asked_about() {1261	base := scratch('clash_strip')!1262	defer {1263		os.rmdir_all(base) or {}1264	}1265	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1266	mut a := open_repo(machine('clash_strip_a')!)!1267	defer {1268		a.close()1269	}1270	mut b := open_repo(machine('clash_strip_b')!)!1271	defer {1272		b.close()1273	}1274	put(a.root, 'secrets.env', 'key one\n')!1275	make_save(a, 'base')!1276	offer_now(a, mut backend)!1277	join(b, mut backend)!1278	put(a.root, 'secrets.env', 'key two\n')!1279	make_save(a, 'A rotates')!1280	offer_now(a, mut backend)!1281	strip_local(a, 'secrets.env', '')!1282	send_strip(a, mut backend, 'secrets.env')!1283	put(b.root, 'secrets.env', 'key three\n')!1284	mine := make_save(b, 'B rotates')!1285	report := steal(b, mut backend, StealOptions{})!1286	assert report.clashes.len == 01287	for save in history.all_saves(b)! {1288		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1289	}1290	assert history.by_id(b, mine.id) != none1291}12921293// A strip scoped to the shared base save changes what the replay sees; the1294// clashes must be found on the same trees, or the replay meets a conflict.1295fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1296	base := scratch('clash_base_strip')!1297	defer {1298		os.rmdir_all(base) or {}1299	}1300	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1301	mut a := open_repo(machine('base_strip_a')!)!1302	defer {1303		a.close()1304	}1305	mut b := open_repo(machine('base_strip_b')!)!1306	defer {1307		b.close()1308	}1309	put(a.root, 'notes.txt', 'shared\n')!1310	common := make_save(a, 'base')!1311	offer_now(a, mut backend)!1312	join(b, mut backend)!1313	put(a.root, 'other.txt', 'A moves on\n')!1314	make_save(a, 'A moves on')!1315	offer_now(a, mut backend)!1316	strip_local(a, 'notes.txt', common.id)!1317	send_strip(a, mut backend, 'notes.txt')!1318	put(b.root, 'notes.txt', 'B edits\n')!1319	make_save(b, 'B edits notes')!1320	if _ := steal(b, mut backend, StealOptions{}) {1321		assert false, 'with the base stripped, notes.txt differs on both sides'1322	} else {1323		assert err.msg().contains('notes.txt changed on both machines')1324	}1325	report := steal(b, mut backend, StealOptions{1326		mine: true1327	})!1328	assert report.clashes == ['notes.txt']1329	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1330}13311332// A folder with files but no saves has nothing to combine with; a steal that1333// fast-forwarded into it would overwrite whatever shares a name.1334fn test_steal_into_a_folder_with_unsaved_files_refuses() {1335	base := scratch('steal_unsaved')!1336	defer {1337		os.rmdir_all(base) or {}1338	}1339	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1340	mut a := open_repo(machine('unsaved_a')!)!1341	defer {1342		a.close()1343	}1344	put(a.root, 'notes.txt', 'from A\n')!1345	make_save(a, 'A writes')!1346	offer_now(a, mut backend)!1347	b_root := machine('unsaved_b')!1348	mut b := storage.create(b_root)!1349	defer {1350		b.close()1351	}1352	put(b_root, 'notes.txt', 'never saved\n')!1353	// Only a repo that already knows its remote name gets this far.1354	b.set_state('remote_repo', 'proj')!1355	if _ := steal(b, mut backend, StealOptions{}) {1356		assert false, 'steal overwrote a folder with unsaved files'1357	} else {1358		assert err.msg() == no_saves_here1359	}1360	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1361	// A fresh `koh init` folder, holding only the default ignore list, is fine.1362	os.rm(os.join_path(b_root, 'notes.txt'))!1363	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1364	steal(b, mut backend, StealOptions{})!1365	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1366}13671368// The document asks steal to warn about every incoming flagged save, so the1369// report names them.1370fn test_steal_reports_incoming_flagged_saves() {1371	base := scratch('flag_warn')!1372	defer {1373		os.rmdir_all(base) or {}1374	}1375	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1376	mut a := open_repo(machine('flag_warn_a')!)!1377	defer {1378		a.close()1379	}1380	mut b := open_repo(machine('flag_warn_b')!)!1381	defer {1382		b.close()1383	}1384	put(a.root, 'a.txt', 'one\n')!1385	make_save(a, 'fine')!1386	offer_now(a, mut backend)!1387	join(b, mut backend)!1388	put(a.root, 'a.txt', 'two\n')!1389	suspect := make_save(a, 'suspect')!1390	put(a.root, 'a.txt', 'three\n')!1391	make_save(a, 'also fine')!1392	events.flag(a, suspect.id, 'broke the build')!1393	offer_now(a, mut backend)!1394	report := steal_now(b, mut backend)!1395	assert report.flagged.map(it.save_id) == [suspect.id]1396	assert report.flagged[0].reason == 'broke the build'1397}13981399// head_of unwraps the head for tests that know the repo has saves.1400fn head_of(store &storage.Store) history.Save {1401	return history.head(store) or { panic('this repo has no head') }1402}14031404// A remote says what it is, so two koh versions never half-understand one1405// bucket. The first offer of a repo records the layout.1406fn test_the_first_offer_records_the_remote_format() {1407	base := scratch('format_write')!1408	defer {1409		os.rmdir_all(base) or {}1410	}1411	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1412	mut a := open_repo(machine('format_write_a')!)!1413	defer {1414		a.close()1415	}1416	put(a.root, 'a.txt', 'one\n')!1417	make_save(a, 'first')!1418	offer_now(a, mut backend)!1419	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1420}14211422// A repo offered before the key existed is version 1: it still syncs, and the1423// next offer records the key then.1424fn test_a_remote_without_a_format_still_syncs() {1425	base := scratch('format_old')!1426	defer {1427		os.rmdir_all(base) or {}1428	}1429	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1430	mut a := open_repo(machine('format_old_a')!)!1431	defer {1432		a.close()1433	}1434	put(a.root, 'a.txt', 'one\n')!1435	make_save(a, 'first')!1436	offer_now(a, mut backend)!1437	// Stand in for a bucket offered before the key existed.1438	backend.delete(format_key('proj'))!1439	mut b := open_repo(machine('format_old_b')!)!1440	defer {1441		b.close()1442	}1443	join(b, mut backend)!1444	assert head_of(b).id == head_of(a).id1445	put(a.root, 'a.txt', 'two\n')!1446	make_save(a, 'second')!1447	offer_now(a, mut backend)!1448	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1449	report := steal_now(b, mut backend)!1450	assert report.head == head_of(a).id1451}14521453// A remote that speaks another layout is refused loudly, on offer and on1454// steal, rather than half-read.1455fn test_an_unknown_remote_format_is_refused() {1456	base := scratch('format_unknown')!1457	defer {1458		os.rmdir_all(base) or {}1459	}1460	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1461	mut a := open_repo(machine('format_bad_a')!)!1462	defer {1463		a.close()1464	}1465	put(a.root, 'a.txt', 'one\n')!1466	make_save(a, 'first')!1467	offer_now(a, mut backend)!1468	backend.delete(format_key('proj'))!1469	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1470	if _ := offer_now(a, mut backend) {1471		assert false, 'offer must refuse a format it does not speak'1472	} else {1473		assert err.msg().contains('koh-remote 2')1474	}1475	if _ := steal_now(a, mut backend) {1476		assert false, 'steal must refuse a format it does not speak'1477	} else {1478		assert err.msg().contains('koh-remote 2')1479	}1480	if _ := fetch_repo(mut backend, 'proj') {1481		assert false, 'a steal must refuse a format it does not speak'1482	} else {1483		assert err.msg().contains('koh-remote 2')1484	}1485}14861487// Two machines offering a new repo at once both find no format key; the loser1488// of the conditional write must read back the winner's value and refuse it,1489// not carry on against a remote it cannot read.1490fn test_a_lost_format_write_is_read_back_and_refused() {1491	base := scratch('format_race')!1492	defer {1493		os.rmdir_all(base) or {}1494	}1495	mut inner := remote.new_folder(os.join_path(base, 'remote'))!1496	mut a := open_repo(machine('format_race_a')!)!1497	defer {1498		a.close()1499	}1500	put(a.root, 'a.txt', 'one\n')!1501	make_save(a, 'first')!1502	mut backend := remote.Backend(FaultyBackend{1503		inner:       inner1504		race_format: true1505	})1506	if _ := offer_now(a, mut backend) {1507		assert false, 'offer must refuse the format another machine wrote first'1508	} else {1509		assert err.msg().contains('koh-remote 2')1510	}1511}