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 · ${fmt.plural(report.fetched_saves, 'new save')}')121 } else {122 ok('brought in ${fmt.plural(report.fetched_saves, 'save')} from "${report.repo}" — head is now [${report.head}]')123 }124 } else {125 ok(if !guided {126 'brought in ${fmt.plural(report.fetched_saves, 'save')} 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}190191192193fn ready_to_steal() bool {194 store := open_store() or { return true }195 head := history.head(store)196 dirty := exchange.dirty(store, head) or {197 store.close()198 return true199 }200 if !dirty {201 store.close()202 return true203 }204 paths := unsaved_paths(store, head) or { []string{} }205 name := os.base(store.root)206 store.close()207 println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')208 println('')209 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')210 if paths.len > 0 {211 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')212 if paths.len > 5 {213 println(' and ${paths.len - 5} more')214 }215 }216 println('')217 println(' [c] checkpoint them and continue')218 println(' [s] save them with a message and continue')219 println(' [q] stop here')220 print(' › ')221 match os.get_line().trim_space() {222 'c' { return cmd_save(['before steal'], true) == 0 }223 's' {224 print(' message: ')225 message := os.get_line().trim_space()226 if message == '' {227 println(' no message — nothing was saved')228 return false229 }230 return cmd_save([message], false) == 0231 }232 else { return false }233 }234}235236fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {237 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)238 before := if save := head {239 snapshot.index_files(history.files_of(store, save.id)!)240 } else {241 map[string]snapshot.FileEntry{}242 }243 mut paths := []string{}244 for path, file in now {245 old := before[path] or { snapshot.FileEntry{} }246 if file.hash != old.hash || file.executable != old.executable {247 paths << path248 }249 }250 for path, _ in before {251 if path !in now {252 paths << path253 }254 }255 paths.sort()256 return paths257}258259// guide_steal is the terminal walk-through steal calls as `guide`: overview, one prompt per clash (`b`260// steps back), then the summary and confirm. It returns each clash's side; quitting or declining cancels261262fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {263 done_working()264 _, columns := view.terminal_size()265 width := if columns > 0 && columns < 80 { columns } else { 80 }266 println('')267 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')268 println('')269 println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}',270 width))271 println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}',272 width))273 println('')274 println(' ${fmt.plural(preview.changed, 'file')} changed')275 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')276 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')277 println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {278 'needs'279 } else {280 'need'281 }} you')282 mut answers := map[string]exchange.Side{}283 if preview.all_side != '' {284 for clash in preview.clashes {285 answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }286 }287 }288 mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }289 for at < preview.clashes.len {290 clash := preview.clashes[at]291 println('')292 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')293 println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',294 width))295 println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',296 width))297 println('')298 println(' [m] keep mine [t] take theirs [d] see both changes')299 println(' [b] back [?] help')300 print(' › ')301 match os.get_line().trim_space() {302 'm' {303 answers[clash.path] = .mine304 at++305 }306 't' {307 answers[clash.path] = .theirs308 at++309 }310 'b' {311 if at > 0 { at-- }312 }313 'd' { show_clash(clash) }314 '?' {315 println(' mine keeps your version; theirs takes the remote version.')316 println(' Nothing is lost either way; both versions stay in history.')317 }318 'q' { return error('steal cancelled') }319 else {}320 }321 }322 println('')323 println(' ${fmt.gold('ready to combine')}')324 for clash in preview.clashes {325 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }326 println(' ${choice} ${clash.path}')327 }328 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')329 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')330 if !confirm(preview.yes)! {331 return error('steal cancelled')332 }333 return answers334}335336fn messages(items []string) string {337 return items.map('"${it}"').join(' · ')338}339340341fn overview_row(text string, width int) string {342 if fmt.visible_width(text) <= width {343 return text344 }345 return fmt.fit(text, width - 4) + ' · …'346}347348fn ago(unix_seconds i64) string {349 return time.unix(unix_seconds).relative()350}351352fn show_clash(clash exchange.Clash) {353 if clash.mine_patch == '' && clash.theirs_patch == '' {354 println(' binary file')355 return356 }357 mut lines := ['your changes']358 lines << clash.mine_patch.trim_right('\n').split_into_lines()359 lines << ''360 lines << 'remote changes'361 lines << clash.theirs_patch.trim_right('\n').split_into_lines()362 view.page_text(lines, clash.path)363}364365366367fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {368 if _ := exchange.offer(store, mut backend) {369 return true370 }371 return false372}373374375fn list_repos(args Args) int {376 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }377 keys := backend.list('') or { return refuse(args.json(), err.msg()) }378 mut names := map[string]bool{}379 for key in keys {380 381 382 parts := key.trim_left('/').split('/')383 if parts.len == 2 && parts[0] != '' && parts[1] == 'head' {384 names[parts[0]] = true385 }386 }387 mut sorted := names.keys()388 sorted.sort()389 if args.json() {390 emit_json(json2.encode(ReposJson{391 repos: sorted392 }))393 return 0394 }395 if sorted.len == 0 {396 ok('the remote has no repos yet')397 return 0398 }399 for name in sorted {400 println(' ${name}')401 }402 return 0403}404405406407408409410fn web_address(value string) ![]string {411 if !value.contains('/') {412 return []string{}413 }414 repo := value.all_after_last('/')415 host := value.all_before_last('/')416 if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [417 `.`,418 `_`,419 `-`,420 ]) {421 return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')422 }423 return [host, repo]424}425426427428429fn steal_web(host string, repo string, args Args) int {430 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }431 if exchange.signed_in_to(base) {432 return steal_repo(repo, args)433 }434 mut backend := remote.Backend(watch('stealing ${repo}', exchange.read_only('${base}/${repo}') or {435 return refuse(args.json(), err.msg())436 }))437 return steal_whole(repo, args, mut backend, '${base}/${repo}')438}439440441442fn steal_repo(name string, args Args) int {443 mut backend := watched_backend('stealing ${name}') or { return refuse(args.json(), err.msg()) }444 return steal_whole(name, args, mut backend, '')445}446447448449450fn steal_whole(name string, args Args, mut backend remote.Backend, source string) int {451 target := os.join_path(os.getwd(), name)452 if os.exists(target) {453 return refuse(args.json(), '${name} already exists here — run steal inside it instead')454 }455 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }456 store := land_repo(target, name, fetched, source) or {457 os.rmdir_all(target) or {}458 return refuse(args.json(), err.msg())459 }460 461 462 mut finished := false463 defer {464 store.close()465 if !finished {466 os.rmdir_all(target) or {}467 }468 }469 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }470 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {471 return refuse(args.json(), err.msg())472 }473 mut flagged := []events.Flag{}474 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {475 if flag.flagged {476 flagged << flag477 }478 }479 flagged.sort(a.save_id < b.save_id)480 finished = true481 if args.json() {482 emit_json(json2.encode(StolenJson{483 repo: name484 forward: true485 saves: fetched.saves.len486 objects: fetched.repo_objects.len487 events: fetched.events_out.len488 head: fetched.head489 flagged: flagged.map(it.save_id)490 unsafe_paths: fetched.unsafe_paths491 }))492 return 0493 }494 ok('fetched "${name}" into ${target}')495 note('${fmt.plural(fetched.saves.len, 'save')} at [${fetched.head}]')496 if source != '' {497 note('read-only: this copy keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')498 }499 warn_flagged(flagged)500 warn_unsafe_paths(fetched.unsafe_paths)501 return 0502}503504505506fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {507 508 os.mkdir_all(target, os.MkdirParams{})!509 mut store := storage.create(target)!510 fill_repo(store, name, fetched, source) or {511 store.rollback()512 store.close()513 return err514 }515 return store516}517518fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {519 store.begin()!520 exchange.write_fetched(store, fetched)!521 history.set_head(store, fetched.head)!522 exchange.remember_repo(store, name)!523 if source != '' {524 exchange.remember_source(store, source)!525 }526 snapshot.mark_restore(store)!527 store.end()!528}529530531fn warn_flagged(flags []events.Flag) {532 for flag in flags {533 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }534 warn('[${flag.save_id}] came in flagged${reason}')535 }536}537538fn warn_unsafe_paths(paths []string) {539 for path in paths {540 warn('dropped unsafe path ${path}')541 }542}543544struct ReposJson {545 repos []string546}547548struct StolenJson {549 repo string550 forward bool551 saves int552 objects int553 events int554 clashes []string555 replayed int556 kept_mine int557 head string558 offer_failed bool559 flagged []string560 previous_head string561 kept_versions []string562 unsafe_paths []string563}