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 local_only = saves_after(store, head.id, fetched.base) or { []history.Save{} }81 }82 if fetched.base == '' && local_only.len > 0 {83 return error('the remote keeps a different history — it shares no save with this repo')84 }8586 store.begin() or { return err }87 final := (write_and_combine(store, fetched, local_only, opts, mut report) or {88 store.rollback()89 return err90 })91 store.commit() or { return err }9293 if head := head_before {94 files := history.files_of(store, final.id)!95 mut wanted := map[string]bool{}96 for file in files {97 wanted[file.path] = true98 }99 mut remove := []string{}100 for file in history.files_of(store, head.id)! {101 if file.path !in wanted {102 remove << file.path103 }104 }105 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,106 remove)!107 report.removed = result.removed108 } else {109 files := history.files_of(store, final.id)!110 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!111 }112 report.head = final.id113 history.write_log_md(store)!114 return report115}116117118119120fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save {121 write_fetched(store, fetched)!122 if local_only.len == 0 {123 124 history.set_head(store, fetched.head)!125 report.forward = true126 return history.head(store) or { return error('the remote head did not land') }127 }128 remote_files := history.files_of(store, fetched.head)!129 base_files := if fetched.base != '' {130 history.files_of(store, fetched.base)!131 } else {132 []snapshot.FileEntry{}133 }134 mut mine_files := []snapshot.FileEntry{}135 if our_head := history.head(store) {136 mine_files = history.files_of(store, our_head.id)!137 }138 clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files))139 report.clashes = clashes.map(it.path)140 side_of := choose_sides(clashes, opts)!141142 143 history.set_head(store, fetched.head)!144 mut current := remote_files.clone()145 for save in local_only {146 parent_files := if save.parent != '' {147 history.files_of(store, save.parent)!148 } else {149 []snapshot.FileEntry{}150 }151 target_files := history.files_of(store, save.id)!152 plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))153 if apply.conflicts(plan) {154 return error('combine could not apply ${plan.conflict} — it is not a clash file')155 }156 if plan.applied.len == 0 {157 158 continue159 }160 history.record_save(store, '', plan.files, history.SaveInput{161 message: save.message162 created_at: save.created_at163 machine: save.machine164 agent: save.agent165 session: save.session166 is_checkpoint: save.is_checkpoint167 replayed_from: save.id168 })!169 current = plan.files.clone()170 report.replayed++171 }172173 mut mine_paths := []string{}174 for clash in clashes {175 if side_of[clash.path] == .mine {176 mine_paths << clash.path177 }178 }179 if mine_paths.len > 0 {180 181 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])182 if final_plan.applied.len > 0 {183 history.record_save(store, '', final_plan.files, history.SaveInput{184 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {185 'file'186 } else {187 'files'188 }}'189 })!190 report.kept_mine = mine_paths.len191 }192 }193 return history.head(store) or { return error('the combined line has no head') }194}195196197198pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {199 base_map := snapshot.index_files(base)200 mine_map := snapshot.index_files(mine)201 theirs_map := snapshot.index_files(theirs)202 mut paths := mine_map.keys()203 for path in theirs_map.keys() {204 if path !in mine_map {205 paths << path206 }207 }208 paths.sort()209 mut clashes := []Clash{}210 for path in paths {211 here := mine_map[path] or {212 snapshot.FileEntry{213 path: path214 }215 }216 there := theirs_map[path] or {217 snapshot.FileEntry{218 path: path219 }220 }221 was := base_map[path] or {222 snapshot.FileEntry{223 path: path224 }225 }226 if same(here, there) {227 continue228 }229 if same(here, was) || same(there, was) {230 continue231 }232 clashes << Clash{233 path: path234 mine: here235 theirs: there236 base: was237 }238 }239 return clashes240}241242243fn describe_clashes(store &storage.Store, clashes []Clash) []Clash {244 mut out := []Clash{}245 for clash in clashes {246 mut decorated := clash247 decorated.stat = clash_stat(store, clash)248 out << decorated249 }250 return out251}252253fn clash_stat(store &storage.Store, clash Clash) string {254 deltas := snapshot.diff_trees(store, snapshot.Tree{255 files: present(clash.mine)256 }, snapshot.Tree{257 files: present(clash.theirs)258 }) or { return '' }259 if deltas.len == 0 {260 return 'no textual difference'261 }262 return '+${deltas[0].added} -${deltas[0].removed}'263}264265266267fn present(entry snapshot.FileEntry) []snapshot.FileEntry {268 if entry.hash == '' {269 return []snapshot.FileEntry{}270 }271 return [entry]272}273274fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {275 mut sides := map[string]Side{}276 if clashes.len == 0 {277 return sides278 }279 for clash in clashes {280 if opts.mine {281 sides[clash.path] = .mine282 continue283 }284 if opts.theirs {285 sides[clash.path] = .theirs286 continue287 }288 if opts.ask != unsafe { nil } {289 sides[clash.path] = opts.ask(clash)290 continue291 }292 return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')293 }294 return sides295}296297fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {298 if a.hash == '' && b.hash == '' {299 return true300 }301 return a.hash == b.hash && a.executable == b.executable302}303304305306fn dirty(store &storage.Store, head history.Save) !bool {307 return !snapshot.same_tree(history.files_of(store, head.id)!, snapshot.walk(store, store.root,308 snapshot.load(store.root))!)309}310311312313pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {314 remote_head := backend.get(head_key(name))!315 if !remote_head.found {316 return error('the remote has no repo called "${name}"')317 }318 save_ids := backend.list('${name}/saves')!319 object_keys := backend.list('${name}/objects')!320 event_keys := backend.list('${name}/events')!321 mut fetched := Fetched{322 head: remote_head.body.bytestr().trim_space()323 remote_save_ids: map[string]bool{}324 }325 for key in object_keys {326 blob := backend.get(key)!327 if blob.found {328 fetched.repo_objects[os.base(key)] = blob.body329 }330 }331 for key in save_ids {332 body := backend.get(key)!333 if !body.found {334 continue335 }336 record := decode_save(body.body)!337 fetched.remote_save_ids[record.id] = true338 fetched.saves << record339 }340 fetched.saves.sort(a.created_at < b.created_at)341 for key in event_keys {342 body := backend.get(key)!343 if body.found {344 fetched.events_out << decode_event(body.body)!345 }346 }347 return fetched348}