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)!.id100 101 assert a.state('remote_head') or { '' } == history.head(a)!.id102103 104 105 put(a.root, 'a.txt', 'two\n')!106 commit(a, 'second')!107 offer_now(a, mut backend)!108 assert backend.get(head_key('proj'))!.body.bytestr() == history.head(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 commit(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 history.head(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 commit(a, 'offered')!154 offer_now(a, mut backend)!155 put(a.root, 'a.txt', 'two\n')!156 ahead := commit(a, 'not offered yet')!157 report := steal_now(a, mut backend)!158 assert report.replayed == 0159 assert history.head(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 commit(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 commit(a, 'A adds docs')!192 offer_now(a, mut backend)!193 head_before := history.head(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 history.head(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 commit(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 := commit(a, 'parent')!239 child := history.commit_files(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 history.head(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 commit(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 commit(a, 'base')!290 offer_now(a, mut backend)!291292 293 join(b, mut backend)!294 put(a.root, 'fromA.txt', 'A\n')!295 commit(a, 'A adds a file')!296 put(b.root, 'fromB.txt', 'B\n')!297 commit(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 == history.head(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 commit(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 commit(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 := commit(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 := commit(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 history.head(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 commit(a, 'base')!398 offer_now(a, mut backend)!399 join(b, mut backend)!400 put(a.root, 'same.txt', 'A version\n')!401 commit(a, 'A edits same')!402 offer_now(a, mut backend)!403 put(b.root, 'same.txt', 'B version\n')!404 commit(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.commit_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 history.head(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 commit(a, 'base')!452 offer_now(a, mut backend)!453 join(b, mut backend)!454455 456 put(a.root, 'same.txt', 'A version\n')!457 commit(a, 'A edits same')!458 offer_now(a, mut backend)!459 put(b.root, 'same.txt', 'B version\n')!460 commit(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 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,469 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0470471 472 report := steal_now(b, mut backend)!473 assert report.clashes == ['same.txt']474 assert report.kept_mine == 0475 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'476 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'477}478479fn test_choosing_mine_keeps_the_local_version() {480 base := scratch('mine')!481 defer {482 os.rmdir_all(base) or {}483 }484 mut backend := remote.new_folder(os.join_path(base, 'remote'))!485 mut a := open_repo(machine('mine_a')!)!486 defer {487 a.close()488 }489 mut b := open_repo(machine('mine_b')!)!490 defer {491 b.close()492 }493 put(a.root, 'same.txt', 'base\n')!494 commit(a, 'base')!495 offer_now(a, mut backend)!496 join(b, mut backend)!497 put(a.root, 'same.txt', 'A version\n')!498 commit(a, 'A edits')!499 offer_now(a, mut backend)!500 put(b.root, 'same.txt', 'B version\n')!501 commit(b, 'B edits')!502503 report := steal(b, mut backend, StealOptions{504 mine: true505 })!506 assert report.kept_mine == 1507 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'508 509 assert history.head(b)!.message.contains('combine: kept mine for 1 file')510 511 assert history.head(b)!.restores == ''512 assert history.all_saves(b)!.len >= 4513}514515fn test_flags_travel_with_the_saves() {516 base := scratch('flags')!517 defer {518 os.rmdir_all(base) or {}519 }520 mut backend := remote.new_folder(os.join_path(base, 'remote'))!521 mut a := open_repo(machine('flag_a')!)!522 defer {523 a.close()524 }525 mut b := open_repo(machine('flag_b')!)!526 defer {527 b.close()528 }529 put(a.root, 'a.txt', 'one\n')!530 save := commit(a, 'suspect save')!531 events.flag(a, save.id, 'look at this')!532 offer_now(a, mut backend)!533534 535 fetched := fetch_back_to_known(b, mut backend, 'proj')!536 assert fetched.events_out.len == 1537 assert fetched.events_out[0].kind == events.kind_flag538 b.begin()!539 write_fetched(b, fetched)!540 history.set_head(b, fetched.head)!541 b.commit()!542 state := events.flag_state(b, save.id)!543 assert state.flagged544 assert state.reason == 'look at this'545}546547fn test_a_second_offer_racing_head_is_refused_not_forced() {548 base := scratch('race')!549 defer {550 os.rmdir_all(base) or {}551 }552 mut backend := remote.new_folder(os.join_path(base, 'remote'))!553 mut a := open_repo(machine('race_a')!)!554 defer {555 a.close()556 }557 put(a.root, 'a.txt', 'one\n')!558 commit(a, 'base')!559 offer_now(a, mut backend)!560561 562 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),563 backend.get(head_key('proj'))!.tag)!564565 put(a.root, 'a.txt', 'two\n')!566 commit(a, 'second')!567 if _ := offer_now(a, mut backend) {568 assert false, 'offering while the remote head moved must be refused'569 } else {570 assert err.msg().contains('run koh steal')571 }572}573574575fn join(store &storage.Store, mut backend remote.Backend) ! {576 fetched := fetch_repo(mut backend, 'proj')!577 store.begin()!578 write_fetched(store, fetched)!579 history.set_head(store, fetched.head)!580 store.commit()!581 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,582 fetched.head)!, [])!583 store.set_state('remote_repo', 'proj')!584}585586struct FaultyBackend {587mut:588 inner remote.Backend589 writes int590 reads int591 fail_write int 592 fail_read int593 594 fail_delete bool595}596597fn (mut f FaultyBackend) get(key string) !remote.Object {598 f.reads++599 if f.fail_read > 0 && f.reads >= f.fail_read {600 return error('injected read failure')601 }602 return f.inner.get(key)!603}604605fn (mut f FaultyBackend) list(prefix string) ![]string {606 return f.inner.list(prefix)!607}608609fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {610 f.writes++611 if f.fail_write > 0 && f.writes >= f.fail_write {612 return error('injected write failure')613 }614 return f.inner.put_if_absent(key, body)!615}616617fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {618 f.writes++619 if f.fail_write > 0 && f.writes >= f.fail_write {620 return error('injected write failure')621 }622 return f.inner.put_if_match(key, body, tag)!623}624625fn (mut f FaultyBackend) delete(key string) ! {626 if f.fail_delete {627 return error('injected delete failure')628 }629 f.inner.delete(key)!630}631632633634635fn test_an_offer_sends_only_the_objects_a_save_adds() {636 base := scratch('offer_diff')!637 defer {638 os.rmdir_all(base) or {}639 }640 mut a := open_repo(machine('offer_diff_a')!)!641 defer {642 a.close()643 }644 for i in 0 .. 20 {645 put(a.root, 'f${i}.txt', 'file ${i}\n')!646 }647 commit(a, 'twenty files')!648 mut backend := FaultyBackend{649 inner: remote.new_folder(os.join_path(base, 'remote'))!650 }651 offer(a, mut backend)!652 put(a.root, 'f3.txt', 'changed\n')!653 commit(a, 'one change')!654 before := backend.writes655 report := offer(a, mut backend)!656 assert report.objects == 1657 658 assert backend.writes - before == 3659}660661fn test_a_failed_offer_is_safe_to_re_run() {662 base := scratch('fault_offer')!663 defer {664 os.rmdir_all(base) or {}665 }666 remote_dir := os.join_path(base, 'remote')667 mut a := open_repo(machine('fault_a')!)!668 defer {669 a.close()670 }671 put(a.root, 'a.txt', 'one\n')!672 commit(a, 'first')!673 put(a.root, 'a.txt', 'two\n')!674 commit(a, 'second')!675676 677 mut flaky := FaultyBackend{678 inner: remote.new_folder(remote_dir)!679 fail_write: 2680 }681 if _ := offer(a, mut flaky) {682 assert false, 'the injected failure should have surfaced'683 }684685 686 687 mut healthy := remote.new_folder(remote_dir)!688 report := offer(a, mut healthy)!689 assert report.saves == 2690 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id691 assert healthy.list('proj/saves')!.len == 2692}693694fn test_a_failed_steal_leaves_nothing_behind() {695 base := scratch('fault_steal')!696 defer {697 os.rmdir_all(base) or {}698 }699 remote_dir := os.join_path(base, 'remote')700 mut a := open_repo(machine('fault_s_a')!)!701 defer {702 a.close()703 }704 mut b := open_repo(machine('fault_s_b')!)!705 defer {706 b.close()707 }708 put(a.root, 'a.txt', 'from A\n')!709 commit(a, 'A writes')!710 mut healthy := remote.new_folder(remote_dir)!711 offer(a, mut healthy)!712713 714 mut flaky := FaultyBackend{715 inner: remote.new_folder(remote_dir)!716 fail_read: 2717 }718 if _ := fetch_back_to_known(b, mut flaky, 'proj') {719 assert false, 'the injected read failure should have surfaced'720 }721 722 assert history.head(b) == none723 assert b.object_count()! == 0724725 726 fetched := fetch_back_to_known(b, mut healthy, 'proj')!727 b.begin()!728 write_fetched(b, fetched)!729 history.set_head(b, fetched.head)!730 b.commit()!731 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!732 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'733}734735fn test_an_interrupted_strip_is_idempotent() {736 737 738 739 base := scratch('fault_strip')!740 defer {741 os.rmdir_all(base) or {}742 }743 mut a := open_repo(machine('fault_strip_a')!)!744 defer {745 a.close()746 }747 put(a.root, 'secret.txt', 'shh\n')!748 put(a.root, 'keep.txt', 'fine\n')!749 save := commit(a, 'add both')!750751 for _ in 0 .. 2 {752 strip_local(a, 'secret.txt', '')!753 }754 files := history.files_of(a, save.id)!755 756 757 assert files.map(it.path) == ['.kohignore', 'keep.txt']758 assert events.strip_events(a)!.len == 1759}760761762763764765fn test_a_strip_matches_the_path_exactly() {766 mut a := open_repo(machine('strip_exact')!)!767 defer {768 a.close()769 }770 hash := a.put_object('x\n'.bytes())!771 mut files := []snapshot.FileEntry{}772 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {773 files << snapshot.FileEntry{774 path: path775 hash: hash776 }777 }778 save := history.commit_files(a, '', files, history.SaveInput{779 message: 'add all'780 })!781 assert strip_scope(a, 'docs', '')!.files == 1782 strip_local(a, 'docs', '')!783 strip_local(a, 'a_b', '')!784 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']785}786787fn test_a_strip_reaches_the_remote_and_other_machines() {788 base := scratch('strip_sync')!789 defer {790 os.rmdir_all(base) or {}791 }792 mut a := open_repo(machine('strip_a')!)!793 defer {794 a.close()795 }796 mut b := open_repo(machine('strip_b')!)!797 defer {798 b.close()799 }800 put(a.root, 'secrets.env', 'api_key=1\n')!801 put(a.root, 'keep.txt', 'fine\n')!802 save := commit(a, 'add both')!803 mut backend := remote.new_folder(os.join_path(base, 'remote'))!804 offer_now(a, mut backend)!805 join(b, mut backend)!806 assert b.object_count()! > 0807808 809 810 mut secret_hash := ''811 for file in history.files_of(a, save.id)! {812 if file.path == 'secrets.env' {813 secret_hash = file.hash814 }815 }816 assert secret_hash != ''817 strip_local(a, 'secrets.env', '')!818 report := push_strip(a, mut backend, 'secrets.env')!819 assert report.event_sent820 assert report.records == 1821822 823 824 record := decode_save(backend.get(save_key('proj', save.id))!.body)!825 for file in record.files {826 assert file.path != 'secrets.env'827 }828 829 830 assert !backend.get(object_key('proj', secret_hash))!.found831 assert backend.list('proj/objects')!.len == 2832833 834 b.begin()!835 fetched := fetch_back_to_known(b, mut backend, 'proj')!836 write_fetched(b, fetched)!837 b.commit()!838 mut paths := []string{}839 for file in history.files_of(b, save.id)! {840 paths << file.path841 }842 assert 'secrets.env' !in paths843 assert 'keep.txt' in paths844845 846 847 848 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)849 put(b.root, 'secrets.env', 'rotated\n')!850 readded := commit(b, 'add the rotated key back')!851 offer_now(b, mut backend)!852 assert backend.list('proj/events')!.len == 1853 b.begin()!854 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!855 b.commit()!856 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)857}858859860861862fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {863 base := scratch('strip_clone')!864 defer {865 os.rmdir_all(base) or {}866 }867 mut backend := remote.new_folder(os.join_path(base, 'remote'))!868 mut a := open_repo(machine('strip_clone_a')!)!869 defer {870 a.close()871 }872 put(a.root, 'secrets.env', 'api_key=1\n')!873 old := commit(a, 'leak the key')!874 offer_now(a, mut backend)!875 strip_local(a, 'secrets.env', '')!876 push_strip(a, mut backend, 'secrets.env')!877 878 put(a.root, 'secrets.env', 'api_key=2\n')!879 rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{880 message: 'rotate the key'881 created_at: events.strip_events(a)!.last().created_at + 60882 })!883 offer_now(a, mut backend)!884885 mut c := open_repo(machine('strip_clone_c')!)!886 defer {887 c.close()888 }889 join(c, mut backend)!890 assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)891 assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)892 assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'893}894895896897898fn test_an_interrupted_remote_strip_finishes_when_re_run() {899 base := scratch('strip_resume')!900 defer {901 os.rmdir_all(base) or {}902 }903 remote_dir := os.join_path(base, 'remote')904 mut backend := remote.new_folder(remote_dir)!905 mut a := open_repo(machine('resume_a')!)!906 defer {907 a.close()908 }909 mut b := open_repo(machine('resume_b')!)!910 defer {911 b.close()912 }913 put(a.root, 'secrets.env', 'api_key=1\n')!914 put(a.root, 'shared.env', 'same bytes on B\n')!915 save := commit(a, 'add secrets')!916 offer_now(a, mut backend)!917 join(b, mut backend)!918 put(b.root, 'copy.txt', 'same bytes on B\n')!919 b_save := commit(b, 'B keeps a copy')!920 offer_now(b, mut backend)!921 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())922 secret_hash := storage.hash_hex('api_key=1\n'.bytes())923924 strip_local(a, 'secrets.env', '')!925 926 927 mut flaky := FaultyBackend{928 inner: remote.new_folder(remote_dir)!929 fail_write: 3930 }931 if _ := push_strip(a, mut flaky, 'secrets.env') {932 assert false, 'the injected failure should have surfaced'933 }934 assert strip_pending(a, 'secrets.env')935 assert strip_local(a, 'secrets.env', '')!.files_removed == 0936 report := push_strip(a, mut backend, 'secrets.env')!937 assert report.records == 1938 assert !strip_pending(a, 'secrets.env')939 assert !backend.get(object_key('proj', secret_hash))!.found940 record := decode_save(backend.get(save_key('proj', save.id))!.body)!941 assert 'secrets.env' !in record.files.map(it.path)942 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!943 assert 'secrets.env' !in b_record.files.map(it.path)944945 946 strip_local(a, 'shared.env', '')!947 push_strip(a, mut backend, 'shared.env')!948 assert backend.get(object_key('proj', shared_hash))!.found949}950951952953fn test_offer_waits_for_an_unapplied_strip() {954 base := scratch('strip_first')!955 defer {956 os.rmdir_all(base) or {}957 }958 mut backend := remote.new_folder(os.join_path(base, 'remote'))!959 mut a := open_repo(machine('strip_first_a')!)!960 defer {961 a.close()962 }963 mut b := open_repo(machine('strip_first_b')!)!964 defer {965 b.close()966 }967 put(a.root, 'secrets.env', 'api_key=1\n')!968 commit(a, 'add secrets')!969 offer_now(a, mut backend)!970 join(b, mut backend)!971 strip_local(a, 'secrets.env', '')!972 push_strip(a, mut backend, 'secrets.env')!973 put(b.root, 'more.txt', 'B keeps working\n')!974 later := commit(b, 'B saves with the secret still in its tree')!975 if _ := offer_now(b, mut backend) {976 assert false, 'B offered records that still list the stripped path'977 }978 steal_now(b, mut backend)!979 offer_now(b, mut backend)!980 record := decode_save(backend.get(save_key('proj', later.id))!.body)!981 assert 'secrets.env' !in record.files.map(it.path)982}983984985986fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {987 base := scratch('strip_twice')!988 defer {989 os.rmdir_all(base) or {}990 }991 remote_dir := os.join_path(base, 'remote')992 mut backend := remote.new_folder(remote_dir)!993 mut a := open_repo(machine('strip_twice_a')!)!994 defer {995 a.close()996 }997 put(a.root, 'secrets.env', 'first key\n')!998 commit(a, 'first key')!999 offer_now(a, mut backend)!1000 strip_local(a, 'secrets.env', '')!1001 mut flaky := FaultyBackend{1002 inner: remote.new_folder(remote_dir)!1003 fail_delete: true1004 }1005 if _ := push_strip(a, mut flaky, 'secrets.env') {1006 assert false, 'the injected delete failure should have surfaced'1007 }1008 put(a.root, 'secrets.env', 'second key\n')!1009 commit(a, 'second key')!1010 offer_now(a, mut backend)!1011 strip_local(a, 'secrets.env', '')!1012 push_strip(a, mut backend, 'secrets.env')!1013 for key in ['first key\n', 'second key\n'] {1014 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1015 }1016}101710181019fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1020 base := scratch('strip_scopes')!1021 defer {1022 os.rmdir_all(base) or {}1023 }1024 remote_dir := os.join_path(base, 'remote')1025 mut backend := remote.new_folder(remote_dir)!1026 mut a := open_repo(machine('strip_scopes_a')!)!1027 defer {1028 a.close()1029 }1030 put(a.root, 'secrets.env', 'first key\n')!1031 first := commit(a, 'first key')!1032 put(a.root, 'secrets.env', 'second key\n')!1033 second := commit(a, 'second key')!1034 offer_now(a, mut backend)!1035 strip_local(a, 'secrets.env', first.id)!1036 1037 mut flaky := FaultyBackend{1038 inner: remote.new_folder(remote_dir)!1039 fail_write: 21040 }1041 if _ := push_strip(a, mut flaky, 'secrets.env') {1042 assert false, 'the injected failure should have surfaced'1043 }1044 strip_local(a, 'secrets.env', second.id)!1045 push_strip(a, mut backend, 'secrets.env')!1046 assert !strip_pending(a, 'secrets.env')1047 for save in [first, second] {1048 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1049 assert 'secrets.env' !in record.files.map(it.path)1050 }1051 for key in ['first key\n', 'second key\n'] {1052 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1053 }1054}1055105610571058fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1059 base := scratch('strip_again')!1060 defer {1061 os.rmdir_all(base) or {}1062 }1063 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1064 mut a := open_repo(machine('strip_again_a')!)!1065 defer {1066 a.close()1067 }1068 mut b := open_repo(machine('strip_again_b')!)!1069 defer {1070 b.close()1071 }1072 put(a.root, 'secrets.env', 'first key\n')!1073 commit(a, 'first key')!1074 offer_now(a, mut backend)!1075 join(b, mut backend)!1076 strip_local(a, 'secrets.env', '')!1077 push_strip(a, mut backend, 'secrets.env')!1078 steal_now(b, mut backend)!1079 1080 os.rm(os.join_path(b.root, 'secrets.env'))!1081 put(a.root, 'secrets.env', 'second key\n')!1082 second := commit(a, 'second key')!1083 offer_now(a, mut backend)!1084 steal_now(b, mut backend)!1085 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1086 strip_local(a, 'secrets.env', '')!1087 push_strip(a, mut backend, 'secrets.env')!1088 steal_now(b, mut backend)!1089 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1090}1091109210931094fn test_a_stripped_path_is_not_asked_about() {1095 base := scratch('clash_strip')!1096 defer {1097 os.rmdir_all(base) or {}1098 }1099 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1100 mut a := open_repo(machine('clash_strip_a')!)!1101 defer {1102 a.close()1103 }1104 mut b := open_repo(machine('clash_strip_b')!)!1105 defer {1106 b.close()1107 }1108 put(a.root, 'secrets.env', 'key one\n')!1109 commit(a, 'base')!1110 offer_now(a, mut backend)!1111 join(b, mut backend)!1112 put(a.root, 'secrets.env', 'key two\n')!1113 commit(a, 'A rotates')!1114 offer_now(a, mut backend)!1115 strip_local(a, 'secrets.env', '')!1116 push_strip(a, mut backend, 'secrets.env')!1117 put(b.root, 'secrets.env', 'key three\n')!1118 mine := commit(b, 'B rotates')!1119 report := steal(b, mut backend, StealOptions{})!1120 assert report.clashes.len == 01121 for save in history.all_saves(b)! {1122 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1123 }1124 assert history.by_id(b, mine.id) != none1125}1126112711281129fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1130 base := scratch('clash_base_strip')!1131 defer {1132 os.rmdir_all(base) or {}1133 }1134 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1135 mut a := open_repo(machine('base_strip_a')!)!1136 defer {1137 a.close()1138 }1139 mut b := open_repo(machine('base_strip_b')!)!1140 defer {1141 b.close()1142 }1143 put(a.root, 'notes.txt', 'shared\n')!1144 common := commit(a, 'base')!1145 offer_now(a, mut backend)!1146 join(b, mut backend)!1147 put(a.root, 'other.txt', 'A moves on\n')!1148 commit(a, 'A moves on')!1149 offer_now(a, mut backend)!1150 strip_local(a, 'notes.txt', common.id)!1151 push_strip(a, mut backend, 'notes.txt')!1152 put(b.root, 'notes.txt', 'B edits\n')!1153 commit(b, 'B edits notes')!1154 if _ := steal(b, mut backend, StealOptions{}) {1155 assert false, 'with the base stripped, notes.txt differs on both sides'1156 } else {1157 assert err.msg().contains('changed on both machines')1158 }1159 report := steal(b, mut backend, StealOptions{1160 mine: true1161 })!1162 assert report.clashes == ['notes.txt']1163 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1164}1165116611671168fn test_steal_into_a_folder_with_unsaved_files_refuses() {1169 base := scratch('steal_unsaved')!1170 defer {1171 os.rmdir_all(base) or {}1172 }1173 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1174 mut a := open_repo(machine('unsaved_a')!)!1175 defer {1176 a.close()1177 }1178 put(a.root, 'notes.txt', 'from A\n')!1179 commit(a, 'A writes')!1180 offer_now(a, mut backend)!1181 b_root := machine('unsaved_b')!1182 mut b := storage.create(b_root)!1183 defer {1184 b.close()1185 }1186 put(b_root, 'notes.txt', 'never saved\n')!1187 1188 b.set_state('remote_repo', 'proj')!1189 if _ := steal(b, mut backend, StealOptions{}) {1190 assert false, 'steal overwrote a folder with unsaved files'1191 } else {1192 assert err.msg() == no_saves_here1193 }1194 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1195 1196 os.rm(os.join_path(b_root, 'notes.txt'))!1197 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1198 steal(b, mut backend, StealOptions{})!1199 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1200}1201120212031204fn test_steal_reports_incoming_flagged_saves() {1205 base := scratch('flag_warn')!1206 defer {1207 os.rmdir_all(base) or {}1208 }1209 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1210 mut a := open_repo(machine('flag_warn_a')!)!1211 defer {1212 a.close()1213 }1214 mut b := open_repo(machine('flag_warn_b')!)!1215 defer {1216 b.close()1217 }1218 put(a.root, 'a.txt', 'one\n')!1219 commit(a, 'fine')!1220 offer_now(a, mut backend)!1221 join(b, mut backend)!1222 put(a.root, 'a.txt', 'two\n')!1223 suspect := commit(a, 'suspect')!1224 put(a.root, 'a.txt', 'three\n')!1225 commit(a, 'also fine')!1226 events.flag(a, suspect.id, 'broke the build')!1227 offer_now(a, mut backend)!1228 report := steal_now(b, mut backend)!1229 assert report.flagged.map(it.save_id) == [suspect.id]1230 assert report.flagged[0].reason == 'broke the build'1231}