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 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 58 59 previous_head string60}6162pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'63pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'64656667pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {68 name := repo_name(store, mut backend)!69 if snapshot.restore_pending(store) {70 return error(snapshot.restore_unfinished)71 }72 head_before := history.head(store)73 74 if dirty(store, head_before)! {75 if head_before == none {76 return error(no_saves_here)77 }78 return error(dirty_tree)79 }80 fetched := fetch_back_to_known(store, mut backend, name)!81 mut report := StealReport{82 repo: name83 fetched_saves: fetched.saves.len84 fetched_objects: fetched.repo_objects.len85 fetched_events: fetched.events_out.len86 head: fetched.head87 }88 mut local_only := []history.Save{}89 if head := head_before {90 91 92 93 local_only = saves_after(store, head.id, fetched.base) or {94 return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')95 }96 }97 if fetched.base == '' && local_only.len > 0 {98 return error('the remote keeps a different history — it shares no save with this repo')99 }100101 strips_before := events.strip_events(store)!.len102 mut clashes := []Clash{}103 mut side_of := map[string]Side{}104 if fetched.saves.len > 0 && local_only.len > 0 {105 clashes = combine_clashes(store, fetched)!106 report.clashes = clashes.map(it.path)107 108 109 side_of = choose_sides(clashes, opts)!110 }111112 store.begin() or { return err }113 114 115 if moved := changed_since(store, head_before, strips_before) {116 if moved {117 store.rollback()118 return error('this repo changed while steal was asking — run koh steal again')119 }120 } else {121 store.rollback()122 return err123 }124 final := (write_and_combine(store, fetched, local_only, clashes, side_of, mut report) or {125 store.rollback()126 return err127 })128 files := history.files_of(store, final.id) or {129 store.rollback()130 return err131 }132 snapshot.check_links(store.root, files) or {133 store.rollback()134 return err135 }136 137 138 moved := if head := head_before { final.id != head.id } else { true }139 if moved {140 snapshot.mark_restore(store) or {141 store.rollback()142 return err143 }144 }145 store.commit() or { return err }146 report.head = final.id147 if local_only.len > 0 && report.fetched_saves > 0 {148 report.previous_head = local_only.last().id149 }150 flags := events.latest_flags(store)!151 for record in fetched.saves {152 if flag := flags[record.id] {153 if flag.flagged {154 report.flagged << flag155 }156 }157 }158159 if head := head_before {160 if final.id == head.id {161 162 return report163 }164 mut wanted := map[string]bool{}165 for file in files {166 wanted[file.path] = true167 }168 mut remove := []string{}169 for file in history.files_of(store, head.id)! {170 if file.path !in wanted {171 remove << file.path172 }173 }174 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,175 remove)!176 report.removed = result.removed177 } else {178 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!179 }180 history.refresh_log_md(store)181 return report182}183184185186187fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, clashes []Clash, side_of map[string]Side, mut report StealReport) !history.Save {188 write_fetched(store, fetched)!189 if fetched.saves.len == 0 {190 191 192 return history.head(store) or { return error('this repo has no head') }193 }194 if local_only.len == 0 {195 196 history.set_head(store, fetched.head)!197 report.forward = true198 return history.head(store) or { return error('the remote head did not land') }199 }200 remote_files := history.files_of(store, fetched.head)!201 mut mine_files := []snapshot.FileEntry{}202 if our_head := history.head(store) {203 mine_files = history.files_of(store, our_head.id)!204 }205206 207 208 209 210 base_files := history.files_of(store, fetched.base)!211 settled := changed_paths(base_files, remote_files)212213 214 history.set_head(store, fetched.head)!215 mut current := remote_files.clone()216 for save in local_only {217 parent_files := if save.parent != '' {218 history.files_of(store, save.parent)!219 } else {220 []snapshot.FileEntry{}221 }222 target_files := history.files_of(store, save.id)!223 plan := apply.three_way(current, parent_files, target_files, [], settled)224 if apply.conflicts(plan) {225 return error('combine could not apply ${plan.conflict} — it is not a clash file')226 }227 if plan.applied.len == 0 {228 229 continue230 }231 history.record_save(store, '', plan.files, history.SaveInput{232 message: save.message233 created_at: save.created_at234 machine: save.machine235 agent: save.agent236 session: save.session237 is_checkpoint: save.is_checkpoint238 replayed_from: save.id239 })!240 current = plan.files.clone()241 report.replayed++242 }243244 mut mine_paths := []string{}245 for clash in clashes {246 if side_of[clash.path] == .mine {247 mine_paths << clash.path248 }249 }250 if mine_paths.len > 0 {251 252 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])253 if final_plan.applied.len > 0 {254 history.record_save(store, '', final_plan.files, history.SaveInput{255 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {256 'file'257 } else {258 'files'259 }}'260 })!261 report.kept_mine = mine_paths.len262 }263 }264 return history.head(store) or { return error('the combined line has no head') }265}266267268269pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {270 base_map := snapshot.index_files(base)271 mine_map := snapshot.index_files(mine)272 theirs_map := snapshot.index_files(theirs)273 mut paths := mine_map.keys()274 for path in theirs_map.keys() {275 if path !in mine_map {276 paths << path277 }278 }279 paths.sort()280 mut clashes := []Clash{}281 for path in paths {282 here := mine_map[path] or {283 snapshot.FileEntry{284 path: path285 }286 }287 there := theirs_map[path] or {288 snapshot.FileEntry{289 path: path290 }291 }292 was := base_map[path] or {293 snapshot.FileEntry{294 path: path295 }296 }297 if same(here, there) {298 continue299 }300 if same(here, was) || same(there, was) {301 continue302 }303 clashes << Clash{304 path: path305 mine: here306 theirs: there307 base: was308 }309 }310 return clashes311}312313314fn changed_paths(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {315 was := snapshot.index_files(before)316 now := snapshot.index_files(after)317 mut paths := []string{}318 for path, entry in now {319 if !same(entry, was[path] or { snapshot.FileEntry{} }) {320 paths << path321 }322 }323 for path, _ in was {324 if path !in now {325 paths << path326 }327 }328 return paths329}330331332333334fn combine_clashes(store &storage.Store, fetched Fetched) ![]Clash {335 base_files := if fetched.base != '' {336 history.files_of(store, fetched.base)!337 } else {338 []snapshot.FileEntry{}339 }340 head := history.head(store) or { return []Clash{} }341 342 343 344 strips := fetched.events_out.filter(it.kind == events.kind_strip)345 346 remote_head := fetched.saves.last()347 mut clashes := find_clashes(after_strips(base_files, fetched.base, 0, strips), after_strips(history.files_of(store,348 head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,349 remote_head.created_at, strips))350 for mut clash in clashes {351 mine := fetched_bytes(store, fetched, clash.mine)!352 theirs := fetched_bytes(store, fetched, clash.theirs)!353 delta := snapshot.diff_bytes(clash.path, 'modified', mine, theirs)!354 clash.stat = if delta.binary { 'binary' } else { '+${delta.added} -${delta.removed}' }355 }356 return clashes357}358359360361fn after_strips(files []snapshot.FileEntry, save_id string, created_at i64, strips []events.Event) []snapshot.FileEntry {362 return files.filter(fn [strips, save_id, created_at] (file snapshot.FileEntry) bool {363 return !strips.any(reaches(it, save_id, created_at) && covers(it.path, file.path))364 })365}366367368369fn fetched_bytes(store &storage.Store, fetched Fetched, entry snapshot.FileEntry) ![]u8 {370 if entry.hash == '' {371 return []u8{}372 }373 if blob := fetched.repo_objects[entry.hash] {374 return zlib.decompress(blob)!375 }376 return store.get_object(entry.hash)!377}378379fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {380 mut sides := map[string]Side{}381 if clashes.len == 0 {382 return sides383 }384 for clash in clashes {385 if opts.mine {386 sides[clash.path] = .mine387 continue388 }389 if opts.theirs {390 sides[clash.path] = .theirs391 continue392 }393 if opts.ask != unsafe { nil } {394 sides[clash.path] = opts.ask(clash)395 continue396 }397 paths := clashes.map(it.path).join(', ')398 return error('${paths} changed on both machines — run on a terminal to choose, or pass --mine or --theirs')399 }400 return sides401}402403fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {404 if a.hash == '' && b.hash == '' {405 return true406 }407 return a.hash == b.hash && a.executable == b.executable408}409410fn changed_since(store &storage.Store, before ?history.Save, strips_before int) !bool {411 if events.strip_events(store)!.len != strips_before {412 return true413 }414 now := history.head(store)415 if now or { history.Save{} }.id != before or { history.Save{} }.id {416 return true417 }418 return dirty(store, now)!419}420421422423424pub fn dirty(store &storage.Store, head ?history.Save) !bool {425 files := snapshot.walk(store, store.root, snapshot.load(store.root))!426 if saved := head {427 return !snapshot.same_tree(history.files_of(store, saved.id)!, files)428 }429 seed := storage.hash_hex(snapshot.default_ignore.bytes())430 return files.any(it.path != snapshot.ignore_file || it.hash != seed)431}432433434435pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {436 check_format(mut backend, name)!437 remote_head := backend.get(head_key(name))!438 if !remote_head.found {439 return error('the remote has no repo called "${name}"')440 }441 save_ids := backend.list('${name}/saves')!442 object_keys := backend.list('${name}/objects')!443 event_keys := backend.list('${name}/events')!444 mut fetched := Fetched{445 head: remote_head.body.bytestr().trim_space()446 }447 for key in object_keys {448 blob := backend.get(key)!449 if blob.found {450 fetched.repo_objects[os.base(key)] = blob.body451 }452 }453 for key in save_ids {454 body := backend.get(key)!455 if !body.found {456 continue457 }458 record := decode_save(body.body)!459 fetched.saves << record460 }461 fetched.saves = parent_first(fetched.saves)!462 for key in event_keys {463 body := backend.get(key)!464 if body.found {465 fetched.events_out << decode_event(body.body)!466 }467 }468 return fetched469}470471472473474fn parent_first(records []SaveRecord) ![]SaveRecord {475 mut by_id := map[string]SaveRecord{}476 for record in records {477 by_id[record.id] = record478 }479 mut placed := map[string]bool{}480 mut ordered := []SaveRecord{}481 for record in records {482 mut chain := []SaveRecord{}483 mut cursor := record.id484 for cursor != '' && cursor !in placed {485 if chain.len > records.len {486 return error('the save records on the remote form a loop')487 }488 current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }489 chain << current490 cursor = current.parent491 }492 chain.reverse_in_place()493 for current in chain {494 placed[current.id] = true495 ordered << current496 }497 }498 return ordered499}