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_and_offered_head_are_remembered() {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 a.state('remote_head') or { '' } == 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 a.state('remote_head') or { '' } == 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}162163164fn test_steal_refuses_an_unsaved_executable_bit_change() {165 base := scratch('chmod')!166 defer {167 os.rmdir_all(base) or {}168 }169 mut backend := remote.new_folder(os.join_path(base, 'remote'))!170 mut a := open_repo(machine('chmod_a')!)!171 defer {172 a.close()173 }174 put(a.root, 'run.sh', 'echo hi\n')!175 commit(a, 'script')!176 offer_now(a, mut backend)!177 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!178 if _ := steal_now(a, mut backend) {179 assert false, 'a chmod-only change should make the tree dirty'180 } else {181 assert err.msg() == dirty_tree182 }183}184185fn test_divergence_without_clashes_keeps_both_machines_work() {186 base := scratch('diverge')!187 defer {188 os.rmdir_all(base) or {}189 }190 mut backend := remote.new_folder(os.join_path(base, 'remote'))!191 mut a := open_repo(machine('div_a')!)!192 defer {193 a.close()194 }195 mut b := open_repo(machine('div_b')!)!196 defer {197 b.close()198 }199 put(a.root, 'shared.txt', 'base\n')!200 commit(a, 'base')!201 offer_now(a, mut backend)!202203 204 join(b, mut backend)!205 put(a.root, 'fromA.txt', 'A\n')!206 commit(a, 'A adds a file')!207 put(b.root, 'fromB.txt', 'B\n')!208 commit(b, 'B adds a file')!209 offer_now(a, mut backend)!210211 212 if _ := offer_now(b, mut backend) {213 assert false, 'offering on top of unknown remote saves should be refused'214 } else {215 assert err.msg().contains('run koh steal')216 }217218 report := steal_now(b, mut backend)!219 assert !report.forward220 assert report.replayed == 1221 assert report.clashes.len == 0222 223 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'224 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'225 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'226 227 mut saw_replay := false228 for save in history.all_saves(b)! {229 if save.replayed_from != '' {230 saw_replay = true231 }232 }233 assert saw_replay234 235 offer_now(b, mut backend)!236 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()237 assert line_head == history.head(b)!.id238}239240fn test_clashing_files_are_chosen_one_at_a_time() {241 base := scratch('clash')!242 defer {243 os.rmdir_all(base) or {}244 }245 mut backend := remote.new_folder(os.join_path(base, 'remote'))!246 mut a := open_repo(machine('clash_a')!)!247 defer {248 a.close()249 }250 mut b := open_repo(machine('clash_b')!)!251 defer {252 b.close()253 }254 put(a.root, 'same.txt', 'base\n')!255 put(a.root, 'other.txt', 'quiet\n')!256 commit(a, 'base')!257 offer_now(a, mut backend)!258 join(b, mut backend)!259260 261 put(a.root, 'same.txt', 'A version\n')!262 commit(a, 'A edits same')!263 offer_now(a, mut backend)!264 put(b.root, 'same.txt', 'B version\n')!265 commit(b, 'B edits same')!266267 268 if _ := steal(b, mut backend, StealOptions{}) {269 assert false, 'an undecided clash must be refused'270 } else {271 assert err.msg().contains('--mine') || err.msg().contains('--theirs')272 }273 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,274 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0275276 277 report := steal_now(b, mut backend)!278 assert report.clashes == ['same.txt']279 assert report.kept_mine == 0280 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'281 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'282}283284fn test_choosing_mine_keeps_the_local_version() {285 base := scratch('mine')!286 defer {287 os.rmdir_all(base) or {}288 }289 mut backend := remote.new_folder(os.join_path(base, 'remote'))!290 mut a := open_repo(machine('mine_a')!)!291 defer {292 a.close()293 }294 mut b := open_repo(machine('mine_b')!)!295 defer {296 b.close()297 }298 put(a.root, 'same.txt', 'base\n')!299 commit(a, 'base')!300 offer_now(a, mut backend)!301 join(b, mut backend)!302 put(a.root, 'same.txt', 'A version\n')!303 commit(a, 'A edits')!304 offer_now(a, mut backend)!305 put(b.root, 'same.txt', 'B version\n')!306 commit(b, 'B edits')!307308 report := steal(b, mut backend, StealOptions{309 mine: true310 })!311 assert report.kept_mine == 1312 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'313 314 assert history.head(b)!.message.contains('combine: kept mine for 1 file')315 316 assert history.head(b)!.restores == ''317 assert history.all_saves(b)!.len >= 4318}319320fn test_flags_travel_with_the_saves() {321 base := scratch('flags')!322 defer {323 os.rmdir_all(base) or {}324 }325 mut backend := remote.new_folder(os.join_path(base, 'remote'))!326 mut a := open_repo(machine('flag_a')!)!327 defer {328 a.close()329 }330 mut b := open_repo(machine('flag_b')!)!331 defer {332 b.close()333 }334 put(a.root, 'a.txt', 'one\n')!335 save := commit(a, 'suspect save')!336 events.flag(a, save.id, 'look at this')!337 offer_now(a, mut backend)!338339 340 fetched := fetch_back_to_known(b, mut backend, 'proj')!341 assert fetched.events_out.len == 1342 assert fetched.events_out[0].kind == events.kind_flag343 b.begin()!344 write_fetched(b, fetched)!345 history.set_head(b, fetched.head)!346 b.commit()!347 state := events.flag_state(b, save.id)!348 assert state.flagged349 assert state.reason == 'look at this'350}351352fn test_a_second_offer_racing_head_is_refused_not_forced() {353 base := scratch('race')!354 defer {355 os.rmdir_all(base) or {}356 }357 mut backend := remote.new_folder(os.join_path(base, 'remote'))!358 mut a := open_repo(machine('race_a')!)!359 defer {360 a.close()361 }362 put(a.root, 'a.txt', 'one\n')!363 commit(a, 'base')!364 offer_now(a, mut backend)!365366 367 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),368 backend.get(head_key('proj'))!.tag)!369370 put(a.root, 'a.txt', 'two\n')!371 commit(a, 'second')!372 if _ := offer_now(a, mut backend) {373 assert false, 'offering while the remote head moved must be refused'374 } else {375 assert err.msg().contains('run koh steal')376 }377}378379380fn join(store &storage.Store, mut backend remote.Backend) ! {381 fetched := fetch_repo(mut backend, 'proj')!382 store.begin()!383 write_fetched(store, fetched)!384 history.set_head(store, fetched.head)!385 store.commit()!386 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,387 fetched.head)!, [])!388 store.set_state('remote_repo', 'proj')!389}390391struct FaultyBackend {392mut:393 inner remote.Backend394 writes int395 reads int396 fail_write int 397 fail_read int398}399400fn (mut f FaultyBackend) get(key string) !remote.Object {401 f.reads++402 if f.fail_read > 0 && f.reads >= f.fail_read {403 return error('injected read failure')404 }405 return f.inner.get(key)!406}407408fn (mut f FaultyBackend) list(prefix string) ![]string {409 return f.inner.list(prefix)!410}411412fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {413 f.writes++414 if f.fail_write > 0 && f.writes >= f.fail_write {415 return error('injected write failure')416 }417 return f.inner.put_if_absent(key, body)!418}419420fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {421 f.writes++422 if f.fail_write > 0 && f.writes >= f.fail_write {423 return error('injected write failure')424 }425 return f.inner.put_if_match(key, body, tag)!426}427428fn (mut f FaultyBackend) delete(key string) ! {429 f.inner.delete(key)!430}431432fn test_a_failed_offer_is_safe_to_re_run() {433 base := scratch('fault_offer')!434 defer {435 os.rmdir_all(base) or {}436 }437 remote_dir := os.join_path(base, 'remote')438 mut a := open_repo(machine('fault_a')!)!439 defer {440 a.close()441 }442 put(a.root, 'a.txt', 'one\n')!443 commit(a, 'first')!444 put(a.root, 'a.txt', 'two\n')!445 commit(a, 'second')!446447 448 mut flaky := FaultyBackend{449 inner: remote.new_folder(remote_dir)!450 fail_write: 2451 }452 if _ := offer(a, mut flaky) {453 assert false, 'the injected failure should have surfaced'454 }455456 457 458 mut healthy := remote.new_folder(remote_dir)!459 report := offer(a, mut healthy)!460 assert report.saves == 2461 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id462 assert healthy.list('proj/saves')!.len == 2463}464465fn test_a_failed_steal_leaves_nothing_behind() {466 base := scratch('fault_steal')!467 defer {468 os.rmdir_all(base) or {}469 }470 remote_dir := os.join_path(base, 'remote')471 mut a := open_repo(machine('fault_s_a')!)!472 defer {473 a.close()474 }475 mut b := open_repo(machine('fault_s_b')!)!476 defer {477 b.close()478 }479 put(a.root, 'a.txt', 'from A\n')!480 commit(a, 'A writes')!481 mut healthy := remote.new_folder(remote_dir)!482 offer(a, mut healthy)!483484 485 mut flaky := FaultyBackend{486 inner: remote.new_folder(remote_dir)!487 fail_read: 2488 }489 if _ := fetch_back_to_known(b, mut flaky, 'proj') {490 assert false, 'the injected read failure should have surfaced'491 }492 493 assert history.head(b) == none494 assert b.object_count()! == 0495496 497 fetched := fetch_back_to_known(b, mut healthy, 'proj')!498 b.begin()!499 write_fetched(b, fetched)!500 history.set_head(b, fetched.head)!501 b.commit()!502 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!503 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'504}505506fn test_an_interrupted_strip_is_idempotent() {507 508 509 510 base := scratch('fault_strip')!511 defer {512 os.rmdir_all(base) or {}513 }514 mut a := open_repo(machine('fault_strip_a')!)!515 defer {516 a.close()517 }518 put(a.root, 'secret.txt', 'shh\n')!519 put(a.root, 'keep.txt', 'fine\n')!520 save := commit(a, 'add both')!521522 523 for _ in 0 .. 2 {524 a.begin()!525 mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!526 stmt.bind_text(1, 'secret.txt')!527 stmt.step()!528 stmt.finalize()529 a.drop_unreferenced_objects()!530 events.strip(a, '', 'secret.txt')!531 a.commit()!532 }533 files := history.files_of(a, save.id)!534 535 536 assert files.map(it.path) == ['.kohignore', 'keep.txt']537 assert events.strip_events(a)!.len == 1538}539540fn test_a_strip_reaches_the_remote_and_other_machines() {541 base := scratch('strip_sync')!542 defer {543 os.rmdir_all(base) or {}544 }545 mut a := open_repo(machine('strip_a')!)!546 defer {547 a.close()548 }549 mut b := open_repo(machine('strip_b')!)!550 defer {551 b.close()552 }553 put(a.root, 'secrets.env', 'api_key=1\n')!554 put(a.root, 'keep.txt', 'fine\n')!555 save := commit(a, 'add both')!556 mut backend := remote.new_folder(os.join_path(base, 'remote'))!557 offer_now(a, mut backend)!558 join(b, mut backend)!559 assert b.object_count()! > 0560561 562 563 mut secret_hash := ''564 for file in history.files_of(a, save.id)! {565 if file.path == 'secrets.env' {566 secret_hash = file.hash567 }568 }569 assert secret_hash != ''570 a.begin()!571 mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!572 stmt.bind_text(1, 'secrets.env')!573 stmt.step()!574 stmt.finalize()575 a.drop_unreferenced_objects()!576 events.strip(a, '', 'secrets.env')!577 a.commit()!578 report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!579 assert report.event_sent580 assert report.records == 1581582 583 584 record := decode_save(backend.get(save_key('proj', save.id))!.body)!585 for file in record.files {586 assert file.path != 'secrets.env'587 }588 589 590 assert !backend.get(object_key('proj', secret_hash))!.found591 assert backend.list('proj/objects')!.len == 2592593 594 b.begin()!595 fetched := fetch_back_to_known(b, mut backend, 'proj')!596 write_fetched(b, fetched)!597 b.commit()!598 mut paths := []string{}599 for file in history.files_of(b, save.id)! {600 paths << file.path601 }602 assert 'secrets.env' !in paths603 assert 'keep.txt' in paths604605 606 607 608 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)609 put(b.root, 'secrets.env', 'rotated\n')!610 readded := commit(b, 'add the rotated key back')!611 offer_now(b, mut backend)!612 assert backend.list('proj/events')!.len == 1613 b.begin()!614 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!615 b.commit()!616 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)617}