sync_test.v

33.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 replayed save keeps its original time but sits on a newer parent, so a164// clone cannot order saves by time.165fn test_a_clone_imports_parents_before_children() {166	base := scratch('clone_order')!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('order_a')!)!172	defer {173		a.close()174	}175	put(a.root, 'a.txt', 'one\n')!176	parent := commit(a, 'parent')!177	child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{178		message:    'replayed, older than its parent'179		created_at: parent.created_at - 3600180	})!181	offer_now(a, mut backend)!182	mut b := open_repo(machine('order_b')!)!183	defer {184		b.close()185	}186	join(b, mut backend)!187	assert history.head(b)!.id == child.id188}189190// A chmod is unsaved work too: a steal would restore the old mode over it.191fn test_steal_refuses_an_unsaved_executable_bit_change() {192	base := scratch('chmod')!193	defer {194		os.rmdir_all(base) or {}195	}196	mut backend := remote.new_folder(os.join_path(base, 'remote'))!197	mut a := open_repo(machine('chmod_a')!)!198	defer {199		a.close()200	}201	put(a.root, 'run.sh', 'echo hi\n')!202	commit(a, 'script')!203	offer_now(a, mut backend)!204	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!205	if _ := steal_now(a, mut backend) {206		assert false, 'a chmod-only change should make the tree dirty'207	} else {208		assert err.msg() == dirty_tree209	}210}211212fn test_divergence_without_clashes_keeps_both_machines_work() {213	base := scratch('diverge')!214	defer {215		os.rmdir_all(base) or {}216	}217	mut backend := remote.new_folder(os.join_path(base, 'remote'))!218	mut a := open_repo(machine('div_a')!)!219	defer {220		a.close()221	}222	mut b := open_repo(machine('div_b')!)!223	defer {224		b.close()225	}226	put(a.root, 'shared.txt', 'base\n')!227	commit(a, 'base')!228	offer_now(a, mut backend)!229230	// B joins, then both machines change a different file.231	join(b, mut backend)!232	put(a.root, 'fromA.txt', 'A\n')!233	commit(a, 'A adds a file')!234	put(b.root, 'fromB.txt', 'B\n')!235	commit(b, 'B adds a file')!236	offer_now(a, mut backend)!237238	// B's offer is refused: the remote has moved on.239	if _ := offer_now(b, mut backend) {240		assert false, 'offering on top of unknown remote saves should be refused'241	} else {242		assert err.msg().contains('run koh steal')243	}244245	report := steal_now(b, mut backend)!246	assert !report.forward247	assert report.replayed == 1248	assert report.clashes.len == 0249	// Both files are on B's line now, and on disk.250	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'251	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'252	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'253	// B's own save was replayed, not erased.254	mut saw_replay := false255	for save in history.all_saves(b)! {256		if save.replayed_from != '' {257			saw_replay = true258		}259	}260	assert saw_replay261	// And the remote now has B's work too.262	offer_now(b, mut backend)!263	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()264	assert line_head == history.head(b)!.id265}266267// An agent that edits a file and then undoes the edit leaves no net change, so268// the file is not a clash when the other machine changed it. Replaying the269// edit itself must not stop the steal: the remote's version simply stands.270// Likewise a file both machines ended up changing the same way.271fn test_a_local_change_undone_does_not_block_a_combine() {272	base := scratch('undone')!273	defer {274		os.rmdir_all(base) or {}275	}276	mut backend := remote.new_folder(os.join_path(base, 'remote'))!277	mut a := open_repo(machine('undone_a')!)!278	defer {279		a.close()280	}281	mut b := open_repo(machine('undone_b')!)!282	defer {283		b.close()284	}285	put(a.root, 'f.txt', 'base\n')!286	put(a.root, 'g.txt', 'base\n')!287	commit(a, 'base')!288	offer_now(a, mut backend)!289	join(b, mut backend)!290291	put(a.root, 'f.txt', 'from A\n')!292	put(a.root, 'g.txt', 'same\n')!293	commit(a, 'A edits both')!294	offer_now(a, mut backend)!295296	put(b.root, 'f.txt', 'tried on B\n')!297	put(b.root, 'g.txt', 'first try\n')!298	commit(b, 'B tries')!299	put(b.root, 'f.txt', 'base\n')!300	put(b.root, 'g.txt', 'same\n')!301	put(b.root, 'h.txt', 'B\n')!302	commit(b, 'B undoes f, lands on the same g, adds h')!303304	report := steal_now(b, mut backend)!305	assert report.clashes.len == 0306	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'307	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'308	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'309}310311// A person deciding a clash may take minutes; the question must come before312// the write lock, or every agent's save on this repo waits and then fails.313fn test_clash_questions_are_asked_before_the_write_lock() {314	base := scratch('ask_unlocked')!315	defer {316		os.rmdir_all(base) or {}317	}318	mut backend := remote.new_folder(os.join_path(base, 'remote'))!319	mut a := open_repo(machine('ask_a')!)!320	defer {321		a.close()322	}323	mut b := open_repo(machine('ask_b')!)!324	defer {325		b.close()326	}327	put(a.root, 'same.txt', 'base\n')!328	commit(a, 'base')!329	offer_now(a, mut backend)!330	join(b, mut backend)!331	put(a.root, 'same.txt', 'A version\n')!332	commit(a, 'A edits same')!333	offer_now(a, mut backend)!334	put(b.root, 'same.txt', 'B version\n')!335	commit(b, 'B edits same')!336	root := b.root337	// An agent saves while the person is still choosing.338	if _ := steal(b, mut backend, StealOptions{339		ask: fn [root] (clash Clash) Side {340			other := storage.open(root) or { panic(err) }341			defer {342				other.close()343			}344			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }345			history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{346				message: 'agent saves during the question'347			}) or { panic('steal held the write lock while asking: ${err}') }348			return .mine349		}350	})351	{352		assert false, 'a steal whose line moved during the question must refuse'353	} else {354		assert err.msg().contains('changed while')355	}356	// The agent's save is still head, and a second steal combines it too.357	assert history.head(b)!.message == 'agent saves during the question'358	report := steal(b, mut backend, StealOptions{359		mine: true360	})!361	assert report.kept_mine == 1362	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'363	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'364}365366fn test_clashing_files_are_chosen_one_at_a_time() {367	base := scratch('clash')!368	defer {369		os.rmdir_all(base) or {}370	}371	mut backend := remote.new_folder(os.join_path(base, 'remote'))!372	mut a := open_repo(machine('clash_a')!)!373	defer {374		a.close()375	}376	mut b := open_repo(machine('clash_b')!)!377	defer {378		b.close()379	}380	put(a.root, 'same.txt', 'base\n')!381	put(a.root, 'other.txt', 'quiet\n')!382	commit(a, 'base')!383	offer_now(a, mut backend)!384	join(b, mut backend)!385386	// Both machines edit the same file, differently.387	put(a.root, 'same.txt', 'A version\n')!388	commit(a, 'A edits same')!389	offer_now(a, mut backend)!390	put(b.root, 'same.txt', 'B version\n')!391	commit(b, 'B edits same')!392393	// Without a decision, steal refuses and says which files clash.394	if _ := steal(b, mut backend, StealOptions{}) {395		assert false, 'an undecided clash must be refused'396	} else {397		assert err.msg().contains('--mine') || err.msg().contains('--theirs')398	}399	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,400		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0401402	// Choosing the remote's version ends with A's content.403	report := steal_now(b, mut backend)!404	assert report.clashes == ['same.txt']405	assert report.kept_mine == 0406	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'407	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'408}409410fn test_choosing_mine_keeps_the_local_version() {411	base := scratch('mine')!412	defer {413		os.rmdir_all(base) or {}414	}415	mut backend := remote.new_folder(os.join_path(base, 'remote'))!416	mut a := open_repo(machine('mine_a')!)!417	defer {418		a.close()419	}420	mut b := open_repo(machine('mine_b')!)!421	defer {422		b.close()423	}424	put(a.root, 'same.txt', 'base\n')!425	commit(a, 'base')!426	offer_now(a, mut backend)!427	join(b, mut backend)!428	put(a.root, 'same.txt', 'A version\n')!429	commit(a, 'A edits')!430	offer_now(a, mut backend)!431	put(b.root, 'same.txt', 'B version\n')!432	commit(b, 'B edits')!433434	report := steal(b, mut backend, StealOptions{435		mine: true436	})!437	assert report.kept_mine == 1438	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'439	// The final save says what it kept.440	assert history.head(b)!.message.contains('combine: kept mine for 1 file')441	// The pre-combine tree is still reachable in history.442	assert history.head(b)!.restores == ''443	assert history.all_saves(b)!.len >= 4444}445446fn test_flags_travel_with_the_saves() {447	base := scratch('flags')!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('flag_a')!)!453	defer {454		a.close()455	}456	mut b := open_repo(machine('flag_b')!)!457	defer {458		b.close()459	}460	put(a.root, 'a.txt', 'one\n')!461	save := commit(a, 'suspect save')!462	events.flag(a, save.id, 'look at this')!463	offer_now(a, mut backend)!464465	// B has nothing yet, so a fetch brings the save and its flag across.466	fetched := fetch_back_to_known(b, mut backend, 'proj')!467	assert fetched.events_out.len == 1468	assert fetched.events_out[0].kind == events.kind_flag469	b.begin()!470	write_fetched(b, fetched)!471	history.set_head(b, fetched.head)!472	b.commit()!473	state := events.flag_state(b, save.id)!474	assert state.flagged475	assert state.reason == 'look at this'476}477478fn test_a_second_offer_racing_head_is_refused_not_forced() {479	base := scratch('race')!480	defer {481		os.rmdir_all(base) or {}482	}483	mut backend := remote.new_folder(os.join_path(base, 'remote'))!484	mut a := open_repo(machine('race_a')!)!485	defer {486		a.close()487	}488	put(a.root, 'a.txt', 'one\n')!489	commit(a, 'base')!490	offer_now(a, mut backend)!491492	// Someone else replaces head behind our back.493	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),494		backend.get(head_key('proj'))!.tag)!495496	put(a.root, 'a.txt', 'two\n')!497	commit(a, 'second')!498	if _ := offer_now(a, mut backend) {499		assert false, 'offering while the remote head moved must be refused'500	} else {501		assert err.msg().contains('run koh steal')502	}503}504505// join makes a brand new machine share the remote's line.506fn join(store &storage.Store, mut backend remote.Backend) ! {507	fetched := fetch_repo(mut backend, 'proj')!508	store.begin()!509	write_fetched(store, fetched)!510	history.set_head(store, fetched.head)!511	store.commit()!512	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,513		fetched.head)!, [])!514	store.set_state('remote_repo', 'proj')!515}516517struct FaultyBackend {518mut:519	inner      remote.Backend520	writes     int521	reads      int522	fail_write int // fail when the write counter reaches this, 0 never523	fail_read  int524	// fail every delete while set525	fail_delete bool526}527528fn (mut f FaultyBackend) get(key string) !remote.Object {529	f.reads++530	if f.fail_read > 0 && f.reads >= f.fail_read {531		return error('injected read failure')532	}533	return f.inner.get(key)!534}535536fn (mut f FaultyBackend) list(prefix string) ![]string {537	return f.inner.list(prefix)!538}539540fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {541	f.writes++542	if f.fail_write > 0 && f.writes >= f.fail_write {543		return error('injected write failure')544	}545	return f.inner.put_if_absent(key, body)!546}547548fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {549	f.writes++550	if f.fail_write > 0 && f.writes >= f.fail_write {551		return error('injected write failure')552	}553	return f.inner.put_if_match(key, body, tag)!554}555556fn (mut f FaultyBackend) delete(key string) ! {557	if f.fail_delete {558		return error('injected delete failure')559	}560	f.inner.delete(key)!561}562563fn test_a_failed_offer_is_safe_to_re_run() {564	base := scratch('fault_offer')!565	defer {566		os.rmdir_all(base) or {}567	}568	remote_dir := os.join_path(base, 'remote')569	mut a := open_repo(machine('fault_a')!)!570	defer {571		a.close()572	}573	put(a.root, 'a.txt', 'one\n')!574	commit(a, 'first')!575	put(a.root, 'a.txt', 'two\n')!576	commit(a, 'second')!577578	// The first offer dies after the first write.579	mut flaky := FaultyBackend{580		inner:      remote.new_folder(remote_dir)!581		fail_write: 2582	}583	if _ := offer(a, mut flaky) {584		assert false, 'the injected failure should have surfaced'585	}586587	// Re-running with a healthy remote finishes the job: "already exists" is588	// success, so nothing is lost and nothing is duplicated.589	mut healthy := remote.new_folder(remote_dir)!590	report := offer(a, mut healthy)!591	assert report.saves == 2592	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id593	assert healthy.list('proj/saves')!.len == 2594}595596fn test_a_failed_steal_leaves_nothing_behind() {597	base := scratch('fault_steal')!598	defer {599		os.rmdir_all(base) or {}600	}601	remote_dir := os.join_path(base, 'remote')602	mut a := open_repo(machine('fault_s_a')!)!603	defer {604		a.close()605	}606	mut b := open_repo(machine('fault_s_b')!)!607	defer {608		b.close()609	}610	put(a.root, 'a.txt', 'from A\n')!611	commit(a, 'A writes')!612	mut healthy := remote.new_folder(remote_dir)!613	offer(a, mut healthy)!614615	// B's fetch fails half way through reading the remote.616	mut flaky := FaultyBackend{617		inner:     remote.new_folder(remote_dir)!618		fail_read: 2619	}620	if _ := fetch_back_to_known(b, mut flaky, 'proj') {621		assert false, 'the injected read failure should have surfaced'622	}623	// Nothing was written: the store is untouched and still has no saves.624	assert history.head(b) == none625	assert b.object_count()! == 0626627	// A healthy retry works.628	fetched := fetch_back_to_known(b, mut healthy, 'proj')!629	b.begin()!630	write_fetched(b, fetched)!631	history.set_head(b, fetched.head)!632	b.commit()!633	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!634	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'635}636637fn test_an_interrupted_strip_is_idempotent() {638	// The local half of a strip is one transaction, so an interrupted strip is639	// simply a strip that did not happen; re-running it must land the same640	// result once, not twice.641	base := scratch('fault_strip')!642	defer {643		os.rmdir_all(base) or {}644	}645	mut a := open_repo(machine('fault_strip_a')!)!646	defer {647		a.close()648	}649	put(a.root, 'secret.txt', 'shh\n')!650	put(a.root, 'keep.txt', 'fine\n')!651	save := commit(a, 'add both')!652653	for _ in 0 .. 2 {654		strip_local(a, 'secret.txt', '')!655	}656	files := history.files_of(a, save.id)!657	// The repo's own `.kohignore` is tracked content, so it stays; only the658	// stripped path goes.659	assert files.map(it.path) == ['.kohignore', 'keep.txt']660	assert events.strip_events(a)!.len == 1661}662663// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into664// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.665// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,666// but a save made on Linux can hold both.667fn test_a_strip_matches_the_path_exactly() {668	mut a := open_repo(machine('strip_exact')!)!669	defer {670		a.close()671	}672	hash := a.put_object('x\n'.bytes())!673	mut files := []snapshot.FileEntry{}674	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {675		files << snapshot.FileEntry{676			path: path677			hash: hash678		}679	}680	save := history.commit_files(a, '', files, history.SaveInput{681		message: 'add all'682	})!683	assert strip_scope(a, 'docs', '')!.files == 1684	strip_local(a, 'docs', '')!685	strip_local(a, 'a_b', '')!686	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']687}688689fn test_a_strip_reaches_the_remote_and_other_machines() {690	base := scratch('strip_sync')!691	defer {692		os.rmdir_all(base) or {}693	}694	mut a := open_repo(machine('strip_a')!)!695	defer {696		a.close()697	}698	mut b := open_repo(machine('strip_b')!)!699	defer {700		b.close()701	}702	put(a.root, 'secrets.env', 'api_key=1\n')!703	put(a.root, 'keep.txt', 'fine\n')!704	save := commit(a, 'add both')!705	mut backend := remote.new_folder(os.join_path(base, 'remote'))!706	offer_now(a, mut backend)!707	join(b, mut backend)!708	assert b.object_count()! > 0709710	// A strips the path locally, the way `koh strip` does, then tells the711	// remote.712	mut secret_hash := ''713	for file in history.files_of(a, save.id)! {714		if file.path == 'secrets.env' {715			secret_hash = file.hash716		}717	}718	assert secret_hash != ''719	strip_local(a, 'secrets.env', '')!720	report := push_strip(a, mut backend, 'secrets.env')!721	assert report.event_sent722	assert report.records == 1723724	// The remote's copy of the save no longer lists the file, and the object is725	// gone from the remote.726	record := decode_save(backend.get(save_key('proj', save.id))!.body)!727	for file in record.files {728		assert file.path != 'secrets.env'729	}730	// The stripped object is gone from the remote; the ones still referenced731	// are not.732	assert !backend.get(object_key('proj', secret_hash))!.found733	assert backend.list('proj/objects')!.len == 2734735	// Another machine hears about it on its next steal and applies it.736	b.begin()!737	fetched := fetch_back_to_known(b, mut backend, 'proj')!738	write_fetched(b, fetched)!739	b.commit()!740	mut paths := []string{}741	for file in history.files_of(b, save.id)! {742		paths << file.path743	}744	assert 'secrets.env' !in paths745	assert 'keep.txt' in paths746747	// The event arrives under its own ID, so it is applied once: B's offer adds748	// no second copy, and a file B saves again after the strip stays saved749	// through B's next steal.750	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)751	put(b.root, 'secrets.env', 'rotated\n')!752	readded := commit(b, 'add the rotated key back')!753	offer_now(b, mut backend)!754	assert backend.list('proj/events')!.len == 1755	b.begin()!756	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!757	b.commit()!758	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)759}760761// The local half commits before the remote hears anything, so a remote failure762// leaves nothing to find locally; the pending note is what lets a re-run763// finish. Objects a remote-only save still needs must survive the strip.764fn test_an_interrupted_remote_strip_finishes_when_re_run() {765	base := scratch('strip_resume')!766	defer {767		os.rmdir_all(base) or {}768	}769	remote_dir := os.join_path(base, 'remote')770	mut backend := remote.new_folder(remote_dir)!771	mut a := open_repo(machine('resume_a')!)!772	defer {773		a.close()774	}775	mut b := open_repo(machine('resume_b')!)!776	defer {777		b.close()778	}779	put(a.root, 'secrets.env', 'api_key=1\n')!780	put(a.root, 'shared.env', 'same bytes on B\n')!781	save := commit(a, 'add secrets')!782	offer_now(a, mut backend)!783	join(b, mut backend)!784	put(b.root, 'copy.txt', 'same bytes on B\n')!785	b_save := commit(b, 'B keeps a copy')!786	offer_now(b, mut backend)!787	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())788	secret_hash := storage.hash_hex('api_key=1\n'.bytes())789790	strip_local(a, 'secrets.env', '')!791	// Write 1 uploads the event and write 2 rewrites the first record; B's save,792	// made from A's tree, lists the file too, and its rewrite (write 3) fails.793	mut flaky := FaultyBackend{794		inner:      remote.new_folder(remote_dir)!795		fail_write: 3796	}797	if _ := push_strip(a, mut flaky, 'secrets.env') {798		assert false, 'the injected failure should have surfaced'799	}800	assert strip_pending(a, 'secrets.env')801	assert strip_local(a, 'secrets.env', '')!.files_removed == 0802	report := push_strip(a, mut backend, 'secrets.env')!803	assert report.records == 1804	assert !strip_pending(a, 'secrets.env')805	assert !backend.get(object_key('proj', secret_hash))!.found806	record := decode_save(backend.get(save_key('proj', save.id))!.body)!807	assert 'secrets.env' !in record.files.map(it.path)808	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!809	assert 'secrets.env' !in b_record.files.map(it.path)810811	// B's save still names the shared bytes, so stripping A's copy keeps them.812	strip_local(a, 'shared.env', '')!813	push_strip(a, mut backend, 'shared.env')!814	assert backend.get(object_key('proj', shared_hash))!.found815}816817// A machine that has not heard of a strip must not offer: its records still818// list the stripped path, and nothing would rewrite them on the remote again.819fn test_offer_waits_for_an_unapplied_strip() {820	base := scratch('strip_first')!821	defer {822		os.rmdir_all(base) or {}823	}824	mut backend := remote.new_folder(os.join_path(base, 'remote'))!825	mut a := open_repo(machine('strip_first_a')!)!826	defer {827		a.close()828	}829	mut b := open_repo(machine('strip_first_b')!)!830	defer {831		b.close()832	}833	put(a.root, 'secrets.env', 'api_key=1\n')!834	commit(a, 'add secrets')!835	offer_now(a, mut backend)!836	join(b, mut backend)!837	strip_local(a, 'secrets.env', '')!838	push_strip(a, mut backend, 'secrets.env')!839	put(b.root, 'more.txt', 'B keeps working\n')!840	later := commit(b, 'B saves with the secret still in its tree')!841	if _ := offer_now(b, mut backend) {842		assert false, 'B offered records that still list the stripped path'843	}844	steal_now(b, mut backend)!845	offer_now(b, mut backend)!846	record := decode_save(backend.get(save_key('proj', later.id))!.body)!847	assert 'secrets.env' !in record.files.map(it.path)848}849850// A strip whose object deletions failed, followed by a second strip of the same851// path, must still delete the first strip's objects when it finishes.852fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {853	base := scratch('strip_twice')!854	defer {855		os.rmdir_all(base) or {}856	}857	remote_dir := os.join_path(base, 'remote')858	mut backend := remote.new_folder(remote_dir)!859	mut a := open_repo(machine('strip_twice_a')!)!860	defer {861		a.close()862	}863	put(a.root, 'secrets.env', 'first key\n')!864	commit(a, 'first key')!865	offer_now(a, mut backend)!866	strip_local(a, 'secrets.env', '')!867	mut flaky := FaultyBackend{868		inner:       remote.new_folder(remote_dir)!869		fail_delete: true870	}871	if _ := push_strip(a, mut flaky, 'secrets.env') {872		assert false, 'the injected delete failure should have surfaced'873	}874	put(a.root, 'secrets.env', 'second key\n')!875	commit(a, 'second key')!876	offer_now(a, mut backend)!877	strip_local(a, 'secrets.env', '')!878	push_strip(a, mut backend, 'secrets.env')!879	for key in ['first key\n', 'second key\n'] {880		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found881	}882}883884// Unfinished strips of one path in different scopes finish together.885fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {886	base := scratch('strip_scopes')!887	defer {888		os.rmdir_all(base) or {}889	}890	remote_dir := os.join_path(base, 'remote')891	mut backend := remote.new_folder(remote_dir)!892	mut a := open_repo(machine('strip_scopes_a')!)!893	defer {894		a.close()895	}896	put(a.root, 'secrets.env', 'first key\n')!897	first := commit(a, 'first key')!898	put(a.root, 'secrets.env', 'second key\n')!899	second := commit(a, 'second key')!900	offer_now(a, mut backend)!901	strip_local(a, 'secrets.env', first.id)!902	// Write 1 uploads the event; write 2, the record rewrite, fails.903	mut flaky := FaultyBackend{904		inner:      remote.new_folder(remote_dir)!905		fail_write: 2906	}907	if _ := push_strip(a, mut flaky, 'secrets.env') {908		assert false, 'the injected failure should have surfaced'909	}910	strip_local(a, 'secrets.env', second.id)!911	push_strip(a, mut backend, 'secrets.env')!912	assert !strip_pending(a, 'secrets.env')913	for save in [first, second] {914		record := decode_save(backend.get(save_key('proj', save.id))!.body)!915		assert 'secrets.env' !in record.files.map(it.path)916	}917	for key in ['first key\n', 'second key\n'] {918		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found919	}920}921922// Stripping a path, re-adding it, and stripping it again is two strips; a923// machine that heard about the first must still apply the second.924fn test_a_second_strip_of_the_same_path_reaches_other_machines() {925	base := scratch('strip_again')!926	defer {927		os.rmdir_all(base) or {}928	}929	mut backend := remote.new_folder(os.join_path(base, 'remote'))!930	mut a := open_repo(machine('strip_again_a')!)!931	defer {932		a.close()933	}934	mut b := open_repo(machine('strip_again_b')!)!935	defer {936		b.close()937	}938	put(a.root, 'secrets.env', 'first key\n')!939	commit(a, 'first key')!940	offer_now(a, mut backend)!941	join(b, mut backend)!942	strip_local(a, 'secrets.env', '')!943	push_strip(a, mut backend, 'secrets.env')!944	steal_now(b, mut backend)!945	// Strip leaves the file on disk; deleting it is the user's part.946	os.rm(os.join_path(b.root, 'secrets.env'))!947	put(a.root, 'secrets.env', 'second key\n')!948	second := commit(a, 'second key')!949	offer_now(a, mut backend)!950	steal_now(b, mut backend)!951	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)952	strip_local(a, 'secrets.env', '')!953	push_strip(a, mut backend, 'secrets.env')!954	steal_now(b, mut backend)!955	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)956}957958// A clash on a path the other machine stripped is no clash: the strip removes959// the path from every save, so there is nothing to choose.960fn test_a_stripped_path_is_not_asked_about() {961	base := scratch('clash_strip')!962	defer {963		os.rmdir_all(base) or {}964	}965	mut backend := remote.new_folder(os.join_path(base, 'remote'))!966	mut a := open_repo(machine('clash_strip_a')!)!967	defer {968		a.close()969	}970	mut b := open_repo(machine('clash_strip_b')!)!971	defer {972		b.close()973	}974	put(a.root, 'secrets.env', 'key one\n')!975	commit(a, 'base')!976	offer_now(a, mut backend)!977	join(b, mut backend)!978	put(a.root, 'secrets.env', 'key two\n')!979	commit(a, 'A rotates')!980	offer_now(a, mut backend)!981	strip_local(a, 'secrets.env', '')!982	push_strip(a, mut backend, 'secrets.env')!983	put(b.root, 'secrets.env', 'key three\n')!984	mine := commit(b, 'B rotates')!985	report := steal(b, mut backend, StealOptions{})!986	assert report.clashes.len == 0987	for save in history.all_saves(b)! {988		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)989	}990	assert history.by_id(b, mine.id) != none991}992993// A strip scoped to the shared base save changes what the replay sees; the994// clashes must be found on the same trees, or the replay meets a conflict.995fn test_a_strip_of_the_shared_base_is_seen_before_asking() {996	base := scratch('clash_base_strip')!997	defer {998		os.rmdir_all(base) or {}999	}1000	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1001	mut a := open_repo(machine('base_strip_a')!)!1002	defer {1003		a.close()1004	}1005	mut b := open_repo(machine('base_strip_b')!)!1006	defer {1007		b.close()1008	}1009	put(a.root, 'notes.txt', 'shared\n')!1010	common := commit(a, 'base')!1011	offer_now(a, mut backend)!1012	join(b, mut backend)!1013	put(a.root, 'other.txt', 'A moves on\n')!1014	commit(a, 'A moves on')!1015	offer_now(a, mut backend)!1016	strip_local(a, 'notes.txt', common.id)!1017	push_strip(a, mut backend, 'notes.txt')!1018	put(b.root, 'notes.txt', 'B edits\n')!1019	commit(b, 'B edits notes')!1020	if _ := steal(b, mut backend, StealOptions{}) {1021		assert false, 'with the base stripped, notes.txt differs on both sides'1022	} else {1023		assert err.msg().contains('changed on both machines')1024	}1025	report := steal(b, mut backend, StealOptions{1026		mine: true1027	})!1028	assert report.clashes == ['notes.txt']1029	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1030}10311032// A folder with files but no saves has nothing to combine with; a steal that1033// fast-forwarded into it would overwrite whatever shares a name.1034fn test_steal_into_a_folder_with_unsaved_files_refuses() {1035	base := scratch('steal_unsaved')!1036	defer {1037		os.rmdir_all(base) or {}1038	}1039	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1040	mut a := open_repo(machine('unsaved_a')!)!1041	defer {1042		a.close()1043	}1044	put(a.root, 'notes.txt', 'from A\n')!1045	commit(a, 'A writes')!1046	offer_now(a, mut backend)!1047	b_root := machine('unsaved_b')!1048	mut b := storage.create(b_root)!1049	defer {1050		b.close()1051	}1052	put(b_root, 'notes.txt', 'never saved\n')!1053	// Only a repo that already knows its remote name gets this far.1054	b.set_state('remote_repo', 'proj')!1055	if _ := steal(b, mut backend, StealOptions{}) {1056		assert false, 'steal overwrote a folder with unsaved files'1057	} else {1058		assert err.msg() == no_saves_here1059	}1060	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1061	// A fresh `koh init` folder, holding only the default ignore list, is fine.1062	os.rm(os.join_path(b_root, 'notes.txt'))!1063	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1064	steal(b, mut backend, StealOptions{})!1065	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1066}10671068// The document asks steal to warn about every incoming flagged save, so the1069// report names them.1070fn test_steal_reports_incoming_flagged_saves() {1071	base := scratch('flag_warn')!1072	defer {1073		os.rmdir_all(base) or {}1074	}1075	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1076	mut a := open_repo(machine('flag_warn_a')!)!1077	defer {1078		a.close()1079	}1080	mut b := open_repo(machine('flag_warn_b')!)!1081	defer {1082		b.close()1083	}1084	put(a.root, 'a.txt', 'one\n')!1085	commit(a, 'fine')!1086	offer_now(a, mut backend)!1087	join(b, mut backend)!1088	put(a.root, 'a.txt', 'two\n')!1089	suspect := commit(a, 'suspect')!1090	put(a.root, 'a.txt', 'three\n')!1091	commit(a, 'also fine')!1092	events.flag(a, suspect.id, 'broke the build')!1093	offer_now(a, mut backend)!1094	report := steal_now(b, mut backend)!1095	assert report.flagged.map(it.save_id) == [suspect.id]1096	assert report.flagged[0].reason == 'broke the build'1097}