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_a_clone_imports_parents_before_children() {166 base := scratch('clone_order')!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('order_a')!)!172 defer {173 a.close()174 }175 put(a.root, 'a.txt', 'one\n')!176 parent := commit(a, 'parent')!177 child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{178 message: 'replayed, older than its parent'179 created_at: parent.created_at - 3600180 })!181 offer_now(a, mut backend)!182 mut b := open_repo(machine('order_b')!)!183 defer {184 b.close()185 }186 join(b, mut backend)!187 assert history.head(b)!.id == child.id188}189190191fn test_steal_refuses_an_unsaved_executable_bit_change() {192 base := scratch('chmod')!193 defer {194 os.rmdir_all(base) or {}195 }196 mut backend := remote.new_folder(os.join_path(base, 'remote'))!197 mut a := open_repo(machine('chmod_a')!)!198 defer {199 a.close()200 }201 put(a.root, 'run.sh', 'echo hi\n')!202 commit(a, 'script')!203 offer_now(a, mut backend)!204 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!205 if _ := steal_now(a, mut backend) {206 assert false, 'a chmod-only change should make the tree dirty'207 } else {208 assert err.msg() == dirty_tree209 }210}211212fn test_divergence_without_clashes_keeps_both_machines_work() {213 base := scratch('diverge')!214 defer {215 os.rmdir_all(base) or {}216 }217 mut backend := remote.new_folder(os.join_path(base, 'remote'))!218 mut a := open_repo(machine('div_a')!)!219 defer {220 a.close()221 }222 mut b := open_repo(machine('div_b')!)!223 defer {224 b.close()225 }226 put(a.root, 'shared.txt', 'base\n')!227 commit(a, 'base')!228 offer_now(a, mut backend)!229230 231 join(b, mut backend)!232 put(a.root, 'fromA.txt', 'A\n')!233 commit(a, 'A adds a file')!234 put(b.root, 'fromB.txt', 'B\n')!235 commit(b, 'B adds a file')!236 offer_now(a, mut backend)!237238 239 if _ := offer_now(b, mut backend) {240 assert false, 'offering on top of unknown remote saves should be refused'241 } else {242 assert err.msg().contains('run koh steal')243 }244245 report := steal_now(b, mut backend)!246 assert !report.forward247 assert report.replayed == 1248 assert report.clashes.len == 0249 250 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'251 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'252 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'253 254 mut saw_replay := false255 for save in history.all_saves(b)! {256 if save.replayed_from != '' {257 saw_replay = true258 }259 }260 assert saw_replay261 262 offer_now(b, mut backend)!263 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()264 assert line_head == history.head(b)!.id265}266267268269270271fn test_a_local_change_undone_does_not_block_a_combine() {272 base := scratch('undone')!273 defer {274 os.rmdir_all(base) or {}275 }276 mut backend := remote.new_folder(os.join_path(base, 'remote'))!277 mut a := open_repo(machine('undone_a')!)!278 defer {279 a.close()280 }281 mut b := open_repo(machine('undone_b')!)!282 defer {283 b.close()284 }285 put(a.root, 'f.txt', 'base\n')!286 put(a.root, 'g.txt', 'base\n')!287 commit(a, 'base')!288 offer_now(a, mut backend)!289 join(b, mut backend)!290291 put(a.root, 'f.txt', 'from A\n')!292 put(a.root, 'g.txt', 'same\n')!293 commit(a, 'A edits both')!294 offer_now(a, mut backend)!295296 put(b.root, 'f.txt', 'tried on B\n')!297 put(b.root, 'g.txt', 'first try\n')!298 commit(b, 'B tries')!299 put(b.root, 'f.txt', 'base\n')!300 put(b.root, 'g.txt', 'same\n')!301 put(b.root, 'h.txt', 'B\n')!302 commit(b, 'B undoes f, lands on the same g, adds h')!303304 report := steal_now(b, mut backend)!305 assert report.clashes.len == 0306 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'307 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'308 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'309}310311312313fn test_clash_questions_are_asked_before_the_write_lock() {314 base := scratch('ask_unlocked')!315 defer {316 os.rmdir_all(base) or {}317 }318 mut backend := remote.new_folder(os.join_path(base, 'remote'))!319 mut a := open_repo(machine('ask_a')!)!320 defer {321 a.close()322 }323 mut b := open_repo(machine('ask_b')!)!324 defer {325 b.close()326 }327 put(a.root, 'same.txt', 'base\n')!328 commit(a, 'base')!329 offer_now(a, mut backend)!330 join(b, mut backend)!331 put(a.root, 'same.txt', 'A version\n')!332 commit(a, 'A edits same')!333 offer_now(a, mut backend)!334 put(b.root, 'same.txt', 'B version\n')!335 commit(b, 'B edits same')!336 root := b.root337 338 if _ := steal(b, mut backend, StealOptions{339 ask: fn [root] (clash Clash) Side {340 other := storage.open(root) or { panic(err) }341 defer {342 other.close()343 }344 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }345 history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{346 message: 'agent saves during the question'347 }) or { panic('steal held the write lock while asking: ${err}') }348 return .mine349 }350 })351 {352 assert false, 'a steal whose line moved during the question must refuse'353 } else {354 assert err.msg().contains('changed while')355 }356 357 assert history.head(b)!.message == 'agent saves during the question'358 report := steal(b, mut backend, StealOptions{359 mine: true360 })!361 assert report.kept_mine == 1362 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'363 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'364}365366fn test_clashing_files_are_chosen_one_at_a_time() {367 base := scratch('clash')!368 defer {369 os.rmdir_all(base) or {}370 }371 mut backend := remote.new_folder(os.join_path(base, 'remote'))!372 mut a := open_repo(machine('clash_a')!)!373 defer {374 a.close()375 }376 mut b := open_repo(machine('clash_b')!)!377 defer {378 b.close()379 }380 put(a.root, 'same.txt', 'base\n')!381 put(a.root, 'other.txt', 'quiet\n')!382 commit(a, 'base')!383 offer_now(a, mut backend)!384 join(b, mut backend)!385386 387 put(a.root, 'same.txt', 'A version\n')!388 commit(a, 'A edits same')!389 offer_now(a, mut backend)!390 put(b.root, 'same.txt', 'B version\n')!391 commit(b, 'B edits same')!392393 394 if _ := steal(b, mut backend, StealOptions{}) {395 assert false, 'an undecided clash must be refused'396 } else {397 assert err.msg().contains('--mine') || err.msg().contains('--theirs')398 }399 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,400 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0401402 403 report := steal_now(b, mut backend)!404 assert report.clashes == ['same.txt']405 assert report.kept_mine == 0406 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'407 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'408}409410fn test_choosing_mine_keeps_the_local_version() {411 base := scratch('mine')!412 defer {413 os.rmdir_all(base) or {}414 }415 mut backend := remote.new_folder(os.join_path(base, 'remote'))!416 mut a := open_repo(machine('mine_a')!)!417 defer {418 a.close()419 }420 mut b := open_repo(machine('mine_b')!)!421 defer {422 b.close()423 }424 put(a.root, 'same.txt', 'base\n')!425 commit(a, 'base')!426 offer_now(a, mut backend)!427 join(b, mut backend)!428 put(a.root, 'same.txt', 'A version\n')!429 commit(a, 'A edits')!430 offer_now(a, mut backend)!431 put(b.root, 'same.txt', 'B version\n')!432 commit(b, 'B edits')!433434 report := steal(b, mut backend, StealOptions{435 mine: true436 })!437 assert report.kept_mine == 1438 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'439 440 assert history.head(b)!.message.contains('combine: kept mine for 1 file')441 442 assert history.head(b)!.restores == ''443 assert history.all_saves(b)!.len >= 4444}445446fn test_flags_travel_with_the_saves() {447 base := scratch('flags')!448 defer {449 os.rmdir_all(base) or {}450 }451 mut backend := remote.new_folder(os.join_path(base, 'remote'))!452 mut a := open_repo(machine('flag_a')!)!453 defer {454 a.close()455 }456 mut b := open_repo(machine('flag_b')!)!457 defer {458 b.close()459 }460 put(a.root, 'a.txt', 'one\n')!461 save := commit(a, 'suspect save')!462 events.flag(a, save.id, 'look at this')!463 offer_now(a, mut backend)!464465 466 fetched := fetch_back_to_known(b, mut backend, 'proj')!467 assert fetched.events_out.len == 1468 assert fetched.events_out[0].kind == events.kind_flag469 b.begin()!470 write_fetched(b, fetched)!471 history.set_head(b, fetched.head)!472 b.commit()!473 state := events.flag_state(b, save.id)!474 assert state.flagged475 assert state.reason == 'look at this'476}477478fn test_a_second_offer_racing_head_is_refused_not_forced() {479 base := scratch('race')!480 defer {481 os.rmdir_all(base) or {}482 }483 mut backend := remote.new_folder(os.join_path(base, 'remote'))!484 mut a := open_repo(machine('race_a')!)!485 defer {486 a.close()487 }488 put(a.root, 'a.txt', 'one\n')!489 commit(a, 'base')!490 offer_now(a, mut backend)!491492 493 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),494 backend.get(head_key('proj'))!.tag)!495496 put(a.root, 'a.txt', 'two\n')!497 commit(a, 'second')!498 if _ := offer_now(a, mut backend) {499 assert false, 'offering while the remote head moved must be refused'500 } else {501 assert err.msg().contains('run koh steal')502 }503}504505506fn join(store &storage.Store, mut backend remote.Backend) ! {507 fetched := fetch_repo(mut backend, 'proj')!508 store.begin()!509 write_fetched(store, fetched)!510 history.set_head(store, fetched.head)!511 store.commit()!512 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,513 fetched.head)!, [])!514 store.set_state('remote_repo', 'proj')!515}516517struct FaultyBackend {518mut:519 inner remote.Backend520 writes int521 reads int522 fail_write int 523 fail_read int524 525 fail_delete bool526}527528fn (mut f FaultyBackend) get(key string) !remote.Object {529 f.reads++530 if f.fail_read > 0 && f.reads >= f.fail_read {531 return error('injected read failure')532 }533 return f.inner.get(key)!534}535536fn (mut f FaultyBackend) list(prefix string) ![]string {537 return f.inner.list(prefix)!538}539540fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {541 f.writes++542 if f.fail_write > 0 && f.writes >= f.fail_write {543 return error('injected write failure')544 }545 return f.inner.put_if_absent(key, body)!546}547548fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {549 f.writes++550 if f.fail_write > 0 && f.writes >= f.fail_write {551 return error('injected write failure')552 }553 return f.inner.put_if_match(key, body, tag)!554}555556fn (mut f FaultyBackend) delete(key string) ! {557 if f.fail_delete {558 return error('injected delete failure')559 }560 f.inner.delete(key)!561}562563fn test_a_failed_offer_is_safe_to_re_run() {564 base := scratch('fault_offer')!565 defer {566 os.rmdir_all(base) or {}567 }568 remote_dir := os.join_path(base, 'remote')569 mut a := open_repo(machine('fault_a')!)!570 defer {571 a.close()572 }573 put(a.root, 'a.txt', 'one\n')!574 commit(a, 'first')!575 put(a.root, 'a.txt', 'two\n')!576 commit(a, 'second')!577578 579 mut flaky := FaultyBackend{580 inner: remote.new_folder(remote_dir)!581 fail_write: 2582 }583 if _ := offer(a, mut flaky) {584 assert false, 'the injected failure should have surfaced'585 }586587 588 589 mut healthy := remote.new_folder(remote_dir)!590 report := offer(a, mut healthy)!591 assert report.saves == 2592 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id593 assert healthy.list('proj/saves')!.len == 2594}595596fn test_a_failed_steal_leaves_nothing_behind() {597 base := scratch('fault_steal')!598 defer {599 os.rmdir_all(base) or {}600 }601 remote_dir := os.join_path(base, 'remote')602 mut a := open_repo(machine('fault_s_a')!)!603 defer {604 a.close()605 }606 mut b := open_repo(machine('fault_s_b')!)!607 defer {608 b.close()609 }610 put(a.root, 'a.txt', 'from A\n')!611 commit(a, 'A writes')!612 mut healthy := remote.new_folder(remote_dir)!613 offer(a, mut healthy)!614615 616 mut flaky := FaultyBackend{617 inner: remote.new_folder(remote_dir)!618 fail_read: 2619 }620 if _ := fetch_back_to_known(b, mut flaky, 'proj') {621 assert false, 'the injected read failure should have surfaced'622 }623 624 assert history.head(b) == none625 assert b.object_count()! == 0626627 628 fetched := fetch_back_to_known(b, mut healthy, 'proj')!629 b.begin()!630 write_fetched(b, fetched)!631 history.set_head(b, fetched.head)!632 b.commit()!633 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!634 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'635}636637fn test_an_interrupted_strip_is_idempotent() {638 639 640 641 base := scratch('fault_strip')!642 defer {643 os.rmdir_all(base) or {}644 }645 mut a := open_repo(machine('fault_strip_a')!)!646 defer {647 a.close()648 }649 put(a.root, 'secret.txt', 'shh\n')!650 put(a.root, 'keep.txt', 'fine\n')!651 save := commit(a, 'add both')!652653 for _ in 0 .. 2 {654 strip_local(a, 'secret.txt', '')!655 }656 files := history.files_of(a, save.id)!657 658 659 assert files.map(it.path) == ['.kohignore', 'keep.txt']660 assert events.strip_events(a)!.len == 1661}662663664665666667fn test_a_strip_matches_the_path_exactly() {668 mut a := open_repo(machine('strip_exact')!)!669 defer {670 a.close()671 }672 hash := a.put_object('x\n'.bytes())!673 mut files := []snapshot.FileEntry{}674 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {675 files << snapshot.FileEntry{676 path: path677 hash: hash678 }679 }680 save := history.commit_files(a, '', files, history.SaveInput{681 message: 'add all'682 })!683 assert strip_scope(a, 'docs', '')!.files == 1684 strip_local(a, 'docs', '')!685 strip_local(a, 'a_b', '')!686 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']687}688689fn test_a_strip_reaches_the_remote_and_other_machines() {690 base := scratch('strip_sync')!691 defer {692 os.rmdir_all(base) or {}693 }694 mut a := open_repo(machine('strip_a')!)!695 defer {696 a.close()697 }698 mut b := open_repo(machine('strip_b')!)!699 defer {700 b.close()701 }702 put(a.root, 'secrets.env', 'api_key=1\n')!703 put(a.root, 'keep.txt', 'fine\n')!704 save := commit(a, 'add both')!705 mut backend := remote.new_folder(os.join_path(base, 'remote'))!706 offer_now(a, mut backend)!707 join(b, mut backend)!708 assert b.object_count()! > 0709710 711 712 mut secret_hash := ''713 for file in history.files_of(a, save.id)! {714 if file.path == 'secrets.env' {715 secret_hash = file.hash716 }717 }718 assert secret_hash != ''719 strip_local(a, 'secrets.env', '')!720 report := push_strip(a, mut backend, 'secrets.env')!721 assert report.event_sent722 assert report.records == 1723724 725 726 record := decode_save(backend.get(save_key('proj', save.id))!.body)!727 for file in record.files {728 assert file.path != 'secrets.env'729 }730 731 732 assert !backend.get(object_key('proj', secret_hash))!.found733 assert backend.list('proj/objects')!.len == 2734735 736 b.begin()!737 fetched := fetch_back_to_known(b, mut backend, 'proj')!738 write_fetched(b, fetched)!739 b.commit()!740 mut paths := []string{}741 for file in history.files_of(b, save.id)! {742 paths << file.path743 }744 assert 'secrets.env' !in paths745 assert 'keep.txt' in paths746747 748 749 750 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)751 put(b.root, 'secrets.env', 'rotated\n')!752 readded := commit(b, 'add the rotated key back')!753 offer_now(b, mut backend)!754 assert backend.list('proj/events')!.len == 1755 b.begin()!756 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!757 b.commit()!758 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)759}760761762763764fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {765 base := scratch('strip_clone')!766 defer {767 os.rmdir_all(base) or {}768 }769 mut backend := remote.new_folder(os.join_path(base, 'remote'))!770 mut a := open_repo(machine('strip_clone_a')!)!771 defer {772 a.close()773 }774 put(a.root, 'secrets.env', 'api_key=1\n')!775 old := commit(a, 'leak the key')!776 offer_now(a, mut backend)!777 strip_local(a, 'secrets.env', '')!778 push_strip(a, mut backend, 'secrets.env')!779 780 put(a.root, 'secrets.env', 'api_key=2\n')!781 rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{782 message: 'rotate the key'783 created_at: events.strip_events(a)!.last().created_at + 60784 })!785 offer_now(a, mut backend)!786787 mut c := open_repo(machine('strip_clone_c')!)!788 defer {789 c.close()790 }791 join(c, mut backend)!792 assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)793 assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)794 assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'795}796797798799800fn test_an_interrupted_remote_strip_finishes_when_re_run() {801 base := scratch('strip_resume')!802 defer {803 os.rmdir_all(base) or {}804 }805 remote_dir := os.join_path(base, 'remote')806 mut backend := remote.new_folder(remote_dir)!807 mut a := open_repo(machine('resume_a')!)!808 defer {809 a.close()810 }811 mut b := open_repo(machine('resume_b')!)!812 defer {813 b.close()814 }815 put(a.root, 'secrets.env', 'api_key=1\n')!816 put(a.root, 'shared.env', 'same bytes on B\n')!817 save := commit(a, 'add secrets')!818 offer_now(a, mut backend)!819 join(b, mut backend)!820 put(b.root, 'copy.txt', 'same bytes on B\n')!821 b_save := commit(b, 'B keeps a copy')!822 offer_now(b, mut backend)!823 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())824 secret_hash := storage.hash_hex('api_key=1\n'.bytes())825826 strip_local(a, 'secrets.env', '')!827 828 829 mut flaky := FaultyBackend{830 inner: remote.new_folder(remote_dir)!831 fail_write: 3832 }833 if _ := push_strip(a, mut flaky, 'secrets.env') {834 assert false, 'the injected failure should have surfaced'835 }836 assert strip_pending(a, 'secrets.env')837 assert strip_local(a, 'secrets.env', '')!.files_removed == 0838 report := push_strip(a, mut backend, 'secrets.env')!839 assert report.records == 1840 assert !strip_pending(a, 'secrets.env')841 assert !backend.get(object_key('proj', secret_hash))!.found842 record := decode_save(backend.get(save_key('proj', save.id))!.body)!843 assert 'secrets.env' !in record.files.map(it.path)844 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!845 assert 'secrets.env' !in b_record.files.map(it.path)846847 848 strip_local(a, 'shared.env', '')!849 push_strip(a, mut backend, 'shared.env')!850 assert backend.get(object_key('proj', shared_hash))!.found851}852853854855fn test_offer_waits_for_an_unapplied_strip() {856 base := scratch('strip_first')!857 defer {858 os.rmdir_all(base) or {}859 }860 mut backend := remote.new_folder(os.join_path(base, 'remote'))!861 mut a := open_repo(machine('strip_first_a')!)!862 defer {863 a.close()864 }865 mut b := open_repo(machine('strip_first_b')!)!866 defer {867 b.close()868 }869 put(a.root, 'secrets.env', 'api_key=1\n')!870 commit(a, 'add secrets')!871 offer_now(a, mut backend)!872 join(b, mut backend)!873 strip_local(a, 'secrets.env', '')!874 push_strip(a, mut backend, 'secrets.env')!875 put(b.root, 'more.txt', 'B keeps working\n')!876 later := commit(b, 'B saves with the secret still in its tree')!877 if _ := offer_now(b, mut backend) {878 assert false, 'B offered records that still list the stripped path'879 }880 steal_now(b, mut backend)!881 offer_now(b, mut backend)!882 record := decode_save(backend.get(save_key('proj', later.id))!.body)!883 assert 'secrets.env' !in record.files.map(it.path)884}885886887888fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {889 base := scratch('strip_twice')!890 defer {891 os.rmdir_all(base) or {}892 }893 remote_dir := os.join_path(base, 'remote')894 mut backend := remote.new_folder(remote_dir)!895 mut a := open_repo(machine('strip_twice_a')!)!896 defer {897 a.close()898 }899 put(a.root, 'secrets.env', 'first key\n')!900 commit(a, 'first key')!901 offer_now(a, mut backend)!902 strip_local(a, 'secrets.env', '')!903 mut flaky := FaultyBackend{904 inner: remote.new_folder(remote_dir)!905 fail_delete: true906 }907 if _ := push_strip(a, mut flaky, 'secrets.env') {908 assert false, 'the injected delete failure should have surfaced'909 }910 put(a.root, 'secrets.env', 'second key\n')!911 commit(a, 'second key')!912 offer_now(a, mut backend)!913 strip_local(a, 'secrets.env', '')!914 push_strip(a, mut backend, 'secrets.env')!915 for key in ['first key\n', 'second key\n'] {916 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found917 }918}919920921fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {922 base := scratch('strip_scopes')!923 defer {924 os.rmdir_all(base) or {}925 }926 remote_dir := os.join_path(base, 'remote')927 mut backend := remote.new_folder(remote_dir)!928 mut a := open_repo(machine('strip_scopes_a')!)!929 defer {930 a.close()931 }932 put(a.root, 'secrets.env', 'first key\n')!933 first := commit(a, 'first key')!934 put(a.root, 'secrets.env', 'second key\n')!935 second := commit(a, 'second key')!936 offer_now(a, mut backend)!937 strip_local(a, 'secrets.env', first.id)!938 939 mut flaky := FaultyBackend{940 inner: remote.new_folder(remote_dir)!941 fail_write: 2942 }943 if _ := push_strip(a, mut flaky, 'secrets.env') {944 assert false, 'the injected failure should have surfaced'945 }946 strip_local(a, 'secrets.env', second.id)!947 push_strip(a, mut backend, 'secrets.env')!948 assert !strip_pending(a, 'secrets.env')949 for save in [first, second] {950 record := decode_save(backend.get(save_key('proj', save.id))!.body)!951 assert 'secrets.env' !in record.files.map(it.path)952 }953 for key in ['first key\n', 'second key\n'] {954 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found955 }956}957958959960fn test_a_second_strip_of_the_same_path_reaches_other_machines() {961 base := scratch('strip_again')!962 defer {963 os.rmdir_all(base) or {}964 }965 mut backend := remote.new_folder(os.join_path(base, 'remote'))!966 mut a := open_repo(machine('strip_again_a')!)!967 defer {968 a.close()969 }970 mut b := open_repo(machine('strip_again_b')!)!971 defer {972 b.close()973 }974 put(a.root, 'secrets.env', 'first key\n')!975 commit(a, 'first key')!976 offer_now(a, mut backend)!977 join(b, mut backend)!978 strip_local(a, 'secrets.env', '')!979 push_strip(a, mut backend, 'secrets.env')!980 steal_now(b, mut backend)!981 982 os.rm(os.join_path(b.root, 'secrets.env'))!983 put(a.root, 'secrets.env', 'second key\n')!984 second := commit(a, 'second key')!985 offer_now(a, mut backend)!986 steal_now(b, mut backend)!987 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)988 strip_local(a, 'secrets.env', '')!989 push_strip(a, mut backend, 'secrets.env')!990 steal_now(b, mut backend)!991 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)992}993994995996fn test_a_stripped_path_is_not_asked_about() {997 base := scratch('clash_strip')!998 defer {999 os.rmdir_all(base) or {}1000 }1001 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1002 mut a := open_repo(machine('clash_strip_a')!)!1003 defer {1004 a.close()1005 }1006 mut b := open_repo(machine('clash_strip_b')!)!1007 defer {1008 b.close()1009 }1010 put(a.root, 'secrets.env', 'key one\n')!1011 commit(a, 'base')!1012 offer_now(a, mut backend)!1013 join(b, mut backend)!1014 put(a.root, 'secrets.env', 'key two\n')!1015 commit(a, 'A rotates')!1016 offer_now(a, mut backend)!1017 strip_local(a, 'secrets.env', '')!1018 push_strip(a, mut backend, 'secrets.env')!1019 put(b.root, 'secrets.env', 'key three\n')!1020 mine := commit(b, 'B rotates')!1021 report := steal(b, mut backend, StealOptions{})!1022 assert report.clashes.len == 01023 for save in history.all_saves(b)! {1024 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1025 }1026 assert history.by_id(b, mine.id) != none1027}1028102910301031fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1032 base := scratch('clash_base_strip')!1033 defer {1034 os.rmdir_all(base) or {}1035 }1036 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1037 mut a := open_repo(machine('base_strip_a')!)!1038 defer {1039 a.close()1040 }1041 mut b := open_repo(machine('base_strip_b')!)!1042 defer {1043 b.close()1044 }1045 put(a.root, 'notes.txt', 'shared\n')!1046 common := commit(a, 'base')!1047 offer_now(a, mut backend)!1048 join(b, mut backend)!1049 put(a.root, 'other.txt', 'A moves on\n')!1050 commit(a, 'A moves on')!1051 offer_now(a, mut backend)!1052 strip_local(a, 'notes.txt', common.id)!1053 push_strip(a, mut backend, 'notes.txt')!1054 put(b.root, 'notes.txt', 'B edits\n')!1055 commit(b, 'B edits notes')!1056 if _ := steal(b, mut backend, StealOptions{}) {1057 assert false, 'with the base stripped, notes.txt differs on both sides'1058 } else {1059 assert err.msg().contains('changed on both machines')1060 }1061 report := steal(b, mut backend, StealOptions{1062 mine: true1063 })!1064 assert report.clashes == ['notes.txt']1065 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1066}1067106810691070fn test_steal_into_a_folder_with_unsaved_files_refuses() {1071 base := scratch('steal_unsaved')!1072 defer {1073 os.rmdir_all(base) or {}1074 }1075 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1076 mut a := open_repo(machine('unsaved_a')!)!1077 defer {1078 a.close()1079 }1080 put(a.root, 'notes.txt', 'from A\n')!1081 commit(a, 'A writes')!1082 offer_now(a, mut backend)!1083 b_root := machine('unsaved_b')!1084 mut b := storage.create(b_root)!1085 defer {1086 b.close()1087 }1088 put(b_root, 'notes.txt', 'never saved\n')!1089 1090 b.set_state('remote_repo', 'proj')!1091 if _ := steal(b, mut backend, StealOptions{}) {1092 assert false, 'steal overwrote a folder with unsaved files'1093 } else {1094 assert err.msg() == no_saves_here1095 }1096 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1097 1098 os.rm(os.join_path(b_root, 'notes.txt'))!1099 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1100 steal(b, mut backend, StealOptions{})!1101 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1102}1103110411051106fn test_steal_reports_incoming_flagged_saves() {1107 base := scratch('flag_warn')!1108 defer {1109 os.rmdir_all(base) or {}1110 }1111 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1112 mut a := open_repo(machine('flag_warn_a')!)!1113 defer {1114 a.close()1115 }1116 mut b := open_repo(machine('flag_warn_b')!)!1117 defer {1118 b.close()1119 }1120 put(a.root, 'a.txt', 'one\n')!1121 commit(a, 'fine')!1122 offer_now(a, mut backend)!1123 join(b, mut backend)!1124 put(a.root, 'a.txt', 'two\n')!1125 suspect := commit(a, 'suspect')!1126 put(a.root, 'a.txt', 'three\n')!1127 commit(a, 'also fine')!1128 events.flag(a, suspect.id, 'broke the build')!1129 offer_now(a, mut backend)!1130 report := steal_now(b, mut backend)!1131 assert report.flagged.map(it.save_id) == [suspect.id]1132 assert report.flagged[0].reason == 'broke the build'1133}