123456789module exchange1011import compress.zlib12import os13import src.apply14import src.events15import src.fmt16import src.history17import src.remote18import src.snapshot19import src.storage2021pub enum Side {22 mine23 theirs24}25262728pub struct Clash {29pub mut:30 path string31 mine snapshot.FileEntry32 theirs snapshot.FileEntry33 base snapshot.FileEntry34 mine_stat string35 theirs_stat string36 mine_patch string37 theirs_patch string38 mine_save history.Save39 theirs_save history.Save40}4142pub struct StealPreview {43pub:44 repo string45 base string46 local []history.Save47 remote []SaveRecord48 changed int49 automatic int50 identical int51 clashes []Clash52 yes bool53 all_side string54}5556pub struct StealOptions {57pub mut:58 mine bool 59 theirs bool 60 61 ask fn (clash Clash) Side = unsafe { nil }62 guide fn (preview StealPreview) !map[string]Side = unsafe { nil }63 yes bool64}6566pub struct StealReport {67pub mut:68 repo string69 forward bool70 fetched_saves int71 fetched_objects int72 fetched_events int73 clashes []string74 kept_mine int75 replayed int76 removed int77 head string78 flagged []events.Flag 79 80 81 previous_head string82 kept_versions []string83}8485pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'86pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'87888990pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {91 name := repo_name(store, mut backend)!92 if snapshot.restore_pending(store) {93 return error(snapshot.restore_unfinished)94 }95 head_before := history.head(store)96 97 if dirty(store, head_before)! {98 if head_before == none {99 return error(no_saves_here)100 }101 return error(dirty_tree)102 }103 fetched := fetch_back_to_known(store, mut backend, name)!104 mut report := StealReport{105 repo: name106 fetched_saves: fetched.saves.len107 fetched_objects: fetched.repo_objects.len108 fetched_events: fetched.events_out.len109 head: fetched.head110 }111 mut local_only := []history.Save{}112 if head := head_before {113 114 115 116 local_only = saves_after(store, head.id, fetched.base) or {117 return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')118 }119 }120 if fetched.base == '' && local_only.len > 0 {121 return error('the remote keeps a different history — it shares no save with this repo')122 }123 if fetched.saves.len > 0 {124 store.begin()!125 write_fetched_records(store, fetched) or {126 store.rollback()127 return err128 }129 store.commit()!130 }131132 strips_before := events.strip_events(store)!.len133 mut clashes := []Clash{}134 mut side_of := map[string]Side{}135 if fetched.saves.len > 0 && local_only.len > 0 {136 clashes = combine_clashes(store, fetched, local_only)!137 report.clashes = clashes.map(it.path)138 if opts.guide != unsafe { nil } {139 side_of = opts.guide(combine_preview(store, name, fetched, local_only, clashes,140 opts.yes, if opts.mine {141 'mine'142 } else if opts.theirs { 'theirs' } else { '' })!)!143 } else {144 145 146 side_of = choose_sides(clashes, opts)!147 }148 }149 for clash in clashes {150 if side_of[clash.path] == .theirs {151 report.kept_versions << '${clash.path}:${local_only.last().id}'152 }153 }154155 store.begin() or { return err }156 157 158 if moved := changed_since(store, head_before, strips_before) {159 if moved {160 store.rollback()161 return error('this repo changed while steal was asking — run koh steal again')162 }163 } else {164 store.rollback()165 return err166 }167 final := (write_and_combine(store, fetched, local_only, clashes, side_of, mut report) or {168 store.rollback()169 return err170 })171 files := history.files_of(store, final.id) or {172 store.rollback()173 return err174 }175 snapshot.check_links(store.root, files) or {176 store.rollback()177 return err178 }179 180 181 moved := if head := head_before { final.id != head.id } else { true }182 if moved {183 snapshot.mark_restore(store) or {184 store.rollback()185 return err186 }187 }188 store.commit() or { return err }189 report.head = final.id190 if local_only.len > 0 && report.fetched_saves > 0 {191 report.previous_head = local_only.last().id192 }193 flags := events.latest_flags(store)!194 for record in fetched.saves {195 if flag := flags[record.id] {196 if flag.flagged {197 report.flagged << flag198 }199 }200 }201202 if head := head_before {203 if final.id == head.id {204 205 return report206 }207 mut wanted := map[string]bool{}208 for file in files {209 wanted[file.path] = true210 }211 mut remove := []string{}212 for file in history.files_of(store, head.id)! {213 if file.path !in wanted {214 remove << file.path215 }216 }217 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,218 remove)!219 report.removed = result.removed220 } else {221 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!222 }223 history.refresh_log_md(store)224 return report225}226227228229230fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, clashes []Clash, side_of map[string]Side, mut report StealReport) !history.Save {231 write_fetched(store, fetched)!232 if fetched.saves.len == 0 {233 234 235 return history.head(store) or { return error('this repo has no head') }236 }237 if local_only.len == 0 {238 239 history.set_head(store, fetched.head)!240 report.forward = true241 return history.head(store) or { return error('the remote head did not land') }242 }243 remote_files := history.files_of(store, fetched.head)!244 mut mine_files := []snapshot.FileEntry{}245 if our_head := history.head(store) {246 mine_files = history.files_of(store, our_head.id)!247 }248249 250 251 252 253 base_files := history.files_of(store, fetched.base)!254 settled := changed_paths(base_files, remote_files)255256 257 history.set_head(store, fetched.head)!258 mut current := remote_files.clone()259 for save in local_only {260 parent_files := if save.parent != '' {261 history.files_of(store, save.parent)!262 } else {263 []snapshot.FileEntry{}264 }265 target_files := history.files_of(store, save.id)!266 plan := apply.three_way(current, parent_files, target_files, [], settled)267 if apply.conflicts(plan) {268 return error('combine could not apply ${plan.conflict} — it is not a clash file')269 }270 if plan.applied.len == 0 {271 272 continue273 }274 history.record_save(store, '', plan.files, history.SaveInput{275 message: save.message276 created_at: save.created_at277 machine: save.machine278 agent: save.agent279 session: save.session280 is_checkpoint: save.is_checkpoint281 replayed_from: save.id282 })!283 current = plan.files.clone()284 report.replayed++285 }286287 mut mine_paths := []string{}288 for clash in clashes {289 if side_of[clash.path] == .mine {290 mine_paths << clash.path291 }292 }293 if mine_paths.len > 0 {294 295 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])296 if final_plan.applied.len > 0 {297 history.record_save(store, '', final_plan.files, history.SaveInput{298 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {299 'file'300 } else {301 'files'302 }}'303 })!304 report.kept_mine = mine_paths.len305 }306 }307 return history.head(store) or { return error('the combined line has no head') }308}309310311312pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {313 base_map := snapshot.index_files(base)314 mine_map := snapshot.index_files(mine)315 theirs_map := snapshot.index_files(theirs)316 mut paths := mine_map.keys()317 for path in theirs_map.keys() {318 if path !in mine_map {319 paths << path320 }321 }322 paths.sort()323 mut clashes := []Clash{}324 for path in paths {325 here := mine_map[path] or {326 snapshot.FileEntry{327 path: path328 }329 }330 there := theirs_map[path] or {331 snapshot.FileEntry{332 path: path333 }334 }335 was := base_map[path] or {336 snapshot.FileEntry{337 path: path338 }339 }340 if same(here, there) {341 continue342 }343 if same(here, was) || same(there, was) {344 continue345 }346 clashes << Clash{347 path: path348 mine: here349 theirs: there350 base: was351 }352 }353 return clashes354}355356357fn changed_paths(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {358 was := snapshot.index_files(before)359 now := snapshot.index_files(after)360 mut paths := []string{}361 for path, entry in now {362 if !same(entry, was[path] or { snapshot.FileEntry{} }) {363 paths << path364 }365 }366 for path, _ in was {367 if path !in now {368 paths << path369 }370 }371 return paths372}373374375376377fn combine_clashes(store &storage.Store, fetched Fetched, local_only []history.Save) ![]Clash {378 base_files := if fetched.base != '' {379 history.files_of(store, fetched.base)!380 } else {381 []snapshot.FileEntry{}382 }383 head := history.head(store) or { return []Clash{} }384 385 386 387 strips := fetched.events_out.filter(it.kind == events.kind_strip)388 389 remote_head := fetched.saves.last()390 mut clashes := find_clashes(after_strips(base_files, fetched.base, 0, strips), after_strips(history.files_of(store,391 head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,392 remote_head.created_at, strips))393 for mut clash in clashes {394 base := fetched_bytes(store, fetched, clash.base)!395 mine := fetched_bytes(store, fetched, clash.mine)!396 theirs := fetched_bytes(store, fetched, clash.theirs)!397 mine_delta := snapshot.diff_bytes(clash.path, 'modified', base, mine)!398 theirs_delta := snapshot.diff_bytes(clash.path, 'modified', base, theirs)!399 clash.mine_stat = delta_stat(mine_delta, clash.mine.size)400 clash.theirs_stat = delta_stat(theirs_delta, clash.theirs.size)401 clash.mine_patch = mine_delta.patch402 clash.theirs_patch = theirs_delta.patch403 clash.mine_save = last_touch(store, local_only, clash.path)!404 clash.theirs_save = last_touch(store, fetched.saves.map(to_save(it)), clash.path)!405 }406 return clashes407}408409fn delta_stat(delta snapshot.Delta, size i64) string {410 if delta.binary {411 return fmt.human_bytes(size)412 }413 return '+${delta.added} -${delta.removed}'414}415416fn last_touch(store &storage.Store, saves []history.Save, path string) !history.Save {417 for index := saves.len - 1; index >= 0; index-- {418 save := saves[index]419 current := snapshot.index_files(history.files_of(store, save.id)!)[path] or {420 snapshot.FileEntry{}421 }422 parent := if save.parent != '' {423 snapshot.index_files(history.files_of(store, save.parent)!)[path] or {424 snapshot.FileEntry{}425 }426 } else {427 snapshot.FileEntry{}428 }429 if !same(current, parent) {430 return save431 }432 }433 return saves.last()434}435436fn combine_preview(store &storage.Store, name string, fetched Fetched, local []history.Save, clashes []Clash, yes bool, all_side string) !StealPreview {437 base := history.files_of(store, fetched.base)!438 mine := history.files_of(store, local.last().id)!439 theirs := history.files_of(store, fetched.head)!440 mine_changed := changed_paths(base, mine)441 theirs_changed := changed_paths(base, theirs)442 mut changed := map[string]bool{}443 for path in mine_changed {444 changed[path] = true445 }446 for path in theirs_changed {447 changed[path] = true448 }449 mut identical := 0450 base_map := snapshot.index_files(base)451 mine_map := snapshot.index_files(mine)452 theirs_map := snapshot.index_files(theirs)453 for path, _ in changed {454 if !same(mine_map[path] or { snapshot.FileEntry{} }, base_map[path] or { snapshot.FileEntry{} })455 && same(mine_map[path] or { snapshot.FileEntry{} }, theirs_map[path] or { snapshot.FileEntry{} }) {456 identical++457 }458 }459 return StealPreview{460 repo: name461 base: fetched.base462 local: local463 remote: fetched.saves464 changed: changed.len465 automatic: changed.len - identical - clashes.len466 identical: identical467 clashes: clashes468 yes: yes469 all_side: all_side470 }471}472473474475fn after_strips(files []snapshot.FileEntry, save_id string, created_at i64, strips []events.Event) []snapshot.FileEntry {476 return files.filter(fn [strips, save_id, created_at] (file snapshot.FileEntry) bool {477 return !strips.any(reaches(it, save_id, created_at) && covers(it.path, file.path))478 })479}480481482483fn fetched_bytes(store &storage.Store, fetched Fetched, entry snapshot.FileEntry) ![]u8 {484 if entry.hash == '' {485 return []u8{}486 }487 if blob := fetched.repo_objects[entry.hash] {488 return zlib.decompress(blob)!489 }490 return store.get_object(entry.hash)!491}492493fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {494 mut sides := map[string]Side{}495 if clashes.len == 0 {496 return sides497 }498 for clash in clashes {499 if opts.mine {500 sides[clash.path] = .mine501 continue502 }503 if opts.theirs {504 sides[clash.path] = .theirs505 continue506 }507 if opts.ask != unsafe { nil } {508 sides[clash.path] = opts.ask(clash)509 continue510 }511 paths := clashes.map(it.path).join(', ')512 return error('${paths} changed on both machines — run on a terminal to choose, or pass --mine or --theirs')513 }514 return sides515}516517fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {518 if a.hash == '' && b.hash == '' {519 return true520 }521 return a.hash == b.hash && a.executable == b.executable522}523524fn changed_since(store &storage.Store, before ?history.Save, strips_before int) !bool {525 if events.strip_events(store)!.len != strips_before {526 return true527 }528 now := history.head(store)529 if now or { history.Save{} }.id != before or { history.Save{} }.id {530 return true531 }532 return dirty(store, now)!533}534535536537538pub fn dirty(store &storage.Store, head ?history.Save) !bool {539 files := snapshot.walk(store, store.root, snapshot.load(store.root))!540 if saved := head {541 return !snapshot.same_tree(history.files_of(store, saved.id)!, files)542 }543 seed := storage.hash_hex(snapshot.default_ignore.bytes())544 return files.any(it.path != snapshot.ignore_file || it.hash != seed)545}546547548549pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {550 check_format(mut backend, name)!551 remote_head := backend.get(head_key(name))!552 if !remote_head.found {553 return error('the remote has no repo called "${name}"')554 }555 save_ids := backend.list('${name}/saves')!556 object_keys := backend.list('${name}/objects')!557 event_keys := backend.list('${name}/events')!558 mut fetched := Fetched{559 head: remote_head.body.bytestr().trim_space()560 }561 for key in object_keys {562 blob := backend.get(key)!563 if blob.found {564 fetched.repo_objects[os.base(key)] = blob.body565 }566 }567 for key in save_ids {568 body := backend.get(key)!569 if !body.found {570 continue571 }572 record := decode_save(body.body)!573 fetched.saves << record574 }575 fetched.saves = parent_first(fetched.saves)!576 for key in event_keys {577 body := backend.get(key)!578 if body.found {579 fetched.events_out << decode_event(body.body)!580 }581 }582 return fetched583}584585586587588fn parent_first(records []SaveRecord) ![]SaveRecord {589 mut by_id := map[string]SaveRecord{}590 for record in records {591 by_id[record.id] = record592 }593 mut placed := map[string]bool{}594 mut ordered := []SaveRecord{}595 for record in records {596 mut chain := []SaveRecord{}597 mut cursor := record.id598 for cursor != '' && cursor !in placed {599 if chain.len > records.len {600 return error('the save records on the remote form a loop')601 }602 current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }603 chain << current604 cursor = current.parent605 }606 chain.reverse_in_place()607 for current in chain {608 placed[current.id] = true609 ordered << current610 }611 }612 return ordered613}