1234567module cli89import json210import os11import src.events12import src.fmt13import src.history14import src.remote15import src.snapshot16import src.storage17import src.exchange18import src.view19import time2021pub fn cmd_steal(raw []string) int {22 args := parse('steal', raw, Options{23 flags: ['list', 'mine', 'theirs', 'json', 'yes']24 }) or { return usage_error(err.msg()) }25 if args.flag('list') {26 if args.positional.len > 0 {27 return usage_error('steal --list takes no arguments')28 }29 return list_repos(args)30 }31 if storage.find_root(os.getwd()) == none {32 name := args.positional_at(0)33 if name == '' {34 return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')35 }36 if args.positional.len > 1 {37 return usage_error('steal takes one repo: koh steal myproject, or koh steal kepr.uk/nina')38 }39 address := web_address(name) or { return refuse(args.json(), err.msg()) }40 if address.len > 0 {41 return steal_web(address[0], address[1], args)42 }43 return steal_repo(name, args)44 }45 store := open_store() or { return refuse(args.json(), err.msg()) }46 defer {47 store.close()48 }49 if args.positional.len > 0 {50 return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')51 }52 guided := on_terminal(args.json())53 if guided {54 if !ready_to_steal() {55 return 156 }57 }58 59 60 source := exchange.source_of(store)61 upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))62 read_only := source != '' && !upgraded63 mut backend := if read_only {64 remote.Backend(watch('stealing', exchange.read_only(source) or {65 return refuse(args.json(), err.msg())66 }))67 } else {68 watched_backend('stealing') or { return refuse(args.json(), err.msg()) }69 }70 mut options := exchange.StealOptions{71 mine: args.flag('mine')72 theirs: args.flag('theirs')73 yes: args.flag('yes')74 }75 if guided {76 options.guide = guide_steal77 }78 report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }79 if upgraded {80 exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }81 if !args.json() {82 ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')83 }84 }85 86 87 offer_failed := !read_only && !offer_after_steal(store, mut backend)88 if args.json() {89 emit_json(json2.encode(StolenJson{90 repo: report.repo91 forward: report.forward92 saves: report.fetched_saves93 objects: report.fetched_objects94 events: report.fetched_events95 clashes: report.clashes96 replayed: report.replayed97 kept_mine: report.kept_mine98 head: report.head99 offer_failed: offer_failed100 flagged: report.flagged.map(it.save_id)101 previous_head: report.previous_head102 kept_versions: report.kept_versions103 unsafe_paths: report.unsafe_paths104 }))105 return 0106 }107 print_steal(report, guided, read_only, offer_failed)108 return 0109}110111112113fn print_steal(report exchange.StealReport, guided bool, read_only bool, offer_failed bool) {114 if report.fetched_saves == 0 {115 ok('already up to date with "${report.repo}" — head is [${report.head}]')116 return117 }118 if report.forward {119 if guided {120 ok('${report.repo} is up to date · ${report.fetched_saves} new saves')121 } else {122 ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')123 }124 } else {125 ok(if !guided {126 'brought in ${report.fetched_saves} saves from "${report.repo}"'127 } else if read_only || offer_failed {128 'combined'129 } else {130 'combined · offered to ${report.repo}'131 })132 if read_only {133 note('your saves stay on this machine (read-only source)')134 } else if !guided {135 note("replayed ${report.replayed} of this machine's saves onto the remote head")136 }137 if guided {138 note_choices(report)139 } else if report.kept_mine > 0 {140 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')141 }142 if guided {143 note('to undo all of it: koh load ${report.previous_head}')144 } else {145 note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')146 }147 note_kept_versions(report)148 }149 warn_flagged(report.flagged)150 warn_unsafe_paths(report.unsafe_paths)151 if report.clashes.len > 0 {152 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')153 }154 if offer_failed {155 warn('the remote was not updated — run "koh offer" when it is reachable again')156 }157}158159160fn on_terminal(json bool) bool {161 return !json && interactive() && os.is_atty(1) != 0162}163164165166fn note_kept_versions(report exchange.StealReport) {167 168 for kept in report.kept_versions {169 path := kept.all_before_last(':')170 save := kept.all_after_last(':')171 note('your ${path} is in ${save} · koh peek ${save}')172 }173}174175176fn note_choices(report exchange.StealReport) {177 theirs := report.kept_versions.map(it.all_before_last(':'))178 mine := report.clashes.filter(it !in theirs)179 mut parts := []string{}180 if mine.len > 0 {181 parts << 'kept mine: ${mine.join(' · ')}'182 }183 if theirs.len > 0 {184 parts << 'took theirs: ${theirs.join(' · ')}'185 }186 if parts.len > 0 {187 note(parts.join(' · '))188 }189}190191fn ready_to_steal() bool {192 store := open_store() or { return true }193 head := history.head(store)194 dirty := exchange.dirty(store, head) or {195 store.close()196 return true197 }198 if !dirty {199 store.close()200 return true201 }202 paths := unsaved_paths(store, head) or { []string{} }203 name := os.base(store.root)204 store.close()205 println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')206 println('')207 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')208 if paths.len > 0 {209 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')210 if paths.len > 5 {211 println(' and ${paths.len - 5} more')212 }213 }214 println('')215 println(' [c] checkpoint them and continue')216 println(' [s] save them with a message and continue')217 println(' [q] stop here')218 print(' › ')219 match os.get_line().trim_space() {220 'c' { return cmd_save(['before steal'], true) == 0 }221 's' {222 print(' message: ')223 message := os.get_line().trim_space()224 if message == '' {225 println(' no message — nothing was saved')226 return false227 }228 return cmd_save([message], false) == 0229 }230 else { return false }231 }232}233234fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {235 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)236 before := if save := head {237 snapshot.index_files(history.files_of(store, save.id)!)238 } else {239 map[string]snapshot.FileEntry{}240 }241 mut paths := []string{}242 for path, file in now {243 old := before[path] or { snapshot.FileEntry{} }244 if file.hash != old.hash || file.executable != old.executable {245 paths << path246 }247 }248 for path, _ in before {249 if path !in now {250 paths << path251 }252 }253 paths.sort()254 return paths255}256257fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {258 done_working()259 _, columns := view.terminal_size()260 width := if columns > 0 && columns < 80 { columns } else { 80 }261 println('')262 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')263 println('')264 println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}',265 width))266 println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}',267 width))268 println('')269 println(' ${fmt.plural(preview.changed, 'file')} changed')270 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')271 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')272 println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {273 'needs'274 } else {275 'need'276 }} you')277 mut answers := map[string]exchange.Side{}278 if preview.all_side != '' {279 for clash in preview.clashes {280 answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }281 }282 }283 mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }284 for at < preview.clashes.len {285 clash := preview.clashes[at]286 println('')287 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')288 println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',289 width))290 println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',291 width))292 println('')293 println(' [m] keep mine [t] take theirs [d] see both changes')294 println(' [b] back [?] help')295 print(' › ')296 match os.get_line().trim_space() {297 'm' {298 answers[clash.path] = .mine299 at++300 }301 't' {302 answers[clash.path] = .theirs303 at++304 }305 'b' {306 if at > 0 { at-- }307 }308 'd' { show_clash(clash) }309 '?' {310 println(' mine keeps your version; theirs takes the remote version.')311 println(' Nothing is lost either way; both versions stay in history.')312 }313 'q' { return error('steal cancelled') }314 else {}315 }316 }317 println('')318 println(' ${fmt.gold('ready to combine')}')319 for clash in preview.clashes {320 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }321 println(' ${choice} ${clash.path}')322 }323 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')324 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')325 if !confirm(preview.yes)! {326 return error('steal cancelled')327 }328 return answers329}330331fn messages(items []string) string {332 return items.map('"${it}"').join(' · ')333}334335// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.336fn overview_row(text string, width int) string {337 if fmt.visible_width(text) <= width {338 return text339 }340 return fmt.fit(text, width - 4) + ' · …'341}342343fn ago(unix_seconds i64) string {344 return time.unix(unix_seconds).relative()345}346347fn show_clash(clash exchange.Clash) {348 if clash.mine_patch == '' && clash.theirs_patch == '' {349 println(' binary file')350 return351 }352 mut lines := ['your changes']353 lines << clash.mine_patch.trim_right('\n').split_into_lines()354 lines << ''355 lines << 'remote changes'356 lines << clash.theirs_patch.trim_right('\n').split_into_lines()357 view.page_text(lines, clash.path)358}359360// offer_after_steal sends what this machine just decided. A failure here does361// not undo the steal; it is reported.362fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {363 if _ := exchange.offer(store, mut backend) {364 return true365 }366 return false367}368369fn list_repos(args Args) int {370 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }371 keys := backend.list('') or { return refuse(args.json(), err.msg()) }372 mut names := map[string]bool{}373 for key in keys {374 parts := key.trim_left('/').split('/')375 if parts.len > 0 && parts[0] != '' {376 names[parts[0]] = true377 }378 }379 mut sorted := names.keys()380 sorted.sort()381 if args.json() {382 emit_json(json2.encode(ReposJson{383 repos: sorted384 }))385 return 0386 }387 if sorted.len == 0 {388 ok('the remote has no repos yet')389 return 0390 }391 for name in sorted {392 println(' ${name}')393 }394 return 0395}396397// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address398// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An399400401402fn web_address(value string) ![]string {403 if !value.contains('/') {404 return []string{}405 }406 repo := value.all_after_last('/')407 host := value.all_before_last('/')408 if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [409 `.`,410 `_`,411 `-`,412 ]) {413 return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')414 }415 return [host, repo]416}417418419420421fn steal_web(host string, repo string, args Args) int {422 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }423 if exchange.signed_in_to(base) {424 return steal_repo(repo, args)425 }426 mut backend := remote.Backend(watch('stealing ${repo}', exchange.read_only('${base}/${repo}') or {427 return refuse(args.json(), err.msg())428 }))429 return steal_whole(repo, args, mut backend, '${base}/${repo}')430}431432433434fn steal_repo(name string, args Args) int {435 mut backend := watched_backend('stealing ${name}') or { return refuse(args.json(), err.msg()) }436 return steal_whole(name, args, mut backend, '')437}438439440441442fn steal_whole(name string, args Args, mut backend remote.Backend, source string) int {443 target := os.join_path(os.getwd(), name)444 if os.exists(target) {445 return refuse(args.json(), '${name} already exists here — run steal inside it instead')446 }447 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }448 store := land_repo(target, name, fetched, source) or {449 os.rmdir_all(target) or {}450 return refuse(args.json(), err.msg())451 }452 453 454 mut finished := false455 defer {456 store.close()457 if !finished {458 os.rmdir_all(target) or {}459 }460 }461 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }462 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {463 return refuse(args.json(), err.msg())464 }465 mut flagged := []events.Flag{}466 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {467 if flag.flagged {468 flagged << flag469 }470 }471 flagged.sort(a.save_id < b.save_id)472 finished = true473 if args.json() {474 emit_json(json2.encode(StolenJson{475 repo: name476 forward: true477 saves: fetched.saves.len478 objects: fetched.repo_objects.len479 events: fetched.events_out.len480 head: fetched.head481 flagged: flagged.map(it.save_id)482 unsafe_paths: fetched.unsafe_paths483 }))484 return 0485 }486 ok('fetched "${name}" into ${target}')487 note('${fetched.saves.len} saves at [${fetched.head}]')488 if source != '' {489 note('read-only: this copy keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')490 }491 warn_flagged(flagged)492 warn_unsafe_paths(fetched.unsafe_paths)493 return 0494}495496497498fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {499 500 os.mkdir_all(target, os.MkdirParams{})!501 mut store := storage.create(target)!502 fill_repo(store, name, fetched, source) or {503 store.rollback()504 store.close()505 return err506 }507 return store508}509510fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {511 store.begin()!512 exchange.write_fetched(store, fetched)!513 history.set_head(store, fetched.head)!514 exchange.remember_repo(store, name)!515 if source != '' {516 exchange.remember_source(store, source)!517 }518 snapshot.mark_restore(store)!519 store.end()!520}521522523fn warn_flagged(flags []events.Flag) {524 for flag in flags {525 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }526 warn('[${flag.save_id}] came in flagged${reason}')527 }528}529530fn warn_unsafe_paths(paths []string) {531 for path in paths {532 warn('dropped unsafe path ${path}')533 }534}535536struct ReposJson {537 repos []string538}539540struct StolenJson {541 repo string542 forward bool543 saves int544 objects int545 events int546 clashes []string547 replayed int548 kept_mine int549 head string550 offer_failed bool551 flagged []string552 previous_head string553 kept_versions []string554 unsafe_paths []string555}