//! `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 import src.view pub fn cmd_steal(raw []string) int { args := parse('steal', raw, Options{ flags: ['list', 'mine', 'theirs', 'json', 'yes'] }) or { return usage_error(err.msg()) } if args.flag('list') { if args.positional.len > 0 { return usage_error('steal --list takes no arguments') } 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') } if args.positional.len > 1 { return usage_error('steal takes one repo: koh steal myproject, or koh steal kepr.uk/nina') } address := web_address(name) or { return refuse(args.json(), err.msg()) } if address.len > 0 { return clone_web(address[0], address[1], args) } 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') } guided := on_terminal(args.json()) if guided { if !ready_to_steal() { return 1 } } // A stolen repo has its own remote: the address it came from, until this machine signs in to // that Kepr, when the bucket takes over. source := exchange.source_of(store) upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source)) read_only := source != '' && !upgraded mut backend := if read_only { exchange.read_only(source) or { return refuse(args.json(), err.msg()) } } else { this_machine_backend() or { return refuse(args.json(), err.msg()) } } mut options := exchange.StealOptions{ mine: args.flag('mine') theirs: args.flag('theirs') yes: args.flag('yes') } if guided { options.guide = guide_steal } report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) } if upgraded { exchange.forget_source(store) or { return refuse(args.json(), err.msg()) } if !args.json() { ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket') } } // The remote should end up with whatever this machine just decided — unless there is no remote // to write to: a repo still riding its source stays read-only, and quietly so. offer_failed := !read_only && !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: offer_failed flagged: report.flagged.map(it.save_id) previous_head: report.previous_head kept_versions: report.kept_versions })) return 0 } if report.fetched_saves == 0 { ok('already up to date with "${report.repo}" — head is [${report.head}]') return 0 } if report.forward { if guided { ok('${report.repo} is up to date · ${report.fetched_saves} new saves') } else { ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]') } } else { ok(if !guided { 'brought in ${report.fetched_saves} saves from "${report.repo}"' } else if read_only || offer_failed { 'combined' } else { 'combined · offered to ${report.repo}' }) if read_only { note('your saves stay on this machine (read-only source)') } else if !guided { 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 guided { note('to undo all of it: koh load ${report.previous_head}') } else { note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped') } note_kept_versions(report) } warn_flagged(report.flagged) if report.clashes.len > 0 { note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}') } if offer_failed { warn('the remote was not updated — run "koh offer" when it is reachable again') } return 0 } // on_terminal is when a steal asks rather than refuses: a person at both ends, and no --json. fn on_terminal(json bool) bool { return !json && interactive() && os.is_atty(1) != 0 } // note_kept_versions names, for each file answered "theirs", the save that still holds this // machine's version. fn note_kept_versions(report exchange.StealReport) { for kept in report.kept_versions { parts := kept.split_nth(':', 2) note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}') } } fn ready_to_steal() bool { store := open_store() or { return true } head := history.head(store) dirty := exchange.dirty(store, head) or { store.close() return true } if !dirty { store.close() return true } paths := unsaved_paths(store, head) or { []string{} } store.close() println(' ${fmt.gold(fmt.glyph_ok + ' steal')}') println('') println(' you have ${fmt.plural(paths.len, 'unsaved file')}') if paths.len > 0 { println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}') if paths.len > 5 { println(' and ${paths.len - 5} more') } } println('') println(' [c] checkpoint them and continue') println(' [s] save them with a message and continue') println(' [q] stop here') print(' › ') match os.get_line().trim_space() { 'c' { return cmd_save(['before steal'], true) == 0 } 's' { print(' message: ') message := os.get_line().trim_space() return message != '' && cmd_save([message], false) == 0 } else { return false } } } fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string { now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!) before := if save := head { snapshot.index_files(history.files_of(store, save.id)!) } else { map[string]snapshot.FileEntry{} } mut paths := []string{} for path, file in now { old := before[path] or { snapshot.FileEntry{} } if file.hash != old.hash || file.executable != old.executable { paths << path } } for path, _ in before { if path !in now { paths << path } } paths.sort() return paths } fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side { _, columns := view.terminal_size() width := if columns > 0 && columns < 80 { columns } else { 80 } println('') println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base}') println('') println(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}') println(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}') println('') println(' ${fmt.plural(preview.changed, 'file')} changed') println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves') println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides') println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 { 'needs' } else { 'need' }} you') mut answers := map[string]exchange.Side{} if preview.all_side != '' { for clash in preview.clashes { answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs } } } mut at := if preview.all_side == '' { 0 } else { preview.clashes.len } for at < preview.clashes.len { clash := preview.clashes[at] println('') println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──') println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${fmt.stamp(clash.mine_save.created_at)} · ${clash.mine_save.machine}', width)) println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${fmt.stamp(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}', width)) println('') println(' [m] keep mine [t] take theirs [d] see both changes') println(' [b] back [?] help') print(' › ') match os.get_line().trim_space() { 'm' { answers[clash.path] = .mine at++ } 't' { answers[clash.path] = .theirs at++ } 'b' { if at > 0 { at-- } } 'd' { show_clash(clash) } '?' { println(' mine keeps your version; theirs takes the remote version.') println(' Nothing is lost either way; both versions stay in history.') } 'q' { return error('steal cancelled') } else {} } } println('') println(' ${fmt.gold('ready to combine')}') for clash in preview.clashes { choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' } println(' ${choice} ${clash.path}') } println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote') println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}') if !confirm(preview.yes)! { return error('steal cancelled') } return answers } fn messages(items []string) string { return fmt.fit(items.map('"${it}"').join(' · '), 52) } fn show_clash(clash exchange.Clash) { if clash.mine_patch == '' && clash.theirs_patch == '' { println(' binary file') return } mut lines := ['your changes'] lines << clash.mine_patch.trim_right('\n').split_into_lines() lines << '' lines << 'remote changes' lines << clash.theirs_patch.trim_right('\n').split_into_lines() view.page_text(lines, clash.path) } // 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 } 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 } // web_address splits a `/` argument out of a steal name: a slash makes it an address // (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An // addressed value that is missing either half is an error, never a name. fn web_address(value string) ![]string { if !value.contains('/') { return []string{} } repo := value.all_after_last('/') host := value.all_before_last('/') if repo == '' || host == '' { return error('"${value}" is not /') } return [host, repo] } // clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address // names this machine's own bucket instead, so the bucket's copy — private or not — is the one to // take. fn clone_web(host string, repo string, args Args) int { base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) } if exchange.signed_in_to(base) { return clone_repo(repo, args) } mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) } return clone_with(repo, args, mut backend, '${base}/${repo}') } // clone_repo fetches a whole repo into ./, for a machine that does not // have it yet. fn clone_repo(name string, args Args) int { mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) } return clone_with(name, args, mut backend, '') } // clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's // files onto the disk. A non-empty `source` means the repo came from a read-only address and stays // local until this machine signs in there. fn clone_with(name string, args Args, mut backend remote.Backend, source string) 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') } fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) } store := land_clone(target, name, fetched, source) or { os.rmdir_all(target) or {} return refuse(args.json(), err.msg()) } // Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it // as "already exists", so the whole clone goes. mut finished := false defer { store.close() if !finished { os.rmdir_all(target) or {} } } 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) finished = true 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}]') if source != '' { note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours') } 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, source string) !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, source) or { store.rollback() store.close() return err } return store } fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! { store.begin()! exchange.write_fetched(store, fetched)! history.set_head(store, fetched.head)! exchange.remember_repo(store, name)! if source != '' { exchange.remember_source(store, source)! } 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 kept_versions []string }