123456789module exchange1011import 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 mine_stat string34 theirs_stat string35 mine_patch string36 theirs_patch string37}3839pub struct StealPreview {40pub:41 repo string42 base string43 local []history.Save44 remote []SaveRecord45 changed int46 automatic int47 identical int48 clashes []Clash49 yes bool50}5152pub struct StealOptions {53pub mut:54 mine bool 55 theirs bool 56 57 ask fn (clash Clash) Side = unsafe { nil }58 guide fn (preview StealPreview) !map[string]Side = unsafe { nil }59 yes bool60}6162pub struct StealReport {63pub mut:64 repo string65 forward bool66 fetched_saves int67 fetched_objects int68 fetched_events int69 clashes []string70 kept_mine int71 replayed int72 removed int73 head string74 flagged []events.Flag 75 76 77 previous_head string78 kept_versions []string79}8081pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'82pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'83848586pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {87 name := repo_name(store, mut backend)!88 if snapshot.restore_pending(store) {89 return error(snapshot.restore_unfinished)90 }91 head_before := history.head(store)92 93 if dirty(store, head_before)! {94 if head_before == none {95 return error(no_saves_here)96 }97 return error(dirty_tree)98 }99 fetched := fetch_back_to_known(store, mut backend, name)!100 mut report := StealReport{101 repo: name102 fetched_saves: fetched.saves.len103 fetched_objects: fetched.repo_objects.len104 fetched_events: fetched.events_out.len105 head: fetched.head106 }107 mut local_only := []history.Save{}108 if head := head_before {109 110 111 112 local_only = saves_after(store, head.id, fetched.base) or {113 return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')114 }115 }116 if fetched.base == '' && local_only.len > 0 {117 return error('the remote keeps a different history — it shares no save with this repo')118 }119120 strips_before := events.strip_events(store)!.len121 mut clashes := []Clash{}122 mut side_of := map[string]Side{}123 if fetched.saves.len > 0 && local_only.len > 0 {124 clashes = combine_clashes(store, fetched)!125 report.clashes = clashes.map(it.path)126 if opts.guide != unsafe { nil } && !opts.mine && !opts.theirs {127 side_of = opts.guide(combine_preview(store, name, fetched, local_only, clashes,128 opts.yes)!)!129 } else {130 131 132 side_of = choose_sides(clashes, opts)!133 }134 }135 for clash in clashes {136 if side_of[clash.path] == .theirs {137 report.kept_versions << '${clash.path}:${local_only.last().id}'138 }139 }140141 store.begin() or { return err }142 143 144 if moved := changed_since(store, head_before, strips_before) {145 if moved {146 store.rollback()147 return error('this repo changed while steal was asking — run koh steal again')148 }149 } else {150 store.rollback()151 return err152 }153 final := (write_and_combine(store, fetched, local_only, clashes, side_of, mut report) or {154 store.rollback()155 return err156 })157 files := history.files_of(store, final.id) or {158 store.rollback()159 return err160 }161 snapshot.check_links(store.root, files) or {162 store.rollback()163 return err164 }165 166 167 moved := if head := head_before { final.id != head.id } else { true }168 if moved {169 snapshot.mark_restore(store) or {170 store.rollback()171 return err172 }173 }174 store.commit() or { return err }175 report.head = final.id176 if local_only.len > 0 && report.fetched_saves > 0 {177 report.previous_head = local_only.last().id178 }179 flags := events.latest_flags(store)!180 for record in fetched.saves {181 if flag := flags[record.id] {182 if flag.flagged {183 report.flagged << flag184 }185 }186 }187188 if head := head_before {189 if final.id == head.id {190 191 return report192 }193 mut wanted := map[string]bool{}194 for file in files {195 wanted[file.path] = true196 }197 mut remove := []string{}198 for file in history.files_of(store, head.id)! {199 if file.path !in wanted {200 remove << file.path201 }202 }203 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,204 remove)!205 report.removed = result.removed206 } else {207 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!208 }209 history.refresh_log_md(store)210 return report211}212213214215216fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, clashes []Clash, side_of map[string]Side, mut report StealReport) !history.Save {217 write_fetched(store, fetched)!218 if fetched.saves.len == 0 {219 220 221 return history.head(store) or { return error('this repo has no head') }222 }223 if local_only.len == 0 {224 225 history.set_head(store, fetched.head)!226 report.forward = true227 return history.head(store) or { return error('the remote head did not land') }228 }229 remote_files := history.files_of(store, fetched.head)!230 mut mine_files := []snapshot.FileEntry{}231 if our_head := history.head(store) {232 mine_files = history.files_of(store, our_head.id)!233 }234235 236 237 238 239 base_files := history.files_of(store, fetched.base)!240 settled := changed_paths(base_files, remote_files)241242 243 history.set_head(store, fetched.head)!244 mut current := remote_files.clone()245 for save in local_only {246 parent_files := if save.parent != '' {247 history.files_of(store, save.parent)!248 } else {249 []snapshot.FileEntry{}250 }251 target_files := history.files_of(store, save.id)!252 plan := apply.three_way(current, parent_files, target_files, [], settled)253 if apply.conflicts(plan) {254 return error('combine could not apply ${plan.conflict} — it is not a clash file')255 }256 if plan.applied.len == 0 {257 258 continue259 }260 history.record_save(store, '', plan.files, history.SaveInput{261 message: save.message262 created_at: save.created_at263 machine: save.machine264 agent: save.agent265 session: save.session266 is_checkpoint: save.is_checkpoint267 replayed_from: save.id268 })!269 current = plan.files.clone()270 report.replayed++271 }272273 mut mine_paths := []string{}274 for clash in clashes {275 if side_of[clash.path] == .mine {276 mine_paths << clash.path277 }278 }279 if mine_paths.len > 0 {280 281 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])282 if final_plan.applied.len > 0 {283 history.record_save(store, '', final_plan.files, history.SaveInput{284 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {285 'file'286 } else {287 'files'288 }}'289 })!290 report.kept_mine = mine_paths.len291 }292 }293 return history.head(store) or { return error('the combined line has no head') }294}295296297298pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {299 base_map := snapshot.index_files(base)300 mine_map := snapshot.index_files(mine)301 theirs_map := snapshot.index_files(theirs)302 mut paths := mine_map.keys()303 for path in theirs_map.keys() {304 if path !in mine_map {305 paths << path306 }307 }308 paths.sort()309 mut clashes := []Clash{}310 for path in paths {311 here := mine_map[path] or {312 snapshot.FileEntry{313 path: path314 }315 }316 there := theirs_map[path] or {317 snapshot.FileEntry{318 path: path319 }320 }321 was := base_map[path] or {322 snapshot.FileEntry{323 path: path324 }325 }326 if same(here, there) {327 continue328 }329 if same(here, was) || same(there, was) {330 continue331 }332 clashes << Clash{333 path: path334 mine: here335 theirs: there336 base: was337 }338 }339 return clashes340}341342343fn changed_paths(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {344 was := snapshot.index_files(before)345 now := snapshot.index_files(after)346 mut paths := []string{}347 for path, entry in now {348 if !same(entry, was[path] or { snapshot.FileEntry{} }) {349 paths << path350 }351 }352 for path, _ in was {353 if path !in now {354 paths << path355 }356 }357 return paths358}359360361362363fn combine_clashes(store &storage.Store, fetched Fetched) ![]Clash {364 base_files := if fetched.base != '' {365 history.files_of(store, fetched.base)!366 } else {367 []snapshot.FileEntry{}368 }369 head := history.head(store) or { return []Clash{} }370 371 372 373 strips := fetched.events_out.filter(it.kind == events.kind_strip)374 375 remote_head := fetched.saves.last()376 mut clashes := find_clashes(after_strips(base_files, fetched.base, 0, strips), after_strips(history.files_of(store,377 head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,378 remote_head.created_at, strips))379 for mut clash in clashes {380 base := fetched_bytes(store, fetched, clash.base)!381 mine := fetched_bytes(store, fetched, clash.mine)!382 theirs := fetched_bytes(store, fetched, clash.theirs)!383 mine_delta := snapshot.diff_bytes(clash.path, 'modified', base, mine)!384 theirs_delta := snapshot.diff_bytes(clash.path, 'modified', base, theirs)!385 clash.mine_stat = delta_stat(mine_delta)386 clash.theirs_stat = delta_stat(theirs_delta)387 clash.mine_patch = mine_delta.patch388 clash.theirs_patch = theirs_delta.patch389 }390 return clashes391}392393fn delta_stat(delta snapshot.Delta) string {394 if delta.binary {395 return 'binary'396 }397 return '+${delta.added} -${delta.removed}'398}399400fn combine_preview(store &storage.Store, name string, fetched Fetched, local []history.Save, clashes []Clash, yes bool) !StealPreview {401 base := history.files_of(store, fetched.base)!402 mine := history.files_of(store, local.last().id)!403 theirs := history.files_of(store, fetched.head)!404 mine_changed := changed_paths(base, mine)405 theirs_changed := changed_paths(base, theirs)406 mut changed := map[string]bool{}407 for path in mine_changed {408 changed[path] = true409 }410 for path in theirs_changed {411 changed[path] = true412 }413 mut identical := 0414 base_map := snapshot.index_files(base)415 mine_map := snapshot.index_files(mine)416 theirs_map := snapshot.index_files(theirs)417 for path, _ in changed {418 if !same(mine_map[path] or { snapshot.FileEntry{} }, base_map[path] or { snapshot.FileEntry{} })419 && same(mine_map[path] or { snapshot.FileEntry{} }, theirs_map[path] or { snapshot.FileEntry{} }) {420 identical++421 }422 }423 return StealPreview{424 repo: name425 base: fetched.base426 local: local427 remote: fetched.saves428 changed: changed.len429 automatic: changed.len - identical - clashes.len430 identical: identical431 clashes: clashes432 yes: yes433 }434}435436437438fn after_strips(files []snapshot.FileEntry, save_id string, created_at i64, strips []events.Event) []snapshot.FileEntry {439 return files.filter(fn [strips, save_id, created_at] (file snapshot.FileEntry) bool {440 return !strips.any(reaches(it, save_id, created_at) && covers(it.path, file.path))441 })442}443444445446fn fetched_bytes(store &storage.Store, fetched Fetched, entry snapshot.FileEntry) ![]u8 {447 if entry.hash == '' {448 return []u8{}449 }450 if blob := fetched.repo_objects[entry.hash] {451 return zlib.decompress(blob)!452 }453 return store.get_object(entry.hash)!454}455456fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {457 mut sides := map[string]Side{}458 if clashes.len == 0 {459 return sides460 }461 for clash in clashes {462 if opts.mine {463 sides[clash.path] = .mine464 continue465 }466 if opts.theirs {467 sides[clash.path] = .theirs468 continue469 }470 if opts.ask != unsafe { nil } {471 sides[clash.path] = opts.ask(clash)472 continue473 }474 paths := clashes.map(it.path).join(', ')475 return error('${paths} changed on both machines — run on a terminal to choose, or pass --mine or --theirs')476 }477 return sides478}479480fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {481 if a.hash == '' && b.hash == '' {482 return true483 }484 return a.hash == b.hash && a.executable == b.executable485}486487fn changed_since(store &storage.Store, before ?history.Save, strips_before int) !bool {488 if events.strip_events(store)!.len != strips_before {489 return true490 }491 now := history.head(store)492 if now or { history.Save{} }.id != before or { history.Save{} }.id {493 return true494 }495 return dirty(store, now)!496}497498499500501pub fn dirty(store &storage.Store, head ?history.Save) !bool {502 files := snapshot.walk(store, store.root, snapshot.load(store.root))!503 if saved := head {504 return !snapshot.same_tree(history.files_of(store, saved.id)!, files)505 }506 seed := storage.hash_hex(snapshot.default_ignore.bytes())507 return files.any(it.path != snapshot.ignore_file || it.hash != seed)508}509510511512pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {513 check_format(mut backend, name)!514 remote_head := backend.get(head_key(name))!515 if !remote_head.found {516 return error('the remote has no repo called "${name}"')517 }518 save_ids := backend.list('${name}/saves')!519 object_keys := backend.list('${name}/objects')!520 event_keys := backend.list('${name}/events')!521 mut fetched := Fetched{522 head: remote_head.body.bytestr().trim_space()523 }524 for key in object_keys {525 blob := backend.get(key)!526 if blob.found {527 fetched.repo_objects[os.base(key)] = blob.body528 }529 }530 for key in save_ids {531 body := backend.get(key)!532 if !body.found {533 continue534 }535 record := decode_save(body.body)!536 fetched.saves << record537 }538 fetched.saves = parent_first(fetched.saves)!539 for key in event_keys {540 body := backend.get(key)!541 if body.found {542 fetched.events_out << decode_event(body.body)!543 }544 }545 return fetched546}547548549550551fn parent_first(records []SaveRecord) ![]SaveRecord {552 mut by_id := map[string]SaveRecord{}553 for record in records {554 by_id[record.id] = record555 }556 mut placed := map[string]bool{}557 mut ordered := []SaveRecord{}558 for record in records {559 mut chain := []SaveRecord{}560 mut cursor := record.id561 for cursor != '' && cursor !in placed {562 if chain.len > records.len {563 return error('the save records on the remote form a loop')564 }565 current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }566 chain << current567 cursor = current.parent568 }569 chain.reverse_in_place()570 for current in chain {571 placed[current.id] = true572 ordered << current573 }574 }575 return ordered576}