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 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 if head_before == none || final.id != head_before or { history.Save{} }.id {138 snapshot.mark_restore(store) or {139 store.rollback()140 return err141 }142 }143 store.commit() or { return err }144 report.head = final.id145 if local_only.len > 0 && report.fetched_saves > 0 {146 report.previous_head = local_only.last().id147 }148 flags := events.latest_flags(store)!149 for record in fetched.saves {150 if flag := flags[record.id] {151 if flag.flagged {152 report.flagged << flag153 }154 }155 }156157 if head := head_before {158 if final.id == head.id {159 160 return report161 }162 mut wanted := map[string]bool{}163 for file in files {164 wanted[file.path] = true165 }166 mut remove := []string{}167 for file in history.files_of(store, head.id)! {168 if file.path !in wanted {169 remove << file.path170 }171 }172 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,173 remove)!174 report.removed = result.removed175 } else {176 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!177 }178 history.refresh_log_md(store)179 return report180}181182183184185fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, clashes []Clash, side_of map[string]Side, mut report StealReport) !history.Save {186 write_fetched(store, fetched)!187 if fetched.saves.len == 0 {188 189 190 return history.head(store) or { return error('this repo has no head') }191 }192 if local_only.len == 0 {193 194 history.set_head(store, fetched.head)!195 report.forward = true196 return history.head(store) or { return error('the remote head did not land') }197 }198 remote_files := history.files_of(store, fetched.head)!199 mut mine_files := []snapshot.FileEntry{}200 if our_head := history.head(store) {201 mine_files = history.files_of(store, our_head.id)!202 }203204 205 206 207 208 base_files := history.files_of(store, fetched.base)!209 settled := changed_paths(base_files, remote_files)210211 212 history.set_head(store, fetched.head)!213 mut current := remote_files.clone()214 for save in local_only {215 parent_files := if save.parent != '' {216 history.files_of(store, save.parent)!217 } else {218 []snapshot.FileEntry{}219 }220 target_files := history.files_of(store, save.id)!221 plan := apply.three_way(current, parent_files, target_files, [], settled)222 if apply.conflicts(plan) {223 return error('combine could not apply ${plan.conflict} — it is not a clash file')224 }225 if plan.applied.len == 0 {226 227 continue228 }229 history.record_save(store, '', plan.files, history.SaveInput{230 message: save.message231 created_at: save.created_at232 machine: save.machine233 agent: save.agent234 session: save.session235 is_checkpoint: save.is_checkpoint236 replayed_from: save.id237 })!238 current = plan.files.clone()239 report.replayed++240 }241242 mut mine_paths := []string{}243 for clash in clashes {244 if side_of[clash.path] == .mine {245 mine_paths << clash.path246 }247 }248 if mine_paths.len > 0 {249 250 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])251 if final_plan.applied.len > 0 {252 history.record_save(store, '', final_plan.files, history.SaveInput{253 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {254 'file'255 } else {256 'files'257 }}'258 })!259 report.kept_mine = mine_paths.len260 }261 }262 return history.head(store) or { return error('the combined line has no head') }263}264265266267pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {268 base_map := snapshot.index_files(base)269 mine_map := snapshot.index_files(mine)270 theirs_map := snapshot.index_files(theirs)271 mut paths := mine_map.keys()272 for path in theirs_map.keys() {273 if path !in mine_map {274 paths << path275 }276 }277 paths.sort()278 mut clashes := []Clash{}279 for path in paths {280 here := mine_map[path] or {281 snapshot.FileEntry{282 path: path283 }284 }285 there := theirs_map[path] or {286 snapshot.FileEntry{287 path: path288 }289 }290 was := base_map[path] or {291 snapshot.FileEntry{292 path: path293 }294 }295 if same(here, there) {296 continue297 }298 if same(here, was) || same(there, was) {299 continue300 }301 clashes << Clash{302 path: path303 mine: here304 theirs: there305 base: was306 }307 }308 return clashes309}310311312fn changed_paths(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {313 was := snapshot.index_files(before)314 now := snapshot.index_files(after)315 mut paths := []string{}316 for path, entry in now {317 if !same(entry, was[path] or { snapshot.FileEntry{} }) {318 paths << path319 }320 }321 for path, _ in was {322 if path !in now {323 paths << path324 }325 }326 return paths327}328329330331332fn combine_clashes(store &storage.Store, fetched Fetched) ![]Clash {333 base_files := if fetched.base != '' {334 history.files_of(store, fetched.base)!335 } else {336 []snapshot.FileEntry{}337 }338 head := history.head(store) or { return []Clash{} }339 340 341 342 strips := fetched.events_out.filter(it.kind == events.kind_strip)343 344 remote_head := fetched.saves.last()345 mut clashes := find_clashes(after_strips(base_files, fetched.base, 0, strips), after_strips(history.files_of(store,346 head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,347 remote_head.created_at, strips))348 for mut clash in clashes {349 mine := fetched_bytes(store, fetched, clash.mine)!350 theirs := fetched_bytes(store, fetched, clash.theirs)!351 delta := snapshot.diff_bytes(clash.path, 'modified', mine, theirs)!352 clash.stat = if delta.binary { 'binary' } else { '+${delta.added} -${delta.removed}' }353 }354 return clashes355}356357358359fn after_strips(files []snapshot.FileEntry, save_id string, created_at i64, strips []events.Event) []snapshot.FileEntry {360 return files.filter(fn [strips, save_id, created_at] (file snapshot.FileEntry) bool {361 return !strips.any(reaches(it, save_id, created_at) && covers(it.path, file.path))362 })363}364365366367fn fetched_bytes(store &storage.Store, fetched Fetched, entry snapshot.FileEntry) ![]u8 {368 if entry.hash == '' {369 return []u8{}370 }371 if blob := fetched.repo_objects[entry.hash] {372 return zlib.decompress(blob)!373 }374 return store.get_object(entry.hash)!375}376377fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {378 mut sides := map[string]Side{}379 if clashes.len == 0 {380 return sides381 }382 for clash in clashes {383 if opts.mine {384 sides[clash.path] = .mine385 continue386 }387 if opts.theirs {388 sides[clash.path] = .theirs389 continue390 }391 if opts.ask != unsafe { nil } {392 sides[clash.path] = opts.ask(clash)393 continue394 }395 return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')396 }397 return sides398}399400fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {401 if a.hash == '' && b.hash == '' {402 return true403 }404 return a.hash == b.hash && a.executable == b.executable405}406407fn changed_since(store &storage.Store, before ?history.Save, strips_before int) !bool {408 if events.strip_events(store)!.len != strips_before {409 return true410 }411 now := history.head(store)412 if now or { history.Save{} }.id != before or { history.Save{} }.id {413 return true414 }415 return dirty(store, now)!416}417418419420421fn dirty(store &storage.Store, head ?history.Save) !bool {422 files := snapshot.walk(store, store.root, snapshot.load(store.root))!423 if saved := head {424 return !snapshot.same_tree(history.files_of(store, saved.id)!, files)425 }426 seed := storage.hash_hex(snapshot.default_ignore.bytes())427 return files.any(it.path != snapshot.ignore_file || it.hash != seed)428}429430431432pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {433 remote_head := backend.get(head_key(name))!434 if !remote_head.found {435 return error('the remote has no repo called "${name}"')436 }437 save_ids := backend.list('${name}/saves')!438 object_keys := backend.list('${name}/objects')!439 event_keys := backend.list('${name}/events')!440 mut fetched := Fetched{441 head: remote_head.body.bytestr().trim_space()442 }443 for key in object_keys {444 blob := backend.get(key)!445 if blob.found {446 fetched.repo_objects[os.base(key)] = blob.body447 }448 }449 for key in save_ids {450 body := backend.get(key)!451 if !body.found {452 continue453 }454 record := decode_save(body.body)!455 fetched.saves << record456 }457 fetched.saves = parent_first(fetched.saves)!458 for key in event_keys {459 body := backend.get(key)!460 if body.found {461 fetched.events_out << decode_event(body.body)!462 }463 }464 return fetched465}466467468469470fn parent_first(records []SaveRecord) ![]SaveRecord {471 mut by_id := map[string]SaveRecord{}472 for record in records {473 by_id[record.id] = record474 }475 mut placed := map[string]bool{}476 mut ordered := []SaveRecord{}477 for record in records {478 mut chain := []SaveRecord{}479 mut cursor := record.id480 for cursor != '' && cursor !in placed {481 if chain.len > records.len {482 return error('the save records on the remote form a loop')483 }484 current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }485 chain << current486 cursor = current.parent487 }488 chain.reverse_in_place()489 for current in chain {490 placed[current.id] = true491 ordered << current492 }493 }494 return ordered495}