123456789module sync1011import compress.zlib12import os13import src.apply14import src.events15import src.history16import src.remote17import src.snapshot18import src.storage1920pub enum Side {21 mine22 theirs23}24252627pub struct Clash {28pub mut:29 path string30 mine snapshot.FileEntry31 theirs snapshot.FileEntry32 base snapshot.FileEntry33 stat string34}3536pub struct StealOptions {37pub mut:38 mine bool 39 theirs bool 40 41 ask fn (clash Clash) Side = unsafe { nil }42}4344pub struct StealReport {45pub mut:46 repo string47 forward bool48 fetched_saves int49 fetched_objects int50 fetched_events int51 clashes []string52 kept_mine int53 replayed int54 removed int55 head string56 flagged []events.Flag 57}5859pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'60pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'61626364pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {65 name := repo_name(store, mut backend)!66 head_before := history.head(store)67 68 if dirty(store, head_before)! {69 if head_before == none {70 return error(no_saves_here)71 }72 return error(dirty_tree)73 }74 fetched := fetch_back_to_known(store, mut backend, name)!75 mut report := StealReport{76 repo: name77 fetched_saves: fetched.saves.len78 fetched_objects: fetched.repo_objects.len79 fetched_events: fetched.events_out.len80 head: fetched.head81 }82 mut local_only := []history.Save{}83 if head := head_before {84 85 86 87 local_only = saves_after(store, head.id, fetched.base) or {88 return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')89 }90 }91 if fetched.base == '' && local_only.len > 0 {92 return error('the remote keeps a different history — it shares no save with this repo')93 }9495 strips_before := events.strip_events(store)!.len96 mut clashes := []Clash{}97 mut side_of := map[string]Side{}98 if fetched.saves.len > 0 && local_only.len > 0 {99 clashes = combine_clashes(store, fetched)!100 report.clashes = clashes.map(it.path)101 102 103 side_of = choose_sides(clashes, opts)!104 }105106 store.begin() or { return err }107 108 109 if moved := changed_since(store, head_before, strips_before) {110 if moved {111 store.rollback()112 return error('this repo changed while steal was asking — run koh steal again')113 }114 } else {115 store.rollback()116 return err117 }118 final := (write_and_combine(store, fetched, local_only, clashes, side_of, mut report) or {119 store.rollback()120 return err121 })122 files := history.files_of(store, final.id) or {123 store.rollback()124 return err125 }126 snapshot.check_links(store.root, files) or {127 store.rollback()128 return err129 }130 store.commit() or { return err }131 report.head = final.id132 flags := events.latest_flags(store)!133 for record in fetched.saves {134 if flag := flags[record.id] {135 if flag.flagged {136 report.flagged << flag137 }138 }139 }140141 if head := head_before {142 if final.id == head.id {143 144 return report145 }146 mut wanted := map[string]bool{}147 for file in files {148 wanted[file.path] = true149 }150 mut remove := []string{}151 for file in history.files_of(store, head.id)! {152 if file.path !in wanted {153 remove << file.path154 }155 }156 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,157 remove)!158 report.removed = result.removed159 } else {160 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!161 }162 history.refresh_log_md(store)163 return report164}165166167168169fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, clashes []Clash, side_of map[string]Side, mut report StealReport) !history.Save {170 write_fetched(store, fetched)!171 if fetched.saves.len == 0 {172 173 174 return history.head(store) or { return error('this repo has no head') }175 }176 if local_only.len == 0 {177 178 history.set_head(store, fetched.head)!179 report.forward = true180 return history.head(store) or { return error('the remote head did not land') }181 }182 remote_files := history.files_of(store, fetched.head)!183 mut mine_files := []snapshot.FileEntry{}184 if our_head := history.head(store) {185 mine_files = history.files_of(store, our_head.id)!186 }187188 189 190 191 192 base_files := history.files_of(store, fetched.base)!193 settled := changed_paths(base_files, remote_files)194195 196 history.set_head(store, fetched.head)!197 mut current := remote_files.clone()198 for save in local_only {199 parent_files := if save.parent != '' {200 history.files_of(store, save.parent)!201 } else {202 []snapshot.FileEntry{}203 }204 target_files := history.files_of(store, save.id)!205 plan := apply.three_way(current, parent_files, target_files, [], settled)206 if apply.conflicts(plan) {207 return error('combine could not apply ${plan.conflict} — it is not a clash file')208 }209 if plan.applied.len == 0 {210 211 continue212 }213 history.record_save(store, '', plan.files, history.SaveInput{214 message: save.message215 created_at: save.created_at216 machine: save.machine217 agent: save.agent218 session: save.session219 is_checkpoint: save.is_checkpoint220 replayed_from: save.id221 })!222 current = plan.files.clone()223 report.replayed++224 }225226 mut mine_paths := []string{}227 for clash in clashes {228 if side_of[clash.path] == .mine {229 mine_paths << clash.path230 }231 }232 if mine_paths.len > 0 {233 234 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])235 if final_plan.applied.len > 0 {236 history.record_save(store, '', final_plan.files, history.SaveInput{237 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {238 'file'239 } else {240 'files'241 }}'242 })!243 report.kept_mine = mine_paths.len244 }245 }246 return history.head(store) or { return error('the combined line has no head') }247}248249250251pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {252 base_map := snapshot.index_files(base)253 mine_map := snapshot.index_files(mine)254 theirs_map := snapshot.index_files(theirs)255 mut paths := mine_map.keys()256 for path in theirs_map.keys() {257 if path !in mine_map {258 paths << path259 }260 }261 paths.sort()262 mut clashes := []Clash{}263 for path in paths {264 here := mine_map[path] or {265 snapshot.FileEntry{266 path: path267 }268 }269 there := theirs_map[path] or {270 snapshot.FileEntry{271 path: path272 }273 }274 was := base_map[path] or {275 snapshot.FileEntry{276 path: path277 }278 }279 if same(here, there) {280 continue281 }282 if same(here, was) || same(there, was) {283 continue284 }285 clashes << Clash{286 path: path287 mine: here288 theirs: there289 base: was290 }291 }292 return clashes293}294295296fn changed_paths(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {297 was := snapshot.index_files(before)298 now := snapshot.index_files(after)299 mut paths := []string{}300 for path, entry in now {301 if !same(entry, was[path] or { snapshot.FileEntry{} }) {302 paths << path303 }304 }305 for path, _ in was {306 if path !in now {307 paths << path308 }309 }310 return paths311}312313314315316fn combine_clashes(store &storage.Store, fetched Fetched) ![]Clash {317 base_files := if fetched.base != '' {318 history.files_of(store, fetched.base)!319 } else {320 []snapshot.FileEntry{}321 }322 head := history.head(store) or { return []Clash{} }323 324 325 326 strips := fetched.events_out.filter(it.kind == events.kind_strip)327 328 remote_head := fetched.saves.last()329 mut clashes := find_clashes(after_strips(base_files, fetched.base, 0, strips), after_strips(history.files_of(store,330 head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,331 remote_head.created_at, strips))332 for mut clash in clashes {333 mine := fetched_bytes(store, fetched, clash.mine)!334 theirs := fetched_bytes(store, fetched, clash.theirs)!335 delta := snapshot.diff_bytes(clash.path, 'modified', mine, theirs)!336 clash.stat = if delta.binary { 'binary' } else { '+${delta.added} -${delta.removed}' }337 }338 return clashes339}340341342343fn after_strips(files []snapshot.FileEntry, save_id string, created_at i64, strips []events.Event) []snapshot.FileEntry {344 return files.filter(fn [strips, save_id, created_at] (file snapshot.FileEntry) bool {345 return !strips.any(reaches(it, save_id, created_at) && covers(it.path, file.path))346 })347}348349350351fn fetched_bytes(store &storage.Store, fetched Fetched, entry snapshot.FileEntry) ![]u8 {352 if entry.hash == '' {353 return []u8{}354 }355 if blob := fetched.repo_objects[entry.hash] {356 return zlib.decompress(blob)!357 }358 return store.get_object(entry.hash)!359}360361fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {362 mut sides := map[string]Side{}363 if clashes.len == 0 {364 return sides365 }366 for clash in clashes {367 if opts.mine {368 sides[clash.path] = .mine369 continue370 }371 if opts.theirs {372 sides[clash.path] = .theirs373 continue374 }375 if opts.ask != unsafe { nil } {376 sides[clash.path] = opts.ask(clash)377 continue378 }379 return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')380 }381 return sides382}383384fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {385 if a.hash == '' && b.hash == '' {386 return true387 }388 return a.hash == b.hash && a.executable == b.executable389}390391fn changed_since(store &storage.Store, before ?history.Save, strips_before int) !bool {392 if events.strip_events(store)!.len != strips_before {393 return true394 }395 now := history.head(store)396 if now or { history.Save{} }.id != before or { history.Save{} }.id {397 return true398 }399 return dirty(store, now)!400}401402403404405fn dirty(store &storage.Store, head ?history.Save) !bool {406 files := snapshot.walk(store, store.root, snapshot.load(store.root))!407 if saved := head {408 return !snapshot.same_tree(history.files_of(store, saved.id)!, files)409 }410 seed := storage.hash_hex(snapshot.default_ignore.bytes())411 return files.any(it.path != snapshot.ignore_file || it.hash != seed)412}413414415416pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {417 remote_head := backend.get(head_key(name))!418 if !remote_head.found {419 return error('the remote has no repo called "${name}"')420 }421 save_ids := backend.list('${name}/saves')!422 object_keys := backend.list('${name}/objects')!423 event_keys := backend.list('${name}/events')!424 mut fetched := Fetched{425 head: remote_head.body.bytestr().trim_space()426 }427 for key in object_keys {428 blob := backend.get(key)!429 if blob.found {430 fetched.repo_objects[os.base(key)] = blob.body431 }432 }433 for key in save_ids {434 body := backend.get(key)!435 if !body.found {436 continue437 }438 record := decode_save(body.body)!439 fetched.saves << record440 }441 fetched.saves = parent_first(fetched.saves)!442 for key in event_keys {443 body := backend.get(key)!444 if body.found {445 fetched.events_out << decode_event(body.body)!446 }447 }448 return fetched449}450451452453454fn parent_first(records []SaveRecord) ![]SaveRecord {455 mut by_id := map[string]SaveRecord{}456 for record in records {457 by_id[record.id] = record458 }459 mut placed := map[string]bool{}460 mut ordered := []SaveRecord{}461 for record in records {462 mut chain := []SaveRecord{}463 mut cursor := record.id464 for cursor != '' && cursor !in placed {465 if chain.len > records.len {466 return error('the save records on the remote form a loop')467 }468 current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }469 chain << current470 cursor = current.parent471 }472 chain.reverse_in_place()473 for current in chain {474 placed[current.id] = true475 ordered << current476 }477 }478 return ordered479}