sync_test.v

34.4 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// A strip removes a path from the saves that existed when it was made. A key762// rotated and saved again afterwards is new work: a machine that clones the763// repo later must get it, not strip it on arrival.764fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {765	base := scratch('strip_clone')!766	defer {767		os.rmdir_all(base) or {}768	}769	mut backend := remote.new_folder(os.join_path(base, 'remote'))!770	mut a := open_repo(machine('strip_clone_a')!)!771	defer {772		a.close()773	}774	put(a.root, 'secrets.env', 'api_key=1\n')!775	old := commit(a, 'leak the key')!776	offer_now(a, mut backend)!777	strip_local(a, 'secrets.env', '')!778	push_strip(a, mut backend, 'secrets.env')!779	// Saves after the strip must be later in time, as they would be for real.780	put(a.root, 'secrets.env', 'api_key=2\n')!781	rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{782		message:    'rotate the key'783		created_at: events.strip_events(a)!.last().created_at + 60784	})!785	offer_now(a, mut backend)!786787	mut c := open_repo(machine('strip_clone_c')!)!788	defer {789		c.close()790	}791	join(c, mut backend)!792	assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)793	assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)794	assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'795}796797// The local half commits before the remote hears anything, so a remote failure798// leaves nothing to find locally; the pending note is what lets a re-run799// finish. Objects a remote-only save still needs must survive the strip.800fn test_an_interrupted_remote_strip_finishes_when_re_run() {801	base := scratch('strip_resume')!802	defer {803		os.rmdir_all(base) or {}804	}805	remote_dir := os.join_path(base, 'remote')806	mut backend := remote.new_folder(remote_dir)!807	mut a := open_repo(machine('resume_a')!)!808	defer {809		a.close()810	}811	mut b := open_repo(machine('resume_b')!)!812	defer {813		b.close()814	}815	put(a.root, 'secrets.env', 'api_key=1\n')!816	put(a.root, 'shared.env', 'same bytes on B\n')!817	save := commit(a, 'add secrets')!818	offer_now(a, mut backend)!819	join(b, mut backend)!820	put(b.root, 'copy.txt', 'same bytes on B\n')!821	b_save := commit(b, 'B keeps a copy')!822	offer_now(b, mut backend)!823	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())824	secret_hash := storage.hash_hex('api_key=1\n'.bytes())825826	strip_local(a, 'secrets.env', '')!827	// Write 1 uploads the event and write 2 rewrites the first record; B's save,828	// made from A's tree, lists the file too, and its rewrite (write 3) fails.829	mut flaky := FaultyBackend{830		inner:      remote.new_folder(remote_dir)!831		fail_write: 3832	}833	if _ := push_strip(a, mut flaky, 'secrets.env') {834		assert false, 'the injected failure should have surfaced'835	}836	assert strip_pending(a, 'secrets.env')837	assert strip_local(a, 'secrets.env', '')!.files_removed == 0838	report := push_strip(a, mut backend, 'secrets.env')!839	assert report.records == 1840	assert !strip_pending(a, 'secrets.env')841	assert !backend.get(object_key('proj', secret_hash))!.found842	record := decode_save(backend.get(save_key('proj', save.id))!.body)!843	assert 'secrets.env' !in record.files.map(it.path)844	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!845	assert 'secrets.env' !in b_record.files.map(it.path)846847	// B's save still names the shared bytes, so stripping A's copy keeps them.848	strip_local(a, 'shared.env', '')!849	push_strip(a, mut backend, 'shared.env')!850	assert backend.get(object_key('proj', shared_hash))!.found851}852853// A machine that has not heard of a strip must not offer: its records still854// list the stripped path, and nothing would rewrite them on the remote again.855fn test_offer_waits_for_an_unapplied_strip() {856	base := scratch('strip_first')!857	defer {858		os.rmdir_all(base) or {}859	}860	mut backend := remote.new_folder(os.join_path(base, 'remote'))!861	mut a := open_repo(machine('strip_first_a')!)!862	defer {863		a.close()864	}865	mut b := open_repo(machine('strip_first_b')!)!866	defer {867		b.close()868	}869	put(a.root, 'secrets.env', 'api_key=1\n')!870	commit(a, 'add secrets')!871	offer_now(a, mut backend)!872	join(b, mut backend)!873	strip_local(a, 'secrets.env', '')!874	push_strip(a, mut backend, 'secrets.env')!875	put(b.root, 'more.txt', 'B keeps working\n')!876	later := commit(b, 'B saves with the secret still in its tree')!877	if _ := offer_now(b, mut backend) {878		assert false, 'B offered records that still list the stripped path'879	}880	steal_now(b, mut backend)!881	offer_now(b, mut backend)!882	record := decode_save(backend.get(save_key('proj', later.id))!.body)!883	assert 'secrets.env' !in record.files.map(it.path)884}885886// A strip whose object deletions failed, followed by a second strip of the same887// path, must still delete the first strip's objects when it finishes.888fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {889	base := scratch('strip_twice')!890	defer {891		os.rmdir_all(base) or {}892	}893	remote_dir := os.join_path(base, 'remote')894	mut backend := remote.new_folder(remote_dir)!895	mut a := open_repo(machine('strip_twice_a')!)!896	defer {897		a.close()898	}899	put(a.root, 'secrets.env', 'first key\n')!900	commit(a, 'first key')!901	offer_now(a, mut backend)!902	strip_local(a, 'secrets.env', '')!903	mut flaky := FaultyBackend{904		inner:       remote.new_folder(remote_dir)!905		fail_delete: true906	}907	if _ := push_strip(a, mut flaky, 'secrets.env') {908		assert false, 'the injected delete failure should have surfaced'909	}910	put(a.root, 'secrets.env', 'second key\n')!911	commit(a, 'second key')!912	offer_now(a, mut backend)!913	strip_local(a, 'secrets.env', '')!914	push_strip(a, mut backend, 'secrets.env')!915	for key in ['first key\n', 'second key\n'] {916		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found917	}918}919920// Unfinished strips of one path in different scopes finish together.921fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {922	base := scratch('strip_scopes')!923	defer {924		os.rmdir_all(base) or {}925	}926	remote_dir := os.join_path(base, 'remote')927	mut backend := remote.new_folder(remote_dir)!928	mut a := open_repo(machine('strip_scopes_a')!)!929	defer {930		a.close()931	}932	put(a.root, 'secrets.env', 'first key\n')!933	first := commit(a, 'first key')!934	put(a.root, 'secrets.env', 'second key\n')!935	second := commit(a, 'second key')!936	offer_now(a, mut backend)!937	strip_local(a, 'secrets.env', first.id)!938	// Write 1 uploads the event; write 2, the record rewrite, fails.939	mut flaky := FaultyBackend{940		inner:      remote.new_folder(remote_dir)!941		fail_write: 2942	}943	if _ := push_strip(a, mut flaky, 'secrets.env') {944		assert false, 'the injected failure should have surfaced'945	}946	strip_local(a, 'secrets.env', second.id)!947	push_strip(a, mut backend, 'secrets.env')!948	assert !strip_pending(a, 'secrets.env')949	for save in [first, second] {950		record := decode_save(backend.get(save_key('proj', save.id))!.body)!951		assert 'secrets.env' !in record.files.map(it.path)952	}953	for key in ['first key\n', 'second key\n'] {954		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found955	}956}957958// Stripping a path, re-adding it, and stripping it again is two strips; a959// machine that heard about the first must still apply the second.960fn test_a_second_strip_of_the_same_path_reaches_other_machines() {961	base := scratch('strip_again')!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('strip_again_a')!)!967	defer {968		a.close()969	}970	mut b := open_repo(machine('strip_again_b')!)!971	defer {972		b.close()973	}974	put(a.root, 'secrets.env', 'first key\n')!975	commit(a, 'first key')!976	offer_now(a, mut backend)!977	join(b, mut backend)!978	strip_local(a, 'secrets.env', '')!979	push_strip(a, mut backend, 'secrets.env')!980	steal_now(b, mut backend)!981	// Strip leaves the file on disk; deleting it is the user's part.982	os.rm(os.join_path(b.root, 'secrets.env'))!983	put(a.root, 'secrets.env', 'second key\n')!984	second := commit(a, 'second key')!985	offer_now(a, mut backend)!986	steal_now(b, mut backend)!987	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)988	strip_local(a, 'secrets.env', '')!989	push_strip(a, mut backend, 'secrets.env')!990	steal_now(b, mut backend)!991	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)992}993994// A clash on a path the other machine stripped is no clash: the strip removes995// the path from every save, so there is nothing to choose.996fn test_a_stripped_path_is_not_asked_about() {997	base := scratch('clash_strip')!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('clash_strip_a')!)!1003	defer {1004		a.close()1005	}1006	mut b := open_repo(machine('clash_strip_b')!)!1007	defer {1008		b.close()1009	}1010	put(a.root, 'secrets.env', 'key one\n')!1011	commit(a, 'base')!1012	offer_now(a, mut backend)!1013	join(b, mut backend)!1014	put(a.root, 'secrets.env', 'key two\n')!1015	commit(a, 'A rotates')!1016	offer_now(a, mut backend)!1017	strip_local(a, 'secrets.env', '')!1018	push_strip(a, mut backend, 'secrets.env')!1019	put(b.root, 'secrets.env', 'key three\n')!1020	mine := commit(b, 'B rotates')!1021	report := steal(b, mut backend, StealOptions{})!1022	assert report.clashes.len == 01023	for save in history.all_saves(b)! {1024		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1025	}1026	assert history.by_id(b, mine.id) != none1027}10281029// A strip scoped to the shared base save changes what the replay sees; the1030// clashes must be found on the same trees, or the replay meets a conflict.1031fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1032	base := scratch('clash_base_strip')!1033	defer {1034		os.rmdir_all(base) or {}1035	}1036	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1037	mut a := open_repo(machine('base_strip_a')!)!1038	defer {1039		a.close()1040	}1041	mut b := open_repo(machine('base_strip_b')!)!1042	defer {1043		b.close()1044	}1045	put(a.root, 'notes.txt', 'shared\n')!1046	common := commit(a, 'base')!1047	offer_now(a, mut backend)!1048	join(b, mut backend)!1049	put(a.root, 'other.txt', 'A moves on\n')!1050	commit(a, 'A moves on')!1051	offer_now(a, mut backend)!1052	strip_local(a, 'notes.txt', common.id)!1053	push_strip(a, mut backend, 'notes.txt')!1054	put(b.root, 'notes.txt', 'B edits\n')!1055	commit(b, 'B edits notes')!1056	if _ := steal(b, mut backend, StealOptions{}) {1057		assert false, 'with the base stripped, notes.txt differs on both sides'1058	} else {1059		assert err.msg().contains('changed on both machines')1060	}1061	report := steal(b, mut backend, StealOptions{1062		mine: true1063	})!1064	assert report.clashes == ['notes.txt']1065	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1066}10671068// A folder with files but no saves has nothing to combine with; a steal that1069// fast-forwarded into it would overwrite whatever shares a name.1070fn test_steal_into_a_folder_with_unsaved_files_refuses() {1071	base := scratch('steal_unsaved')!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('unsaved_a')!)!1077	defer {1078		a.close()1079	}1080	put(a.root, 'notes.txt', 'from A\n')!1081	commit(a, 'A writes')!1082	offer_now(a, mut backend)!1083	b_root := machine('unsaved_b')!1084	mut b := storage.create(b_root)!1085	defer {1086		b.close()1087	}1088	put(b_root, 'notes.txt', 'never saved\n')!1089	// Only a repo that already knows its remote name gets this far.1090	b.set_state('remote_repo', 'proj')!1091	if _ := steal(b, mut backend, StealOptions{}) {1092		assert false, 'steal overwrote a folder with unsaved files'1093	} else {1094		assert err.msg() == no_saves_here1095	}1096	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1097	// A fresh `koh init` folder, holding only the default ignore list, is fine.1098	os.rm(os.join_path(b_root, 'notes.txt'))!1099	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1100	steal(b, mut backend, StealOptions{})!1101	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1102}11031104// The document asks steal to warn about every incoming flagged save, so the1105// report names them.1106fn test_steal_reports_incoming_flagged_saves() {1107	base := scratch('flag_warn')!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('flag_warn_a')!)!1113	defer {1114		a.close()1115	}1116	mut b := open_repo(machine('flag_warn_b')!)!1117	defer {1118		b.close()1119	}1120	put(a.root, 'a.txt', 'one\n')!1121	commit(a, 'fine')!1122	offer_now(a, mut backend)!1123	join(b, mut backend)!1124	put(a.root, 'a.txt', 'two\n')!1125	suspect := commit(a, 'suspect')!1126	put(a.root, 'a.txt', 'three\n')!1127	commit(a, 'also fine')!1128	events.flag(a, suspect.id, 'broke the build')!1129	offer_now(a, mut backend)!1130	report := steal_now(b, mut backend)!1131	assert report.flagged.map(it.save_id) == [suspect.id]1132	assert report.flagged[0].reason == 'broke the build'1133}