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 if report.fetched_saves == 0 {108 ok('already up to date with "${report.repo}" — head is [${report.head}]')109 return 0110 }111 if report.forward {112 if guided {113 ok('${report.repo} is up to date · ${report.fetched_saves} new saves')114 } else {115 ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')116 }117 } else {118 ok(if !guided {119 'brought in ${report.fetched_saves} saves from "${report.repo}"'120 } else if read_only || offer_failed {121 'combined'122 } else {123 'combined · offered to ${report.repo}'124 })125 if read_only {126 note('your saves stay on this machine (read-only source)')127 } else if !guided {128 note("replayed ${report.replayed} of this machine's saves onto the remote head")129 }130 if guided {131 note_choices(report)132 } else if report.kept_mine > 0 {133 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')134 }135 if guided {136 note('to undo all of it: koh load ${report.previous_head}')137 } else {138 note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')139 }140 note_kept_versions(report)141 }142 warn_flagged(report.flagged)143 warn_unsafe_paths(report.unsafe_paths)144 if report.clashes.len > 0 {145 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')146 }147 if offer_failed {148 warn('the remote was not updated — run "koh offer" when it is reachable again')149 }150 return 0151}152153154fn on_terminal(json bool) bool {155 return !json && interactive() && os.is_atty(1) != 0156}157158159160fn note_kept_versions(report exchange.StealReport) {161 162 for kept in report.kept_versions {163 path := kept.all_before_last(':')164 save := kept.all_after_last(':')165 note('your ${path} is in ${save} · koh peek ${save}')166 }167}168169170fn note_choices(report exchange.StealReport) {171 theirs := report.kept_versions.map(it.all_before_last(':'))172 mine := report.clashes.filter(it !in theirs)173 mut parts := []string{}174 if mine.len > 0 {175 parts << 'kept mine: ${mine.join(' · ')}'176 }177 if theirs.len > 0 {178 parts << 'took theirs: ${theirs.join(' · ')}'179 }180 if parts.len > 0 {181 note(parts.join(' · '))182 }183}184185fn ready_to_steal() bool {186 store := open_store() or { return true }187 head := history.head(store)188 dirty := exchange.dirty(store, head) or {189 store.close()190 return true191 }192 if !dirty {193 store.close()194 return true195 }196 paths := unsaved_paths(store, head) or { []string{} }197 name := os.base(store.root)198 store.close()199 println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')200 println('')201 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')202 if paths.len > 0 {203 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')204 if paths.len > 5 {205 println(' and ${paths.len - 5} more')206 }207 }208 println('')209 println(' [c] checkpoint them and continue')210 println(' [s] save them with a message and continue')211 println(' [q] stop here')212 print(' › ')213 match os.get_line().trim_space() {214 'c' { return cmd_save(['before steal'], true) == 0 }215 's' {216 print(' message: ')217 message := os.get_line().trim_space()218 if message == '' {219 println(' no message — nothing was saved')220 return false221 }222 return cmd_save([message], false) == 0223 }224 else { return false }225 }226}227228fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {229 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)230 before := if save := head {231 snapshot.index_files(history.files_of(store, save.id)!)232 } else {233 map[string]snapshot.FileEntry{}234 }235 mut paths := []string{}236 for path, file in now {237 old := before[path] or { snapshot.FileEntry{} }238 if file.hash != old.hash || file.executable != old.executable {239 paths << path240 }241 }242 for path, _ in before {243 if path !in now {244 paths << path245 }246 }247 paths.sort()248 return paths249}250251fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {252 done_working()253 _, columns := view.terminal_size()254 width := if columns > 0 && columns < 80 { columns } else { 80 }255 println('')256 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')257 println('')258 println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}',259 width))260 println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}',261 width))262 println('')263 println(' ${fmt.plural(preview.changed, 'file')} changed')264 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')265 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')266 println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {267 'needs'268 } else {269 'need'270 }} you')271 mut answers := map[string]exchange.Side{}272 if preview.all_side != '' {273 for clash in preview.clashes {274 answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }275 }276 }277 mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }278 for at < preview.clashes.len {279 clash := preview.clashes[at]280 println('')281 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')282 println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',283 width))284 println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',285 width))286 println('')287 println(' [m] keep mine [t] take theirs [d] see both changes')288 println(' [b] back [?] help')289 print(' › ')290 match os.get_line().trim_space() {291 'm' {292 answers[clash.path] = .mine293 at++294 }295 't' {296 answers[clash.path] = .theirs297 at++298 }299 'b' {300 if at > 0 { at-- }301 }302 'd' { show_clash(clash) }303 '?' {304 println(' mine keeps your version; theirs takes the remote version.')305 println(' Nothing is lost either way; both versions stay in history.')306 }307 'q' { return error('steal cancelled') }308 else {}309 }310 }311 println('')312 println(' ${fmt.gold('ready to combine')}')313 for clash in preview.clashes {314 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }315 println(' ${choice} ${clash.path}')316 }317 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')318 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')319 if !confirm(preview.yes)! {320 return error('steal cancelled')321 }322 return answers323}324325fn messages(items []string) string {326 return items.map('"${it}"').join(' · ')327}328329// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.330fn overview_row(text string, width int) string {331 if fmt.visible_width(text) <= width {332 return text333 }334 return fmt.fit(text, width - 4) + ' · …'335}336337fn ago(unix_seconds i64) string {338 return time.unix(unix_seconds).relative()339}340341fn show_clash(clash exchange.Clash) {342 if clash.mine_patch == '' && clash.theirs_patch == '' {343 println(' binary file')344 return345 }346 mut lines := ['your changes']347 lines << clash.mine_patch.trim_right('\n').split_into_lines()348 lines << ''349 lines << 'remote changes'350 lines << clash.theirs_patch.trim_right('\n').split_into_lines()351 view.page_text(lines, clash.path)352}353354// offer_after_steal sends what this machine just decided. A failure here does355// not undo the steal; it is reported.356fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {357 if _ := exchange.offer(store, mut backend) {358 return true359 }360 return false361}362363fn list_repos(args Args) int {364 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }365 keys := backend.list('') or { return refuse(args.json(), err.msg()) }366 mut names := map[string]bool{}367 for key in keys {368 parts := key.trim_left('/').split('/')369 if parts.len > 0 && parts[0] != '' {370 names[parts[0]] = true371 }372 }373 mut sorted := names.keys()374 sorted.sort()375 if args.json() {376 emit_json(json2.encode(ReposJson{377 repos: sorted378 }))379 return 0380 }381 if sorted.len == 0 {382 ok('the remote has no repos yet')383 return 0384 }385 for name in sorted {386 println(' ${name}')387 }388 return 0389}390391// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address392// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An393394395396fn web_address(value string) ![]string {397 if !value.contains('/') {398 return []string{}399 }400 repo := value.all_after_last('/')401 host := value.all_before_last('/')402 if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [403 `.`,404 `_`,405 `-`,406 ]) {407 return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')408 }409 return [host, repo]410}411412413414415fn steal_web(host string, repo string, args Args) int {416 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }417 if exchange.signed_in_to(base) {418 return steal_repo(repo, args)419 }420 mut backend := remote.Backend(watch('stealing ${repo}', exchange.read_only('${base}/${repo}') or {421 return refuse(args.json(), err.msg())422 }))423 return steal_whole(repo, args, mut backend, '${base}/${repo}')424}425426427428fn steal_repo(name string, args Args) int {429 mut backend := watched_backend('stealing ${name}') or { return refuse(args.json(), err.msg()) }430 return steal_whole(name, args, mut backend, '')431}432433434435436fn steal_whole(name string, args Args, mut backend remote.Backend, source string) int {437 target := os.join_path(os.getwd(), name)438 if os.exists(target) {439 return refuse(args.json(), '${name} already exists here — run steal inside it instead')440 }441 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }442 store := land_repo(target, name, fetched, source) or {443 os.rmdir_all(target) or {}444 return refuse(args.json(), err.msg())445 }446 447 448 mut finished := false449 defer {450 store.close()451 if !finished {452 os.rmdir_all(target) or {}453 }454 }455 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }456 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {457 return refuse(args.json(), err.msg())458 }459 mut flagged := []events.Flag{}460 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {461 if flag.flagged {462 flagged << flag463 }464 }465 flagged.sort(a.save_id < b.save_id)466 finished = true467 if args.json() {468 emit_json(json2.encode(StolenJson{469 repo: name470 forward: true471 saves: fetched.saves.len472 objects: fetched.repo_objects.len473 events: fetched.events_out.len474 head: fetched.head475 flagged: flagged.map(it.save_id)476 unsafe_paths: fetched.unsafe_paths477 }))478 return 0479 }480 ok('fetched "${name}" into ${target}')481 note('${fetched.saves.len} saves at [${fetched.head}]')482 if source != '' {483 note('read-only: this copy keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')484 }485 warn_flagged(flagged)486 warn_unsafe_paths(fetched.unsafe_paths)487 return 0488}489490491492fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {493 494 os.mkdir_all(target, os.MkdirParams{})!495 mut store := storage.create(target)!496 fill_repo(store, name, fetched, source) or {497 store.rollback()498 store.close()499 return err500 }501 return store502}503504fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {505 store.begin()!506 exchange.write_fetched(store, fetched)!507 history.set_head(store, fetched.head)!508 exchange.remember_repo(store, name)!509 if source != '' {510 exchange.remember_source(store, source)!511 }512 snapshot.mark_restore(store)!513 store.end()!514}515516517fn warn_flagged(flags []events.Flag) {518 for flag in flags {519 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }520 warn('[${flag.save_id}] came in flagged${reason}')521 }522}523524fn warn_unsafe_paths(paths []string) {525 for path in paths {526 warn('dropped unsafe path ${path}')527 }528}529530struct ReposJson {531 repos []string532}533534struct StolenJson {535 repo string536 forward bool537 saves int538 objects int539 events int540 clashes []string541 replayed int542 kept_mine int543 head string544 offer_failed bool545 flagged []string546 previous_head string547 kept_versions []string548 unsafe_paths []string549}