//! Two-machine tests: two repos and one folder remote standing in for the Mac //! and the PC. This is where offer, steal and combine are actually proven — //! the folder backend exists so these run offline. module sync import os import src.events import src.history import src.remote import src.snapshot import src.storage // Machines keep their repo in a folder of the same NAME, because the remote key // is the folder's name; that is what makes them the same repo to the remote. fn machine(name string) !string { dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj') os.rmdir_all(os.dir(dir)) or {} os.mkdir_all(dir, os.MkdirParams{})! return dir } fn scratch(name string) !string { dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}') os.rmdir_all(dir) or {} os.mkdir_all(dir, os.MkdirParams{})! return dir } fn open_repo(dir string) !storage.Store { mut store := storage.create(dir)! os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')! return store } fn put(root string, rel string, text string) ! { full := os.join_path(root, rel) os.mkdir_all(os.dir(full), os.MkdirParams{})! os.write_file(full, text)! } fn commit(store &storage.Store, message string) !history.Save { return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{ message: message })! } fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport { return offer(store, mut backend)! } fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport { return steal(store, mut backend, StealOptions{ theirs: true })! } fn test_first_offer_publishes_the_whole_line() { base := scratch('first')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('first_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'first')! put(a.root, 'a.txt', 'two\n')! commit(a, 'second')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! report := offer_now(a, mut backend)! assert report.repo == 'proj' assert report.saves == 2 assert report.objects >= 2 assert backend.get(head_key('proj'))!.found assert backend.list('proj/saves')!.len == 2 // A second offer with nothing new uploads nothing and still succeeds. again := offer_now(a, mut backend)! assert again.saves == 0 } fn test_the_repo_name_is_remembered_and_head_offered() { base := scratch('remember')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('remember_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! offer_now(a, mut backend)! assert a.state('remote_repo') or { '' } == 'proj' assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id // A second offer, with the remote already holding our head, must not // refuse: the name is remembered, so the remote is ours. put(a.root, 'a.txt', 'two\n')! commit(a, 'second')! offer_now(a, mut backend)! assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id } fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() { base := scratch('forward')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('fwd_a')!)! defer { a.close() } put(a.root, 'a.txt', 'from A\n')! commit(a, 'A writes')! offer_now(a, mut backend)! // B starts empty and pulls the whole repo. mut b := open_repo(machine('fwd_b')!)! defer { b.close() } fetched := fetch_repo(mut backend, 'proj')! b.begin()! write_fetched(b, fetched)! history.set_head(b, fetched.head)! b.commit()! snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])! assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n' assert history.head(b)!.id == fetched.head } // A machine that is only ahead has nothing to combine: stealing must leave its // saves, and their IDs, exactly where they are. fn test_steal_while_only_ahead_leaves_the_line_alone() { base := scratch('ahead')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('ahead_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'offered')! offer_now(a, mut backend)! put(a.root, 'a.txt', 'two\n')! ahead := commit(a, 'not offered yet')! report := steal_now(a, mut backend)! assert report.replayed == 0 assert history.head(a)!.id == ahead.id assert history.all_saves(a)!.len == 2 offer_now(a, mut backend)! assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id } // A link is invisible to the walk, so a tree holding one is clean; the files // a steal brings in must still never land in the folder it points at. fn test_steal_never_writes_through_a_symlinked_folder() { base := scratch('steal_link')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('steal_link_a')!)! defer { a.close() } mut b := open_repo(machine('steal_link_b')!)! defer { b.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'first')! offer_now(a, mut backend)! join(b, mut backend)! outside := os.join_path(base, 'outside') put(outside, 'a.md', 'precious\n')! os.symlink(outside, os.join_path(b.root, 'docs'))! put(a.root, 'docs/a.md', 'from A\n')! commit(a, 'A adds docs')! offer_now(a, mut backend)! head_before := history.head(b)!.id if _ := steal_now(b, mut backend) { assert false, 'a steal writing through a symlinked folder must refuse' } else { assert err.msg().contains('docs') } assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n' assert history.head(b)!.id == head_before } // A steal onto a half-restored tree would replay it as somebody's work. fn test_steal_refuses_after_a_restore_cut_short() { base := scratch('steal_cut')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('steal_cut_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'first')! offer_now(a, mut backend)! a.set_state('restoring', 'yes')! if _ := steal_now(a, mut backend) { assert false, 'steal must refuse while a restore is unfinished' } else { assert err.msg().contains('koh revert') } } // A replayed save keeps its original time but sits on a newer parent, so a // clone cannot order saves by time. fn test_a_clone_imports_parents_before_children() { base := scratch('clone_order')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('order_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! parent := commit(a, 'parent')! child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{ message: 'replayed, older than its parent' created_at: parent.created_at - 3600 })! offer_now(a, mut backend)! mut b := open_repo(machine('order_b')!)! defer { b.close() } join(b, mut backend)! assert history.head(b)!.id == child.id } // A chmod is unsaved work too: a steal would restore the old mode over it. fn test_steal_refuses_an_unsaved_executable_bit_change() { base := scratch('chmod')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('chmod_a')!)! defer { a.close() } put(a.root, 'run.sh', 'echo hi\n')! commit(a, 'script')! offer_now(a, mut backend)! os.chmod(os.join_path(a.root, 'run.sh'), 0o755)! if _ := steal_now(a, mut backend) { assert false, 'a chmod-only change should make the tree dirty' } else { assert err.msg() == dirty_tree } } fn test_divergence_without_clashes_keeps_both_machines_work() { base := scratch('diverge')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('div_a')!)! defer { a.close() } mut b := open_repo(machine('div_b')!)! defer { b.close() } put(a.root, 'shared.txt', 'base\n')! commit(a, 'base')! offer_now(a, mut backend)! // B joins, then both machines change a different file. join(b, mut backend)! put(a.root, 'fromA.txt', 'A\n')! commit(a, 'A adds a file')! put(b.root, 'fromB.txt', 'B\n')! commit(b, 'B adds a file')! offer_now(a, mut backend)! // B's offer is refused: the remote has moved on. if _ := offer_now(b, mut backend) { assert false, 'offering on top of unknown remote saves should be refused' } else { assert err.msg().contains('run koh steal') } report := steal_now(b, mut backend)! assert !report.forward assert report.replayed == 1 assert report.clashes.len == 0 // Both files are on B's line now, and on disk. assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n' assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n' assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n' // B's own save was replayed, not erased. mut saw_replay := false for save in history.all_saves(b)! { if save.replayed_from != '' { saw_replay = true } } assert saw_replay // And the remote now has B's work too. offer_now(b, mut backend)! line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space() assert line_head == history.head(b)!.id } // An agent that edits a file and then undoes the edit leaves no net change, so // the file is not a clash when the other machine changed it. Replaying the // edit itself must not stop the steal: the remote's version simply stands. // Likewise a file both machines ended up changing the same way. fn test_a_local_change_undone_does_not_block_a_combine() { base := scratch('undone')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('undone_a')!)! defer { a.close() } mut b := open_repo(machine('undone_b')!)! defer { b.close() } put(a.root, 'f.txt', 'base\n')! put(a.root, 'g.txt', 'base\n')! commit(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'f.txt', 'from A\n')! put(a.root, 'g.txt', 'same\n')! commit(a, 'A edits both')! offer_now(a, mut backend)! put(b.root, 'f.txt', 'tried on B\n')! put(b.root, 'g.txt', 'first try\n')! commit(b, 'B tries')! put(b.root, 'f.txt', 'base\n')! put(b.root, 'g.txt', 'same\n')! put(b.root, 'h.txt', 'B\n')! commit(b, 'B undoes f, lands on the same g, adds h')! report := steal_now(b, mut backend)! assert report.clashes.len == 0 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n' assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n' assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n' } // A person deciding a clash may take minutes; the question must come before // the write lock, or every agent's save on this repo waits and then fails. fn test_clash_questions_are_asked_before_the_write_lock() { base := scratch('ask_unlocked')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('ask_a')!)! defer { a.close() } mut b := open_repo(machine('ask_b')!)! defer { b.close() } put(a.root, 'same.txt', 'base\n')! commit(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'same.txt', 'A version\n')! commit(a, 'A edits same')! offer_now(a, mut backend)! put(b.root, 'same.txt', 'B version\n')! commit(b, 'B edits same')! root := b.root // An agent saves while the person is still choosing. if _ := steal(b, mut backend, StealOptions{ ask: fn [root] (clash Clash) Side { other := storage.open(root) or { panic(err) } defer { other.close() } os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) } history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{ message: 'agent saves during the question' }) or { panic('steal held the write lock while asking: ${err}') } return .mine } }) { assert false, 'a steal whose line moved during the question must refuse' } else { assert err.msg().contains('changed while') } // The agent's save is still head, and a second steal combines it too. assert history.head(b)!.message == 'agent saves during the question' report := steal(b, mut backend, StealOptions{ mine: true })! assert report.kept_mine == 1 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n' assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n' } fn test_clashing_files_are_chosen_one_at_a_time() { base := scratch('clash')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('clash_a')!)! defer { a.close() } mut b := open_repo(machine('clash_b')!)! defer { b.close() } put(a.root, 'same.txt', 'base\n')! put(a.root, 'other.txt', 'quiet\n')! commit(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! // Both machines edit the same file, differently. put(a.root, 'same.txt', 'A version\n')! commit(a, 'A edits same')! offer_now(a, mut backend)! put(b.root, 'same.txt', 'B version\n')! commit(b, 'B edits same')! // Without a decision, steal refuses and says which files clash. if _ := steal(b, mut backend, StealOptions{}) { assert false, 'an undecided clash must be refused' } else { assert err.msg().contains('--mine') || err.msg().contains('--theirs') } assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0 // Choosing the remote's version ends with A's content. report := steal_now(b, mut backend)! assert report.clashes == ['same.txt'] assert report.kept_mine == 0 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n' assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n' } fn test_choosing_mine_keeps_the_local_version() { base := scratch('mine')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('mine_a')!)! defer { a.close() } mut b := open_repo(machine('mine_b')!)! defer { b.close() } put(a.root, 'same.txt', 'base\n')! commit(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'same.txt', 'A version\n')! commit(a, 'A edits')! offer_now(a, mut backend)! put(b.root, 'same.txt', 'B version\n')! commit(b, 'B edits')! report := steal(b, mut backend, StealOptions{ mine: true })! assert report.kept_mine == 1 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n' // The final save says what it kept. assert history.head(b)!.message.contains('combine: kept mine for 1 file') // The pre-combine tree is still reachable in history. assert history.head(b)!.restores == '' assert history.all_saves(b)!.len >= 4 } fn test_flags_travel_with_the_saves() { base := scratch('flags')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('flag_a')!)! defer { a.close() } mut b := open_repo(machine('flag_b')!)! defer { b.close() } put(a.root, 'a.txt', 'one\n')! save := commit(a, 'suspect save')! events.flag(a, save.id, 'look at this')! offer_now(a, mut backend)! // B has nothing yet, so a fetch brings the save and its flag across. fetched := fetch_back_to_known(b, mut backend, 'proj')! assert fetched.events_out.len == 1 assert fetched.events_out[0].kind == events.kind_flag b.begin()! write_fetched(b, fetched)! history.set_head(b, fetched.head)! b.commit()! state := events.flag_state(b, save.id)! assert state.flagged assert state.reason == 'look at this' } fn test_a_second_offer_racing_head_is_refused_not_forced() { base := scratch('race')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('race_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'base')! offer_now(a, mut backend)! // Someone else replaces head behind our back. assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(), backend.get(head_key('proj'))!.tag)! put(a.root, 'a.txt', 'two\n')! commit(a, 'second')! if _ := offer_now(a, mut backend) { assert false, 'offering while the remote head moved must be refused' } else { assert err.msg().contains('run koh steal') } } // join makes a brand new machine share the remote's line. fn join(store &storage.Store, mut backend remote.Backend) ! { fetched := fetch_repo(mut backend, 'proj')! store.begin()! write_fetched(store, fetched)! history.set_head(store, fetched.head)! store.commit()! snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store, fetched.head)!, [])! store.set_state('remote_repo', 'proj')! } struct FaultyBackend { mut: inner remote.Backend writes int reads int fail_write int // fail when the write counter reaches this, 0 never fail_read int // fail every delete while set fail_delete bool } fn (mut f FaultyBackend) get(key string) !remote.Object { f.reads++ if f.fail_read > 0 && f.reads >= f.fail_read { return error('injected read failure') } return f.inner.get(key)! } fn (mut f FaultyBackend) list(prefix string) ![]string { return f.inner.list(prefix)! } fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool { f.writes++ if f.fail_write > 0 && f.writes >= f.fail_write { return error('injected write failure') } return f.inner.put_if_absent(key, body)! } fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool { f.writes++ if f.fail_write > 0 && f.writes >= f.fail_write { return error('injected write failure') } return f.inner.put_if_match(key, body, tag)! } fn (mut f FaultyBackend) delete(key string) ! { if f.fail_delete { return error('injected delete failure') } f.inner.delete(key)! } fn test_a_failed_offer_is_safe_to_re_run() { base := scratch('fault_offer')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut a := open_repo(machine('fault_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'first')! put(a.root, 'a.txt', 'two\n')! commit(a, 'second')! // The first offer dies after the first write. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_write: 2 } if _ := offer(a, mut flaky) { assert false, 'the injected failure should have surfaced' } // Re-running with a healthy remote finishes the job: "already exists" is // success, so nothing is lost and nothing is duplicated. mut healthy := remote.new_folder(remote_dir)! report := offer(a, mut healthy)! assert report.saves == 2 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id assert healthy.list('proj/saves')!.len == 2 } fn test_a_failed_steal_leaves_nothing_behind() { base := scratch('fault_steal')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut a := open_repo(machine('fault_s_a')!)! defer { a.close() } mut b := open_repo(machine('fault_s_b')!)! defer { b.close() } put(a.root, 'a.txt', 'from A\n')! commit(a, 'A writes')! mut healthy := remote.new_folder(remote_dir)! offer(a, mut healthy)! // B's fetch fails half way through reading the remote. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_read: 2 } if _ := fetch_back_to_known(b, mut flaky, 'proj') { assert false, 'the injected read failure should have surfaced' } // Nothing was written: the store is untouched and still has no saves. assert history.head(b) == none assert b.object_count()! == 0 // A healthy retry works. fetched := fetch_back_to_known(b, mut healthy, 'proj')! b.begin()! write_fetched(b, fetched)! history.set_head(b, fetched.head)! b.commit()! snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])! assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n' } fn test_an_interrupted_strip_is_idempotent() { // The local half of a strip is one transaction, so an interrupted strip is // simply a strip that did not happen; re-running it must land the same // result once, not twice. base := scratch('fault_strip')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('fault_strip_a')!)! defer { a.close() } put(a.root, 'secret.txt', 'shh\n')! put(a.root, 'keep.txt', 'fine\n')! save := commit(a, 'add both')! for _ in 0 .. 2 { strip_local(a, 'secret.txt', '')! } files := history.files_of(a, save.id)! // The repo's own `.kohignore` is tracked content, so it stays; only the // stripped path goes. assert files.map(it.path) == ['.kohignore', 'keep.txt'] assert events.strip_events(a)!.len == 1 } // SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into // `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly. // The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk, // but a save made on Linux can hold both. fn test_a_strip_matches_the_path_exactly() { mut a := open_repo(machine('strip_exact')!)! defer { a.close() } hash := a.put_object('x\n'.bytes())! mut files := []snapshot.FileEntry{} for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] { files << snapshot.FileEntry{ path: path hash: hash } } save := history.commit_files(a, '', files, history.SaveInput{ message: 'add all' })! assert strip_scope(a, 'docs', '')!.files == 1 strip_local(a, 'docs', '')! strip_local(a, 'a_b', '')! assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt'] } fn test_a_strip_reaches_the_remote_and_other_machines() { base := scratch('strip_sync')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('strip_a')!)! defer { a.close() } mut b := open_repo(machine('strip_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! put(a.root, 'keep.txt', 'fine\n')! save := commit(a, 'add both')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! offer_now(a, mut backend)! join(b, mut backend)! assert b.object_count()! > 0 // A strips the path locally, the way `koh strip` does, then tells the // remote. mut secret_hash := '' for file in history.files_of(a, save.id)! { if file.path == 'secrets.env' { secret_hash = file.hash } } assert secret_hash != '' strip_local(a, 'secrets.env', '')! report := push_strip(a, mut backend, 'secrets.env')! assert report.event_sent assert report.records == 1 // The remote's copy of the save no longer lists the file, and the object is // gone from the remote. record := decode_save(backend.get(save_key('proj', save.id))!.body)! for file in record.files { assert file.path != 'secrets.env' } // The stripped object is gone from the remote; the ones still referenced // are not. assert !backend.get(object_key('proj', secret_hash))!.found assert backend.list('proj/objects')!.len == 2 // Another machine hears about it on its next steal and applies it. b.begin()! fetched := fetch_back_to_known(b, mut backend, 'proj')! write_fetched(b, fetched)! b.commit()! mut paths := []string{} for file in history.files_of(b, save.id)! { paths << file.path } assert 'secrets.env' !in paths assert 'keep.txt' in paths // The event arrives under its own ID, so it is applied once: B's offer adds // no second copy, and a file B saves again after the strip stays saved // through B's next steal. assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id) put(b.root, 'secrets.env', 'rotated\n')! readded := commit(b, 'add the rotated key back')! offer_now(b, mut backend)! assert backend.list('proj/events')!.len == 1 b.begin()! write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)! b.commit()! assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path) } // A strip removes a path from the saves that existed when it was made. A key // rotated and saved again afterwards is new work: a machine that clones the // repo later must get it, not strip it on arrival. fn test_a_clone_keeps_a_file_saved_again_after_a_strip() { base := scratch('strip_clone')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_clone_a')!)! defer { a.close() } put(a.root, 'secrets.env', 'api_key=1\n')! old := commit(a, 'leak the key')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', '')! push_strip(a, mut backend, 'secrets.env')! // Saves after the strip must be later in time, as they would be for real. put(a.root, 'secrets.env', 'api_key=2\n')! rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{ message: 'rotate the key' created_at: events.strip_events(a)!.last().created_at + 60 })! offer_now(a, mut backend)! mut c := open_repo(machine('strip_clone_c')!)! defer { c.close() } join(c, mut backend)! assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path) assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path) assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n' } // The local half commits before the remote hears anything, so a remote failure // leaves nothing to find locally; the pending note is what lets a re-run // finish. Objects a remote-only save still needs must survive the strip. fn test_an_interrupted_remote_strip_finishes_when_re_run() { base := scratch('strip_resume')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! mut a := open_repo(machine('resume_a')!)! defer { a.close() } mut b := open_repo(machine('resume_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! put(a.root, 'shared.env', 'same bytes on B\n')! save := commit(a, 'add secrets')! offer_now(a, mut backend)! join(b, mut backend)! put(b.root, 'copy.txt', 'same bytes on B\n')! b_save := commit(b, 'B keeps a copy')! offer_now(b, mut backend)! shared_hash := storage.hash_hex('same bytes on B\n'.bytes()) secret_hash := storage.hash_hex('api_key=1\n'.bytes()) strip_local(a, 'secrets.env', '')! // Write 1 uploads the event and write 2 rewrites the first record; B's save, // made from A's tree, lists the file too, and its rewrite (write 3) fails. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_write: 3 } if _ := push_strip(a, mut flaky, 'secrets.env') { assert false, 'the injected failure should have surfaced' } assert strip_pending(a, 'secrets.env') assert strip_local(a, 'secrets.env', '')!.files_removed == 0 report := push_strip(a, mut backend, 'secrets.env')! assert report.records == 1 assert !strip_pending(a, 'secrets.env') assert !backend.get(object_key('proj', secret_hash))!.found record := decode_save(backend.get(save_key('proj', save.id))!.body)! assert 'secrets.env' !in record.files.map(it.path) b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)! assert 'secrets.env' !in b_record.files.map(it.path) // B's save still names the shared bytes, so stripping A's copy keeps them. strip_local(a, 'shared.env', '')! push_strip(a, mut backend, 'shared.env')! assert backend.get(object_key('proj', shared_hash))!.found } // A machine that has not heard of a strip must not offer: its records still // list the stripped path, and nothing would rewrite them on the remote again. fn test_offer_waits_for_an_unapplied_strip() { base := scratch('strip_first')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_first_a')!)! defer { a.close() } mut b := open_repo(machine('strip_first_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! commit(a, 'add secrets')! offer_now(a, mut backend)! join(b, mut backend)! strip_local(a, 'secrets.env', '')! push_strip(a, mut backend, 'secrets.env')! put(b.root, 'more.txt', 'B keeps working\n')! later := commit(b, 'B saves with the secret still in its tree')! if _ := offer_now(b, mut backend) { assert false, 'B offered records that still list the stripped path' } steal_now(b, mut backend)! offer_now(b, mut backend)! record := decode_save(backend.get(save_key('proj', later.id))!.body)! assert 'secrets.env' !in record.files.map(it.path) } // A strip whose object deletions failed, followed by a second strip of the same // path, must still delete the first strip's objects when it finishes. fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() { base := scratch('strip_twice')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! mut a := open_repo(machine('strip_twice_a')!)! defer { a.close() } put(a.root, 'secrets.env', 'first key\n')! commit(a, 'first key')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', '')! mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_delete: true } if _ := push_strip(a, mut flaky, 'secrets.env') { assert false, 'the injected delete failure should have surfaced' } put(a.root, 'secrets.env', 'second key\n')! commit(a, 'second key')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', '')! push_strip(a, mut backend, 'secrets.env')! for key in ['first key\n', 'second key\n'] { assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found } } // Unfinished strips of one path in different scopes finish together. fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() { base := scratch('strip_scopes')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! mut a := open_repo(machine('strip_scopes_a')!)! defer { a.close() } put(a.root, 'secrets.env', 'first key\n')! first := commit(a, 'first key')! put(a.root, 'secrets.env', 'second key\n')! second := commit(a, 'second key')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', first.id)! // Write 1 uploads the event; write 2, the record rewrite, fails. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_write: 2 } if _ := push_strip(a, mut flaky, 'secrets.env') { assert false, 'the injected failure should have surfaced' } strip_local(a, 'secrets.env', second.id)! push_strip(a, mut backend, 'secrets.env')! assert !strip_pending(a, 'secrets.env') for save in [first, second] { record := decode_save(backend.get(save_key('proj', save.id))!.body)! assert 'secrets.env' !in record.files.map(it.path) } for key in ['first key\n', 'second key\n'] { assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found } } // Stripping a path, re-adding it, and stripping it again is two strips; a // machine that heard about the first must still apply the second. fn test_a_second_strip_of_the_same_path_reaches_other_machines() { base := scratch('strip_again')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_again_a')!)! defer { a.close() } mut b := open_repo(machine('strip_again_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'first key\n')! commit(a, 'first key')! offer_now(a, mut backend)! join(b, mut backend)! strip_local(a, 'secrets.env', '')! push_strip(a, mut backend, 'secrets.env')! steal_now(b, mut backend)! // Strip leaves the file on disk; deleting it is the user's part. os.rm(os.join_path(b.root, 'secrets.env'))! put(a.root, 'secrets.env', 'second key\n')! second := commit(a, 'second key')! offer_now(a, mut backend)! steal_now(b, mut backend)! assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path) strip_local(a, 'secrets.env', '')! push_strip(a, mut backend, 'secrets.env')! steal_now(b, mut backend)! assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path) } // A clash on a path the other machine stripped is no clash: the strip removes // the path from every save, so there is nothing to choose. fn test_a_stripped_path_is_not_asked_about() { base := scratch('clash_strip')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('clash_strip_a')!)! defer { a.close() } mut b := open_repo(machine('clash_strip_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'key one\n')! commit(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'secrets.env', 'key two\n')! commit(a, 'A rotates')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', '')! push_strip(a, mut backend, 'secrets.env')! put(b.root, 'secrets.env', 'key three\n')! mine := commit(b, 'B rotates')! report := steal(b, mut backend, StealOptions{})! assert report.clashes.len == 0 for save in history.all_saves(b)! { assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path) } assert history.by_id(b, mine.id) != none } // A strip scoped to the shared base save changes what the replay sees; the // clashes must be found on the same trees, or the replay meets a conflict. fn test_a_strip_of_the_shared_base_is_seen_before_asking() { base := scratch('clash_base_strip')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('base_strip_a')!)! defer { a.close() } mut b := open_repo(machine('base_strip_b')!)! defer { b.close() } put(a.root, 'notes.txt', 'shared\n')! common := commit(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'other.txt', 'A moves on\n')! commit(a, 'A moves on')! offer_now(a, mut backend)! strip_local(a, 'notes.txt', common.id)! push_strip(a, mut backend, 'notes.txt')! put(b.root, 'notes.txt', 'B edits\n')! commit(b, 'B edits notes')! if _ := steal(b, mut backend, StealOptions{}) { assert false, 'with the base stripped, notes.txt differs on both sides' } else { assert err.msg().contains('changed on both machines') } report := steal(b, mut backend, StealOptions{ mine: true })! assert report.clashes == ['notes.txt'] assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n' } // A folder with files but no saves has nothing to combine with; a steal that // fast-forwarded into it would overwrite whatever shares a name. fn test_steal_into_a_folder_with_unsaved_files_refuses() { base := scratch('steal_unsaved')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('unsaved_a')!)! defer { a.close() } put(a.root, 'notes.txt', 'from A\n')! commit(a, 'A writes')! offer_now(a, mut backend)! b_root := machine('unsaved_b')! mut b := storage.create(b_root)! defer { b.close() } put(b_root, 'notes.txt', 'never saved\n')! // Only a repo that already knows its remote name gets this far. b.set_state('remote_repo', 'proj')! if _ := steal(b, mut backend, StealOptions{}) { assert false, 'steal overwrote a folder with unsaved files' } else { assert err.msg() == no_saves_here } assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n' // A fresh `koh init` folder, holding only the default ignore list, is fine. os.rm(os.join_path(b_root, 'notes.txt'))! put(b_root, snapshot.ignore_file, snapshot.default_ignore)! steal(b, mut backend, StealOptions{})! assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n' } // The document asks steal to warn about every incoming flagged save, so the // report names them. fn test_steal_reports_incoming_flagged_saves() { base := scratch('flag_warn')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('flag_warn_a')!)! defer { a.close() } mut b := open_repo(machine('flag_warn_b')!)! defer { b.close() } put(a.root, 'a.txt', 'one\n')! commit(a, 'fine')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'a.txt', 'two\n')! suspect := commit(a, 'suspect')! put(a.root, 'a.txt', 'three\n')! commit(a, 'also fine')! events.flag(a, suspect.id, 'broke the build')! offer_now(a, mut backend)! report := steal_now(b, mut backend)! assert report.flagged.map(it.save_id) == [suspect.id] assert report.flagged[0].reason == 'broke the build' }