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 clone_web(address[0], address[1], args)42 }43 return clone_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 exchange.read_only(source) or { return refuse(args.json(), err.msg()) }65 } else {66 this_machine_backend() or { return refuse(args.json(), err.msg()) }67 }68 mut options := exchange.StealOptions{69 mine: args.flag('mine')70 theirs: args.flag('theirs')71 yes: args.flag('yes')72 }73 if guided {74 options.guide = guide_steal75 }76 report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }77 if upgraded {78 exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }79 if !args.json() {80 ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')81 }82 }83 84 85 offer_failed := !read_only && !offer_after_steal(store, mut backend)86 if args.json() {87 emit_json(json2.encode(StolenJson{88 repo: report.repo89 forward: report.forward90 saves: report.fetched_saves91 objects: report.fetched_objects92 events: report.fetched_events93 clashes: report.clashes94 replayed: report.replayed95 kept_mine: report.kept_mine96 head: report.head97 offer_failed: offer_failed98 flagged: report.flagged.map(it.save_id)99 previous_head: report.previous_head100 kept_versions: report.kept_versions101 }))102 return 0103 }104 if report.fetched_saves == 0 {105 ok('already up to date with "${report.repo}" — head is [${report.head}]')106 return 0107 }108 if report.forward {109 if guided {110 ok('${report.repo} is up to date · ${report.fetched_saves} new saves')111 } else {112 ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')113 }114 } else {115 ok(if !guided {116 'brought in ${report.fetched_saves} saves from "${report.repo}"'117 } else if read_only || offer_failed {118 'combined'119 } else {120 'combined · offered to ${report.repo}'121 })122 if read_only {123 note('your saves stay on this machine (read-only source)')124 } else if !guided {125 note("replayed ${report.replayed} of this machine's saves onto the remote head")126 }127 if guided {128 note_choices(report)129 } else if report.kept_mine > 0 {130 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')131 }132 if guided {133 note('to undo all of it: koh load ${report.previous_head}')134 } else {135 note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')136 }137 note_kept_versions(report)138 }139 warn_flagged(report.flagged)140 if report.clashes.len > 0 {141 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')142 }143 if offer_failed {144 warn('the remote was not updated — run "koh offer" when it is reachable again')145 }146 return 0147}148149150fn on_terminal(json bool) bool {151 return !json && interactive() && os.is_atty(1) != 0152}153154155156fn note_kept_versions(report exchange.StealReport) {157 158 for kept in report.kept_versions {159 path := kept.all_before_last(':')160 save := kept.all_after_last(':')161 note('your ${path} is in ${save} · koh peek ${save}')162 }163}164165166fn note_choices(report exchange.StealReport) {167 theirs := report.kept_versions.map(it.all_before_last(':'))168 mine := report.clashes.filter(it !in theirs)169 mut parts := []string{}170 if mine.len > 0 {171 parts << 'kept mine: ${mine.join(' · ')}'172 }173 if theirs.len > 0 {174 parts << 'took theirs: ${theirs.join(' · ')}'175 }176 if parts.len > 0 {177 note(parts.join(' · '))178 }179}180181fn ready_to_steal() bool {182 store := open_store() or { return true }183 head := history.head(store)184 dirty := exchange.dirty(store, head) or {185 store.close()186 return true187 }188 if !dirty {189 store.close()190 return true191 }192 paths := unsaved_paths(store, head) or { []string{} }193 name := os.base(store.root)194 store.close()195 println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')196 println('')197 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')198 if paths.len > 0 {199 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')200 if paths.len > 5 {201 println(' and ${paths.len - 5} more')202 }203 }204 println('')205 println(' [c] checkpoint them and continue')206 println(' [s] save them with a message and continue')207 println(' [q] stop here')208 print(' › ')209 match os.get_line().trim_space() {210 'c' { return cmd_save(['before steal'], true) == 0 }211 's' {212 print(' message: ')213 message := os.get_line().trim_space()214 if message == '' {215 println(' no message — nothing was saved')216 return false217 }218 return cmd_save([message], false) == 0219 }220 else { return false }221 }222}223224fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {225 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)226 before := if save := head {227 snapshot.index_files(history.files_of(store, save.id)!)228 } else {229 map[string]snapshot.FileEntry{}230 }231 mut paths := []string{}232 for path, file in now {233 old := before[path] or { snapshot.FileEntry{} }234 if file.hash != old.hash || file.executable != old.executable {235 paths << path236 }237 }238 for path, _ in before {239 if path !in now {240 paths << path241 }242 }243 paths.sort()244 return paths245}246247fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {248 _, columns := view.terminal_size()249 width := if columns > 0 && columns < 80 { columns } else { 80 }250 println('')251 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')252 println('')253 println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}',254 width))255 println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}',256 width))257 println('')258 println(' ${fmt.plural(preview.changed, 'file')} changed')259 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')260 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')261 println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {262 'needs'263 } else {264 'need'265 }} you')266 mut answers := map[string]exchange.Side{}267 if preview.all_side != '' {268 for clash in preview.clashes {269 answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }270 }271 }272 mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }273 for at < preview.clashes.len {274 clash := preview.clashes[at]275 println('')276 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')277 println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',278 width))279 println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',280 width))281 println('')282 println(' [m] keep mine [t] take theirs [d] see both changes')283 println(' [b] back [?] help')284 print(' › ')285 match os.get_line().trim_space() {286 'm' {287 answers[clash.path] = .mine288 at++289 }290 't' {291 answers[clash.path] = .theirs292 at++293 }294 'b' {295 if at > 0 { at-- }296 }297 'd' { show_clash(clash) }298 '?' {299 println(' mine keeps your version; theirs takes the remote version.')300 println(' Nothing is lost either way; both versions stay in history.')301 }302 'q' { return error('steal cancelled') }303 else {}304 }305 }306 println('')307 println(' ${fmt.gold('ready to combine')}')308 for clash in preview.clashes {309 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }310 println(' ${choice} ${clash.path}')311 }312 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')313 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')314 if !confirm(preview.yes)! {315 return error('steal cancelled')316 }317 return answers318}319320fn messages(items []string) string {321 return items.map('"${it}"').join(' · ')322}323324// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.325fn overview_row(text string, width int) string {326 if fmt.visible_width(text) <= width {327 return text328 }329 return fmt.fit(text, width - 4) + ' · …'330}331332fn ago(unix_seconds i64) string {333 return time.unix(unix_seconds).relative()334}335336fn show_clash(clash exchange.Clash) {337 if clash.mine_patch == '' && clash.theirs_patch == '' {338 println(' binary file')339 return340 }341 mut lines := ['your changes']342 lines << clash.mine_patch.trim_right('\n').split_into_lines()343 lines << ''344 lines << 'remote changes'345 lines << clash.theirs_patch.trim_right('\n').split_into_lines()346 view.page_text(lines, clash.path)347}348349// offer_after_steal pushes what this machine just decided. A failure here does350// not undo the steal; it is reported.351fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {352 if _ := exchange.offer(store, mut backend) {353 return true354 }355 return false356}357358fn list_repos(args Args) int {359 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }360 keys := backend.list('') or { return refuse(args.json(), err.msg()) }361 mut names := map[string]bool{}362 for key in keys {363 parts := key.trim_left('/').split('/')364 if parts.len > 0 && parts[0] != '' {365 names[parts[0]] = true366 }367 }368 mut sorted := names.keys()369 sorted.sort()370 if args.json() {371 emit_json(json2.encode(ReposJson{372 repos: sorted373 }))374 return 0375 }376 if sorted.len == 0 {377 ok('the remote has no repos yet')378 return 0379 }380 for name in sorted {381 println(' ${name}')382 }383 return 0384}385386// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address387// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An388389fn web_address(value string) ![]string {390 if !value.contains('/') {391 return []string{}392 }393 repo := value.all_after_last('/')394 host := value.all_before_last('/')395 if repo == '' || host == '' {396 return error('"${value}" is not <host>/<repo>')397 }398 return [host, repo]399}400401402403404fn clone_web(host string, repo string, args Args) int {405 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }406 if exchange.signed_in_to(base) {407 return clone_repo(repo, args)408 }409 mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }410 return clone_with(repo, args, mut backend, '${base}/${repo}')411}412413414415fn clone_repo(name string, args Args) int {416 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }417 return clone_with(name, args, mut backend, '')418}419420421422423fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {424 target := os.join_path(os.getwd(), name)425 if os.exists(target) {426 return refuse(args.json(), '${name} already exists here — run steal inside it instead')427 }428 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }429 store := land_clone(target, name, fetched, source) or {430 os.rmdir_all(target) or {}431 return refuse(args.json(), err.msg())432 }433 434 435 mut finished := false436 defer {437 store.close()438 if !finished {439 os.rmdir_all(target) or {}440 }441 }442 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }443 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {444 return refuse(args.json(), err.msg())445 }446 mut flagged := []events.Flag{}447 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {448 if flag.flagged {449 flagged << flag450 }451 }452 flagged.sort(a.save_id < b.save_id)453 finished = true454 if args.json() {455 emit_json(json2.encode(StolenJson{456 repo: name457 forward: true458 saves: fetched.saves.len459 objects: fetched.repo_objects.len460 events: fetched.events_out.len461 head: fetched.head462 flagged: flagged.map(it.save_id)463 }))464 return 0465 }466 ok('fetched "${name}" into ${target}')467 note('${fetched.saves.len} saves at [${fetched.head}]')468 if source != '' {469 note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')470 }471 warn_flagged(flagged)472 return 0473}474475476477fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {478 479 os.mkdir_all(target, os.MkdirParams{})!480 mut store := storage.create(target)!481 fill_clone(store, name, fetched, source) or {482 store.rollback()483 store.close()484 return err485 }486 return store487}488489fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {490 store.begin()!491 exchange.write_fetched(store, fetched)!492 history.set_head(store, fetched.head)!493 exchange.remember_repo(store, name)!494 if source != '' {495 exchange.remember_source(store, source)!496 }497 snapshot.mark_restore(store)!498 store.commit()!499}500501502fn warn_flagged(flags []events.Flag) {503 for flag in flags {504 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }505 warn('[${flag.save_id}] came in flagged${reason}')506 }507}508509struct ReposJson {510 repos []string511}512513struct StolenJson {514 repo string515 forward bool516 saves int517 objects int518 events int519 clashes []string520 replayed int521 kept_mine int522 head string523 offer_failed bool524 flagged []string525 previous_head string526 kept_versions []string527}