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}5758pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'59pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'60616263pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {64 name := repo_name(store, mut backend)!65 head_before := history.head(store)66 67 if dirty(store, head_before)! {68 if head_before == none {69 return error(no_saves_here)70 }71 return error(dirty_tree)72 }73 fetched := fetch_back_to_known(store, mut backend, name)!74 mut report := StealReport{75 repo: name76 fetched_saves: fetched.saves.len77 fetched_objects: fetched.repo_objects.len78 fetched_events: fetched.events_out.len79 head: fetched.head80 }81 mut local_only := []history.Save{}82 if head := head_before {83 84 85 86 local_only = saves_after(store, head.id, fetched.base) or {87 return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')88 }89 }90 if fetched.base == '' && local_only.len > 0 {91 return error('the remote keeps a different history — it shares no save with this repo')92 }9394 strips_before := events.strip_events(store)!.len95 mut clashes := []Clash{}96 mut side_of := map[string]Side{}97 if fetched.saves.len > 0 && local_only.len > 0 {98 clashes = combine_clashes(store, fetched)!99 report.clashes = clashes.map(it.path)100 101 102 side_of = choose_sides(clashes, opts)!103 }104105 store.begin() or { return err }106 107 108 if moved := changed_since(store, head_before, strips_before) {109 if moved {110 store.rollback()111 return error('this repo changed while steal was asking — run koh steal again')112 }113 } else {114 store.rollback()115 return err116 }117 final := (write_and_combine(store, fetched, local_only, clashes, side_of, mut report) or {118 store.rollback()119 return err120 })121 store.commit() or { return err }122 report.head = final.id123124 if head := head_before {125 if final.id == head.id {126 127 return report128 }129 files := history.files_of(store, final.id)!130 mut wanted := map[string]bool{}131 for file in files {132 wanted[file.path] = true133 }134 mut remove := []string{}135 for file in history.files_of(store, head.id)! {136 if file.path !in wanted {137 remove << file.path138 }139 }140 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,141 remove)!142 report.removed = result.removed143 } else {144 files := history.files_of(store, final.id)!145 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!146 }147 history.refresh_log_md(store)148 return report149}150151152153154fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, clashes []Clash, side_of map[string]Side, mut report StealReport) !history.Save {155 write_fetched(store, fetched)!156 if fetched.saves.len == 0 {157 158 159 return history.head(store) or { return error('this repo has no head') }160 }161 if local_only.len == 0 {162 163 history.set_head(store, fetched.head)!164 report.forward = true165 return history.head(store) or { return error('the remote head did not land') }166 }167 remote_files := history.files_of(store, fetched.head)!168 mut mine_files := []snapshot.FileEntry{}169 if our_head := history.head(store) {170 mine_files = history.files_of(store, our_head.id)!171 }172173 174 history.set_head(store, fetched.head)!175 mut current := remote_files.clone()176 for save in local_only {177 parent_files := if save.parent != '' {178 history.files_of(store, save.parent)!179 } else {180 []snapshot.FileEntry{}181 }182 target_files := history.files_of(store, save.id)!183 plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))184 if apply.conflicts(plan) {185 return error('combine could not apply ${plan.conflict} — it is not a clash file')186 }187 if plan.applied.len == 0 {188 189 continue190 }191 history.record_save(store, '', plan.files, history.SaveInput{192 message: save.message193 created_at: save.created_at194 machine: save.machine195 agent: save.agent196 session: save.session197 is_checkpoint: save.is_checkpoint198 replayed_from: save.id199 })!200 current = plan.files.clone()201 report.replayed++202 }203204 mut mine_paths := []string{}205 for clash in clashes {206 if side_of[clash.path] == .mine {207 mine_paths << clash.path208 }209 }210 if mine_paths.len > 0 {211 212 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])213 if final_plan.applied.len > 0 {214 history.record_save(store, '', final_plan.files, history.SaveInput{215 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {216 'file'217 } else {218 'files'219 }}'220 })!221 report.kept_mine = mine_paths.len222 }223 }224 return history.head(store) or { return error('the combined line has no head') }225}226227228229pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {230 base_map := snapshot.index_files(base)231 mine_map := snapshot.index_files(mine)232 theirs_map := snapshot.index_files(theirs)233 mut paths := mine_map.keys()234 for path in theirs_map.keys() {235 if path !in mine_map {236 paths << path237 }238 }239 paths.sort()240 mut clashes := []Clash{}241 for path in paths {242 here := mine_map[path] or {243 snapshot.FileEntry{244 path: path245 }246 }247 there := theirs_map[path] or {248 snapshot.FileEntry{249 path: path250 }251 }252 was := base_map[path] or {253 snapshot.FileEntry{254 path: path255 }256 }257 if same(here, there) {258 continue259 }260 if same(here, was) || same(there, was) {261 continue262 }263 clashes << Clash{264 path: path265 mine: here266 theirs: there267 base: was268 }269 }270 return clashes271}272273274275276fn combine_clashes(store &storage.Store, fetched Fetched) ![]Clash {277 base_files := if fetched.base != '' {278 history.files_of(store, fetched.base)!279 } else {280 []snapshot.FileEntry{}281 }282 head := history.head(store) or { return []Clash{} }283 284 285 286 strips := fetched.events_out.filter(it.kind == events.kind_strip)287 mut clashes := find_clashes(after_strips(base_files, fetched.base, strips), after_strips(history.files_of(store,288 head.id)!, head.id, strips), after_strips(to_files(fetched.saves.last()), fetched.head,289 strips))290 for mut clash in clashes {291 mine := fetched_bytes(store, fetched, clash.mine)!292 theirs := fetched_bytes(store, fetched, clash.theirs)!293 delta := snapshot.diff_bytes(clash.path, 'modified', mine, theirs)!294 clash.stat = if delta.binary { 'binary' } else { '+${delta.added} -${delta.removed}' }295 }296 return clashes297}298299300fn after_strips(files []snapshot.FileEntry, save_id string, strips []events.Event) []snapshot.FileEntry {301 return files.filter(fn [strips, save_id] (file snapshot.FileEntry) bool {302 for strip in strips {303 if (strip.save_id == '' || strip.save_id == save_id) && covers(strip.path, file.path) {304 return false305 }306 }307 return true308 })309}310311312313fn fetched_bytes(store &storage.Store, fetched Fetched, entry snapshot.FileEntry) ![]u8 {314 if entry.hash == '' {315 return []u8{}316 }317 if blob := fetched.repo_objects[entry.hash] {318 return zlib.decompress(blob)!319 }320 return store.get_object(entry.hash)!321}322323fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {324 mut sides := map[string]Side{}325 if clashes.len == 0 {326 return sides327 }328 for clash in clashes {329 if opts.mine {330 sides[clash.path] = .mine331 continue332 }333 if opts.theirs {334 sides[clash.path] = .theirs335 continue336 }337 if opts.ask != unsafe { nil } {338 sides[clash.path] = opts.ask(clash)339 continue340 }341 return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')342 }343 return sides344}345346fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {347 if a.hash == '' && b.hash == '' {348 return true349 }350 return a.hash == b.hash && a.executable == b.executable351}352353fn changed_since(store &storage.Store, before ?history.Save, strips_before int) !bool {354 if events.strip_events(store)!.len != strips_before {355 return true356 }357 now := history.head(store)358 if now or { history.Save{} }.id != before or { history.Save{} }.id {359 return true360 }361 return dirty(store, now)!362}363364365366367fn dirty(store &storage.Store, head ?history.Save) !bool {368 files := snapshot.walk(store, store.root, snapshot.load(store.root))!369 if saved := head {370 return !snapshot.same_tree(history.files_of(store, saved.id)!, files)371 }372 seed := storage.hash_hex(snapshot.default_ignore.bytes())373 return files.any(it.path != snapshot.ignore_file || it.hash != seed)374}375376377378pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {379 remote_head := backend.get(head_key(name))!380 if !remote_head.found {381 return error('the remote has no repo called "${name}"')382 }383 save_ids := backend.list('${name}/saves')!384 object_keys := backend.list('${name}/objects')!385 event_keys := backend.list('${name}/events')!386 mut fetched := Fetched{387 head: remote_head.body.bytestr().trim_space()388 remote_save_ids: map[string]bool{}389 }390 for key in object_keys {391 blob := backend.get(key)!392 if blob.found {393 fetched.repo_objects[os.base(key)] = blob.body394 }395 }396 for key in save_ids {397 body := backend.get(key)!398 if !body.found {399 continue400 }401 record := decode_save(body.body)!402 fetched.remote_save_ids[record.id] = true403 fetched.saves << record404 }405 fetched.saves = parent_first(fetched.saves)!406 for key in event_keys {407 body := backend.get(key)!408 if body.found {409 fetched.events_out << decode_event(body.body)!410 }411 }412 return fetched413}414415416417418fn parent_first(records []SaveRecord) ![]SaveRecord {419 mut by_id := map[string]SaveRecord{}420 for record in records {421 by_id[record.id] = record422 }423 mut placed := map[string]bool{}424 mut ordered := []SaveRecord{}425 for record in records {426 mut chain := []SaveRecord{}427 mut cursor := record.id428 for cursor != '' && cursor !in placed {429 if chain.len > records.len {430 return error('the save records on the remote form a loop')431 }432 current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }433 chain << current434 cursor = current.parent435 }436 chain.reverse_in_place()437 for current in chain {438 placed[current.id] = true439 ordered << current440 }441 }442 return ordered443}