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}137138139fn test_steal_refuses_an_unsaved_executable_bit_change() {140 base := scratch('chmod')!141 defer {142 os.rmdir_all(base) or {}143 }144 mut backend := remote.new_folder(os.join_path(base, 'remote'))!145 mut a := open_repo(machine('chmod_a')!)!146 defer {147 a.close()148 }149 put(a.root, 'run.sh', 'echo hi\n')!150 commit(a, 'script')!151 offer_now(a, mut backend)!152 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!153 if _ := steal_now(a, mut backend) {154 assert false, 'a chmod-only change should make the tree dirty'155 } else {156 assert err.msg() == dirty_tree157 }158}159160fn test_divergence_without_clashes_keeps_both_machines_work() {161 base := scratch('diverge')!162 defer {163 os.rmdir_all(base) or {}164 }165 mut backend := remote.new_folder(os.join_path(base, 'remote'))!166 mut a := open_repo(machine('div_a')!)!167 defer {168 a.close()169 }170 mut b := open_repo(machine('div_b')!)!171 defer {172 b.close()173 }174 put(a.root, 'shared.txt', 'base\n')!175 commit(a, 'base')!176 offer_now(a, mut backend)!177178 179 join(b, mut backend)!180 put(a.root, 'fromA.txt', 'A\n')!181 commit(a, 'A adds a file')!182 put(b.root, 'fromB.txt', 'B\n')!183 commit(b, 'B adds a file')!184 offer_now(a, mut backend)!185186 187 if _ := offer_now(b, mut backend) {188 assert false, 'offering on top of unknown remote saves should be refused'189 } else {190 assert err.msg().contains('run koh steal')191 }192193 report := steal_now(b, mut backend)!194 assert !report.forward195 assert report.replayed == 1196 assert report.clashes.len == 0197 198 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'199 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'200 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'201 202 mut saw_replay := false203 for save in history.all_saves(b)! {204 if save.replayed_from != '' {205 saw_replay = true206 }207 }208 assert saw_replay209 210 offer_now(b, mut backend)!211 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()212 assert line_head == history.head(b)!.id213}214215fn test_clashing_files_are_chosen_one_at_a_time() {216 base := scratch('clash')!217 defer {218 os.rmdir_all(base) or {}219 }220 mut backend := remote.new_folder(os.join_path(base, 'remote'))!221 mut a := open_repo(machine('clash_a')!)!222 defer {223 a.close()224 }225 mut b := open_repo(machine('clash_b')!)!226 defer {227 b.close()228 }229 put(a.root, 'same.txt', 'base\n')!230 put(a.root, 'other.txt', 'quiet\n')!231 commit(a, 'base')!232 offer_now(a, mut backend)!233 join(b, mut backend)!234235 236 put(a.root, 'same.txt', 'A version\n')!237 commit(a, 'A edits same')!238 offer_now(a, mut backend)!239 put(b.root, 'same.txt', 'B version\n')!240 commit(b, 'B edits same')!241242 243 if _ := steal(b, mut backend, StealOptions{}) {244 assert false, 'an undecided clash must be refused'245 } else {246 assert err.msg().contains('--mine') || err.msg().contains('--theirs')247 }248 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,249 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0250251 252 report := steal_now(b, mut backend)!253 assert report.clashes == ['same.txt']254 assert report.kept_mine == 0255 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'256 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'257}258259fn test_choosing_mine_keeps_the_local_version() {260 base := scratch('mine')!261 defer {262 os.rmdir_all(base) or {}263 }264 mut backend := remote.new_folder(os.join_path(base, 'remote'))!265 mut a := open_repo(machine('mine_a')!)!266 defer {267 a.close()268 }269 mut b := open_repo(machine('mine_b')!)!270 defer {271 b.close()272 }273 put(a.root, 'same.txt', 'base\n')!274 commit(a, 'base')!275 offer_now(a, mut backend)!276 join(b, mut backend)!277 put(a.root, 'same.txt', 'A version\n')!278 commit(a, 'A edits')!279 offer_now(a, mut backend)!280 put(b.root, 'same.txt', 'B version\n')!281 commit(b, 'B edits')!282283 report := steal(b, mut backend, StealOptions{284 mine: true285 })!286 assert report.kept_mine == 1287 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'288 289 assert history.head(b)!.message.contains('combine: kept mine for 1 file')290 291 assert history.head(b)!.restores == ''292 assert history.all_saves(b)!.len >= 4293}294295fn test_flags_travel_with_the_saves() {296 base := scratch('flags')!297 defer {298 os.rmdir_all(base) or {}299 }300 mut backend := remote.new_folder(os.join_path(base, 'remote'))!301 mut a := open_repo(machine('flag_a')!)!302 defer {303 a.close()304 }305 mut b := open_repo(machine('flag_b')!)!306 defer {307 b.close()308 }309 put(a.root, 'a.txt', 'one\n')!310 save := commit(a, 'suspect save')!311 events.flag(a, save.id, 'look at this')!312 offer_now(a, mut backend)!313314 315 fetched := fetch_back_to_known(b, mut backend, 'proj')!316 assert fetched.events_out.len == 1317 assert fetched.events_out[0].kind == events.kind_flag318 b.begin()!319 write_fetched(b, fetched)!320 history.set_head(b, fetched.head)!321 b.commit()!322 state := events.flag_state(b, save.id)!323 assert state.flagged324 assert state.reason == 'look at this'325}326327fn test_a_second_offer_racing_head_is_refused_not_forced() {328 base := scratch('race')!329 defer {330 os.rmdir_all(base) or {}331 }332 mut backend := remote.new_folder(os.join_path(base, 'remote'))!333 mut a := open_repo(machine('race_a')!)!334 defer {335 a.close()336 }337 put(a.root, 'a.txt', 'one\n')!338 commit(a, 'base')!339 offer_now(a, mut backend)!340341 342 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),343 backend.get(head_key('proj'))!.tag)!344345 put(a.root, 'a.txt', 'two\n')!346 commit(a, 'second')!347 if _ := offer_now(a, mut backend) {348 assert false, 'offering while the remote head moved must be refused'349 } else {350 assert err.msg().contains('run koh steal')351 }352}353354355fn join(store &storage.Store, mut backend remote.Backend) ! {356 fetched := fetch_repo(mut backend, 'proj')!357 store.begin()!358 write_fetched(store, fetched)!359 history.set_head(store, fetched.head)!360 store.commit()!361 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,362 fetched.head)!, [])!363 store.set_state('remote_repo', 'proj')!364}365366struct FaultyBackend {367mut:368 inner remote.Backend369 writes int370 reads int371 fail_write int 372 fail_read int373}374375fn (mut f FaultyBackend) get(key string) !remote.Object {376 f.reads++377 if f.fail_read > 0 && f.reads >= f.fail_read {378 return error('injected read failure')379 }380 return f.inner.get(key)!381}382383fn (mut f FaultyBackend) list(prefix string) ![]string {384 return f.inner.list(prefix)!385}386387fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {388 f.writes++389 if f.fail_write > 0 && f.writes >= f.fail_write {390 return error('injected write failure')391 }392 return f.inner.put_if_absent(key, body)!393}394395fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {396 f.writes++397 if f.fail_write > 0 && f.writes >= f.fail_write {398 return error('injected write failure')399 }400 return f.inner.put_if_match(key, body, tag)!401}402403fn (mut f FaultyBackend) delete(key string) ! {404 f.inner.delete(key)!405}406407fn test_a_failed_offer_is_safe_to_re_run() {408 base := scratch('fault_offer')!409 defer {410 os.rmdir_all(base) or {}411 }412 remote_dir := os.join_path(base, 'remote')413 mut a := open_repo(machine('fault_a')!)!414 defer {415 a.close()416 }417 put(a.root, 'a.txt', 'one\n')!418 commit(a, 'first')!419 put(a.root, 'a.txt', 'two\n')!420 commit(a, 'second')!421422 423 mut flaky := FaultyBackend{424 inner: remote.new_folder(remote_dir)!425 fail_write: 2426 }427 if _ := offer(a, mut flaky) {428 assert false, 'the injected failure should have surfaced'429 }430431 432 433 mut healthy := remote.new_folder(remote_dir)!434 report := offer(a, mut healthy)!435 assert report.saves == 2436 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id437 assert healthy.list('proj/saves')!.len == 2438}439440fn test_a_failed_steal_leaves_nothing_behind() {441 base := scratch('fault_steal')!442 defer {443 os.rmdir_all(base) or {}444 }445 remote_dir := os.join_path(base, 'remote')446 mut a := open_repo(machine('fault_s_a')!)!447 defer {448 a.close()449 }450 mut b := open_repo(machine('fault_s_b')!)!451 defer {452 b.close()453 }454 put(a.root, 'a.txt', 'from A\n')!455 commit(a, 'A writes')!456 mut healthy := remote.new_folder(remote_dir)!457 offer(a, mut healthy)!458459 460 mut flaky := FaultyBackend{461 inner: remote.new_folder(remote_dir)!462 fail_read: 2463 }464 if _ := fetch_back_to_known(b, mut flaky, 'proj') {465 assert false, 'the injected read failure should have surfaced'466 }467 468 assert history.head(b) == none469 assert b.object_count()! == 0470471 472 fetched := fetch_back_to_known(b, mut healthy, 'proj')!473 b.begin()!474 write_fetched(b, fetched)!475 history.set_head(b, fetched.head)!476 b.commit()!477 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!478 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'479}480481fn test_an_interrupted_strip_is_idempotent() {482 483 484 485 base := scratch('fault_strip')!486 defer {487 os.rmdir_all(base) or {}488 }489 mut a := open_repo(machine('fault_strip_a')!)!490 defer {491 a.close()492 }493 put(a.root, 'secret.txt', 'shh\n')!494 put(a.root, 'keep.txt', 'fine\n')!495 save := commit(a, 'add both')!496497 498 for _ in 0 .. 2 {499 a.begin()!500 mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!501 stmt.bind_text(1, 'secret.txt')!502 stmt.step()!503 stmt.finalize()504 a.drop_unreferenced_objects()!505 events.strip(a, '', 'secret.txt')!506 a.commit()!507 }508 files := history.files_of(a, save.id)!509 510 511 assert files.map(it.path) == ['.kohignore', 'keep.txt']512 assert events.strip_events(a)!.len == 1513}514515fn test_a_strip_reaches_the_remote_and_other_machines() {516 base := scratch('strip_sync')!517 defer {518 os.rmdir_all(base) or {}519 }520 mut a := open_repo(machine('strip_a')!)!521 defer {522 a.close()523 }524 mut b := open_repo(machine('strip_b')!)!525 defer {526 b.close()527 }528 put(a.root, 'secrets.env', 'api_key=1\n')!529 put(a.root, 'keep.txt', 'fine\n')!530 save := commit(a, 'add both')!531 mut backend := remote.new_folder(os.join_path(base, 'remote'))!532 offer_now(a, mut backend)!533 join(b, mut backend)!534 assert b.object_count()! > 0535536 537 538 mut secret_hash := ''539 for file in history.files_of(a, save.id)! {540 if file.path == 'secrets.env' {541 secret_hash = file.hash542 }543 }544 assert secret_hash != ''545 a.begin()!546 mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!547 stmt.bind_text(1, 'secrets.env')!548 stmt.step()!549 stmt.finalize()550 a.drop_unreferenced_objects()!551 events.strip(a, '', 'secrets.env')!552 a.commit()!553 report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!554 assert report.event_sent555 assert report.records == 1556557 558 559 record := decode_save(backend.get(save_key('proj', save.id))!.body)!560 for file in record.files {561 assert file.path != 'secrets.env'562 }563 564 565 assert !backend.get(object_key('proj', secret_hash))!.found566 assert backend.list('proj/objects')!.len == 2567568 569 b.begin()!570 fetched := fetch_back_to_known(b, mut backend, 'proj')!571 write_fetched(b, fetched)!572 b.commit()!573 mut paths := []string{}574 for file in history.files_of(b, save.id)! {575 paths << file.path576 }577 assert 'secrets.env' !in paths578 assert 'keep.txt' in paths579580 581 582 583 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)584 put(b.root, 'secrets.env', 'rotated\n')!585 readded := commit(b, 'add the rotated key back')!586 offer_now(b, mut backend)!587 assert backend.list('proj/events')!.len == 1588 b.begin()!589 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!590 b.commit()!591 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)592}