12345module sync67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage13141516fn machine(name string) !string {17 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')18 os.rmdir_all(os.dir(dir)) or {}19 os.mkdir_all(dir, os.MkdirParams{})!20 return dir21}2223fn scratch(name string) !string {24 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')25 os.rmdir_all(dir) or {}26 os.mkdir_all(dir, os.MkdirParams{})!27 return dir28}2930fn open_repo(dir string) !storage.Store {31 mut store := storage.create(dir)!32 os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!33 return store34}3536fn put(root string, rel string, text string) ! {37 full := os.join_path(root, rel)38 os.mkdir_all(os.dir(full), os.MkdirParams{})!39 os.write_file(full, text)!40}4142fn commit(store &storage.Store, message string) !history.Save {43 return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{44 message: message45 })!46}4748fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {49 return offer(store, mut backend)!50}5152fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {53 return steal(store, mut backend, StealOptions{54 theirs: true55 })!56}5758fn test_first_offer_publishes_the_whole_line() {59 base := scratch('first')!60 defer {61 os.rmdir_all(base) or {}62 }63 mut a := open_repo(machine('first_a')!)!64 defer {65 a.close()66 }67 put(a.root, 'a.txt', 'one\n')!68 commit(a, 'first')!69 put(a.root, 'a.txt', 'two\n')!70 commit(a, 'second')!71 mut backend := remote.new_folder(os.join_path(base, 'remote'))!7273 report := offer_now(a, mut backend)!74 assert report.repo == 'proj'75 assert report.saves == 276 assert report.objects >= 277 assert backend.get(head_key('proj'))!.found78 assert backend.list('proj/saves')!.len == 27980 81 again := offer_now(a, mut backend)!82 assert again.saves == 083}8485fn test_the_repo_name_is_remembered_and_head_offered() {86 base := scratch('remember')!87 defer {88 os.rmdir_all(base) or {}89 }90 mut a := open_repo(machine('remember_a')!)!91 defer {92 a.close()93 }94 put(a.root, 'a.txt', 'one\n')!95 commit(a, 'first')!96 mut backend := remote.new_folder(os.join_path(base, 'remote'))!97 offer_now(a, mut backend)!98 assert a.state('remote_repo') or { '' } == 'proj'99 assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id100101 102 103 put(a.root, 'a.txt', 'two\n')!104 commit(a, 'second')!105 offer_now(a, mut backend)!106 assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id107}108109fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {110 base := scratch('forward')!111 defer {112 os.rmdir_all(base) or {}113 }114 mut backend := remote.new_folder(os.join_path(base, 'remote'))!115 mut a := open_repo(machine('fwd_a')!)!116 defer {117 a.close()118 }119 put(a.root, 'a.txt', 'from A\n')!120 commit(a, 'A writes')!121 offer_now(a, mut backend)!122123 124 mut b := open_repo(machine('fwd_b')!)!125 defer {126 b.close()127 }128 fetched := fetch_repo(mut backend, 'proj')!129 b.begin()!130 write_fetched(b, fetched)!131 history.set_head(b, fetched.head)!132 b.commit()!133 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!134 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'135 assert history.head(b)!.id == fetched.head136}137138139140fn test_steal_while_only_ahead_leaves_the_line_alone() {141 base := scratch('ahead')!142 defer {143 os.rmdir_all(base) or {}144 }145 mut backend := remote.new_folder(os.join_path(base, 'remote'))!146 mut a := open_repo(machine('ahead_a')!)!147 defer {148 a.close()149 }150 put(a.root, 'a.txt', 'one\n')!151 commit(a, 'offered')!152 offer_now(a, mut backend)!153 put(a.root, 'a.txt', 'two\n')!154 ahead := commit(a, 'not offered yet')!155 report := steal_now(a, mut backend)!156 assert report.replayed == 0157 assert history.head(a)!.id == ahead.id158 assert history.all_saves(a)!.len == 2159 offer_now(a, mut backend)!160 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id161}162163164165fn test_a_clone_imports_parents_before_children() {166 base := scratch('clone_order')!167 defer {168 os.rmdir_all(base) or {}169 }170 mut backend := remote.new_folder(os.join_path(base, 'remote'))!171 mut a := open_repo(machine('order_a')!)!172 defer {173 a.close()174 }175 put(a.root, 'a.txt', 'one\n')!176 parent := commit(a, 'parent')!177 child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{178 message: 'replayed, older than its parent'179 created_at: parent.created_at - 3600180 })!181 offer_now(a, mut backend)!182 mut b := open_repo(machine('order_b')!)!183 defer {184 b.close()185 }186 join(b, mut backend)!187 assert history.head(b)!.id == child.id188}189190191fn test_steal_refuses_an_unsaved_executable_bit_change() {192 base := scratch('chmod')!193 defer {194 os.rmdir_all(base) or {}195 }196 mut backend := remote.new_folder(os.join_path(base, 'remote'))!197 mut a := open_repo(machine('chmod_a')!)!198 defer {199 a.close()200 }201 put(a.root, 'run.sh', 'echo hi\n')!202 commit(a, 'script')!203 offer_now(a, mut backend)!204 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!205 if _ := steal_now(a, mut backend) {206 assert false, 'a chmod-only change should make the tree dirty'207 } else {208 assert err.msg() == dirty_tree209 }210}211212fn test_divergence_without_clashes_keeps_both_machines_work() {213 base := scratch('diverge')!214 defer {215 os.rmdir_all(base) or {}216 }217 mut backend := remote.new_folder(os.join_path(base, 'remote'))!218 mut a := open_repo(machine('div_a')!)!219 defer {220 a.close()221 }222 mut b := open_repo(machine('div_b')!)!223 defer {224 b.close()225 }226 put(a.root, 'shared.txt', 'base\n')!227 commit(a, 'base')!228 offer_now(a, mut backend)!229230 231 join(b, mut backend)!232 put(a.root, 'fromA.txt', 'A\n')!233 commit(a, 'A adds a file')!234 put(b.root, 'fromB.txt', 'B\n')!235 commit(b, 'B adds a file')!236 offer_now(a, mut backend)!237238 239 if _ := offer_now(b, mut backend) {240 assert false, 'offering on top of unknown remote saves should be refused'241 } else {242 assert err.msg().contains('run koh steal')243 }244245 report := steal_now(b, mut backend)!246 assert !report.forward247 assert report.replayed == 1248 assert report.clashes.len == 0249 250 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'251 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'252 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'253 254 mut saw_replay := false255 for save in history.all_saves(b)! {256 if save.replayed_from != '' {257 saw_replay = true258 }259 }260 assert saw_replay261 262 offer_now(b, mut backend)!263 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()264 assert line_head == history.head(b)!.id265}266267268269270271fn test_a_local_change_undone_does_not_block_a_combine() {272 base := scratch('undone')!273 defer {274 os.rmdir_all(base) or {}275 }276 mut backend := remote.new_folder(os.join_path(base, 'remote'))!277 mut a := open_repo(machine('undone_a')!)!278 defer {279 a.close()280 }281 mut b := open_repo(machine('undone_b')!)!282 defer {283 b.close()284 }285 put(a.root, 'f.txt', 'base\n')!286 put(a.root, 'g.txt', 'base\n')!287 commit(a, 'base')!288 offer_now(a, mut backend)!289 join(b, mut backend)!290291 put(a.root, 'f.txt', 'from A\n')!292 put(a.root, 'g.txt', 'same\n')!293 commit(a, 'A edits both')!294 offer_now(a, mut backend)!295296 put(b.root, 'f.txt', 'tried on B\n')!297 put(b.root, 'g.txt', 'first try\n')!298 commit(b, 'B tries')!299 put(b.root, 'f.txt', 'base\n')!300 put(b.root, 'g.txt', 'same\n')!301 put(b.root, 'h.txt', 'B\n')!302 commit(b, 'B undoes f, lands on the same g, adds h')!303304 report := steal_now(b, mut backend)!305 assert report.clashes.len == 0306 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'307 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'308 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'309}310311312313fn test_clash_questions_are_asked_before_the_write_lock() {314 base := scratch('ask_unlocked')!315 defer {316 os.rmdir_all(base) or {}317 }318 mut backend := remote.new_folder(os.join_path(base, 'remote'))!319 mut a := open_repo(machine('ask_a')!)!320 defer {321 a.close()322 }323 mut b := open_repo(machine('ask_b')!)!324 defer {325 b.close()326 }327 put(a.root, 'same.txt', 'base\n')!328 commit(a, 'base')!329 offer_now(a, mut backend)!330 join(b, mut backend)!331 put(a.root, 'same.txt', 'A version\n')!332 commit(a, 'A edits same')!333 offer_now(a, mut backend)!334 put(b.root, 'same.txt', 'B version\n')!335 commit(b, 'B edits same')!336 root := b.root337 338 if _ := steal(b, mut backend, StealOptions{339 ask: fn [root] (clash Clash) Side {340 other := storage.open(root) or { panic(err) }341 defer {342 other.close()343 }344 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }345 history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{346 message: 'agent saves during the question'347 }) or { panic('steal held the write lock while asking: ${err}') }348 return .mine349 }350 })351 {352 assert false, 'a steal whose line moved during the question must refuse'353 } else {354 assert err.msg().contains('changed while')355 }356 357 assert history.head(b)!.message == 'agent saves during the question'358 report := steal(b, mut backend, StealOptions{359 mine: true360 })!361 assert report.kept_mine == 1362 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'363 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'364}365366fn test_clashing_files_are_chosen_one_at_a_time() {367 base := scratch('clash')!368 defer {369 os.rmdir_all(base) or {}370 }371 mut backend := remote.new_folder(os.join_path(base, 'remote'))!372 mut a := open_repo(machine('clash_a')!)!373 defer {374 a.close()375 }376 mut b := open_repo(machine('clash_b')!)!377 defer {378 b.close()379 }380 put(a.root, 'same.txt', 'base\n')!381 put(a.root, 'other.txt', 'quiet\n')!382 commit(a, 'base')!383 offer_now(a, mut backend)!384 join(b, mut backend)!385386 387 put(a.root, 'same.txt', 'A version\n')!388 commit(a, 'A edits same')!389 offer_now(a, mut backend)!390 put(b.root, 'same.txt', 'B version\n')!391 commit(b, 'B edits same')!392393 394 if _ := steal(b, mut backend, StealOptions{}) {395 assert false, 'an undecided clash must be refused'396 } else {397 assert err.msg().contains('--mine') || err.msg().contains('--theirs')398 }399 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,400 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0401402 403 report := steal_now(b, mut backend)!404 assert report.clashes == ['same.txt']405 assert report.kept_mine == 0406 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'407 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'408}409410fn test_choosing_mine_keeps_the_local_version() {411 base := scratch('mine')!412 defer {413 os.rmdir_all(base) or {}414 }415 mut backend := remote.new_folder(os.join_path(base, 'remote'))!416 mut a := open_repo(machine('mine_a')!)!417 defer {418 a.close()419 }420 mut b := open_repo(machine('mine_b')!)!421 defer {422 b.close()423 }424 put(a.root, 'same.txt', 'base\n')!425 commit(a, 'base')!426 offer_now(a, mut backend)!427 join(b, mut backend)!428 put(a.root, 'same.txt', 'A version\n')!429 commit(a, 'A edits')!430 offer_now(a, mut backend)!431 put(b.root, 'same.txt', 'B version\n')!432 commit(b, 'B edits')!433434 report := steal(b, mut backend, StealOptions{435 mine: true436 })!437 assert report.kept_mine == 1438 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'439 440 assert history.head(b)!.message.contains('combine: kept mine for 1 file')441 442 assert history.head(b)!.restores == ''443 assert history.all_saves(b)!.len >= 4444}445446fn test_flags_travel_with_the_saves() {447 base := scratch('flags')!448 defer {449 os.rmdir_all(base) or {}450 }451 mut backend := remote.new_folder(os.join_path(base, 'remote'))!452 mut a := open_repo(machine('flag_a')!)!453 defer {454 a.close()455 }456 mut b := open_repo(machine('flag_b')!)!457 defer {458 b.close()459 }460 put(a.root, 'a.txt', 'one\n')!461 save := commit(a, 'suspect save')!462 events.flag(a, save.id, 'look at this')!463 offer_now(a, mut backend)!464465 466 fetched := fetch_back_to_known(b, mut backend, 'proj')!467 assert fetched.events_out.len == 1468 assert fetched.events_out[0].kind == events.kind_flag469 b.begin()!470 write_fetched(b, fetched)!471 history.set_head(b, fetched.head)!472 b.commit()!473 state := events.flag_state(b, save.id)!474 assert state.flagged475 assert state.reason == 'look at this'476}477478fn test_a_second_offer_racing_head_is_refused_not_forced() {479 base := scratch('race')!480 defer {481 os.rmdir_all(base) or {}482 }483 mut backend := remote.new_folder(os.join_path(base, 'remote'))!484 mut a := open_repo(machine('race_a')!)!485 defer {486 a.close()487 }488 put(a.root, 'a.txt', 'one\n')!489 commit(a, 'base')!490 offer_now(a, mut backend)!491492 493 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),494 backend.get(head_key('proj'))!.tag)!495496 put(a.root, 'a.txt', 'two\n')!497 commit(a, 'second')!498 if _ := offer_now(a, mut backend) {499 assert false, 'offering while the remote head moved must be refused'500 } else {501 assert err.msg().contains('run koh steal')502 }503}504505506fn join(store &storage.Store, mut backend remote.Backend) ! {507 fetched := fetch_repo(mut backend, 'proj')!508 store.begin()!509 write_fetched(store, fetched)!510 history.set_head(store, fetched.head)!511 store.commit()!512 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,513 fetched.head)!, [])!514 store.set_state('remote_repo', 'proj')!515}516517struct FaultyBackend {518mut:519 inner remote.Backend520 writes int521 reads int522 fail_write int 523 fail_read int524 525 fail_delete bool526}527528fn (mut f FaultyBackend) get(key string) !remote.Object {529 f.reads++530 if f.fail_read > 0 && f.reads >= f.fail_read {531 return error('injected read failure')532 }533 return f.inner.get(key)!534}535536fn (mut f FaultyBackend) list(prefix string) ![]string {537 return f.inner.list(prefix)!538}539540fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {541 f.writes++542 if f.fail_write > 0 && f.writes >= f.fail_write {543 return error('injected write failure')544 }545 return f.inner.put_if_absent(key, body)!546}547548fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {549 f.writes++550 if f.fail_write > 0 && f.writes >= f.fail_write {551 return error('injected write failure')552 }553 return f.inner.put_if_match(key, body, tag)!554}555556fn (mut f FaultyBackend) delete(key string) ! {557 if f.fail_delete {558 return error('injected delete failure')559 }560 f.inner.delete(key)!561}562563fn test_a_failed_offer_is_safe_to_re_run() {564 base := scratch('fault_offer')!565 defer {566 os.rmdir_all(base) or {}567 }568 remote_dir := os.join_path(base, 'remote')569 mut a := open_repo(machine('fault_a')!)!570 defer {571 a.close()572 }573 put(a.root, 'a.txt', 'one\n')!574 commit(a, 'first')!575 put(a.root, 'a.txt', 'two\n')!576 commit(a, 'second')!577578 579 mut flaky := FaultyBackend{580 inner: remote.new_folder(remote_dir)!581 fail_write: 2582 }583 if _ := offer(a, mut flaky) {584 assert false, 'the injected failure should have surfaced'585 }586587 588 589 mut healthy := remote.new_folder(remote_dir)!590 report := offer(a, mut healthy)!591 assert report.saves == 2592 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id593 assert healthy.list('proj/saves')!.len == 2594}595596fn test_a_failed_steal_leaves_nothing_behind() {597 base := scratch('fault_steal')!598 defer {599 os.rmdir_all(base) or {}600 }601 remote_dir := os.join_path(base, 'remote')602 mut a := open_repo(machine('fault_s_a')!)!603 defer {604 a.close()605 }606 mut b := open_repo(machine('fault_s_b')!)!607 defer {608 b.close()609 }610 put(a.root, 'a.txt', 'from A\n')!611 commit(a, 'A writes')!612 mut healthy := remote.new_folder(remote_dir)!613 offer(a, mut healthy)!614615 616 mut flaky := FaultyBackend{617 inner: remote.new_folder(remote_dir)!618 fail_read: 2619 }620 if _ := fetch_back_to_known(b, mut flaky, 'proj') {621 assert false, 'the injected read failure should have surfaced'622 }623 624 assert history.head(b) == none625 assert b.object_count()! == 0626627 628 fetched := fetch_back_to_known(b, mut healthy, 'proj')!629 b.begin()!630 write_fetched(b, fetched)!631 history.set_head(b, fetched.head)!632 b.commit()!633 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!634 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'635}636637fn test_an_interrupted_strip_is_idempotent() {638 639 640 641 base := scratch('fault_strip')!642 defer {643 os.rmdir_all(base) or {}644 }645 mut a := open_repo(machine('fault_strip_a')!)!646 defer {647 a.close()648 }649 put(a.root, 'secret.txt', 'shh\n')!650 put(a.root, 'keep.txt', 'fine\n')!651 save := commit(a, 'add both')!652653 for _ in 0 .. 2 {654 strip_local(a, 'secret.txt', '')!655 }656 files := history.files_of(a, save.id)!657 658 659 assert files.map(it.path) == ['.kohignore', 'keep.txt']660 assert events.strip_events(a)!.len == 1661}662663664665666667fn test_a_strip_matches_the_path_exactly() {668 mut a := open_repo(machine('strip_exact')!)!669 defer {670 a.close()671 }672 hash := a.put_object('x\n'.bytes())!673 mut files := []snapshot.FileEntry{}674 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {675 files << snapshot.FileEntry{676 path: path677 hash: hash678 }679 }680 save := history.commit_files(a, '', files, history.SaveInput{681 message: 'add all'682 })!683 assert strip_scope(a, 'docs', '')!.files == 1684 strip_local(a, 'docs', '')!685 strip_local(a, 'a_b', '')!686 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']687}688689fn test_a_strip_reaches_the_remote_and_other_machines() {690 base := scratch('strip_sync')!691 defer {692 os.rmdir_all(base) or {}693 }694 mut a := open_repo(machine('strip_a')!)!695 defer {696 a.close()697 }698 mut b := open_repo(machine('strip_b')!)!699 defer {700 b.close()701 }702 put(a.root, 'secrets.env', 'api_key=1\n')!703 put(a.root, 'keep.txt', 'fine\n')!704 save := commit(a, 'add both')!705 mut backend := remote.new_folder(os.join_path(base, 'remote'))!706 offer_now(a, mut backend)!707 join(b, mut backend)!708 assert b.object_count()! > 0709710 711 712 mut secret_hash := ''713 for file in history.files_of(a, save.id)! {714 if file.path == 'secrets.env' {715 secret_hash = file.hash716 }717 }718 assert secret_hash != ''719 strip_local(a, 'secrets.env', '')!720 report := push_strip(a, mut backend, 'secrets.env')!721 assert report.event_sent722 assert report.records == 1723724 725 726 record := decode_save(backend.get(save_key('proj', save.id))!.body)!727 for file in record.files {728 assert file.path != 'secrets.env'729 }730 731 732 assert !backend.get(object_key('proj', secret_hash))!.found733 assert backend.list('proj/objects')!.len == 2734735 736 b.begin()!737 fetched := fetch_back_to_known(b, mut backend, 'proj')!738 write_fetched(b, fetched)!739 b.commit()!740 mut paths := []string{}741 for file in history.files_of(b, save.id)! {742 paths << file.path743 }744 assert 'secrets.env' !in paths745 assert 'keep.txt' in paths746747 748 749 750 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)751 put(b.root, 'secrets.env', 'rotated\n')!752 readded := commit(b, 'add the rotated key back')!753 offer_now(b, mut backend)!754 assert backend.list('proj/events')!.len == 1755 b.begin()!756 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!757 b.commit()!758 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)759}760761762763764fn test_an_interrupted_remote_strip_finishes_when_re_run() {765 base := scratch('strip_resume')!766 defer {767 os.rmdir_all(base) or {}768 }769 remote_dir := os.join_path(base, 'remote')770 mut backend := remote.new_folder(remote_dir)!771 mut a := open_repo(machine('resume_a')!)!772 defer {773 a.close()774 }775 mut b := open_repo(machine('resume_b')!)!776 defer {777 b.close()778 }779 put(a.root, 'secrets.env', 'api_key=1\n')!780 put(a.root, 'shared.env', 'same bytes on B\n')!781 save := commit(a, 'add secrets')!782 offer_now(a, mut backend)!783 join(b, mut backend)!784 put(b.root, 'copy.txt', 'same bytes on B\n')!785 b_save := commit(b, 'B keeps a copy')!786 offer_now(b, mut backend)!787 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())788 secret_hash := storage.hash_hex('api_key=1\n'.bytes())789790 strip_local(a, 'secrets.env', '')!791 792 793 mut flaky := FaultyBackend{794 inner: remote.new_folder(remote_dir)!795 fail_write: 3796 }797 if _ := push_strip(a, mut flaky, 'secrets.env') {798 assert false, 'the injected failure should have surfaced'799 }800 assert strip_pending(a, 'secrets.env')801 assert strip_local(a, 'secrets.env', '')!.files_removed == 0802 report := push_strip(a, mut backend, 'secrets.env')!803 assert report.records == 1804 assert !strip_pending(a, 'secrets.env')805 assert !backend.get(object_key('proj', secret_hash))!.found806 record := decode_save(backend.get(save_key('proj', save.id))!.body)!807 assert 'secrets.env' !in record.files.map(it.path)808 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!809 assert 'secrets.env' !in b_record.files.map(it.path)810811 812 strip_local(a, 'shared.env', '')!813 push_strip(a, mut backend, 'shared.env')!814 assert backend.get(object_key('proj', shared_hash))!.found815}816817818819fn test_offer_waits_for_an_unapplied_strip() {820 base := scratch('strip_first')!821 defer {822 os.rmdir_all(base) or {}823 }824 mut backend := remote.new_folder(os.join_path(base, 'remote'))!825 mut a := open_repo(machine('strip_first_a')!)!826 defer {827 a.close()828 }829 mut b := open_repo(machine('strip_first_b')!)!830 defer {831 b.close()832 }833 put(a.root, 'secrets.env', 'api_key=1\n')!834 commit(a, 'add secrets')!835 offer_now(a, mut backend)!836 join(b, mut backend)!837 strip_local(a, 'secrets.env', '')!838 push_strip(a, mut backend, 'secrets.env')!839 put(b.root, 'more.txt', 'B keeps working\n')!840 later := commit(b, 'B saves with the secret still in its tree')!841 if _ := offer_now(b, mut backend) {842 assert false, 'B offered records that still list the stripped path'843 }844 steal_now(b, mut backend)!845 offer_now(b, mut backend)!846 record := decode_save(backend.get(save_key('proj', later.id))!.body)!847 assert 'secrets.env' !in record.files.map(it.path)848}849850851852fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {853 base := scratch('strip_twice')!854 defer {855 os.rmdir_all(base) or {}856 }857 remote_dir := os.join_path(base, 'remote')858 mut backend := remote.new_folder(remote_dir)!859 mut a := open_repo(machine('strip_twice_a')!)!860 defer {861 a.close()862 }863 put(a.root, 'secrets.env', 'first key\n')!864 commit(a, 'first key')!865 offer_now(a, mut backend)!866 strip_local(a, 'secrets.env', '')!867 mut flaky := FaultyBackend{868 inner: remote.new_folder(remote_dir)!869 fail_delete: true870 }871 if _ := push_strip(a, mut flaky, 'secrets.env') {872 assert false, 'the injected delete failure should have surfaced'873 }874 put(a.root, 'secrets.env', 'second key\n')!875 commit(a, 'second key')!876 offer_now(a, mut backend)!877 strip_local(a, 'secrets.env', '')!878 push_strip(a, mut backend, 'secrets.env')!879 for key in ['first key\n', 'second key\n'] {880 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found881 }882}883884885fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {886 base := scratch('strip_scopes')!887 defer {888 os.rmdir_all(base) or {}889 }890 remote_dir := os.join_path(base, 'remote')891 mut backend := remote.new_folder(remote_dir)!892 mut a := open_repo(machine('strip_scopes_a')!)!893 defer {894 a.close()895 }896 put(a.root, 'secrets.env', 'first key\n')!897 first := commit(a, 'first key')!898 put(a.root, 'secrets.env', 'second key\n')!899 second := commit(a, 'second key')!900 offer_now(a, mut backend)!901 strip_local(a, 'secrets.env', first.id)!902 903 mut flaky := FaultyBackend{904 inner: remote.new_folder(remote_dir)!905 fail_write: 2906 }907 if _ := push_strip(a, mut flaky, 'secrets.env') {908 assert false, 'the injected failure should have surfaced'909 }910 strip_local(a, 'secrets.env', second.id)!911 push_strip(a, mut backend, 'secrets.env')!912 assert !strip_pending(a, 'secrets.env')913 for save in [first, second] {914 record := decode_save(backend.get(save_key('proj', save.id))!.body)!915 assert 'secrets.env' !in record.files.map(it.path)916 }917 for key in ['first key\n', 'second key\n'] {918 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found919 }920}921922923924fn test_a_second_strip_of_the_same_path_reaches_other_machines() {925 base := scratch('strip_again')!926 defer {927 os.rmdir_all(base) or {}928 }929 mut backend := remote.new_folder(os.join_path(base, 'remote'))!930 mut a := open_repo(machine('strip_again_a')!)!931 defer {932 a.close()933 }934 mut b := open_repo(machine('strip_again_b')!)!935 defer {936 b.close()937 }938 put(a.root, 'secrets.env', 'first key\n')!939 commit(a, 'first key')!940 offer_now(a, mut backend)!941 join(b, mut backend)!942 strip_local(a, 'secrets.env', '')!943 push_strip(a, mut backend, 'secrets.env')!944 steal_now(b, mut backend)!945 946 os.rm(os.join_path(b.root, 'secrets.env'))!947 put(a.root, 'secrets.env', 'second key\n')!948 second := commit(a, 'second key')!949 offer_now(a, mut backend)!950 steal_now(b, mut backend)!951 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)952 strip_local(a, 'secrets.env', '')!953 push_strip(a, mut backend, 'secrets.env')!954 steal_now(b, mut backend)!955 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)956}957958959960fn test_a_stripped_path_is_not_asked_about() {961 base := scratch('clash_strip')!962 defer {963 os.rmdir_all(base) or {}964 }965 mut backend := remote.new_folder(os.join_path(base, 'remote'))!966 mut a := open_repo(machine('clash_strip_a')!)!967 defer {968 a.close()969 }970 mut b := open_repo(machine('clash_strip_b')!)!971 defer {972 b.close()973 }974 put(a.root, 'secrets.env', 'key one\n')!975 commit(a, 'base')!976 offer_now(a, mut backend)!977 join(b, mut backend)!978 put(a.root, 'secrets.env', 'key two\n')!979 commit(a, 'A rotates')!980 offer_now(a, mut backend)!981 strip_local(a, 'secrets.env', '')!982 push_strip(a, mut backend, 'secrets.env')!983 put(b.root, 'secrets.env', 'key three\n')!984 mine := commit(b, 'B rotates')!985 report := steal(b, mut backend, StealOptions{})!986 assert report.clashes.len == 0987 for save in history.all_saves(b)! {988 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)989 }990 assert history.by_id(b, mine.id) != none991}992993994995fn test_a_strip_of_the_shared_base_is_seen_before_asking() {996 base := scratch('clash_base_strip')!997 defer {998 os.rmdir_all(base) or {}999 }1000 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1001 mut a := open_repo(machine('base_strip_a')!)!1002 defer {1003 a.close()1004 }1005 mut b := open_repo(machine('base_strip_b')!)!1006 defer {1007 b.close()1008 }1009 put(a.root, 'notes.txt', 'shared\n')!1010 common := commit(a, 'base')!1011 offer_now(a, mut backend)!1012 join(b, mut backend)!1013 put(a.root, 'other.txt', 'A moves on\n')!1014 commit(a, 'A moves on')!1015 offer_now(a, mut backend)!1016 strip_local(a, 'notes.txt', common.id)!1017 push_strip(a, mut backend, 'notes.txt')!1018 put(b.root, 'notes.txt', 'B edits\n')!1019 commit(b, 'B edits notes')!1020 if _ := steal(b, mut backend, StealOptions{}) {1021 assert false, 'with the base stripped, notes.txt differs on both sides'1022 } else {1023 assert err.msg().contains('changed on both machines')1024 }1025 report := steal(b, mut backend, StealOptions{1026 mine: true1027 })!1028 assert report.clashes == ['notes.txt']1029 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1030}1031103210331034fn test_steal_into_a_folder_with_unsaved_files_refuses() {1035 base := scratch('steal_unsaved')!1036 defer {1037 os.rmdir_all(base) or {}1038 }1039 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1040 mut a := open_repo(machine('unsaved_a')!)!1041 defer {1042 a.close()1043 }1044 put(a.root, 'notes.txt', 'from A\n')!1045 commit(a, 'A writes')!1046 offer_now(a, mut backend)!1047 b_root := machine('unsaved_b')!1048 mut b := storage.create(b_root)!1049 defer {1050 b.close()1051 }1052 put(b_root, 'notes.txt', 'never saved\n')!1053 1054 b.set_state('remote_repo', 'proj')!1055 if _ := steal(b, mut backend, StealOptions{}) {1056 assert false, 'steal overwrote a folder with unsaved files'1057 } else {1058 assert err.msg() == no_saves_here1059 }1060 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1061 1062 os.rm(os.join_path(b_root, 'notes.txt'))!1063 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1064 steal(b, mut backend, StealOptions{})!1065 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1066}1067106810691070fn test_steal_reports_incoming_flagged_saves() {1071 base := scratch('flag_warn')!1072 defer {1073 os.rmdir_all(base) or {}1074 }1075 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1076 mut a := open_repo(machine('flag_warn_a')!)!1077 defer {1078 a.close()1079 }1080 mut b := open_repo(machine('flag_warn_b')!)!1081 defer {1082 b.close()1083 }1084 put(a.root, 'a.txt', 'one\n')!1085 commit(a, 'fine')!1086 offer_now(a, mut backend)!1087 join(b, mut backend)!1088 put(a.root, 'a.txt', 'two\n')!1089 suspect := commit(a, 'suspect')!1090 put(a.root, 'a.txt', 'three\n')!1091 commit(a, 'also fine')!1092 events.flag(a, suspect.id, 'broke the build')!1093 offer_now(a, mut backend)!1094 report := steal_now(b, mut backend)!1095 assert report.flagged.map(it.save_id) == [suspect.id]1096 assert report.flagged[0].reason == 'broke the build'1097}