//! `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 json import os import src.fmt import src.history import src.remote import src.snapshot import src.storage import src.sync 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 := sync.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 := sync.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(json.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 })) 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')}') } } 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 _ := sync.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 sync.Clash) sync.Side { println(' ${amber('both changed')} ${clash.path} ${clash.stat}') print(' ${amber('m')}ine or ${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(json.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 := sync.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) } // SQLite creates the file, not the folder holding it. os.mkdir_all(target, os.MkdirParams{}) or { return refuse(args.json(), err.msg()) } mut store := storage.create(target) or { return refuse(args.json(), err.msg()) } defer { store.close() } store.begin() or { return refuse(args.json(), err.msg()) } sync.write_fetched(store, fetched) or { store.rollback() return refuse(args.json(), err.msg()) } history.set_head(store, fetched.head) or { store.rollback() return refuse(args.json(), err.msg()) } store.commit() or { return refuse(args.json(), err.msg()) } 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()) } if args.json() { emit_json(json.encode(StolenJson{ repo: name forward: true saves: fetched.saves.len objects: fetched.repo_objects.len events: fetched.events_out.len head: fetched.head })) return 0 } ok('fetched "${name}" into ${target}') note('${fetched.saves.len} saves at [${fetched.head}]') return 0 } 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 }