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 history.Save46 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 guide fn (preview StealPreview) !map[string]Side = unsafe { nil }62 yes bool63}6465pub struct StealReport {66pub mut:67 repo string68 forward bool69 fetched_saves int70 fetched_objects int71 fetched_events int72 clashes []string73 kept_mine int74 replayed int75 removed int76 head string77 flagged []events.Flag 78 79 80 previous_head string81 kept_versions []string82}8384pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'85868788pub struct ClashError {89 Error90pub:91 paths []string92}9394pub fn (e ClashError) msg() string {95 return '${e.paths.join(', ')} changed on both machines — run on a terminal to choose, or pass --mine or --theirs'96}9798pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'99100101102pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {103 name := repo_name(store, mut backend)!104 if snapshot.restore_pending(store) {105 return error(snapshot.restore_unfinished)106 }107 head_before := history.head(store)108 109 if dirty(store, head_before)! {110 if head_before == none {111 return error(no_saves_here)112 }113 return error(dirty_tree)114 }115 fetched := fetch_back_to_known(store, mut backend, name)!116 mut report := StealReport{117 repo: name118 fetched_saves: fetched.saves.len119 fetched_objects: fetched.repo_objects.len120 fetched_events: fetched.events_out.len121 head: fetched.head122 }123 mut local_only := []history.Save{}124 if head := head_before {125 126 127 128 local_only = saves_after(store, head.id, fetched.base) or {129 return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')130 }131 }132 if fetched.base == '' && local_only.len > 0 {133 return error('the remote keeps a different history — it shares no save with this repo')134 }135 if fetched.saves.len > 0 {136 store.begin()!137 write_fetched_records(store, fetched) or {138 store.rollback()139 return err140 }141 store.end()!142 }143144 strips_before := events.strip_events(store)!.len145 mut clashes := []Clash{}146 mut side_of := map[string]Side{}147 if fetched.saves.len > 0 && local_only.len > 0 {148 clashes = combine_clashes(store, fetched, local_only)!149 report.clashes = clashes.map(it.path)150 if opts.guide != unsafe { nil } {151 side_of = opts.guide(combine_preview(store, name, fetched, local_only, clashes,152 opts.yes, if opts.mine {153 'mine'154 } else if opts.theirs { 'theirs' } else { '' })!)!155 } else {156 157 158 side_of = choose_sides(clashes, opts)!159 }160 }161 for clash in clashes {162 if side_of[clash.path] == .theirs {163 report.kept_versions << '${clash.path}:${local_only.last().id}'164 }165 }166167 store.begin() or { return err }168 169 170 if moved := changed_since(store, head_before, strips_before) {171 if moved {172 store.rollback()173 return error('this repo changed while steal was asking — run koh steal again')174 }175 } else {176 store.rollback()177 return err178 }179 final := (write_and_combine(store, fetched, local_only, clashes, side_of, mut report) or {180 store.rollback()181 return err182 })183 files := history.files_of(store, final.id) or {184 store.rollback()185 return err186 }187 snapshot.check_links(store.root, files) or {188 store.rollback()189 return err190 }191 192 193 moved := if head := head_before { final.id != head.id } else { true }194 if moved {195 snapshot.mark_restore(store) or {196 store.rollback()197 return err198 }199 }200 store.end() or { return err }201 report.head = final.id202 if local_only.len > 0 && report.fetched_saves > 0 {203 report.previous_head = local_only.last().id204 }205 flags := events.latest_flags(store)!206 for record in fetched.saves {207 if flag := flags[record.id] {208 if flag.flagged {209 report.flagged << flag210 }211 }212 }213214 if head := head_before {215 if final.id == head.id {216 217 return report218 }219 mut wanted := map[string]bool{}220 for file in files {221 wanted[file.path] = true222 }223 mut remove := []string{}224 for file in history.files_of(store, head.id)! {225 if file.path !in wanted {226 remove << file.path227 }228 }229 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,230 remove)!231 report.removed = result.removed232 } else {233 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!234 }235 history.refresh_log_md(store)236 return report237}238239240241242fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, clashes []Clash, side_of map[string]Side, mut report StealReport) !history.Save {243 write_fetched(store, fetched)!244 if fetched.saves.len == 0 {245 246 247 return history.head(store) or { return error('this repo has no head') }248 }249 if local_only.len == 0 {250 251 history.set_head(store, fetched.head)!252 report.forward = true253 return history.head(store) or { return error('the remote head did not land') }254 }255 remote_files := history.files_of(store, fetched.head)!256 mut mine_files := []snapshot.FileEntry{}257 if our_head := history.head(store) {258 mine_files = history.files_of(store, our_head.id)!259 }260261 262 263 264 265 base_files := history.files_of(store, fetched.base)!266 settled := changed_paths(base_files, remote_files)267268 269 history.set_head(store, fetched.head)!270 mut current := remote_files.clone()271 for save in local_only {272 parent_files := if save.parent != '' {273 history.files_of(store, save.parent)!274 } else {275 []snapshot.FileEntry{}276 }277 target_files := history.files_of(store, save.id)!278 plan := apply.three_way(current, parent_files, target_files, [], settled)279 if apply.conflicts(plan) {280 return error('combine could not apply ${plan.conflict} — it is not a clash file')281 }282 if plan.applied.len == 0 {283 284 continue285 }286 history.record_save(store, '', plan.files, history.SaveInput{287 message: save.message288 created_at: save.created_at289 machine: save.machine290 agent: save.agent291 session: save.session292 is_checkpoint: save.is_checkpoint293 replayed_from: save.id294 })!295 current = plan.files.clone()296 report.replayed++297 }298299 mut mine_paths := []string{}300 for clash in clashes {301 if side_of[clash.path] == .mine {302 mine_paths << clash.path303 }304 }305 if mine_paths.len > 0 {306 307 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])308 if final_plan.applied.len > 0 {309 history.record_save(store, '', final_plan.files, history.SaveInput{310 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {311 'file'312 } else {313 'files'314 }}'315 })!316 report.kept_mine = mine_paths.len317 }318 }319 return history.head(store) or { return error('the combined line has no head') }320}321322323324pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {325 base_map := snapshot.index_files(base)326 mine_map := snapshot.index_files(mine)327 theirs_map := snapshot.index_files(theirs)328 mut paths := mine_map.keys()329 for path in theirs_map.keys() {330 if path !in mine_map {331 paths << path332 }333 }334 paths.sort()335 mut clashes := []Clash{}336 for path in paths {337 here := mine_map[path] or {338 snapshot.FileEntry{339 path: path340 }341 }342 there := theirs_map[path] or {343 snapshot.FileEntry{344 path: path345 }346 }347 was := base_map[path] or {348 snapshot.FileEntry{349 path: path350 }351 }352 if same(here, there) {353 continue354 }355 if same(here, was) || same(there, was) {356 continue357 }358 clashes << Clash{359 path: path360 mine: here361 theirs: there362 base: was363 }364 }365 return clashes366}367368369fn changed_paths(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {370 was := snapshot.index_files(before)371 now := snapshot.index_files(after)372 mut paths := []string{}373 for path, entry in now {374 if !same(entry, was[path] or { snapshot.FileEntry{} }) {375 paths << path376 }377 }378 for path, _ in was {379 if path !in now {380 paths << path381 }382 }383 return paths384}385386387388389fn combine_clashes(store &storage.Store, fetched Fetched, local_only []history.Save) ![]Clash {390 base_files := if fetched.base != '' {391 history.files_of(store, fetched.base)!392 } else {393 []snapshot.FileEntry{}394 }395 head := history.head(store) or { return []Clash{} }396 397 398 399 strips := known_strips(store, fetched)!400 remote_head := fetched.saves.last()401 mut clashes := find_clashes(after_strips(base_files, fetched.base, strips), after_strips(history.files_of(store,402 head.id)!, head.id, strips), after_strips(to_files(remote_head), remote_head.id, strips))403 for mut clash in clashes {404 base := fetched_bytes(store, fetched, clash.base)!405 mine := fetched_bytes(store, fetched, clash.mine)!406 theirs := fetched_bytes(store, fetched, clash.theirs)!407 mine_delta := snapshot.diff_bytes(clash.path, 'modified', base, mine)!408 theirs_delta := snapshot.diff_bytes(clash.path, 'modified', base, theirs)!409 clash.mine_stat = delta_stat(mine_delta, clash.mine.size)410 clash.theirs_stat = delta_stat(theirs_delta, clash.theirs.size)411 clash.mine_patch = mine_delta.patch412 clash.theirs_patch = theirs_delta.patch413 clash.mine_save = last_touch(store, local_only, clash.path)!414 clash.theirs_save = last_touch(store, fetched.saves.map(to_save(it)), clash.path)!415 }416 return clashes417}418419fn delta_stat(delta snapshot.Delta, size i64) string {420 if delta.binary {421 return fmt.human_bytes(size)422 }423 return '+${delta.added} -${delta.removed}'424}425426fn last_touch(store &storage.Store, saves []history.Save, path string) !history.Save {427 for index := saves.len - 1; index >= 0; index-- {428 save := saves[index]429 current := snapshot.index_files(history.files_of(store, save.id)!)[path] or {430 snapshot.FileEntry{}431 }432 parent := if save.parent != '' {433 snapshot.index_files(history.files_of(store, save.parent)!)[path] or {434 snapshot.FileEntry{}435 }436 } else {437 snapshot.FileEntry{}438 }439 if !same(current, parent) {440 return save441 }442 }443 return saves.last()444}445446fn combine_preview(store &storage.Store, name string, fetched Fetched, local []history.Save, clashes []Clash, yes bool, all_side string) !StealPreview {447 base := history.files_of(store, fetched.base)!448 mine := history.files_of(store, local.last().id)!449 theirs := history.files_of(store, fetched.head)!450 mine_changed := changed_paths(base, mine)451 theirs_changed := changed_paths(base, theirs)452 mut changed := map[string]bool{}453 for path in mine_changed {454 changed[path] = true455 }456 for path in theirs_changed {457 changed[path] = true458 }459 mut identical := 0460 base_map := snapshot.index_files(base)461 mine_map := snapshot.index_files(mine)462 theirs_map := snapshot.index_files(theirs)463 for path, _ in changed {464 if !same(mine_map[path] or { snapshot.FileEntry{} }, base_map[path] or { snapshot.FileEntry{} })465 && same(mine_map[path] or { snapshot.FileEntry{} }, theirs_map[path] or { snapshot.FileEntry{} }) {466 identical++467 }468 }469 return StealPreview{470 repo: name471 base: history.by_id(store, fetched.base) or { history.Save{} }472 local: local473 remote: fetched.saves474 changed: changed.len475 automatic: changed.len - identical - clashes.len476 identical: identical477 clashes: clashes478 yes: yes479 all_side: all_side480 }481}482483484485fn after_strips(files []snapshot.FileEntry, save_id string, strips []events.Event) []snapshot.FileEntry {486 return files.filter(fn [strips, save_id] (file snapshot.FileEntry) bool {487 return !strips.any(reaches(it, save_id) && covers(it.path, file.path))488 })489}490491492493fn fetched_bytes(store &storage.Store, fetched Fetched, entry snapshot.FileEntry) ![]u8 {494 if entry.hash == '' {495 return []u8{}496 }497 if blob := fetched.repo_objects[entry.hash] {498 return zlib.decompress(blob)!499 }500 return store.get_object(entry.hash)!501}502503fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {504 mut sides := map[string]Side{}505 if clashes.len == 0 {506 return sides507 }508 for clash in clashes {509 if opts.mine {510 sides[clash.path] = .mine511 continue512 }513 if opts.theirs {514 sides[clash.path] = .theirs515 continue516 }517 return ClashError{518 paths: clashes.map(it.path)519 }520 }521 return sides522}523524fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {525 if a.hash == '' && b.hash == '' {526 return true527 }528 return a.hash == b.hash && a.executable == b.executable529}530531fn changed_since(store &storage.Store, before ?history.Save, strips_before int) !bool {532 if events.strip_events(store)!.len != strips_before {533 return true534 }535 now := history.head(store)536 if now or { history.Save{} }.id != before or { history.Save{} }.id {537 return true538 }539 return dirty(store, now)!540}541542543544545pub fn dirty(store &storage.Store, head ?history.Save) !bool {546 files := snapshot.walk(store, store.root, snapshot.load(store.root))!547 if saved := head {548 return !snapshot.same_tree(history.files_of(store, saved.id)!, files)549 }550 seed := storage.hash_hex(snapshot.default_ignore.bytes())551 return files.any(it.path != snapshot.ignore_file || it.hash != seed)552}553554555556pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {557 check_format(mut backend, name)!558 remote_head := backend.get(head_key(name))!559 if !remote_head.found {560 return error('the remote has no repo called "${name}"')561 }562 save_ids := backend.list('${name}/saves')!563 object_keys := backend.list('${name}/objects')!564 event_keys := backend.list('${name}/events')!565 mut fetched := Fetched{566 head: remote_head.body.bytestr().trim_space()567 }568 for key in object_keys {569 blob := backend.get(key)!570 if blob.found {571 fetched.repo_objects[os.base(key)] = blob.body572 }573 }574 for key in save_ids {575 body := backend.get(key)!576 if !body.found {577 continue578 }579 record := decode_save(body.body)!580 fetched.saves << record581 }582 fetched.saves = parent_first(fetched.saves)!583 for key in event_keys {584 body := backend.get(key)!585 if body.found {586 fetched.events_out << decode_event(body.body)!587 }588 }589 return fetched590}591592593594595fn parent_first(records []SaveRecord) ![]SaveRecord {596 mut by_id := map[string]SaveRecord{}597 for record in records {598 by_id[record.id] = record599 }600 mut placed := map[string]bool{}601 mut ordered := []SaveRecord{}602 for record in records {603 mut chain := []SaveRecord{}604 mut cursor := record.id605 for cursor != '' && cursor !in placed {606 if chain.len > records.len {607 return error('the save records on the remote form a loop')608 }609 current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }610 chain << current611 cursor = current.parent612 }613 chain.reverse_in_place()614 for current in chain {615 placed[current.id] = true616 ordered << current617 }618 }619 return ordered620}