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 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 unsafe_paths: report.unsafe_paths102 }))103 return 0104 }105 if report.fetched_saves == 0 {106 ok('already up to date with "${report.repo}" — head is [${report.head}]')107 return 0108 }109 if report.forward {110 if guided {111 ok('${report.repo} is up to date · ${report.fetched_saves} new saves')112 } else {113 ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')114 }115 } else {116 ok(if !guided {117 'brought in ${report.fetched_saves} saves from "${report.repo}"'118 } else if read_only || offer_failed {119 'combined'120 } else {121 'combined · offered to ${report.repo}'122 })123 if read_only {124 note('your saves stay on this machine (read-only source)')125 } else if !guided {126 note("replayed ${report.replayed} of this machine's saves onto the remote head")127 }128 if guided {129 note_choices(report)130 } else if report.kept_mine > 0 {131 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')132 }133 if guided {134 note('to undo all of it: koh load ${report.previous_head}')135 } else {136 note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')137 }138 note_kept_versions(report)139 }140 warn_flagged(report.flagged)141 warn_unsafe_paths(report.unsafe_paths)142 if report.clashes.len > 0 {143 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')144 }145 if offer_failed {146 warn('the remote was not updated — run "koh offer" when it is reachable again')147 }148 return 0149}150151152fn on_terminal(json bool) bool {153 return !json && interactive() && os.is_atty(1) != 0154}155156157158fn note_kept_versions(report exchange.StealReport) {159 160 for kept in report.kept_versions {161 path := kept.all_before_last(':')162 save := kept.all_after_last(':')163 note('your ${path} is in ${save} · koh peek ${save}')164 }165}166167168fn note_choices(report exchange.StealReport) {169 theirs := report.kept_versions.map(it.all_before_last(':'))170 mine := report.clashes.filter(it !in theirs)171 mut parts := []string{}172 if mine.len > 0 {173 parts << 'kept mine: ${mine.join(' · ')}'174 }175 if theirs.len > 0 {176 parts << 'took theirs: ${theirs.join(' · ')}'177 }178 if parts.len > 0 {179 note(parts.join(' · '))180 }181}182183fn ready_to_steal() bool {184 store := open_store() or { return true }185 head := history.head(store)186 dirty := exchange.dirty(store, head) or {187 store.close()188 return true189 }190 if !dirty {191 store.close()192 return true193 }194 paths := unsaved_paths(store, head) or { []string{} }195 name := os.base(store.root)196 store.close()197 println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')198 println('')199 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')200 if paths.len > 0 {201 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')202 if paths.len > 5 {203 println(' and ${paths.len - 5} more')204 }205 }206 println('')207 println(' [c] checkpoint them and continue')208 println(' [s] save them with a message and continue')209 println(' [q] stop here')210 print(' › ')211 match os.get_line().trim_space() {212 'c' { return cmd_save(['before steal'], true) == 0 }213 's' {214 print(' message: ')215 message := os.get_line().trim_space()216 if message == '' {217 println(' no message — nothing was saved')218 return false219 }220 return cmd_save([message], false) == 0221 }222 else { return false }223 }224}225226fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {227 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)228 before := if save := head {229 snapshot.index_files(history.files_of(store, save.id)!)230 } else {231 map[string]snapshot.FileEntry{}232 }233 mut paths := []string{}234 for path, file in now {235 old := before[path] or { snapshot.FileEntry{} }236 if file.hash != old.hash || file.executable != old.executable {237 paths << path238 }239 }240 for path, _ in before {241 if path !in now {242 paths << path243 }244 }245 paths.sort()246 return paths247}248249fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {250 _, columns := view.terminal_size()251 width := if columns > 0 && columns < 80 { columns } else { 80 }252 println('')253 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')254 println('')255 println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}',256 width))257 println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}',258 width))259 println('')260 println(' ${fmt.plural(preview.changed, 'file')} changed')261 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')262 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')263 println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {264 'needs'265 } else {266 'need'267 }} you')268 mut answers := map[string]exchange.Side{}269 if preview.all_side != '' {270 for clash in preview.clashes {271 answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }272 }273 }274 mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }275 for at < preview.clashes.len {276 clash := preview.clashes[at]277 println('')278 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')279 println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',280 width))281 println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',282 width))283 println('')284 println(' [m] keep mine [t] take theirs [d] see both changes')285 println(' [b] back [?] help')286 print(' › ')287 match os.get_line().trim_space() {288 'm' {289 answers[clash.path] = .mine290 at++291 }292 't' {293 answers[clash.path] = .theirs294 at++295 }296 'b' {297 if at > 0 { at-- }298 }299 'd' { show_clash(clash) }300 '?' {301 println(' mine keeps your version; theirs takes the remote version.')302 println(' Nothing is lost either way; both versions stay in history.')303 }304 'q' { return error('steal cancelled') }305 else {}306 }307 }308 println('')309 println(' ${fmt.gold('ready to combine')}')310 for clash in preview.clashes {311 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }312 println(' ${choice} ${clash.path}')313 }314 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')315 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')316 if !confirm(preview.yes)! {317 return error('steal cancelled')318 }319 return answers320}321322fn messages(items []string) string {323 return items.map('"${it}"').join(' · ')324}325326// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.327fn overview_row(text string, width int) string {328 if fmt.visible_width(text) <= width {329 return text330 }331 return fmt.fit(text, width - 4) + ' · …'332}333334fn ago(unix_seconds i64) string {335 return time.unix(unix_seconds).relative()336}337338fn show_clash(clash exchange.Clash) {339 if clash.mine_patch == '' && clash.theirs_patch == '' {340 println(' binary file')341 return342 }343 mut lines := ['your changes']344 lines << clash.mine_patch.trim_right('\n').split_into_lines()345 lines << ''346 lines << 'remote changes'347 lines << clash.theirs_patch.trim_right('\n').split_into_lines()348 view.page_text(lines, clash.path)349}350351// offer_after_steal sends what this machine just decided. A failure here does352// not undo the steal; it is reported.353fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {354 if _ := exchange.offer(store, mut backend) {355 return true356 }357 return false358}359360fn list_repos(args Args) int {361 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }362 keys := backend.list('') or { return refuse(args.json(), err.msg()) }363 mut names := map[string]bool{}364 for key in keys {365 parts := key.trim_left('/').split('/')366 if parts.len > 0 && parts[0] != '' {367 names[parts[0]] = true368 }369 }370 mut sorted := names.keys()371 sorted.sort()372 if args.json() {373 emit_json(json2.encode(ReposJson{374 repos: sorted375 }))376 return 0377 }378 if sorted.len == 0 {379 ok('the remote has no repos yet')380 return 0381 }382 for name in sorted {383 println(' ${name}')384 }385 return 0386}387388// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address389// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An390391392393fn web_address(value string) ![]string {394 if !value.contains('/') {395 return []string{}396 }397 repo := value.all_after_last('/')398 host := value.all_before_last('/')399 if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [400 `.`,401 `_`,402 `-`,403 ]) {404 return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')405 }406 return [host, repo]407}408409410411412fn steal_web(host string, repo string, args Args) int {413 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }414 if exchange.signed_in_to(base) {415 return steal_repo(repo, args)416 }417 mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }418 return steal_whole(repo, args, mut backend, '${base}/${repo}')419}420421422423fn steal_repo(name string, args Args) int {424 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }425 return steal_whole(name, args, mut backend, '')426}427428429430431fn steal_whole(name string, args Args, mut backend remote.Backend, source string) int {432 target := os.join_path(os.getwd(), name)433 if os.exists(target) {434 return refuse(args.json(), '${name} already exists here — run steal inside it instead')435 }436 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }437 store := land_repo(target, name, fetched, source) or {438 os.rmdir_all(target) or {}439 return refuse(args.json(), err.msg())440 }441 442 443 mut finished := false444 defer {445 store.close()446 if !finished {447 os.rmdir_all(target) or {}448 }449 }450 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }451 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {452 return refuse(args.json(), err.msg())453 }454 mut flagged := []events.Flag{}455 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {456 if flag.flagged {457 flagged << flag458 }459 }460 flagged.sort(a.save_id < b.save_id)461 finished = true462 if args.json() {463 emit_json(json2.encode(StolenJson{464 repo: name465 forward: true466 saves: fetched.saves.len467 objects: fetched.repo_objects.len468 events: fetched.events_out.len469 head: fetched.head470 flagged: flagged.map(it.save_id)471 unsafe_paths: fetched.unsafe_paths472 }))473 return 0474 }475 ok('fetched "${name}" into ${target}')476 note('${fetched.saves.len} saves at [${fetched.head}]')477 if source != '' {478 note('read-only: this copy keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')479 }480 warn_flagged(flagged)481 warn_unsafe_paths(fetched.unsafe_paths)482 return 0483}484485486487fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {488 489 os.mkdir_all(target, os.MkdirParams{})!490 mut store := storage.create(target)!491 fill_repo(store, name, fetched, source) or {492 store.rollback()493 store.close()494 return err495 }496 return store497}498499fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {500 store.begin()!501 exchange.write_fetched(store, fetched)!502 history.set_head(store, fetched.head)!503 exchange.remember_repo(store, name)!504 if source != '' {505 exchange.remember_source(store, source)!506 }507 snapshot.mark_restore(store)!508 store.end()!509}510511512fn warn_flagged(flags []events.Flag) {513 for flag in flags {514 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }515 warn('[${flag.save_id}] came in flagged${reason}')516 }517}518519fn warn_unsafe_paths(paths []string) {520 for path in paths {521 warn('dropped unsafe path ${path}')522 }523}524525struct ReposJson {526 repos []string527}528529struct StolenJson {530 repo string531 forward bool532 saves int533 objects int534 events int535 clashes []string536 replayed int537 kept_mine int538 head string539 offer_failed bool540 flagged []string541 previous_head string542 kept_versions []string543 unsafe_paths []string544}