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}266267268269fn test_clash_questions_are_asked_before_the_write_lock() {270 base := scratch('ask_unlocked')!271 defer {272 os.rmdir_all(base) or {}273 }274 mut backend := remote.new_folder(os.join_path(base, 'remote'))!275 mut a := open_repo(machine('ask_a')!)!276 defer {277 a.close()278 }279 mut b := open_repo(machine('ask_b')!)!280 defer {281 b.close()282 }283 put(a.root, 'same.txt', 'base\n')!284 commit(a, 'base')!285 offer_now(a, mut backend)!286 join(b, mut backend)!287 put(a.root, 'same.txt', 'A version\n')!288 commit(a, 'A edits same')!289 offer_now(a, mut backend)!290 put(b.root, 'same.txt', 'B version\n')!291 commit(b, 'B edits same')!292 root := b.root293 294 if _ := steal(b, mut backend, StealOptions{295 ask: fn [root] (clash Clash) Side {296 other := storage.open(root) or { panic(err) }297 defer {298 other.close()299 }300 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }301 history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{302 message: 'agent saves during the question'303 }) or { panic('steal held the write lock while asking: ${err}') }304 return .mine305 }306 })307 {308 assert false, 'a steal whose line moved during the question must refuse'309 } else {310 assert err.msg().contains('changed while')311 }312 313 assert history.head(b)!.message == 'agent saves during the question'314 report := steal(b, mut backend, StealOptions{315 mine: true316 })!317 assert report.kept_mine == 1318 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'319 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'320}321322fn test_clashing_files_are_chosen_one_at_a_time() {323 base := scratch('clash')!324 defer {325 os.rmdir_all(base) or {}326 }327 mut backend := remote.new_folder(os.join_path(base, 'remote'))!328 mut a := open_repo(machine('clash_a')!)!329 defer {330 a.close()331 }332 mut b := open_repo(machine('clash_b')!)!333 defer {334 b.close()335 }336 put(a.root, 'same.txt', 'base\n')!337 put(a.root, 'other.txt', 'quiet\n')!338 commit(a, 'base')!339 offer_now(a, mut backend)!340 join(b, mut backend)!341342 343 put(a.root, 'same.txt', 'A version\n')!344 commit(a, 'A edits same')!345 offer_now(a, mut backend)!346 put(b.root, 'same.txt', 'B version\n')!347 commit(b, 'B edits same')!348349 350 if _ := steal(b, mut backend, StealOptions{}) {351 assert false, 'an undecided clash must be refused'352 } else {353 assert err.msg().contains('--mine') || err.msg().contains('--theirs')354 }355 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,356 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0357358 359 report := steal_now(b, mut backend)!360 assert report.clashes == ['same.txt']361 assert report.kept_mine == 0362 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'363 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'364}365366fn test_choosing_mine_keeps_the_local_version() {367 base := scratch('mine')!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('mine_a')!)!373 defer {374 a.close()375 }376 mut b := open_repo(machine('mine_b')!)!377 defer {378 b.close()379 }380 put(a.root, 'same.txt', 'base\n')!381 commit(a, 'base')!382 offer_now(a, mut backend)!383 join(b, mut backend)!384 put(a.root, 'same.txt', 'A version\n')!385 commit(a, 'A edits')!386 offer_now(a, mut backend)!387 put(b.root, 'same.txt', 'B version\n')!388 commit(b, 'B edits')!389390 report := steal(b, mut backend, StealOptions{391 mine: true392 })!393 assert report.kept_mine == 1394 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'395 396 assert history.head(b)!.message.contains('combine: kept mine for 1 file')397 398 assert history.head(b)!.restores == ''399 assert history.all_saves(b)!.len >= 4400}401402fn test_flags_travel_with_the_saves() {403 base := scratch('flags')!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('flag_a')!)!409 defer {410 a.close()411 }412 mut b := open_repo(machine('flag_b')!)!413 defer {414 b.close()415 }416 put(a.root, 'a.txt', 'one\n')!417 save := commit(a, 'suspect save')!418 events.flag(a, save.id, 'look at this')!419 offer_now(a, mut backend)!420421 422 fetched := fetch_back_to_known(b, mut backend, 'proj')!423 assert fetched.events_out.len == 1424 assert fetched.events_out[0].kind == events.kind_flag425 b.begin()!426 write_fetched(b, fetched)!427 history.set_head(b, fetched.head)!428 b.commit()!429 state := events.flag_state(b, save.id)!430 assert state.flagged431 assert state.reason == 'look at this'432}433434fn test_a_second_offer_racing_head_is_refused_not_forced() {435 base := scratch('race')!436 defer {437 os.rmdir_all(base) or {}438 }439 mut backend := remote.new_folder(os.join_path(base, 'remote'))!440 mut a := open_repo(machine('race_a')!)!441 defer {442 a.close()443 }444 put(a.root, 'a.txt', 'one\n')!445 commit(a, 'base')!446 offer_now(a, mut backend)!447448 449 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),450 backend.get(head_key('proj'))!.tag)!451452 put(a.root, 'a.txt', 'two\n')!453 commit(a, 'second')!454 if _ := offer_now(a, mut backend) {455 assert false, 'offering while the remote head moved must be refused'456 } else {457 assert err.msg().contains('run koh steal')458 }459}460461462fn join(store &storage.Store, mut backend remote.Backend) ! {463 fetched := fetch_repo(mut backend, 'proj')!464 store.begin()!465 write_fetched(store, fetched)!466 history.set_head(store, fetched.head)!467 store.commit()!468 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,469 fetched.head)!, [])!470 store.set_state('remote_repo', 'proj')!471}472473struct FaultyBackend {474mut:475 inner remote.Backend476 writes int477 reads int478 fail_write int 479 fail_read int480 481 fail_delete bool482}483484fn (mut f FaultyBackend) get(key string) !remote.Object {485 f.reads++486 if f.fail_read > 0 && f.reads >= f.fail_read {487 return error('injected read failure')488 }489 return f.inner.get(key)!490}491492fn (mut f FaultyBackend) list(prefix string) ![]string {493 return f.inner.list(prefix)!494}495496fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {497 f.writes++498 if f.fail_write > 0 && f.writes >= f.fail_write {499 return error('injected write failure')500 }501 return f.inner.put_if_absent(key, body)!502}503504fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {505 f.writes++506 if f.fail_write > 0 && f.writes >= f.fail_write {507 return error('injected write failure')508 }509 return f.inner.put_if_match(key, body, tag)!510}511512fn (mut f FaultyBackend) delete(key string) ! {513 if f.fail_delete {514 return error('injected delete failure')515 }516 f.inner.delete(key)!517}518519fn test_a_failed_offer_is_safe_to_re_run() {520 base := scratch('fault_offer')!521 defer {522 os.rmdir_all(base) or {}523 }524 remote_dir := os.join_path(base, 'remote')525 mut a := open_repo(machine('fault_a')!)!526 defer {527 a.close()528 }529 put(a.root, 'a.txt', 'one\n')!530 commit(a, 'first')!531 put(a.root, 'a.txt', 'two\n')!532 commit(a, 'second')!533534 535 mut flaky := FaultyBackend{536 inner: remote.new_folder(remote_dir)!537 fail_write: 2538 }539 if _ := offer(a, mut flaky) {540 assert false, 'the injected failure should have surfaced'541 }542543 544 545 mut healthy := remote.new_folder(remote_dir)!546 report := offer(a, mut healthy)!547 assert report.saves == 2548 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id549 assert healthy.list('proj/saves')!.len == 2550}551552fn test_a_failed_steal_leaves_nothing_behind() {553 base := scratch('fault_steal')!554 defer {555 os.rmdir_all(base) or {}556 }557 remote_dir := os.join_path(base, 'remote')558 mut a := open_repo(machine('fault_s_a')!)!559 defer {560 a.close()561 }562 mut b := open_repo(machine('fault_s_b')!)!563 defer {564 b.close()565 }566 put(a.root, 'a.txt', 'from A\n')!567 commit(a, 'A writes')!568 mut healthy := remote.new_folder(remote_dir)!569 offer(a, mut healthy)!570571 572 mut flaky := FaultyBackend{573 inner: remote.new_folder(remote_dir)!574 fail_read: 2575 }576 if _ := fetch_back_to_known(b, mut flaky, 'proj') {577 assert false, 'the injected read failure should have surfaced'578 }579 580 assert history.head(b) == none581 assert b.object_count()! == 0582583 584 fetched := fetch_back_to_known(b, mut healthy, 'proj')!585 b.begin()!586 write_fetched(b, fetched)!587 history.set_head(b, fetched.head)!588 b.commit()!589 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!590 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'591}592593fn test_an_interrupted_strip_is_idempotent() {594 595 596 597 base := scratch('fault_strip')!598 defer {599 os.rmdir_all(base) or {}600 }601 mut a := open_repo(machine('fault_strip_a')!)!602 defer {603 a.close()604 }605 put(a.root, 'secret.txt', 'shh\n')!606 put(a.root, 'keep.txt', 'fine\n')!607 save := commit(a, 'add both')!608609 for _ in 0 .. 2 {610 strip_local(a, 'secret.txt', '')!611 }612 files := history.files_of(a, save.id)!613 614 615 assert files.map(it.path) == ['.kohignore', 'keep.txt']616 assert events.strip_events(a)!.len == 1617}618619620621622623fn test_a_strip_matches_the_path_exactly() {624 mut a := open_repo(machine('strip_exact')!)!625 defer {626 a.close()627 }628 hash := a.put_object('x\n'.bytes())!629 mut files := []snapshot.FileEntry{}630 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {631 files << snapshot.FileEntry{632 path: path633 hash: hash634 }635 }636 save := history.commit_files(a, '', files, history.SaveInput{637 message: 'add all'638 })!639 assert strip_scope(a, 'docs', '')!.files == 1640 strip_local(a, 'docs', '')!641 strip_local(a, 'a_b', '')!642 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']643}644645fn test_a_strip_reaches_the_remote_and_other_machines() {646 base := scratch('strip_sync')!647 defer {648 os.rmdir_all(base) or {}649 }650 mut a := open_repo(machine('strip_a')!)!651 defer {652 a.close()653 }654 mut b := open_repo(machine('strip_b')!)!655 defer {656 b.close()657 }658 put(a.root, 'secrets.env', 'api_key=1\n')!659 put(a.root, 'keep.txt', 'fine\n')!660 save := commit(a, 'add both')!661 mut backend := remote.new_folder(os.join_path(base, 'remote'))!662 offer_now(a, mut backend)!663 join(b, mut backend)!664 assert b.object_count()! > 0665666 667 668 mut secret_hash := ''669 for file in history.files_of(a, save.id)! {670 if file.path == 'secrets.env' {671 secret_hash = file.hash672 }673 }674 assert secret_hash != ''675 strip_local(a, 'secrets.env', '')!676 report := push_strip(a, mut backend, 'secrets.env')!677 assert report.event_sent678 assert report.records == 1679680 681 682 record := decode_save(backend.get(save_key('proj', save.id))!.body)!683 for file in record.files {684 assert file.path != 'secrets.env'685 }686 687 688 assert !backend.get(object_key('proj', secret_hash))!.found689 assert backend.list('proj/objects')!.len == 2690691 692 b.begin()!693 fetched := fetch_back_to_known(b, mut backend, 'proj')!694 write_fetched(b, fetched)!695 b.commit()!696 mut paths := []string{}697 for file in history.files_of(b, save.id)! {698 paths << file.path699 }700 assert 'secrets.env' !in paths701 assert 'keep.txt' in paths702703 704 705 706 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)707 put(b.root, 'secrets.env', 'rotated\n')!708 readded := commit(b, 'add the rotated key back')!709 offer_now(b, mut backend)!710 assert backend.list('proj/events')!.len == 1711 b.begin()!712 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!713 b.commit()!714 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)715}716717718719720fn test_an_interrupted_remote_strip_finishes_when_re_run() {721 base := scratch('strip_resume')!722 defer {723 os.rmdir_all(base) or {}724 }725 remote_dir := os.join_path(base, 'remote')726 mut backend := remote.new_folder(remote_dir)!727 mut a := open_repo(machine('resume_a')!)!728 defer {729 a.close()730 }731 mut b := open_repo(machine('resume_b')!)!732 defer {733 b.close()734 }735 put(a.root, 'secrets.env', 'api_key=1\n')!736 put(a.root, 'shared.env', 'same bytes on B\n')!737 save := commit(a, 'add secrets')!738 offer_now(a, mut backend)!739 join(b, mut backend)!740 put(b.root, 'copy.txt', 'same bytes on B\n')!741 b_save := commit(b, 'B keeps a copy')!742 offer_now(b, mut backend)!743 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())744 secret_hash := storage.hash_hex('api_key=1\n'.bytes())745746 strip_local(a, 'secrets.env', '')!747 748 749 mut flaky := FaultyBackend{750 inner: remote.new_folder(remote_dir)!751 fail_write: 3752 }753 if _ := push_strip(a, mut flaky, 'secrets.env') {754 assert false, 'the injected failure should have surfaced'755 }756 assert strip_pending(a, 'secrets.env')757 assert strip_local(a, 'secrets.env', '')!.files_removed == 0758 report := push_strip(a, mut backend, 'secrets.env')!759 assert report.records == 1760 assert !strip_pending(a, 'secrets.env')761 assert !backend.get(object_key('proj', secret_hash))!.found762 record := decode_save(backend.get(save_key('proj', save.id))!.body)!763 assert 'secrets.env' !in record.files.map(it.path)764 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!765 assert 'secrets.env' !in b_record.files.map(it.path)766767 768 strip_local(a, 'shared.env', '')!769 push_strip(a, mut backend, 'shared.env')!770 assert backend.get(object_key('proj', shared_hash))!.found771}772773774775fn test_offer_waits_for_an_unapplied_strip() {776 base := scratch('strip_first')!777 defer {778 os.rmdir_all(base) or {}779 }780 mut backend := remote.new_folder(os.join_path(base, 'remote'))!781 mut a := open_repo(machine('strip_first_a')!)!782 defer {783 a.close()784 }785 mut b := open_repo(machine('strip_first_b')!)!786 defer {787 b.close()788 }789 put(a.root, 'secrets.env', 'api_key=1\n')!790 commit(a, 'add secrets')!791 offer_now(a, mut backend)!792 join(b, mut backend)!793 strip_local(a, 'secrets.env', '')!794 push_strip(a, mut backend, 'secrets.env')!795 put(b.root, 'more.txt', 'B keeps working\n')!796 later := commit(b, 'B saves with the secret still in its tree')!797 if _ := offer_now(b, mut backend) {798 assert false, 'B offered records that still list the stripped path'799 }800 steal_now(b, mut backend)!801 offer_now(b, mut backend)!802 record := decode_save(backend.get(save_key('proj', later.id))!.body)!803 assert 'secrets.env' !in record.files.map(it.path)804}805806807808fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {809 base := scratch('strip_twice')!810 defer {811 os.rmdir_all(base) or {}812 }813 remote_dir := os.join_path(base, 'remote')814 mut backend := remote.new_folder(remote_dir)!815 mut a := open_repo(machine('strip_twice_a')!)!816 defer {817 a.close()818 }819 put(a.root, 'secrets.env', 'first key\n')!820 commit(a, 'first key')!821 offer_now(a, mut backend)!822 strip_local(a, 'secrets.env', '')!823 mut flaky := FaultyBackend{824 inner: remote.new_folder(remote_dir)!825 fail_delete: true826 }827 if _ := push_strip(a, mut flaky, 'secrets.env') {828 assert false, 'the injected delete failure should have surfaced'829 }830 put(a.root, 'secrets.env', 'second key\n')!831 commit(a, 'second key')!832 offer_now(a, mut backend)!833 strip_local(a, 'secrets.env', '')!834 push_strip(a, mut backend, 'secrets.env')!835 for key in ['first key\n', 'second key\n'] {836 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found837 }838}839840841fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {842 base := scratch('strip_scopes')!843 defer {844 os.rmdir_all(base) or {}845 }846 remote_dir := os.join_path(base, 'remote')847 mut backend := remote.new_folder(remote_dir)!848 mut a := open_repo(machine('strip_scopes_a')!)!849 defer {850 a.close()851 }852 put(a.root, 'secrets.env', 'first key\n')!853 first := commit(a, 'first key')!854 put(a.root, 'secrets.env', 'second key\n')!855 second := commit(a, 'second key')!856 offer_now(a, mut backend)!857 strip_local(a, 'secrets.env', first.id)!858 859 mut flaky := FaultyBackend{860 inner: remote.new_folder(remote_dir)!861 fail_write: 2862 }863 if _ := push_strip(a, mut flaky, 'secrets.env') {864 assert false, 'the injected failure should have surfaced'865 }866 strip_local(a, 'secrets.env', second.id)!867 push_strip(a, mut backend, 'secrets.env')!868 assert !strip_pending(a, 'secrets.env')869 for save in [first, second] {870 record := decode_save(backend.get(save_key('proj', save.id))!.body)!871 assert 'secrets.env' !in record.files.map(it.path)872 }873 for key in ['first key\n', 'second key\n'] {874 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found875 }876}877878879880fn test_a_second_strip_of_the_same_path_reaches_other_machines() {881 base := scratch('strip_again')!882 defer {883 os.rmdir_all(base) or {}884 }885 mut backend := remote.new_folder(os.join_path(base, 'remote'))!886 mut a := open_repo(machine('strip_again_a')!)!887 defer {888 a.close()889 }890 mut b := open_repo(machine('strip_again_b')!)!891 defer {892 b.close()893 }894 put(a.root, 'secrets.env', 'first key\n')!895 commit(a, 'first key')!896 offer_now(a, mut backend)!897 join(b, mut backend)!898 strip_local(a, 'secrets.env', '')!899 push_strip(a, mut backend, 'secrets.env')!900 steal_now(b, mut backend)!901 902 os.rm(os.join_path(b.root, 'secrets.env'))!903 put(a.root, 'secrets.env', 'second key\n')!904 second := commit(a, 'second key')!905 offer_now(a, mut backend)!906 steal_now(b, mut backend)!907 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)908 strip_local(a, 'secrets.env', '')!909 push_strip(a, mut backend, 'secrets.env')!910 steal_now(b, mut backend)!911 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)912}913914915916fn test_a_stripped_path_is_not_asked_about() {917 base := scratch('clash_strip')!918 defer {919 os.rmdir_all(base) or {}920 }921 mut backend := remote.new_folder(os.join_path(base, 'remote'))!922 mut a := open_repo(machine('clash_strip_a')!)!923 defer {924 a.close()925 }926 mut b := open_repo(machine('clash_strip_b')!)!927 defer {928 b.close()929 }930 put(a.root, 'secrets.env', 'key one\n')!931 commit(a, 'base')!932 offer_now(a, mut backend)!933 join(b, mut backend)!934 put(a.root, 'secrets.env', 'key two\n')!935 commit(a, 'A rotates')!936 offer_now(a, mut backend)!937 strip_local(a, 'secrets.env', '')!938 push_strip(a, mut backend, 'secrets.env')!939 put(b.root, 'secrets.env', 'key three\n')!940 mine := commit(b, 'B rotates')!941 report := steal(b, mut backend, StealOptions{})!942 assert report.clashes.len == 0943 for save in history.all_saves(b)! {944 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)945 }946 assert history.by_id(b, mine.id) != none947}948949950951fn test_a_strip_of_the_shared_base_is_seen_before_asking() {952 base := scratch('clash_base_strip')!953 defer {954 os.rmdir_all(base) or {}955 }956 mut backend := remote.new_folder(os.join_path(base, 'remote'))!957 mut a := open_repo(machine('base_strip_a')!)!958 defer {959 a.close()960 }961 mut b := open_repo(machine('base_strip_b')!)!962 defer {963 b.close()964 }965 put(a.root, 'notes.txt', 'shared\n')!966 common := commit(a, 'base')!967 offer_now(a, mut backend)!968 join(b, mut backend)!969 put(a.root, 'other.txt', 'A moves on\n')!970 commit(a, 'A moves on')!971 offer_now(a, mut backend)!972 strip_local(a, 'notes.txt', common.id)!973 push_strip(a, mut backend, 'notes.txt')!974 put(b.root, 'notes.txt', 'B edits\n')!975 commit(b, 'B edits notes')!976 if _ := steal(b, mut backend, StealOptions{}) {977 assert false, 'with the base stripped, notes.txt differs on both sides'978 } else {979 assert err.msg().contains('changed on both machines')980 }981 report := steal(b, mut backend, StealOptions{982 mine: true983 })!984 assert report.clashes == ['notes.txt']985 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'986}987988989990fn test_steal_into_a_folder_with_unsaved_files_refuses() {991 base := scratch('steal_unsaved')!992 defer {993 os.rmdir_all(base) or {}994 }995 mut backend := remote.new_folder(os.join_path(base, 'remote'))!996 mut a := open_repo(machine('unsaved_a')!)!997 defer {998 a.close()999 }1000 put(a.root, 'notes.txt', 'from A\n')!1001 commit(a, 'A writes')!1002 offer_now(a, mut backend)!1003 b_root := machine('unsaved_b')!1004 mut b := storage.create(b_root)!1005 defer {1006 b.close()1007 }1008 put(b_root, 'notes.txt', 'never saved\n')!1009 1010 b.set_state('remote_repo', 'proj')!1011 if _ := steal(b, mut backend, StealOptions{}) {1012 assert false, 'steal overwrote a folder with unsaved files'1013 } else {1014 assert err.msg() == no_saves_here1015 }1016 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1017 1018 os.rm(os.join_path(b_root, 'notes.txt'))!1019 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1020 steal(b, mut backend, StealOptions{})!1021 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1022}1023102410251026fn test_steal_reports_incoming_flagged_saves() {1027 base := scratch('flag_warn')!1028 defer {1029 os.rmdir_all(base) or {}1030 }1031 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1032 mut a := open_repo(machine('flag_warn_a')!)!1033 defer {1034 a.close()1035 }1036 mut b := open_repo(machine('flag_warn_b')!)!1037 defer {1038 b.close()1039 }1040 put(a.root, 'a.txt', 'one\n')!1041 commit(a, 'fine')!1042 offer_now(a, mut backend)!1043 join(b, mut backend)!1044 put(a.root, 'a.txt', 'two\n')!1045 suspect := commit(a, 'suspect')!1046 put(a.root, 'a.txt', 'three\n')!1047 commit(a, 'also fine')!1048 events.flag(a, suspect.id, 'broke the build')!1049 offer_now(a, mut backend)!1050 report := steal_now(b, mut backend)!1051 assert report.flagged.map(it.save_id) == [suspect.id]1052 assert report.flagged[0].reason == 'broke the build'1053}