123456789module sync1011import os12import src.apply13import src.history14import src.remote15import src.snapshot16import src.storage1718pub enum Side {19 mine20 theirs21}22232425pub struct Clash {26pub mut:27 path string28 mine snapshot.FileEntry29 theirs snapshot.FileEntry30 base snapshot.FileEntry31 stat string32}3334pub struct StealOptions {35pub mut:36 mine bool 37 theirs bool 38 39 ask fn (clash Clash) Side = unsafe { nil }40}4142pub struct StealReport {43pub mut:44 repo string45 forward bool46 fetched_saves int47 fetched_objects int48 fetched_events int49 clashes []string50 kept_mine int51 replayed int52 removed int53 head string54}5556pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'57585960pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {61 name := repo_name(store, mut backend)!62 head_before := history.head(store)63 if head := head_before {64 65 changed := dirty(store, head)!66 if changed {67 return error(dirty_tree)68 }69 }70 fetched := fetch_back_to_known(store, mut backend, name)!71 mut report := StealReport{72 repo: name73 fetched_saves: fetched.saves.len74 fetched_objects: fetched.repo_objects.len75 fetched_events: fetched.events_out.len76 head: fetched.head77 }78 mut local_only := []history.Save{}79 if head := head_before {80 81 82 83 local_only = saves_after(store, head.id, fetched.base) or {84 return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')85 }86 }87 if fetched.base == '' && local_only.len > 0 {88 return error('the remote keeps a different history — it shares no save with this repo')89 }9091 store.begin() or { return err }92 final := (write_and_combine(store, fetched, local_only, opts, mut report) or {93 store.rollback()94 return err95 })96 store.commit() or { return err }97 report.head = final.id9899 if head := head_before {100 if final.id == head.id {101 102 return report103 }104 files := history.files_of(store, final.id)!105 mut wanted := map[string]bool{}106 for file in files {107 wanted[file.path] = true108 }109 mut remove := []string{}110 for file in history.files_of(store, head.id)! {111 if file.path !in wanted {112 remove << file.path113 }114 }115 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,116 remove)!117 report.removed = result.removed118 } else {119 files := history.files_of(store, final.id)!120 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!121 }122 history.write_log_md(store)!123 return report124}125126127128129fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save {130 write_fetched(store, fetched)!131 if fetched.saves.len == 0 {132 133 134 return history.head(store) or { return error('this repo has no head') }135 }136 if local_only.len == 0 {137 138 history.set_head(store, fetched.head)!139 report.forward = true140 return history.head(store) or { return error('the remote head did not land') }141 }142 remote_files := history.files_of(store, fetched.head)!143 base_files := if fetched.base != '' {144 history.files_of(store, fetched.base)!145 } else {146 []snapshot.FileEntry{}147 }148 mut mine_files := []snapshot.FileEntry{}149 if our_head := history.head(store) {150 mine_files = history.files_of(store, our_head.id)!151 }152 clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files))153 report.clashes = clashes.map(it.path)154 side_of := choose_sides(clashes, opts)!155156 157 history.set_head(store, fetched.head)!158 mut current := remote_files.clone()159 for save in local_only {160 parent_files := if save.parent != '' {161 history.files_of(store, save.parent)!162 } else {163 []snapshot.FileEntry{}164 }165 target_files := history.files_of(store, save.id)!166 plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))167 if apply.conflicts(plan) {168 return error('combine could not apply ${plan.conflict} — it is not a clash file')169 }170 if plan.applied.len == 0 {171 172 continue173 }174 history.record_save(store, '', plan.files, history.SaveInput{175 message: save.message176 created_at: save.created_at177 machine: save.machine178 agent: save.agent179 session: save.session180 is_checkpoint: save.is_checkpoint181 replayed_from: save.id182 })!183 current = plan.files.clone()184 report.replayed++185 }186187 mut mine_paths := []string{}188 for clash in clashes {189 if side_of[clash.path] == .mine {190 mine_paths << clash.path191 }192 }193 if mine_paths.len > 0 {194 195 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])196 if final_plan.applied.len > 0 {197 history.record_save(store, '', final_plan.files, history.SaveInput{198 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {199 'file'200 } else {201 'files'202 }}'203 })!204 report.kept_mine = mine_paths.len205 }206 }207 return history.head(store) or { return error('the combined line has no head') }208}209210211212pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {213 base_map := snapshot.index_files(base)214 mine_map := snapshot.index_files(mine)215 theirs_map := snapshot.index_files(theirs)216 mut paths := mine_map.keys()217 for path in theirs_map.keys() {218 if path !in mine_map {219 paths << path220 }221 }222 paths.sort()223 mut clashes := []Clash{}224 for path in paths {225 here := mine_map[path] or {226 snapshot.FileEntry{227 path: path228 }229 }230 there := theirs_map[path] or {231 snapshot.FileEntry{232 path: path233 }234 }235 was := base_map[path] or {236 snapshot.FileEntry{237 path: path238 }239 }240 if same(here, there) {241 continue242 }243 if same(here, was) || same(there, was) {244 continue245 }246 clashes << Clash{247 path: path248 mine: here249 theirs: there250 base: was251 }252 }253 return clashes254}255256257fn describe_clashes(store &storage.Store, clashes []Clash) []Clash {258 mut out := []Clash{}259 for clash in clashes {260 mut decorated := clash261 decorated.stat = clash_stat(store, clash)262 out << decorated263 }264 return out265}266267fn clash_stat(store &storage.Store, clash Clash) string {268 deltas := snapshot.diff_trees(store, snapshot.Tree{269 files: present(clash.mine)270 }, snapshot.Tree{271 files: present(clash.theirs)272 }) or { return '' }273 if deltas.len == 0 {274 return 'no textual difference'275 }276 return '+${deltas[0].added} -${deltas[0].removed}'277}278279280281fn present(entry snapshot.FileEntry) []snapshot.FileEntry {282 if entry.hash == '' {283 return []snapshot.FileEntry{}284 }285 return [entry]286}287288fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {289 mut sides := map[string]Side{}290 if clashes.len == 0 {291 return sides292 }293 for clash in clashes {294 if opts.mine {295 sides[clash.path] = .mine296 continue297 }298 if opts.theirs {299 sides[clash.path] = .theirs300 continue301 }302 if opts.ask != unsafe { nil } {303 sides[clash.path] = opts.ask(clash)304 continue305 }306 return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')307 }308 return sides309}310311fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {312 if a.hash == '' && b.hash == '' {313 return true314 }315 return a.hash == b.hash && a.executable == b.executable316}317318319320fn dirty(store &storage.Store, head history.Save) !bool {321 return !snapshot.same_tree(history.files_of(store, head.id)!, snapshot.walk(store, store.root,322 snapshot.load(store.root))!)323}324325326327pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {328 remote_head := backend.get(head_key(name))!329 if !remote_head.found {330 return error('the remote has no repo called "${name}"')331 }332 save_ids := backend.list('${name}/saves')!333 object_keys := backend.list('${name}/objects')!334 event_keys := backend.list('${name}/events')!335 mut fetched := Fetched{336 head: remote_head.body.bytestr().trim_space()337 remote_save_ids: map[string]bool{}338 }339 for key in object_keys {340 blob := backend.get(key)!341 if blob.found {342 fetched.repo_objects[os.base(key)] = blob.body343 }344 }345 for key in save_ids {346 body := backend.get(key)!347 if !body.found {348 continue349 }350 record := decode_save(body.body)!351 fetched.remote_save_ids[record.id] = true352 fetched.saves << record353 }354 fetched.saves.sort(a.created_at < b.created_at)355 for key in event_keys {356 body := backend.get(key)!357 if body.found {358 fetched.events_out << decode_event(body.body)!359 }360 }361 return fetched362}