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 json: args.json()75 approve: approve_sync76 }77 if guided {78 options.guide = guide_steal79 }80 report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }81 if upgraded {82 exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }83 if !args.json() {84 ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')85 }86 }87 88 89 offer_failed := !read_only && !offer_after_steal(store, mut backend)90 if args.json() {91 emit_json(json2.encode(StolenJson{92 repo: report.repo93 forward: report.forward94 saves: report.fetched_saves95 objects: report.fetched_objects96 events: report.fetched_events97 clashes: report.clashes98 replayed: report.replayed99 kept_mine: report.kept_mine100 head: report.head101 offer_failed: offer_failed102 flagged: report.flagged.map(it.save_id)103 previous_head: report.previous_head104 kept_versions: report.kept_versions105 unsafe_paths: report.unsafe_paths106 }))107 return 0108 }109 print_steal(report, guided, read_only, offer_failed)110 return 0111}112113114115fn print_steal(report exchange.StealReport, guided bool, read_only bool, offer_failed bool) {116 if report.fetched_saves == 0 {117 ok('already up to date with "${report.repo}" — head is [${report.head}]')118 return119 }120 if report.forward {121 if guided {122 ok('${report.repo} is up to date · ${fmt.plural(report.fetched_saves, 'new save')}')123 } else {124 ok('brought in ${fmt.plural(report.fetched_saves, 'save')} from "${report.repo}" — head is now [${report.head}]')125 }126 } else {127 ok(if !guided {128 'brought in ${fmt.plural(report.fetched_saves, 'save')} from "${report.repo}"'129 } else if read_only || offer_failed {130 'combined'131 } else {132 'combined · offered to ${report.repo}'133 })134 if read_only {135 note('your saves stay on this machine (read-only source)')136 } else if !guided {137 note("replayed ${report.replayed} of this machine's saves onto the remote head")138 }139 if guided {140 note_choices(report)141 } else if report.kept_mine > 0 {142 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')143 }144 if guided {145 note('to undo all of it: koh load ${report.previous_head}')146 } else {147 note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')148 }149 note_kept_versions(report)150 }151 warn_flagged(report.flagged)152 warn_unsafe_paths(report.unsafe_paths)153 if report.clashes.len > 0 {154 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')155 }156 if offer_failed {157 warn('the remote was not updated — run "koh offer" when it is reachable again')158 }159}160161162fn on_terminal(json bool) bool {163 return !json && interactive() && os.is_atty(1) != 0164}165166167168fn note_kept_versions(report exchange.StealReport) {169 170 for kept in report.kept_versions {171 path := kept.all_before_last(':')172 save := kept.all_after_last(':')173 note('your ${path} is in ${save} · koh peek ${save}')174 }175}176177178fn note_choices(report exchange.StealReport) {179 theirs := report.kept_versions.map(it.all_before_last(':'))180 mine := report.clashes.filter(it !in theirs)181 mut parts := []string{}182 if mine.len > 0 {183 parts << 'kept mine: ${mine.join(' · ')}'184 }185 if theirs.len > 0 {186 parts << 'took theirs: ${theirs.join(' · ')}'187 }188 if parts.len > 0 {189 note(parts.join(' · '))190 }191}192193194195fn ready_to_steal() bool {196 store := open_store() or { return true }197 head := history.head(store)198 dirty := exchange.dirty(store, head) or {199 store.close()200 return true201 }202 if !dirty {203 store.close()204 return true205 }206 paths := unsaved_paths(store, head) or { []string{} }207 name := os.base(store.root)208 store.close()209 println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')210 println('')211 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')212 if paths.len > 0 {213 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')214 if paths.len > 5 {215 println(' and ${paths.len - 5} more')216 }217 }218 println('')219 println(' [c] checkpoint them and continue')220 println(' [s] save them with a message and continue')221 println(' [q] stop here')222 print(' › ')223 match os.get_line().trim_space() {224 'c' { return cmd_save(['before steal'], true) == 0 }225 's' {226 print(' message: ')227 message := os.get_line().trim_space()228 if message == '' {229 println(' no message — nothing was saved')230 return false231 }232 return cmd_save([message], false) == 0233 }234 else { return false }235 }236}237238fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {239 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)240 before := if save := head {241 snapshot.index_files(history.files_of(store, save.id)!)242 } else {243 map[string]snapshot.FileEntry{}244 }245 mut paths := []string{}246 for path, file in now {247 old := before[path] or { snapshot.FileEntry{} }248 if file.hash != old.hash || file.executable != old.executable {249 paths << path250 }251 }252 for path, _ in before {253 if path !in now {254 paths << path255 }256 }257 paths.sort()258 return paths259}260261// guide_steal is the terminal walk-through steal calls as `guide`: overview, one prompt per clash (`b`262// steps back), then the summary and confirm. It returns each clash's side; quitting or declining cancels263264fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {265 done_working()266 _, columns := view.terminal_size()267 width := if columns > 0 && columns < 80 { columns } else { 80 }268 println('')269 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')270 println('')271 println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}',272 width))273 println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}',274 width))275 println('')276 println(' ${fmt.plural(preview.changed, 'file')} changed')277 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')278 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')279 println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {280 'needs'281 } else {282 'need'283 }} you')284 mut answers := map[string]exchange.Side{}285 if preview.all_side != '' {286 for clash in preview.clashes {287 answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }288 }289 }290 mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }291 for at < preview.clashes.len {292 clash := preview.clashes[at]293 println('')294 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')295 println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',296 width))297 println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',298 width))299 println('')300 println(' [m] keep mine [t] take theirs [d] see both changes')301 println(' [b] back [?] help')302 print(' › ')303 match os.get_line().trim_space() {304 'm' {305 answers[clash.path] = .mine306 at++307 }308 't' {309 answers[clash.path] = .theirs310 at++311 }312 'b' {313 if at > 0 { at-- }314 }315 'd' { show_clash(clash) }316 '?' {317 println(' mine keeps your version; theirs takes the remote version.')318 println(' Nothing is lost either way; both versions stay in history.')319 }320 'q' { return error('steal cancelled') }321 else {}322 }323 }324 println('')325 println(' ${fmt.gold('ready to combine')}')326 for clash in preview.clashes {327 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }328 println(' ${choice} ${clash.path}')329 }330 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')331 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')332 if !confirm(preview.yes)! {333 return error('steal cancelled')334 }335 return answers336}337338fn messages(items []string) string {339 return items.map('"${it}"').join(' · ')340}341342343fn overview_row(text string, width int) string {344 if fmt.visible_width(text) <= width {345 return text346 }347 return fmt.fit(text, width - 4) + ' · …'348}349350fn ago(unix_seconds i64) string {351 return time.unix(unix_seconds).relative()352}353354fn show_clash(clash exchange.Clash) {355 if clash.mine_patch == '' && clash.theirs_patch == '' {356 println(' binary file')357 return358 }359 mut lines := ['your changes']360 lines << clash.mine_patch.trim_right('\n').split_into_lines()361 lines << ''362 lines << 'remote changes'363 lines << clash.theirs_patch.trim_right('\n').split_into_lines()364 view.page_text(lines, clash.path)365}366367368369fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {370 if _ := exchange.offer(store, mut backend) {371 return true372 }373 return false374}375376377fn list_repos(args Args) int {378 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }379 keys := backend.list('') or { return refuse(args.json(), err.msg()) }380 mut names := map[string]bool{}381 for key in keys {382 383 384 parts := key.trim_left('/').split('/')385 if parts.len == 2 && parts[0] != '' && parts[1] == 'head' {386 names[parts[0]] = true387 }388 }389 mut sorted := names.keys()390 sorted.sort()391 if args.json() {392 emit_json(json2.encode(ReposJson{393 repos: sorted394 }))395 return 0396 }397 if sorted.len == 0 {398 ok('the remote has no repos yet')399 return 0400 }401 for name in sorted {402 println(' ${name}')403 }404 return 0405}406407408409410411412fn web_address(value string) ![]string {413 if !value.contains('/') {414 return []string{}415 }416 repo := value.all_after_last('/')417 host := value.all_before_last('/')418 if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [419 `.`,420 `_`,421 `-`,422 ]) {423 return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')424 }425 return [host, repo]426}427428429430431fn steal_web(host string, repo string, args Args) int {432 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }433 if exchange.signed_in_to(base) {434 return steal_repo(repo, args)435 }436 mut web := remote.new_web(base) or { return refuse(args.json(), err.msg()) }437 web.on_body = fn [repo] (received u64) {438 working('stealing ${repo} · ${fmt.human_bytes(i64(received))} received')439 }440 return steal_whole(repo, args, web, '${base}/${repo}')441}442443444445fn steal_repo(name string, args Args) int {446 447 backend := Watched{448 label: 'stealing ${name}'449 whole: true450 inner: this_machine_backend() or { return refuse(args.json(), err.msg()) }451 }452 return steal_whole(name, args, backend, '')453}454455456457458fn steal_whole(name string, args Args, chosen remote.Backend, source string) int {459 target := os.join_path(os.getwd(), name)460 if os.exists(target) {461 return refuse(args.json(), '${name} already exists here — run steal inside it instead')462 }463 mut backend := chosen464 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }465 store := land_repo(target, name, fetched, source) or {466 os.rmdir_all(target) or {}467 return refuse(args.json(), err.msg())468 }469 470 471 mut finished := false472 defer {473 store.close()474 if !finished {475 os.rmdir_all(target) or {}476 }477 }478 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }479 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {480 return refuse(args.json(), err.msg())481 }482 mut flagged := []events.Flag{}483 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {484 if flag.flagged {485 flagged << flag486 }487 }488 flagged.sort(a.save_id < b.save_id)489 finished = true490 if args.json() {491 emit_json(json2.encode(StolenJson{492 repo: name493 forward: true494 saves: fetched.saves.len495 objects: fetched.repo_objects.len496 events: fetched.events_out.len497 head: fetched.head498 flagged: flagged.map(it.save_id)499 unsafe_paths: fetched.unsafe_paths500 }))501 return 0502 }503 ok('fetched "${name}" into ${target}')504 note('${fmt.plural(fetched.saves.len, 'save')} at [${fetched.head}]')505 if source != '' {506 note('read-only: steal keeps this copy up to date from ${source}; run "koh remote ${exchange.source_host(source)}" to offer from it')507 }508 warn_flagged(flagged)509 warn_unsafe_paths(fetched.unsafe_paths)510 return 0511}512513514515fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {516 517 os.mkdir_all(target, os.MkdirParams{})!518 mut store := storage.create(target)!519 fill_repo(store, name, fetched, source) or {520 store.rollback()521 store.close()522 return err523 }524 return store525}526527fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {528 store.begin()!529 exchange.write_fetched(store, fetched)!530 history.set_head(store, fetched.head)!531 exchange.remember_repo(store, name)!532 if source != '' {533 exchange.remember_source(store, source)!534 }535 snapshot.mark_restore(store)!536 store.end()!537}538539540fn warn_flagged(flags []events.Flag) {541 for flag in flags {542 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }543 warn('[${flag.save_id}] came in flagged${reason}')544 }545}546547fn warn_unsafe_paths(paths []string) {548 for path in paths {549 warn('dropped unsafe path ${path}')550 }551}552553struct ReposJson {554 repos []string555}556557struct StolenJson {558 repo string559 forward bool560 saves int561 objects int562 events int563 clashes []string564 replayed int565 kept_mine int566 head string567 offer_failed bool568 flagged []string569 previous_head string570 kept_versions []string571 unsafe_paths []string572}573574fn approve_sync(saves int, incoming_events int, yes bool, json bool) ! {575 if json && !yes {576 return error('needs --yes with --json')577 }578 if interactive() && !json {579 note('apply ${saves} incoming saves and ${incoming_events} events; working files and stripped history may change')580 }581 if !confirm(yes)! {582 return error('nothing was applied')583 }584}