12345module sync67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage13141516fn 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 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 102 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 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}137138139140fn 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}162163164165fn test_steal_never_writes_through_a_symlinked_folder() {166 base := scratch('steal_link')!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('steal_link_a')!)!172 defer {173 a.close()174 }175 mut b := open_repo(machine('steal_link_b')!)!176 defer {177 b.close()178 }179 put(a.root, 'a.txt', 'one\n')!180 commit(a, 'first')!181 offer_now(a, mut backend)!182 join(b, mut backend)!183 outside := os.join_path(base, 'outside')184 put(outside, 'a.md', 'precious\n')!185 os.symlink(outside, os.join_path(b.root, 'docs'))!186 put(a.root, 'docs/a.md', 'from A\n')!187 commit(a, 'A adds docs')!188 offer_now(a, mut backend)!189 head_before := history.head(b)!.id190 if _ := steal_now(b, mut backend) {191 assert false, 'a steal writing through a symlinked folder must refuse'192 } else {193 assert err.msg().contains('docs')194 }195 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'196 assert history.head(b)!.id == head_before197}198199200fn test_steal_refuses_after_a_restore_cut_short() {201 base := scratch('steal_cut')!202 defer {203 os.rmdir_all(base) or {}204 }205 mut backend := remote.new_folder(os.join_path(base, 'remote'))!206 mut a := open_repo(machine('steal_cut_a')!)!207 defer {208 a.close()209 }210 put(a.root, 'a.txt', 'one\n')!211 commit(a, 'first')!212 offer_now(a, mut backend)!213 a.set_state('restoring', 'yes')!214 if _ := steal_now(a, mut backend) {215 assert false, 'steal must refuse while a restore is unfinished'216 } else {217 assert err.msg().contains('koh revert')218 }219}220221222223fn test_a_clone_imports_parents_before_children() {224 base := scratch('clone_order')!225 defer {226 os.rmdir_all(base) or {}227 }228 mut backend := remote.new_folder(os.join_path(base, 'remote'))!229 mut a := open_repo(machine('order_a')!)!230 defer {231 a.close()232 }233 put(a.root, 'a.txt', 'one\n')!234 parent := commit(a, 'parent')!235 child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{236 message: 'replayed, older than its parent'237 created_at: parent.created_at - 3600238 })!239 offer_now(a, mut backend)!240 mut b := open_repo(machine('order_b')!)!241 defer {242 b.close()243 }244 join(b, mut backend)!245 assert history.head(b)!.id == child.id246}247248249fn test_steal_refuses_an_unsaved_executable_bit_change() {250 base := scratch('chmod')!251 defer {252 os.rmdir_all(base) or {}253 }254 mut backend := remote.new_folder(os.join_path(base, 'remote'))!255 mut a := open_repo(machine('chmod_a')!)!256 defer {257 a.close()258 }259 put(a.root, 'run.sh', 'echo hi\n')!260 commit(a, 'script')!261 offer_now(a, mut backend)!262 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!263 if _ := steal_now(a, mut backend) {264 assert false, 'a chmod-only change should make the tree dirty'265 } else {266 assert err.msg() == dirty_tree267 }268}269270fn test_divergence_without_clashes_keeps_both_machines_work() {271 base := scratch('diverge')!272 defer {273 os.rmdir_all(base) or {}274 }275 mut backend := remote.new_folder(os.join_path(base, 'remote'))!276 mut a := open_repo(machine('div_a')!)!277 defer {278 a.close()279 }280 mut b := open_repo(machine('div_b')!)!281 defer {282 b.close()283 }284 put(a.root, 'shared.txt', 'base\n')!285 commit(a, 'base')!286 offer_now(a, mut backend)!287288 289 join(b, mut backend)!290 put(a.root, 'fromA.txt', 'A\n')!291 commit(a, 'A adds a file')!292 put(b.root, 'fromB.txt', 'B\n')!293 commit(b, 'B adds a file')!294 offer_now(a, mut backend)!295296 297 if _ := offer_now(b, mut backend) {298 assert false, 'offering on top of unknown remote saves should be refused'299 } else {300 assert err.msg().contains('run koh steal')301 }302303 report := steal_now(b, mut backend)!304 assert !report.forward305 assert report.replayed == 1306 assert report.clashes.len == 0307 308 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'309 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'310 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'311 312 mut saw_replay := false313 for save in history.all_saves(b)! {314 if save.replayed_from != '' {315 saw_replay = true316 }317 }318 assert saw_replay319 320 offer_now(b, mut backend)!321 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()322 assert line_head == history.head(b)!.id323}324325326327328329fn test_a_local_change_undone_does_not_block_a_combine() {330 base := scratch('undone')!331 defer {332 os.rmdir_all(base) or {}333 }334 mut backend := remote.new_folder(os.join_path(base, 'remote'))!335 mut a := open_repo(machine('undone_a')!)!336 defer {337 a.close()338 }339 mut b := open_repo(machine('undone_b')!)!340 defer {341 b.close()342 }343 put(a.root, 'f.txt', 'base\n')!344 put(a.root, 'g.txt', 'base\n')!345 commit(a, 'base')!346 offer_now(a, mut backend)!347 join(b, mut backend)!348349 put(a.root, 'f.txt', 'from A\n')!350 put(a.root, 'g.txt', 'same\n')!351 commit(a, 'A edits both')!352 offer_now(a, mut backend)!353354 put(b.root, 'f.txt', 'tried on B\n')!355 put(b.root, 'g.txt', 'first try\n')!356 tried := commit(b, 'B tries')!357 put(b.root, 'f.txt', 'base\n')!358 put(b.root, 'g.txt', 'same\n')!359 put(b.root, 'h.txt', 'B\n')!360 undone := commit(b, 'B undoes f, lands on the same g, adds h')!361362 report := steal_now(b, mut backend)!363 assert report.clashes.len == 0364 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'365 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'366 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'367 368 369 assert report.replayed == 1370 assert history.head(b)!.replayed_from == undone.id371 ids := history.all_saves(b)!.map(it.id)372 assert tried.id in ids && undone.id in ids373 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')374}375376377378fn test_clash_questions_are_asked_before_the_write_lock() {379 base := scratch('ask_unlocked')!380 defer {381 os.rmdir_all(base) or {}382 }383 mut backend := remote.new_folder(os.join_path(base, 'remote'))!384 mut a := open_repo(machine('ask_a')!)!385 defer {386 a.close()387 }388 mut b := open_repo(machine('ask_b')!)!389 defer {390 b.close()391 }392 put(a.root, 'same.txt', 'base\n')!393 commit(a, 'base')!394 offer_now(a, mut backend)!395 join(b, mut backend)!396 put(a.root, 'same.txt', 'A version\n')!397 commit(a, 'A edits same')!398 offer_now(a, mut backend)!399 put(b.root, 'same.txt', 'B version\n')!400 commit(b, 'B edits same')!401 root := b.root402 403 if _ := steal(b, mut backend, StealOptions{404 ask: fn [root] (clash Clash) Side {405 other := storage.open(root) or { panic(err) }406 defer {407 other.close()408 }409 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }410 history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{411 message: 'agent saves during the question'412 }) or { panic('steal held the write lock while asking: ${err}') }413 return .mine414 }415 })416 {417 assert false, 'a steal whose line moved during the question must refuse'418 } else {419 assert err.msg().contains('changed while')420 }421 422 assert history.head(b)!.message == 'agent saves during the question'423 report := steal(b, mut backend, StealOptions{424 mine: true425 })!426 assert report.kept_mine == 1427 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'428 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'429}430431fn test_clashing_files_are_chosen_one_at_a_time() {432 base := scratch('clash')!433 defer {434 os.rmdir_all(base) or {}435 }436 mut backend := remote.new_folder(os.join_path(base, 'remote'))!437 mut a := open_repo(machine('clash_a')!)!438 defer {439 a.close()440 }441 mut b := open_repo(machine('clash_b')!)!442 defer {443 b.close()444 }445 put(a.root, 'same.txt', 'base\n')!446 put(a.root, 'other.txt', 'quiet\n')!447 commit(a, 'base')!448 offer_now(a, mut backend)!449 join(b, mut backend)!450451 452 put(a.root, 'same.txt', 'A version\n')!453 commit(a, 'A edits same')!454 offer_now(a, mut backend)!455 put(b.root, 'same.txt', 'B version\n')!456 commit(b, 'B edits same')!457458 459 if _ := steal(b, mut backend, StealOptions{}) {460 assert false, 'an undecided clash must be refused'461 } else {462 assert err.msg().contains('--mine') || err.msg().contains('--theirs')463 }464 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,465 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0466467 468 report := steal_now(b, mut backend)!469 assert report.clashes == ['same.txt']470 assert report.kept_mine == 0471 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'472 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'473}474475fn test_choosing_mine_keeps_the_local_version() {476 base := scratch('mine')!477 defer {478 os.rmdir_all(base) or {}479 }480 mut backend := remote.new_folder(os.join_path(base, 'remote'))!481 mut a := open_repo(machine('mine_a')!)!482 defer {483 a.close()484 }485 mut b := open_repo(machine('mine_b')!)!486 defer {487 b.close()488 }489 put(a.root, 'same.txt', 'base\n')!490 commit(a, 'base')!491 offer_now(a, mut backend)!492 join(b, mut backend)!493 put(a.root, 'same.txt', 'A version\n')!494 commit(a, 'A edits')!495 offer_now(a, mut backend)!496 put(b.root, 'same.txt', 'B version\n')!497 commit(b, 'B edits')!498499 report := steal(b, mut backend, StealOptions{500 mine: true501 })!502 assert report.kept_mine == 1503 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'504 505 assert history.head(b)!.message.contains('combine: kept mine for 1 file')506 507 assert history.head(b)!.restores == ''508 assert history.all_saves(b)!.len >= 4509}510511fn test_flags_travel_with_the_saves() {512 base := scratch('flags')!513 defer {514 os.rmdir_all(base) or {}515 }516 mut backend := remote.new_folder(os.join_path(base, 'remote'))!517 mut a := open_repo(machine('flag_a')!)!518 defer {519 a.close()520 }521 mut b := open_repo(machine('flag_b')!)!522 defer {523 b.close()524 }525 put(a.root, 'a.txt', 'one\n')!526 save := commit(a, 'suspect save')!527 events.flag(a, save.id, 'look at this')!528 offer_now(a, mut backend)!529530 531 fetched := fetch_back_to_known(b, mut backend, 'proj')!532 assert fetched.events_out.len == 1533 assert fetched.events_out[0].kind == events.kind_flag534 b.begin()!535 write_fetched(b, fetched)!536 history.set_head(b, fetched.head)!537 b.commit()!538 state := events.flag_state(b, save.id)!539 assert state.flagged540 assert state.reason == 'look at this'541}542543fn test_a_second_offer_racing_head_is_refused_not_forced() {544 base := scratch('race')!545 defer {546 os.rmdir_all(base) or {}547 }548 mut backend := remote.new_folder(os.join_path(base, 'remote'))!549 mut a := open_repo(machine('race_a')!)!550 defer {551 a.close()552 }553 put(a.root, 'a.txt', 'one\n')!554 commit(a, 'base')!555 offer_now(a, mut backend)!556557 558 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),559 backend.get(head_key('proj'))!.tag)!560561 put(a.root, 'a.txt', 'two\n')!562 commit(a, 'second')!563 if _ := offer_now(a, mut backend) {564 assert false, 'offering while the remote head moved must be refused'565 } else {566 assert err.msg().contains('run koh steal')567 }568}569570571fn join(store &storage.Store, mut backend remote.Backend) ! {572 fetched := fetch_repo(mut backend, 'proj')!573 store.begin()!574 write_fetched(store, fetched)!575 history.set_head(store, fetched.head)!576 store.commit()!577 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,578 fetched.head)!, [])!579 store.set_state('remote_repo', 'proj')!580}581582struct FaultyBackend {583mut:584 inner remote.Backend585 writes int586 reads int587 fail_write int 588 fail_read int589 590 fail_delete bool591}592593fn (mut f FaultyBackend) get(key string) !remote.Object {594 f.reads++595 if f.fail_read > 0 && f.reads >= f.fail_read {596 return error('injected read failure')597 }598 return f.inner.get(key)!599}600601fn (mut f FaultyBackend) list(prefix string) ![]string {602 return f.inner.list(prefix)!603}604605fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {606 f.writes++607 if f.fail_write > 0 && f.writes >= f.fail_write {608 return error('injected write failure')609 }610 return f.inner.put_if_absent(key, body)!611}612613fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {614 f.writes++615 if f.fail_write > 0 && f.writes >= f.fail_write {616 return error('injected write failure')617 }618 return f.inner.put_if_match(key, body, tag)!619}620621fn (mut f FaultyBackend) delete(key string) ! {622 if f.fail_delete {623 return error('injected delete failure')624 }625 f.inner.delete(key)!626}627628fn test_a_failed_offer_is_safe_to_re_run() {629 base := scratch('fault_offer')!630 defer {631 os.rmdir_all(base) or {}632 }633 remote_dir := os.join_path(base, 'remote')634 mut a := open_repo(machine('fault_a')!)!635 defer {636 a.close()637 }638 put(a.root, 'a.txt', 'one\n')!639 commit(a, 'first')!640 put(a.root, 'a.txt', 'two\n')!641 commit(a, 'second')!642643 644 mut flaky := FaultyBackend{645 inner: remote.new_folder(remote_dir)!646 fail_write: 2647 }648 if _ := offer(a, mut flaky) {649 assert false, 'the injected failure should have surfaced'650 }651652 653 654 mut healthy := remote.new_folder(remote_dir)!655 report := offer(a, mut healthy)!656 assert report.saves == 2657 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id658 assert healthy.list('proj/saves')!.len == 2659}660661fn test_a_failed_steal_leaves_nothing_behind() {662 base := scratch('fault_steal')!663 defer {664 os.rmdir_all(base) or {}665 }666 remote_dir := os.join_path(base, 'remote')667 mut a := open_repo(machine('fault_s_a')!)!668 defer {669 a.close()670 }671 mut b := open_repo(machine('fault_s_b')!)!672 defer {673 b.close()674 }675 put(a.root, 'a.txt', 'from A\n')!676 commit(a, 'A writes')!677 mut healthy := remote.new_folder(remote_dir)!678 offer(a, mut healthy)!679680 681 mut flaky := FaultyBackend{682 inner: remote.new_folder(remote_dir)!683 fail_read: 2684 }685 if _ := fetch_back_to_known(b, mut flaky, 'proj') {686 assert false, 'the injected read failure should have surfaced'687 }688 689 assert history.head(b) == none690 assert b.object_count()! == 0691692 693 fetched := fetch_back_to_known(b, mut healthy, 'proj')!694 b.begin()!695 write_fetched(b, fetched)!696 history.set_head(b, fetched.head)!697 b.commit()!698 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!699 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'700}701702fn test_an_interrupted_strip_is_idempotent() {703 704 705 706 base := scratch('fault_strip')!707 defer {708 os.rmdir_all(base) or {}709 }710 mut a := open_repo(machine('fault_strip_a')!)!711 defer {712 a.close()713 }714 put(a.root, 'secret.txt', 'shh\n')!715 put(a.root, 'keep.txt', 'fine\n')!716 save := commit(a, 'add both')!717718 for _ in 0 .. 2 {719 strip_local(a, 'secret.txt', '')!720 }721 files := history.files_of(a, save.id)!722 723 724 assert files.map(it.path) == ['.kohignore', 'keep.txt']725 assert events.strip_events(a)!.len == 1726}727728729730731732fn test_a_strip_matches_the_path_exactly() {733 mut a := open_repo(machine('strip_exact')!)!734 defer {735 a.close()736 }737 hash := a.put_object('x\n'.bytes())!738 mut files := []snapshot.FileEntry{}739 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {740 files << snapshot.FileEntry{741 path: path742 hash: hash743 }744 }745 save := history.commit_files(a, '', files, history.SaveInput{746 message: 'add all'747 })!748 assert strip_scope(a, 'docs', '')!.files == 1749 strip_local(a, 'docs', '')!750 strip_local(a, 'a_b', '')!751 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']752}753754fn test_a_strip_reaches_the_remote_and_other_machines() {755 base := scratch('strip_sync')!756 defer {757 os.rmdir_all(base) or {}758 }759 mut a := open_repo(machine('strip_a')!)!760 defer {761 a.close()762 }763 mut b := open_repo(machine('strip_b')!)!764 defer {765 b.close()766 }767 put(a.root, 'secrets.env', 'api_key=1\n')!768 put(a.root, 'keep.txt', 'fine\n')!769 save := commit(a, 'add both')!770 mut backend := remote.new_folder(os.join_path(base, 'remote'))!771 offer_now(a, mut backend)!772 join(b, mut backend)!773 assert b.object_count()! > 0774775 776 777 mut secret_hash := ''778 for file in history.files_of(a, save.id)! {779 if file.path == 'secrets.env' {780 secret_hash = file.hash781 }782 }783 assert secret_hash != ''784 strip_local(a, 'secrets.env', '')!785 report := push_strip(a, mut backend, 'secrets.env')!786 assert report.event_sent787 assert report.records == 1788789 790 791 record := decode_save(backend.get(save_key('proj', save.id))!.body)!792 for file in record.files {793 assert file.path != 'secrets.env'794 }795 796 797 assert !backend.get(object_key('proj', secret_hash))!.found798 assert backend.list('proj/objects')!.len == 2799800 801 b.begin()!802 fetched := fetch_back_to_known(b, mut backend, 'proj')!803 write_fetched(b, fetched)!804 b.commit()!805 mut paths := []string{}806 for file in history.files_of(b, save.id)! {807 paths << file.path808 }809 assert 'secrets.env' !in paths810 assert 'keep.txt' in paths811812 813 814 815 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)816 put(b.root, 'secrets.env', 'rotated\n')!817 readded := commit(b, 'add the rotated key back')!818 offer_now(b, mut backend)!819 assert backend.list('proj/events')!.len == 1820 b.begin()!821 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!822 b.commit()!823 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)824}825826827828829fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {830 base := scratch('strip_clone')!831 defer {832 os.rmdir_all(base) or {}833 }834 mut backend := remote.new_folder(os.join_path(base, 'remote'))!835 mut a := open_repo(machine('strip_clone_a')!)!836 defer {837 a.close()838 }839 put(a.root, 'secrets.env', 'api_key=1\n')!840 old := commit(a, 'leak the key')!841 offer_now(a, mut backend)!842 strip_local(a, 'secrets.env', '')!843 push_strip(a, mut backend, 'secrets.env')!844 845 put(a.root, 'secrets.env', 'api_key=2\n')!846 rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{847 message: 'rotate the key'848 created_at: events.strip_events(a)!.last().created_at + 60849 })!850 offer_now(a, mut backend)!851852 mut c := open_repo(machine('strip_clone_c')!)!853 defer {854 c.close()855 }856 join(c, mut backend)!857 assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)858 assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)859 assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'860}861862863864865fn test_an_interrupted_remote_strip_finishes_when_re_run() {866 base := scratch('strip_resume')!867 defer {868 os.rmdir_all(base) or {}869 }870 remote_dir := os.join_path(base, 'remote')871 mut backend := remote.new_folder(remote_dir)!872 mut a := open_repo(machine('resume_a')!)!873 defer {874 a.close()875 }876 mut b := open_repo(machine('resume_b')!)!877 defer {878 b.close()879 }880 put(a.root, 'secrets.env', 'api_key=1\n')!881 put(a.root, 'shared.env', 'same bytes on B\n')!882 save := commit(a, 'add secrets')!883 offer_now(a, mut backend)!884 join(b, mut backend)!885 put(b.root, 'copy.txt', 'same bytes on B\n')!886 b_save := commit(b, 'B keeps a copy')!887 offer_now(b, mut backend)!888 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())889 secret_hash := storage.hash_hex('api_key=1\n'.bytes())890891 strip_local(a, 'secrets.env', '')!892 893 894 mut flaky := FaultyBackend{895 inner: remote.new_folder(remote_dir)!896 fail_write: 3897 }898 if _ := push_strip(a, mut flaky, 'secrets.env') {899 assert false, 'the injected failure should have surfaced'900 }901 assert strip_pending(a, 'secrets.env')902 assert strip_local(a, 'secrets.env', '')!.files_removed == 0903 report := push_strip(a, mut backend, 'secrets.env')!904 assert report.records == 1905 assert !strip_pending(a, 'secrets.env')906 assert !backend.get(object_key('proj', secret_hash))!.found907 record := decode_save(backend.get(save_key('proj', save.id))!.body)!908 assert 'secrets.env' !in record.files.map(it.path)909 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!910 assert 'secrets.env' !in b_record.files.map(it.path)911912 913 strip_local(a, 'shared.env', '')!914 push_strip(a, mut backend, 'shared.env')!915 assert backend.get(object_key('proj', shared_hash))!.found916}917918919920fn test_offer_waits_for_an_unapplied_strip() {921 base := scratch('strip_first')!922 defer {923 os.rmdir_all(base) or {}924 }925 mut backend := remote.new_folder(os.join_path(base, 'remote'))!926 mut a := open_repo(machine('strip_first_a')!)!927 defer {928 a.close()929 }930 mut b := open_repo(machine('strip_first_b')!)!931 defer {932 b.close()933 }934 put(a.root, 'secrets.env', 'api_key=1\n')!935 commit(a, 'add secrets')!936 offer_now(a, mut backend)!937 join(b, mut backend)!938 strip_local(a, 'secrets.env', '')!939 push_strip(a, mut backend, 'secrets.env')!940 put(b.root, 'more.txt', 'B keeps working\n')!941 later := commit(b, 'B saves with the secret still in its tree')!942 if _ := offer_now(b, mut backend) {943 assert false, 'B offered records that still list the stripped path'944 }945 steal_now(b, mut backend)!946 offer_now(b, mut backend)!947 record := decode_save(backend.get(save_key('proj', later.id))!.body)!948 assert 'secrets.env' !in record.files.map(it.path)949}950951952953fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {954 base := scratch('strip_twice')!955 defer {956 os.rmdir_all(base) or {}957 }958 remote_dir := os.join_path(base, 'remote')959 mut backend := remote.new_folder(remote_dir)!960 mut a := open_repo(machine('strip_twice_a')!)!961 defer {962 a.close()963 }964 put(a.root, 'secrets.env', 'first key\n')!965 commit(a, 'first key')!966 offer_now(a, mut backend)!967 strip_local(a, 'secrets.env', '')!968 mut flaky := FaultyBackend{969 inner: remote.new_folder(remote_dir)!970 fail_delete: true971 }972 if _ := push_strip(a, mut flaky, 'secrets.env') {973 assert false, 'the injected delete failure should have surfaced'974 }975 put(a.root, 'secrets.env', 'second key\n')!976 commit(a, 'second key')!977 offer_now(a, mut backend)!978 strip_local(a, 'secrets.env', '')!979 push_strip(a, mut backend, 'secrets.env')!980 for key in ['first key\n', 'second key\n'] {981 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found982 }983}984985986fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {987 base := scratch('strip_scopes')!988 defer {989 os.rmdir_all(base) or {}990 }991 remote_dir := os.join_path(base, 'remote')992 mut backend := remote.new_folder(remote_dir)!993 mut a := open_repo(machine('strip_scopes_a')!)!994 defer {995 a.close()996 }997 put(a.root, 'secrets.env', 'first key\n')!998 first := commit(a, 'first key')!999 put(a.root, 'secrets.env', 'second key\n')!1000 second := commit(a, 'second key')!1001 offer_now(a, mut backend)!1002 strip_local(a, 'secrets.env', first.id)!1003 1004 mut flaky := FaultyBackend{1005 inner: remote.new_folder(remote_dir)!1006 fail_write: 21007 }1008 if _ := push_strip(a, mut flaky, 'secrets.env') {1009 assert false, 'the injected failure should have surfaced'1010 }1011 strip_local(a, 'secrets.env', second.id)!1012 push_strip(a, mut backend, 'secrets.env')!1013 assert !strip_pending(a, 'secrets.env')1014 for save in [first, second] {1015 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1016 assert 'secrets.env' !in record.files.map(it.path)1017 }1018 for key in ['first key\n', 'second key\n'] {1019 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1020 }1021}1022102310241025fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1026 base := scratch('strip_again')!1027 defer {1028 os.rmdir_all(base) or {}1029 }1030 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1031 mut a := open_repo(machine('strip_again_a')!)!1032 defer {1033 a.close()1034 }1035 mut b := open_repo(machine('strip_again_b')!)!1036 defer {1037 b.close()1038 }1039 put(a.root, 'secrets.env', 'first key\n')!1040 commit(a, 'first key')!1041 offer_now(a, mut backend)!1042 join(b, mut backend)!1043 strip_local(a, 'secrets.env', '')!1044 push_strip(a, mut backend, 'secrets.env')!1045 steal_now(b, mut backend)!1046 1047 os.rm(os.join_path(b.root, 'secrets.env'))!1048 put(a.root, 'secrets.env', 'second key\n')!1049 second := commit(a, 'second key')!1050 offer_now(a, mut backend)!1051 steal_now(b, mut backend)!1052 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1053 strip_local(a, 'secrets.env', '')!1054 push_strip(a, mut backend, 'secrets.env')!1055 steal_now(b, mut backend)!1056 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1057}1058105910601061fn test_a_stripped_path_is_not_asked_about() {1062 base := scratch('clash_strip')!1063 defer {1064 os.rmdir_all(base) or {}1065 }1066 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1067 mut a := open_repo(machine('clash_strip_a')!)!1068 defer {1069 a.close()1070 }1071 mut b := open_repo(machine('clash_strip_b')!)!1072 defer {1073 b.close()1074 }1075 put(a.root, 'secrets.env', 'key one\n')!1076 commit(a, 'base')!1077 offer_now(a, mut backend)!1078 join(b, mut backend)!1079 put(a.root, 'secrets.env', 'key two\n')!1080 commit(a, 'A rotates')!1081 offer_now(a, mut backend)!1082 strip_local(a, 'secrets.env', '')!1083 push_strip(a, mut backend, 'secrets.env')!1084 put(b.root, 'secrets.env', 'key three\n')!1085 mine := commit(b, 'B rotates')!1086 report := steal(b, mut backend, StealOptions{})!1087 assert report.clashes.len == 01088 for save in history.all_saves(b)! {1089 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1090 }1091 assert history.by_id(b, mine.id) != none1092}1093109410951096fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1097 base := scratch('clash_base_strip')!1098 defer {1099 os.rmdir_all(base) or {}1100 }1101 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1102 mut a := open_repo(machine('base_strip_a')!)!1103 defer {1104 a.close()1105 }1106 mut b := open_repo(machine('base_strip_b')!)!1107 defer {1108 b.close()1109 }1110 put(a.root, 'notes.txt', 'shared\n')!1111 common := commit(a, 'base')!1112 offer_now(a, mut backend)!1113 join(b, mut backend)!1114 put(a.root, 'other.txt', 'A moves on\n')!1115 commit(a, 'A moves on')!1116 offer_now(a, mut backend)!1117 strip_local(a, 'notes.txt', common.id)!1118 push_strip(a, mut backend, 'notes.txt')!1119 put(b.root, 'notes.txt', 'B edits\n')!1120 commit(b, 'B edits notes')!1121 if _ := steal(b, mut backend, StealOptions{}) {1122 assert false, 'with the base stripped, notes.txt differs on both sides'1123 } else {1124 assert err.msg().contains('changed on both machines')1125 }1126 report := steal(b, mut backend, StealOptions{1127 mine: true1128 })!1129 assert report.clashes == ['notes.txt']1130 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1131}1132113311341135fn test_steal_into_a_folder_with_unsaved_files_refuses() {1136 base := scratch('steal_unsaved')!1137 defer {1138 os.rmdir_all(base) or {}1139 }1140 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1141 mut a := open_repo(machine('unsaved_a')!)!1142 defer {1143 a.close()1144 }1145 put(a.root, 'notes.txt', 'from A\n')!1146 commit(a, 'A writes')!1147 offer_now(a, mut backend)!1148 b_root := machine('unsaved_b')!1149 mut b := storage.create(b_root)!1150 defer {1151 b.close()1152 }1153 put(b_root, 'notes.txt', 'never saved\n')!1154 1155 b.set_state('remote_repo', 'proj')!1156 if _ := steal(b, mut backend, StealOptions{}) {1157 assert false, 'steal overwrote a folder with unsaved files'1158 } else {1159 assert err.msg() == no_saves_here1160 }1161 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1162 1163 os.rm(os.join_path(b_root, 'notes.txt'))!1164 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1165 steal(b, mut backend, StealOptions{})!1166 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1167}1168116911701171fn test_steal_reports_incoming_flagged_saves() {1172 base := scratch('flag_warn')!1173 defer {1174 os.rmdir_all(base) or {}1175 }1176 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1177 mut a := open_repo(machine('flag_warn_a')!)!1178 defer {1179 a.close()1180 }1181 mut b := open_repo(machine('flag_warn_b')!)!1182 defer {1183 b.close()1184 }1185 put(a.root, 'a.txt', 'one\n')!1186 commit(a, 'fine')!1187 offer_now(a, mut backend)!1188 join(b, mut backend)!1189 put(a.root, 'a.txt', 'two\n')!1190 suspect := commit(a, 'suspect')!1191 put(a.root, 'a.txt', 'three\n')!1192 commit(a, 'also fine')!1193 events.flag(a, suspect.id, 'broke the build')!1194 offer_now(a, mut backend)!1195 report := steal_now(b, mut backend)!1196 assert report.flagged.map(it.save_id) == [suspect.id]1197 assert report.flagged[0].reason == 'broke the build'1198}