12345module sync67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage13141516fn machine(name string) !string {17 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')18 os.rmdir_all(os.dir(dir)) or {}19 os.mkdir_all(dir, os.MkdirParams{})!20 return dir21}2223fn scratch(name string) !string {24 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')25 os.rmdir_all(dir) or {}26 os.mkdir_all(dir, os.MkdirParams{})!27 return dir28}2930fn open_repo(dir string) !storage.Store {31 mut store := storage.create(dir)!32 os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!33 return store34}3536fn put(root string, rel string, text string) ! {37 full := os.join_path(root, rel)38 os.mkdir_all(os.dir(full), os.MkdirParams{})!39 os.write_file(full, text)!40}4142fn commit(store &storage.Store, message string) !history.Save {43 return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{44 message: message45 })!46}4748fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {49 return offer(store, mut backend)!50}5152fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {53 return steal(store, mut backend, StealOptions{54 theirs: true55 })!56}5758fn test_first_offer_publishes_the_whole_line() {59 base := scratch('first')!60 defer {61 os.rmdir_all(base) or {}62 }63 mut a := open_repo(machine('first_a')!)!64 defer {65 a.close()66 }67 put(a.root, 'a.txt', 'one\n')!68 commit(a, 'first')!69 put(a.root, 'a.txt', 'two\n')!70 commit(a, 'second')!71 mut backend := remote.new_folder(os.join_path(base, 'remote'))!7273 report := offer_now(a, mut backend)!74 assert report.repo == 'proj'75 assert report.saves == 276 assert report.objects >= 277 assert backend.get(head_key('proj'))!.found78 assert backend.list('proj/saves')!.len == 27980 81 again := offer_now(a, mut backend)!82 assert again.saves == 083}8485fn test_the_repo_name_is_remembered_and_head_offered() {86 base := scratch('remember')!87 defer {88 os.rmdir_all(base) or {}89 }90 mut a := open_repo(machine('remember_a')!)!91 defer {92 a.close()93 }94 put(a.root, 'a.txt', 'one\n')!95 commit(a, 'first')!96 mut backend := remote.new_folder(os.join_path(base, 'remote'))!97 offer_now(a, mut backend)!98 assert a.state('remote_repo') or { '' } == 'proj'99 assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id100 101 assert a.state('remote_head') or { '' } == history.head(a)!.id102103 104 105 put(a.root, 'a.txt', 'two\n')!106 commit(a, 'second')!107 offer_now(a, mut backend)!108 assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id109}110111fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {112 base := scratch('forward')!113 defer {114 os.rmdir_all(base) or {}115 }116 mut backend := remote.new_folder(os.join_path(base, 'remote'))!117 mut a := open_repo(machine('fwd_a')!)!118 defer {119 a.close()120 }121 put(a.root, 'a.txt', 'from A\n')!122 commit(a, 'A writes')!123 offer_now(a, mut backend)!124125 126 mut b := open_repo(machine('fwd_b')!)!127 defer {128 b.close()129 }130 fetched := fetch_repo(mut backend, 'proj')!131 b.begin()!132 write_fetched(b, fetched)!133 history.set_head(b, fetched.head)!134 b.commit()!135 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!136 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'137 assert history.head(b)!.id == fetched.head138}139140141142fn test_steal_while_only_ahead_leaves_the_line_alone() {143 base := scratch('ahead')!144 defer {145 os.rmdir_all(base) or {}146 }147 mut backend := remote.new_folder(os.join_path(base, 'remote'))!148 mut a := open_repo(machine('ahead_a')!)!149 defer {150 a.close()151 }152 put(a.root, 'a.txt', 'one\n')!153 commit(a, 'offered')!154 offer_now(a, mut backend)!155 put(a.root, 'a.txt', 'two\n')!156 ahead := commit(a, 'not offered yet')!157 report := steal_now(a, mut backend)!158 assert report.replayed == 0159 assert history.head(a)!.id == ahead.id160 assert history.all_saves(a)!.len == 2161 offer_now(a, mut backend)!162 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id163}164165166167fn test_steal_never_writes_through_a_symlinked_folder() {168 base := scratch('steal_link')!169 defer {170 os.rmdir_all(base) or {}171 }172 mut backend := remote.new_folder(os.join_path(base, 'remote'))!173 mut a := open_repo(machine('steal_link_a')!)!174 defer {175 a.close()176 }177 mut b := open_repo(machine('steal_link_b')!)!178 defer {179 b.close()180 }181 put(a.root, 'a.txt', 'one\n')!182 commit(a, 'first')!183 offer_now(a, mut backend)!184 join(b, mut backend)!185 outside := os.join_path(base, 'outside')186 put(outside, 'a.md', 'precious\n')!187 os.symlink(outside, os.join_path(b.root, 'docs'))!188 put(a.root, 'docs/a.md', 'from A\n')!189 commit(a, 'A adds docs')!190 offer_now(a, mut backend)!191 head_before := history.head(b)!.id192 if _ := steal_now(b, mut backend) {193 assert false, 'a steal writing through a symlinked folder must refuse'194 } else {195 assert err.msg().contains('docs')196 }197 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'198 assert history.head(b)!.id == head_before199}200201202fn test_steal_refuses_after_a_restore_cut_short() {203 base := scratch('steal_cut')!204 defer {205 os.rmdir_all(base) or {}206 }207 mut backend := remote.new_folder(os.join_path(base, 'remote'))!208 mut a := open_repo(machine('steal_cut_a')!)!209 defer {210 a.close()211 }212 put(a.root, 'a.txt', 'one\n')!213 commit(a, 'first')!214 offer_now(a, mut backend)!215 a.set_state('restoring', 'yes')!216 if _ := steal_now(a, mut backend) {217 assert false, 'steal must refuse while a restore is unfinished'218 } else {219 assert err.msg().contains('koh revert')220 }221}222223224225fn test_a_clone_imports_parents_before_children() {226 base := scratch('clone_order')!227 defer {228 os.rmdir_all(base) or {}229 }230 mut backend := remote.new_folder(os.join_path(base, 'remote'))!231 mut a := open_repo(machine('order_a')!)!232 defer {233 a.close()234 }235 put(a.root, 'a.txt', 'one\n')!236 parent := commit(a, 'parent')!237 child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{238 message: 'replayed, older than its parent'239 created_at: parent.created_at - 3600240 })!241 offer_now(a, mut backend)!242 mut b := open_repo(machine('order_b')!)!243 defer {244 b.close()245 }246 join(b, mut backend)!247 assert history.head(b)!.id == child.id248}249250251fn test_steal_refuses_an_unsaved_executable_bit_change() {252 base := scratch('chmod')!253 defer {254 os.rmdir_all(base) or {}255 }256 mut backend := remote.new_folder(os.join_path(base, 'remote'))!257 mut a := open_repo(machine('chmod_a')!)!258 defer {259 a.close()260 }261 put(a.root, 'run.sh', 'echo hi\n')!262 commit(a, 'script')!263 offer_now(a, mut backend)!264 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!265 if _ := steal_now(a, mut backend) {266 assert false, 'a chmod-only change should make the tree dirty'267 } else {268 assert err.msg() == dirty_tree269 }270}271272fn test_divergence_without_clashes_keeps_both_machines_work() {273 base := scratch('diverge')!274 defer {275 os.rmdir_all(base) or {}276 }277 mut backend := remote.new_folder(os.join_path(base, 'remote'))!278 mut a := open_repo(machine('div_a')!)!279 defer {280 a.close()281 }282 mut b := open_repo(machine('div_b')!)!283 defer {284 b.close()285 }286 put(a.root, 'shared.txt', 'base\n')!287 commit(a, 'base')!288 offer_now(a, mut backend)!289290 291 join(b, mut backend)!292 put(a.root, 'fromA.txt', 'A\n')!293 commit(a, 'A adds a file')!294 put(b.root, 'fromB.txt', 'B\n')!295 commit(b, 'B adds a file')!296 offer_now(a, mut backend)!297298 299 if _ := offer_now(b, mut backend) {300 assert false, 'offering on top of unknown remote saves should be refused'301 } else {302 assert err.msg().contains('run koh steal')303 }304305 report := steal_now(b, mut backend)!306 assert !report.forward307 assert report.replayed == 1308 assert report.clashes.len == 0309 310 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'311 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'312 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'313 314 mut saw_replay := false315 for save in history.all_saves(b)! {316 if save.replayed_from != '' {317 saw_replay = true318 }319 }320 assert saw_replay321 322 offer_now(b, mut backend)!323 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()324 assert line_head == history.head(b)!.id325}326327328329330331fn test_a_local_change_undone_does_not_block_a_combine() {332 base := scratch('undone')!333 defer {334 os.rmdir_all(base) or {}335 }336 mut backend := remote.new_folder(os.join_path(base, 'remote'))!337 mut a := open_repo(machine('undone_a')!)!338 defer {339 a.close()340 }341 mut b := open_repo(machine('undone_b')!)!342 defer {343 b.close()344 }345 put(a.root, 'f.txt', 'base\n')!346 put(a.root, 'g.txt', 'base\n')!347 commit(a, 'base')!348 offer_now(a, mut backend)!349 join(b, mut backend)!350351 put(a.root, 'f.txt', 'from A\n')!352 put(a.root, 'g.txt', 'same\n')!353 commit(a, 'A edits both')!354 offer_now(a, mut backend)!355356 put(b.root, 'f.txt', 'tried on B\n')!357 put(b.root, 'g.txt', 'first try\n')!358 tried := commit(b, 'B tries')!359 put(b.root, 'f.txt', 'base\n')!360 put(b.root, 'g.txt', 'same\n')!361 put(b.root, 'h.txt', 'B\n')!362 undone := commit(b, 'B undoes f, lands on the same g, adds h')!363364 report := steal_now(b, mut backend)!365 assert report.clashes.len == 0366 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'367 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'368 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'369 370 371 assert report.replayed == 1372 assert history.head(b)!.replayed_from == undone.id373 ids := history.all_saves(b)!.map(it.id)374 assert tried.id in ids && undone.id in ids375 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')376}377378379380fn test_clash_questions_are_asked_before_the_write_lock() {381 base := scratch('ask_unlocked')!382 defer {383 os.rmdir_all(base) or {}384 }385 mut backend := remote.new_folder(os.join_path(base, 'remote'))!386 mut a := open_repo(machine('ask_a')!)!387 defer {388 a.close()389 }390 mut b := open_repo(machine('ask_b')!)!391 defer {392 b.close()393 }394 put(a.root, 'same.txt', 'base\n')!395 commit(a, 'base')!396 offer_now(a, mut backend)!397 join(b, mut backend)!398 put(a.root, 'same.txt', 'A version\n')!399 commit(a, 'A edits same')!400 offer_now(a, mut backend)!401 put(b.root, 'same.txt', 'B version\n')!402 commit(b, 'B edits same')!403 root := b.root404 405 if _ := steal(b, mut backend, StealOptions{406 ask: fn [root] (clash Clash) Side {407 other := storage.open(root) or { panic(err) }408 defer {409 other.close()410 }411 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }412 history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{413 message: 'agent saves during the question'414 }) or { panic('steal held the write lock while asking: ${err}') }415 return .mine416 }417 })418 {419 assert false, 'a steal whose line moved during the question must refuse'420 } else {421 assert err.msg().contains('changed while')422 }423 424 assert history.head(b)!.message == 'agent saves during the question'425 report := steal(b, mut backend, StealOptions{426 mine: true427 })!428 assert report.kept_mine == 1429 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'430 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'431}432433fn test_clashing_files_are_chosen_one_at_a_time() {434 base := scratch('clash')!435 defer {436 os.rmdir_all(base) or {}437 }438 mut backend := remote.new_folder(os.join_path(base, 'remote'))!439 mut a := open_repo(machine('clash_a')!)!440 defer {441 a.close()442 }443 mut b := open_repo(machine('clash_b')!)!444 defer {445 b.close()446 }447 put(a.root, 'same.txt', 'base\n')!448 put(a.root, 'other.txt', 'quiet\n')!449 commit(a, 'base')!450 offer_now(a, mut backend)!451 join(b, mut backend)!452453 454 put(a.root, 'same.txt', 'A version\n')!455 commit(a, 'A edits same')!456 offer_now(a, mut backend)!457 put(b.root, 'same.txt', 'B version\n')!458 commit(b, 'B edits same')!459460 461 if _ := steal(b, mut backend, StealOptions{}) {462 assert false, 'an undecided clash must be refused'463 } else {464 assert err.msg().contains('--mine') || err.msg().contains('--theirs')465 }466 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,467 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0468469 470 report := steal_now(b, mut backend)!471 assert report.clashes == ['same.txt']472 assert report.kept_mine == 0473 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'474 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'475}476477fn test_choosing_mine_keeps_the_local_version() {478 base := scratch('mine')!479 defer {480 os.rmdir_all(base) or {}481 }482 mut backend := remote.new_folder(os.join_path(base, 'remote'))!483 mut a := open_repo(machine('mine_a')!)!484 defer {485 a.close()486 }487 mut b := open_repo(machine('mine_b')!)!488 defer {489 b.close()490 }491 put(a.root, 'same.txt', 'base\n')!492 commit(a, 'base')!493 offer_now(a, mut backend)!494 join(b, mut backend)!495 put(a.root, 'same.txt', 'A version\n')!496 commit(a, 'A edits')!497 offer_now(a, mut backend)!498 put(b.root, 'same.txt', 'B version\n')!499 commit(b, 'B edits')!500501 report := steal(b, mut backend, StealOptions{502 mine: true503 })!504 assert report.kept_mine == 1505 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'506 507 assert history.head(b)!.message.contains('combine: kept mine for 1 file')508 509 assert history.head(b)!.restores == ''510 assert history.all_saves(b)!.len >= 4511}512513fn test_flags_travel_with_the_saves() {514 base := scratch('flags')!515 defer {516 os.rmdir_all(base) or {}517 }518 mut backend := remote.new_folder(os.join_path(base, 'remote'))!519 mut a := open_repo(machine('flag_a')!)!520 defer {521 a.close()522 }523 mut b := open_repo(machine('flag_b')!)!524 defer {525 b.close()526 }527 put(a.root, 'a.txt', 'one\n')!528 save := commit(a, 'suspect save')!529 events.flag(a, save.id, 'look at this')!530 offer_now(a, mut backend)!531532 533 fetched := fetch_back_to_known(b, mut backend, 'proj')!534 assert fetched.events_out.len == 1535 assert fetched.events_out[0].kind == events.kind_flag536 b.begin()!537 write_fetched(b, fetched)!538 history.set_head(b, fetched.head)!539 b.commit()!540 state := events.flag_state(b, save.id)!541 assert state.flagged542 assert state.reason == 'look at this'543}544545fn test_a_second_offer_racing_head_is_refused_not_forced() {546 base := scratch('race')!547 defer {548 os.rmdir_all(base) or {}549 }550 mut backend := remote.new_folder(os.join_path(base, 'remote'))!551 mut a := open_repo(machine('race_a')!)!552 defer {553 a.close()554 }555 put(a.root, 'a.txt', 'one\n')!556 commit(a, 'base')!557 offer_now(a, mut backend)!558559 560 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),561 backend.get(head_key('proj'))!.tag)!562563 put(a.root, 'a.txt', 'two\n')!564 commit(a, 'second')!565 if _ := offer_now(a, mut backend) {566 assert false, 'offering while the remote head moved must be refused'567 } else {568 assert err.msg().contains('run koh steal')569 }570}571572573fn join(store &storage.Store, mut backend remote.Backend) ! {574 fetched := fetch_repo(mut backend, 'proj')!575 store.begin()!576 write_fetched(store, fetched)!577 history.set_head(store, fetched.head)!578 store.commit()!579 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,580 fetched.head)!, [])!581 store.set_state('remote_repo', 'proj')!582}583584struct FaultyBackend {585mut:586 inner remote.Backend587 writes int588 reads int589 fail_write int 590 fail_read int591 592 fail_delete bool593}594595fn (mut f FaultyBackend) get(key string) !remote.Object {596 f.reads++597 if f.fail_read > 0 && f.reads >= f.fail_read {598 return error('injected read failure')599 }600 return f.inner.get(key)!601}602603fn (mut f FaultyBackend) list(prefix string) ![]string {604 return f.inner.list(prefix)!605}606607fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {608 f.writes++609 if f.fail_write > 0 && f.writes >= f.fail_write {610 return error('injected write failure')611 }612 return f.inner.put_if_absent(key, body)!613}614615fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {616 f.writes++617 if f.fail_write > 0 && f.writes >= f.fail_write {618 return error('injected write failure')619 }620 return f.inner.put_if_match(key, body, tag)!621}622623fn (mut f FaultyBackend) delete(key string) ! {624 if f.fail_delete {625 return error('injected delete failure')626 }627 f.inner.delete(key)!628}629630fn test_a_failed_offer_is_safe_to_re_run() {631 base := scratch('fault_offer')!632 defer {633 os.rmdir_all(base) or {}634 }635 remote_dir := os.join_path(base, 'remote')636 mut a := open_repo(machine('fault_a')!)!637 defer {638 a.close()639 }640 put(a.root, 'a.txt', 'one\n')!641 commit(a, 'first')!642 put(a.root, 'a.txt', 'two\n')!643 commit(a, 'second')!644645 646 mut flaky := FaultyBackend{647 inner: remote.new_folder(remote_dir)!648 fail_write: 2649 }650 if _ := offer(a, mut flaky) {651 assert false, 'the injected failure should have surfaced'652 }653654 655 656 mut healthy := remote.new_folder(remote_dir)!657 report := offer(a, mut healthy)!658 assert report.saves == 2659 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id660 assert healthy.list('proj/saves')!.len == 2661}662663fn test_a_failed_steal_leaves_nothing_behind() {664 base := scratch('fault_steal')!665 defer {666 os.rmdir_all(base) or {}667 }668 remote_dir := os.join_path(base, 'remote')669 mut a := open_repo(machine('fault_s_a')!)!670 defer {671 a.close()672 }673 mut b := open_repo(machine('fault_s_b')!)!674 defer {675 b.close()676 }677 put(a.root, 'a.txt', 'from A\n')!678 commit(a, 'A writes')!679 mut healthy := remote.new_folder(remote_dir)!680 offer(a, mut healthy)!681682 683 mut flaky := FaultyBackend{684 inner: remote.new_folder(remote_dir)!685 fail_read: 2686 }687 if _ := fetch_back_to_known(b, mut flaky, 'proj') {688 assert false, 'the injected read failure should have surfaced'689 }690 691 assert history.head(b) == none692 assert b.object_count()! == 0693694 695 fetched := fetch_back_to_known(b, mut healthy, 'proj')!696 b.begin()!697 write_fetched(b, fetched)!698 history.set_head(b, fetched.head)!699 b.commit()!700 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!701 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'702}703704fn test_an_interrupted_strip_is_idempotent() {705 706 707 708 base := scratch('fault_strip')!709 defer {710 os.rmdir_all(base) or {}711 }712 mut a := open_repo(machine('fault_strip_a')!)!713 defer {714 a.close()715 }716 put(a.root, 'secret.txt', 'shh\n')!717 put(a.root, 'keep.txt', 'fine\n')!718 save := commit(a, 'add both')!719720 for _ in 0 .. 2 {721 strip_local(a, 'secret.txt', '')!722 }723 files := history.files_of(a, save.id)!724 725 726 assert files.map(it.path) == ['.kohignore', 'keep.txt']727 assert events.strip_events(a)!.len == 1728}729730731732733734fn test_a_strip_matches_the_path_exactly() {735 mut a := open_repo(machine('strip_exact')!)!736 defer {737 a.close()738 }739 hash := a.put_object('x\n'.bytes())!740 mut files := []snapshot.FileEntry{}741 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {742 files << snapshot.FileEntry{743 path: path744 hash: hash745 }746 }747 save := history.commit_files(a, '', files, history.SaveInput{748 message: 'add all'749 })!750 assert strip_scope(a, 'docs', '')!.files == 1751 strip_local(a, 'docs', '')!752 strip_local(a, 'a_b', '')!753 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']754}755756fn test_a_strip_reaches_the_remote_and_other_machines() {757 base := scratch('strip_sync')!758 defer {759 os.rmdir_all(base) or {}760 }761 mut a := open_repo(machine('strip_a')!)!762 defer {763 a.close()764 }765 mut b := open_repo(machine('strip_b')!)!766 defer {767 b.close()768 }769 put(a.root, 'secrets.env', 'api_key=1\n')!770 put(a.root, 'keep.txt', 'fine\n')!771 save := commit(a, 'add both')!772 mut backend := remote.new_folder(os.join_path(base, 'remote'))!773 offer_now(a, mut backend)!774 join(b, mut backend)!775 assert b.object_count()! > 0776777 778 779 mut secret_hash := ''780 for file in history.files_of(a, save.id)! {781 if file.path == 'secrets.env' {782 secret_hash = file.hash783 }784 }785 assert secret_hash != ''786 strip_local(a, 'secrets.env', '')!787 report := push_strip(a, mut backend, 'secrets.env')!788 assert report.event_sent789 assert report.records == 1790791 792 793 record := decode_save(backend.get(save_key('proj', save.id))!.body)!794 for file in record.files {795 assert file.path != 'secrets.env'796 }797 798 799 assert !backend.get(object_key('proj', secret_hash))!.found800 assert backend.list('proj/objects')!.len == 2801802 803 b.begin()!804 fetched := fetch_back_to_known(b, mut backend, 'proj')!805 write_fetched(b, fetched)!806 b.commit()!807 mut paths := []string{}808 for file in history.files_of(b, save.id)! {809 paths << file.path810 }811 assert 'secrets.env' !in paths812 assert 'keep.txt' in paths813814 815 816 817 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)818 put(b.root, 'secrets.env', 'rotated\n')!819 readded := commit(b, 'add the rotated key back')!820 offer_now(b, mut backend)!821 assert backend.list('proj/events')!.len == 1822 b.begin()!823 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!824 b.commit()!825 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)826}827828829830831fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {832 base := scratch('strip_clone')!833 defer {834 os.rmdir_all(base) or {}835 }836 mut backend := remote.new_folder(os.join_path(base, 'remote'))!837 mut a := open_repo(machine('strip_clone_a')!)!838 defer {839 a.close()840 }841 put(a.root, 'secrets.env', 'api_key=1\n')!842 old := commit(a, 'leak the key')!843 offer_now(a, mut backend)!844 strip_local(a, 'secrets.env', '')!845 push_strip(a, mut backend, 'secrets.env')!846 847 put(a.root, 'secrets.env', 'api_key=2\n')!848 rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{849 message: 'rotate the key'850 created_at: events.strip_events(a)!.last().created_at + 60851 })!852 offer_now(a, mut backend)!853854 mut c := open_repo(machine('strip_clone_c')!)!855 defer {856 c.close()857 }858 join(c, mut backend)!859 assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)860 assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)861 assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'862}863864865866867fn test_an_interrupted_remote_strip_finishes_when_re_run() {868 base := scratch('strip_resume')!869 defer {870 os.rmdir_all(base) or {}871 }872 remote_dir := os.join_path(base, 'remote')873 mut backend := remote.new_folder(remote_dir)!874 mut a := open_repo(machine('resume_a')!)!875 defer {876 a.close()877 }878 mut b := open_repo(machine('resume_b')!)!879 defer {880 b.close()881 }882 put(a.root, 'secrets.env', 'api_key=1\n')!883 put(a.root, 'shared.env', 'same bytes on B\n')!884 save := commit(a, 'add secrets')!885 offer_now(a, mut backend)!886 join(b, mut backend)!887 put(b.root, 'copy.txt', 'same bytes on B\n')!888 b_save := commit(b, 'B keeps a copy')!889 offer_now(b, mut backend)!890 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())891 secret_hash := storage.hash_hex('api_key=1\n'.bytes())892893 strip_local(a, 'secrets.env', '')!894 895 896 mut flaky := FaultyBackend{897 inner: remote.new_folder(remote_dir)!898 fail_write: 3899 }900 if _ := push_strip(a, mut flaky, 'secrets.env') {901 assert false, 'the injected failure should have surfaced'902 }903 assert strip_pending(a, 'secrets.env')904 assert strip_local(a, 'secrets.env', '')!.files_removed == 0905 report := push_strip(a, mut backend, 'secrets.env')!906 assert report.records == 1907 assert !strip_pending(a, 'secrets.env')908 assert !backend.get(object_key('proj', secret_hash))!.found909 record := decode_save(backend.get(save_key('proj', save.id))!.body)!910 assert 'secrets.env' !in record.files.map(it.path)911 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!912 assert 'secrets.env' !in b_record.files.map(it.path)913914 915 strip_local(a, 'shared.env', '')!916 push_strip(a, mut backend, 'shared.env')!917 assert backend.get(object_key('proj', shared_hash))!.found918}919920921922fn test_offer_waits_for_an_unapplied_strip() {923 base := scratch('strip_first')!924 defer {925 os.rmdir_all(base) or {}926 }927 mut backend := remote.new_folder(os.join_path(base, 'remote'))!928 mut a := open_repo(machine('strip_first_a')!)!929 defer {930 a.close()931 }932 mut b := open_repo(machine('strip_first_b')!)!933 defer {934 b.close()935 }936 put(a.root, 'secrets.env', 'api_key=1\n')!937 commit(a, 'add secrets')!938 offer_now(a, mut backend)!939 join(b, mut backend)!940 strip_local(a, 'secrets.env', '')!941 push_strip(a, mut backend, 'secrets.env')!942 put(b.root, 'more.txt', 'B keeps working\n')!943 later := commit(b, 'B saves with the secret still in its tree')!944 if _ := offer_now(b, mut backend) {945 assert false, 'B offered records that still list the stripped path'946 }947 steal_now(b, mut backend)!948 offer_now(b, mut backend)!949 record := decode_save(backend.get(save_key('proj', later.id))!.body)!950 assert 'secrets.env' !in record.files.map(it.path)951}952953954955fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {956 base := scratch('strip_twice')!957 defer {958 os.rmdir_all(base) or {}959 }960 remote_dir := os.join_path(base, 'remote')961 mut backend := remote.new_folder(remote_dir)!962 mut a := open_repo(machine('strip_twice_a')!)!963 defer {964 a.close()965 }966 put(a.root, 'secrets.env', 'first key\n')!967 commit(a, 'first key')!968 offer_now(a, mut backend)!969 strip_local(a, 'secrets.env', '')!970 mut flaky := FaultyBackend{971 inner: remote.new_folder(remote_dir)!972 fail_delete: true973 }974 if _ := push_strip(a, mut flaky, 'secrets.env') {975 assert false, 'the injected delete failure should have surfaced'976 }977 put(a.root, 'secrets.env', 'second key\n')!978 commit(a, 'second key')!979 offer_now(a, mut backend)!980 strip_local(a, 'secrets.env', '')!981 push_strip(a, mut backend, 'secrets.env')!982 for key in ['first key\n', 'second key\n'] {983 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found984 }985}986987988fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {989 base := scratch('strip_scopes')!990 defer {991 os.rmdir_all(base) or {}992 }993 remote_dir := os.join_path(base, 'remote')994 mut backend := remote.new_folder(remote_dir)!995 mut a := open_repo(machine('strip_scopes_a')!)!996 defer {997 a.close()998 }999 put(a.root, 'secrets.env', 'first key\n')!1000 first := commit(a, 'first key')!1001 put(a.root, 'secrets.env', 'second key\n')!1002 second := commit(a, 'second key')!1003 offer_now(a, mut backend)!1004 strip_local(a, 'secrets.env', first.id)!1005 1006 mut flaky := FaultyBackend{1007 inner: remote.new_folder(remote_dir)!1008 fail_write: 21009 }1010 if _ := push_strip(a, mut flaky, 'secrets.env') {1011 assert false, 'the injected failure should have surfaced'1012 }1013 strip_local(a, 'secrets.env', second.id)!1014 push_strip(a, mut backend, 'secrets.env')!1015 assert !strip_pending(a, 'secrets.env')1016 for save in [first, second] {1017 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1018 assert 'secrets.env' !in record.files.map(it.path)1019 }1020 for key in ['first key\n', 'second key\n'] {1021 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1022 }1023}1024102510261027fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1028 base := scratch('strip_again')!1029 defer {1030 os.rmdir_all(base) or {}1031 }1032 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1033 mut a := open_repo(machine('strip_again_a')!)!1034 defer {1035 a.close()1036 }1037 mut b := open_repo(machine('strip_again_b')!)!1038 defer {1039 b.close()1040 }1041 put(a.root, 'secrets.env', 'first key\n')!1042 commit(a, 'first key')!1043 offer_now(a, mut backend)!1044 join(b, mut backend)!1045 strip_local(a, 'secrets.env', '')!1046 push_strip(a, mut backend, 'secrets.env')!1047 steal_now(b, mut backend)!1048 1049 os.rm(os.join_path(b.root, 'secrets.env'))!1050 put(a.root, 'secrets.env', 'second key\n')!1051 second := commit(a, 'second key')!1052 offer_now(a, mut backend)!1053 steal_now(b, mut backend)!1054 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1055 strip_local(a, 'secrets.env', '')!1056 push_strip(a, mut backend, 'secrets.env')!1057 steal_now(b, mut backend)!1058 assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1059}1060106110621063fn test_a_stripped_path_is_not_asked_about() {1064 base := scratch('clash_strip')!1065 defer {1066 os.rmdir_all(base) or {}1067 }1068 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1069 mut a := open_repo(machine('clash_strip_a')!)!1070 defer {1071 a.close()1072 }1073 mut b := open_repo(machine('clash_strip_b')!)!1074 defer {1075 b.close()1076 }1077 put(a.root, 'secrets.env', 'key one\n')!1078 commit(a, 'base')!1079 offer_now(a, mut backend)!1080 join(b, mut backend)!1081 put(a.root, 'secrets.env', 'key two\n')!1082 commit(a, 'A rotates')!1083 offer_now(a, mut backend)!1084 strip_local(a, 'secrets.env', '')!1085 push_strip(a, mut backend, 'secrets.env')!1086 put(b.root, 'secrets.env', 'key three\n')!1087 mine := commit(b, 'B rotates')!1088 report := steal(b, mut backend, StealOptions{})!1089 assert report.clashes.len == 01090 for save in history.all_saves(b)! {1091 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1092 }1093 assert history.by_id(b, mine.id) != none1094}1095109610971098fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1099 base := scratch('clash_base_strip')!1100 defer {1101 os.rmdir_all(base) or {}1102 }1103 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1104 mut a := open_repo(machine('base_strip_a')!)!1105 defer {1106 a.close()1107 }1108 mut b := open_repo(machine('base_strip_b')!)!1109 defer {1110 b.close()1111 }1112 put(a.root, 'notes.txt', 'shared\n')!1113 common := commit(a, 'base')!1114 offer_now(a, mut backend)!1115 join(b, mut backend)!1116 put(a.root, 'other.txt', 'A moves on\n')!1117 commit(a, 'A moves on')!1118 offer_now(a, mut backend)!1119 strip_local(a, 'notes.txt', common.id)!1120 push_strip(a, mut backend, 'notes.txt')!1121 put(b.root, 'notes.txt', 'B edits\n')!1122 commit(b, 'B edits notes')!1123 if _ := steal(b, mut backend, StealOptions{}) {1124 assert false, 'with the base stripped, notes.txt differs on both sides'1125 } else {1126 assert err.msg().contains('changed on both machines')1127 }1128 report := steal(b, mut backend, StealOptions{1129 mine: true1130 })!1131 assert report.clashes == ['notes.txt']1132 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1133}1134113511361137fn test_steal_into_a_folder_with_unsaved_files_refuses() {1138 base := scratch('steal_unsaved')!1139 defer {1140 os.rmdir_all(base) or {}1141 }1142 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1143 mut a := open_repo(machine('unsaved_a')!)!1144 defer {1145 a.close()1146 }1147 put(a.root, 'notes.txt', 'from A\n')!1148 commit(a, 'A writes')!1149 offer_now(a, mut backend)!1150 b_root := machine('unsaved_b')!1151 mut b := storage.create(b_root)!1152 defer {1153 b.close()1154 }1155 put(b_root, 'notes.txt', 'never saved\n')!1156 1157 b.set_state('remote_repo', 'proj')!1158 if _ := steal(b, mut backend, StealOptions{}) {1159 assert false, 'steal overwrote a folder with unsaved files'1160 } else {1161 assert err.msg() == no_saves_here1162 }1163 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1164 1165 os.rm(os.join_path(b_root, 'notes.txt'))!1166 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1167 steal(b, mut backend, StealOptions{})!1168 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1169}1170117111721173fn test_steal_reports_incoming_flagged_saves() {1174 base := scratch('flag_warn')!1175 defer {1176 os.rmdir_all(base) or {}1177 }1178 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1179 mut a := open_repo(machine('flag_warn_a')!)!1180 defer {1181 a.close()1182 }1183 mut b := open_repo(machine('flag_warn_b')!)!1184 defer {1185 b.close()1186 }1187 put(a.root, 'a.txt', 'one\n')!1188 commit(a, 'fine')!1189 offer_now(a, mut backend)!1190 join(b, mut backend)!1191 put(a.root, 'a.txt', 'two\n')!1192 suspect := commit(a, 'suspect')!1193 put(a.root, 'a.txt', 'three\n')!1194 commit(a, 'also fine')!1195 events.flag(a, suspect.id, 'broke the build')!1196 offer_now(a, mut backend)!1197 report := steal_now(b, mut backend)!1198 assert report.flagged.map(it.save_id) == [suspect.id]1199 assert report.flagged[0].reason == 'broke the build'1200}