//! 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_and_offered_head_are_remembered() { 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 a.state('remote_head') or { '' } == 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 a.state('remote_head') or { '' } == 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 } 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 } 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 } 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) ! { 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')! // What `koh strip` does locally, twice over. for _ in 0 .. 2 { a.begin()! mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')! stmt.bind_text(1, 'secret.txt')! stmt.step()! stmt.finalize() a.drop_unreferenced_objects()! events.strip(a, '', 'secret.txt')! a.commit()! } 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 } 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 != '' a.begin()! mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')! stmt.bind_text(1, 'secrets.env')! stmt.step()! stmt.finalize() a.drop_unreferenced_objects()! events.strip(a, '', 'secrets.env')! a.commit()! report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)! 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 }