12345module sync67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage13141516fn machine(name string) !string {17 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')18 os.rmdir_all(os.dir(dir)) or {}19 os.mkdir_all(dir, os.MkdirParams{})!20 return dir21}2223fn scratch(name string) !string {24 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')25 os.rmdir_all(dir) or {}26 os.mkdir_all(dir, os.MkdirParams{})!27 return dir28}2930fn open_repo(dir string) !storage.Store {31 mut store := storage.create(dir)!32 os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!33 return store34}3536fn put(root string, rel string, text string) ! {37 full := os.join_path(root, rel)38 os.mkdir_all(os.dir(full), os.MkdirParams{})!39 os.write_file(full, text)!40}4142fn commit(store &storage.Store, message string) !history.Save {43 return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{44 message: message45 })!46}4748fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {49 return offer(store, mut backend)!50}5152fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {53 return steal(store, mut backend, StealOptions{54 theirs: true55 })!56}5758fn test_first_offer_publishes_the_whole_line() {59 base := scratch('first')!60 defer {61 os.rmdir_all(base) or {}62 }63 mut a := open_repo(machine('first_a')!)!64 defer {65 a.close()66 }67 put(a.root, 'a.txt', 'one\n')!68 commit(a, 'first')!69 put(a.root, 'a.txt', 'two\n')!70 commit(a, 'second')!71 mut backend := remote.new_folder(os.join_path(base, 'remote'))!7273 report := offer_now(a, mut backend)!74 assert report.repo == 'proj'75 assert report.saves == 276 assert report.objects >= 277 assert backend.get(head_key('proj'))!.found78 assert backend.list('proj/saves')!.len == 27980 81 again := offer_now(a, mut backend)!82 assert again.saves == 083}8485fn test_the_repo_name_is_remembered_and_head_offered() {86 base := scratch('remember')!87 defer {88 os.rmdir_all(base) or {}89 }90 mut a := open_repo(machine('remember_a')!)!91 defer {92 a.close()93 }94 put(a.root, 'a.txt', 'one\n')!95 commit(a, 'first')!96 mut backend := remote.new_folder(os.join_path(base, 'remote'))!97 offer_now(a, mut backend)!98 assert a.state('remote_repo') or { '' } == 'proj'99 assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id100101 102 103 put(a.root, 'a.txt', 'two\n')!104 commit(a, 'second')!105 offer_now(a, mut backend)!106 assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id107}108109fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {110 base := scratch('forward')!111 defer {112 os.rmdir_all(base) or {}113 }114 mut backend := remote.new_folder(os.join_path(base, 'remote'))!115 mut a := open_repo(machine('fwd_a')!)!116 defer {117 a.close()118 }119 put(a.root, 'a.txt', 'from A\n')!120 commit(a, 'A writes')!121 offer_now(a, mut backend)!122123 124 mut b := open_repo(machine('fwd_b')!)!125 defer {126 b.close()127 }128 fetched := fetch_repo(mut backend, 'proj')!129 b.begin()!130 write_fetched(b, fetched)!131 history.set_head(b, fetched.head)!132 b.commit()!133 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!134 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'135 assert history.head(b)!.id == fetched.head136}137138139140fn test_steal_while_only_ahead_leaves_the_line_alone() {141 base := scratch('ahead')!142 defer {143 os.rmdir_all(base) or {}144 }145 mut backend := remote.new_folder(os.join_path(base, 'remote'))!146 mut a := open_repo(machine('ahead_a')!)!147 defer {148 a.close()149 }150 put(a.root, 'a.txt', 'one\n')!151 commit(a, 'offered')!152 offer_now(a, mut backend)!153 put(a.root, 'a.txt', 'two\n')!154 ahead := commit(a, 'not offered yet')!155 report := steal_now(a, mut backend)!156 assert report.replayed == 0157 assert history.head(a)!.id == ahead.id158 assert history.all_saves(a)!.len == 2159 offer_now(a, mut backend)!160 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id161}162163164165fn test_steal_never_writes_through_a_symlinked_folder() {166 base := scratch('steal_link')!167 defer {168 os.rmdir_all(base) or {}169 }170 mut backend := remote.new_folder(os.join_path(base, 'remote'))!171 mut a := open_repo(machine('steal_link_a')!)!172 defer {173 a.close()174 }175 mut b := open_repo(machine('steal_link_b')!)!176 defer {177 b.close()178 }179 put(a.root, 'a.txt', 'one\n')!180 commit(a, 'first')!181 offer_now(a, mut backend)!182 join(b, mut backend)!183 outside := os.join_path(base, 'outside')184 put(outside, 'a.md', 'precious\n')!185 os.symlink(outside, os.join_path(b.root, 'docs'))!186 put(a.root, 'docs/a.md', 'from A\n')!187 commit(a, 'A adds docs')!188 offer_now(a, mut backend)!189 head_before := history.head(b)!.id190 if _ := steal_now(b, mut backend) {191 assert false, 'a steal writing through a symlinked folder must refuse'192 } else {193 assert err.msg().contains('docs')194 }195 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'196 assert history.head(b)!.id == head_before197}198199200fn test_steal_refuses_after_a_restore_cut_short() {201 base := scratch('steal_cut')!202 defer {203 os.rmdir_all(base) or {}204 }205 mut backend := remote.new_folder(os.join_path(base, 'remote'))!206 mut a := open_repo(machine('steal_cut_a')!)!207 defer {208 a.close()209 }210 put(a.root, 'a.txt', 'one\n')!211 commit(a, 'first')!212 offer_now(a, mut backend)!213 a.set_state('restoring', 'yes')!214 if _ := steal_now(a, mut backend) {215 assert false, 'steal must refuse while a restore is unfinished'216 } else {217 assert err.msg().contains('koh revert')218 }219}220221222223fn test_a_clone_imports_parents_before_children() {224 base := scratch('clone_order')!225 defer {226 os.rmdir_all(base) or {}227 }228 mut backend := remote.new_folder(os.join_path(base, 'remote'))!229 mut a := open_repo(machine('order_a')!)!230 defer {231 a.close()232 }233 put(a.root, 'a.txt', 'one\n')!234 parent := commit(a, 'parent')!235 child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{236 message: 'replayed, older than its parent'237 created_at: parent.created_at - 3600238 })!239 offer_now(a, mut backend)!240 mut b := open_repo(machine('order_b')!)!241 defer {242 b.close()243 }244 join(b, mut backend)!245 assert history.head(b)!.id == child.id246}247248249fn test_steal_refuses_an_unsaved_executable_bit_change() {250 base := scratch('chmod')!251 defer {252 os.rmdir_all(base) or {}253 }254 mut backend := remote.new_folder(os.join_path(base, 'remote'))!255 mut a := open_repo(machine('chmod_a')!)!256 defer {257 a.close()258 }259 put(a.root, 'run.sh', 'echo hi\n')!260 commit(a, 'script')!261 offer_now(a, mut backend)!262 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!263 if _ := steal_now(a, mut backend) {264 assert false, 'a chmod-only change should make the tree dirty'265 } else {266 assert err.msg() == dirty_tree267 }268}269270fn test_divergence_without_clashes_keeps_both_machines_work() {271 base := scratch('diverge')!272 defer {273 os.rmdir_all(base) or {}274 }275 mut backend := remote.new_folder(os.join_path(base, 'remote'))!276 mut a := open_repo(machine('div_a')!)!277 defer {278 a.close()279 }280 mut b := open_repo(machine('div_b')!)!281 defer {282 b.close()283 }284 put(a.root, 'shared.txt', 'base\n')!285 commit(a, 'base')!286 offer_now(a, mut backend)!287288 289 join(b, mut backend)!290 put(a.root, 'fromA.txt', 'A\n')!291 commit(a, 'A adds a file')!292 put(b.root, 'fromB.txt', 'B\n')!293 commit(b, 'B adds a file')!294 offer_now(a, mut backend)!295296 297 if _ := offer_now(b, mut backend) {298 assert false, 'offering on top of unknown remote saves should be refused'299 } else {300 assert err.msg().contains('run koh steal')301 }302303 report := steal_now(b, mut backend)!304 assert !report.forward305 assert report.replayed == 1306 assert report.clashes.len == 0307 308 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'309 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'310 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'311 312 mut saw_replay := false313 for save in history.all_saves(b)! {314 if save.replayed_from != '' {315 saw_replay = true316 }317 }318 assert saw_replay319 320 offer_now(b, mut backend)!321 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()322 assert line_head == history.head(b)!.id323}324325326327328329fn test_a_local_change_undone_does_not_block_a_combine() {330 base := scratch('undone')!331 defer {332 os.rmdir_all(base) or {}333 }334 mut backend := remote.new_folder(os.join_path(base, 'remote'))!335 mut a := open_repo(machine('undone_a')!)!336 defer {337 a.close()338 }339 mut b := open_repo(machine('undone_b')!)!340 defer {341 b.close()342 }343 put(a.root, 'f.txt', 'base\n')!344 put(a.root, 'g.txt', 'base\n')!345 commit(a, 'base')!346 offer_now(a, mut backend)!347 join(b, mut backend)!348349 put(a.root, 'f.txt', 'from A\n')!350 put(a.root, 'g.txt', 'same\n')!351 commit(a, 'A edits both')!352 offer_now(a, mut backend)!353354 put(b.root, 'f.txt', 'tried on B\n')!355 put(b.root, 'g.txt', 'first try\n')!356 commit(b, 'B tries')!357 put(b.root, 'f.txt', 'base\n')!358 put(b.root, 'g.txt', 'same\n')!359 put(b.root, 'h.txt', 'B\n')!360 commit(b, 'B undoes f, lands on the same g, adds h')!361362 report := steal_now(b, mut backend)!363 assert report.clashes.len == 0364 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'365 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'366 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'367}368369370371fn test_clash_questions_are_asked_before_the_write_lock() {372 base := scratch('ask_unlocked')!373 defer {374 os.rmdir_all(base) or {}375 }376 mut backend := remote.new_folder(os.join_path(base, 'remote'))!377 mut a := open_repo(machine('ask_a')!)!378 defer {379 a.close()380 }381 mut b := open_repo(machine('ask_b')!)!382 defer {383 b.close()384 }385 put(a.root, 'same.txt', 'base\n')!386 commit(a, 'base')!387 offer_now(a, mut backend)!388 join(b, mut backend)!389 put(a.root, 'same.txt', 'A version\n')!390 commit(a, 'A edits same')!391 offer_now(a, mut backend)!392 put(b.root, 'same.txt', 'B version\n')!393 commit(b, 'B edits same')!394 root := b.root395 396 if _ := steal(b, mut backend, StealOptions{397 ask: fn [root] (clash Clash) Side {398 other := storage.open(root) or { panic(err) }399 defer {400 other.close()401 }402 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }403 history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{404 message: 'agent saves during the question'405 }) or { panic('steal held the write lock while asking: ${err}') }406 return .mine407 }408 })409 {410 assert false, 'a steal whose line moved during the question must refuse'411 } else {412 assert err.msg().contains('changed while')413 }414 415 assert history.head(b)!.message == 'agent saves during the question'416 report := steal(b, mut backend, StealOptions{417 mine: true418 })!419 assert report.kept_mine == 1420 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'421 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'422}423424fn test_clashing_files_are_chosen_one_at_a_time() {425 base := scratch('clash')!426 defer {427 os.rmdir_all(base) or {}428 }429 mut backend := remote.new_folder(os.join_path(base, 'remote'))!430 mut a := open_repo(machine('clash_a')!)!431 defer {432 a.close()433 }434 mut b := open_repo(machine('clash_b')!)!435 defer {436 b.close()437 }438 put(a.root, 'same.txt', 'base\n')!439 put(a.root, 'other.txt', 'quiet\n')!440 commit(a, 'base')!441 offer_now(a, mut backend)!442 join(b, mut backend)!443444 445 put(a.root, 'same.txt', 'A version\n')!446 commit(a, 'A edits same')!447 offer_now(a, mut backend)!448 put(b.root, 'same.txt', 'B version\n')!449 commit(b, 'B edits same')!450451 452 if _ := steal(b, mut backend, StealOptions{}) {453 assert false, 'an undecided clash must be refused'454 } else {455 assert err.msg().contains('--mine') || err.msg().contains('--theirs')456 }457 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,458 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0459460 461 report := steal_now(b, mut backend)!462 assert report.clashes == ['same.txt']463 assert report.kept_mine == 0464 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'465 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'466}467468fn test_choosing_mine_keeps_the_local_version() {469 base := scratch('mine')!470 defer {471 os.rmdir_all(base) or {}472 }473 mut backend := remote.new_folder(os.join_path(base, 'remote'))!474 mut a := open_repo(machine('mine_a')!)!475 defer {476 a.close()477 }478 mut b := open_repo(machine('mine_b')!)!479 defer {480 b.close()481 }482 put(a.root, 'same.txt', 'base\n')!483 commit(a, 'base')!484 offer_now(a, mut backend)!485 join(b, mut backend)!486 put(a.root, 'same.txt', 'A version\n')!487 commit(a, 'A edits')!488 offer_now(a, mut backend)!489 put(b.root, 'same.txt', 'B version\n')!490 commit(b, 'B edits')!491492 report := steal(b, mut backend, StealOptions{493 mine: true494 })!495 assert report.kept_mine == 1496 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'497 498 assert history.head(b)!.message.contains('combine: kept mine for 1 file')499 500 assert history.head(b)!.restores == ''501 assert history.all_saves(b)!.len >= 4502}503504fn test_flags_travel_with_the_saves() {505 base := scratch('flags')!506 defer {507 os.rmdir_all(base) or {}508 }509 mut backend := remote.new_folder(os.join_path(base, 'remote'))!510 mut a := open_repo(machine('flag_a')!)!511 defer {512 a.close()513 }514 mut b := open_repo(machine('flag_b')!)!515 defer {516 b.close()517 }518 put(a.root, 'a.txt', 'one\n')!519 save := commit(a, 'suspect save')!520 events.flag(a, save.id, 'look at this')!521 offer_now(a, mut backend)!522523 524 fetched := fetch_back_to_known(b, mut backend, 'proj')!525 assert fetched.events_out.len == 1526 assert fetched.events_out[0].kind == events.kind_flag527 b.begin()!528 write_fetched(b, fetched)!529 history.set_head(b, fetched.head)!530 b.commit()!531 state := events.flag_state(b, save.id)!532 assert state.flagged533 assert state.reason == 'look at this'534}535536fn test_a_second_offer_racing_head_is_refused_not_forced() {537 base := scratch('race')!538 defer {539 os.rmdir_all(base) or {}540 }541 mut backend := remote.new_folder(os.join_path(base, 'remote'))!542 mut a := open_repo(machine('race_a')!)!543 defer {544 a.close()545 }546 put(a.root, 'a.txt', 'one\n')!547 commit(a, 'base')!548 offer_now(a, mut backend)!549550 551 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),552 backend.get(head_key('proj'))!.tag)!553554 put(a.root, 'a.txt', 'two\n')!555 commit(a, 'second')!556 if _ := offer_now(a, mut backend) {557 assert false, 'offering while the remote head moved must be refused'558 } else {559 assert err.msg().contains('run koh steal')560 }561}562563564fn join(store &storage.Store, mut backend remote.Backend) ! {565 fetched := fetch_repo(mut backend, 'proj')!566 store.begin()!567 write_fetched(store, fetched)!568 history.set_head(store, fetched.head)!569 store.commit()!570 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,571 fetched.head)!, [])!572 store.set_state('remote_repo', 'proj')!573}574575struct FaultyBackend {576mut:577 inner remote.Backend578 writes int579 reads int580 fail_write int 581 fail_read int582 583 fail_delete bool584}585586fn (mut f FaultyBackend) get(key string) !remote.Object {587 f.reads++588 if f.fail_read > 0 && f.reads >= f.fail_read {589 return error('injected read failure')590 }591 return f.inner.get(key)!592}593594fn (mut f FaultyBackend) list(prefix string) ![]string {595 return f.inner.list(prefix)!596}597598fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {599 f.writes++600 if f.fail_write > 0 && f.writes >= f.fail_write {601 return error('injected write failure')602 }603 return f.inner.put_if_absent(key, body)!604}605606fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {607 f.writes++608 if f.fail_write > 0 && f.writes >= f.fail_write {609 return error('injected write failure')610 }611 return f.inner.put_if_match(key, body, tag)!612}613614fn (mut f FaultyBackend) delete(key string) ! {615 if f.fail_delete {616 return error('injected delete failure')617 }618 f.inner.delete(key)!619}620621fn test_a_failed_offer_is_safe_to_re_run() {622 base := scratch('fault_offer')!623 defer {624 os.rmdir_all(base) or {}625 }626 remote_dir := os.join_path(base, 'remote')627 mut a := open_repo(machine('fault_a')!)!628 defer {629 a.close()630 }631 put(a.root, 'a.txt', 'one\n')!632 commit(a, 'first')!633 put(a.root, 'a.txt', 'two\n')!634 commit(a, 'second')!635636 637 mut flaky := FaultyBackend{638 inner: remote.new_folder(remote_dir)!639 fail_write: 2640 }641 if _ := offer(a, mut flaky) {642 assert false, 'the injected failure should have surfaced'643 }644645 646 647 mut healthy := remote.new_folder(remote_dir)!648 report := offer(a, mut healthy)!649 assert report.saves == 2650 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id651 assert healthy.list('proj/saves')!.len == 2652}653654fn test_a_failed_steal_leaves_nothing_behind() {655 base := scratch('fault_steal')!656 defer {657 os.rmdir_all(base) or {}658 }659 remote_dir := os.join_path(base, 'remote')660 mut a := open_repo(machine('fault_s_a')!)!661 defer {662 a.close()663 }664 mut b := open_repo(machine('fault_s_b')!)!665 defer {666 b.close()667 }668 put(a.root, 'a.txt', 'from A\n')!669 commit(a, 'A writes')!670 mut healthy := remote.new_folder(remote_dir)!671 offer(a, mut healthy)!672673 674 mut flaky := FaultyBackend{675 inner: remote.new_folder(remote_dir)!676 fail_read: 2677 }678 if _ := fetch_back_to_known(b, mut flaky, 'proj') {679 assert false, 'the injected read failure should have surfaced'680 }681 682 assert history.head(b) == none683 assert b.object_count()! == 0684685 686 fetched := fetch_back_to_known(b, mut healthy, 'proj')!687 b.begin()!688 write_fetched(b, fetched)!689 history.set_head(b, fetched.head)!690 b.commit()!691 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!692 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'693}694695fn test_an_interrupted_strip_is_idempotent() {696 697 698 699 base := scratch('fault_strip')!700 defer {701 os.rmdir_all(base) or {}702 }703 mut a := open_repo(machine('fault_strip_a')!)!704 defer {705 a.close()706 }707 put(a.root, 'secret.txt', 'shh\n')!708 put(a.root, 'keep.txt', 'fine\n')!709 save := commit(a, 'add both')!710711 for _ in 0 .. 2 {712 strip_local(a, 'secret.txt', '')!713 }714 files := history.files_of(a, save.id)!715 716 717 assert files.map(it.path) == ['.kohignore', 'keep.txt']718 assert events.strip_events(a)!.len == 1719}720721722723724725fn test_a_strip_matches_the_path_exactly() {726 mut a := open_repo(machine('strip_exact')!)!727 defer {728 a.close()729 }730 hash := a.put_object('x\n'.bytes())!731 mut files := []snapshot.FileEntry{}732 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {733 files << snapshot.FileEntry{734 path: path735 hash: hash736 }737 }738 save := history.commit_files(a, '', files, history.SaveInput{739 message: 'add all'740 })!741 assert strip_scope(a, 'docs', '')!.files == 1742 strip_local(a, 'docs', '')!743 strip_local(a, 'a_b', '')!744 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']745}746747fn test_a_strip_reaches_the_remote_and_other_machines() {748 base := scratch('strip_sync')!749 defer {750 os.rmdir_all(base) or {}751 }752 mut a := open_repo(machine('strip_a')!)!753 defer {754 a.close()755 }756 mut b := open_repo(machine('strip_b')!)!757 defer {758 b.close()759 }760 put(a.root, 'secrets.env', 'api_key=1\n')!761 put(a.root, 'keep.txt', 'fine\n')!762 save := commit(a, 'add both')!763 mut backend := remote.new_folder(os.join_path(base, 'remote'))!764 offer_now(a, mut backend)!765 join(b, mut backend)!766 assert b.object_count()! > 0767768 769 770 mut secret_hash := ''771 for file in history.files_of(a, save.id)! {772 if file.path == 'secrets.env' {773 secret_hash = file.hash774 }775 }776 assert secret_hash != ''777 strip_local(a, 'secrets.env', '')!778 report := push_strip(a, mut backend, 'secrets.env')!779 assert report.event_sent780 assert report.records == 1781782 783 784 record := decode_save(backend.get(save_key('proj', save.id))!.body)!785 for file in record.files {786 assert file.path != 'secrets.env'787 }788 789 790 assert !backend.get(object_key('proj', secret_hash))!.found791 assert backend.list('proj/objects')!.len == 2792793 794 b.begin()!795 fetched := fetch_back_to_known(b, mut backend, 'proj')!796 write_fetched(b, fetched)!797 b.commit()!798 mut paths := []string{}799 for file in history.files_of(b, save.id)! {800 paths << file.path801 }802 assert 'secrets.env' !in paths803 assert 'keep.txt' in paths804805 806 807 808 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)809 put(b.root, 'secrets.env', 'rotated\n')!810 readded := commit(b, 'add the rotated key back')!811 offer_now(b, mut backend)!812 assert backend.list('proj/events')!.len == 1813 b.begin()!814 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!815 b.commit()!816 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)817}818819820821822fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {823 base := scratch('strip_clone')!824 defer {825 os.rmdir_all(base) or {}826 }827 mut backend := remote.new_folder(os.join_path(base, 'remote'))!828 mut a := open_repo(machine('strip_clone_a')!)!829 defer {830 a.close()831 }832 put(a.root, 'secrets.env', 'api_key=1\n')!833 old := commit(a, 'leak the key')!834 offer_now(a, mut backend)!835 strip_local(a, 'secrets.env', '')!836 push_strip(a, mut backend, 'secrets.env')!837 838 put(a.root, 'secrets.env', 'api_key=2\n')!839 rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{840 message: 'rotate the key'841 created_at: events.strip_events(a)!.last().created_at + 60842 })!843 offer_now(a, mut backend)!844845 mut c := open_repo(machine('strip_clone_c')!)!846 defer {847 c.close()848 }849 join(c, mut backend)!850 assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)851 assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)852 assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'853}854855856857858fn test_an_interrupted_remote_strip_finishes_when_re_run() {859 base := scratch('strip_resume')!860 defer {861 os.rmdir_all(base) or {}862 }863 remote_dir := os.join_path(base, 'remote')864 mut backend := remote.new_folder(remote_dir)!865 mut a := open_repo(machine('resume_a')!)!866 defer {867 a.close()868 }869 mut b := open_repo(machine('resume_b')!)!870 defer {871 b.close()872 }873 put(a.root, 'secrets.env', 'api_key=1\n')!874 put(a.root, 'shared.env', 'same bytes on B\n')!875 save := commit(a, 'add secrets')!876 offer_now(a, mut backend)!877 join(b, mut backend)!878 put(b.root, 'copy.txt', 'same bytes on B\n')!879 b_save := commit(b, 'B keeps a copy')!880 offer_now(b, mut backend)!881 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())882 secret_hash := storage.hash_hex('api_key=1\n'.bytes())883884 strip_local(a, 'secrets.env', '')!885 886 887 mut flaky := FaultyBackend{888 inner: remote.new_folder(remote_dir)!889 fail_write: 3890 }891 if _ := push_strip(a, mut flaky, 'secrets.env') {892 assert false, 'the injected failure should have surfaced'893 }894 assert strip_pending(a, 'secrets.env')895 assert strip_local(a, 'secrets.env', '')!.files_removed == 0896 report := push_strip(a, mut backend, 'secrets.env')!897 assert report.records == 1898 assert !strip_pending(a, 'secrets.env')899 assert !backend.get(object_key('proj', secret_hash))!.found900 record := decode_save(backend.get(save_key('proj', save.id))!.body)!901 assert 'secrets.env' !in record.files.map(it.path)902 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!903 assert 'secrets.env' !in b_record.files.map(it.path)904905 906 strip_local(a, 'shared.env', '')!907 push_strip(a, mut backend, 'shared.env')!908 assert backend.get(object_key('proj', shared_hash))!.found909}910911912913fn test_offer_waits_for_an_unapplied_strip() {914 base := scratch('strip_first')!915 defer {916 os.rmdir_all(base) or {}917 }918 mut backend := remote.new_folder(os.join_path(base, 'remote'))!919 mut a := open_repo(machine('strip_first_a')!)!920 defer {921 a.close()922 }923 mut b := open_repo(machine('strip_first_b')!)!924 defer {925 b.close()926 }927 put(a.root, 'secrets.env', 'api_key=1\n')!928 commit(a, 'add secrets')!929 offer_now(a, mut backend)!930 join(b, mut backend)!931 strip_local(a, 'secrets.env', '')!932 push_strip(a, mut backend, 'secrets.env')!933 put(b.root, 'more.txt', 'B keeps working\n')!934 later := commit(b, 'B saves with the secret still in its tree')!935 if _ := offer_now(b, mut backend) {936 assert false, 'B offered records that still list the stripped path'937 }938 steal_now(b, mut backend)!939 offer_now(b, mut backend)!940 record := decode_save(backend.get(save_key('proj', later.id))!.body)!941 assert 'secrets.env' !in record.files.map(it.path)942}943944945946fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {947 base := scratch('strip_twice')!948 defer {949 os.rmdir_all(base) or {}950 }951 remote_dir := os.join_path(base, 'remote')952 mut backend := remote.new_folder(remote_dir)!953 mut a := open_repo(machine('strip_twice_a')!)!954 defer {955 a.close()956 }957 put(a.root, 'secrets.env', 'first key\n')!958 commit(a, 'first key')!959 offer_now(a, mut backend)!960 strip_local(a, 'secrets.env', '')!961 mut flaky := FaultyBackend{962 inner: remote.new_folder(remote_dir)!963 fail_delete: true964 }965 if _ := push_strip(a, mut flaky, 'secrets.env') {966 assert false, 'the injected delete failure should have surfaced'967 }968 put(a.root, 'secrets.env', 'second key\n')!969 commit(a, 'second key')!970 offer_now(a, mut backend)!971 strip_local(a, 'secrets.env', '')!972 push_strip(a, mut backend, 'secrets.env')!973 for key in ['first key\n', 'second key\n'] {974 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found975 }976}977978979fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {980 base := scratch('strip_scopes')!981 defer {982 os.rmdir_all(base) or {}983 }984 remote_dir := os.join_path(base, 'remote')985 mut backend := remote.new_folder(remote_dir)!986 mut a := open_repo(machine('strip_scopes_a')!)!987 defer {988 a.close()989 }990 put(a.root, 'secrets.env', 'first key\n')!991 first := commit(a, 'first key')!992 put(a.root, 'secrets.env', 'second key\n')!993 second := commit(a, 'second key')!994 offer_now(a, mut backend)!995 strip_local(a, 'secrets.env', first.id)!996 997 mut flaky := FaultyBackend{998 inner: remote.new_folder(remote_dir)!999 fail_write: 21000 }1001 if _ := push_strip(a, mut flaky, 'secrets.env') {1002 assert false, 'the injected failure should have surfaced'1003 }1004 strip_local(a, 'secrets.env', second.id)!1005 push_strip(a, mut backend, 'secrets.env')!1006 assert !strip_pending(a, 'secrets.env')1007 for save in [first, second] {1008 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1009 assert 'secrets.env' !in record.files.map(it.path)1010 }1011 for key in ['first key\n', 'second key\n'] {1012 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1013 }1014}1015101610171018fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1019 base := scratch('strip_again')!1020 defer {1021 os.rmdir_all(base) or {}1022 }1023 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1024 mut a := open_repo(machine('strip_again_a')!)!1025 defer {1026 a.close()1027 }1028 mut b := open_repo(machine('strip_again_b')!)!1029 defer {1030 b.close()1031 }1032 put(a.root, 'secrets.env', 'first key\n')!1033 commit(a, 'first key')!1034 offer_now(a, mut backend)!1035 join(b, mut backend)!1036 strip_local(a, 'secrets.env', '')!1037 push_strip(a, mut backend, 'secrets.env')!1038 steal_now(b, mut backend)!1039 1040 os.rm(os.join_path(b.root, 'secrets.env'))!1041 put(a.root, 'secrets.env', 'second key\n')!1042 second := commit(a, 'second key')!1043 offer_now(a, mut backend)!1044 steal_now(b, mut backend)!1045 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1046 strip_local(a, 'secrets.env', '')!1047 push_strip(a, mut backend, 'secrets.env')!1048 steal_now(b, mut backend)!1049 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1050}1051105210531054fn test_a_stripped_path_is_not_asked_about() {1055 base := scratch('clash_strip')!1056 defer {1057 os.rmdir_all(base) or {}1058 }1059 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1060 mut a := open_repo(machine('clash_strip_a')!)!1061 defer {1062 a.close()1063 }1064 mut b := open_repo(machine('clash_strip_b')!)!1065 defer {1066 b.close()1067 }1068 put(a.root, 'secrets.env', 'key one\n')!1069 commit(a, 'base')!1070 offer_now(a, mut backend)!1071 join(b, mut backend)!1072 put(a.root, 'secrets.env', 'key two\n')!1073 commit(a, 'A rotates')!1074 offer_now(a, mut backend)!1075 strip_local(a, 'secrets.env', '')!1076 push_strip(a, mut backend, 'secrets.env')!1077 put(b.root, 'secrets.env', 'key three\n')!1078 mine := commit(b, 'B rotates')!1079 report := steal(b, mut backend, StealOptions{})!1080 assert report.clashes.len == 01081 for save in history.all_saves(b)! {1082 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1083 }1084 assert history.by_id(b, mine.id) != none1085}1086108710881089fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1090 base := scratch('clash_base_strip')!1091 defer {1092 os.rmdir_all(base) or {}1093 }1094 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1095 mut a := open_repo(machine('base_strip_a')!)!1096 defer {1097 a.close()1098 }1099 mut b := open_repo(machine('base_strip_b')!)!1100 defer {1101 b.close()1102 }1103 put(a.root, 'notes.txt', 'shared\n')!1104 common := commit(a, 'base')!1105 offer_now(a, mut backend)!1106 join(b, mut backend)!1107 put(a.root, 'other.txt', 'A moves on\n')!1108 commit(a, 'A moves on')!1109 offer_now(a, mut backend)!1110 strip_local(a, 'notes.txt', common.id)!1111 push_strip(a, mut backend, 'notes.txt')!1112 put(b.root, 'notes.txt', 'B edits\n')!1113 commit(b, 'B edits notes')!1114 if _ := steal(b, mut backend, StealOptions{}) {1115 assert false, 'with the base stripped, notes.txt differs on both sides'1116 } else {1117 assert err.msg().contains('changed on both machines')1118 }1119 report := steal(b, mut backend, StealOptions{1120 mine: true1121 })!1122 assert report.clashes == ['notes.txt']1123 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1124}1125112611271128fn test_steal_into_a_folder_with_unsaved_files_refuses() {1129 base := scratch('steal_unsaved')!1130 defer {1131 os.rmdir_all(base) or {}1132 }1133 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1134 mut a := open_repo(machine('unsaved_a')!)!1135 defer {1136 a.close()1137 }1138 put(a.root, 'notes.txt', 'from A\n')!1139 commit(a, 'A writes')!1140 offer_now(a, mut backend)!1141 b_root := machine('unsaved_b')!1142 mut b := storage.create(b_root)!1143 defer {1144 b.close()1145 }1146 put(b_root, 'notes.txt', 'never saved\n')!1147 1148 b.set_state('remote_repo', 'proj')!1149 if _ := steal(b, mut backend, StealOptions{}) {1150 assert false, 'steal overwrote a folder with unsaved files'1151 } else {1152 assert err.msg() == no_saves_here1153 }1154 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1155 1156 os.rm(os.join_path(b_root, 'notes.txt'))!1157 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1158 steal(b, mut backend, StealOptions{})!1159 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1160}1161116211631164fn test_steal_reports_incoming_flagged_saves() {1165 base := scratch('flag_warn')!1166 defer {1167 os.rmdir_all(base) or {}1168 }1169 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1170 mut a := open_repo(machine('flag_warn_a')!)!1171 defer {1172 a.close()1173 }1174 mut b := open_repo(machine('flag_warn_b')!)!1175 defer {1176 b.close()1177 }1178 put(a.root, 'a.txt', 'one\n')!1179 commit(a, 'fine')!1180 offer_now(a, mut backend)!1181 join(b, mut backend)!1182 put(a.root, 'a.txt', 'two\n')!1183 suspect := commit(a, 'suspect')!1184 put(a.root, 'a.txt', 'three\n')!1185 commit(a, 'also fine')!1186 events.flag(a, suspect.id, 'broke the build')!1187 offer_now(a, mut backend)!1188 report := steal_now(b, mut backend)!1189 assert report.flagged.map(it.save_id) == [suspect.id]1190 assert report.flagged[0].reason == 'broke the build'1191}