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}198199200201fn test_a_clone_imports_parents_before_children() {202 base := scratch('clone_order')!203 defer {204 os.rmdir_all(base) or {}205 }206 mut backend := remote.new_folder(os.join_path(base, 'remote'))!207 mut a := open_repo(machine('order_a')!)!208 defer {209 a.close()210 }211 put(a.root, 'a.txt', 'one\n')!212 parent := commit(a, 'parent')!213 child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{214 message: 'replayed, older than its parent'215 created_at: parent.created_at - 3600216 })!217 offer_now(a, mut backend)!218 mut b := open_repo(machine('order_b')!)!219 defer {220 b.close()221 }222 join(b, mut backend)!223 assert history.head(b)!.id == child.id224}225226227fn test_steal_refuses_an_unsaved_executable_bit_change() {228 base := scratch('chmod')!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('chmod_a')!)!234 defer {235 a.close()236 }237 put(a.root, 'run.sh', 'echo hi\n')!238 commit(a, 'script')!239 offer_now(a, mut backend)!240 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!241 if _ := steal_now(a, mut backend) {242 assert false, 'a chmod-only change should make the tree dirty'243 } else {244 assert err.msg() == dirty_tree245 }246}247248fn test_divergence_without_clashes_keeps_both_machines_work() {249 base := scratch('diverge')!250 defer {251 os.rmdir_all(base) or {}252 }253 mut backend := remote.new_folder(os.join_path(base, 'remote'))!254 mut a := open_repo(machine('div_a')!)!255 defer {256 a.close()257 }258 mut b := open_repo(machine('div_b')!)!259 defer {260 b.close()261 }262 put(a.root, 'shared.txt', 'base\n')!263 commit(a, 'base')!264 offer_now(a, mut backend)!265266 267 join(b, mut backend)!268 put(a.root, 'fromA.txt', 'A\n')!269 commit(a, 'A adds a file')!270 put(b.root, 'fromB.txt', 'B\n')!271 commit(b, 'B adds a file')!272 offer_now(a, mut backend)!273274 275 if _ := offer_now(b, mut backend) {276 assert false, 'offering on top of unknown remote saves should be refused'277 } else {278 assert err.msg().contains('run koh steal')279 }280281 report := steal_now(b, mut backend)!282 assert !report.forward283 assert report.replayed == 1284 assert report.clashes.len == 0285 286 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'287 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'288 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'289 290 mut saw_replay := false291 for save in history.all_saves(b)! {292 if save.replayed_from != '' {293 saw_replay = true294 }295 }296 assert saw_replay297 298 offer_now(b, mut backend)!299 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()300 assert line_head == history.head(b)!.id301}302303304305306307fn test_a_local_change_undone_does_not_block_a_combine() {308 base := scratch('undone')!309 defer {310 os.rmdir_all(base) or {}311 }312 mut backend := remote.new_folder(os.join_path(base, 'remote'))!313 mut a := open_repo(machine('undone_a')!)!314 defer {315 a.close()316 }317 mut b := open_repo(machine('undone_b')!)!318 defer {319 b.close()320 }321 put(a.root, 'f.txt', 'base\n')!322 put(a.root, 'g.txt', 'base\n')!323 commit(a, 'base')!324 offer_now(a, mut backend)!325 join(b, mut backend)!326327 put(a.root, 'f.txt', 'from A\n')!328 put(a.root, 'g.txt', 'same\n')!329 commit(a, 'A edits both')!330 offer_now(a, mut backend)!331332 put(b.root, 'f.txt', 'tried on B\n')!333 put(b.root, 'g.txt', 'first try\n')!334 commit(b, 'B tries')!335 put(b.root, 'f.txt', 'base\n')!336 put(b.root, 'g.txt', 'same\n')!337 put(b.root, 'h.txt', 'B\n')!338 commit(b, 'B undoes f, lands on the same g, adds h')!339340 report := steal_now(b, mut backend)!341 assert report.clashes.len == 0342 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'343 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'344 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'345}346347348349fn test_clash_questions_are_asked_before_the_write_lock() {350 base := scratch('ask_unlocked')!351 defer {352 os.rmdir_all(base) or {}353 }354 mut backend := remote.new_folder(os.join_path(base, 'remote'))!355 mut a := open_repo(machine('ask_a')!)!356 defer {357 a.close()358 }359 mut b := open_repo(machine('ask_b')!)!360 defer {361 b.close()362 }363 put(a.root, 'same.txt', 'base\n')!364 commit(a, 'base')!365 offer_now(a, mut backend)!366 join(b, mut backend)!367 put(a.root, 'same.txt', 'A version\n')!368 commit(a, 'A edits same')!369 offer_now(a, mut backend)!370 put(b.root, 'same.txt', 'B version\n')!371 commit(b, 'B edits same')!372 root := b.root373 374 if _ := steal(b, mut backend, StealOptions{375 ask: fn [root] (clash Clash) Side {376 other := storage.open(root) or { panic(err) }377 defer {378 other.close()379 }380 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }381 history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{382 message: 'agent saves during the question'383 }) or { panic('steal held the write lock while asking: ${err}') }384 return .mine385 }386 })387 {388 assert false, 'a steal whose line moved during the question must refuse'389 } else {390 assert err.msg().contains('changed while')391 }392 393 assert history.head(b)!.message == 'agent saves during the question'394 report := steal(b, mut backend, StealOptions{395 mine: true396 })!397 assert report.kept_mine == 1398 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'399 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'400}401402fn test_clashing_files_are_chosen_one_at_a_time() {403 base := scratch('clash')!404 defer {405 os.rmdir_all(base) or {}406 }407 mut backend := remote.new_folder(os.join_path(base, 'remote'))!408 mut a := open_repo(machine('clash_a')!)!409 defer {410 a.close()411 }412 mut b := open_repo(machine('clash_b')!)!413 defer {414 b.close()415 }416 put(a.root, 'same.txt', 'base\n')!417 put(a.root, 'other.txt', 'quiet\n')!418 commit(a, 'base')!419 offer_now(a, mut backend)!420 join(b, mut backend)!421422 423 put(a.root, 'same.txt', 'A version\n')!424 commit(a, 'A edits same')!425 offer_now(a, mut backend)!426 put(b.root, 'same.txt', 'B version\n')!427 commit(b, 'B edits same')!428429 430 if _ := steal(b, mut backend, StealOptions{}) {431 assert false, 'an undecided clash must be refused'432 } else {433 assert err.msg().contains('--mine') || err.msg().contains('--theirs')434 }435 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,436 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0437438 439 report := steal_now(b, mut backend)!440 assert report.clashes == ['same.txt']441 assert report.kept_mine == 0442 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'443 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'444}445446fn test_choosing_mine_keeps_the_local_version() {447 base := scratch('mine')!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('mine_a')!)!453 defer {454 a.close()455 }456 mut b := open_repo(machine('mine_b')!)!457 defer {458 b.close()459 }460 put(a.root, 'same.txt', 'base\n')!461 commit(a, 'base')!462 offer_now(a, mut backend)!463 join(b, mut backend)!464 put(a.root, 'same.txt', 'A version\n')!465 commit(a, 'A edits')!466 offer_now(a, mut backend)!467 put(b.root, 'same.txt', 'B version\n')!468 commit(b, 'B edits')!469470 report := steal(b, mut backend, StealOptions{471 mine: true472 })!473 assert report.kept_mine == 1474 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'475 476 assert history.head(b)!.message.contains('combine: kept mine for 1 file')477 478 assert history.head(b)!.restores == ''479 assert history.all_saves(b)!.len >= 4480}481482fn test_flags_travel_with_the_saves() {483 base := scratch('flags')!484 defer {485 os.rmdir_all(base) or {}486 }487 mut backend := remote.new_folder(os.join_path(base, 'remote'))!488 mut a := open_repo(machine('flag_a')!)!489 defer {490 a.close()491 }492 mut b := open_repo(machine('flag_b')!)!493 defer {494 b.close()495 }496 put(a.root, 'a.txt', 'one\n')!497 save := commit(a, 'suspect save')!498 events.flag(a, save.id, 'look at this')!499 offer_now(a, mut backend)!500501 502 fetched := fetch_back_to_known(b, mut backend, 'proj')!503 assert fetched.events_out.len == 1504 assert fetched.events_out[0].kind == events.kind_flag505 b.begin()!506 write_fetched(b, fetched)!507 history.set_head(b, fetched.head)!508 b.commit()!509 state := events.flag_state(b, save.id)!510 assert state.flagged511 assert state.reason == 'look at this'512}513514fn test_a_second_offer_racing_head_is_refused_not_forced() {515 base := scratch('race')!516 defer {517 os.rmdir_all(base) or {}518 }519 mut backend := remote.new_folder(os.join_path(base, 'remote'))!520 mut a := open_repo(machine('race_a')!)!521 defer {522 a.close()523 }524 put(a.root, 'a.txt', 'one\n')!525 commit(a, 'base')!526 offer_now(a, mut backend)!527528 529 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),530 backend.get(head_key('proj'))!.tag)!531532 put(a.root, 'a.txt', 'two\n')!533 commit(a, 'second')!534 if _ := offer_now(a, mut backend) {535 assert false, 'offering while the remote head moved must be refused'536 } else {537 assert err.msg().contains('run koh steal')538 }539}540541542fn join(store &storage.Store, mut backend remote.Backend) ! {543 fetched := fetch_repo(mut backend, 'proj')!544 store.begin()!545 write_fetched(store, fetched)!546 history.set_head(store, fetched.head)!547 store.commit()!548 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,549 fetched.head)!, [])!550 store.set_state('remote_repo', 'proj')!551}552553struct FaultyBackend {554mut:555 inner remote.Backend556 writes int557 reads int558 fail_write int 559 fail_read int560 561 fail_delete bool562}563564fn (mut f FaultyBackend) get(key string) !remote.Object {565 f.reads++566 if f.fail_read > 0 && f.reads >= f.fail_read {567 return error('injected read failure')568 }569 return f.inner.get(key)!570}571572fn (mut f FaultyBackend) list(prefix string) ![]string {573 return f.inner.list(prefix)!574}575576fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {577 f.writes++578 if f.fail_write > 0 && f.writes >= f.fail_write {579 return error('injected write failure')580 }581 return f.inner.put_if_absent(key, body)!582}583584fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {585 f.writes++586 if f.fail_write > 0 && f.writes >= f.fail_write {587 return error('injected write failure')588 }589 return f.inner.put_if_match(key, body, tag)!590}591592fn (mut f FaultyBackend) delete(key string) ! {593 if f.fail_delete {594 return error('injected delete failure')595 }596 f.inner.delete(key)!597}598599fn test_a_failed_offer_is_safe_to_re_run() {600 base := scratch('fault_offer')!601 defer {602 os.rmdir_all(base) or {}603 }604 remote_dir := os.join_path(base, 'remote')605 mut a := open_repo(machine('fault_a')!)!606 defer {607 a.close()608 }609 put(a.root, 'a.txt', 'one\n')!610 commit(a, 'first')!611 put(a.root, 'a.txt', 'two\n')!612 commit(a, 'second')!613614 615 mut flaky := FaultyBackend{616 inner: remote.new_folder(remote_dir)!617 fail_write: 2618 }619 if _ := offer(a, mut flaky) {620 assert false, 'the injected failure should have surfaced'621 }622623 624 625 mut healthy := remote.new_folder(remote_dir)!626 report := offer(a, mut healthy)!627 assert report.saves == 2628 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id629 assert healthy.list('proj/saves')!.len == 2630}631632fn test_a_failed_steal_leaves_nothing_behind() {633 base := scratch('fault_steal')!634 defer {635 os.rmdir_all(base) or {}636 }637 remote_dir := os.join_path(base, 'remote')638 mut a := open_repo(machine('fault_s_a')!)!639 defer {640 a.close()641 }642 mut b := open_repo(machine('fault_s_b')!)!643 defer {644 b.close()645 }646 put(a.root, 'a.txt', 'from A\n')!647 commit(a, 'A writes')!648 mut healthy := remote.new_folder(remote_dir)!649 offer(a, mut healthy)!650651 652 mut flaky := FaultyBackend{653 inner: remote.new_folder(remote_dir)!654 fail_read: 2655 }656 if _ := fetch_back_to_known(b, mut flaky, 'proj') {657 assert false, 'the injected read failure should have surfaced'658 }659 660 assert history.head(b) == none661 assert b.object_count()! == 0662663 664 fetched := fetch_back_to_known(b, mut healthy, 'proj')!665 b.begin()!666 write_fetched(b, fetched)!667 history.set_head(b, fetched.head)!668 b.commit()!669 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!670 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'671}672673fn test_an_interrupted_strip_is_idempotent() {674 675 676 677 base := scratch('fault_strip')!678 defer {679 os.rmdir_all(base) or {}680 }681 mut a := open_repo(machine('fault_strip_a')!)!682 defer {683 a.close()684 }685 put(a.root, 'secret.txt', 'shh\n')!686 put(a.root, 'keep.txt', 'fine\n')!687 save := commit(a, 'add both')!688689 for _ in 0 .. 2 {690 strip_local(a, 'secret.txt', '')!691 }692 files := history.files_of(a, save.id)!693 694 695 assert files.map(it.path) == ['.kohignore', 'keep.txt']696 assert events.strip_events(a)!.len == 1697}698699700701702703fn test_a_strip_matches_the_path_exactly() {704 mut a := open_repo(machine('strip_exact')!)!705 defer {706 a.close()707 }708 hash := a.put_object('x\n'.bytes())!709 mut files := []snapshot.FileEntry{}710 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {711 files << snapshot.FileEntry{712 path: path713 hash: hash714 }715 }716 save := history.commit_files(a, '', files, history.SaveInput{717 message: 'add all'718 })!719 assert strip_scope(a, 'docs', '')!.files == 1720 strip_local(a, 'docs', '')!721 strip_local(a, 'a_b', '')!722 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']723}724725fn test_a_strip_reaches_the_remote_and_other_machines() {726 base := scratch('strip_sync')!727 defer {728 os.rmdir_all(base) or {}729 }730 mut a := open_repo(machine('strip_a')!)!731 defer {732 a.close()733 }734 mut b := open_repo(machine('strip_b')!)!735 defer {736 b.close()737 }738 put(a.root, 'secrets.env', 'api_key=1\n')!739 put(a.root, 'keep.txt', 'fine\n')!740 save := commit(a, 'add both')!741 mut backend := remote.new_folder(os.join_path(base, 'remote'))!742 offer_now(a, mut backend)!743 join(b, mut backend)!744 assert b.object_count()! > 0745746 747 748 mut secret_hash := ''749 for file in history.files_of(a, save.id)! {750 if file.path == 'secrets.env' {751 secret_hash = file.hash752 }753 }754 assert secret_hash != ''755 strip_local(a, 'secrets.env', '')!756 report := push_strip(a, mut backend, 'secrets.env')!757 assert report.event_sent758 assert report.records == 1759760 761 762 record := decode_save(backend.get(save_key('proj', save.id))!.body)!763 for file in record.files {764 assert file.path != 'secrets.env'765 }766 767 768 assert !backend.get(object_key('proj', secret_hash))!.found769 assert backend.list('proj/objects')!.len == 2770771 772 b.begin()!773 fetched := fetch_back_to_known(b, mut backend, 'proj')!774 write_fetched(b, fetched)!775 b.commit()!776 mut paths := []string{}777 for file in history.files_of(b, save.id)! {778 paths << file.path779 }780 assert 'secrets.env' !in paths781 assert 'keep.txt' in paths782783 784 785 786 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)787 put(b.root, 'secrets.env', 'rotated\n')!788 readded := commit(b, 'add the rotated key back')!789 offer_now(b, mut backend)!790 assert backend.list('proj/events')!.len == 1791 b.begin()!792 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!793 b.commit()!794 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)795}796797798799800fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {801 base := scratch('strip_clone')!802 defer {803 os.rmdir_all(base) or {}804 }805 mut backend := remote.new_folder(os.join_path(base, 'remote'))!806 mut a := open_repo(machine('strip_clone_a')!)!807 defer {808 a.close()809 }810 put(a.root, 'secrets.env', 'api_key=1\n')!811 old := commit(a, 'leak the key')!812 offer_now(a, mut backend)!813 strip_local(a, 'secrets.env', '')!814 push_strip(a, mut backend, 'secrets.env')!815 816 put(a.root, 'secrets.env', 'api_key=2\n')!817 rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{818 message: 'rotate the key'819 created_at: events.strip_events(a)!.last().created_at + 60820 })!821 offer_now(a, mut backend)!822823 mut c := open_repo(machine('strip_clone_c')!)!824 defer {825 c.close()826 }827 join(c, mut backend)!828 assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)829 assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)830 assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'831}832833834835836fn test_an_interrupted_remote_strip_finishes_when_re_run() {837 base := scratch('strip_resume')!838 defer {839 os.rmdir_all(base) or {}840 }841 remote_dir := os.join_path(base, 'remote')842 mut backend := remote.new_folder(remote_dir)!843 mut a := open_repo(machine('resume_a')!)!844 defer {845 a.close()846 }847 mut b := open_repo(machine('resume_b')!)!848 defer {849 b.close()850 }851 put(a.root, 'secrets.env', 'api_key=1\n')!852 put(a.root, 'shared.env', 'same bytes on B\n')!853 save := commit(a, 'add secrets')!854 offer_now(a, mut backend)!855 join(b, mut backend)!856 put(b.root, 'copy.txt', 'same bytes on B\n')!857 b_save := commit(b, 'B keeps a copy')!858 offer_now(b, mut backend)!859 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())860 secret_hash := storage.hash_hex('api_key=1\n'.bytes())861862 strip_local(a, 'secrets.env', '')!863 864 865 mut flaky := FaultyBackend{866 inner: remote.new_folder(remote_dir)!867 fail_write: 3868 }869 if _ := push_strip(a, mut flaky, 'secrets.env') {870 assert false, 'the injected failure should have surfaced'871 }872 assert strip_pending(a, 'secrets.env')873 assert strip_local(a, 'secrets.env', '')!.files_removed == 0874 report := push_strip(a, mut backend, 'secrets.env')!875 assert report.records == 1876 assert !strip_pending(a, 'secrets.env')877 assert !backend.get(object_key('proj', secret_hash))!.found878 record := decode_save(backend.get(save_key('proj', save.id))!.body)!879 assert 'secrets.env' !in record.files.map(it.path)880 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!881 assert 'secrets.env' !in b_record.files.map(it.path)882883 884 strip_local(a, 'shared.env', '')!885 push_strip(a, mut backend, 'shared.env')!886 assert backend.get(object_key('proj', shared_hash))!.found887}888889890891fn test_offer_waits_for_an_unapplied_strip() {892 base := scratch('strip_first')!893 defer {894 os.rmdir_all(base) or {}895 }896 mut backend := remote.new_folder(os.join_path(base, 'remote'))!897 mut a := open_repo(machine('strip_first_a')!)!898 defer {899 a.close()900 }901 mut b := open_repo(machine('strip_first_b')!)!902 defer {903 b.close()904 }905 put(a.root, 'secrets.env', 'api_key=1\n')!906 commit(a, 'add secrets')!907 offer_now(a, mut backend)!908 join(b, mut backend)!909 strip_local(a, 'secrets.env', '')!910 push_strip(a, mut backend, 'secrets.env')!911 put(b.root, 'more.txt', 'B keeps working\n')!912 later := commit(b, 'B saves with the secret still in its tree')!913 if _ := offer_now(b, mut backend) {914 assert false, 'B offered records that still list the stripped path'915 }916 steal_now(b, mut backend)!917 offer_now(b, mut backend)!918 record := decode_save(backend.get(save_key('proj', later.id))!.body)!919 assert 'secrets.env' !in record.files.map(it.path)920}921922923924fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {925 base := scratch('strip_twice')!926 defer {927 os.rmdir_all(base) or {}928 }929 remote_dir := os.join_path(base, 'remote')930 mut backend := remote.new_folder(remote_dir)!931 mut a := open_repo(machine('strip_twice_a')!)!932 defer {933 a.close()934 }935 put(a.root, 'secrets.env', 'first key\n')!936 commit(a, 'first key')!937 offer_now(a, mut backend)!938 strip_local(a, 'secrets.env', '')!939 mut flaky := FaultyBackend{940 inner: remote.new_folder(remote_dir)!941 fail_delete: true942 }943 if _ := push_strip(a, mut flaky, 'secrets.env') {944 assert false, 'the injected delete failure should have surfaced'945 }946 put(a.root, 'secrets.env', 'second key\n')!947 commit(a, 'second key')!948 offer_now(a, mut backend)!949 strip_local(a, 'secrets.env', '')!950 push_strip(a, mut backend, 'secrets.env')!951 for key in ['first key\n', 'second key\n'] {952 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found953 }954}955956957fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {958 base := scratch('strip_scopes')!959 defer {960 os.rmdir_all(base) or {}961 }962 remote_dir := os.join_path(base, 'remote')963 mut backend := remote.new_folder(remote_dir)!964 mut a := open_repo(machine('strip_scopes_a')!)!965 defer {966 a.close()967 }968 put(a.root, 'secrets.env', 'first key\n')!969 first := commit(a, 'first key')!970 put(a.root, 'secrets.env', 'second key\n')!971 second := commit(a, 'second key')!972 offer_now(a, mut backend)!973 strip_local(a, 'secrets.env', first.id)!974 975 mut flaky := FaultyBackend{976 inner: remote.new_folder(remote_dir)!977 fail_write: 2978 }979 if _ := push_strip(a, mut flaky, 'secrets.env') {980 assert false, 'the injected failure should have surfaced'981 }982 strip_local(a, 'secrets.env', second.id)!983 push_strip(a, mut backend, 'secrets.env')!984 assert !strip_pending(a, 'secrets.env')985 for save in [first, second] {986 record := decode_save(backend.get(save_key('proj', save.id))!.body)!987 assert 'secrets.env' !in record.files.map(it.path)988 }989 for key in ['first key\n', 'second key\n'] {990 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found991 }992}993994995996fn test_a_second_strip_of_the_same_path_reaches_other_machines() {997 base := scratch('strip_again')!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('strip_again_a')!)!1003 defer {1004 a.close()1005 }1006 mut b := open_repo(machine('strip_again_b')!)!1007 defer {1008 b.close()1009 }1010 put(a.root, 'secrets.env', 'first key\n')!1011 commit(a, 'first key')!1012 offer_now(a, mut backend)!1013 join(b, mut backend)!1014 strip_local(a, 'secrets.env', '')!1015 push_strip(a, mut backend, 'secrets.env')!1016 steal_now(b, mut backend)!1017 1018 os.rm(os.join_path(b.root, 'secrets.env'))!1019 put(a.root, 'secrets.env', 'second key\n')!1020 second := commit(a, 'second key')!1021 offer_now(a, mut backend)!1022 steal_now(b, mut backend)!1023 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1024 strip_local(a, 'secrets.env', '')!1025 push_strip(a, mut backend, 'secrets.env')!1026 steal_now(b, mut backend)!1027 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1028}1029103010311032fn test_a_stripped_path_is_not_asked_about() {1033 base := scratch('clash_strip')!1034 defer {1035 os.rmdir_all(base) or {}1036 }1037 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1038 mut a := open_repo(machine('clash_strip_a')!)!1039 defer {1040 a.close()1041 }1042 mut b := open_repo(machine('clash_strip_b')!)!1043 defer {1044 b.close()1045 }1046 put(a.root, 'secrets.env', 'key one\n')!1047 commit(a, 'base')!1048 offer_now(a, mut backend)!1049 join(b, mut backend)!1050 put(a.root, 'secrets.env', 'key two\n')!1051 commit(a, 'A rotates')!1052 offer_now(a, mut backend)!1053 strip_local(a, 'secrets.env', '')!1054 push_strip(a, mut backend, 'secrets.env')!1055 put(b.root, 'secrets.env', 'key three\n')!1056 mine := commit(b, 'B rotates')!1057 report := steal(b, mut backend, StealOptions{})!1058 assert report.clashes.len == 01059 for save in history.all_saves(b)! {1060 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1061 }1062 assert history.by_id(b, mine.id) != none1063}1064106510661067fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1068 base := scratch('clash_base_strip')!1069 defer {1070 os.rmdir_all(base) or {}1071 }1072 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1073 mut a := open_repo(machine('base_strip_a')!)!1074 defer {1075 a.close()1076 }1077 mut b := open_repo(machine('base_strip_b')!)!1078 defer {1079 b.close()1080 }1081 put(a.root, 'notes.txt', 'shared\n')!1082 common := commit(a, 'base')!1083 offer_now(a, mut backend)!1084 join(b, mut backend)!1085 put(a.root, 'other.txt', 'A moves on\n')!1086 commit(a, 'A moves on')!1087 offer_now(a, mut backend)!1088 strip_local(a, 'notes.txt', common.id)!1089 push_strip(a, mut backend, 'notes.txt')!1090 put(b.root, 'notes.txt', 'B edits\n')!1091 commit(b, 'B edits notes')!1092 if _ := steal(b, mut backend, StealOptions{}) {1093 assert false, 'with the base stripped, notes.txt differs on both sides'1094 } else {1095 assert err.msg().contains('changed on both machines')1096 }1097 report := steal(b, mut backend, StealOptions{1098 mine: true1099 })!1100 assert report.clashes == ['notes.txt']1101 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1102}1103110411051106fn test_steal_into_a_folder_with_unsaved_files_refuses() {1107 base := scratch('steal_unsaved')!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('unsaved_a')!)!1113 defer {1114 a.close()1115 }1116 put(a.root, 'notes.txt', 'from A\n')!1117 commit(a, 'A writes')!1118 offer_now(a, mut backend)!1119 b_root := machine('unsaved_b')!1120 mut b := storage.create(b_root)!1121 defer {1122 b.close()1123 }1124 put(b_root, 'notes.txt', 'never saved\n')!1125 1126 b.set_state('remote_repo', 'proj')!1127 if _ := steal(b, mut backend, StealOptions{}) {1128 assert false, 'steal overwrote a folder with unsaved files'1129 } else {1130 assert err.msg() == no_saves_here1131 }1132 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1133 1134 os.rm(os.join_path(b_root, 'notes.txt'))!1135 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1136 steal(b, mut backend, StealOptions{})!1137 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1138}1139114011411142fn test_steal_reports_incoming_flagged_saves() {1143 base := scratch('flag_warn')!1144 defer {1145 os.rmdir_all(base) or {}1146 }1147 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1148 mut a := open_repo(machine('flag_warn_a')!)!1149 defer {1150 a.close()1151 }1152 mut b := open_repo(machine('flag_warn_b')!)!1153 defer {1154 b.close()1155 }1156 put(a.root, 'a.txt', 'one\n')!1157 commit(a, 'fine')!1158 offer_now(a, mut backend)!1159 join(b, mut backend)!1160 put(a.root, 'a.txt', 'two\n')!1161 suspect := commit(a, 'suspect')!1162 put(a.root, 'a.txt', 'three\n')!1163 commit(a, 'also fine')!1164 events.flag(a, suspect.id, 'broke the build')!1165 offer_now(a, mut backend)!1166 report := steal_now(b, mut backend)!1167 assert report.flagged.map(it.save_id) == [suspect.id]1168 assert report.flagged[0].reason == 'broke the build'1169}