sync_test.v

31.7 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// A person deciding a clash may take minutes; the question must come before268// the write lock, or every agent's save on this repo waits and then fails.269fn test_clash_questions_are_asked_before_the_write_lock() {270	base := scratch('ask_unlocked')!271	defer {272		os.rmdir_all(base) or {}273	}274	mut backend := remote.new_folder(os.join_path(base, 'remote'))!275	mut a := open_repo(machine('ask_a')!)!276	defer {277		a.close()278	}279	mut b := open_repo(machine('ask_b')!)!280	defer {281		b.close()282	}283	put(a.root, 'same.txt', 'base\n')!284	commit(a, 'base')!285	offer_now(a, mut backend)!286	join(b, mut backend)!287	put(a.root, 'same.txt', 'A version\n')!288	commit(a, 'A edits same')!289	offer_now(a, mut backend)!290	put(b.root, 'same.txt', 'B version\n')!291	commit(b, 'B edits same')!292	root := b.root293	// An agent saves while the person is still choosing.294	if _ := steal(b, mut backend, StealOptions{295		ask: fn [root] (clash Clash) Side {296			other := storage.open(root) or { panic(err) }297			defer {298				other.close()299			}300			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }301			history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{302				message: 'agent saves during the question'303			}) or { panic('steal held the write lock while asking: ${err}') }304			return .mine305		}306	})307	{308		assert false, 'a steal whose line moved during the question must refuse'309	} else {310		assert err.msg().contains('changed while')311	}312	// The agent's save is still head, and a second steal combines it too.313	assert history.head(b)!.message == 'agent saves during the question'314	report := steal(b, mut backend, StealOptions{315		mine: true316	})!317	assert report.kept_mine == 1318	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'319	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'320}321322fn test_clashing_files_are_chosen_one_at_a_time() {323	base := scratch('clash')!324	defer {325		os.rmdir_all(base) or {}326	}327	mut backend := remote.new_folder(os.join_path(base, 'remote'))!328	mut a := open_repo(machine('clash_a')!)!329	defer {330		a.close()331	}332	mut b := open_repo(machine('clash_b')!)!333	defer {334		b.close()335	}336	put(a.root, 'same.txt', 'base\n')!337	put(a.root, 'other.txt', 'quiet\n')!338	commit(a, 'base')!339	offer_now(a, mut backend)!340	join(b, mut backend)!341342	// Both machines edit the same file, differently.343	put(a.root, 'same.txt', 'A version\n')!344	commit(a, 'A edits same')!345	offer_now(a, mut backend)!346	put(b.root, 'same.txt', 'B version\n')!347	commit(b, 'B edits same')!348349	// Without a decision, steal refuses and says which files clash.350	if _ := steal(b, mut backend, StealOptions{}) {351		assert false, 'an undecided clash must be refused'352	} else {353		assert err.msg().contains('--mine') || err.msg().contains('--theirs')354	}355	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,356		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0357358	// Choosing the remote's version ends with A's content.359	report := steal_now(b, mut backend)!360	assert report.clashes == ['same.txt']361	assert report.kept_mine == 0362	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'363	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'364}365366fn test_choosing_mine_keeps_the_local_version() {367	base := scratch('mine')!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('mine_a')!)!373	defer {374		a.close()375	}376	mut b := open_repo(machine('mine_b')!)!377	defer {378		b.close()379	}380	put(a.root, 'same.txt', 'base\n')!381	commit(a, 'base')!382	offer_now(a, mut backend)!383	join(b, mut backend)!384	put(a.root, 'same.txt', 'A version\n')!385	commit(a, 'A edits')!386	offer_now(a, mut backend)!387	put(b.root, 'same.txt', 'B version\n')!388	commit(b, 'B edits')!389390	report := steal(b, mut backend, StealOptions{391		mine: true392	})!393	assert report.kept_mine == 1394	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'395	// The final save says what it kept.396	assert history.head(b)!.message.contains('combine: kept mine for 1 file')397	// The pre-combine tree is still reachable in history.398	assert history.head(b)!.restores == ''399	assert history.all_saves(b)!.len >= 4400}401402fn test_flags_travel_with_the_saves() {403	base := scratch('flags')!404	defer {405		os.rmdir_all(base) or {}406	}407	mut backend := remote.new_folder(os.join_path(base, 'remote'))!408	mut a := open_repo(machine('flag_a')!)!409	defer {410		a.close()411	}412	mut b := open_repo(machine('flag_b')!)!413	defer {414		b.close()415	}416	put(a.root, 'a.txt', 'one\n')!417	save := commit(a, 'suspect save')!418	events.flag(a, save.id, 'look at this')!419	offer_now(a, mut backend)!420421	// B has nothing yet, so a fetch brings the save and its flag across.422	fetched := fetch_back_to_known(b, mut backend, 'proj')!423	assert fetched.events_out.len == 1424	assert fetched.events_out[0].kind == events.kind_flag425	b.begin()!426	write_fetched(b, fetched)!427	history.set_head(b, fetched.head)!428	b.commit()!429	state := events.flag_state(b, save.id)!430	assert state.flagged431	assert state.reason == 'look at this'432}433434fn test_a_second_offer_racing_head_is_refused_not_forced() {435	base := scratch('race')!436	defer {437		os.rmdir_all(base) or {}438	}439	mut backend := remote.new_folder(os.join_path(base, 'remote'))!440	mut a := open_repo(machine('race_a')!)!441	defer {442		a.close()443	}444	put(a.root, 'a.txt', 'one\n')!445	commit(a, 'base')!446	offer_now(a, mut backend)!447448	// Someone else replaces head behind our back.449	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),450		backend.get(head_key('proj'))!.tag)!451452	put(a.root, 'a.txt', 'two\n')!453	commit(a, 'second')!454	if _ := offer_now(a, mut backend) {455		assert false, 'offering while the remote head moved must be refused'456	} else {457		assert err.msg().contains('run koh steal')458	}459}460461// join makes a brand new machine share the remote's line.462fn join(store &storage.Store, mut backend remote.Backend) ! {463	fetched := fetch_repo(mut backend, 'proj')!464	store.begin()!465	write_fetched(store, fetched)!466	history.set_head(store, fetched.head)!467	store.commit()!468	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,469		fetched.head)!, [])!470	store.set_state('remote_repo', 'proj')!471}472473struct FaultyBackend {474mut:475	inner      remote.Backend476	writes     int477	reads      int478	fail_write int // fail when the write counter reaches this, 0 never479	fail_read  int480	// fail every delete while set481	fail_delete bool482}483484fn (mut f FaultyBackend) get(key string) !remote.Object {485	f.reads++486	if f.fail_read > 0 && f.reads >= f.fail_read {487		return error('injected read failure')488	}489	return f.inner.get(key)!490}491492fn (mut f FaultyBackend) list(prefix string) ![]string {493	return f.inner.list(prefix)!494}495496fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {497	f.writes++498	if f.fail_write > 0 && f.writes >= f.fail_write {499		return error('injected write failure')500	}501	return f.inner.put_if_absent(key, body)!502}503504fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {505	f.writes++506	if f.fail_write > 0 && f.writes >= f.fail_write {507		return error('injected write failure')508	}509	return f.inner.put_if_match(key, body, tag)!510}511512fn (mut f FaultyBackend) delete(key string) ! {513	if f.fail_delete {514		return error('injected delete failure')515	}516	f.inner.delete(key)!517}518519fn test_a_failed_offer_is_safe_to_re_run() {520	base := scratch('fault_offer')!521	defer {522		os.rmdir_all(base) or {}523	}524	remote_dir := os.join_path(base, 'remote')525	mut a := open_repo(machine('fault_a')!)!526	defer {527		a.close()528	}529	put(a.root, 'a.txt', 'one\n')!530	commit(a, 'first')!531	put(a.root, 'a.txt', 'two\n')!532	commit(a, 'second')!533534	// The first offer dies after the first write.535	mut flaky := FaultyBackend{536		inner:      remote.new_folder(remote_dir)!537		fail_write: 2538	}539	if _ := offer(a, mut flaky) {540		assert false, 'the injected failure should have surfaced'541	}542543	// Re-running with a healthy remote finishes the job: "already exists" is544	// success, so nothing is lost and nothing is duplicated.545	mut healthy := remote.new_folder(remote_dir)!546	report := offer(a, mut healthy)!547	assert report.saves == 2548	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id549	assert healthy.list('proj/saves')!.len == 2550}551552fn test_a_failed_steal_leaves_nothing_behind() {553	base := scratch('fault_steal')!554	defer {555		os.rmdir_all(base) or {}556	}557	remote_dir := os.join_path(base, 'remote')558	mut a := open_repo(machine('fault_s_a')!)!559	defer {560		a.close()561	}562	mut b := open_repo(machine('fault_s_b')!)!563	defer {564		b.close()565	}566	put(a.root, 'a.txt', 'from A\n')!567	commit(a, 'A writes')!568	mut healthy := remote.new_folder(remote_dir)!569	offer(a, mut healthy)!570571	// B's fetch fails half way through reading the remote.572	mut flaky := FaultyBackend{573		inner:     remote.new_folder(remote_dir)!574		fail_read: 2575	}576	if _ := fetch_back_to_known(b, mut flaky, 'proj') {577		assert false, 'the injected read failure should have surfaced'578	}579	// Nothing was written: the store is untouched and still has no saves.580	assert history.head(b) == none581	assert b.object_count()! == 0582583	// A healthy retry works.584	fetched := fetch_back_to_known(b, mut healthy, 'proj')!585	b.begin()!586	write_fetched(b, fetched)!587	history.set_head(b, fetched.head)!588	b.commit()!589	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!590	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'591}592593fn test_an_interrupted_strip_is_idempotent() {594	// The local half of a strip is one transaction, so an interrupted strip is595	// simply a strip that did not happen; re-running it must land the same596	// result once, not twice.597	base := scratch('fault_strip')!598	defer {599		os.rmdir_all(base) or {}600	}601	mut a := open_repo(machine('fault_strip_a')!)!602	defer {603		a.close()604	}605	put(a.root, 'secret.txt', 'shh\n')!606	put(a.root, 'keep.txt', 'fine\n')!607	save := commit(a, 'add both')!608609	for _ in 0 .. 2 {610		strip_local(a, 'secret.txt', '')!611	}612	files := history.files_of(a, save.id)!613	// The repo's own `.kohignore` is tracked content, so it stays; only the614	// stripped path goes.615	assert files.map(it.path) == ['.kohignore', 'keep.txt']616	assert events.strip_events(a)!.len == 1617}618619// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into620// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.621// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,622// but a save made on Linux can hold both.623fn test_a_strip_matches_the_path_exactly() {624	mut a := open_repo(machine('strip_exact')!)!625	defer {626		a.close()627	}628	hash := a.put_object('x\n'.bytes())!629	mut files := []snapshot.FileEntry{}630	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {631		files << snapshot.FileEntry{632			path: path633			hash: hash634		}635	}636	save := history.commit_files(a, '', files, history.SaveInput{637		message: 'add all'638	})!639	assert strip_scope(a, 'docs', '')!.files == 1640	strip_local(a, 'docs', '')!641	strip_local(a, 'a_b', '')!642	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']643}644645fn test_a_strip_reaches_the_remote_and_other_machines() {646	base := scratch('strip_sync')!647	defer {648		os.rmdir_all(base) or {}649	}650	mut a := open_repo(machine('strip_a')!)!651	defer {652		a.close()653	}654	mut b := open_repo(machine('strip_b')!)!655	defer {656		b.close()657	}658	put(a.root, 'secrets.env', 'api_key=1\n')!659	put(a.root, 'keep.txt', 'fine\n')!660	save := commit(a, 'add both')!661	mut backend := remote.new_folder(os.join_path(base, 'remote'))!662	offer_now(a, mut backend)!663	join(b, mut backend)!664	assert b.object_count()! > 0665666	// A strips the path locally, the way `koh strip` does, then tells the667	// remote.668	mut secret_hash := ''669	for file in history.files_of(a, save.id)! {670		if file.path == 'secrets.env' {671			secret_hash = file.hash672		}673	}674	assert secret_hash != ''675	strip_local(a, 'secrets.env', '')!676	report := push_strip(a, mut backend, 'secrets.env')!677	assert report.event_sent678	assert report.records == 1679680	// The remote's copy of the save no longer lists the file, and the object is681	// gone from the remote.682	record := decode_save(backend.get(save_key('proj', save.id))!.body)!683	for file in record.files {684		assert file.path != 'secrets.env'685	}686	// The stripped object is gone from the remote; the ones still referenced687	// are not.688	assert !backend.get(object_key('proj', secret_hash))!.found689	assert backend.list('proj/objects')!.len == 2690691	// Another machine hears about it on its next steal and applies it.692	b.begin()!693	fetched := fetch_back_to_known(b, mut backend, 'proj')!694	write_fetched(b, fetched)!695	b.commit()!696	mut paths := []string{}697	for file in history.files_of(b, save.id)! {698		paths << file.path699	}700	assert 'secrets.env' !in paths701	assert 'keep.txt' in paths702703	// The event arrives under its own ID, so it is applied once: B's offer adds704	// no second copy, and a file B saves again after the strip stays saved705	// through B's next steal.706	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)707	put(b.root, 'secrets.env', 'rotated\n')!708	readded := commit(b, 'add the rotated key back')!709	offer_now(b, mut backend)!710	assert backend.list('proj/events')!.len == 1711	b.begin()!712	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!713	b.commit()!714	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)715}716717// The local half commits before the remote hears anything, so a remote failure718// leaves nothing to find locally; the pending note is what lets a re-run719// finish. Objects a remote-only save still needs must survive the strip.720fn test_an_interrupted_remote_strip_finishes_when_re_run() {721	base := scratch('strip_resume')!722	defer {723		os.rmdir_all(base) or {}724	}725	remote_dir := os.join_path(base, 'remote')726	mut backend := remote.new_folder(remote_dir)!727	mut a := open_repo(machine('resume_a')!)!728	defer {729		a.close()730	}731	mut b := open_repo(machine('resume_b')!)!732	defer {733		b.close()734	}735	put(a.root, 'secrets.env', 'api_key=1\n')!736	put(a.root, 'shared.env', 'same bytes on B\n')!737	save := commit(a, 'add secrets')!738	offer_now(a, mut backend)!739	join(b, mut backend)!740	put(b.root, 'copy.txt', 'same bytes on B\n')!741	b_save := commit(b, 'B keeps a copy')!742	offer_now(b, mut backend)!743	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())744	secret_hash := storage.hash_hex('api_key=1\n'.bytes())745746	strip_local(a, 'secrets.env', '')!747	// Write 1 uploads the event and write 2 rewrites the first record; B's save,748	// made from A's tree, lists the file too, and its rewrite (write 3) fails.749	mut flaky := FaultyBackend{750		inner:      remote.new_folder(remote_dir)!751		fail_write: 3752	}753	if _ := push_strip(a, mut flaky, 'secrets.env') {754		assert false, 'the injected failure should have surfaced'755	}756	assert strip_pending(a, 'secrets.env')757	assert strip_local(a, 'secrets.env', '')!.files_removed == 0758	report := push_strip(a, mut backend, 'secrets.env')!759	assert report.records == 1760	assert !strip_pending(a, 'secrets.env')761	assert !backend.get(object_key('proj', secret_hash))!.found762	record := decode_save(backend.get(save_key('proj', save.id))!.body)!763	assert 'secrets.env' !in record.files.map(it.path)764	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!765	assert 'secrets.env' !in b_record.files.map(it.path)766767	// B's save still names the shared bytes, so stripping A's copy keeps them.768	strip_local(a, 'shared.env', '')!769	push_strip(a, mut backend, 'shared.env')!770	assert backend.get(object_key('proj', shared_hash))!.found771}772773// A machine that has not heard of a strip must not offer: its records still774// list the stripped path, and nothing would rewrite them on the remote again.775fn test_offer_waits_for_an_unapplied_strip() {776	base := scratch('strip_first')!777	defer {778		os.rmdir_all(base) or {}779	}780	mut backend := remote.new_folder(os.join_path(base, 'remote'))!781	mut a := open_repo(machine('strip_first_a')!)!782	defer {783		a.close()784	}785	mut b := open_repo(machine('strip_first_b')!)!786	defer {787		b.close()788	}789	put(a.root, 'secrets.env', 'api_key=1\n')!790	commit(a, 'add secrets')!791	offer_now(a, mut backend)!792	join(b, mut backend)!793	strip_local(a, 'secrets.env', '')!794	push_strip(a, mut backend, 'secrets.env')!795	put(b.root, 'more.txt', 'B keeps working\n')!796	later := commit(b, 'B saves with the secret still in its tree')!797	if _ := offer_now(b, mut backend) {798		assert false, 'B offered records that still list the stripped path'799	}800	steal_now(b, mut backend)!801	offer_now(b, mut backend)!802	record := decode_save(backend.get(save_key('proj', later.id))!.body)!803	assert 'secrets.env' !in record.files.map(it.path)804}805806// A strip whose object deletions failed, followed by a second strip of the same807// path, must still delete the first strip's objects when it finishes.808fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {809	base := scratch('strip_twice')!810	defer {811		os.rmdir_all(base) or {}812	}813	remote_dir := os.join_path(base, 'remote')814	mut backend := remote.new_folder(remote_dir)!815	mut a := open_repo(machine('strip_twice_a')!)!816	defer {817		a.close()818	}819	put(a.root, 'secrets.env', 'first key\n')!820	commit(a, 'first key')!821	offer_now(a, mut backend)!822	strip_local(a, 'secrets.env', '')!823	mut flaky := FaultyBackend{824		inner:       remote.new_folder(remote_dir)!825		fail_delete: true826	}827	if _ := push_strip(a, mut flaky, 'secrets.env') {828		assert false, 'the injected delete failure should have surfaced'829	}830	put(a.root, 'secrets.env', 'second key\n')!831	commit(a, 'second key')!832	offer_now(a, mut backend)!833	strip_local(a, 'secrets.env', '')!834	push_strip(a, mut backend, 'secrets.env')!835	for key in ['first key\n', 'second key\n'] {836		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found837	}838}839840// Unfinished strips of one path in different scopes finish together.841fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {842	base := scratch('strip_scopes')!843	defer {844		os.rmdir_all(base) or {}845	}846	remote_dir := os.join_path(base, 'remote')847	mut backend := remote.new_folder(remote_dir)!848	mut a := open_repo(machine('strip_scopes_a')!)!849	defer {850		a.close()851	}852	put(a.root, 'secrets.env', 'first key\n')!853	first := commit(a, 'first key')!854	put(a.root, 'secrets.env', 'second key\n')!855	second := commit(a, 'second key')!856	offer_now(a, mut backend)!857	strip_local(a, 'secrets.env', first.id)!858	// Write 1 uploads the event; write 2, the record rewrite, fails.859	mut flaky := FaultyBackend{860		inner:      remote.new_folder(remote_dir)!861		fail_write: 2862	}863	if _ := push_strip(a, mut flaky, 'secrets.env') {864		assert false, 'the injected failure should have surfaced'865	}866	strip_local(a, 'secrets.env', second.id)!867	push_strip(a, mut backend, 'secrets.env')!868	assert !strip_pending(a, 'secrets.env')869	for save in [first, second] {870		record := decode_save(backend.get(save_key('proj', save.id))!.body)!871		assert 'secrets.env' !in record.files.map(it.path)872	}873	for key in ['first key\n', 'second key\n'] {874		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found875	}876}877878// Stripping a path, re-adding it, and stripping it again is two strips; a879// machine that heard about the first must still apply the second.880fn test_a_second_strip_of_the_same_path_reaches_other_machines() {881	base := scratch('strip_again')!882	defer {883		os.rmdir_all(base) or {}884	}885	mut backend := remote.new_folder(os.join_path(base, 'remote'))!886	mut a := open_repo(machine('strip_again_a')!)!887	defer {888		a.close()889	}890	mut b := open_repo(machine('strip_again_b')!)!891	defer {892		b.close()893	}894	put(a.root, 'secrets.env', 'first key\n')!895	commit(a, 'first key')!896	offer_now(a, mut backend)!897	join(b, mut backend)!898	strip_local(a, 'secrets.env', '')!899	push_strip(a, mut backend, 'secrets.env')!900	steal_now(b, mut backend)!901	// Strip leaves the file on disk; deleting it is the user's part.902	os.rm(os.join_path(b.root, 'secrets.env'))!903	put(a.root, 'secrets.env', 'second key\n')!904	second := commit(a, 'second key')!905	offer_now(a, mut backend)!906	steal_now(b, mut backend)!907	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)908	strip_local(a, 'secrets.env', '')!909	push_strip(a, mut backend, 'secrets.env')!910	steal_now(b, mut backend)!911	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)912}913914// A clash on a path the other machine stripped is no clash: the strip removes915// the path from every save, so there is nothing to choose.916fn test_a_stripped_path_is_not_asked_about() {917	base := scratch('clash_strip')!918	defer {919		os.rmdir_all(base) or {}920	}921	mut backend := remote.new_folder(os.join_path(base, 'remote'))!922	mut a := open_repo(machine('clash_strip_a')!)!923	defer {924		a.close()925	}926	mut b := open_repo(machine('clash_strip_b')!)!927	defer {928		b.close()929	}930	put(a.root, 'secrets.env', 'key one\n')!931	commit(a, 'base')!932	offer_now(a, mut backend)!933	join(b, mut backend)!934	put(a.root, 'secrets.env', 'key two\n')!935	commit(a, 'A rotates')!936	offer_now(a, mut backend)!937	strip_local(a, 'secrets.env', '')!938	push_strip(a, mut backend, 'secrets.env')!939	put(b.root, 'secrets.env', 'key three\n')!940	mine := commit(b, 'B rotates')!941	report := steal(b, mut backend, StealOptions{})!942	assert report.clashes.len == 0943	for save in history.all_saves(b)! {944		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)945	}946	assert history.by_id(b, mine.id) != none947}948949// A strip scoped to the shared base save changes what the replay sees; the950// clashes must be found on the same trees, or the replay meets a conflict.951fn test_a_strip_of_the_shared_base_is_seen_before_asking() {952	base := scratch('clash_base_strip')!953	defer {954		os.rmdir_all(base) or {}955	}956	mut backend := remote.new_folder(os.join_path(base, 'remote'))!957	mut a := open_repo(machine('base_strip_a')!)!958	defer {959		a.close()960	}961	mut b := open_repo(machine('base_strip_b')!)!962	defer {963		b.close()964	}965	put(a.root, 'notes.txt', 'shared\n')!966	common := commit(a, 'base')!967	offer_now(a, mut backend)!968	join(b, mut backend)!969	put(a.root, 'other.txt', 'A moves on\n')!970	commit(a, 'A moves on')!971	offer_now(a, mut backend)!972	strip_local(a, 'notes.txt', common.id)!973	push_strip(a, mut backend, 'notes.txt')!974	put(b.root, 'notes.txt', 'B edits\n')!975	commit(b, 'B edits notes')!976	if _ := steal(b, mut backend, StealOptions{}) {977		assert false, 'with the base stripped, notes.txt differs on both sides'978	} else {979		assert err.msg().contains('changed on both machines')980	}981	report := steal(b, mut backend, StealOptions{982		mine: true983	})!984	assert report.clashes == ['notes.txt']985	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'986}987988// A folder with files but no saves has nothing to combine with; a steal that989// fast-forwarded into it would overwrite whatever shares a name.990fn test_steal_into_a_folder_with_unsaved_files_refuses() {991	base := scratch('steal_unsaved')!992	defer {993		os.rmdir_all(base) or {}994	}995	mut backend := remote.new_folder(os.join_path(base, 'remote'))!996	mut a := open_repo(machine('unsaved_a')!)!997	defer {998		a.close()999	}1000	put(a.root, 'notes.txt', 'from A\n')!1001	commit(a, 'A writes')!1002	offer_now(a, mut backend)!1003	b_root := machine('unsaved_b')!1004	mut b := storage.create(b_root)!1005	defer {1006		b.close()1007	}1008	put(b_root, 'notes.txt', 'never saved\n')!1009	// Only a repo that already knows its remote name gets this far.1010	b.set_state('remote_repo', 'proj')!1011	if _ := steal(b, mut backend, StealOptions{}) {1012		assert false, 'steal overwrote a folder with unsaved files'1013	} else {1014		assert err.msg() == no_saves_here1015	}1016	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1017	// A fresh `koh init` folder, holding only the default ignore list, is fine.1018	os.rm(os.join_path(b_root, 'notes.txt'))!1019	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1020	steal(b, mut backend, StealOptions{})!1021	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1022}10231024// The document asks steal to warn about every incoming flagged save, so the1025// report names them.1026fn test_steal_reports_incoming_flagged_saves() {1027	base := scratch('flag_warn')!1028	defer {1029		os.rmdir_all(base) or {}1030	}1031	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1032	mut a := open_repo(machine('flag_warn_a')!)!1033	defer {1034		a.close()1035	}1036	mut b := open_repo(machine('flag_warn_b')!)!1037	defer {1038		b.close()1039	}1040	put(a.root, 'a.txt', 'one\n')!1041	commit(a, 'fine')!1042	offer_now(a, mut backend)!1043	join(b, mut backend)!1044	put(a.root, 'a.txt', 'two\n')!1045	suspect := commit(a, 'suspect')!1046	put(a.root, 'a.txt', 'three\n')!1047	commit(a, 'also fine')!1048	events.flag(a, suspect.id, 'broke the build')!1049	offer_now(a, mut backend)!1050	report := steal_now(b, mut backend)!1051	assert report.flagged.map(it.save_id) == [suspect.id]1052	assert report.flagged[0].reason == 'broke the build'1053}