123456789module sync1011import os12import src.apply13import src.events14import src.history15import src.remote16import src.snapshot17import src.storage1819pub enum Side {20 mine21 theirs22}23242526pub struct Clash {27pub mut:28 path string29 mine snapshot.FileEntry30 theirs snapshot.FileEntry31 base snapshot.FileEntry32 stat string33}3435pub struct StealOptions {36pub mut:37 mine bool 38 theirs bool 39 40 ask fn (clash Clash) Side = unsafe { nil }41}4243pub struct StealReport {44pub mut:45 repo string46 forward bool47 fetched_saves int48 fetched_objects int49 fetched_events int50 clashes []string51 kept_mine int52 replayed int53 removed int54 head string55}5657pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'58596061pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {62 name := repo_name(store, mut backend)!63 head_before := history.head(store)64 if head := head_before {65 66 changed := dirty(store, head)!67 if changed {68 return error(dirty_tree)69 }70 }71 fetched := fetch_back_to_known(store, mut backend, name)!72 mut report := StealReport{73 repo: name74 fetched_saves: fetched.saves.len75 fetched_objects: fetched.repo_objects.len76 fetched_events: fetched.events_out.len77 head: fetched.head78 }79 mut local_only := []history.Save{}80 if head := head_before {81 local_only = saves_after(store, head.id, fetched.base) or { []history.Save{} }82 }83 if fetched.base == '' && local_only.len > 0 {84 return error('the remote keeps a different history — it shares no save with this repo')85 }8687 store.begin() or { return err }88 final := (write_and_combine(store, fetched, local_only, opts, mut report) or {89 store.rollback()90 return err91 })92 store.commit() or { return err }9394 if head := head_before {95 files := history.files_of(store, final.id)!96 mut wanted := map[string]bool{}97 for file in files {98 wanted[file.path] = true99 }100 mut remove := []string{}101 for file in history.files_of(store, head.id)! {102 if file.path !in wanted {103 remove << file.path104 }105 }106 result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,107 remove)!108 report.removed = result.removed109 } else {110 files := history.files_of(store, final.id)!111 snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!112 }113 report.head = final.id114 history.write_log_md(store)!115 return report116}117118119120121fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save {122 write_fetched(store, fetched)!123 if local_only.len == 0 {124 125 history.set_head(store, fetched.head)!126 report.forward = true127 return history.head(store) or { return error('the remote head did not land') }128 }129 remote_files := history.files_of(store, fetched.head)!130 base_files := if fetched.base != '' {131 history.files_of(store, fetched.base)!132 } else {133 []snapshot.FileEntry{}134 }135 mut mine_files := []snapshot.FileEntry{}136 if our_head := history.head(store) {137 mine_files = history.files_of(store, our_head.id)!138 }139 clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files))140 report.clashes = clashes.map(it.path)141 side_of := choose_sides(clashes, opts)!142143 144 history.set_head(store, fetched.head)!145 mut current := remote_files.clone()146 for save in local_only {147 parent_files := if save.parent != '' {148 history.files_of(store, save.parent)!149 } else {150 []snapshot.FileEntry{}151 }152 target_files := history.files_of(store, save.id)!153 plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))154 if apply.conflicts(plan) {155 return error('combine could not apply ${plan.conflict} — it is not a clash file')156 }157 if plan.applied.len == 0 {158 159 continue160 }161 history.record_save(store, '', plan.files, history.SaveInput{162 message: save.message163 created_at: save.created_at164 machine: save.machine165 agent: save.agent166 session: save.session167 is_checkpoint: save.is_checkpoint168 replayed_from: save.id169 })!170 current = plan.files.clone()171 report.replayed++172 }173174 mut mine_paths := []string{}175 for clash in clashes {176 if side_of[clash.path] == .mine {177 mine_paths << clash.path178 }179 }180 if mine_paths.len > 0 {181 182 final_plan := apply.three_way(current, current, mine_files, mine_paths, [])183 if final_plan.applied.len > 0 {184 history.record_save(store, '', final_plan.files, history.SaveInput{185 message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {186 'file'187 } else {188 'files'189 }}'190 })!191 report.kept_mine = mine_paths.len192 }193 }194 return history.head(store) or { return error('the combined line has no head') }195}196197198199pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {200 base_map := snapshot.index_files(base)201 mine_map := snapshot.index_files(mine)202 theirs_map := snapshot.index_files(theirs)203 mut paths := mine_map.keys()204 for path in theirs_map.keys() {205 if path !in mine_map {206 paths << path207 }208 }209 paths.sort()210 mut clashes := []Clash{}211 for path in paths {212 here := mine_map[path] or {213 snapshot.FileEntry{214 path: path215 }216 }217 there := theirs_map[path] or {218 snapshot.FileEntry{219 path: path220 }221 }222 was := base_map[path] or {223 snapshot.FileEntry{224 path: path225 }226 }227 if same(here, there) {228 continue229 }230 if same(here, was) || same(there, was) {231 continue232 }233 clashes << Clash{234 path: path235 mine: here236 theirs: there237 base: was238 }239 }240 return clashes241}242243244fn describe_clashes(store &storage.Store, clashes []Clash) []Clash {245 mut out := []Clash{}246 for clash in clashes {247 mut decorated := clash248 decorated.stat = clash_stat(store, clash)249 out << decorated250 }251 return out252}253254fn clash_stat(store &storage.Store, clash Clash) string {255 deltas := snapshot.diff_trees(store, snapshot.Tree{256 files: present(clash.mine)257 }, snapshot.Tree{258 files: present(clash.theirs)259 }) or { return '' }260 if deltas.len == 0 {261 return 'no textual difference'262 }263 return '+${deltas[0].added} -${deltas[0].removed}'264}265266267268fn present(entry snapshot.FileEntry) []snapshot.FileEntry {269 if entry.hash == '' {270 return []snapshot.FileEntry{}271 }272 return [entry]273}274275fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {276 mut sides := map[string]Side{}277 if clashes.len == 0 {278 return sides279 }280 for clash in clashes {281 if opts.mine {282 sides[clash.path] = .mine283 continue284 }285 if opts.theirs {286 sides[clash.path] = .theirs287 continue288 }289 if opts.ask != unsafe { nil } {290 sides[clash.path] = opts.ask(clash)291 continue292 }293 return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')294 }295 return sides296}297298fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {299 if a.hash == '' && b.hash == '' {300 return true301 }302 return a.hash == b.hash && a.executable == b.executable303}304305306307fn dirty(store &storage.Store, head history.Save) !bool {308 before := snapshot.index_files(history.files_of(store, head.id)!)309 after := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)310 for path in after.keys() {311 if path !in before || before[path].hash != after[path].hash {312 return true313 }314 }315 for path in before.keys() {316 if path !in after {317 return true318 }319 }320 return false321}322323324325pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {326 remote_head := backend.get(head_key(name))!327 if !remote_head.found {328 return error('the remote has no repo called "${name}"')329 }330 save_ids := backend.list('${name}/saves')!331 object_keys := backend.list('${name}/objects')!332 event_keys := backend.list('${name}/events')!333 mut fetched := Fetched{334 head: remote_head.body.bytestr().trim_space()335 remote_save_ids: map[string]bool{}336 }337 for key in object_keys {338 blob := backend.get(key)!339 if blob.found {340 fetched.repo_objects[os.base(key)] = blob.body341 }342 }343 for key in save_ids {344 body := backend.get(key)!345 if !body.found {346 continue347 }348 record := decode_save(body.body)!349 fetched.remote_save_ids[record.id] = true350 fetched.saves << record351 }352 fetched.saves.sort(a.created_at < b.created_at)353 for key in event_keys {354 body := backend.get(key)!355 if body.found {356 fetched.events_out << decode_event(body.body)!357 }358 }359 return fetched360}