//! Stealing: bringing another machine's saves in, and combining when both //! machines moved. //! //! The order is always the same, and it is what makes an interrupted steal //! harmless: read everything from the remote first, then land it in one //! transaction, then write the working tree. Divergence is resolved per file — //! a clash is never merged line by line, it is chosen. module sync import os import src.apply import src.history import src.remote import src.snapshot import src.storage pub enum Side { mine theirs } // Clash is one file both machines changed differently. `stat` is the short // diff stat the terminal prompt shows, so the choice is informed. pub struct Clash { pub mut: path string mine snapshot.FileEntry theirs snapshot.FileEntry base snapshot.FileEntry stat string } pub struct StealOptions { pub mut: mine bool // resolve every clash as mine theirs bool // resolve every clash as theirs // Asked once per clash when neither flag is set. Nil means "nobody to ask". ask fn (clash Clash) Side = unsafe { nil } } pub struct StealReport { pub mut: repo string forward bool fetched_saves int fetched_objects int fetched_events int clashes []string kept_mine int replayed int removed int head string } pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree' // steal brings the remote's new saves in. Inside a repo with its own work this // may have to combine; a repo with nothing of its own simply fast-forwards. pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport { name := repo_name(store, mut backend)! head_before := history.head(store) if head := head_before { // Combining rewrites the working tree, so unsaved work would be lost. changed := dirty(store, head)! if changed { return error(dirty_tree) } } fetched := fetch_back_to_known(store, mut backend, name)! mut report := StealReport{ repo: name fetched_saves: fetched.saves.len fetched_objects: fetched.repo_objects.len fetched_events: fetched.events_out.len head: fetched.head } mut local_only := []history.Save{} if head := head_before { local_only = saves_after(store, head.id, fetched.base) or { []history.Save{} } } if fetched.base == '' && local_only.len > 0 { return error('the remote keeps a different history — it shares no save with this repo') } store.begin() or { return err } final := (write_and_combine(store, fetched, local_only, opts, mut report) or { store.rollback() return err }) store.commit() or { return err } if head := head_before { files := history.files_of(store, final.id)! mut wanted := map[string]bool{} for file in files { wanted[file.path] = true } mut remove := []string{} for file in history.files_of(store, head.id)! { if file.path !in wanted { remove << file.path } } result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files, remove)! report.removed = result.removed } else { files := history.files_of(store, final.id)! snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])! } report.head = final.id history.write_log_md(store)! return report } // write_and_combine lands a fetch and, when the local line has saves the remote // lacks, replays them on top of the remote head. It runs inside the caller's // transaction and returns the new head. fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save { write_fetched(store, fetched)! if local_only.len == 0 { // Nothing of ours is missing from the remote: the line simply moves on. history.set_head(store, fetched.head)! report.forward = true return history.head(store) or { return error('the remote head did not land') } } remote_files := history.files_of(store, fetched.head)! base_files := if fetched.base != '' { history.files_of(store, fetched.base)! } else { []snapshot.FileEntry{} } mut mine_files := []snapshot.FileEntry{} if our_head := history.head(store) { mine_files = history.files_of(store, our_head.id)! } clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files)) report.clashes = clashes.map(it.path) side_of := choose_sides(clashes, opts)! // Take the remote's line first, so the replayed saves chain onto it. history.set_head(store, fetched.head)! mut current := remote_files.clone() for save in local_only { parent_files := if save.parent != '' { history.files_of(store, save.parent)! } else { []snapshot.FileEntry{} } target_files := history.files_of(store, save.id)! plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path)) if apply.conflicts(plan) { return error('combine could not apply ${plan.conflict} — it is not a clash file') } if plan.applied.len == 0 { // Everything this save touched was a clash resolved as theirs. continue } history.record_save(store, '', plan.files, history.SaveInput{ message: save.message created_at: save.created_at machine: save.machine agent: save.agent session: save.session is_checkpoint: save.is_checkpoint replayed_from: save.id })! current = plan.files.clone() report.replayed++ } mut mine_paths := []string{} for clash in clashes { if side_of[clash.path] == .mine { mine_paths << clash.path } } if mine_paths.len > 0 { // One last save puts my versions of the chosen files back. final_plan := apply.three_way(current, current, mine_files, mine_paths, []) if final_plan.applied.len > 0 { history.record_save(store, '', final_plan.files, history.SaveInput{ message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 { 'file' } else { 'files' }}' })! report.kept_mine = mine_paths.len } } return history.head(store) or { return error('the combined line has no head') } } // find_clashes lists the files both sides changed, differently. Files changed // identically on both sides are not clashes: there is nothing to decide. pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash { base_map := snapshot.index_files(base) mine_map := snapshot.index_files(mine) theirs_map := snapshot.index_files(theirs) mut paths := mine_map.keys() for path in theirs_map.keys() { if path !in mine_map { paths << path } } paths.sort() mut clashes := []Clash{} for path in paths { here := mine_map[path] or { snapshot.FileEntry{ path: path } } there := theirs_map[path] or { snapshot.FileEntry{ path: path } } was := base_map[path] or { snapshot.FileEntry{ path: path } } if same(here, there) { continue } if same(here, was) || same(there, was) { continue } clashes << Clash{ path: path mine: here theirs: there base: was } } return clashes } // describe_clashes adds the diff stat each prompt needs. fn describe_clashes(store &storage.Store, clashes []Clash) []Clash { mut out := []Clash{} for clash in clashes { mut decorated := clash decorated.stat = clash_stat(store, clash) out << decorated } return out } fn clash_stat(store &storage.Store, clash Clash) string { deltas := snapshot.diff_trees(store, snapshot.Tree{ files: present(clash.mine) }, snapshot.Tree{ files: present(clash.theirs) }) or { return '' } if deltas.len == 0 { return 'no textual difference' } return '+${deltas[0].added} -${deltas[0].removed}' } // present drops an absent side, so a comparison never asks the object store for // an empty hash. fn present(entry snapshot.FileEntry) []snapshot.FileEntry { if entry.hash == '' { return []snapshot.FileEntry{} } return [entry] } fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side { mut sides := map[string]Side{} if clashes.len == 0 { return sides } for clash in clashes { if opts.mine { sides[clash.path] = .mine continue } if opts.theirs { sides[clash.path] = .theirs continue } if opts.ask != unsafe { nil } { sides[clash.path] = opts.ask(clash) continue } return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs') } return sides } fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool { if a.hash == '' && b.hash == '' { return true } return a.hash == b.hash && a.executable == b.executable } // dirty reports whether the working tree differs from `head` in a way a steal // would overwrite. fn dirty(store &storage.Store, head history.Save) !bool { return !snapshot.same_tree(history.files_of(store, head.id)!, snapshot.walk(store, store.root, snapshot.load(store.root))!) } // fetch_repo pulls a whole repo — head, saves, objects and events — for // `koh steal `, which lands a copy in a new folder. pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched { remote_head := backend.get(head_key(name))! if !remote_head.found { return error('the remote has no repo called "${name}"') } save_ids := backend.list('${name}/saves')! object_keys := backend.list('${name}/objects')! event_keys := backend.list('${name}/events')! mut fetched := Fetched{ head: remote_head.body.bytestr().trim_space() remote_save_ids: map[string]bool{} } for key in object_keys { blob := backend.get(key)! if blob.found { fetched.repo_objects[os.base(key)] = blob.body } } for key in save_ids { body := backend.get(key)! if !body.found { continue } record := decode_save(body.body)! fetched.remote_save_ids[record.id] = true fetched.saves << record } fetched.saves.sort(a.created_at < b.created_at) for key in event_keys { body := backend.get(key)! if body.found { fetched.events_out << decode_event(body.body)! } } return fetched }