sync_test.v

24.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_and_offered_head_are_remembered() {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 a.state('remote_head') or { '' } == 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 a.state('remote_head') or { '' } == 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}266267fn test_clashing_files_are_chosen_one_at_a_time() {268	base := scratch('clash')!269	defer {270		os.rmdir_all(base) or {}271	}272	mut backend := remote.new_folder(os.join_path(base, 'remote'))!273	mut a := open_repo(machine('clash_a')!)!274	defer {275		a.close()276	}277	mut b := open_repo(machine('clash_b')!)!278	defer {279		b.close()280	}281	put(a.root, 'same.txt', 'base\n')!282	put(a.root, 'other.txt', 'quiet\n')!283	commit(a, 'base')!284	offer_now(a, mut backend)!285	join(b, mut backend)!286287	// Both machines edit the same file, differently.288	put(a.root, 'same.txt', 'A version\n')!289	commit(a, 'A edits same')!290	offer_now(a, mut backend)!291	put(b.root, 'same.txt', 'B version\n')!292	commit(b, 'B edits same')!293294	// Without a decision, steal refuses and says which files clash.295	if _ := steal(b, mut backend, StealOptions{}) {296		assert false, 'an undecided clash must be refused'297	} else {298		assert err.msg().contains('--mine') || err.msg().contains('--theirs')299	}300	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,301		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0302303	// Choosing the remote's version ends with A's content.304	report := steal_now(b, mut backend)!305	assert report.clashes == ['same.txt']306	assert report.kept_mine == 0307	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'308	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'309}310311fn test_choosing_mine_keeps_the_local_version() {312	base := scratch('mine')!313	defer {314		os.rmdir_all(base) or {}315	}316	mut backend := remote.new_folder(os.join_path(base, 'remote'))!317	mut a := open_repo(machine('mine_a')!)!318	defer {319		a.close()320	}321	mut b := open_repo(machine('mine_b')!)!322	defer {323		b.close()324	}325	put(a.root, 'same.txt', 'base\n')!326	commit(a, 'base')!327	offer_now(a, mut backend)!328	join(b, mut backend)!329	put(a.root, 'same.txt', 'A version\n')!330	commit(a, 'A edits')!331	offer_now(a, mut backend)!332	put(b.root, 'same.txt', 'B version\n')!333	commit(b, 'B edits')!334335	report := steal(b, mut backend, StealOptions{336		mine: true337	})!338	assert report.kept_mine == 1339	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'340	// The final save says what it kept.341	assert history.head(b)!.message.contains('combine: kept mine for 1 file')342	// The pre-combine tree is still reachable in history.343	assert history.head(b)!.restores == ''344	assert history.all_saves(b)!.len >= 4345}346347fn test_flags_travel_with_the_saves() {348	base := scratch('flags')!349	defer {350		os.rmdir_all(base) or {}351	}352	mut backend := remote.new_folder(os.join_path(base, 'remote'))!353	mut a := open_repo(machine('flag_a')!)!354	defer {355		a.close()356	}357	mut b := open_repo(machine('flag_b')!)!358	defer {359		b.close()360	}361	put(a.root, 'a.txt', 'one\n')!362	save := commit(a, 'suspect save')!363	events.flag(a, save.id, 'look at this')!364	offer_now(a, mut backend)!365366	// B has nothing yet, so a fetch brings the save and its flag across.367	fetched := fetch_back_to_known(b, mut backend, 'proj')!368	assert fetched.events_out.len == 1369	assert fetched.events_out[0].kind == events.kind_flag370	b.begin()!371	write_fetched(b, fetched)!372	history.set_head(b, fetched.head)!373	b.commit()!374	state := events.flag_state(b, save.id)!375	assert state.flagged376	assert state.reason == 'look at this'377}378379fn test_a_second_offer_racing_head_is_refused_not_forced() {380	base := scratch('race')!381	defer {382		os.rmdir_all(base) or {}383	}384	mut backend := remote.new_folder(os.join_path(base, 'remote'))!385	mut a := open_repo(machine('race_a')!)!386	defer {387		a.close()388	}389	put(a.root, 'a.txt', 'one\n')!390	commit(a, 'base')!391	offer_now(a, mut backend)!392393	// Someone else replaces head behind our back.394	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),395		backend.get(head_key('proj'))!.tag)!396397	put(a.root, 'a.txt', 'two\n')!398	commit(a, 'second')!399	if _ := offer_now(a, mut backend) {400		assert false, 'offering while the remote head moved must be refused'401	} else {402		assert err.msg().contains('run koh steal')403	}404}405406// join makes a brand new machine share the remote's line.407fn join(store &storage.Store, mut backend remote.Backend) ! {408	fetched := fetch_repo(mut backend, 'proj')!409	store.begin()!410	write_fetched(store, fetched)!411	history.set_head(store, fetched.head)!412	store.commit()!413	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,414		fetched.head)!, [])!415	store.set_state('remote_repo', 'proj')!416}417418struct FaultyBackend {419mut:420	inner      remote.Backend421	writes     int422	reads      int423	fail_write int // fail when the write counter reaches this, 0 never424	fail_read  int425	// fail every delete while set426	fail_delete bool427}428429fn (mut f FaultyBackend) get(key string) !remote.Object {430	f.reads++431	if f.fail_read > 0 && f.reads >= f.fail_read {432		return error('injected read failure')433	}434	return f.inner.get(key)!435}436437fn (mut f FaultyBackend) list(prefix string) ![]string {438	return f.inner.list(prefix)!439}440441fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {442	f.writes++443	if f.fail_write > 0 && f.writes >= f.fail_write {444		return error('injected write failure')445	}446	return f.inner.put_if_absent(key, body)!447}448449fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {450	f.writes++451	if f.fail_write > 0 && f.writes >= f.fail_write {452		return error('injected write failure')453	}454	return f.inner.put_if_match(key, body, tag)!455}456457fn (mut f FaultyBackend) delete(key string) ! {458	if f.fail_delete {459		return error('injected delete failure')460	}461	f.inner.delete(key)!462}463464fn test_a_failed_offer_is_safe_to_re_run() {465	base := scratch('fault_offer')!466	defer {467		os.rmdir_all(base) or {}468	}469	remote_dir := os.join_path(base, 'remote')470	mut a := open_repo(machine('fault_a')!)!471	defer {472		a.close()473	}474	put(a.root, 'a.txt', 'one\n')!475	commit(a, 'first')!476	put(a.root, 'a.txt', 'two\n')!477	commit(a, 'second')!478479	// The first offer dies after the first write.480	mut flaky := FaultyBackend{481		inner:      remote.new_folder(remote_dir)!482		fail_write: 2483	}484	if _ := offer(a, mut flaky) {485		assert false, 'the injected failure should have surfaced'486	}487488	// Re-running with a healthy remote finishes the job: "already exists" is489	// success, so nothing is lost and nothing is duplicated.490	mut healthy := remote.new_folder(remote_dir)!491	report := offer(a, mut healthy)!492	assert report.saves == 2493	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id494	assert healthy.list('proj/saves')!.len == 2495}496497fn test_a_failed_steal_leaves_nothing_behind() {498	base := scratch('fault_steal')!499	defer {500		os.rmdir_all(base) or {}501	}502	remote_dir := os.join_path(base, 'remote')503	mut a := open_repo(machine('fault_s_a')!)!504	defer {505		a.close()506	}507	mut b := open_repo(machine('fault_s_b')!)!508	defer {509		b.close()510	}511	put(a.root, 'a.txt', 'from A\n')!512	commit(a, 'A writes')!513	mut healthy := remote.new_folder(remote_dir)!514	offer(a, mut healthy)!515516	// B's fetch fails half way through reading the remote.517	mut flaky := FaultyBackend{518		inner:     remote.new_folder(remote_dir)!519		fail_read: 2520	}521	if _ := fetch_back_to_known(b, mut flaky, 'proj') {522		assert false, 'the injected read failure should have surfaced'523	}524	// Nothing was written: the store is untouched and still has no saves.525	assert history.head(b) == none526	assert b.object_count()! == 0527528	// A healthy retry works.529	fetched := fetch_back_to_known(b, mut healthy, 'proj')!530	b.begin()!531	write_fetched(b, fetched)!532	history.set_head(b, fetched.head)!533	b.commit()!534	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!535	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'536}537538fn test_an_interrupted_strip_is_idempotent() {539	// The local half of a strip is one transaction, so an interrupted strip is540	// simply a strip that did not happen; re-running it must land the same541	// result once, not twice.542	base := scratch('fault_strip')!543	defer {544		os.rmdir_all(base) or {}545	}546	mut a := open_repo(machine('fault_strip_a')!)!547	defer {548		a.close()549	}550	put(a.root, 'secret.txt', 'shh\n')!551	put(a.root, 'keep.txt', 'fine\n')!552	save := commit(a, 'add both')!553554	for _ in 0 .. 2 {555		strip_local(a, 'secret.txt', '')!556	}557	files := history.files_of(a, save.id)!558	// The repo's own `.kohignore` is tracked content, so it stays; only the559	// stripped path goes.560	assert files.map(it.path) == ['.kohignore', 'keep.txt']561	assert events.strip_events(a)!.len == 1562}563564fn test_a_strip_reaches_the_remote_and_other_machines() {565	base := scratch('strip_sync')!566	defer {567		os.rmdir_all(base) or {}568	}569	mut a := open_repo(machine('strip_a')!)!570	defer {571		a.close()572	}573	mut b := open_repo(machine('strip_b')!)!574	defer {575		b.close()576	}577	put(a.root, 'secrets.env', 'api_key=1\n')!578	put(a.root, 'keep.txt', 'fine\n')!579	save := commit(a, 'add both')!580	mut backend := remote.new_folder(os.join_path(base, 'remote'))!581	offer_now(a, mut backend)!582	join(b, mut backend)!583	assert b.object_count()! > 0584585	// A strips the path locally, the way `koh strip` does, then tells the586	// remote.587	mut secret_hash := ''588	for file in history.files_of(a, save.id)! {589		if file.path == 'secrets.env' {590			secret_hash = file.hash591		}592	}593	assert secret_hash != ''594	strip_local(a, 'secrets.env', '')!595	report := push_strip(a, mut backend, 'secrets.env')!596	assert report.event_sent597	assert report.records == 1598599	// The remote's copy of the save no longer lists the file, and the object is600	// gone from the remote.601	record := decode_save(backend.get(save_key('proj', save.id))!.body)!602	for file in record.files {603		assert file.path != 'secrets.env'604	}605	// The stripped object is gone from the remote; the ones still referenced606	// are not.607	assert !backend.get(object_key('proj', secret_hash))!.found608	assert backend.list('proj/objects')!.len == 2609610	// Another machine hears about it on its next steal and applies it.611	b.begin()!612	fetched := fetch_back_to_known(b, mut backend, 'proj')!613	write_fetched(b, fetched)!614	b.commit()!615	mut paths := []string{}616	for file in history.files_of(b, save.id)! {617		paths << file.path618	}619	assert 'secrets.env' !in paths620	assert 'keep.txt' in paths621622	// The event arrives under its own ID, so it is applied once: B's offer adds623	// no second copy, and a file B saves again after the strip stays saved624	// through B's next steal.625	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)626	put(b.root, 'secrets.env', 'rotated\n')!627	readded := commit(b, 'add the rotated key back')!628	offer_now(b, mut backend)!629	assert backend.list('proj/events')!.len == 1630	b.begin()!631	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!632	b.commit()!633	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)634}635636// The local half commits before the remote hears anything, so a remote failure637// leaves nothing to find locally; the pending note is what lets a re-run638// finish. Objects a remote-only save still needs must survive the strip.639fn test_an_interrupted_remote_strip_finishes_when_re_run() {640	base := scratch('strip_resume')!641	defer {642		os.rmdir_all(base) or {}643	}644	remote_dir := os.join_path(base, 'remote')645	mut backend := remote.new_folder(remote_dir)!646	mut a := open_repo(machine('resume_a')!)!647	defer {648		a.close()649	}650	mut b := open_repo(machine('resume_b')!)!651	defer {652		b.close()653	}654	put(a.root, 'secrets.env', 'api_key=1\n')!655	put(a.root, 'shared.env', 'same bytes on B\n')!656	save := commit(a, 'add secrets')!657	offer_now(a, mut backend)!658	join(b, mut backend)!659	put(b.root, 'copy.txt', 'same bytes on B\n')!660	b_save := commit(b, 'B keeps a copy')!661	offer_now(b, mut backend)!662	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())663	secret_hash := storage.hash_hex('api_key=1\n'.bytes())664665	strip_local(a, 'secrets.env', '')!666	// Write 1 uploads the event and write 2 rewrites the first record; B's save,667	// made from A's tree, lists the file too, and its rewrite (write 3) fails.668	mut flaky := FaultyBackend{669		inner:      remote.new_folder(remote_dir)!670		fail_write: 3671	}672	if _ := push_strip(a, mut flaky, 'secrets.env') {673		assert false, 'the injected failure should have surfaced'674	}675	assert strip_pending(a, 'secrets.env')676	assert strip_local(a, 'secrets.env', '')!.files_removed == 0677	report := push_strip(a, mut backend, 'secrets.env')!678	assert report.records == 1679	assert !strip_pending(a, 'secrets.env')680	assert !backend.get(object_key('proj', secret_hash))!.found681	record := decode_save(backend.get(save_key('proj', save.id))!.body)!682	assert 'secrets.env' !in record.files.map(it.path)683	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!684	assert 'secrets.env' !in b_record.files.map(it.path)685686	// B's save still names the shared bytes, so stripping A's copy keeps them.687	strip_local(a, 'shared.env', '')!688	push_strip(a, mut backend, 'shared.env')!689	assert backend.get(object_key('proj', shared_hash))!.found690}691692// A machine that has not heard of a strip must not offer: its records still693// list the stripped path, and nothing would rewrite them on the remote again.694fn test_offer_waits_for_an_unapplied_strip() {695	base := scratch('strip_first')!696	defer {697		os.rmdir_all(base) or {}698	}699	mut backend := remote.new_folder(os.join_path(base, 'remote'))!700	mut a := open_repo(machine('strip_first_a')!)!701	defer {702		a.close()703	}704	mut b := open_repo(machine('strip_first_b')!)!705	defer {706		b.close()707	}708	put(a.root, 'secrets.env', 'api_key=1\n')!709	commit(a, 'add secrets')!710	offer_now(a, mut backend)!711	join(b, mut backend)!712	strip_local(a, 'secrets.env', '')!713	push_strip(a, mut backend, 'secrets.env')!714	put(b.root, 'more.txt', 'B keeps working\n')!715	later := commit(b, 'B saves with the secret still in its tree')!716	if _ := offer_now(b, mut backend) {717		assert false, 'B offered records that still list the stripped path'718	}719	steal_now(b, mut backend)!720	offer_now(b, mut backend)!721	record := decode_save(backend.get(save_key('proj', later.id))!.body)!722	assert 'secrets.env' !in record.files.map(it.path)723}724725// A strip whose object deletions failed, followed by a second strip of the same726// path, must still delete the first strip's objects when it finishes.727fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {728	base := scratch('strip_twice')!729	defer {730		os.rmdir_all(base) or {}731	}732	remote_dir := os.join_path(base, 'remote')733	mut backend := remote.new_folder(remote_dir)!734	mut a := open_repo(machine('strip_twice_a')!)!735	defer {736		a.close()737	}738	put(a.root, 'secrets.env', 'first key\n')!739	commit(a, 'first key')!740	offer_now(a, mut backend)!741	strip_local(a, 'secrets.env', '')!742	mut flaky := FaultyBackend{743		inner:       remote.new_folder(remote_dir)!744		fail_delete: true745	}746	if _ := push_strip(a, mut flaky, 'secrets.env') {747		assert false, 'the injected delete failure should have surfaced'748	}749	put(a.root, 'secrets.env', 'second key\n')!750	commit(a, 'second key')!751	offer_now(a, mut backend)!752	strip_local(a, 'secrets.env', '')!753	push_strip(a, mut backend, 'secrets.env')!754	for key in ['first key\n', 'second key\n'] {755		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found756	}757}758759// Unfinished strips of one path in different scopes finish together.760fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {761	base := scratch('strip_scopes')!762	defer {763		os.rmdir_all(base) or {}764	}765	remote_dir := os.join_path(base, 'remote')766	mut backend := remote.new_folder(remote_dir)!767	mut a := open_repo(machine('strip_scopes_a')!)!768	defer {769		a.close()770	}771	put(a.root, 'secrets.env', 'first key\n')!772	first := commit(a, 'first key')!773	put(a.root, 'secrets.env', 'second key\n')!774	second := commit(a, 'second key')!775	offer_now(a, mut backend)!776	strip_local(a, 'secrets.env', first.id)!777	// Write 1 uploads the event; write 2, the record rewrite, fails.778	mut flaky := FaultyBackend{779		inner:      remote.new_folder(remote_dir)!780		fail_write: 2781	}782	if _ := push_strip(a, mut flaky, 'secrets.env') {783		assert false, 'the injected failure should have surfaced'784	}785	strip_local(a, 'secrets.env', second.id)!786	push_strip(a, mut backend, 'secrets.env')!787	assert !strip_pending(a, 'secrets.env')788	for save in [first, second] {789		record := decode_save(backend.get(save_key('proj', save.id))!.body)!790		assert 'secrets.env' !in record.files.map(it.path)791	}792	for key in ['first key\n', 'second key\n'] {793		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found794	}795}796797// Stripping a path, re-adding it, and stripping it again is two strips; a798// machine that heard about the first must still apply the second.799fn test_a_second_strip_of_the_same_path_reaches_other_machines() {800	base := scratch('strip_again')!801	defer {802		os.rmdir_all(base) or {}803	}804	mut backend := remote.new_folder(os.join_path(base, 'remote'))!805	mut a := open_repo(machine('strip_again_a')!)!806	defer {807		a.close()808	}809	mut b := open_repo(machine('strip_again_b')!)!810	defer {811		b.close()812	}813	put(a.root, 'secrets.env', 'first key\n')!814	commit(a, 'first key')!815	offer_now(a, mut backend)!816	join(b, mut backend)!817	strip_local(a, 'secrets.env', '')!818	push_strip(a, mut backend, 'secrets.env')!819	steal_now(b, mut backend)!820	// Strip leaves the file on disk; deleting it is the user's part.821	os.rm(os.join_path(b.root, 'secrets.env'))!822	put(a.root, 'secrets.env', 'second key\n')!823	second := commit(a, 'second key')!824	offer_now(a, mut backend)!825	steal_now(b, mut backend)!826	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)827	strip_local(a, 'secrets.env', '')!828	push_strip(a, mut backend, 'secrets.env')!829	steal_now(b, mut backend)!830	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)831}