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}137138fn test_divergence_without_clashes_keeps_both_machines_work() {139 base := scratch('diverge')!140 defer {141 os.rmdir_all(base) or {}142 }143 mut backend := remote.new_folder(os.join_path(base, 'remote'))!144 mut a := open_repo(machine('div_a')!)!145 defer {146 a.close()147 }148 mut b := open_repo(machine('div_b')!)!149 defer {150 b.close()151 }152 put(a.root, 'shared.txt', 'base\n')!153 commit(a, 'base')!154 offer_now(a, mut backend)!155156 157 join(b, mut backend)!158 put(a.root, 'fromA.txt', 'A\n')!159 commit(a, 'A adds a file')!160 put(b.root, 'fromB.txt', 'B\n')!161 commit(b, 'B adds a file')!162 offer_now(a, mut backend)!163164 165 if _ := offer_now(b, mut backend) {166 assert false, 'offering on top of unknown remote saves should be refused'167 } else {168 assert err.msg().contains('run koh steal')169 }170171 report := steal_now(b, mut backend)!172 assert !report.forward173 assert report.replayed == 1174 assert report.clashes.len == 0175 176 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'177 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'178 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'179 180 mut saw_replay := false181 for save in history.all_saves(b)! {182 if save.replayed_from != '' {183 saw_replay = true184 }185 }186 assert saw_replay187 188 offer_now(b, mut backend)!189 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()190 assert line_head == history.head(b)!.id191}192193fn test_clashing_files_are_chosen_one_at_a_time() {194 base := scratch('clash')!195 defer {196 os.rmdir_all(base) or {}197 }198 mut backend := remote.new_folder(os.join_path(base, 'remote'))!199 mut a := open_repo(machine('clash_a')!)!200 defer {201 a.close()202 }203 mut b := open_repo(machine('clash_b')!)!204 defer {205 b.close()206 }207 put(a.root, 'same.txt', 'base\n')!208 put(a.root, 'other.txt', 'quiet\n')!209 commit(a, 'base')!210 offer_now(a, mut backend)!211 join(b, mut backend)!212213 214 put(a.root, 'same.txt', 'A version\n')!215 commit(a, 'A edits same')!216 offer_now(a, mut backend)!217 put(b.root, 'same.txt', 'B version\n')!218 commit(b, 'B edits same')!219220 221 if _ := steal(b, mut backend, StealOptions{}) {222 assert false, 'an undecided clash must be refused'223 } else {224 assert err.msg().contains('--mine') || err.msg().contains('--theirs')225 }226 assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,227 history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0228229 230 report := steal_now(b, mut backend)!231 assert report.clashes == ['same.txt']232 assert report.kept_mine == 0233 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'234 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'235}236237fn test_choosing_mine_keeps_the_local_version() {238 base := scratch('mine')!239 defer {240 os.rmdir_all(base) or {}241 }242 mut backend := remote.new_folder(os.join_path(base, 'remote'))!243 mut a := open_repo(machine('mine_a')!)!244 defer {245 a.close()246 }247 mut b := open_repo(machine('mine_b')!)!248 defer {249 b.close()250 }251 put(a.root, 'same.txt', 'base\n')!252 commit(a, 'base')!253 offer_now(a, mut backend)!254 join(b, mut backend)!255 put(a.root, 'same.txt', 'A version\n')!256 commit(a, 'A edits')!257 offer_now(a, mut backend)!258 put(b.root, 'same.txt', 'B version\n')!259 commit(b, 'B edits')!260261 report := steal(b, mut backend, StealOptions{262 mine: true263 })!264 assert report.kept_mine == 1265 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'266 267 assert history.head(b)!.message.contains('combine: kept mine for 1 file')268 269 assert history.head(b)!.restores == ''270 assert history.all_saves(b)!.len >= 4271}272273fn test_flags_travel_with_the_saves() {274 base := scratch('flags')!275 defer {276 os.rmdir_all(base) or {}277 }278 mut backend := remote.new_folder(os.join_path(base, 'remote'))!279 mut a := open_repo(machine('flag_a')!)!280 defer {281 a.close()282 }283 mut b := open_repo(machine('flag_b')!)!284 defer {285 b.close()286 }287 put(a.root, 'a.txt', 'one\n')!288 save := commit(a, 'suspect save')!289 events.flag(a, save.id, 'look at this')!290 offer_now(a, mut backend)!291292 293 fetched := fetch_back_to_known(b, mut backend, 'proj')!294 assert fetched.events_out.len == 1295 assert fetched.events_out[0].kind == events.kind_flag296 b.begin()!297 write_fetched(b, fetched)!298 history.set_head(b, fetched.head)!299 b.commit()!300 state := events.flag_state(b, save.id)!301 assert state.flagged302 assert state.reason == 'look at this'303}304305fn test_a_second_offer_racing_head_is_refused_not_forced() {306 base := scratch('race')!307 defer {308 os.rmdir_all(base) or {}309 }310 mut backend := remote.new_folder(os.join_path(base, 'remote'))!311 mut a := open_repo(machine('race_a')!)!312 defer {313 a.close()314 }315 put(a.root, 'a.txt', 'one\n')!316 commit(a, 'base')!317 offer_now(a, mut backend)!318319 320 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),321 backend.get(head_key('proj'))!.tag)!322323 put(a.root, 'a.txt', 'two\n')!324 commit(a, 'second')!325 if _ := offer_now(a, mut backend) {326 assert false, 'offering while the remote head moved must be refused'327 } else {328 assert err.msg().contains('run koh steal')329 }330}331332333fn join(store &storage.Store, mut backend remote.Backend) ! {334 fetched := fetch_repo(mut backend, 'proj')!335 store.begin()!336 write_fetched(store, fetched)!337 history.set_head(store, fetched.head)!338 store.commit()!339 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,340 fetched.head)!, [])!341 store.set_state('remote_repo', 'proj')!342}343344struct FaultyBackend {345mut:346 inner remote.Backend347 writes int348 reads int349 fail_write int 350 fail_read int351}352353fn (mut f FaultyBackend) get(key string) !remote.Object {354 f.reads++355 if f.fail_read > 0 && f.reads >= f.fail_read {356 return error('injected read failure')357 }358 return f.inner.get(key)!359}360361fn (mut f FaultyBackend) list(prefix string) ![]string {362 return f.inner.list(prefix)!363}364365fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {366 f.writes++367 if f.fail_write > 0 && f.writes >= f.fail_write {368 return error('injected write failure')369 }370 return f.inner.put_if_absent(key, body)!371}372373fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {374 f.writes++375 if f.fail_write > 0 && f.writes >= f.fail_write {376 return error('injected write failure')377 }378 return f.inner.put_if_match(key, body, tag)!379}380381fn (mut f FaultyBackend) delete(key string) ! {382 f.inner.delete(key)!383}384385fn test_a_failed_offer_is_safe_to_re_run() {386 base := scratch('fault_offer')!387 defer {388 os.rmdir_all(base) or {}389 }390 remote_dir := os.join_path(base, 'remote')391 mut a := open_repo(machine('fault_a')!)!392 defer {393 a.close()394 }395 put(a.root, 'a.txt', 'one\n')!396 commit(a, 'first')!397 put(a.root, 'a.txt', 'two\n')!398 commit(a, 'second')!399400 401 mut flaky := FaultyBackend{402 inner: remote.new_folder(remote_dir)!403 fail_write: 2404 }405 if _ := offer(a, mut flaky) {406 assert false, 'the injected failure should have surfaced'407 }408409 410 411 mut healthy := remote.new_folder(remote_dir)!412 report := offer(a, mut healthy)!413 assert report.saves == 2414 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id415 assert healthy.list('proj/saves')!.len == 2416}417418fn test_a_failed_steal_leaves_nothing_behind() {419 base := scratch('fault_steal')!420 defer {421 os.rmdir_all(base) or {}422 }423 remote_dir := os.join_path(base, 'remote')424 mut a := open_repo(machine('fault_s_a')!)!425 defer {426 a.close()427 }428 mut b := open_repo(machine('fault_s_b')!)!429 defer {430 b.close()431 }432 put(a.root, 'a.txt', 'from A\n')!433 commit(a, 'A writes')!434 mut healthy := remote.new_folder(remote_dir)!435 offer(a, mut healthy)!436437 438 mut flaky := FaultyBackend{439 inner: remote.new_folder(remote_dir)!440 fail_read: 2441 }442 if _ := fetch_back_to_known(b, mut flaky, 'proj') {443 assert false, 'the injected read failure should have surfaced'444 }445 446 assert history.head(b) == none447 assert b.object_count()! == 0448449 450 fetched := fetch_back_to_known(b, mut healthy, 'proj')!451 b.begin()!452 write_fetched(b, fetched)!453 history.set_head(b, fetched.head)!454 b.commit()!455 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!456 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'457}458459fn test_an_interrupted_strip_is_idempotent() {460 461 462 463 base := scratch('fault_strip')!464 defer {465 os.rmdir_all(base) or {}466 }467 mut a := open_repo(machine('fault_strip_a')!)!468 defer {469 a.close()470 }471 put(a.root, 'secret.txt', 'shh\n')!472 put(a.root, 'keep.txt', 'fine\n')!473 save := commit(a, 'add both')!474475 476 for _ in 0 .. 2 {477 a.begin()!478 mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!479 stmt.bind_text(1, 'secret.txt')!480 stmt.step()!481 stmt.finalize()482 a.drop_unreferenced_objects()!483 events.strip(a, '', 'secret.txt')!484 a.commit()!485 }486 files := history.files_of(a, save.id)!487 488 489 assert files.map(it.path) == ['.kohignore', 'keep.txt']490 assert events.strip_events(a)!.len == 1491}492493fn test_a_strip_reaches_the_remote_and_other_machines() {494 base := scratch('strip_sync')!495 defer {496 os.rmdir_all(base) or {}497 }498 mut a := open_repo(machine('strip_a')!)!499 defer {500 a.close()501 }502 mut b := open_repo(machine('strip_b')!)!503 defer {504 b.close()505 }506 put(a.root, 'secrets.env', 'api_key=1\n')!507 put(a.root, 'keep.txt', 'fine\n')!508 save := commit(a, 'add both')!509 mut backend := remote.new_folder(os.join_path(base, 'remote'))!510 offer_now(a, mut backend)!511 join(b, mut backend)!512 assert b.object_count()! > 0513514 515 516 mut secret_hash := ''517 for file in history.files_of(a, save.id)! {518 if file.path == 'secrets.env' {519 secret_hash = file.hash520 }521 }522 assert secret_hash != ''523 a.begin()!524 mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!525 stmt.bind_text(1, 'secrets.env')!526 stmt.step()!527 stmt.finalize()528 a.drop_unreferenced_objects()!529 events.strip(a, '', 'secrets.env')!530 a.commit()!531 report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!532 assert report.event_sent533 assert report.records == 1534535 536 537 record := decode_save(backend.get(save_key('proj', save.id))!.body)!538 for file in record.files {539 assert file.path != 'secrets.env'540 }541 542 543 assert !backend.get(object_key('proj', secret_hash))!.found544 assert backend.list('proj/objects')!.len == 2545546 547 b.begin()!548 fetched := fetch_back_to_known(b, mut backend, 'proj')!549 write_fetched(b, fetched)!550 b.commit()!551 mut paths := []string{}552 for file in history.files_of(b, save.id)! {553 paths << file.path554 }555 assert 'secrets.env' !in paths556 assert 'keep.txt' in paths557}