//! `koh steal` — bring another machine's saves in. //! //! Inside a repo this takes 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 import time 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 steal_web(address[0], address[1], args) } return steal_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 { remote.Backend(watch('stealing', exchange.read_only(source) or { return refuse(args.json(), err.msg()) })) } else { watched_backend('stealing') 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 unsafe_paths: report.unsafe_paths })) return 0 } print_steal(report, guided, read_only, offer_failed) return 0 } // print_steal tells the person how a finished steal went: what came in, what the two machines // disagreed on, and how to get back. fn print_steal(report exchange.StealReport, guided bool, read_only bool, offer_failed bool) { if report.fetched_saves == 0 { ok('already up to date with "${report.repo}" — head is [${report.head}]') return } if report.forward { if guided { ok('${report.repo} is up to date · ${fmt.plural(report.fetched_saves, 'new save')}') } else { ok('brought in ${fmt.plural(report.fetched_saves, 'save')} from "${report.repo}" — head is now [${report.head}]') } } else { ok(if !guided { 'brought in ${fmt.plural(report.fetched_saves, 'save')} 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 guided { note_choices(report) } else 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) warn_unsafe_paths(report.unsafe_paths) 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') } } // 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) { // `:`: a save ID has no colon, a path may. for kept in report.kept_versions { path := kept.all_before_last(':') save := kept.all_after_last(':') note('your ${path} is in ${save} · koh peek ${save}') } } // note_choices is the done screen's line of answers: `kept mine: a · took theirs: b`. fn note_choices(report exchange.StealReport) { theirs := report.kept_versions.map(it.all_before_last(':')) mine := report.clashes.filter(it !in theirs) mut parts := []string{} if mine.len > 0 { parts << 'kept mine: ${mine.join(' · ')}' } if theirs.len > 0 { parts << 'took theirs: ${theirs.join(' · ')}' } if parts.len > 0 { note(parts.join(' · ')) } } // ready_to_steal is the guided steal's first step: with unsaved work it offers checkpoint, save or stop, // and answers whether to go on. A tree it cannot read counts as ready. 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{} } name := os.base(store.root) store.close() println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}') 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() if message == '' { println(' no message — nothing was saved') return false } return 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 } // guide_steal is the terminal walk-through steal calls as `guide`: overview, one prompt per clash (`b` // steps back), then the summary and confirm. It returns each clash's side; quitting or declining cancels // with an error, and the line and tree are unchanged. --mine/--theirs answer the clashes, not the summary. fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side { done_working() _, 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.id} · ${ago(preview.base.created_at)}') println('') println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}', width)) println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}', width)) 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} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}', width)) println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(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 items.map('"${it}"').join(' · ') } // overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out. fn overview_row(text string, width int) string { if fmt.visible_width(text) <= width { return text } return fmt.fit(text, width - 4) + ' · …' } fn ago(unix_seconds i64) string { return time.unix(unix_seconds).relative() } 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 sends 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 } // list_repos names the repos on this machine's remote: the first path segment of every key. 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 { // A repo is a folder with a `head`. Anything else at the top of the remote, such as // Kepr's own `.kepr/` folder of uploads, is not one and cannot be stolen. parts := key.trim_left('/').split('/') if parts.len == 2 && parts[0] != '' && parts[1] == 'head' { 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. The repo half becomes a // URL path segment and a folder here, so it must be a name Kepr serves: letters, digits, `.`, `_` // and `-`, and never `.` or `..`. fn web_address(value string) ![]string { if !value.contains('/') { return []string{} } repo := value.all_after_last('/') host := value.all_before_last('/') if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [ `.`, `_`, `-`, ]) { return error('"${value}" is not / — a repo name is letters, digits, ., _ and -') } return [host, repo] } // steal_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 steal_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 steal_repo(repo, args) } mut backend := remote.Backend(watch('stealing ${repo}', exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) })) return steal_whole(repo, args, mut backend, '${base}/${repo}') } // steal_repo fetches a whole repo into ./, for a machine that does not // have it yet. fn steal_repo(name string, args Args) int { mut backend := watched_backend('stealing ${name}') or { return refuse(args.json(), err.msg()) } return steal_whole(name, args, mut backend, '') } // steal_whole 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 steal_whole(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_repo(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 new folder 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) unsafe_paths: fetched.unsafe_paths })) return 0 } ok('fetched "${name}" into ${target}') note('${fmt.plural(fetched.saves.len, 'save')} at [${fetched.head}]') if source != '' { note('read-only: this copy keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours') } warn_flagged(flagged) warn_unsafe_paths(fetched.unsafe_paths) return 0 } // land_repo creates ./ and writes the fetched history into it. Objects // are checked on the way in, so a damaged remote fails here. fn land_repo(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_repo(store, name, fetched, source) or { store.rollback() store.close() return err } return store } fn fill_repo(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.end()! } // 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}') } } fn warn_unsafe_paths(paths []string) { for path in paths { warn('dropped unsafe path ${path}') } } 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 unsafe_paths []string }