exchange_test.v

46.3 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.storage1314fn test_arriving_record_drops_unsafe_paths_and_reports_them() {15	mut record := SaveRecord{16		files: [17			FileRecord{ path: 'safe.txt' },18			FileRecord{ path: '../outside.txt' },19			FileRecord{ path: 'bad\\name.txt' },20			FileRecord{ path: 'Icon\r' },21		]22	}23	dropped := drop_unsafe_paths(mut record)24	assert record.files.map(it.path) == ['safe.txt']25	assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']26}2728// Machines keep their repo in a folder of the same NAME, because the remote key29// is the folder's name; that is what makes them the same repo to the remote.30fn machine(name string) !string {31	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')32	os.rmdir_all(os.dir(dir)) or {}33	os.mkdir_all(dir, os.MkdirParams{})!34	return dir35}3637fn scratch(name string) !string {38	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')39	os.rmdir_all(dir) or {}40	os.mkdir_all(dir, os.MkdirParams{})!41	return dir42}4344fn open_repo(dir string) !storage.Store {45	mut store := storage.create(dir)!46	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!47	return store48}4950fn put(root string, rel string, text string) ! {51	full := os.join_path(root, rel)52	os.mkdir_all(os.dir(full), os.MkdirParams{})!53	os.write_file(full, text)!54}5556fn make_save(store &storage.Store, message string) !history.Save {57	return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{58		message: message59	})!60}6162fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {63	return offer(store, mut backend)!64}6566fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {67	return steal(store, mut backend, StealOptions{68		theirs: true69	})!70}7172fn test_first_offer_publishes_the_whole_line() {73	base := scratch('first')!74	defer {75		os.rmdir_all(base) or {}76	}77	mut a := open_repo(machine('first_a')!)!78	defer {79		a.close()80	}81	put(a.root, 'a.txt', 'one\n')!82	make_save(a, 'first')!83	put(a.root, 'a.txt', 'two\n')!84	make_save(a, 'second')!85	mut backend := remote.new_folder(os.join_path(base, 'remote'))!8687	report := offer_now(a, mut backend)!88	assert report.repo == 'proj'89	assert report.saves == 290	assert report.objects >= 291	assert backend.get(head_key('proj'))!.found92	assert backend.list('proj/saves')!.len == 29394	// A second offer with nothing new uploads nothing and still succeeds.95	again := offer_now(a, mut backend)!96	assert again.saves == 097}9899fn test_the_repo_name_is_remembered_and_head_offered() {100	base := scratch('remember')!101	defer {102		os.rmdir_all(base) or {}103	}104	mut a := open_repo(machine('remember_a')!)!105	defer {106		a.close()107	}108	put(a.root, 'a.txt', 'one\n')!109	make_save(a, 'first')!110	mut backend := remote.new_folder(os.join_path(base, 'remote'))!111	offer_now(a, mut backend)!112	assert a.state('remote_repo') or { '' } == 'proj'113	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id114	// Offer's step 3: the new remote head is recorded in state.115	assert a.state('remote_head') or { '' } == head_of(a).id116117	// A second offer, with the remote already holding our head, must not118	// refuse: the name is remembered, so the remote is ours.119	put(a.root, 'a.txt', 'two\n')!120	make_save(a, 'second')!121	offer_now(a, mut backend)!122	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id123}124125fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {126	base := scratch('forward')!127	defer {128		os.rmdir_all(base) or {}129	}130	mut backend := remote.new_folder(os.join_path(base, 'remote'))!131	mut a := open_repo(machine('fwd_a')!)!132	defer {133		a.close()134	}135	put(a.root, 'a.txt', 'from A\n')!136	make_save(a, 'A writes')!137	offer_now(a, mut backend)!138139	// B starts empty and takes the whole repo.140	mut b := open_repo(machine('fwd_b')!)!141	defer {142		b.close()143	}144	fetched := fetch_repo(mut backend, 'proj')!145	b.begin()!146	write_fetched(b, fetched)!147	history.set_head(b, fetched.head)!148	b.end()!149	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!150	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'151	assert head_of(b).id == fetched.head152}153154// A machine that is only ahead has nothing to combine: stealing must leave its155// saves, and their IDs, exactly where they are.156fn test_steal_while_only_ahead_leaves_the_line_alone() {157	base := scratch('ahead')!158	defer {159		os.rmdir_all(base) or {}160	}161	mut backend := remote.new_folder(os.join_path(base, 'remote'))!162	mut a := open_repo(machine('ahead_a')!)!163	defer {164		a.close()165	}166	put(a.root, 'a.txt', 'one\n')!167	make_save(a, 'offered')!168	offer_now(a, mut backend)!169	put(a.root, 'a.txt', 'two\n')!170	ahead := make_save(a, 'not offered yet')!171	report := steal_now(a, mut backend)!172	assert report.replayed == 0173	assert head_of(a).id == ahead.id174	// Nothing was restored, so nothing is left marked as half-restored.175	assert !snapshot.restore_pending(a)176	assert history.all_saves(a)!.len == 2177	offer_now(a, mut backend)!178	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id179}180181// A link is invisible to the walk, so a tree holding one is clean; the files182// a steal brings in must still never land in the folder it points at.183fn test_steal_never_writes_through_a_symlinked_folder() {184	base := scratch('steal_link')!185	defer {186		os.rmdir_all(base) or {}187	}188	mut backend := remote.new_folder(os.join_path(base, 'remote'))!189	mut a := open_repo(machine('steal_link_a')!)!190	defer {191		a.close()192	}193	mut b := open_repo(machine('steal_link_b')!)!194	defer {195		b.close()196	}197	put(a.root, 'a.txt', 'one\n')!198	make_save(a, 'first')!199	offer_now(a, mut backend)!200	join(b, mut backend)!201	outside := os.join_path(base, 'outside')202	put(outside, 'a.md', 'precious\n')!203	os.symlink(outside, os.join_path(b.root, 'docs'))!204	put(a.root, 'docs/a.md', 'from A\n')!205	make_save(a, 'A adds docs')!206	offer_now(a, mut backend)!207	head_before := head_of(b).id208	if _ := steal_now(b, mut backend) {209		assert false, 'a steal writing through a symlinked folder must refuse'210	} else {211		assert err.msg().contains('docs')212	}213	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'214	assert head_of(b).id == head_before215}216217// A steal onto a half-restored tree would replay it as somebody's work.218fn test_steal_refuses_after_a_restore_cut_short() {219	base := scratch('steal_cut')!220	defer {221		os.rmdir_all(base) or {}222	}223	mut backend := remote.new_folder(os.join_path(base, 'remote'))!224	mut a := open_repo(machine('steal_cut_a')!)!225	defer {226		a.close()227	}228	put(a.root, 'a.txt', 'one\n')!229	make_save(a, 'first')!230	offer_now(a, mut backend)!231	a.set_state('restoring', 'yes')!232	if _ := steal_now(a, mut backend) {233		assert false, 'steal must refuse while a restore is unfinished'234	} else {235		assert err.msg().contains('koh revert')236	}237}238239// A replayed save keeps its original time but sits on a newer parent, so a240// steal cannot order saves by time.241fn test_a_steal_imports_parents_before_children() {242	base := scratch('steal_order')!243	defer {244		os.rmdir_all(base) or {}245	}246	mut backend := remote.new_folder(os.join_path(base, 'remote'))!247	mut a := open_repo(machine('order_a')!)!248	defer {249		a.close()250	}251	put(a.root, 'a.txt', 'one\n')!252	parent := make_save(a, 'parent')!253	child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{254		message:    'replayed, older than its parent'255		created_at: parent.created_at - 3600256	})!257	offer_now(a, mut backend)!258	mut b := open_repo(machine('order_b')!)!259	defer {260		b.close()261	}262	join(b, mut backend)!263	assert head_of(b).id == child.id264}265266// A chmod is unsaved work too: a steal would restore the old mode over it.267fn test_steal_refuses_an_unsaved_executable_bit_change() {268	base := scratch('chmod')!269	defer {270		os.rmdir_all(base) or {}271	}272	mut backend := remote.new_folder(os.join_path(base, 'remote'))!273	mut a := open_repo(machine('chmod_a')!)!274	defer {275		a.close()276	}277	put(a.root, 'run.sh', 'echo hi\n')!278	make_save(a, 'script')!279	offer_now(a, mut backend)!280	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!281	if _ := steal_now(a, mut backend) {282		assert false, 'a chmod-only change should make the tree dirty'283	} else {284		assert err.msg() == dirty_tree285	}286}287288fn test_divergence_without_clashes_keeps_both_machines_work() {289	base := scratch('diverge')!290	defer {291		os.rmdir_all(base) or {}292	}293	mut backend := remote.new_folder(os.join_path(base, 'remote'))!294	mut a := open_repo(machine('div_a')!)!295	defer {296		a.close()297	}298	mut b := open_repo(machine('div_b')!)!299	defer {300		b.close()301	}302	put(a.root, 'shared.txt', 'base\n')!303	make_save(a, 'base')!304	offer_now(a, mut backend)!305306	// B joins, then both machines change a different file.307	join(b, mut backend)!308	put(a.root, 'fromA.txt', 'A\n')!309	make_save(a, 'A adds a file')!310	put(b.root, 'fromB.txt', 'B\n')!311	make_save(b, 'B adds a file')!312	offer_now(a, mut backend)!313314	// B's offer is refused: the remote has moved on.315	if _ := offer_now(b, mut backend) {316		assert false, 'offering on top of unknown remote saves should be refused'317	} else {318		assert err.msg().contains('run koh steal')319	}320321	report := steal_now(b, mut backend)!322	assert !report.forward323	assert report.replayed == 1324	assert report.clashes.len == 0325	// Both files are on B's line now, and on disk.326	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'327	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'328	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'329	// B's own save was replayed, not erased.330	mut saw_replay := false331	for save in history.all_saves(b)! {332		if save.replayed_from != '' {333			saw_replay = true334		}335	}336	assert saw_replay337	// And the remote now has B's work too.338	offer_now(b, mut backend)!339	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()340	assert line_head == head_of(b).id341}342343// An agent that edits a file and then undoes the edit leaves no net change, so344// the file is not a clash when the other machine changed it. Replaying the345// edit itself must not stop the steal: the remote's version simply stands.346// Likewise a file both machines ended up changing the same way.347fn test_a_local_change_undone_does_not_block_a_combine() {348	base := scratch('undone')!349	defer {350		os.rmdir_all(base) or {}351	}352	mut backend := remote.new_folder(os.join_path(base, 'remote'))!353	mut a := open_repo(machine('undone_a')!)!354	defer {355		a.close()356	}357	mut b := open_repo(machine('undone_b')!)!358	defer {359		b.close()360	}361	put(a.root, 'f.txt', 'base\n')!362	put(a.root, 'g.txt', 'base\n')!363	make_save(a, 'base')!364	offer_now(a, mut backend)!365	join(b, mut backend)!366367	put(a.root, 'f.txt', 'from A\n')!368	put(a.root, 'g.txt', 'same\n')!369	make_save(a, 'A edits both')!370	offer_now(a, mut backend)!371372	put(b.root, 'f.txt', 'tried on B\n')!373	put(b.root, 'g.txt', 'first try\n')!374	tried := make_save(b, 'B tries')!375	put(b.root, 'f.txt', 'base\n')!376	put(b.root, 'g.txt', 'same\n')!377	put(b.root, 'h.txt', 'B\n')!378	undone := make_save(b, 'B undoes f, lands on the same g, adds h')!379380	report := steal_now(b, mut backend)!381	assert report.clashes.len == 0382	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'383	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'384	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'385	// "B tries" only touched files A changed, so it has nothing left to386	// replay; the second save carries h.txt. Both originals stay in history.387	assert report.replayed == 1388	assert head_of(b).replayed_from == undone.id389	ids := history.all_saves(b)!.map(it.id)390	assert tried.id in ids && undone.id in ids391	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')392}393394// A person deciding a clash may take minutes; the question must come before395// the write lock, or every agent's save on this repo waits and then fails.396fn test_clash_questions_are_asked_before_the_write_lock() {397	base := scratch('ask_unlocked')!398	defer {399		os.rmdir_all(base) or {}400	}401	mut backend := remote.new_folder(os.join_path(base, 'remote'))!402	mut a := open_repo(machine('ask_a')!)!403	defer {404		a.close()405	}406	mut b := open_repo(machine('ask_b')!)!407	defer {408		b.close()409	}410	put(a.root, 'same.txt', 'base\n')!411	make_save(a, 'base')!412	offer_now(a, mut backend)!413	join(b, mut backend)!414	put(a.root, 'same.txt', 'A version\n')!415	make_save(a, 'A edits same')!416	offer_now(a, mut backend)!417	put(b.root, 'same.txt', 'B version\n')!418	make_save(b, 'B edits same')!419	root := b.root420	// An agent saves while the person is still choosing.421	if _ := steal(b, mut backend, StealOptions{422		guide: fn [root] (preview StealPreview) !map[string]Side {423			other := storage.open(root) or { panic(err) }424			defer {425				other.close()426			}427			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }428			history.save_worktree(other, root, snapshot.load(root), history.SaveInput{429				message: 'agent saves during the question'430			}) or { panic('steal held the write lock while asking: ${err}') }431			return {432				preview.clashes[0].path: Side.mine433			}434		}435	})436	{437		assert false, 'a steal whose line moved during the question must refuse'438	} else {439		assert err.msg().contains('changed while')440	}441	// The agent's save is still head, and a second steal combines it too.442	assert head_of(b).message == 'agent saves during the question'443	report := steal(b, mut backend, StealOptions{444		mine: true445	})!446	assert report.kept_mine == 1447	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'448	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'449}450451fn test_clashing_files_are_chosen_one_at_a_time() {452	base := scratch('clash')!453	defer {454		os.rmdir_all(base) or {}455	}456	mut backend := remote.new_folder(os.join_path(base, 'remote'))!457	mut a := open_repo(machine('clash_a')!)!458	defer {459		a.close()460	}461	mut b := open_repo(machine('clash_b')!)!462	defer {463		b.close()464	}465	put(a.root, 'same.txt', 'base\n')!466	put(a.root, 'other.txt', 'quiet\n')!467	make_save(a, 'base')!468	offer_now(a, mut backend)!469	join(b, mut backend)!470471	// Both machines edit the same file, differently.472	put(a.root, 'same.txt', 'A version\n')!473	make_save(a, 'A edits same')!474	offer_now(a, mut backend)!475	put(b.root, 'same.txt', 'B version\n')!476	make_save(b, 'B edits same')!477478	// Without a decision, steal refuses and says which files clash.479	if _ := steal(b, mut backend, StealOptions{}) {480		assert false, 'an undecided clash must be refused'481	} else {482		assert err.msg().contains('--mine') || err.msg().contains('--theirs')483		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.484		if err is ClashError {485			assert err.paths == ['same.txt']486		} else {487			assert false, 'an undecided clash must be a ClashError, not ${err}'488		}489	}490	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,491		head_of(b).id)!).len == 0492493	// Choosing the remote's version ends with A's content.494	report := steal_now(b, mut backend)!495	assert report.clashes == ['same.txt']496	assert report.kept_mine == 0497	assert report.kept_versions == ['same.txt:${report.previous_head}']498	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'499	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'500}501502fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {503	base := scratch('guided_cancel')!504	defer { os.rmdir_all(base) or {} }505	mut backend := remote.new_folder(os.join_path(base, 'remote'))!506	mut a := open_repo(machine('guided_cancel_a')!)!507	defer { a.close() }508	mut b := open_repo(machine('guided_cancel_b')!)!509	defer { b.close() }510	put(a.root, 'same.txt', 'base\n')!511	make_save(a, 'base')!512	offer_now(a, mut backend)!513	join(b, mut backend)!514	put(a.root, 'same.txt', 'remote\n')!515	remote_head := make_save(a, 'remote change')!516	offer_now(a, mut backend)!517	put(b.root, 'same.txt', 'mine\n')!518	local_head := make_save(b, 'local change')!519520	if _ := steal(b, mut backend, StealOptions{521		guide: fn (preview StealPreview) !map[string]Side {522			assert preview.clashes.len == 1523			return error('cancelled')524		}525	}) {526		assert false, 'the guide cancelled'527	} else {528		assert err.msg() == 'cancelled'529	}530	assert head_of(b).id == local_head.id531	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'532	assert history.by_id(b, remote_head.id) != none533	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1534}535536fn test_choosing_mine_keeps_the_local_version() {537	base := scratch('mine')!538	defer {539		os.rmdir_all(base) or {}540	}541	mut backend := remote.new_folder(os.join_path(base, 'remote'))!542	mut a := open_repo(machine('mine_a')!)!543	defer {544		a.close()545	}546	mut b := open_repo(machine('mine_b')!)!547	defer {548		b.close()549	}550	put(a.root, 'same.txt', 'base\n')!551	make_save(a, 'base')!552	offer_now(a, mut backend)!553	join(b, mut backend)!554	put(a.root, 'same.txt', 'A version\n')!555	make_save(a, 'A edits')!556	offer_now(a, mut backend)!557	put(b.root, 'same.txt', 'B version\n')!558	make_save(b, 'B edits')!559560	report := steal(b, mut backend, StealOptions{561		mine: true562	})!563	assert report.kept_mine == 1564	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'565	// The final save says what it kept.566	assert head_of(b).message.contains('combine: kept mine for 1 file')567	// The pre-combine tree is still reachable in history.568	assert head_of(b).restores == ''569	assert history.all_saves(b)!.len >= 4570}571572fn test_flags_travel_with_the_saves() {573	base := scratch('flags')!574	defer {575		os.rmdir_all(base) or {}576	}577	mut backend := remote.new_folder(os.join_path(base, 'remote'))!578	mut a := open_repo(machine('flag_a')!)!579	defer {580		a.close()581	}582	mut b := open_repo(machine('flag_b')!)!583	defer {584		b.close()585	}586	put(a.root, 'a.txt', 'one\n')!587	save := make_save(a, 'suspect save')!588	events.flag(a, save.id, 'look at this')!589	offer_now(a, mut backend)!590591	// B has nothing yet, so a fetch brings the save and its flag across.592	fetched := fetch_back_to_known(b, mut backend, 'proj')!593	assert fetched.events_out.len == 1594	assert fetched.events_out[0].kind == events.kind_flag595	b.begin()!596	write_fetched(b, fetched)!597	history.set_head(b, fetched.head)!598	b.end()!599	state := events.flag_state(b, save.id)!600	assert state.flagged601	assert state.reason == 'look at this'602}603604fn test_a_second_offer_racing_head_is_refused_not_forced() {605	base := scratch('race')!606	defer {607		os.rmdir_all(base) or {}608	}609	mut backend := remote.new_folder(os.join_path(base, 'remote'))!610	mut a := open_repo(machine('race_a')!)!611	defer {612		a.close()613	}614	put(a.root, 'a.txt', 'one\n')!615	make_save(a, 'base')!616	offer_now(a, mut backend)!617618	// Someone else replaces head behind our back.619	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),620		backend.get(head_key('proj'))!.tag)!621622	put(a.root, 'a.txt', 'two\n')!623	make_save(a, 'second')!624	if _ := offer_now(a, mut backend) {625		assert false, 'offering while the remote head moved must be refused'626	} else {627		assert err.msg().contains('run koh steal')628	}629}630631// join makes a brand new machine share the remote's line.632fn join(store &storage.Store, mut backend remote.Backend) ! {633	fetched := fetch_repo(mut backend, 'proj')!634	store.begin()!635	write_fetched(store, fetched)!636	history.set_head(store, fetched.head)!637	store.end()!638	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,639		fetched.head)!, [])!640	store.set_state('remote_repo', 'proj')!641}642643struct FaultyBackend {644mut:645	inner      remote.Backend646	writes     int647	reads      int648	fail_write int // fail when the write counter reaches this, 0 never649	fail_read  int650	// fail every delete while set651	fail_delete bool652	// race_format stands in for another machine writing the format key first:653	// the first `format` write loses, to a value this koh does not speak.654	race_format bool655	raced       bool656}657658fn (mut f FaultyBackend) get(key string) !remote.Object {659	f.reads++660	if f.fail_read > 0 && f.reads >= f.fail_read {661		return error('injected read failure')662	}663	return f.inner.get(key)!664}665666fn (mut f FaultyBackend) list(prefix string) ![]string {667	return f.inner.list(prefix)!668}669670fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {671	f.writes++672	if f.fail_write > 0 && f.writes >= f.fail_write {673		return error('injected write failure')674	}675	if f.race_format && !f.raced && key.ends_with('/format') {676		f.raced = true677		f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!678		return false679	}680	return f.inner.put_if_absent(key, body)!681}682683fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {684	f.writes++685	if f.fail_write > 0 && f.writes >= f.fail_write {686		return error('injected write failure')687	}688	return f.inner.put_if_match(key, body, tag)!689}690691fn (mut f FaultyBackend) delete(key string) ! {692	if f.fail_delete {693		return error('injected delete failure')694	}695	f.inner.delete(key)!696}697698// Every save offers after it is made, so a one-file change must cost one699// object, not one conditional write per file in the repo: on S3 each of those700// is a round trip.701fn test_an_offer_sends_only_the_objects_a_save_adds() {702	base := scratch('offer_diff')!703	defer {704		os.rmdir_all(base) or {}705	}706	mut a := open_repo(machine('offer_diff_a')!)!707	defer {708		a.close()709	}710	for i in 0 .. 20 {711		put(a.root, 'f${i}.txt', 'file ${i}\n')!712	}713	make_save(a, 'twenty files')!714	mut backend := FaultyBackend{715		inner: remote.new_folder(os.join_path(base, 'remote'))!716	}717	offer(a, mut backend)!718	put(a.root, 'f3.txt', 'changed\n')!719	make_save(a, 'one change')!720	before := backend.writes721	report := offer(a, mut backend)!722	assert report.objects == 1723	// One object, one save record, one head swap.724	assert backend.writes - before == 3725}726727fn test_a_failed_offer_is_safe_to_re_run() {728	base := scratch('fault_offer')!729	defer {730		os.rmdir_all(base) or {}731	}732	remote_dir := os.join_path(base, 'remote')733	mut a := open_repo(machine('fault_a')!)!734	defer {735		a.close()736	}737	put(a.root, 'a.txt', 'one\n')!738	make_save(a, 'first')!739	put(a.root, 'a.txt', 'two\n')!740	make_save(a, 'second')!741742	// The first offer dies after the first write.743	mut flaky := FaultyBackend{744		inner:      remote.new_folder(remote_dir)!745		fail_write: 2746	}747	if _ := offer(a, mut flaky) {748		assert false, 'the injected failure should have surfaced'749	}750751	// Re-running with a healthy remote finishes the job: "already exists" is752	// success, so nothing is lost and nothing is duplicated.753	mut healthy := remote.new_folder(remote_dir)!754	report := offer(a, mut healthy)!755	assert report.saves == 2756	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id757	assert healthy.list('proj/saves')!.len == 2758}759760fn test_a_failed_steal_leaves_nothing_behind() {761	base := scratch('fault_steal')!762	defer {763		os.rmdir_all(base) or {}764	}765	remote_dir := os.join_path(base, 'remote')766	mut a := open_repo(machine('fault_s_a')!)!767	defer {768		a.close()769	}770	mut b := open_repo(machine('fault_s_b')!)!771	defer {772		b.close()773	}774	put(a.root, 'a.txt', 'from A\n')!775	make_save(a, 'A writes')!776	mut healthy := remote.new_folder(remote_dir)!777	offer(a, mut healthy)!778779	// B's fetch fails half way through reading the remote.780	mut flaky := FaultyBackend{781		inner:     remote.new_folder(remote_dir)!782		fail_read: 2783	}784	if _ := fetch_back_to_known(b, mut flaky, 'proj') {785		assert false, 'the injected read failure should have surfaced'786	}787	// Nothing was written: the store is untouched and still has no saves.788	assert history.head(b) == none789	assert b.object_count()! == 0790791	// A healthy retry works.792	fetched := fetch_back_to_known(b, mut healthy, 'proj')!793	b.begin()!794	write_fetched(b, fetched)!795	history.set_head(b, fetched.head)!796	b.end()!797	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!798	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'799}800801fn test_an_interrupted_strip_is_idempotent() {802	// The local half of a strip is one transaction, so an interrupted strip is803	// simply a strip that did not happen; re-running it must land the same804	// result once, not twice.805	base := scratch('fault_strip')!806	defer {807		os.rmdir_all(base) or {}808	}809	mut a := open_repo(machine('fault_strip_a')!)!810	defer {811		a.close()812	}813	put(a.root, 'secret.txt', 'shh\n')!814	put(a.root, 'keep.txt', 'fine\n')!815	save := make_save(a, 'add both')!816817	for _ in 0 .. 2 {818		strip_local(a, 'secret.txt', '')!819	}820	files := history.files_of(a, save.id)!821	// The repo's own `.kohignore` is tracked content, so it stays; only the822	// stripped path goes.823	assert files.map(it.path) == ['.kohignore', 'keep.txt']824	assert events.strip_events(a)!.len == 1825}826827// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into828// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.829// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,830// but a save made on Linux can hold both.831fn test_a_strip_matches_the_path_exactly() {832	mut a := open_repo(machine('strip_exact')!)!833	defer {834		a.close()835	}836	hash := a.put_object('x\n'.bytes())!837	mut files := []snapshot.FileEntry{}838	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {839		files << snapshot.FileEntry{840			path: path841			hash: hash842		}843	}844	save := history.save_tree(a, '', files, history.SaveInput{845		message: 'add all'846	})!847	assert strip_scope(a, 'docs', '')!.files == 1848	strip_local(a, 'docs', '')!849	strip_local(a, 'a_b', '')!850	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']851}852853fn test_a_strip_reaches_the_remote_and_other_machines() {854	base := scratch('strip_sync')!855	defer {856		os.rmdir_all(base) or {}857	}858	mut a := open_repo(machine('strip_a')!)!859	defer {860		a.close()861	}862	mut b := open_repo(machine('strip_b')!)!863	defer {864		b.close()865	}866	put(a.root, 'secrets.env', 'api_key=1\n')!867	put(a.root, 'keep.txt', 'fine\n')!868	save := make_save(a, 'add both')!869	mut backend := remote.new_folder(os.join_path(base, 'remote'))!870	offer_now(a, mut backend)!871	join(b, mut backend)!872	assert b.object_count()! > 0873874	// A strips the path locally, the way `koh strip` does, then tells the875	// remote.876	mut secret_hash := ''877	for file in history.files_of(a, save.id)! {878		if file.path == 'secrets.env' {879			secret_hash = file.hash880		}881	}882	assert secret_hash != ''883	strip_local(a, 'secrets.env', '')!884	report := send_strip(a, mut backend, 'secrets.env')!885	assert report.event_sent886	assert report.records == 1887888	// The remote's copy of the save no longer lists the file, and the object is889	// gone from the remote.890	record := decode_save(backend.get(save_key('proj', save.id))!.body)!891	for file in record.files {892		assert file.path != 'secrets.env'893	}894	// The stripped object is gone from the remote; the ones still referenced895	// are not.896	assert !backend.get(object_key('proj', secret_hash))!.found897	assert backend.list('proj/objects')!.len == 2898899	// Another machine hears about it on its next steal and applies it.900	b.begin()!901	fetched := fetch_back_to_known(b, mut backend, 'proj')!902	write_fetched(b, fetched)!903	b.end()!904	mut paths := []string{}905	for file in history.files_of(b, save.id)! {906		paths << file.path907	}908	assert 'secrets.env' !in paths909	assert 'keep.txt' in paths910911	// The event arrives under its own ID, so it is applied once: B's offer adds912	// no second copy. The path is banned for good: written again, the file is913	// neither unsaved work nor ever walked into a save.914	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)915	put(b.root, 'secrets.env', 'rotated\n')!916	assert !dirty(b, history.head(b))!917	put(b.root, 'keep.txt', 'still fine\n')!918	later := make_save(b, 'edit keep')!919	offer_now(b, mut backend)!920	assert backend.list('proj/events')!.len == 1921	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)922}923924// A strip removes a path from all of history, whenever a save was made. A925// machine that saved the file after the strip, before it heard of it, loses926// it from that save too on its next steal, so no copy anywhere keeps it.927fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {928	base := scratch('strip_later')!929	defer {930		os.rmdir_all(base) or {}931	}932	mut backend := remote.new_folder(os.join_path(base, 'remote'))!933	mut a := open_repo(machine('strip_later_a')!)!934	defer {935		a.close()936	}937	mut b := open_repo(machine('strip_later_b')!)!938	defer {939		b.close()940	}941	put(a.root, 'secrets.env', 'api_key=1\n')!942	old := make_save(a, 'leak the key')!943	offer_now(a, mut backend)!944	join(b, mut backend)!945	strip_local(a, 'secrets.env', '')!946	send_strip(a, mut backend, 'secrets.env')!947	// B has not heard, and saves the file again an hour after the strip.948	put(b.root, 'secrets.env', 'api_key=2\n')!949	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{950		message:    'rotate the key'951		created_at: events.strip_events(a)!.last().created_at + 3600952	})!953	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)954	b.begin()!955	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!956	b.end()!957	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)958	offer_now(b, mut backend)!959960	mut c := open_repo(machine('strip_later_c')!)!961	defer {962		c.close()963	}964	join(c, mut backend)!965	for save in [old.id, later.id] {966		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)967	}968	assert !os.exists(os.join_path(c.root, 'secrets.env'))969}970971// A strip this machine already holds bans its path from saves that arrive972// later too, not only from those a fetch brings with the strip.973fn test_a_held_strip_reaches_saves_arriving_after_it() {974	base := scratch('strip_held')!975	defer {976		os.rmdir_all(base) or {}977	}978	mut backend := remote.new_folder(os.join_path(base, 'remote'))!979	mut a := open_repo(machine('strip_held_a')!)!980	defer {981		a.close()982	}983	mut b := open_repo(machine('strip_held_b')!)!984	defer {985		b.close()986	}987	put(a.root, 'secrets.env', 'api_key=1\n')!988	make_save(a, 'leak the key')!989	offer_now(a, mut backend)!990	join(b, mut backend)!991	strip_local(b, 'secrets.env', '')!992	put(a.root, 'keep.txt', 'fine\n')!993	later := make_save(a, 'add keep')!994	offer_now(a, mut backend)!995	b.begin()!996	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!997	b.end()!998	paths := history.files_of(b, later.id)!.map(it.path)999	assert 'secrets.env' !in paths1000	assert 'keep.txt' in paths1001}10021003// StripRace stands in for another machine that strips `path` completely, event1004// and rewrite, just before this machine's first save record lands.1005struct StripRace {1006mut:1007	inner remote.Backend1008	other &storage.Store1009	path  string1010	done  bool1011}10121013fn (mut r StripRace) get(key string) !remote.Object {1014	return r.inner.get(key)!1015}10161017fn (mut r StripRace) list(prefix string) ![]string {1018	return r.inner.list(prefix)!1019}10201021fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1022	if !r.done && key.contains('/saves/') {1023		r.done = true1024		strip_local(r.other, r.path, '')!1025		send_strip(r.other, mut r.inner, r.path)!1026	}1027	return r.inner.put_if_absent(key, body)!1028}10291030fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1031	return r.inner.put_if_match(key, body, tag)!1032}10331034fn (mut r StripRace) delete(key string) ! {1035	r.inner.delete(key)!1036}10371038// A strip that finishes on another machine while an offer uploads has already1039// rewritten the remote without the offer's records, so the offer takes back1040// what the strip reaches and leaves head where it was.1041fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1042	base := scratch('strip_race')!1043	defer {1044		os.rmdir_all(base) or {}1045	}1046	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1047	mut a := open_repo(machine('strip_race_a')!)!1048	defer {1049		a.close()1050	}1051	mut b := open_repo(machine('strip_race_b')!)!1052	defer {1053		b.close()1054	}1055	put(a.root, 'secrets.env', 'api_key=1\n')!1056	first := make_save(a, 'leak the key')!1057	offer_now(a, mut backend)!1058	join(b, mut backend)!1059	put(a.root, 'secrets.env', 'api_key=2\n')!1060	later := make_save(a, 'rotate the key')!1061	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1062	mut race := StripRace{1063		inner: backend1064		other: &b1065		path:  'secrets.env'1066	}1067	if _ := offer(a, mut race) {1068		assert false, 'an offer that raced a strip must refuse'1069	} else {1070		assert err.msg().contains('koh steal'), err.msg()1071	}1072	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1073	assert !backend.get(object_key('proj', secret))!.found1074	for key in backend.list('proj/saves')! {1075		record := decode_save(backend.get(key)!.body)!1076		assert 'secrets.env' !in record.files.map(it.path), record.id1077	}1078}10791080// The local half lands before the remote hears anything, so a remote failure1081// leaves nothing to find locally; the pending note is what lets a re-run1082// finish. Objects a remote-only save still needs must survive the strip.1083fn test_an_interrupted_remote_strip_finishes_when_re_run() {1084	base := scratch('strip_resume')!1085	defer {1086		os.rmdir_all(base) or {}1087	}1088	remote_dir := os.join_path(base, 'remote')1089	mut backend := remote.new_folder(remote_dir)!1090	mut a := open_repo(machine('resume_a')!)!1091	defer {1092		a.close()1093	}1094	mut b := open_repo(machine('resume_b')!)!1095	defer {1096		b.close()1097	}1098	put(a.root, 'secrets.env', 'api_key=1\n')!1099	put(a.root, 'shared.env', 'same bytes on B\n')!1100	save := make_save(a, 'add secrets')!1101	offer_now(a, mut backend)!1102	join(b, mut backend)!1103	put(b.root, 'copy.txt', 'same bytes on B\n')!1104	b_save := make_save(b, 'B keeps a copy')!1105	offer_now(b, mut backend)!1106	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1107	secret_hash := storage.hash_hex('api_key=1\n'.bytes())11081109	strip_local(a, 'secrets.env', '')!1110	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1111	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1112	mut flaky := FaultyBackend{1113		inner:      remote.new_folder(remote_dir)!1114		fail_write: 31115	}1116	if _ := send_strip(a, mut flaky, 'secrets.env') {1117		assert false, 'the injected failure should have surfaced'1118	}1119	assert strip_pending(a, 'secrets.env')1120	assert strip_local(a, 'secrets.env', '')!.files_removed == 01121	report := send_strip(a, mut backend, 'secrets.env')!1122	assert report.records == 11123	assert !strip_pending(a, 'secrets.env')1124	assert !backend.get(object_key('proj', secret_hash))!.found1125	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1126	assert 'secrets.env' !in record.files.map(it.path)1127	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1128	assert 'secrets.env' !in b_record.files.map(it.path)11291130	// B's save still names the shared bytes, so stripping A's copy keeps them.1131	strip_local(a, 'shared.env', '')!1132	send_strip(a, mut backend, 'shared.env')!1133	assert backend.get(object_key('proj', shared_hash))!.found1134}11351136// A machine that has not heard of a strip must not offer: its records still1137// list the stripped path, and nothing would rewrite them on the remote again.1138fn test_offer_waits_for_an_unapplied_strip() {1139	base := scratch('strip_first')!1140	defer {1141		os.rmdir_all(base) or {}1142	}1143	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1144	mut a := open_repo(machine('strip_first_a')!)!1145	defer {1146		a.close()1147	}1148	mut b := open_repo(machine('strip_first_b')!)!1149	defer {1150		b.close()1151	}1152	put(a.root, 'secrets.env', 'api_key=1\n')!1153	make_save(a, 'add secrets')!1154	offer_now(a, mut backend)!1155	join(b, mut backend)!1156	strip_local(a, 'secrets.env', '')!1157	send_strip(a, mut backend, 'secrets.env')!1158	put(b.root, 'more.txt', 'B keeps working\n')!1159	later := make_save(b, 'B saves with the secret still in its tree')!1160	if _ := offer_now(b, mut backend) {1161		assert false, 'B offered records that still list the stripped path'1162	}1163	steal_now(b, mut backend)!1164	offer_now(b, mut backend)!1165	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1166	assert 'secrets.env' !in record.files.map(it.path)1167}11681169// A strip whose object deletions failed must still delete them when it is run1170// again, and meanwhile the banned path cannot come back in through a save.1171fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1172	base := scratch('strip_twice')!1173	defer {1174		os.rmdir_all(base) or {}1175	}1176	remote_dir := os.join_path(base, 'remote')1177	mut backend := remote.new_folder(remote_dir)!1178	mut a := open_repo(machine('strip_twice_a')!)!1179	defer {1180		a.close()1181	}1182	put(a.root, 'secrets.env', 'first key\n')!1183	make_save(a, 'first key')!1184	offer_now(a, mut backend)!1185	strip_local(a, 'secrets.env', '')!1186	mut flaky := FaultyBackend{1187		inner:       remote.new_folder(remote_dir)!1188		fail_delete: true1189	}1190	if _ := send_strip(a, mut flaky, 'secrets.env') {1191		assert false, 'the injected delete failure should have surfaced'1192	}1193	put(a.root, 'secrets.env', 'second key\n')!1194	put(a.root, 'other.txt', 'more work\n')!1195	make_save(a, 'more work')!1196	offer_now(a, mut backend)!1197	send_strip(a, mut backend, 'secrets.env')!1198	for key in ['first key\n', 'second key\n'] {1199		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1200	}1201}12021203// Unfinished strips of one path in different scopes finish together.1204fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1205	base := scratch('strip_scopes')!1206	defer {1207		os.rmdir_all(base) or {}1208	}1209	remote_dir := os.join_path(base, 'remote')1210	mut backend := remote.new_folder(remote_dir)!1211	mut a := open_repo(machine('strip_scopes_a')!)!1212	defer {1213		a.close()1214	}1215	put(a.root, 'secrets.env', 'first key\n')!1216	first := make_save(a, 'first key')!1217	put(a.root, 'secrets.env', 'second key\n')!1218	second := make_save(a, 'second key')!1219	offer_now(a, mut backend)!1220	strip_local(a, 'secrets.env', first.id)!1221	// Write 1 uploads the event; write 2, the record rewrite, fails.1222	mut flaky := FaultyBackend{1223		inner:      remote.new_folder(remote_dir)!1224		fail_write: 21225	}1226	if _ := send_strip(a, mut flaky, 'secrets.env') {1227		assert false, 'the injected failure should have surfaced'1228	}1229	strip_local(a, 'secrets.env', second.id)!1230	send_strip(a, mut backend, 'secrets.env')!1231	assert !strip_pending(a, 'secrets.env')1232	for save in [first, second] {1233		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1234		assert 'secrets.env' !in record.files.map(it.path)1235	}1236	for key in ['first key\n', 'second key\n'] {1237		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1238	}1239}12401241// A strip bans its path on every machine that has heard of it: written again,1242// the file makes no save, here or there, so there is never a second copy to strip.1243fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1244	base := scratch('strip_again')!1245	defer {1246		os.rmdir_all(base) or {}1247	}1248	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1249	mut a := open_repo(machine('strip_again_a')!)!1250	defer {1251		a.close()1252	}1253	mut b := open_repo(machine('strip_again_b')!)!1254	defer {1255		b.close()1256	}1257	put(a.root, 'secrets.env', 'first key\n')!1258	make_save(a, 'first key')!1259	offer_now(a, mut backend)!1260	join(b, mut backend)!1261	strip_local(a, 'secrets.env', '')!1262	send_strip(a, mut backend, 'secrets.env')!1263	steal_now(b, mut backend)!1264	for repo in [a, b] {1265		put(repo.root, 'secrets.env', 'second key\n')!1266		if _ := make_save(repo, 'second key') {1267			assert false, 'a banned path must not make a save'1268		}1269	}1270}12711272// A clash on a path the other machine stripped is no clash: the strip removes1273// the path from every save, so there is nothing to choose.1274fn test_a_stripped_path_is_not_asked_about() {1275	base := scratch('clash_strip')!1276	defer {1277		os.rmdir_all(base) or {}1278	}1279	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1280	mut a := open_repo(machine('clash_strip_a')!)!1281	defer {1282		a.close()1283	}1284	mut b := open_repo(machine('clash_strip_b')!)!1285	defer {1286		b.close()1287	}1288	put(a.root, 'secrets.env', 'key one\n')!1289	make_save(a, 'base')!1290	offer_now(a, mut backend)!1291	join(b, mut backend)!1292	put(a.root, 'secrets.env', 'key two\n')!1293	make_save(a, 'A rotates')!1294	offer_now(a, mut backend)!1295	strip_local(a, 'secrets.env', '')!1296	send_strip(a, mut backend, 'secrets.env')!1297	put(b.root, 'secrets.env', 'key three\n')!1298	mine := make_save(b, 'B rotates')!1299	report := steal(b, mut backend, StealOptions{})!1300	assert report.clashes.len == 01301	for save in history.all_saves(b)! {1302		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1303	}1304	assert history.by_id(b, mine.id) != none1305}13061307// A strip scoped to the shared base save changes what the replay sees; the1308// clashes must be found on the same trees, or the replay meets a conflict.1309fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1310	base := scratch('clash_base_strip')!1311	defer {1312		os.rmdir_all(base) or {}1313	}1314	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1315	mut a := open_repo(machine('base_strip_a')!)!1316	defer {1317		a.close()1318	}1319	mut b := open_repo(machine('base_strip_b')!)!1320	defer {1321		b.close()1322	}1323	put(a.root, 'notes.txt', 'shared\n')!1324	common := make_save(a, 'base')!1325	offer_now(a, mut backend)!1326	join(b, mut backend)!1327	put(a.root, 'other.txt', 'A moves on\n')!1328	make_save(a, 'A moves on')!1329	offer_now(a, mut backend)!1330	strip_local(a, 'notes.txt', common.id)!1331	send_strip(a, mut backend, 'notes.txt')!1332	put(b.root, 'notes.txt', 'B edits\n')!1333	make_save(b, 'B edits notes')!1334	if _ := steal(b, mut backend, StealOptions{}) {1335		assert false, 'with the base stripped, notes.txt differs on both sides'1336	} else {1337		assert err.msg().contains('notes.txt changed on both machines')1338	}1339	report := steal(b, mut backend, StealOptions{1340		mine: true1341	})!1342	assert report.clashes == ['notes.txt']1343	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1344}13451346// A folder with files but no saves has nothing to combine with; a steal that1347// fast-forwarded into it would overwrite whatever shares a name.1348fn test_steal_into_a_folder_with_unsaved_files_refuses() {1349	base := scratch('steal_unsaved')!1350	defer {1351		os.rmdir_all(base) or {}1352	}1353	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1354	mut a := open_repo(machine('unsaved_a')!)!1355	defer {1356		a.close()1357	}1358	put(a.root, 'notes.txt', 'from A\n')!1359	make_save(a, 'A writes')!1360	offer_now(a, mut backend)!1361	b_root := machine('unsaved_b')!1362	mut b := storage.create(b_root)!1363	defer {1364		b.close()1365	}1366	put(b_root, 'notes.txt', 'never saved\n')!1367	// Only a repo that already knows its remote name gets this far.1368	b.set_state('remote_repo', 'proj')!1369	if _ := steal(b, mut backend, StealOptions{}) {1370		assert false, 'steal overwrote a folder with unsaved files'1371	} else {1372		assert err.msg() == no_saves_here1373	}1374	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1375	// A fresh `koh init` folder, holding only the default ignore list, is fine.1376	os.rm(os.join_path(b_root, 'notes.txt'))!1377	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1378	steal(b, mut backend, StealOptions{})!1379	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1380}13811382// The document asks steal to warn about every incoming flagged save, so the1383// report names them.1384fn test_steal_reports_incoming_flagged_saves() {1385	base := scratch('flag_warn')!1386	defer {1387		os.rmdir_all(base) or {}1388	}1389	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1390	mut a := open_repo(machine('flag_warn_a')!)!1391	defer {1392		a.close()1393	}1394	mut b := open_repo(machine('flag_warn_b')!)!1395	defer {1396		b.close()1397	}1398	put(a.root, 'a.txt', 'one\n')!1399	make_save(a, 'fine')!1400	offer_now(a, mut backend)!1401	join(b, mut backend)!1402	put(a.root, 'a.txt', 'two\n')!1403	suspect := make_save(a, 'suspect')!1404	put(a.root, 'a.txt', 'three\n')!1405	make_save(a, 'also fine')!1406	events.flag(a, suspect.id, 'broke the build')!1407	offer_now(a, mut backend)!1408	report := steal_now(b, mut backend)!1409	assert report.flagged.map(it.save_id) == [suspect.id]1410	assert report.flagged[0].reason == 'broke the build'1411}14121413// head_of unwraps the head for tests that know the repo has saves.1414fn head_of(store &storage.Store) history.Save {1415	return history.head(store) or { panic('this repo has no head') }1416}14171418// A remote says what it is, so two koh versions never half-understand one1419// bucket. The first offer of a repo records the layout.1420fn test_the_first_offer_records_the_remote_format() {1421	base := scratch('format_write')!1422	defer {1423		os.rmdir_all(base) or {}1424	}1425	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1426	mut a := open_repo(machine('format_write_a')!)!1427	defer {1428		a.close()1429	}1430	put(a.root, 'a.txt', 'one\n')!1431	make_save(a, 'first')!1432	offer_now(a, mut backend)!1433	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1434}14351436// A repo offered before the key existed is version 1: it still syncs, and the1437// next offer records the key then.1438fn test_a_remote_without_a_format_still_syncs() {1439	base := scratch('format_old')!1440	defer {1441		os.rmdir_all(base) or {}1442	}1443	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1444	mut a := open_repo(machine('format_old_a')!)!1445	defer {1446		a.close()1447	}1448	put(a.root, 'a.txt', 'one\n')!1449	make_save(a, 'first')!1450	offer_now(a, mut backend)!1451	// Stand in for a bucket offered before the key existed.1452	backend.delete(format_key('proj'))!1453	mut b := open_repo(machine('format_old_b')!)!1454	defer {1455		b.close()1456	}1457	join(b, mut backend)!1458	assert head_of(b).id == head_of(a).id1459	put(a.root, 'a.txt', 'two\n')!1460	make_save(a, 'second')!1461	offer_now(a, mut backend)!1462	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1463	report := steal_now(b, mut backend)!1464	assert report.head == head_of(a).id1465}14661467// A remote that speaks another layout is refused loudly, on offer and on1468// steal, rather than half-read.1469fn test_an_unknown_remote_format_is_refused() {1470	base := scratch('format_unknown')!1471	defer {1472		os.rmdir_all(base) or {}1473	}1474	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1475	mut a := open_repo(machine('format_bad_a')!)!1476	defer {1477		a.close()1478	}1479	put(a.root, 'a.txt', 'one\n')!1480	make_save(a, 'first')!1481	offer_now(a, mut backend)!1482	backend.delete(format_key('proj'))!1483	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1484	if _ := offer_now(a, mut backend) {1485		assert false, 'offer must refuse a format it does not speak'1486	} else {1487		assert err.msg().contains('koh-remote 2')1488	}1489	if _ := steal_now(a, mut backend) {1490		assert false, 'steal must refuse a format it does not speak'1491	} else {1492		assert err.msg().contains('koh-remote 2')1493	}1494	if _ := fetch_repo(mut backend, 'proj') {1495		assert false, 'a steal must refuse a format it does not speak'1496	} else {1497		assert err.msg().contains('koh-remote 2')1498	}1499}15001501// Two machines offering a new repo at once both find no format key; the loser1502// of the conditional write must read back the winner's value and refuse it,1503// not carry on against a remote it cannot read.1504fn test_a_lost_format_write_is_read_back_and_refused() {1505	base := scratch('format_race')!1506	defer {1507		os.rmdir_all(base) or {}1508	}1509	mut inner := remote.new_folder(os.join_path(base, 'remote'))!1510	mut a := open_repo(machine('format_race_a')!)!1511	defer {1512		a.close()1513	}1514	put(a.root, 'a.txt', 'one\n')!1515	make_save(a, 'first')!1516	mut backend := remote.Backend(FaultyBackend{1517		inner:       inner1518		race_format: true1519	})1520	if _ := offer_now(a, mut backend) {1521		assert false, 'offer must refuse the format another machine wrote first'1522	} else {1523		assert err.msg().contains('koh-remote 2')1524	}1525}