12345module exchange67import 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 make_save(store &storage.Store, message string) !history.Save {43 return history.save_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 make_save(a, 'first')!69 put(a.root, 'a.txt', 'two\n')!70 make_save(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 make_save(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() == head_of(a).id100 101 assert a.state('remote_head') or { '' } == head_of(a).id102103 104 105 put(a.root, 'a.txt', 'two\n')!106 make_save(a, 'second')!107 offer_now(a, mut backend)!108 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id109}110111fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {112 base := scratch('forward')!113 defer {114 os.rmdir_all(base) or {}115 }116 mut backend := remote.new_folder(os.join_path(base, 'remote'))!117 mut a := open_repo(machine('fwd_a')!)!118 defer {119 a.close()120 }121 put(a.root, 'a.txt', 'from A\n')!122 make_save(a, 'A writes')!123 offer_now(a, mut backend)!124125 126 mut b := open_repo(machine('fwd_b')!)!127 defer {128 b.close()129 }130 fetched := fetch_repo(mut backend, 'proj')!131 b.begin()!132 write_fetched(b, fetched)!133 history.set_head(b, fetched.head)!134 b.end()!135 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!136 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'137 assert head_of(b).id == fetched.head138}139140141142fn test_steal_while_only_ahead_leaves_the_line_alone() {143 base := scratch('ahead')!144 defer {145 os.rmdir_all(base) or {}146 }147 mut backend := remote.new_folder(os.join_path(base, 'remote'))!148 mut a := open_repo(machine('ahead_a')!)!149 defer {150 a.close()151 }152 put(a.root, 'a.txt', 'one\n')!153 make_save(a, 'offered')!154 offer_now(a, mut backend)!155 put(a.root, 'a.txt', 'two\n')!156 ahead := make_save(a, 'not offered yet')!157 report := steal_now(a, mut backend)!158 assert report.replayed == 0159 assert head_of(a).id == ahead.id160 161 assert !snapshot.restore_pending(a)162 assert history.all_saves(a)!.len == 2163 offer_now(a, mut backend)!164 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id165}166167168169fn test_steal_never_writes_through_a_symlinked_folder() {170 base := scratch('steal_link')!171 defer {172 os.rmdir_all(base) or {}173 }174 mut backend := remote.new_folder(os.join_path(base, 'remote'))!175 mut a := open_repo(machine('steal_link_a')!)!176 defer {177 a.close()178 }179 mut b := open_repo(machine('steal_link_b')!)!180 defer {181 b.close()182 }183 put(a.root, 'a.txt', 'one\n')!184 make_save(a, 'first')!185 offer_now(a, mut backend)!186 join(b, mut backend)!187 outside := os.join_path(base, 'outside')188 put(outside, 'a.md', 'precious\n')!189 os.symlink(outside, os.join_path(b.root, 'docs'))!190 put(a.root, 'docs/a.md', 'from A\n')!191 make_save(a, 'A adds docs')!192 offer_now(a, mut backend)!193 head_before := head_of(b).id194 if _ := steal_now(b, mut backend) {195 assert false, 'a steal writing through a symlinked folder must refuse'196 } else {197 assert err.msg().contains('docs')198 }199 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'200 assert head_of(b).id == head_before201}202203204fn test_steal_refuses_after_a_restore_cut_short() {205 base := scratch('steal_cut')!206 defer {207 os.rmdir_all(base) or {}208 }209 mut backend := remote.new_folder(os.join_path(base, 'remote'))!210 mut a := open_repo(machine('steal_cut_a')!)!211 defer {212 a.close()213 }214 put(a.root, 'a.txt', 'one\n')!215 make_save(a, 'first')!216 offer_now(a, mut backend)!217 a.set_state('restoring', 'yes')!218 if _ := steal_now(a, mut backend) {219 assert false, 'steal must refuse while a restore is unfinished'220 } else {221 assert err.msg().contains('koh revert')222 }223}224225226227fn test_a_steal_imports_parents_before_children() {228 base := scratch('steal_order')!229 defer {230 os.rmdir_all(base) or {}231 }232 mut backend := remote.new_folder(os.join_path(base, 'remote'))!233 mut a := open_repo(machine('order_a')!)!234 defer {235 a.close()236 }237 put(a.root, 'a.txt', 'one\n')!238 parent := make_save(a, 'parent')!239 child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{240 message: 'replayed, older than its parent'241 created_at: parent.created_at - 3600242 })!243 offer_now(a, mut backend)!244 mut b := open_repo(machine('order_b')!)!245 defer {246 b.close()247 }248 join(b, mut backend)!249 assert head_of(b).id == child.id250}251252253fn test_steal_refuses_an_unsaved_executable_bit_change() {254 base := scratch('chmod')!255 defer {256 os.rmdir_all(base) or {}257 }258 mut backend := remote.new_folder(os.join_path(base, 'remote'))!259 mut a := open_repo(machine('chmod_a')!)!260 defer {261 a.close()262 }263 put(a.root, 'run.sh', 'echo hi\n')!264 make_save(a, 'script')!265 offer_now(a, mut backend)!266 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!267 if _ := steal_now(a, mut backend) {268 assert false, 'a chmod-only change should make the tree dirty'269 } else {270 assert err.msg() == dirty_tree271 }272}273274fn test_divergence_without_clashes_keeps_both_machines_work() {275 base := scratch('diverge')!276 defer {277 os.rmdir_all(base) or {}278 }279 mut backend := remote.new_folder(os.join_path(base, 'remote'))!280 mut a := open_repo(machine('div_a')!)!281 defer {282 a.close()283 }284 mut b := open_repo(machine('div_b')!)!285 defer {286 b.close()287 }288 put(a.root, 'shared.txt', 'base\n')!289 make_save(a, 'base')!290 offer_now(a, mut backend)!291292 293 join(b, mut backend)!294 put(a.root, 'fromA.txt', 'A\n')!295 make_save(a, 'A adds a file')!296 put(b.root, 'fromB.txt', 'B\n')!297 make_save(b, 'B adds a file')!298 offer_now(a, mut backend)!299300 301 if _ := offer_now(b, mut backend) {302 assert false, 'offering on top of unknown remote saves should be refused'303 } else {304 assert err.msg().contains('run koh steal')305 }306307 report := steal_now(b, mut backend)!308 assert !report.forward309 assert report.replayed == 1310 assert report.clashes.len == 0311 312 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'313 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'314 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'315 316 mut saw_replay := false317 for save in history.all_saves(b)! {318 if save.replayed_from != '' {319 saw_replay = true320 }321 }322 assert saw_replay323 324 offer_now(b, mut backend)!325 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()326 assert line_head == head_of(b).id327}328329330331332333fn test_a_local_change_undone_does_not_block_a_combine() {334 base := scratch('undone')!335 defer {336 os.rmdir_all(base) or {}337 }338 mut backend := remote.new_folder(os.join_path(base, 'remote'))!339 mut a := open_repo(machine('undone_a')!)!340 defer {341 a.close()342 }343 mut b := open_repo(machine('undone_b')!)!344 defer {345 b.close()346 }347 put(a.root, 'f.txt', 'base\n')!348 put(a.root, 'g.txt', 'base\n')!349 make_save(a, 'base')!350 offer_now(a, mut backend)!351 join(b, mut backend)!352353 put(a.root, 'f.txt', 'from A\n')!354 put(a.root, 'g.txt', 'same\n')!355 make_save(a, 'A edits both')!356 offer_now(a, mut backend)!357358 put(b.root, 'f.txt', 'tried on B\n')!359 put(b.root, 'g.txt', 'first try\n')!360 tried := make_save(b, 'B tries')!361 put(b.root, 'f.txt', 'base\n')!362 put(b.root, 'g.txt', 'same\n')!363 put(b.root, 'h.txt', 'B\n')!364 undone := make_save(b, 'B undoes f, lands on the same g, adds h')!365366 report := steal_now(b, mut backend)!367 assert report.clashes.len == 0368 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'369 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'370 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'371 372 373 assert report.replayed == 1374 assert head_of(b).replayed_from == undone.id375 ids := history.all_saves(b)!.map(it.id)376 assert tried.id in ids && undone.id in ids377 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')378}379380381382fn test_clash_questions_are_asked_before_the_write_lock() {383 base := scratch('ask_unlocked')!384 defer {385 os.rmdir_all(base) or {}386 }387 mut backend := remote.new_folder(os.join_path(base, 'remote'))!388 mut a := open_repo(machine('ask_a')!)!389 defer {390 a.close()391 }392 mut b := open_repo(machine('ask_b')!)!393 defer {394 b.close()395 }396 put(a.root, 'same.txt', 'base\n')!397 make_save(a, 'base')!398 offer_now(a, mut backend)!399 join(b, mut backend)!400 put(a.root, 'same.txt', 'A version\n')!401 make_save(a, 'A edits same')!402 offer_now(a, mut backend)!403 put(b.root, 'same.txt', 'B version\n')!404 make_save(b, 'B edits same')!405 root := b.root406 407 if _ := steal(b, mut backend, StealOptions{408 guide: fn [root] (preview StealPreview) !map[string]Side {409 other := storage.open(root) or { panic(err) }410 defer {411 other.close()412 }413 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }414 history.save_worktree(other, root, snapshot.load(root), history.SaveInput{415 message: 'agent saves during the question'416 }) or { panic('steal held the write lock while asking: ${err}') }417 return {418 preview.clashes[0].path: Side.mine419 }420 }421 })422 {423 assert false, 'a steal whose line moved during the question must refuse'424 } else {425 assert err.msg().contains('changed while')426 }427 428 assert head_of(b).message == 'agent saves during the question'429 report := steal(b, mut backend, StealOptions{430 mine: true431 })!432 assert report.kept_mine == 1433 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'434 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'435}436437fn test_clashing_files_are_chosen_one_at_a_time() {438 base := scratch('clash')!439 defer {440 os.rmdir_all(base) or {}441 }442 mut backend := remote.new_folder(os.join_path(base, 'remote'))!443 mut a := open_repo(machine('clash_a')!)!444 defer {445 a.close()446 }447 mut b := open_repo(machine('clash_b')!)!448 defer {449 b.close()450 }451 put(a.root, 'same.txt', 'base\n')!452 put(a.root, 'other.txt', 'quiet\n')!453 make_save(a, 'base')!454 offer_now(a, mut backend)!455 join(b, mut backend)!456457 458 put(a.root, 'same.txt', 'A version\n')!459 make_save(a, 'A edits same')!460 offer_now(a, mut backend)!461 put(b.root, 'same.txt', 'B version\n')!462 make_save(b, 'B edits same')!463464 465 if _ := steal(b, mut backend, StealOptions{}) {466 assert false, 'an undecided clash must be refused'467 } else {468 assert err.msg().contains('--mine') || err.msg().contains('--theirs')469 470 if err is ClashError {471 assert err.paths == ['same.txt']472 } else {473 assert false, 'an undecided clash must be a ClashError, not ${err}'474 }475 }476 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,477 head_of(b).id)!).len == 0478479 480 report := steal_now(b, mut backend)!481 assert report.clashes == ['same.txt']482 assert report.kept_mine == 0483 assert report.kept_versions == ['same.txt:${report.previous_head}']484 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'485 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'486}487488fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {489 base := scratch('guided_cancel')!490 defer { os.rmdir_all(base) or {} }491 mut backend := remote.new_folder(os.join_path(base, 'remote'))!492 mut a := open_repo(machine('guided_cancel_a')!)!493 defer { a.close() }494 mut b := open_repo(machine('guided_cancel_b')!)!495 defer { b.close() }496 put(a.root, 'same.txt', 'base\n')!497 make_save(a, 'base')!498 offer_now(a, mut backend)!499 join(b, mut backend)!500 put(a.root, 'same.txt', 'remote\n')!501 remote_head := make_save(a, 'remote change')!502 offer_now(a, mut backend)!503 put(b.root, 'same.txt', 'mine\n')!504 local_head := make_save(b, 'local change')!505506 if _ := steal(b, mut backend, StealOptions{507 guide: fn (preview StealPreview) !map[string]Side {508 assert preview.clashes.len == 1509 return error('cancelled')510 }511 }) {512 assert false, 'the guide cancelled'513 } else {514 assert err.msg() == 'cancelled'515 }516 assert head_of(b).id == local_head.id517 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'518 assert history.by_id(b, remote_head.id) != none519 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1520}521522fn test_choosing_mine_keeps_the_local_version() {523 base := scratch('mine')!524 defer {525 os.rmdir_all(base) or {}526 }527 mut backend := remote.new_folder(os.join_path(base, 'remote'))!528 mut a := open_repo(machine('mine_a')!)!529 defer {530 a.close()531 }532 mut b := open_repo(machine('mine_b')!)!533 defer {534 b.close()535 }536 put(a.root, 'same.txt', 'base\n')!537 make_save(a, 'base')!538 offer_now(a, mut backend)!539 join(b, mut backend)!540 put(a.root, 'same.txt', 'A version\n')!541 make_save(a, 'A edits')!542 offer_now(a, mut backend)!543 put(b.root, 'same.txt', 'B version\n')!544 make_save(b, 'B edits')!545546 report := steal(b, mut backend, StealOptions{547 mine: true548 })!549 assert report.kept_mine == 1550 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'551 552 assert head_of(b).message.contains('combine: kept mine for 1 file')553 554 assert head_of(b).restores == ''555 assert history.all_saves(b)!.len >= 4556}557558fn test_flags_travel_with_the_saves() {559 base := scratch('flags')!560 defer {561 os.rmdir_all(base) or {}562 }563 mut backend := remote.new_folder(os.join_path(base, 'remote'))!564 mut a := open_repo(machine('flag_a')!)!565 defer {566 a.close()567 }568 mut b := open_repo(machine('flag_b')!)!569 defer {570 b.close()571 }572 put(a.root, 'a.txt', 'one\n')!573 save := make_save(a, 'suspect save')!574 events.flag(a, save.id, 'look at this')!575 offer_now(a, mut backend)!576577 578 fetched := fetch_back_to_known(b, mut backend, 'proj')!579 assert fetched.events_out.len == 1580 assert fetched.events_out[0].kind == events.kind_flag581 b.begin()!582 write_fetched(b, fetched)!583 history.set_head(b, fetched.head)!584 b.end()!585 state := events.flag_state(b, save.id)!586 assert state.flagged587 assert state.reason == 'look at this'588}589590fn test_a_second_offer_racing_head_is_refused_not_forced() {591 base := scratch('race')!592 defer {593 os.rmdir_all(base) or {}594 }595 mut backend := remote.new_folder(os.join_path(base, 'remote'))!596 mut a := open_repo(machine('race_a')!)!597 defer {598 a.close()599 }600 put(a.root, 'a.txt', 'one\n')!601 make_save(a, 'base')!602 offer_now(a, mut backend)!603604 605 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),606 backend.get(head_key('proj'))!.tag)!607608 put(a.root, 'a.txt', 'two\n')!609 make_save(a, 'second')!610 if _ := offer_now(a, mut backend) {611 assert false, 'offering while the remote head moved must be refused'612 } else {613 assert err.msg().contains('run koh steal')614 }615}616617618fn join(store &storage.Store, mut backend remote.Backend) ! {619 fetched := fetch_repo(mut backend, 'proj')!620 store.begin()!621 write_fetched(store, fetched)!622 history.set_head(store, fetched.head)!623 store.end()!624 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,625 fetched.head)!, [])!626 store.set_state('remote_repo', 'proj')!627}628629struct FaultyBackend {630mut:631 inner remote.Backend632 writes int633 reads int634 fail_write int 635 fail_read int636 637 fail_delete bool638 639 640 race_format bool641 raced bool642}643644fn (mut f FaultyBackend) get(key string) !remote.Object {645 f.reads++646 if f.fail_read > 0 && f.reads >= f.fail_read {647 return error('injected read failure')648 }649 return f.inner.get(key)!650}651652fn (mut f FaultyBackend) list(prefix string) ![]string {653 return f.inner.list(prefix)!654}655656fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {657 f.writes++658 if f.fail_write > 0 && f.writes >= f.fail_write {659 return error('injected write failure')660 }661 if f.race_format && !f.raced && key.ends_with('/format') {662 f.raced = true663 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!664 return false665 }666 return f.inner.put_if_absent(key, body)!667}668669fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {670 f.writes++671 if f.fail_write > 0 && f.writes >= f.fail_write {672 return error('injected write failure')673 }674 return f.inner.put_if_match(key, body, tag)!675}676677fn (mut f FaultyBackend) delete(key string) ! {678 if f.fail_delete {679 return error('injected delete failure')680 }681 f.inner.delete(key)!682}683684685686687fn test_an_offer_sends_only_the_objects_a_save_adds() {688 base := scratch('offer_diff')!689 defer {690 os.rmdir_all(base) or {}691 }692 mut a := open_repo(machine('offer_diff_a')!)!693 defer {694 a.close()695 }696 for i in 0 .. 20 {697 put(a.root, 'f${i}.txt', 'file ${i}\n')!698 }699 make_save(a, 'twenty files')!700 mut backend := FaultyBackend{701 inner: remote.new_folder(os.join_path(base, 'remote'))!702 }703 offer(a, mut backend)!704 put(a.root, 'f3.txt', 'changed\n')!705 make_save(a, 'one change')!706 before := backend.writes707 report := offer(a, mut backend)!708 assert report.objects == 1709 710 assert backend.writes - before == 3711}712713fn test_a_failed_offer_is_safe_to_re_run() {714 base := scratch('fault_offer')!715 defer {716 os.rmdir_all(base) or {}717 }718 remote_dir := os.join_path(base, 'remote')719 mut a := open_repo(machine('fault_a')!)!720 defer {721 a.close()722 }723 put(a.root, 'a.txt', 'one\n')!724 make_save(a, 'first')!725 put(a.root, 'a.txt', 'two\n')!726 make_save(a, 'second')!727728 729 mut flaky := FaultyBackend{730 inner: remote.new_folder(remote_dir)!731 fail_write: 2732 }733 if _ := offer(a, mut flaky) {734 assert false, 'the injected failure should have surfaced'735 }736737 738 739 mut healthy := remote.new_folder(remote_dir)!740 report := offer(a, mut healthy)!741 assert report.saves == 2742 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id743 assert healthy.list('proj/saves')!.len == 2744}745746fn test_a_failed_steal_leaves_nothing_behind() {747 base := scratch('fault_steal')!748 defer {749 os.rmdir_all(base) or {}750 }751 remote_dir := os.join_path(base, 'remote')752 mut a := open_repo(machine('fault_s_a')!)!753 defer {754 a.close()755 }756 mut b := open_repo(machine('fault_s_b')!)!757 defer {758 b.close()759 }760 put(a.root, 'a.txt', 'from A\n')!761 make_save(a, 'A writes')!762 mut healthy := remote.new_folder(remote_dir)!763 offer(a, mut healthy)!764765 766 mut flaky := FaultyBackend{767 inner: remote.new_folder(remote_dir)!768 fail_read: 2769 }770 if _ := fetch_back_to_known(b, mut flaky, 'proj') {771 assert false, 'the injected read failure should have surfaced'772 }773 774 assert history.head(b) == none775 assert b.object_count()! == 0776777 778 fetched := fetch_back_to_known(b, mut healthy, 'proj')!779 b.begin()!780 write_fetched(b, fetched)!781 history.set_head(b, fetched.head)!782 b.end()!783 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!784 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'785}786787fn test_an_interrupted_strip_is_idempotent() {788 789 790 791 base := scratch('fault_strip')!792 defer {793 os.rmdir_all(base) or {}794 }795 mut a := open_repo(machine('fault_strip_a')!)!796 defer {797 a.close()798 }799 put(a.root, 'secret.txt', 'shh\n')!800 put(a.root, 'keep.txt', 'fine\n')!801 save := make_save(a, 'add both')!802803 for _ in 0 .. 2 {804 strip_local(a, 'secret.txt', '')!805 }806 files := history.files_of(a, save.id)!807 808 809 assert files.map(it.path) == ['.kohignore', 'keep.txt']810 assert events.strip_events(a)!.len == 1811}812813814815816817fn test_a_strip_matches_the_path_exactly() {818 mut a := open_repo(machine('strip_exact')!)!819 defer {820 a.close()821 }822 hash := a.put_object('x\n'.bytes())!823 mut files := []snapshot.FileEntry{}824 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {825 files << snapshot.FileEntry{826 path: path827 hash: hash828 }829 }830 save := history.save_tree(a, '', files, history.SaveInput{831 message: 'add all'832 })!833 assert strip_scope(a, 'docs', '')!.files == 1834 strip_local(a, 'docs', '')!835 strip_local(a, 'a_b', '')!836 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']837}838839fn test_a_strip_reaches_the_remote_and_other_machines() {840 base := scratch('strip_sync')!841 defer {842 os.rmdir_all(base) or {}843 }844 mut a := open_repo(machine('strip_a')!)!845 defer {846 a.close()847 }848 mut b := open_repo(machine('strip_b')!)!849 defer {850 b.close()851 }852 put(a.root, 'secrets.env', 'api_key=1\n')!853 put(a.root, 'keep.txt', 'fine\n')!854 save := make_save(a, 'add both')!855 mut backend := remote.new_folder(os.join_path(base, 'remote'))!856 offer_now(a, mut backend)!857 join(b, mut backend)!858 assert b.object_count()! > 0859860 861 862 mut secret_hash := ''863 for file in history.files_of(a, save.id)! {864 if file.path == 'secrets.env' {865 secret_hash = file.hash866 }867 }868 assert secret_hash != ''869 strip_local(a, 'secrets.env', '')!870 report := send_strip(a, mut backend, 'secrets.env')!871 assert report.event_sent872 assert report.records == 1873874 875 876 record := decode_save(backend.get(save_key('proj', save.id))!.body)!877 for file in record.files {878 assert file.path != 'secrets.env'879 }880 881 882 assert !backend.get(object_key('proj', secret_hash))!.found883 assert backend.list('proj/objects')!.len == 2884885 886 b.begin()!887 fetched := fetch_back_to_known(b, mut backend, 'proj')!888 write_fetched(b, fetched)!889 b.end()!890 mut paths := []string{}891 for file in history.files_of(b, save.id)! {892 paths << file.path893 }894 assert 'secrets.env' !in paths895 assert 'keep.txt' in paths896897 898 899 900 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)901 put(b.root, 'secrets.env', 'rotated\n')!902 assert !dirty(b, history.head(b))!903 put(b.root, 'keep.txt', 'still fine\n')!904 later := make_save(b, 'edit keep')!905 offer_now(b, mut backend)!906 assert backend.list('proj/events')!.len == 1907 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)908}909910911912913fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {914 base := scratch('strip_later')!915 defer {916 os.rmdir_all(base) or {}917 }918 mut backend := remote.new_folder(os.join_path(base, 'remote'))!919 mut a := open_repo(machine('strip_later_a')!)!920 defer {921 a.close()922 }923 mut b := open_repo(machine('strip_later_b')!)!924 defer {925 b.close()926 }927 put(a.root, 'secrets.env', 'api_key=1\n')!928 old := make_save(a, 'leak the key')!929 offer_now(a, mut backend)!930 join(b, mut backend)!931 strip_local(a, 'secrets.env', '')!932 send_strip(a, mut backend, 'secrets.env')!933 934 put(b.root, 'secrets.env', 'api_key=2\n')!935 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{936 message: 'rotate the key'937 created_at: events.strip_events(a)!.last().created_at + 3600938 })!939 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)940 b.begin()!941 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!942 b.end()!943 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)944 offer_now(b, mut backend)!945946 mut c := open_repo(machine('strip_later_c')!)!947 defer {948 c.close()949 }950 join(c, mut backend)!951 for save in [old.id, later.id] {952 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)953 }954 assert !os.exists(os.join_path(c.root, 'secrets.env'))955}956957958959fn test_a_held_strip_reaches_saves_arriving_after_it() {960 base := scratch('strip_held')!961 defer {962 os.rmdir_all(base) or {}963 }964 mut backend := remote.new_folder(os.join_path(base, 'remote'))!965 mut a := open_repo(machine('strip_held_a')!)!966 defer {967 a.close()968 }969 mut b := open_repo(machine('strip_held_b')!)!970 defer {971 b.close()972 }973 put(a.root, 'secrets.env', 'api_key=1\n')!974 make_save(a, 'leak the key')!975 offer_now(a, mut backend)!976 join(b, mut backend)!977 strip_local(b, 'secrets.env', '')!978 put(a.root, 'keep.txt', 'fine\n')!979 later := make_save(a, 'add keep')!980 offer_now(a, mut backend)!981 b.begin()!982 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!983 b.end()!984 paths := history.files_of(b, later.id)!.map(it.path)985 assert 'secrets.env' !in paths986 assert 'keep.txt' in paths987}988989990991struct StripRace {992mut:993 inner remote.Backend994 other &storage.Store995 path string996 done bool997}998999fn (mut r StripRace) get(key string) !remote.Object {1000 return r.inner.get(key)!1001}10021003fn (mut r StripRace) list(prefix string) ![]string {1004 return r.inner.list(prefix)!1005}10061007fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1008 if !r.done && key.contains('/saves/') {1009 r.done = true1010 strip_local(r.other, r.path, '')!1011 send_strip(r.other, mut r.inner, r.path)!1012 }1013 return r.inner.put_if_absent(key, body)!1014}10151016fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1017 return r.inner.put_if_match(key, body, tag)!1018}10191020fn (mut r StripRace) delete(key string) ! {1021 r.inner.delete(key)!1022}10231024102510261027fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1028 base := scratch('strip_race')!1029 defer {1030 os.rmdir_all(base) or {}1031 }1032 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1033 mut a := open_repo(machine('strip_race_a')!)!1034 defer {1035 a.close()1036 }1037 mut b := open_repo(machine('strip_race_b')!)!1038 defer {1039 b.close()1040 }1041 put(a.root, 'secrets.env', 'api_key=1\n')!1042 first := make_save(a, 'leak the key')!1043 offer_now(a, mut backend)!1044 join(b, mut backend)!1045 put(a.root, 'secrets.env', 'api_key=2\n')!1046 later := make_save(a, 'rotate the key')!1047 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1048 mut race := StripRace{1049 inner: backend1050 other: &b1051 path: 'secrets.env'1052 }1053 if _ := offer(a, mut race) {1054 assert false, 'an offer that raced a strip must refuse'1055 } else {1056 assert err.msg().contains('koh steal'), err.msg()1057 }1058 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1059 assert !backend.get(object_key('proj', secret))!.found1060 for key in backend.list('proj/saves')! {1061 record := decode_save(backend.get(key)!.body)!1062 assert 'secrets.env' !in record.files.map(it.path), record.id1063 }1064}10651066106710681069fn test_an_interrupted_remote_strip_finishes_when_re_run() {1070 base := scratch('strip_resume')!1071 defer {1072 os.rmdir_all(base) or {}1073 }1074 remote_dir := os.join_path(base, 'remote')1075 mut backend := remote.new_folder(remote_dir)!1076 mut a := open_repo(machine('resume_a')!)!1077 defer {1078 a.close()1079 }1080 mut b := open_repo(machine('resume_b')!)!1081 defer {1082 b.close()1083 }1084 put(a.root, 'secrets.env', 'api_key=1\n')!1085 put(a.root, 'shared.env', 'same bytes on B\n')!1086 save := make_save(a, 'add secrets')!1087 offer_now(a, mut backend)!1088 join(b, mut backend)!1089 put(b.root, 'copy.txt', 'same bytes on B\n')!1090 b_save := make_save(b, 'B keeps a copy')!1091 offer_now(b, mut backend)!1092 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1093 secret_hash := storage.hash_hex('api_key=1\n'.bytes())10941095 strip_local(a, 'secrets.env', '')!1096 1097 1098 mut flaky := FaultyBackend{1099 inner: remote.new_folder(remote_dir)!1100 fail_write: 31101 }1102 if _ := send_strip(a, mut flaky, 'secrets.env') {1103 assert false, 'the injected failure should have surfaced'1104 }1105 assert strip_pending(a, 'secrets.env')1106 assert strip_local(a, 'secrets.env', '')!.files_removed == 01107 report := send_strip(a, mut backend, 'secrets.env')!1108 assert report.records == 11109 assert !strip_pending(a, 'secrets.env')1110 assert !backend.get(object_key('proj', secret_hash))!.found1111 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1112 assert 'secrets.env' !in record.files.map(it.path)1113 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1114 assert 'secrets.env' !in b_record.files.map(it.path)11151116 1117 strip_local(a, 'shared.env', '')!1118 send_strip(a, mut backend, 'shared.env')!1119 assert backend.get(object_key('proj', shared_hash))!.found1120}1121112211231124fn test_offer_waits_for_an_unapplied_strip() {1125 base := scratch('strip_first')!1126 defer {1127 os.rmdir_all(base) or {}1128 }1129 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1130 mut a := open_repo(machine('strip_first_a')!)!1131 defer {1132 a.close()1133 }1134 mut b := open_repo(machine('strip_first_b')!)!1135 defer {1136 b.close()1137 }1138 put(a.root, 'secrets.env', 'api_key=1\n')!1139 make_save(a, 'add secrets')!1140 offer_now(a, mut backend)!1141 join(b, mut backend)!1142 strip_local(a, 'secrets.env', '')!1143 send_strip(a, mut backend, 'secrets.env')!1144 put(b.root, 'more.txt', 'B keeps working\n')!1145 later := make_save(b, 'B saves with the secret still in its tree')!1146 if _ := offer_now(b, mut backend) {1147 assert false, 'B offered records that still list the stripped path'1148 }1149 steal_now(b, mut backend)!1150 offer_now(b, mut backend)!1151 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1152 assert 'secrets.env' !in record.files.map(it.path)1153}1154115511561157fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1158 base := scratch('strip_twice')!1159 defer {1160 os.rmdir_all(base) or {}1161 }1162 remote_dir := os.join_path(base, 'remote')1163 mut backend := remote.new_folder(remote_dir)!1164 mut a := open_repo(machine('strip_twice_a')!)!1165 defer {1166 a.close()1167 }1168 put(a.root, 'secrets.env', 'first key\n')!1169 make_save(a, 'first key')!1170 offer_now(a, mut backend)!1171 strip_local(a, 'secrets.env', '')!1172 mut flaky := FaultyBackend{1173 inner: remote.new_folder(remote_dir)!1174 fail_delete: true1175 }1176 if _ := send_strip(a, mut flaky, 'secrets.env') {1177 assert false, 'the injected delete failure should have surfaced'1178 }1179 put(a.root, 'secrets.env', 'second key\n')!1180 put(a.root, 'other.txt', 'more work\n')!1181 make_save(a, 'more work')!1182 offer_now(a, mut backend)!1183 send_strip(a, mut backend, 'secrets.env')!1184 for key in ['first key\n', 'second key\n'] {1185 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1186 }1187}118811891190fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1191 base := scratch('strip_scopes')!1192 defer {1193 os.rmdir_all(base) or {}1194 }1195 remote_dir := os.join_path(base, 'remote')1196 mut backend := remote.new_folder(remote_dir)!1197 mut a := open_repo(machine('strip_scopes_a')!)!1198 defer {1199 a.close()1200 }1201 put(a.root, 'secrets.env', 'first key\n')!1202 first := make_save(a, 'first key')!1203 put(a.root, 'secrets.env', 'second key\n')!1204 second := make_save(a, 'second key')!1205 offer_now(a, mut backend)!1206 strip_local(a, 'secrets.env', first.id)!1207 1208 mut flaky := FaultyBackend{1209 inner: remote.new_folder(remote_dir)!1210 fail_write: 21211 }1212 if _ := send_strip(a, mut flaky, 'secrets.env') {1213 assert false, 'the injected failure should have surfaced'1214 }1215 strip_local(a, 'secrets.env', second.id)!1216 send_strip(a, mut backend, 'secrets.env')!1217 assert !strip_pending(a, 'secrets.env')1218 for save in [first, second] {1219 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1220 assert 'secrets.env' !in record.files.map(it.path)1221 }1222 for key in ['first key\n', 'second key\n'] {1223 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1224 }1225}1226122712281229fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1230 base := scratch('strip_again')!1231 defer {1232 os.rmdir_all(base) or {}1233 }1234 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1235 mut a := open_repo(machine('strip_again_a')!)!1236 defer {1237 a.close()1238 }1239 mut b := open_repo(machine('strip_again_b')!)!1240 defer {1241 b.close()1242 }1243 put(a.root, 'secrets.env', 'first key\n')!1244 make_save(a, 'first key')!1245 offer_now(a, mut backend)!1246 join(b, mut backend)!1247 strip_local(a, 'secrets.env', '')!1248 send_strip(a, mut backend, 'secrets.env')!1249 steal_now(b, mut backend)!1250 for repo in [a, b] {1251 put(repo.root, 'secrets.env', 'second key\n')!1252 if _ := make_save(repo, 'second key') {1253 assert false, 'a banned path must not make a save'1254 }1255 }1256}1257125812591260fn test_a_stripped_path_is_not_asked_about() {1261 base := scratch('clash_strip')!1262 defer {1263 os.rmdir_all(base) or {}1264 }1265 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1266 mut a := open_repo(machine('clash_strip_a')!)!1267 defer {1268 a.close()1269 }1270 mut b := open_repo(machine('clash_strip_b')!)!1271 defer {1272 b.close()1273 }1274 put(a.root, 'secrets.env', 'key one\n')!1275 make_save(a, 'base')!1276 offer_now(a, mut backend)!1277 join(b, mut backend)!1278 put(a.root, 'secrets.env', 'key two\n')!1279 make_save(a, 'A rotates')!1280 offer_now(a, mut backend)!1281 strip_local(a, 'secrets.env', '')!1282 send_strip(a, mut backend, 'secrets.env')!1283 put(b.root, 'secrets.env', 'key three\n')!1284 mine := make_save(b, 'B rotates')!1285 report := steal(b, mut backend, StealOptions{})!1286 assert report.clashes.len == 01287 for save in history.all_saves(b)! {1288 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1289 }1290 assert history.by_id(b, mine.id) != none1291}1292129312941295fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1296 base := scratch('clash_base_strip')!1297 defer {1298 os.rmdir_all(base) or {}1299 }1300 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1301 mut a := open_repo(machine('base_strip_a')!)!1302 defer {1303 a.close()1304 }1305 mut b := open_repo(machine('base_strip_b')!)!1306 defer {1307 b.close()1308 }1309 put(a.root, 'notes.txt', 'shared\n')!1310 common := make_save(a, 'base')!1311 offer_now(a, mut backend)!1312 join(b, mut backend)!1313 put(a.root, 'other.txt', 'A moves on\n')!1314 make_save(a, 'A moves on')!1315 offer_now(a, mut backend)!1316 strip_local(a, 'notes.txt', common.id)!1317 send_strip(a, mut backend, 'notes.txt')!1318 put(b.root, 'notes.txt', 'B edits\n')!1319 make_save(b, 'B edits notes')!1320 if _ := steal(b, mut backend, StealOptions{}) {1321 assert false, 'with the base stripped, notes.txt differs on both sides'1322 } else {1323 assert err.msg().contains('notes.txt changed on both machines')1324 }1325 report := steal(b, mut backend, StealOptions{1326 mine: true1327 })!1328 assert report.clashes == ['notes.txt']1329 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1330}1331133213331334fn test_steal_into_a_folder_with_unsaved_files_refuses() {1335 base := scratch('steal_unsaved')!1336 defer {1337 os.rmdir_all(base) or {}1338 }1339 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1340 mut a := open_repo(machine('unsaved_a')!)!1341 defer {1342 a.close()1343 }1344 put(a.root, 'notes.txt', 'from A\n')!1345 make_save(a, 'A writes')!1346 offer_now(a, mut backend)!1347 b_root := machine('unsaved_b')!1348 mut b := storage.create(b_root)!1349 defer {1350 b.close()1351 }1352 put(b_root, 'notes.txt', 'never saved\n')!1353 1354 b.set_state('remote_repo', 'proj')!1355 if _ := steal(b, mut backend, StealOptions{}) {1356 assert false, 'steal overwrote a folder with unsaved files'1357 } else {1358 assert err.msg() == no_saves_here1359 }1360 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1361 1362 os.rm(os.join_path(b_root, 'notes.txt'))!1363 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1364 steal(b, mut backend, StealOptions{})!1365 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1366}1367136813691370fn test_steal_reports_incoming_flagged_saves() {1371 base := scratch('flag_warn')!1372 defer {1373 os.rmdir_all(base) or {}1374 }1375 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1376 mut a := open_repo(machine('flag_warn_a')!)!1377 defer {1378 a.close()1379 }1380 mut b := open_repo(machine('flag_warn_b')!)!1381 defer {1382 b.close()1383 }1384 put(a.root, 'a.txt', 'one\n')!1385 make_save(a, 'fine')!1386 offer_now(a, mut backend)!1387 join(b, mut backend)!1388 put(a.root, 'a.txt', 'two\n')!1389 suspect := make_save(a, 'suspect')!1390 put(a.root, 'a.txt', 'three\n')!1391 make_save(a, 'also fine')!1392 events.flag(a, suspect.id, 'broke the build')!1393 offer_now(a, mut backend)!1394 report := steal_now(b, mut backend)!1395 assert report.flagged.map(it.save_id) == [suspect.id]1396 assert report.flagged[0].reason == 'broke the build'1397}139813991400fn head_of(store &storage.Store) history.Save {1401 return history.head(store) or { panic('this repo has no head') }1402}1403140414051406fn test_the_first_offer_records_the_remote_format() {1407 base := scratch('format_write')!1408 defer {1409 os.rmdir_all(base) or {}1410 }1411 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1412 mut a := open_repo(machine('format_write_a')!)!1413 defer {1414 a.close()1415 }1416 put(a.root, 'a.txt', 'one\n')!1417 make_save(a, 'first')!1418 offer_now(a, mut backend)!1419 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1420}1421142214231424fn test_a_remote_without_a_format_still_syncs() {1425 base := scratch('format_old')!1426 defer {1427 os.rmdir_all(base) or {}1428 }1429 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1430 mut a := open_repo(machine('format_old_a')!)!1431 defer {1432 a.close()1433 }1434 put(a.root, 'a.txt', 'one\n')!1435 make_save(a, 'first')!1436 offer_now(a, mut backend)!1437 1438 backend.delete(format_key('proj'))!1439 mut b := open_repo(machine('format_old_b')!)!1440 defer {1441 b.close()1442 }1443 join(b, mut backend)!1444 assert head_of(b).id == head_of(a).id1445 put(a.root, 'a.txt', 'two\n')!1446 make_save(a, 'second')!1447 offer_now(a, mut backend)!1448 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1449 report := steal_now(b, mut backend)!1450 assert report.head == head_of(a).id1451}1452145314541455fn test_an_unknown_remote_format_is_refused() {1456 base := scratch('format_unknown')!1457 defer {1458 os.rmdir_all(base) or {}1459 }1460 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1461 mut a := open_repo(machine('format_bad_a')!)!1462 defer {1463 a.close()1464 }1465 put(a.root, 'a.txt', 'one\n')!1466 make_save(a, 'first')!1467 offer_now(a, mut backend)!1468 backend.delete(format_key('proj'))!1469 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1470 if _ := offer_now(a, mut backend) {1471 assert false, 'offer must refuse a format it does not speak'1472 } else {1473 assert err.msg().contains('koh-remote 2')1474 }1475 if _ := steal_now(a, mut backend) {1476 assert false, 'steal must refuse a format it does not speak'1477 } else {1478 assert err.msg().contains('koh-remote 2')1479 }1480 if _ := fetch_repo(mut backend, 'proj') {1481 assert false, 'a steal must refuse a format it does not speak'1482 } else {1483 assert err.msg().contains('koh-remote 2')1484 }1485}14861487148814891490fn test_a_lost_format_write_is_read_back_and_refused() {1491 base := scratch('format_race')!1492 defer {1493 os.rmdir_all(base) or {}1494 }1495 mut inner := remote.new_folder(os.join_path(base, 'remote'))!1496 mut a := open_repo(machine('format_race_a')!)!1497 defer {1498 a.close()1499 }1500 put(a.root, 'a.txt', 'one\n')!1501 make_save(a, 'first')!1502 mut backend := remote.Backend(FaultyBackend{1503 inner: inner1504 race_format: true1505 })1506 if _ := offer_now(a, mut backend) {1507 assert false, 'offer must refuse the format another machine wrote first'1508 } else {1509 assert err.msg().contains('koh-remote 2')1510 }1511}