sync_test.v

36.1 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 steal onto a half-restored tree would replay it as somebody's work.200fn test_steal_refuses_after_a_restore_cut_short() {201	base := scratch('steal_cut')!202	defer {203		os.rmdir_all(base) or {}204	}205	mut backend := remote.new_folder(os.join_path(base, 'remote'))!206	mut a := open_repo(machine('steal_cut_a')!)!207	defer {208		a.close()209	}210	put(a.root, 'a.txt', 'one\n')!211	commit(a, 'first')!212	offer_now(a, mut backend)!213	a.set_state('restoring', 'yes')!214	if _ := steal_now(a, mut backend) {215		assert false, 'steal must refuse while a restore is unfinished'216	} else {217		assert err.msg().contains('koh revert')218	}219}220221// A replayed save keeps its original time but sits on a newer parent, so a222// clone cannot order saves by time.223fn test_a_clone_imports_parents_before_children() {224	base := scratch('clone_order')!225	defer {226		os.rmdir_all(base) or {}227	}228	mut backend := remote.new_folder(os.join_path(base, 'remote'))!229	mut a := open_repo(machine('order_a')!)!230	defer {231		a.close()232	}233	put(a.root, 'a.txt', 'one\n')!234	parent := commit(a, 'parent')!235	child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{236		message:    'replayed, older than its parent'237		created_at: parent.created_at - 3600238	})!239	offer_now(a, mut backend)!240	mut b := open_repo(machine('order_b')!)!241	defer {242		b.close()243	}244	join(b, mut backend)!245	assert history.head(b)!.id == child.id246}247248// A chmod is unsaved work too: a steal would restore the old mode over it.249fn test_steal_refuses_an_unsaved_executable_bit_change() {250	base := scratch('chmod')!251	defer {252		os.rmdir_all(base) or {}253	}254	mut backend := remote.new_folder(os.join_path(base, 'remote'))!255	mut a := open_repo(machine('chmod_a')!)!256	defer {257		a.close()258	}259	put(a.root, 'run.sh', 'echo hi\n')!260	commit(a, 'script')!261	offer_now(a, mut backend)!262	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!263	if _ := steal_now(a, mut backend) {264		assert false, 'a chmod-only change should make the tree dirty'265	} else {266		assert err.msg() == dirty_tree267	}268}269270fn test_divergence_without_clashes_keeps_both_machines_work() {271	base := scratch('diverge')!272	defer {273		os.rmdir_all(base) or {}274	}275	mut backend := remote.new_folder(os.join_path(base, 'remote'))!276	mut a := open_repo(machine('div_a')!)!277	defer {278		a.close()279	}280	mut b := open_repo(machine('div_b')!)!281	defer {282		b.close()283	}284	put(a.root, 'shared.txt', 'base\n')!285	commit(a, 'base')!286	offer_now(a, mut backend)!287288	// B joins, then both machines change a different file.289	join(b, mut backend)!290	put(a.root, 'fromA.txt', 'A\n')!291	commit(a, 'A adds a file')!292	put(b.root, 'fromB.txt', 'B\n')!293	commit(b, 'B adds a file')!294	offer_now(a, mut backend)!295296	// B's offer is refused: the remote has moved on.297	if _ := offer_now(b, mut backend) {298		assert false, 'offering on top of unknown remote saves should be refused'299	} else {300		assert err.msg().contains('run koh steal')301	}302303	report := steal_now(b, mut backend)!304	assert !report.forward305	assert report.replayed == 1306	assert report.clashes.len == 0307	// Both files are on B's line now, and on disk.308	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'309	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'310	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'311	// B's own save was replayed, not erased.312	mut saw_replay := false313	for save in history.all_saves(b)! {314		if save.replayed_from != '' {315			saw_replay = true316		}317	}318	assert saw_replay319	// And the remote now has B's work too.320	offer_now(b, mut backend)!321	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()322	assert line_head == history.head(b)!.id323}324325// An agent that edits a file and then undoes the edit leaves no net change, so326// the file is not a clash when the other machine changed it. Replaying the327// edit itself must not stop the steal: the remote's version simply stands.328// Likewise a file both machines ended up changing the same way.329fn test_a_local_change_undone_does_not_block_a_combine() {330	base := scratch('undone')!331	defer {332		os.rmdir_all(base) or {}333	}334	mut backend := remote.new_folder(os.join_path(base, 'remote'))!335	mut a := open_repo(machine('undone_a')!)!336	defer {337		a.close()338	}339	mut b := open_repo(machine('undone_b')!)!340	defer {341		b.close()342	}343	put(a.root, 'f.txt', 'base\n')!344	put(a.root, 'g.txt', 'base\n')!345	commit(a, 'base')!346	offer_now(a, mut backend)!347	join(b, mut backend)!348349	put(a.root, 'f.txt', 'from A\n')!350	put(a.root, 'g.txt', 'same\n')!351	commit(a, 'A edits both')!352	offer_now(a, mut backend)!353354	put(b.root, 'f.txt', 'tried on B\n')!355	put(b.root, 'g.txt', 'first try\n')!356	commit(b, 'B tries')!357	put(b.root, 'f.txt', 'base\n')!358	put(b.root, 'g.txt', 'same\n')!359	put(b.root, 'h.txt', 'B\n')!360	commit(b, 'B undoes f, lands on the same g, adds h')!361362	report := steal_now(b, mut backend)!363	assert report.clashes.len == 0364	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'365	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'366	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'367}368369// A person deciding a clash may take minutes; the question must come before370// the write lock, or every agent's save on this repo waits and then fails.371fn test_clash_questions_are_asked_before_the_write_lock() {372	base := scratch('ask_unlocked')!373	defer {374		os.rmdir_all(base) or {}375	}376	mut backend := remote.new_folder(os.join_path(base, 'remote'))!377	mut a := open_repo(machine('ask_a')!)!378	defer {379		a.close()380	}381	mut b := open_repo(machine('ask_b')!)!382	defer {383		b.close()384	}385	put(a.root, 'same.txt', 'base\n')!386	commit(a, 'base')!387	offer_now(a, mut backend)!388	join(b, mut backend)!389	put(a.root, 'same.txt', 'A version\n')!390	commit(a, 'A edits same')!391	offer_now(a, mut backend)!392	put(b.root, 'same.txt', 'B version\n')!393	commit(b, 'B edits same')!394	root := b.root395	// An agent saves while the person is still choosing.396	if _ := steal(b, mut backend, StealOptions{397		ask: fn [root] (clash Clash) Side {398			other := storage.open(root) or { panic(err) }399			defer {400				other.close()401			}402			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }403			history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{404				message: 'agent saves during the question'405			}) or { panic('steal held the write lock while asking: ${err}') }406			return .mine407		}408	})409	{410		assert false, 'a steal whose line moved during the question must refuse'411	} else {412		assert err.msg().contains('changed while')413	}414	// The agent's save is still head, and a second steal combines it too.415	assert history.head(b)!.message == 'agent saves during the question'416	report := steal(b, mut backend, StealOptions{417		mine: true418	})!419	assert report.kept_mine == 1420	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'421	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'422}423424fn test_clashing_files_are_chosen_one_at_a_time() {425	base := scratch('clash')!426	defer {427		os.rmdir_all(base) or {}428	}429	mut backend := remote.new_folder(os.join_path(base, 'remote'))!430	mut a := open_repo(machine('clash_a')!)!431	defer {432		a.close()433	}434	mut b := open_repo(machine('clash_b')!)!435	defer {436		b.close()437	}438	put(a.root, 'same.txt', 'base\n')!439	put(a.root, 'other.txt', 'quiet\n')!440	commit(a, 'base')!441	offer_now(a, mut backend)!442	join(b, mut backend)!443444	// Both machines edit the same file, differently.445	put(a.root, 'same.txt', 'A version\n')!446	commit(a, 'A edits same')!447	offer_now(a, mut backend)!448	put(b.root, 'same.txt', 'B version\n')!449	commit(b, 'B edits same')!450451	// Without a decision, steal refuses and says which files clash.452	if _ := steal(b, mut backend, StealOptions{}) {453		assert false, 'an undecided clash must be refused'454	} else {455		assert err.msg().contains('--mine') || err.msg().contains('--theirs')456	}457	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,458		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0459460	// Choosing the remote's version ends with A's content.461	report := steal_now(b, mut backend)!462	assert report.clashes == ['same.txt']463	assert report.kept_mine == 0464	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'465	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'466}467468fn test_choosing_mine_keeps_the_local_version() {469	base := scratch('mine')!470	defer {471		os.rmdir_all(base) or {}472	}473	mut backend := remote.new_folder(os.join_path(base, 'remote'))!474	mut a := open_repo(machine('mine_a')!)!475	defer {476		a.close()477	}478	mut b := open_repo(machine('mine_b')!)!479	defer {480		b.close()481	}482	put(a.root, 'same.txt', 'base\n')!483	commit(a, 'base')!484	offer_now(a, mut backend)!485	join(b, mut backend)!486	put(a.root, 'same.txt', 'A version\n')!487	commit(a, 'A edits')!488	offer_now(a, mut backend)!489	put(b.root, 'same.txt', 'B version\n')!490	commit(b, 'B edits')!491492	report := steal(b, mut backend, StealOptions{493		mine: true494	})!495	assert report.kept_mine == 1496	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'497	// The final save says what it kept.498	assert history.head(b)!.message.contains('combine: kept mine for 1 file')499	// The pre-combine tree is still reachable in history.500	assert history.head(b)!.restores == ''501	assert history.all_saves(b)!.len >= 4502}503504fn test_flags_travel_with_the_saves() {505	base := scratch('flags')!506	defer {507		os.rmdir_all(base) or {}508	}509	mut backend := remote.new_folder(os.join_path(base, 'remote'))!510	mut a := open_repo(machine('flag_a')!)!511	defer {512		a.close()513	}514	mut b := open_repo(machine('flag_b')!)!515	defer {516		b.close()517	}518	put(a.root, 'a.txt', 'one\n')!519	save := commit(a, 'suspect save')!520	events.flag(a, save.id, 'look at this')!521	offer_now(a, mut backend)!522523	// B has nothing yet, so a fetch brings the save and its flag across.524	fetched := fetch_back_to_known(b, mut backend, 'proj')!525	assert fetched.events_out.len == 1526	assert fetched.events_out[0].kind == events.kind_flag527	b.begin()!528	write_fetched(b, fetched)!529	history.set_head(b, fetched.head)!530	b.commit()!531	state := events.flag_state(b, save.id)!532	assert state.flagged533	assert state.reason == 'look at this'534}535536fn test_a_second_offer_racing_head_is_refused_not_forced() {537	base := scratch('race')!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('race_a')!)!543	defer {544		a.close()545	}546	put(a.root, 'a.txt', 'one\n')!547	commit(a, 'base')!548	offer_now(a, mut backend)!549550	// Someone else replaces head behind our back.551	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),552		backend.get(head_key('proj'))!.tag)!553554	put(a.root, 'a.txt', 'two\n')!555	commit(a, 'second')!556	if _ := offer_now(a, mut backend) {557		assert false, 'offering while the remote head moved must be refused'558	} else {559		assert err.msg().contains('run koh steal')560	}561}562563// join makes a brand new machine share the remote's line.564fn join(store &storage.Store, mut backend remote.Backend) ! {565	fetched := fetch_repo(mut backend, 'proj')!566	store.begin()!567	write_fetched(store, fetched)!568	history.set_head(store, fetched.head)!569	store.commit()!570	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,571		fetched.head)!, [])!572	store.set_state('remote_repo', 'proj')!573}574575struct FaultyBackend {576mut:577	inner      remote.Backend578	writes     int579	reads      int580	fail_write int // fail when the write counter reaches this, 0 never581	fail_read  int582	// fail every delete while set583	fail_delete bool584}585586fn (mut f FaultyBackend) get(key string) !remote.Object {587	f.reads++588	if f.fail_read > 0 && f.reads >= f.fail_read {589		return error('injected read failure')590	}591	return f.inner.get(key)!592}593594fn (mut f FaultyBackend) list(prefix string) ![]string {595	return f.inner.list(prefix)!596}597598fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {599	f.writes++600	if f.fail_write > 0 && f.writes >= f.fail_write {601		return error('injected write failure')602	}603	return f.inner.put_if_absent(key, body)!604}605606fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {607	f.writes++608	if f.fail_write > 0 && f.writes >= f.fail_write {609		return error('injected write failure')610	}611	return f.inner.put_if_match(key, body, tag)!612}613614fn (mut f FaultyBackend) delete(key string) ! {615	if f.fail_delete {616		return error('injected delete failure')617	}618	f.inner.delete(key)!619}620621fn test_a_failed_offer_is_safe_to_re_run() {622	base := scratch('fault_offer')!623	defer {624		os.rmdir_all(base) or {}625	}626	remote_dir := os.join_path(base, 'remote')627	mut a := open_repo(machine('fault_a')!)!628	defer {629		a.close()630	}631	put(a.root, 'a.txt', 'one\n')!632	commit(a, 'first')!633	put(a.root, 'a.txt', 'two\n')!634	commit(a, 'second')!635636	// The first offer dies after the first write.637	mut flaky := FaultyBackend{638		inner:      remote.new_folder(remote_dir)!639		fail_write: 2640	}641	if _ := offer(a, mut flaky) {642		assert false, 'the injected failure should have surfaced'643	}644645	// Re-running with a healthy remote finishes the job: "already exists" is646	// success, so nothing is lost and nothing is duplicated.647	mut healthy := remote.new_folder(remote_dir)!648	report := offer(a, mut healthy)!649	assert report.saves == 2650	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id651	assert healthy.list('proj/saves')!.len == 2652}653654fn test_a_failed_steal_leaves_nothing_behind() {655	base := scratch('fault_steal')!656	defer {657		os.rmdir_all(base) or {}658	}659	remote_dir := os.join_path(base, 'remote')660	mut a := open_repo(machine('fault_s_a')!)!661	defer {662		a.close()663	}664	mut b := open_repo(machine('fault_s_b')!)!665	defer {666		b.close()667	}668	put(a.root, 'a.txt', 'from A\n')!669	commit(a, 'A writes')!670	mut healthy := remote.new_folder(remote_dir)!671	offer(a, mut healthy)!672673	// B's fetch fails half way through reading the remote.674	mut flaky := FaultyBackend{675		inner:     remote.new_folder(remote_dir)!676		fail_read: 2677	}678	if _ := fetch_back_to_known(b, mut flaky, 'proj') {679		assert false, 'the injected read failure should have surfaced'680	}681	// Nothing was written: the store is untouched and still has no saves.682	assert history.head(b) == none683	assert b.object_count()! == 0684685	// A healthy retry works.686	fetched := fetch_back_to_known(b, mut healthy, 'proj')!687	b.begin()!688	write_fetched(b, fetched)!689	history.set_head(b, fetched.head)!690	b.commit()!691	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!692	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'693}694695fn test_an_interrupted_strip_is_idempotent() {696	// The local half of a strip is one transaction, so an interrupted strip is697	// simply a strip that did not happen; re-running it must land the same698	// result once, not twice.699	base := scratch('fault_strip')!700	defer {701		os.rmdir_all(base) or {}702	}703	mut a := open_repo(machine('fault_strip_a')!)!704	defer {705		a.close()706	}707	put(a.root, 'secret.txt', 'shh\n')!708	put(a.root, 'keep.txt', 'fine\n')!709	save := commit(a, 'add both')!710711	for _ in 0 .. 2 {712		strip_local(a, 'secret.txt', '')!713	}714	files := history.files_of(a, save.id)!715	// The repo's own `.kohignore` is tracked content, so it stays; only the716	// stripped path goes.717	assert files.map(it.path) == ['.kohignore', 'keep.txt']718	assert events.strip_events(a)!.len == 1719}720721// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into722// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.723// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,724// but a save made on Linux can hold both.725fn test_a_strip_matches_the_path_exactly() {726	mut a := open_repo(machine('strip_exact')!)!727	defer {728		a.close()729	}730	hash := a.put_object('x\n'.bytes())!731	mut files := []snapshot.FileEntry{}732	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {733		files << snapshot.FileEntry{734			path: path735			hash: hash736		}737	}738	save := history.commit_files(a, '', files, history.SaveInput{739		message: 'add all'740	})!741	assert strip_scope(a, 'docs', '')!.files == 1742	strip_local(a, 'docs', '')!743	strip_local(a, 'a_b', '')!744	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']745}746747fn test_a_strip_reaches_the_remote_and_other_machines() {748	base := scratch('strip_sync')!749	defer {750		os.rmdir_all(base) or {}751	}752	mut a := open_repo(machine('strip_a')!)!753	defer {754		a.close()755	}756	mut b := open_repo(machine('strip_b')!)!757	defer {758		b.close()759	}760	put(a.root, 'secrets.env', 'api_key=1\n')!761	put(a.root, 'keep.txt', 'fine\n')!762	save := commit(a, 'add both')!763	mut backend := remote.new_folder(os.join_path(base, 'remote'))!764	offer_now(a, mut backend)!765	join(b, mut backend)!766	assert b.object_count()! > 0767768	// A strips the path locally, the way `koh strip` does, then tells the769	// remote.770	mut secret_hash := ''771	for file in history.files_of(a, save.id)! {772		if file.path == 'secrets.env' {773			secret_hash = file.hash774		}775	}776	assert secret_hash != ''777	strip_local(a, 'secrets.env', '')!778	report := push_strip(a, mut backend, 'secrets.env')!779	assert report.event_sent780	assert report.records == 1781782	// The remote's copy of the save no longer lists the file, and the object is783	// gone from the remote.784	record := decode_save(backend.get(save_key('proj', save.id))!.body)!785	for file in record.files {786		assert file.path != 'secrets.env'787	}788	// The stripped object is gone from the remote; the ones still referenced789	// are not.790	assert !backend.get(object_key('proj', secret_hash))!.found791	assert backend.list('proj/objects')!.len == 2792793	// Another machine hears about it on its next steal and applies it.794	b.begin()!795	fetched := fetch_back_to_known(b, mut backend, 'proj')!796	write_fetched(b, fetched)!797	b.commit()!798	mut paths := []string{}799	for file in history.files_of(b, save.id)! {800		paths << file.path801	}802	assert 'secrets.env' !in paths803	assert 'keep.txt' in paths804805	// The event arrives under its own ID, so it is applied once: B's offer adds806	// no second copy, and a file B saves again after the strip stays saved807	// through B's next steal.808	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)809	put(b.root, 'secrets.env', 'rotated\n')!810	readded := commit(b, 'add the rotated key back')!811	offer_now(b, mut backend)!812	assert backend.list('proj/events')!.len == 1813	b.begin()!814	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!815	b.commit()!816	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)817}818819// A strip removes a path from the saves that existed when it was made. A key820// rotated and saved again afterwards is new work: a machine that clones the821// repo later must get it, not strip it on arrival.822fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {823	base := scratch('strip_clone')!824	defer {825		os.rmdir_all(base) or {}826	}827	mut backend := remote.new_folder(os.join_path(base, 'remote'))!828	mut a := open_repo(machine('strip_clone_a')!)!829	defer {830		a.close()831	}832	put(a.root, 'secrets.env', 'api_key=1\n')!833	old := commit(a, 'leak the key')!834	offer_now(a, mut backend)!835	strip_local(a, 'secrets.env', '')!836	push_strip(a, mut backend, 'secrets.env')!837	// Saves after the strip must be later in time, as they would be for real.838	put(a.root, 'secrets.env', 'api_key=2\n')!839	rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{840		message:    'rotate the key'841		created_at: events.strip_events(a)!.last().created_at + 60842	})!843	offer_now(a, mut backend)!844845	mut c := open_repo(machine('strip_clone_c')!)!846	defer {847		c.close()848	}849	join(c, mut backend)!850	assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)851	assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)852	assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'853}854855// The local half commits before the remote hears anything, so a remote failure856// leaves nothing to find locally; the pending note is what lets a re-run857// finish. Objects a remote-only save still needs must survive the strip.858fn test_an_interrupted_remote_strip_finishes_when_re_run() {859	base := scratch('strip_resume')!860	defer {861		os.rmdir_all(base) or {}862	}863	remote_dir := os.join_path(base, 'remote')864	mut backend := remote.new_folder(remote_dir)!865	mut a := open_repo(machine('resume_a')!)!866	defer {867		a.close()868	}869	mut b := open_repo(machine('resume_b')!)!870	defer {871		b.close()872	}873	put(a.root, 'secrets.env', 'api_key=1\n')!874	put(a.root, 'shared.env', 'same bytes on B\n')!875	save := commit(a, 'add secrets')!876	offer_now(a, mut backend)!877	join(b, mut backend)!878	put(b.root, 'copy.txt', 'same bytes on B\n')!879	b_save := commit(b, 'B keeps a copy')!880	offer_now(b, mut backend)!881	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())882	secret_hash := storage.hash_hex('api_key=1\n'.bytes())883884	strip_local(a, 'secrets.env', '')!885	// Write 1 uploads the event and write 2 rewrites the first record; B's save,886	// made from A's tree, lists the file too, and its rewrite (write 3) fails.887	mut flaky := FaultyBackend{888		inner:      remote.new_folder(remote_dir)!889		fail_write: 3890	}891	if _ := push_strip(a, mut flaky, 'secrets.env') {892		assert false, 'the injected failure should have surfaced'893	}894	assert strip_pending(a, 'secrets.env')895	assert strip_local(a, 'secrets.env', '')!.files_removed == 0896	report := push_strip(a, mut backend, 'secrets.env')!897	assert report.records == 1898	assert !strip_pending(a, 'secrets.env')899	assert !backend.get(object_key('proj', secret_hash))!.found900	record := decode_save(backend.get(save_key('proj', save.id))!.body)!901	assert 'secrets.env' !in record.files.map(it.path)902	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!903	assert 'secrets.env' !in b_record.files.map(it.path)904905	// B's save still names the shared bytes, so stripping A's copy keeps them.906	strip_local(a, 'shared.env', '')!907	push_strip(a, mut backend, 'shared.env')!908	assert backend.get(object_key('proj', shared_hash))!.found909}910911// A machine that has not heard of a strip must not offer: its records still912// list the stripped path, and nothing would rewrite them on the remote again.913fn test_offer_waits_for_an_unapplied_strip() {914	base := scratch('strip_first')!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_first_a')!)!920	defer {921		a.close()922	}923	mut b := open_repo(machine('strip_first_b')!)!924	defer {925		b.close()926	}927	put(a.root, 'secrets.env', 'api_key=1\n')!928	commit(a, 'add secrets')!929	offer_now(a, mut backend)!930	join(b, mut backend)!931	strip_local(a, 'secrets.env', '')!932	push_strip(a, mut backend, 'secrets.env')!933	put(b.root, 'more.txt', 'B keeps working\n')!934	later := commit(b, 'B saves with the secret still in its tree')!935	if _ := offer_now(b, mut backend) {936		assert false, 'B offered records that still list the stripped path'937	}938	steal_now(b, mut backend)!939	offer_now(b, mut backend)!940	record := decode_save(backend.get(save_key('proj', later.id))!.body)!941	assert 'secrets.env' !in record.files.map(it.path)942}943944// A strip whose object deletions failed, followed by a second strip of the same945// path, must still delete the first strip's objects when it finishes.946fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {947	base := scratch('strip_twice')!948	defer {949		os.rmdir_all(base) or {}950	}951	remote_dir := os.join_path(base, 'remote')952	mut backend := remote.new_folder(remote_dir)!953	mut a := open_repo(machine('strip_twice_a')!)!954	defer {955		a.close()956	}957	put(a.root, 'secrets.env', 'first key\n')!958	commit(a, 'first key')!959	offer_now(a, mut backend)!960	strip_local(a, 'secrets.env', '')!961	mut flaky := FaultyBackend{962		inner:       remote.new_folder(remote_dir)!963		fail_delete: true964	}965	if _ := push_strip(a, mut flaky, 'secrets.env') {966		assert false, 'the injected delete failure should have surfaced'967	}968	put(a.root, 'secrets.env', 'second key\n')!969	commit(a, 'second key')!970	offer_now(a, mut backend)!971	strip_local(a, 'secrets.env', '')!972	push_strip(a, mut backend, 'secrets.env')!973	for key in ['first key\n', 'second key\n'] {974		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found975	}976}977978// Unfinished strips of one path in different scopes finish together.979fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {980	base := scratch('strip_scopes')!981	defer {982		os.rmdir_all(base) or {}983	}984	remote_dir := os.join_path(base, 'remote')985	mut backend := remote.new_folder(remote_dir)!986	mut a := open_repo(machine('strip_scopes_a')!)!987	defer {988		a.close()989	}990	put(a.root, 'secrets.env', 'first key\n')!991	first := commit(a, 'first key')!992	put(a.root, 'secrets.env', 'second key\n')!993	second := commit(a, 'second key')!994	offer_now(a, mut backend)!995	strip_local(a, 'secrets.env', first.id)!996	// Write 1 uploads the event; write 2, the record rewrite, fails.997	mut flaky := FaultyBackend{998		inner:      remote.new_folder(remote_dir)!999		fail_write: 21000	}1001	if _ := push_strip(a, mut flaky, 'secrets.env') {1002		assert false, 'the injected failure should have surfaced'1003	}1004	strip_local(a, 'secrets.env', second.id)!1005	push_strip(a, mut backend, 'secrets.env')!1006	assert !strip_pending(a, 'secrets.env')1007	for save in [first, second] {1008		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1009		assert 'secrets.env' !in record.files.map(it.path)1010	}1011	for key in ['first key\n', 'second key\n'] {1012		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1013	}1014}10151016// Stripping a path, re-adding it, and stripping it again is two strips; a1017// machine that heard about the first must still apply the second.1018fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1019	base := scratch('strip_again')!1020	defer {1021		os.rmdir_all(base) or {}1022	}1023	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1024	mut a := open_repo(machine('strip_again_a')!)!1025	defer {1026		a.close()1027	}1028	mut b := open_repo(machine('strip_again_b')!)!1029	defer {1030		b.close()1031	}1032	put(a.root, 'secrets.env', 'first key\n')!1033	commit(a, 'first key')!1034	offer_now(a, mut backend)!1035	join(b, mut backend)!1036	strip_local(a, 'secrets.env', '')!1037	push_strip(a, mut backend, 'secrets.env')!1038	steal_now(b, mut backend)!1039	// Strip leaves the file on disk; deleting it is the user's part.1040	os.rm(os.join_path(b.root, 'secrets.env'))!1041	put(a.root, 'secrets.env', 'second key\n')!1042	second := commit(a, 'second key')!1043	offer_now(a, mut backend)!1044	steal_now(b, mut backend)!1045	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1046	strip_local(a, 'secrets.env', '')!1047	push_strip(a, mut backend, 'secrets.env')!1048	steal_now(b, mut backend)!1049	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1050}10511052// A clash on a path the other machine stripped is no clash: the strip removes1053// the path from every save, so there is nothing to choose.1054fn test_a_stripped_path_is_not_asked_about() {1055	base := scratch('clash_strip')!1056	defer {1057		os.rmdir_all(base) or {}1058	}1059	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1060	mut a := open_repo(machine('clash_strip_a')!)!1061	defer {1062		a.close()1063	}1064	mut b := open_repo(machine('clash_strip_b')!)!1065	defer {1066		b.close()1067	}1068	put(a.root, 'secrets.env', 'key one\n')!1069	commit(a, 'base')!1070	offer_now(a, mut backend)!1071	join(b, mut backend)!1072	put(a.root, 'secrets.env', 'key two\n')!1073	commit(a, 'A rotates')!1074	offer_now(a, mut backend)!1075	strip_local(a, 'secrets.env', '')!1076	push_strip(a, mut backend, 'secrets.env')!1077	put(b.root, 'secrets.env', 'key three\n')!1078	mine := commit(b, 'B rotates')!1079	report := steal(b, mut backend, StealOptions{})!1080	assert report.clashes.len == 01081	for save in history.all_saves(b)! {1082		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1083	}1084	assert history.by_id(b, mine.id) != none1085}10861087// A strip scoped to the shared base save changes what the replay sees; the1088// clashes must be found on the same trees, or the replay meets a conflict.1089fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1090	base := scratch('clash_base_strip')!1091	defer {1092		os.rmdir_all(base) or {}1093	}1094	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1095	mut a := open_repo(machine('base_strip_a')!)!1096	defer {1097		a.close()1098	}1099	mut b := open_repo(machine('base_strip_b')!)!1100	defer {1101		b.close()1102	}1103	put(a.root, 'notes.txt', 'shared\n')!1104	common := commit(a, 'base')!1105	offer_now(a, mut backend)!1106	join(b, mut backend)!1107	put(a.root, 'other.txt', 'A moves on\n')!1108	commit(a, 'A moves on')!1109	offer_now(a, mut backend)!1110	strip_local(a, 'notes.txt', common.id)!1111	push_strip(a, mut backend, 'notes.txt')!1112	put(b.root, 'notes.txt', 'B edits\n')!1113	commit(b, 'B edits notes')!1114	if _ := steal(b, mut backend, StealOptions{}) {1115		assert false, 'with the base stripped, notes.txt differs on both sides'1116	} else {1117		assert err.msg().contains('changed on both machines')1118	}1119	report := steal(b, mut backend, StealOptions{1120		mine: true1121	})!1122	assert report.clashes == ['notes.txt']1123	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1124}11251126// A folder with files but no saves has nothing to combine with; a steal that1127// fast-forwarded into it would overwrite whatever shares a name.1128fn test_steal_into_a_folder_with_unsaved_files_refuses() {1129	base := scratch('steal_unsaved')!1130	defer {1131		os.rmdir_all(base) or {}1132	}1133	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1134	mut a := open_repo(machine('unsaved_a')!)!1135	defer {1136		a.close()1137	}1138	put(a.root, 'notes.txt', 'from A\n')!1139	commit(a, 'A writes')!1140	offer_now(a, mut backend)!1141	b_root := machine('unsaved_b')!1142	mut b := storage.create(b_root)!1143	defer {1144		b.close()1145	}1146	put(b_root, 'notes.txt', 'never saved\n')!1147	// Only a repo that already knows its remote name gets this far.1148	b.set_state('remote_repo', 'proj')!1149	if _ := steal(b, mut backend, StealOptions{}) {1150		assert false, 'steal overwrote a folder with unsaved files'1151	} else {1152		assert err.msg() == no_saves_here1153	}1154	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1155	// A fresh `koh init` folder, holding only the default ignore list, is fine.1156	os.rm(os.join_path(b_root, 'notes.txt'))!1157	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1158	steal(b, mut backend, StealOptions{})!1159	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1160}11611162// The document asks steal to warn about every incoming flagged save, so the1163// report names them.1164fn test_steal_reports_incoming_flagged_saves() {1165	base := scratch('flag_warn')!1166	defer {1167		os.rmdir_all(base) or {}1168	}1169	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1170	mut a := open_repo(machine('flag_warn_a')!)!1171	defer {1172		a.close()1173	}1174	mut b := open_repo(machine('flag_warn_b')!)!1175	defer {1176		b.close()1177	}1178	put(a.root, 'a.txt', 'one\n')!1179	commit(a, 'fine')!1180	offer_now(a, mut backend)!1181	join(b, mut backend)!1182	put(a.root, 'a.txt', 'two\n')!1183	suspect := commit(a, 'suspect')!1184	put(a.root, 'a.txt', 'three\n')!1185	commit(a, 'also fine')!1186	events.flag(a, suspect.id, 'broke the build')!1187	offer_now(a, mut backend)!1188	report := steal_now(b, mut backend)!1189	assert report.flagged.map(it.save_id) == [suspect.id]1190	assert report.flagged[0].reason == 'broke the build'1191}