//! `koh steal` — bring another machine's saves in. //! //! Inside a repo this pulls the remote's new saves and combines when both //! machines moved. Outside one it fetches a whole repo into a new folder. //! `--list` shows what the remote holds. module cli import json2 import os import src.events import src.fmt import src.history import src.remote import src.snapshot import src.storage import src.exchange pub fn cmd_steal(raw []string) int { args := parse('steal', raw, Options{ flags: ['list', 'mine', 'theirs', 'json'] }) or { return usage_error(err.msg()) } if args.flag('list') { return list_repos(args) } if storage.find_root(os.getwd()) == none { name := args.positional_at(0) if name == '' { return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject') } return clone_repo(name, args) } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } if args.positional.len > 0 { return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo') } mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) } mut options := exchange.StealOptions{ mine: args.flag('mine') theirs: args.flag('theirs') } if !options.mine && !options.theirs && interactive() && os.is_atty(1) != 0 { options.ask = ask_clash } report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) } // The remote should end up with whatever this machine just decided. offered := offer_after_steal(store, mut backend) if args.json() { emit_json(json2.encode(StolenJson{ repo: report.repo forward: report.forward saves: report.fetched_saves objects: report.fetched_objects events: report.fetched_events clashes: report.clashes replayed: report.replayed kept_mine: report.kept_mine head: report.head offer_failed: !offered flagged: report.flagged.map(it.save_id) previous_head: report.previous_head })) return 0 } if report.fetched_saves == 0 { ok('already up to date with "${report.repo}" — head is [${report.head}]') return 0 } if report.forward { ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]') } else { ok('brought in ${report.fetched_saves} saves from "${report.repo}"') note("replayed ${report.replayed} of this machine's saves onto the remote head") if report.kept_mine > 0 { note('kept mine for ${fmt.plural(report.kept_mine, 'file')}') } note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped') } warn_flagged(report.flagged) if report.clashes.len > 0 { note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}') } if !offered { warn('the remote was not updated — run "koh offer" when it is reachable again') } return 0 } // offer_after_steal pushes what this machine just decided. A failure here does // not undo the steal; it is reported. fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool { if _ := exchange.offer(store, mut backend) { return true } return false } // ask_clash is the per-file question a terminal gets. Only a lowercase y-style // answer is needed here: `m` for mine, `t` for theirs. fn ask_clash(clash exchange.Clash) exchange.Side { println(' ${fmt.amber('both changed')} ${clash.path} ${clash.stat}') print(' ${fmt.amber('m')}ine or ${fmt.amber('t')}heirs? [t] ') answer := os.get_line().trim_space() if answer == 'm' { return .mine } return .theirs } fn list_repos(args Args) int { mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) } keys := backend.list('') or { return refuse(args.json(), err.msg()) } mut names := map[string]bool{} for key in keys { parts := key.trim_left('/').split('/') if parts.len > 0 && parts[0] != '' { names[parts[0]] = true } } mut sorted := names.keys() sorted.sort() if args.json() { emit_json(json2.encode(ReposJson{ repos: sorted })) return 0 } if sorted.len == 0 { ok('the remote has no repos yet') return 0 } for name in sorted { println(' ${name}') } return 0 } // clone_repo fetches a whole repo into ./, for a machine that does not // have it yet. fn clone_repo(name string, args Args) int { target := os.join_path(os.getwd(), name) if os.exists(target) { return refuse(args.json(), '${name} already exists here — run steal inside it instead') } mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) } fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) } store := land_clone(target, name, fetched) or { // A folder left behind would make the retry refuse as "already exists". os.rmdir_all(target) or {} return refuse(args.json(), err.msg()) } defer { store.close() } files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) } snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or { return refuse(args.json(), err.msg()) } mut flagged := []events.Flag{} for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } { if flag.flagged { flagged << flag } } flagged.sort(a.save_id < b.save_id) if args.json() { emit_json(json2.encode(StolenJson{ repo: name forward: true saves: fetched.saves.len objects: fetched.repo_objects.len events: fetched.events_out.len head: fetched.head flagged: flagged.map(it.save_id) })) return 0 } ok('fetched "${name}" into ${target}') note('${fetched.saves.len} saves at [${fetched.head}]') warn_flagged(flagged) return 0 } // land_clone creates ./ and commits the fetched history into it. Objects // are checked on the way in, so a damaged remote fails here. fn land_clone(target string, name string, fetched exchange.Fetched) !storage.Store { // SQLite creates the file, not the folder holding it. os.mkdir_all(target, os.MkdirParams{})! mut store := storage.create(target)! fill_clone(store, name, fetched) or { store.rollback() store.close() return err } return store } fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched) ! { store.begin()! exchange.write_fetched(store, fetched)! history.set_head(store, fetched.head)! exchange.remember_repo(store, name)! snapshot.mark_restore(store)! store.commit()! } // warn_flagged prints the line the document asks for, per incoming flagged save. fn warn_flagged(flags []events.Flag) { for flag in flags { reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' } warn('[${flag.save_id}] came in flagged${reason}') } } struct ReposJson { repos []string } struct StolenJson { repo string forward bool saves int objects int events int clashes []string replayed int kept_mine int head string offer_failed bool flagged []string previous_head string }