sync_test.v

35.5 KB · raw

1//! Two-machine tests: two repos and one folder remote standing in for the Mac2//! and the PC. This is where offer, steal and combine are actually proven —3//! the folder backend exists so these run offline.45module sync67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage1314// Machines keep their repo in a folder of the same NAME, because the remote key15// is the folder's name; that is what makes them the same repo to the remote.16fn machine(name string) !string {17	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')18	os.rmdir_all(os.dir(dir)) or {}19	os.mkdir_all(dir, os.MkdirParams{})!20	return dir21}2223fn scratch(name string) !string {24	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')25	os.rmdir_all(dir) or {}26	os.mkdir_all(dir, os.MkdirParams{})!27	return dir28}2930fn open_repo(dir string) !storage.Store {31	mut store := storage.create(dir)!32	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!33	return store34}3536fn put(root string, rel string, text string) ! {37	full := os.join_path(root, rel)38	os.mkdir_all(os.dir(full), os.MkdirParams{})!39	os.write_file(full, text)!40}4142fn commit(store &storage.Store, message string) !history.Save {43	return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{44		message: message45	})!46}4748fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {49	return offer(store, mut backend)!50}5152fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {53	return steal(store, mut backend, StealOptions{54		theirs: true55	})!56}5758fn test_first_offer_publishes_the_whole_line() {59	base := scratch('first')!60	defer {61		os.rmdir_all(base) or {}62	}63	mut a := open_repo(machine('first_a')!)!64	defer {65		a.close()66	}67	put(a.root, 'a.txt', 'one\n')!68	commit(a, 'first')!69	put(a.root, 'a.txt', 'two\n')!70	commit(a, 'second')!71	mut backend := remote.new_folder(os.join_path(base, 'remote'))!7273	report := offer_now(a, mut backend)!74	assert report.repo == 'proj'75	assert report.saves == 276	assert report.objects >= 277	assert backend.get(head_key('proj'))!.found78	assert backend.list('proj/saves')!.len == 27980	// A second offer with nothing new uploads nothing and still succeeds.81	again := offer_now(a, mut backend)!82	assert again.saves == 083}8485fn test_the_repo_name_is_remembered_and_head_offered() {86	base := scratch('remember')!87	defer {88		os.rmdir_all(base) or {}89	}90	mut a := open_repo(machine('remember_a')!)!91	defer {92		a.close()93	}94	put(a.root, 'a.txt', 'one\n')!95	commit(a, 'first')!96	mut backend := remote.new_folder(os.join_path(base, 'remote'))!97	offer_now(a, mut backend)!98	assert a.state('remote_repo') or { '' } == 'proj'99	assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id100101	// A second offer, with the remote already holding our head, must not102	// refuse: the name is remembered, so the remote is ours.103	put(a.root, 'a.txt', 'two\n')!104	commit(a, 'second')!105	offer_now(a, mut backend)!106	assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id107}108109fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {110	base := scratch('forward')!111	defer {112		os.rmdir_all(base) or {}113	}114	mut backend := remote.new_folder(os.join_path(base, 'remote'))!115	mut a := open_repo(machine('fwd_a')!)!116	defer {117		a.close()118	}119	put(a.root, 'a.txt', 'from A\n')!120	commit(a, 'A writes')!121	offer_now(a, mut backend)!122123	// B starts empty and pulls the whole repo.124	mut b := open_repo(machine('fwd_b')!)!125	defer {126		b.close()127	}128	fetched := fetch_repo(mut backend, 'proj')!129	b.begin()!130	write_fetched(b, fetched)!131	history.set_head(b, fetched.head)!132	b.commit()!133	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!134	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'135	assert history.head(b)!.id == fetched.head136}137138// A machine that is only ahead has nothing to combine: stealing must leave its139// saves, and their IDs, exactly where they are.140fn test_steal_while_only_ahead_leaves_the_line_alone() {141	base := scratch('ahead')!142	defer {143		os.rmdir_all(base) or {}144	}145	mut backend := remote.new_folder(os.join_path(base, 'remote'))!146	mut a := open_repo(machine('ahead_a')!)!147	defer {148		a.close()149	}150	put(a.root, 'a.txt', 'one\n')!151	commit(a, 'offered')!152	offer_now(a, mut backend)!153	put(a.root, 'a.txt', 'two\n')!154	ahead := commit(a, 'not offered yet')!155	report := steal_now(a, mut backend)!156	assert report.replayed == 0157	assert history.head(a)!.id == ahead.id158	assert history.all_saves(a)!.len == 2159	offer_now(a, mut backend)!160	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id161}162163// A link is invisible to the walk, so a tree holding one is clean; the files164// a steal brings in must still never land in the folder it points at.165fn test_steal_never_writes_through_a_symlinked_folder() {166	base := scratch('steal_link')!167	defer {168		os.rmdir_all(base) or {}169	}170	mut backend := remote.new_folder(os.join_path(base, 'remote'))!171	mut a := open_repo(machine('steal_link_a')!)!172	defer {173		a.close()174	}175	mut b := open_repo(machine('steal_link_b')!)!176	defer {177		b.close()178	}179	put(a.root, 'a.txt', 'one\n')!180	commit(a, 'first')!181	offer_now(a, mut backend)!182	join(b, mut backend)!183	outside := os.join_path(base, 'outside')184	put(outside, 'a.md', 'precious\n')!185	os.symlink(outside, os.join_path(b.root, 'docs'))!186	put(a.root, 'docs/a.md', 'from A\n')!187	commit(a, 'A adds docs')!188	offer_now(a, mut backend)!189	head_before := history.head(b)!.id190	if _ := steal_now(b, mut backend) {191		assert false, 'a steal writing through a symlinked folder must refuse'192	} else {193		assert err.msg().contains('docs')194	}195	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'196	assert history.head(b)!.id == head_before197}198199// A replayed save keeps its original time but sits on a newer parent, so a200// clone cannot order saves by time.201fn test_a_clone_imports_parents_before_children() {202	base := scratch('clone_order')!203	defer {204		os.rmdir_all(base) or {}205	}206	mut backend := remote.new_folder(os.join_path(base, 'remote'))!207	mut a := open_repo(machine('order_a')!)!208	defer {209		a.close()210	}211	put(a.root, 'a.txt', 'one\n')!212	parent := commit(a, 'parent')!213	child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{214		message:    'replayed, older than its parent'215		created_at: parent.created_at - 3600216	})!217	offer_now(a, mut backend)!218	mut b := open_repo(machine('order_b')!)!219	defer {220		b.close()221	}222	join(b, mut backend)!223	assert history.head(b)!.id == child.id224}225226// A chmod is unsaved work too: a steal would restore the old mode over it.227fn test_steal_refuses_an_unsaved_executable_bit_change() {228	base := scratch('chmod')!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('chmod_a')!)!234	defer {235		a.close()236	}237	put(a.root, 'run.sh', 'echo hi\n')!238	commit(a, 'script')!239	offer_now(a, mut backend)!240	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!241	if _ := steal_now(a, mut backend) {242		assert false, 'a chmod-only change should make the tree dirty'243	} else {244		assert err.msg() == dirty_tree245	}246}247248fn test_divergence_without_clashes_keeps_both_machines_work() {249	base := scratch('diverge')!250	defer {251		os.rmdir_all(base) or {}252	}253	mut backend := remote.new_folder(os.join_path(base, 'remote'))!254	mut a := open_repo(machine('div_a')!)!255	defer {256		a.close()257	}258	mut b := open_repo(machine('div_b')!)!259	defer {260		b.close()261	}262	put(a.root, 'shared.txt', 'base\n')!263	commit(a, 'base')!264	offer_now(a, mut backend)!265266	// B joins, then both machines change a different file.267	join(b, mut backend)!268	put(a.root, 'fromA.txt', 'A\n')!269	commit(a, 'A adds a file')!270	put(b.root, 'fromB.txt', 'B\n')!271	commit(b, 'B adds a file')!272	offer_now(a, mut backend)!273274	// B's offer is refused: the remote has moved on.275	if _ := offer_now(b, mut backend) {276		assert false, 'offering on top of unknown remote saves should be refused'277	} else {278		assert err.msg().contains('run koh steal')279	}280281	report := steal_now(b, mut backend)!282	assert !report.forward283	assert report.replayed == 1284	assert report.clashes.len == 0285	// Both files are on B's line now, and on disk.286	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'287	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'288	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'289	// B's own save was replayed, not erased.290	mut saw_replay := false291	for save in history.all_saves(b)! {292		if save.replayed_from != '' {293			saw_replay = true294		}295	}296	assert saw_replay297	// And the remote now has B's work too.298	offer_now(b, mut backend)!299	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()300	assert line_head == history.head(b)!.id301}302303// An agent that edits a file and then undoes the edit leaves no net change, so304// the file is not a clash when the other machine changed it. Replaying the305// edit itself must not stop the steal: the remote's version simply stands.306// Likewise a file both machines ended up changing the same way.307fn test_a_local_change_undone_does_not_block_a_combine() {308	base := scratch('undone')!309	defer {310		os.rmdir_all(base) or {}311	}312	mut backend := remote.new_folder(os.join_path(base, 'remote'))!313	mut a := open_repo(machine('undone_a')!)!314	defer {315		a.close()316	}317	mut b := open_repo(machine('undone_b')!)!318	defer {319		b.close()320	}321	put(a.root, 'f.txt', 'base\n')!322	put(a.root, 'g.txt', 'base\n')!323	commit(a, 'base')!324	offer_now(a, mut backend)!325	join(b, mut backend)!326327	put(a.root, 'f.txt', 'from A\n')!328	put(a.root, 'g.txt', 'same\n')!329	commit(a, 'A edits both')!330	offer_now(a, mut backend)!331332	put(b.root, 'f.txt', 'tried on B\n')!333	put(b.root, 'g.txt', 'first try\n')!334	commit(b, 'B tries')!335	put(b.root, 'f.txt', 'base\n')!336	put(b.root, 'g.txt', 'same\n')!337	put(b.root, 'h.txt', 'B\n')!338	commit(b, 'B undoes f, lands on the same g, adds h')!339340	report := steal_now(b, mut backend)!341	assert report.clashes.len == 0342	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'343	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'344	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'345}346347// A person deciding a clash may take minutes; the question must come before348// the write lock, or every agent's save on this repo waits and then fails.349fn test_clash_questions_are_asked_before_the_write_lock() {350	base := scratch('ask_unlocked')!351	defer {352		os.rmdir_all(base) or {}353	}354	mut backend := remote.new_folder(os.join_path(base, 'remote'))!355	mut a := open_repo(machine('ask_a')!)!356	defer {357		a.close()358	}359	mut b := open_repo(machine('ask_b')!)!360	defer {361		b.close()362	}363	put(a.root, 'same.txt', 'base\n')!364	commit(a, 'base')!365	offer_now(a, mut backend)!366	join(b, mut backend)!367	put(a.root, 'same.txt', 'A version\n')!368	commit(a, 'A edits same')!369	offer_now(a, mut backend)!370	put(b.root, 'same.txt', 'B version\n')!371	commit(b, 'B edits same')!372	root := b.root373	// An agent saves while the person is still choosing.374	if _ := steal(b, mut backend, StealOptions{375		ask: fn [root] (clash Clash) Side {376			other := storage.open(root) or { panic(err) }377			defer {378				other.close()379			}380			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }381			history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{382				message: 'agent saves during the question'383			}) or { panic('steal held the write lock while asking: ${err}') }384			return .mine385		}386	})387	{388		assert false, 'a steal whose line moved during the question must refuse'389	} else {390		assert err.msg().contains('changed while')391	}392	// The agent's save is still head, and a second steal combines it too.393	assert history.head(b)!.message == 'agent saves during the question'394	report := steal(b, mut backend, StealOptions{395		mine: true396	})!397	assert report.kept_mine == 1398	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'399	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'400}401402fn test_clashing_files_are_chosen_one_at_a_time() {403	base := scratch('clash')!404	defer {405		os.rmdir_all(base) or {}406	}407	mut backend := remote.new_folder(os.join_path(base, 'remote'))!408	mut a := open_repo(machine('clash_a')!)!409	defer {410		a.close()411	}412	mut b := open_repo(machine('clash_b')!)!413	defer {414		b.close()415	}416	put(a.root, 'same.txt', 'base\n')!417	put(a.root, 'other.txt', 'quiet\n')!418	commit(a, 'base')!419	offer_now(a, mut backend)!420	join(b, mut backend)!421422	// Both machines edit the same file, differently.423	put(a.root, 'same.txt', 'A version\n')!424	commit(a, 'A edits same')!425	offer_now(a, mut backend)!426	put(b.root, 'same.txt', 'B version\n')!427	commit(b, 'B edits same')!428429	// Without a decision, steal refuses and says which files clash.430	if _ := steal(b, mut backend, StealOptions{}) {431		assert false, 'an undecided clash must be refused'432	} else {433		assert err.msg().contains('--mine') || err.msg().contains('--theirs')434	}435	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,436		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0437438	// Choosing the remote's version ends with A's content.439	report := steal_now(b, mut backend)!440	assert report.clashes == ['same.txt']441	assert report.kept_mine == 0442	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'443	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'444}445446fn test_choosing_mine_keeps_the_local_version() {447	base := scratch('mine')!448	defer {449		os.rmdir_all(base) or {}450	}451	mut backend := remote.new_folder(os.join_path(base, 'remote'))!452	mut a := open_repo(machine('mine_a')!)!453	defer {454		a.close()455	}456	mut b := open_repo(machine('mine_b')!)!457	defer {458		b.close()459	}460	put(a.root, 'same.txt', 'base\n')!461	commit(a, 'base')!462	offer_now(a, mut backend)!463	join(b, mut backend)!464	put(a.root, 'same.txt', 'A version\n')!465	commit(a, 'A edits')!466	offer_now(a, mut backend)!467	put(b.root, 'same.txt', 'B version\n')!468	commit(b, 'B edits')!469470	report := steal(b, mut backend, StealOptions{471		mine: true472	})!473	assert report.kept_mine == 1474	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'475	// The final save says what it kept.476	assert history.head(b)!.message.contains('combine: kept mine for 1 file')477	// The pre-combine tree is still reachable in history.478	assert history.head(b)!.restores == ''479	assert history.all_saves(b)!.len >= 4480}481482fn test_flags_travel_with_the_saves() {483	base := scratch('flags')!484	defer {485		os.rmdir_all(base) or {}486	}487	mut backend := remote.new_folder(os.join_path(base, 'remote'))!488	mut a := open_repo(machine('flag_a')!)!489	defer {490		a.close()491	}492	mut b := open_repo(machine('flag_b')!)!493	defer {494		b.close()495	}496	put(a.root, 'a.txt', 'one\n')!497	save := commit(a, 'suspect save')!498	events.flag(a, save.id, 'look at this')!499	offer_now(a, mut backend)!500501	// B has nothing yet, so a fetch brings the save and its flag across.502	fetched := fetch_back_to_known(b, mut backend, 'proj')!503	assert fetched.events_out.len == 1504	assert fetched.events_out[0].kind == events.kind_flag505	b.begin()!506	write_fetched(b, fetched)!507	history.set_head(b, fetched.head)!508	b.commit()!509	state := events.flag_state(b, save.id)!510	assert state.flagged511	assert state.reason == 'look at this'512}513514fn test_a_second_offer_racing_head_is_refused_not_forced() {515	base := scratch('race')!516	defer {517		os.rmdir_all(base) or {}518	}519	mut backend := remote.new_folder(os.join_path(base, 'remote'))!520	mut a := open_repo(machine('race_a')!)!521	defer {522		a.close()523	}524	put(a.root, 'a.txt', 'one\n')!525	commit(a, 'base')!526	offer_now(a, mut backend)!527528	// Someone else replaces head behind our back.529	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),530		backend.get(head_key('proj'))!.tag)!531532	put(a.root, 'a.txt', 'two\n')!533	commit(a, 'second')!534	if _ := offer_now(a, mut backend) {535		assert false, 'offering while the remote head moved must be refused'536	} else {537		assert err.msg().contains('run koh steal')538	}539}540541// join makes a brand new machine share the remote's line.542fn join(store &storage.Store, mut backend remote.Backend) ! {543	fetched := fetch_repo(mut backend, 'proj')!544	store.begin()!545	write_fetched(store, fetched)!546	history.set_head(store, fetched.head)!547	store.commit()!548	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,549		fetched.head)!, [])!550	store.set_state('remote_repo', 'proj')!551}552553struct FaultyBackend {554mut:555	inner      remote.Backend556	writes     int557	reads      int558	fail_write int // fail when the write counter reaches this, 0 never559	fail_read  int560	// fail every delete while set561	fail_delete bool562}563564fn (mut f FaultyBackend) get(key string) !remote.Object {565	f.reads++566	if f.fail_read > 0 && f.reads >= f.fail_read {567		return error('injected read failure')568	}569	return f.inner.get(key)!570}571572fn (mut f FaultyBackend) list(prefix string) ![]string {573	return f.inner.list(prefix)!574}575576fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {577	f.writes++578	if f.fail_write > 0 && f.writes >= f.fail_write {579		return error('injected write failure')580	}581	return f.inner.put_if_absent(key, body)!582}583584fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {585	f.writes++586	if f.fail_write > 0 && f.writes >= f.fail_write {587		return error('injected write failure')588	}589	return f.inner.put_if_match(key, body, tag)!590}591592fn (mut f FaultyBackend) delete(key string) ! {593	if f.fail_delete {594		return error('injected delete failure')595	}596	f.inner.delete(key)!597}598599fn test_a_failed_offer_is_safe_to_re_run() {600	base := scratch('fault_offer')!601	defer {602		os.rmdir_all(base) or {}603	}604	remote_dir := os.join_path(base, 'remote')605	mut a := open_repo(machine('fault_a')!)!606	defer {607		a.close()608	}609	put(a.root, 'a.txt', 'one\n')!610	commit(a, 'first')!611	put(a.root, 'a.txt', 'two\n')!612	commit(a, 'second')!613614	// The first offer dies after the first write.615	mut flaky := FaultyBackend{616		inner:      remote.new_folder(remote_dir)!617		fail_write: 2618	}619	if _ := offer(a, mut flaky) {620		assert false, 'the injected failure should have surfaced'621	}622623	// Re-running with a healthy remote finishes the job: "already exists" is624	// success, so nothing is lost and nothing is duplicated.625	mut healthy := remote.new_folder(remote_dir)!626	report := offer(a, mut healthy)!627	assert report.saves == 2628	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id629	assert healthy.list('proj/saves')!.len == 2630}631632fn test_a_failed_steal_leaves_nothing_behind() {633	base := scratch('fault_steal')!634	defer {635		os.rmdir_all(base) or {}636	}637	remote_dir := os.join_path(base, 'remote')638	mut a := open_repo(machine('fault_s_a')!)!639	defer {640		a.close()641	}642	mut b := open_repo(machine('fault_s_b')!)!643	defer {644		b.close()645	}646	put(a.root, 'a.txt', 'from A\n')!647	commit(a, 'A writes')!648	mut healthy := remote.new_folder(remote_dir)!649	offer(a, mut healthy)!650651	// B's fetch fails half way through reading the remote.652	mut flaky := FaultyBackend{653		inner:     remote.new_folder(remote_dir)!654		fail_read: 2655	}656	if _ := fetch_back_to_known(b, mut flaky, 'proj') {657		assert false, 'the injected read failure should have surfaced'658	}659	// Nothing was written: the store is untouched and still has no saves.660	assert history.head(b) == none661	assert b.object_count()! == 0662663	// A healthy retry works.664	fetched := fetch_back_to_known(b, mut healthy, 'proj')!665	b.begin()!666	write_fetched(b, fetched)!667	history.set_head(b, fetched.head)!668	b.commit()!669	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!670	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'671}672673fn test_an_interrupted_strip_is_idempotent() {674	// The local half of a strip is one transaction, so an interrupted strip is675	// simply a strip that did not happen; re-running it must land the same676	// result once, not twice.677	base := scratch('fault_strip')!678	defer {679		os.rmdir_all(base) or {}680	}681	mut a := open_repo(machine('fault_strip_a')!)!682	defer {683		a.close()684	}685	put(a.root, 'secret.txt', 'shh\n')!686	put(a.root, 'keep.txt', 'fine\n')!687	save := commit(a, 'add both')!688689	for _ in 0 .. 2 {690		strip_local(a, 'secret.txt', '')!691	}692	files := history.files_of(a, save.id)!693	// The repo's own `.kohignore` is tracked content, so it stays; only the694	// stripped path goes.695	assert files.map(it.path) == ['.kohignore', 'keep.txt']696	assert events.strip_events(a)!.len == 1697}698699// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into700// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.701// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,702// but a save made on Linux can hold both.703fn test_a_strip_matches_the_path_exactly() {704	mut a := open_repo(machine('strip_exact')!)!705	defer {706		a.close()707	}708	hash := a.put_object('x\n'.bytes())!709	mut files := []snapshot.FileEntry{}710	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {711		files << snapshot.FileEntry{712			path: path713			hash: hash714		}715	}716	save := history.commit_files(a, '', files, history.SaveInput{717		message: 'add all'718	})!719	assert strip_scope(a, 'docs', '')!.files == 1720	strip_local(a, 'docs', '')!721	strip_local(a, 'a_b', '')!722	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']723}724725fn test_a_strip_reaches_the_remote_and_other_machines() {726	base := scratch('strip_sync')!727	defer {728		os.rmdir_all(base) or {}729	}730	mut a := open_repo(machine('strip_a')!)!731	defer {732		a.close()733	}734	mut b := open_repo(machine('strip_b')!)!735	defer {736		b.close()737	}738	put(a.root, 'secrets.env', 'api_key=1\n')!739	put(a.root, 'keep.txt', 'fine\n')!740	save := commit(a, 'add both')!741	mut backend := remote.new_folder(os.join_path(base, 'remote'))!742	offer_now(a, mut backend)!743	join(b, mut backend)!744	assert b.object_count()! > 0745746	// A strips the path locally, the way `koh strip` does, then tells the747	// remote.748	mut secret_hash := ''749	for file in history.files_of(a, save.id)! {750		if file.path == 'secrets.env' {751			secret_hash = file.hash752		}753	}754	assert secret_hash != ''755	strip_local(a, 'secrets.env', '')!756	report := push_strip(a, mut backend, 'secrets.env')!757	assert report.event_sent758	assert report.records == 1759760	// The remote's copy of the save no longer lists the file, and the object is761	// gone from the remote.762	record := decode_save(backend.get(save_key('proj', save.id))!.body)!763	for file in record.files {764		assert file.path != 'secrets.env'765	}766	// The stripped object is gone from the remote; the ones still referenced767	// are not.768	assert !backend.get(object_key('proj', secret_hash))!.found769	assert backend.list('proj/objects')!.len == 2770771	// Another machine hears about it on its next steal and applies it.772	b.begin()!773	fetched := fetch_back_to_known(b, mut backend, 'proj')!774	write_fetched(b, fetched)!775	b.commit()!776	mut paths := []string{}777	for file in history.files_of(b, save.id)! {778		paths << file.path779	}780	assert 'secrets.env' !in paths781	assert 'keep.txt' in paths782783	// The event arrives under its own ID, so it is applied once: B's offer adds784	// no second copy, and a file B saves again after the strip stays saved785	// through B's next steal.786	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)787	put(b.root, 'secrets.env', 'rotated\n')!788	readded := commit(b, 'add the rotated key back')!789	offer_now(b, mut backend)!790	assert backend.list('proj/events')!.len == 1791	b.begin()!792	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!793	b.commit()!794	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)795}796797// A strip removes a path from the saves that existed when it was made. A key798// rotated and saved again afterwards is new work: a machine that clones the799// repo later must get it, not strip it on arrival.800fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {801	base := scratch('strip_clone')!802	defer {803		os.rmdir_all(base) or {}804	}805	mut backend := remote.new_folder(os.join_path(base, 'remote'))!806	mut a := open_repo(machine('strip_clone_a')!)!807	defer {808		a.close()809	}810	put(a.root, 'secrets.env', 'api_key=1\n')!811	old := commit(a, 'leak the key')!812	offer_now(a, mut backend)!813	strip_local(a, 'secrets.env', '')!814	push_strip(a, mut backend, 'secrets.env')!815	// Saves after the strip must be later in time, as they would be for real.816	put(a.root, 'secrets.env', 'api_key=2\n')!817	rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{818		message:    'rotate the key'819		created_at: events.strip_events(a)!.last().created_at + 60820	})!821	offer_now(a, mut backend)!822823	mut c := open_repo(machine('strip_clone_c')!)!824	defer {825		c.close()826	}827	join(c, mut backend)!828	assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)829	assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)830	assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'831}832833// The local half commits before the remote hears anything, so a remote failure834// leaves nothing to find locally; the pending note is what lets a re-run835// finish. Objects a remote-only save still needs must survive the strip.836fn test_an_interrupted_remote_strip_finishes_when_re_run() {837	base := scratch('strip_resume')!838	defer {839		os.rmdir_all(base) or {}840	}841	remote_dir := os.join_path(base, 'remote')842	mut backend := remote.new_folder(remote_dir)!843	mut a := open_repo(machine('resume_a')!)!844	defer {845		a.close()846	}847	mut b := open_repo(machine('resume_b')!)!848	defer {849		b.close()850	}851	put(a.root, 'secrets.env', 'api_key=1\n')!852	put(a.root, 'shared.env', 'same bytes on B\n')!853	save := commit(a, 'add secrets')!854	offer_now(a, mut backend)!855	join(b, mut backend)!856	put(b.root, 'copy.txt', 'same bytes on B\n')!857	b_save := commit(b, 'B keeps a copy')!858	offer_now(b, mut backend)!859	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())860	secret_hash := storage.hash_hex('api_key=1\n'.bytes())861862	strip_local(a, 'secrets.env', '')!863	// Write 1 uploads the event and write 2 rewrites the first record; B's save,864	// made from A's tree, lists the file too, and its rewrite (write 3) fails.865	mut flaky := FaultyBackend{866		inner:      remote.new_folder(remote_dir)!867		fail_write: 3868	}869	if _ := push_strip(a, mut flaky, 'secrets.env') {870		assert false, 'the injected failure should have surfaced'871	}872	assert strip_pending(a, 'secrets.env')873	assert strip_local(a, 'secrets.env', '')!.files_removed == 0874	report := push_strip(a, mut backend, 'secrets.env')!875	assert report.records == 1876	assert !strip_pending(a, 'secrets.env')877	assert !backend.get(object_key('proj', secret_hash))!.found878	record := decode_save(backend.get(save_key('proj', save.id))!.body)!879	assert 'secrets.env' !in record.files.map(it.path)880	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!881	assert 'secrets.env' !in b_record.files.map(it.path)882883	// B's save still names the shared bytes, so stripping A's copy keeps them.884	strip_local(a, 'shared.env', '')!885	push_strip(a, mut backend, 'shared.env')!886	assert backend.get(object_key('proj', shared_hash))!.found887}888889// A machine that has not heard of a strip must not offer: its records still890// list the stripped path, and nothing would rewrite them on the remote again.891fn test_offer_waits_for_an_unapplied_strip() {892	base := scratch('strip_first')!893	defer {894		os.rmdir_all(base) or {}895	}896	mut backend := remote.new_folder(os.join_path(base, 'remote'))!897	mut a := open_repo(machine('strip_first_a')!)!898	defer {899		a.close()900	}901	mut b := open_repo(machine('strip_first_b')!)!902	defer {903		b.close()904	}905	put(a.root, 'secrets.env', 'api_key=1\n')!906	commit(a, 'add secrets')!907	offer_now(a, mut backend)!908	join(b, mut backend)!909	strip_local(a, 'secrets.env', '')!910	push_strip(a, mut backend, 'secrets.env')!911	put(b.root, 'more.txt', 'B keeps working\n')!912	later := commit(b, 'B saves with the secret still in its tree')!913	if _ := offer_now(b, mut backend) {914		assert false, 'B offered records that still list the stripped path'915	}916	steal_now(b, mut backend)!917	offer_now(b, mut backend)!918	record := decode_save(backend.get(save_key('proj', later.id))!.body)!919	assert 'secrets.env' !in record.files.map(it.path)920}921922// A strip whose object deletions failed, followed by a second strip of the same923// path, must still delete the first strip's objects when it finishes.924fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {925	base := scratch('strip_twice')!926	defer {927		os.rmdir_all(base) or {}928	}929	remote_dir := os.join_path(base, 'remote')930	mut backend := remote.new_folder(remote_dir)!931	mut a := open_repo(machine('strip_twice_a')!)!932	defer {933		a.close()934	}935	put(a.root, 'secrets.env', 'first key\n')!936	commit(a, 'first key')!937	offer_now(a, mut backend)!938	strip_local(a, 'secrets.env', '')!939	mut flaky := FaultyBackend{940		inner:       remote.new_folder(remote_dir)!941		fail_delete: true942	}943	if _ := push_strip(a, mut flaky, 'secrets.env') {944		assert false, 'the injected delete failure should have surfaced'945	}946	put(a.root, 'secrets.env', 'second key\n')!947	commit(a, 'second key')!948	offer_now(a, mut backend)!949	strip_local(a, 'secrets.env', '')!950	push_strip(a, mut backend, 'secrets.env')!951	for key in ['first key\n', 'second key\n'] {952		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found953	}954}955956// Unfinished strips of one path in different scopes finish together.957fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {958	base := scratch('strip_scopes')!959	defer {960		os.rmdir_all(base) or {}961	}962	remote_dir := os.join_path(base, 'remote')963	mut backend := remote.new_folder(remote_dir)!964	mut a := open_repo(machine('strip_scopes_a')!)!965	defer {966		a.close()967	}968	put(a.root, 'secrets.env', 'first key\n')!969	first := commit(a, 'first key')!970	put(a.root, 'secrets.env', 'second key\n')!971	second := commit(a, 'second key')!972	offer_now(a, mut backend)!973	strip_local(a, 'secrets.env', first.id)!974	// Write 1 uploads the event; write 2, the record rewrite, fails.975	mut flaky := FaultyBackend{976		inner:      remote.new_folder(remote_dir)!977		fail_write: 2978	}979	if _ := push_strip(a, mut flaky, 'secrets.env') {980		assert false, 'the injected failure should have surfaced'981	}982	strip_local(a, 'secrets.env', second.id)!983	push_strip(a, mut backend, 'secrets.env')!984	assert !strip_pending(a, 'secrets.env')985	for save in [first, second] {986		record := decode_save(backend.get(save_key('proj', save.id))!.body)!987		assert 'secrets.env' !in record.files.map(it.path)988	}989	for key in ['first key\n', 'second key\n'] {990		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found991	}992}993994// Stripping a path, re-adding it, and stripping it again is two strips; a995// machine that heard about the first must still apply the second.996fn test_a_second_strip_of_the_same_path_reaches_other_machines() {997	base := scratch('strip_again')!998	defer {999		os.rmdir_all(base) or {}1000	}1001	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1002	mut a := open_repo(machine('strip_again_a')!)!1003	defer {1004		a.close()1005	}1006	mut b := open_repo(machine('strip_again_b')!)!1007	defer {1008		b.close()1009	}1010	put(a.root, 'secrets.env', 'first key\n')!1011	commit(a, 'first key')!1012	offer_now(a, mut backend)!1013	join(b, mut backend)!1014	strip_local(a, 'secrets.env', '')!1015	push_strip(a, mut backend, 'secrets.env')!1016	steal_now(b, mut backend)!1017	// Strip leaves the file on disk; deleting it is the user's part.1018	os.rm(os.join_path(b.root, 'secrets.env'))!1019	put(a.root, 'secrets.env', 'second key\n')!1020	second := commit(a, 'second key')!1021	offer_now(a, mut backend)!1022	steal_now(b, mut backend)!1023	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1024	strip_local(a, 'secrets.env', '')!1025	push_strip(a, mut backend, 'secrets.env')!1026	steal_now(b, mut backend)!1027	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1028}10291030// A clash on a path the other machine stripped is no clash: the strip removes1031// the path from every save, so there is nothing to choose.1032fn test_a_stripped_path_is_not_asked_about() {1033	base := scratch('clash_strip')!1034	defer {1035		os.rmdir_all(base) or {}1036	}1037	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1038	mut a := open_repo(machine('clash_strip_a')!)!1039	defer {1040		a.close()1041	}1042	mut b := open_repo(machine('clash_strip_b')!)!1043	defer {1044		b.close()1045	}1046	put(a.root, 'secrets.env', 'key one\n')!1047	commit(a, 'base')!1048	offer_now(a, mut backend)!1049	join(b, mut backend)!1050	put(a.root, 'secrets.env', 'key two\n')!1051	commit(a, 'A rotates')!1052	offer_now(a, mut backend)!1053	strip_local(a, 'secrets.env', '')!1054	push_strip(a, mut backend, 'secrets.env')!1055	put(b.root, 'secrets.env', 'key three\n')!1056	mine := commit(b, 'B rotates')!1057	report := steal(b, mut backend, StealOptions{})!1058	assert report.clashes.len == 01059	for save in history.all_saves(b)! {1060		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1061	}1062	assert history.by_id(b, mine.id) != none1063}10641065// A strip scoped to the shared base save changes what the replay sees; the1066// clashes must be found on the same trees, or the replay meets a conflict.1067fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1068	base := scratch('clash_base_strip')!1069	defer {1070		os.rmdir_all(base) or {}1071	}1072	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1073	mut a := open_repo(machine('base_strip_a')!)!1074	defer {1075		a.close()1076	}1077	mut b := open_repo(machine('base_strip_b')!)!1078	defer {1079		b.close()1080	}1081	put(a.root, 'notes.txt', 'shared\n')!1082	common := commit(a, 'base')!1083	offer_now(a, mut backend)!1084	join(b, mut backend)!1085	put(a.root, 'other.txt', 'A moves on\n')!1086	commit(a, 'A moves on')!1087	offer_now(a, mut backend)!1088	strip_local(a, 'notes.txt', common.id)!1089	push_strip(a, mut backend, 'notes.txt')!1090	put(b.root, 'notes.txt', 'B edits\n')!1091	commit(b, 'B edits notes')!1092	if _ := steal(b, mut backend, StealOptions{}) {1093		assert false, 'with the base stripped, notes.txt differs on both sides'1094	} else {1095		assert err.msg().contains('changed on both machines')1096	}1097	report := steal(b, mut backend, StealOptions{1098		mine: true1099	})!1100	assert report.clashes == ['notes.txt']1101	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1102}11031104// A folder with files but no saves has nothing to combine with; a steal that1105// fast-forwarded into it would overwrite whatever shares a name.1106fn test_steal_into_a_folder_with_unsaved_files_refuses() {1107	base := scratch('steal_unsaved')!1108	defer {1109		os.rmdir_all(base) or {}1110	}1111	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1112	mut a := open_repo(machine('unsaved_a')!)!1113	defer {1114		a.close()1115	}1116	put(a.root, 'notes.txt', 'from A\n')!1117	commit(a, 'A writes')!1118	offer_now(a, mut backend)!1119	b_root := machine('unsaved_b')!1120	mut b := storage.create(b_root)!1121	defer {1122		b.close()1123	}1124	put(b_root, 'notes.txt', 'never saved\n')!1125	// Only a repo that already knows its remote name gets this far.1126	b.set_state('remote_repo', 'proj')!1127	if _ := steal(b, mut backend, StealOptions{}) {1128		assert false, 'steal overwrote a folder with unsaved files'1129	} else {1130		assert err.msg() == no_saves_here1131	}1132	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1133	// A fresh `koh init` folder, holding only the default ignore list, is fine.1134	os.rm(os.join_path(b_root, 'notes.txt'))!1135	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1136	steal(b, mut backend, StealOptions{})!1137	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1138}11391140// The document asks steal to warn about every incoming flagged save, so the1141// report names them.1142fn test_steal_reports_incoming_flagged_saves() {1143	base := scratch('flag_warn')!1144	defer {1145		os.rmdir_all(base) or {}1146	}1147	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1148	mut a := open_repo(machine('flag_warn_a')!)!1149	defer {1150		a.close()1151	}1152	mut b := open_repo(machine('flag_warn_b')!)!1153	defer {1154		b.close()1155	}1156	put(a.root, 'a.txt', 'one\n')!1157	commit(a, 'fine')!1158	offer_now(a, mut backend)!1159	join(b, mut backend)!1160	put(a.root, 'a.txt', 'two\n')!1161	suspect := commit(a, 'suspect')!1162	put(a.root, 'a.txt', 'three\n')!1163	commit(a, 'also fine')!1164	events.flag(a, suspect.id, 'broke the build')!1165	offer_now(a, mut backend)!1166	report := steal_now(b, mut backend)!1167	assert report.flagged.map(it.save_id) == [suspect.id]1168	assert report.flagged[0].reason == 'broke the build'1169}