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.commit()!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_clone_imports_parents_before_children() {228 base := scratch('clone_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 ask: fn [root] (clash Clash) 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 .mine418 }419 })420 {421 assert false, 'a steal whose line moved during the question must refuse'422 } else {423 assert err.msg().contains('changed while')424 }425 426 assert head_of(b).message == 'agent saves during the question'427 report := steal(b, mut backend, StealOptions{428 mine: true429 })!430 assert report.kept_mine == 1431 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'432 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'433}434435fn test_clashing_files_are_chosen_one_at_a_time() {436 base := scratch('clash')!437 defer {438 os.rmdir_all(base) or {}439 }440 mut backend := remote.new_folder(os.join_path(base, 'remote'))!441 mut a := open_repo(machine('clash_a')!)!442 defer {443 a.close()444 }445 mut b := open_repo(machine('clash_b')!)!446 defer {447 b.close()448 }449 put(a.root, 'same.txt', 'base\n')!450 put(a.root, 'other.txt', 'quiet\n')!451 make_save(a, 'base')!452 offer_now(a, mut backend)!453 join(b, mut backend)!454455 456 put(a.root, 'same.txt', 'A version\n')!457 make_save(a, 'A edits same')!458 offer_now(a, mut backend)!459 put(b.root, 'same.txt', 'B version\n')!460 make_save(b, 'B edits same')!461462 463 if _ := steal(b, mut backend, StealOptions{}) {464 assert false, 'an undecided clash must be refused'465 } else {466 assert err.msg().contains('--mine') || err.msg().contains('--theirs')467 468 if err is ClashError {469 assert err.paths == ['same.txt']470 } else {471 assert false, 'an undecided clash must be a ClashError, not ${err}'472 }473 }474 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,475 head_of(b).id)!).len == 0476477 478 report := steal_now(b, mut backend)!479 assert report.clashes == ['same.txt']480 assert report.kept_mine == 0481 assert report.kept_versions == ['same.txt:${report.previous_head}']482 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'483 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'484}485486fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {487 base := scratch('guided_cancel')!488 defer { os.rmdir_all(base) or {} }489 mut backend := remote.new_folder(os.join_path(base, 'remote'))!490 mut a := open_repo(machine('guided_cancel_a')!)!491 defer { a.close() }492 mut b := open_repo(machine('guided_cancel_b')!)!493 defer { b.close() }494 put(a.root, 'same.txt', 'base\n')!495 make_save(a, 'base')!496 offer_now(a, mut backend)!497 join(b, mut backend)!498 put(a.root, 'same.txt', 'remote\n')!499 remote_head := make_save(a, 'remote change')!500 offer_now(a, mut backend)!501 put(b.root, 'same.txt', 'mine\n')!502 local_head := make_save(b, 'local change')!503504 if _ := steal(b, mut backend, StealOptions{505 guide: fn (preview StealPreview) !map[string]Side {506 assert preview.clashes.len == 1507 return error('cancelled')508 }509 }) {510 assert false, 'the guide cancelled'511 } else {512 assert err.msg() == 'cancelled'513 }514 assert head_of(b).id == local_head.id515 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'516 assert history.by_id(b, remote_head.id) != none517 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1518}519520fn test_choosing_mine_keeps_the_local_version() {521 base := scratch('mine')!522 defer {523 os.rmdir_all(base) or {}524 }525 mut backend := remote.new_folder(os.join_path(base, 'remote'))!526 mut a := open_repo(machine('mine_a')!)!527 defer {528 a.close()529 }530 mut b := open_repo(machine('mine_b')!)!531 defer {532 b.close()533 }534 put(a.root, 'same.txt', 'base\n')!535 make_save(a, 'base')!536 offer_now(a, mut backend)!537 join(b, mut backend)!538 put(a.root, 'same.txt', 'A version\n')!539 make_save(a, 'A edits')!540 offer_now(a, mut backend)!541 put(b.root, 'same.txt', 'B version\n')!542 make_save(b, 'B edits')!543544 report := steal(b, mut backend, StealOptions{545 mine: true546 })!547 assert report.kept_mine == 1548 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'549 550 assert head_of(b).message.contains('combine: kept mine for 1 file')551 552 assert head_of(b).restores == ''553 assert history.all_saves(b)!.len >= 4554}555556fn test_flags_travel_with_the_saves() {557 base := scratch('flags')!558 defer {559 os.rmdir_all(base) or {}560 }561 mut backend := remote.new_folder(os.join_path(base, 'remote'))!562 mut a := open_repo(machine('flag_a')!)!563 defer {564 a.close()565 }566 mut b := open_repo(machine('flag_b')!)!567 defer {568 b.close()569 }570 put(a.root, 'a.txt', 'one\n')!571 save := make_save(a, 'suspect save')!572 events.flag(a, save.id, 'look at this')!573 offer_now(a, mut backend)!574575 576 fetched := fetch_back_to_known(b, mut backend, 'proj')!577 assert fetched.events_out.len == 1578 assert fetched.events_out[0].kind == events.kind_flag579 b.begin()!580 write_fetched(b, fetched)!581 history.set_head(b, fetched.head)!582 b.commit()!583 state := events.flag_state(b, save.id)!584 assert state.flagged585 assert state.reason == 'look at this'586}587588fn test_a_second_offer_racing_head_is_refused_not_forced() {589 base := scratch('race')!590 defer {591 os.rmdir_all(base) or {}592 }593 mut backend := remote.new_folder(os.join_path(base, 'remote'))!594 mut a := open_repo(machine('race_a')!)!595 defer {596 a.close()597 }598 put(a.root, 'a.txt', 'one\n')!599 make_save(a, 'base')!600 offer_now(a, mut backend)!601602 603 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),604 backend.get(head_key('proj'))!.tag)!605606 put(a.root, 'a.txt', 'two\n')!607 make_save(a, 'second')!608 if _ := offer_now(a, mut backend) {609 assert false, 'offering while the remote head moved must be refused'610 } else {611 assert err.msg().contains('run koh steal')612 }613}614615616fn join(store &storage.Store, mut backend remote.Backend) ! {617 fetched := fetch_repo(mut backend, 'proj')!618 store.begin()!619 write_fetched(store, fetched)!620 history.set_head(store, fetched.head)!621 store.commit()!622 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,623 fetched.head)!, [])!624 store.set_state('remote_repo', 'proj')!625}626627struct FaultyBackend {628mut:629 inner remote.Backend630 writes int631 reads int632 fail_write int 633 fail_read int634 635 fail_delete bool636 637 638 race_format bool639 raced bool640}641642fn (mut f FaultyBackend) get(key string) !remote.Object {643 f.reads++644 if f.fail_read > 0 && f.reads >= f.fail_read {645 return error('injected read failure')646 }647 return f.inner.get(key)!648}649650fn (mut f FaultyBackend) list(prefix string) ![]string {651 return f.inner.list(prefix)!652}653654fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {655 f.writes++656 if f.fail_write > 0 && f.writes >= f.fail_write {657 return error('injected write failure')658 }659 if f.race_format && !f.raced && key.ends_with('/format') {660 f.raced = true661 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!662 return false663 }664 return f.inner.put_if_absent(key, body)!665}666667fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {668 f.writes++669 if f.fail_write > 0 && f.writes >= f.fail_write {670 return error('injected write failure')671 }672 return f.inner.put_if_match(key, body, tag)!673}674675fn (mut f FaultyBackend) delete(key string) ! {676 if f.fail_delete {677 return error('injected delete failure')678 }679 f.inner.delete(key)!680}681682683684685fn test_an_offer_sends_only_the_objects_a_save_adds() {686 base := scratch('offer_diff')!687 defer {688 os.rmdir_all(base) or {}689 }690 mut a := open_repo(machine('offer_diff_a')!)!691 defer {692 a.close()693 }694 for i in 0 .. 20 {695 put(a.root, 'f${i}.txt', 'file ${i}\n')!696 }697 make_save(a, 'twenty files')!698 mut backend := FaultyBackend{699 inner: remote.new_folder(os.join_path(base, 'remote'))!700 }701 offer(a, mut backend)!702 put(a.root, 'f3.txt', 'changed\n')!703 make_save(a, 'one change')!704 before := backend.writes705 report := offer(a, mut backend)!706 assert report.objects == 1707 708 assert backend.writes - before == 3709}710711fn test_a_failed_offer_is_safe_to_re_run() {712 base := scratch('fault_offer')!713 defer {714 os.rmdir_all(base) or {}715 }716 remote_dir := os.join_path(base, 'remote')717 mut a := open_repo(machine('fault_a')!)!718 defer {719 a.close()720 }721 put(a.root, 'a.txt', 'one\n')!722 make_save(a, 'first')!723 put(a.root, 'a.txt', 'two\n')!724 make_save(a, 'second')!725726 727 mut flaky := FaultyBackend{728 inner: remote.new_folder(remote_dir)!729 fail_write: 2730 }731 if _ := offer(a, mut flaky) {732 assert false, 'the injected failure should have surfaced'733 }734735 736 737 mut healthy := remote.new_folder(remote_dir)!738 report := offer(a, mut healthy)!739 assert report.saves == 2740 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id741 assert healthy.list('proj/saves')!.len == 2742}743744fn test_a_failed_steal_leaves_nothing_behind() {745 base := scratch('fault_steal')!746 defer {747 os.rmdir_all(base) or {}748 }749 remote_dir := os.join_path(base, 'remote')750 mut a := open_repo(machine('fault_s_a')!)!751 defer {752 a.close()753 }754 mut b := open_repo(machine('fault_s_b')!)!755 defer {756 b.close()757 }758 put(a.root, 'a.txt', 'from A\n')!759 make_save(a, 'A writes')!760 mut healthy := remote.new_folder(remote_dir)!761 offer(a, mut healthy)!762763 764 mut flaky := FaultyBackend{765 inner: remote.new_folder(remote_dir)!766 fail_read: 2767 }768 if _ := fetch_back_to_known(b, mut flaky, 'proj') {769 assert false, 'the injected read failure should have surfaced'770 }771 772 assert history.head(b) == none773 assert b.object_count()! == 0774775 776 fetched := fetch_back_to_known(b, mut healthy, 'proj')!777 b.begin()!778 write_fetched(b, fetched)!779 history.set_head(b, fetched.head)!780 b.commit()!781 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!782 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'783}784785fn test_an_interrupted_strip_is_idempotent() {786 787 788 789 base := scratch('fault_strip')!790 defer {791 os.rmdir_all(base) or {}792 }793 mut a := open_repo(machine('fault_strip_a')!)!794 defer {795 a.close()796 }797 put(a.root, 'secret.txt', 'shh\n')!798 put(a.root, 'keep.txt', 'fine\n')!799 save := make_save(a, 'add both')!800801 for _ in 0 .. 2 {802 strip_local(a, 'secret.txt', '')!803 }804 files := history.files_of(a, save.id)!805 806 807 assert files.map(it.path) == ['.kohignore', 'keep.txt']808 assert events.strip_events(a)!.len == 1809}810811812813814815fn test_a_strip_matches_the_path_exactly() {816 mut a := open_repo(machine('strip_exact')!)!817 defer {818 a.close()819 }820 hash := a.put_object('x\n'.bytes())!821 mut files := []snapshot.FileEntry{}822 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {823 files << snapshot.FileEntry{824 path: path825 hash: hash826 }827 }828 save := history.save_tree(a, '', files, history.SaveInput{829 message: 'add all'830 })!831 assert strip_scope(a, 'docs', '')!.files == 1832 strip_local(a, 'docs', '')!833 strip_local(a, 'a_b', '')!834 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']835}836837fn test_a_strip_reaches_the_remote_and_other_machines() {838 base := scratch('strip_sync')!839 defer {840 os.rmdir_all(base) or {}841 }842 mut a := open_repo(machine('strip_a')!)!843 defer {844 a.close()845 }846 mut b := open_repo(machine('strip_b')!)!847 defer {848 b.close()849 }850 put(a.root, 'secrets.env', 'api_key=1\n')!851 put(a.root, 'keep.txt', 'fine\n')!852 save := make_save(a, 'add both')!853 mut backend := remote.new_folder(os.join_path(base, 'remote'))!854 offer_now(a, mut backend)!855 join(b, mut backend)!856 assert b.object_count()! > 0857858 859 860 mut secret_hash := ''861 for file in history.files_of(a, save.id)! {862 if file.path == 'secrets.env' {863 secret_hash = file.hash864 }865 }866 assert secret_hash != ''867 strip_local(a, 'secrets.env', '')!868 report := push_strip(a, mut backend, 'secrets.env')!869 assert report.event_sent870 assert report.records == 1871872 873 874 record := decode_save(backend.get(save_key('proj', save.id))!.body)!875 for file in record.files {876 assert file.path != 'secrets.env'877 }878 879 880 assert !backend.get(object_key('proj', secret_hash))!.found881 assert backend.list('proj/objects')!.len == 2882883 884 b.begin()!885 fetched := fetch_back_to_known(b, mut backend, 'proj')!886 write_fetched(b, fetched)!887 b.commit()!888 mut paths := []string{}889 for file in history.files_of(b, save.id)! {890 paths << file.path891 }892 assert 'secrets.env' !in paths893 assert 'keep.txt' in paths894895 896 897 898 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)899 put(b.root, 'secrets.env', 'rotated\n')!900 readded := make_save(b, 'add the rotated key back')!901 offer_now(b, mut backend)!902 assert backend.list('proj/events')!.len == 1903 b.begin()!904 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!905 b.commit()!906 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)907}908909910911912fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {913 base := scratch('strip_clone')!914 defer {915 os.rmdir_all(base) or {}916 }917 mut backend := remote.new_folder(os.join_path(base, 'remote'))!918 mut a := open_repo(machine('strip_clone_a')!)!919 defer {920 a.close()921 }922 put(a.root, 'secrets.env', 'api_key=1\n')!923 old := make_save(a, 'leak the key')!924 offer_now(a, mut backend)!925 strip_local(a, 'secrets.env', '')!926 push_strip(a, mut backend, 'secrets.env')!927 928 put(a.root, 'secrets.env', 'api_key=2\n')!929 rotated := history.save_tree(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{930 message: 'rotate the key'931 created_at: events.strip_events(a)!.last().created_at + 60932 })!933 offer_now(a, mut backend)!934935 mut c := open_repo(machine('strip_clone_c')!)!936 defer {937 c.close()938 }939 join(c, mut backend)!940 assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)941 assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)942 assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'943}944945946947948fn test_an_interrupted_remote_strip_finishes_when_re_run() {949 base := scratch('strip_resume')!950 defer {951 os.rmdir_all(base) or {}952 }953 remote_dir := os.join_path(base, 'remote')954 mut backend := remote.new_folder(remote_dir)!955 mut a := open_repo(machine('resume_a')!)!956 defer {957 a.close()958 }959 mut b := open_repo(machine('resume_b')!)!960 defer {961 b.close()962 }963 put(a.root, 'secrets.env', 'api_key=1\n')!964 put(a.root, 'shared.env', 'same bytes on B\n')!965 save := make_save(a, 'add secrets')!966 offer_now(a, mut backend)!967 join(b, mut backend)!968 put(b.root, 'copy.txt', 'same bytes on B\n')!969 b_save := make_save(b, 'B keeps a copy')!970 offer_now(b, mut backend)!971 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())972 secret_hash := storage.hash_hex('api_key=1\n'.bytes())973974 strip_local(a, 'secrets.env', '')!975 976 977 mut flaky := FaultyBackend{978 inner: remote.new_folder(remote_dir)!979 fail_write: 3980 }981 if _ := push_strip(a, mut flaky, 'secrets.env') {982 assert false, 'the injected failure should have surfaced'983 }984 assert strip_pending(a, 'secrets.env')985 assert strip_local(a, 'secrets.env', '')!.files_removed == 0986 report := push_strip(a, mut backend, 'secrets.env')!987 assert report.records == 1988 assert !strip_pending(a, 'secrets.env')989 assert !backend.get(object_key('proj', secret_hash))!.found990 record := decode_save(backend.get(save_key('proj', save.id))!.body)!991 assert 'secrets.env' !in record.files.map(it.path)992 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!993 assert 'secrets.env' !in b_record.files.map(it.path)994995 996 strip_local(a, 'shared.env', '')!997 push_strip(a, mut backend, 'shared.env')!998 assert backend.get(object_key('proj', shared_hash))!.found999}1000100110021003fn test_offer_waits_for_an_unapplied_strip() {1004 base := scratch('strip_first')!1005 defer {1006 os.rmdir_all(base) or {}1007 }1008 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1009 mut a := open_repo(machine('strip_first_a')!)!1010 defer {1011 a.close()1012 }1013 mut b := open_repo(machine('strip_first_b')!)!1014 defer {1015 b.close()1016 }1017 put(a.root, 'secrets.env', 'api_key=1\n')!1018 make_save(a, 'add secrets')!1019 offer_now(a, mut backend)!1020 join(b, mut backend)!1021 strip_local(a, 'secrets.env', '')!1022 push_strip(a, mut backend, 'secrets.env')!1023 put(b.root, 'more.txt', 'B keeps working\n')!1024 later := make_save(b, 'B saves with the secret still in its tree')!1025 if _ := offer_now(b, mut backend) {1026 assert false, 'B offered records that still list the stripped path'1027 }1028 steal_now(b, mut backend)!1029 offer_now(b, mut backend)!1030 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1031 assert 'secrets.env' !in record.files.map(it.path)1032}1033103410351036fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {1037 base := scratch('strip_twice')!1038 defer {1039 os.rmdir_all(base) or {}1040 }1041 remote_dir := os.join_path(base, 'remote')1042 mut backend := remote.new_folder(remote_dir)!1043 mut a := open_repo(machine('strip_twice_a')!)!1044 defer {1045 a.close()1046 }1047 put(a.root, 'secrets.env', 'first key\n')!1048 make_save(a, 'first key')!1049 offer_now(a, mut backend)!1050 strip_local(a, 'secrets.env', '')!1051 mut flaky := FaultyBackend{1052 inner: remote.new_folder(remote_dir)!1053 fail_delete: true1054 }1055 if _ := push_strip(a, mut flaky, 'secrets.env') {1056 assert false, 'the injected delete failure should have surfaced'1057 }1058 put(a.root, 'secrets.env', 'second key\n')!1059 make_save(a, 'second key')!1060 offer_now(a, mut backend)!1061 strip_local(a, 'secrets.env', '')!1062 push_strip(a, mut backend, 'secrets.env')!1063 for key in ['first key\n', 'second key\n'] {1064 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1065 }1066}106710681069fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1070 base := scratch('strip_scopes')!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('strip_scopes_a')!)!1077 defer {1078 a.close()1079 }1080 put(a.root, 'secrets.env', 'first key\n')!1081 first := make_save(a, 'first key')!1082 put(a.root, 'secrets.env', 'second key\n')!1083 second := make_save(a, 'second key')!1084 offer_now(a, mut backend)!1085 strip_local(a, 'secrets.env', first.id)!1086 1087 mut flaky := FaultyBackend{1088 inner: remote.new_folder(remote_dir)!1089 fail_write: 21090 }1091 if _ := push_strip(a, mut flaky, 'secrets.env') {1092 assert false, 'the injected failure should have surfaced'1093 }1094 strip_local(a, 'secrets.env', second.id)!1095 push_strip(a, mut backend, 'secrets.env')!1096 assert !strip_pending(a, 'secrets.env')1097 for save in [first, second] {1098 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1099 assert 'secrets.env' !in record.files.map(it.path)1100 }1101 for key in ['first key\n', 'second key\n'] {1102 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1103 }1104}1105110611071108fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1109 base := scratch('strip_again')!1110 defer {1111 os.rmdir_all(base) or {}1112 }1113 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1114 mut a := open_repo(machine('strip_again_a')!)!1115 defer {1116 a.close()1117 }1118 mut b := open_repo(machine('strip_again_b')!)!1119 defer {1120 b.close()1121 }1122 put(a.root, 'secrets.env', 'first key\n')!1123 make_save(a, 'first key')!1124 offer_now(a, mut backend)!1125 join(b, mut backend)!1126 strip_local(a, 'secrets.env', '')!1127 push_strip(a, mut backend, 'secrets.env')!1128 steal_now(b, mut backend)!1129 1130 os.rm(os.join_path(b.root, 'secrets.env'))!1131 put(a.root, 'secrets.env', 'second key\n')!1132 second := make_save(a, 'second key')!1133 offer_now(a, mut backend)!1134 steal_now(b, mut backend)!1135 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1136 strip_local(a, 'secrets.env', '')!1137 push_strip(a, mut backend, 'secrets.env')!1138 steal_now(b, mut backend)!1139 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1140}1141114211431144fn test_a_stripped_path_is_not_asked_about() {1145 base := scratch('clash_strip')!1146 defer {1147 os.rmdir_all(base) or {}1148 }1149 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1150 mut a := open_repo(machine('clash_strip_a')!)!1151 defer {1152 a.close()1153 }1154 mut b := open_repo(machine('clash_strip_b')!)!1155 defer {1156 b.close()1157 }1158 put(a.root, 'secrets.env', 'key one\n')!1159 make_save(a, 'base')!1160 offer_now(a, mut backend)!1161 join(b, mut backend)!1162 put(a.root, 'secrets.env', 'key two\n')!1163 make_save(a, 'A rotates')!1164 offer_now(a, mut backend)!1165 strip_local(a, 'secrets.env', '')!1166 push_strip(a, mut backend, 'secrets.env')!1167 put(b.root, 'secrets.env', 'key three\n')!1168 mine := make_save(b, 'B rotates')!1169 report := steal(b, mut backend, StealOptions{})!1170 assert report.clashes.len == 01171 for save in history.all_saves(b)! {1172 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1173 }1174 assert history.by_id(b, mine.id) != none1175}1176117711781179fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1180 base := scratch('clash_base_strip')!1181 defer {1182 os.rmdir_all(base) or {}1183 }1184 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1185 mut a := open_repo(machine('base_strip_a')!)!1186 defer {1187 a.close()1188 }1189 mut b := open_repo(machine('base_strip_b')!)!1190 defer {1191 b.close()1192 }1193 put(a.root, 'notes.txt', 'shared\n')!1194 common := make_save(a, 'base')!1195 offer_now(a, mut backend)!1196 join(b, mut backend)!1197 put(a.root, 'other.txt', 'A moves on\n')!1198 make_save(a, 'A moves on')!1199 offer_now(a, mut backend)!1200 strip_local(a, 'notes.txt', common.id)!1201 push_strip(a, mut backend, 'notes.txt')!1202 put(b.root, 'notes.txt', 'B edits\n')!1203 make_save(b, 'B edits notes')!1204 if _ := steal(b, mut backend, StealOptions{}) {1205 assert false, 'with the base stripped, notes.txt differs on both sides'1206 } else {1207 assert err.msg().contains('notes.txt changed on both machines')1208 }1209 report := steal(b, mut backend, StealOptions{1210 mine: true1211 })!1212 assert report.clashes == ['notes.txt']1213 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1214}1215121612171218fn test_steal_into_a_folder_with_unsaved_files_refuses() {1219 base := scratch('steal_unsaved')!1220 defer {1221 os.rmdir_all(base) or {}1222 }1223 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1224 mut a := open_repo(machine('unsaved_a')!)!1225 defer {1226 a.close()1227 }1228 put(a.root, 'notes.txt', 'from A\n')!1229 make_save(a, 'A writes')!1230 offer_now(a, mut backend)!1231 b_root := machine('unsaved_b')!1232 mut b := storage.create(b_root)!1233 defer {1234 b.close()1235 }1236 put(b_root, 'notes.txt', 'never saved\n')!1237 1238 b.set_state('remote_repo', 'proj')!1239 if _ := steal(b, mut backend, StealOptions{}) {1240 assert false, 'steal overwrote a folder with unsaved files'1241 } else {1242 assert err.msg() == no_saves_here1243 }1244 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1245 1246 os.rm(os.join_path(b_root, 'notes.txt'))!1247 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1248 steal(b, mut backend, StealOptions{})!1249 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1250}1251125212531254fn test_steal_reports_incoming_flagged_saves() {1255 base := scratch('flag_warn')!1256 defer {1257 os.rmdir_all(base) or {}1258 }1259 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1260 mut a := open_repo(machine('flag_warn_a')!)!1261 defer {1262 a.close()1263 }1264 mut b := open_repo(machine('flag_warn_b')!)!1265 defer {1266 b.close()1267 }1268 put(a.root, 'a.txt', 'one\n')!1269 make_save(a, 'fine')!1270 offer_now(a, mut backend)!1271 join(b, mut backend)!1272 put(a.root, 'a.txt', 'two\n')!1273 suspect := make_save(a, 'suspect')!1274 put(a.root, 'a.txt', 'three\n')!1275 make_save(a, 'also fine')!1276 events.flag(a, suspect.id, 'broke the build')!1277 offer_now(a, mut backend)!1278 report := steal_now(b, mut backend)!1279 assert report.flagged.map(it.save_id) == [suspect.id]1280 assert report.flagged[0].reason == 'broke the build'1281}128212831284fn head_of(store &storage.Store) history.Save {1285 return history.head(store) or { panic('this repo has no head') }1286}1287128812891290fn test_the_first_offer_records_the_remote_format() {1291 base := scratch('format_write')!1292 defer {1293 os.rmdir_all(base) or {}1294 }1295 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1296 mut a := open_repo(machine('format_write_a')!)!1297 defer {1298 a.close()1299 }1300 put(a.root, 'a.txt', 'one\n')!1301 make_save(a, 'first')!1302 offer_now(a, mut backend)!1303 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1304}1305130613071308fn test_a_remote_without_a_format_still_syncs() {1309 base := scratch('format_old')!1310 defer {1311 os.rmdir_all(base) or {}1312 }1313 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1314 mut a := open_repo(machine('format_old_a')!)!1315 defer {1316 a.close()1317 }1318 put(a.root, 'a.txt', 'one\n')!1319 make_save(a, 'first')!1320 offer_now(a, mut backend)!1321 1322 backend.delete(format_key('proj'))!1323 mut b := open_repo(machine('format_old_b')!)!1324 defer {1325 b.close()1326 }1327 join(b, mut backend)!1328 assert head_of(b).id == head_of(a).id1329 put(a.root, 'a.txt', 'two\n')!1330 make_save(a, 'second')!1331 offer_now(a, mut backend)!1332 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1333 report := steal_now(b, mut backend)!1334 assert report.head == head_of(a).id1335}1336133713381339fn test_an_unknown_remote_format_is_refused() {1340 base := scratch('format_unknown')!1341 defer {1342 os.rmdir_all(base) or {}1343 }1344 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1345 mut a := open_repo(machine('format_bad_a')!)!1346 defer {1347 a.close()1348 }1349 put(a.root, 'a.txt', 'one\n')!1350 make_save(a, 'first')!1351 offer_now(a, mut backend)!1352 backend.delete(format_key('proj'))!1353 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1354 if _ := offer_now(a, mut backend) {1355 assert false, 'offer must refuse a format it does not speak'1356 } else {1357 assert err.msg().contains('koh-remote 2')1358 }1359 if _ := steal_now(a, mut backend) {1360 assert false, 'steal must refuse a format it does not speak'1361 } else {1362 assert err.msg().contains('koh-remote 2')1363 }1364 if _ := fetch_repo(mut backend, 'proj') {1365 assert false, 'a clone must refuse a format it does not speak'1366 } else {1367 assert err.msg().contains('koh-remote 2')1368 }1369}13701371137213731374fn test_a_lost_format_write_is_read_back_and_refused() {1375 base := scratch('format_race')!1376 defer {1377 os.rmdir_all(base) or {}1378 }1379 mut inner := remote.new_folder(os.join_path(base, 'remote'))!1380 mut a := open_repo(machine('format_race_a')!)!1381 defer {1382 a.close()1383 }1384 put(a.root, 'a.txt', 'one\n')!1385 make_save(a, 'first')!1386 mut backend := remote.Backend(FaultyBackend{1387 inner: inner1388 race_format: true1389 })1390 if _ := offer_now(a, mut backend) {1391 assert false, 'offer must refuse the format another machine wrote first'1392 } else {1393 assert err.msg().contains('koh-remote 2')1394 }1395}