//! `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 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 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 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) 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) { // `:`: 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(' · ')) } } 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 } 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.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 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 }