1234567module cli89import json210import os11import src.events12import src.fmt13import src.history14import src.remote15import src.snapshot16import src.storage17import src.exchange18import src.view1920pub fn cmd_steal(raw []string) int {21 args := parse('steal', raw, Options{22 flags: ['list', 'mine', 'theirs', 'json', 'yes']23 }) or { return usage_error(err.msg()) }24 if args.flag('list') {25 if args.positional.len > 0 {26 return usage_error('steal --list takes no arguments')27 }28 return list_repos(args)29 }30 if storage.find_root(os.getwd()) == none {31 name := args.positional_at(0)32 if name == '' {33 return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')34 }35 if args.positional.len > 1 {36 return usage_error('steal takes one repo: koh steal myproject, or koh steal kepr.uk/nina')37 }38 address := web_address(name) or { return refuse(args.json(), err.msg()) }39 if address.len > 0 {40 return clone_web(address[0], address[1], args)41 }42 return clone_repo(name, args)43 }44 store := open_store() or { return refuse(args.json(), err.msg()) }45 defer {46 store.close()47 }48 if args.positional.len > 0 {49 return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')50 }51 guided := on_terminal(args.json())52 if guided {53 if !ready_to_steal() {54 return 155 }56 }57 58 59 source := exchange.source_of(store)60 mut upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))61 mut backend := remote.Backend(unsafe { nil })62 if source != '' && !upgraded {63 backend = exchange.read_only(source) or { return refuse(args.json(), err.msg()) }64 } else {65 backend = this_machine_backend() or { return refuse(args.json(), err.msg()) }66 }67 mut options := exchange.StealOptions{68 mine: args.flag('mine')69 theirs: args.flag('theirs')70 yes: args.flag('yes')71 }72 if guided {73 options.guide = guide_steal74 }75 report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }76 if upgraded {77 exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }78 if !args.json() {79 ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')80 }81 }82 83 84 offered := if source != '' && !upgraded { true } else { offer_after_steal(store, mut backend) }85 if args.json() {86 emit_json(json2.encode(StolenJson{87 repo: report.repo88 forward: report.forward89 saves: report.fetched_saves90 objects: report.fetched_objects91 events: report.fetched_events92 clashes: report.clashes93 replayed: report.replayed94 kept_mine: report.kept_mine95 head: report.head96 offer_failed: !offered97 flagged: report.flagged.map(it.save_id)98 previous_head: report.previous_head99 kept_versions: report.kept_versions100 }))101 return 0102 }103 if report.fetched_saves == 0 {104 ok('already up to date with "${report.repo}" — head is [${report.head}]')105 return 0106 }107 if report.forward {108 if guided {109 ok('${report.repo} is up to date · ${report.fetched_saves} new saves')110 } else {111 ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')112 }113 } else {114 ok(if guided {115 if source != '' && !upgraded {116 'combined'117 } else if offered {118 'combined · offered to ${report.repo}'119 } else {120 'combined'121 }122 } else {123 'brought in ${report.fetched_saves} saves from "${report.repo}"'124 })125 if source != '' && !upgraded {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 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 if report.clashes.len > 0 {142 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')143 }144 if !offered {145 warn('the remote was not updated — run "koh offer" when it is reachable again')146 }147 return 0148}149150151fn on_terminal(json bool) bool {152 return !json && interactive() && os.is_atty(1) != 0153}154155156157fn note_kept_versions(report exchange.StealReport) {158 for kept in report.kept_versions {159 parts := kept.split_nth(':', 2)160 note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}')161 }162}163164fn ready_to_steal() bool {165 store := open_store() or { return true }166 head := history.head(store)167 dirty := exchange.dirty(store, head) or {168 store.close()169 return true170 }171 if !dirty {172 store.close()173 return true174 }175 paths := unsaved_paths(store, head) or { []string{} }176 store.close()177 println(' ${fmt.gold(fmt.glyph_ok + ' steal')}')178 println('')179 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')180 if paths.len > 0 {181 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')182 if paths.len > 5 {183 println(' and ${paths.len - 5} more')184 }185 }186 println('')187 println(' [c] checkpoint them and continue')188 println(' [s] save them with a message and continue')189 println(' [q] stop here')190 print(' › ')191 match os.get_line().trim_space() {192 'c' { return cmd_save(['before steal'], true) == 0 }193 's' {194 print(' message: ')195 message := os.get_line().trim_space()196 return message != '' && cmd_save([message], false) == 0197 }198 else { return false }199 }200}201202fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {203 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)204 before := if save := head {205 snapshot.index_files(history.files_of(store, save.id)!)206 } else {207 map[string]snapshot.FileEntry{}208 }209 mut paths := []string{}210 for path, file in now {211 old := before[path] or { snapshot.FileEntry{} }212 if file.hash != old.hash || file.executable != old.executable {213 paths << path214 }215 }216 for path, _ in before {217 if path !in now {218 paths << path219 }220 }221 paths.sort()222 return paths223}224225fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {226 _, columns := view.terminal_size()227 width := if columns > 0 && columns < 80 { columns } else { 80 }228 println('')229 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base}')230 println('')231 println(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}')232 println(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}')233 println('')234 println(' ${fmt.plural(preview.changed, 'file')} changed')235 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')236 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')237 println(' ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {238 'needs'239 } else {240 'need'241 }} you')242 mut answers := map[string]exchange.Side{}243 if preview.all_side != '' {244 for clash in preview.clashes {245 answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }246 }247 }248 mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }249 for at < preview.clashes.len {250 clash := preview.clashes[at]251 println('')252 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')253 println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${fmt.stamp(clash.mine_save.created_at)} · ${clash.mine_save.machine}',254 width))255 println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${fmt.stamp(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',256 width))257 println('')258 println(' [m] keep mine [t] take theirs [d] see both changes')259 println(' [b] back [?] help')260 print(' › ')261 match os.get_line().trim_space() {262 'm' {263 answers[clash.path] = .mine264 at++265 }266 't' {267 answers[clash.path] = .theirs268 at++269 }270 'b' {271 if at > 0 { at-- }272 }273 'd' { show_clash(clash) }274 '?' {275 println(' mine keeps your version; theirs takes the remote version.')276 println(' Nothing is lost either way; both versions stay in history.')277 }278 'q' { return error('steal cancelled') }279 else {}280 }281 }282 println('')283 println(' ${fmt.gold('ready to combine')}')284 for clash in preview.clashes {285 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }286 println(' ${choice} ${clash.path}')287 }288 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')289 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')290 if !confirm(preview.yes)! {291 return error('steal cancelled')292 }293 return answers294}295296fn messages(items []string) string {297 return fmt.fit(items.map('"${it}"').join(' · '), 52)298}299300fn show_clash(clash exchange.Clash) {301 if clash.mine_patch == '' && clash.theirs_patch == '' {302 println(' binary file')303 return304 }305 mut lines := ['your changes']306 lines << clash.mine_patch.trim_right('\n').split_into_lines()307 lines << ''308 lines << 'remote changes'309 lines << clash.theirs_patch.trim_right('\n').split_into_lines()310 view.page_text(lines, clash.path)311}312313314315fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {316 if _ := exchange.offer(store, mut backend) {317 return true318 }319 return false320}321322fn list_repos(args Args) int {323 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }324 keys := backend.list('') or { return refuse(args.json(), err.msg()) }325 mut names := map[string]bool{}326 for key in keys {327 parts := key.trim_left('/').split('/')328 if parts.len > 0 && parts[0] != '' {329 names[parts[0]] = true330 }331 }332 mut sorted := names.keys()333 sorted.sort()334 if args.json() {335 emit_json(json2.encode(ReposJson{336 repos: sorted337 }))338 return 0339 }340 if sorted.len == 0 {341 ok('the remote has no repos yet')342 return 0343 }344 for name in sorted {345 println(' ${name}')346 }347 return 0348}349350351352353fn web_address(value string) ![]string {354 if !value.contains('/') {355 return []string{}356 }357 repo := value.all_after_last('/')358 host := value.all_before_last('/')359 if repo == '' || host == '' {360 return error('"${value}" is not <host>/<repo>')361 }362 return [host, repo]363}364365366367368fn clone_web(host string, repo string, args Args) int {369 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }370 if exchange.signed_in_to(base) {371 return clone_repo(repo, args)372 }373 mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }374 return clone_with(repo, args, mut backend, '${base}/${repo}')375}376377378379fn clone_repo(name string, args Args) int {380 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }381 return clone_with(name, args, mut backend, '')382}383384385386387fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {388 target := os.join_path(os.getwd(), name)389 if os.exists(target) {390 return refuse(args.json(), '${name} already exists here — run steal inside it instead')391 }392 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }393 store := land_clone(target, name, fetched, source) or {394 os.rmdir_all(target) or {}395 return refuse(args.json(), err.msg())396 }397 398 399 mut finished := false400 defer {401 store.close()402 if !finished {403 os.rmdir_all(target) or {}404 }405 }406 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }407 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {408 return refuse(args.json(), err.msg())409 }410 mut flagged := []events.Flag{}411 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {412 if flag.flagged {413 flagged << flag414 }415 }416 flagged.sort(a.save_id < b.save_id)417 finished = true418 if args.json() {419 emit_json(json2.encode(StolenJson{420 repo: name421 forward: true422 saves: fetched.saves.len423 objects: fetched.repo_objects.len424 events: fetched.events_out.len425 head: fetched.head426 flagged: flagged.map(it.save_id)427 }))428 return 0429 }430 ok('fetched "${name}" into ${target}')431 note('${fetched.saves.len} saves at [${fetched.head}]')432 if source != '' {433 note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')434 }435 warn_flagged(flagged)436 return 0437}438439440441fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {442 443 os.mkdir_all(target, os.MkdirParams{})!444 mut store := storage.create(target)!445 fill_clone(store, name, fetched, source) or {446 store.rollback()447 store.close()448 return err449 }450 return store451}452453fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {454 store.begin()!455 exchange.write_fetched(store, fetched)!456 history.set_head(store, fetched.head)!457 exchange.remember_repo(store, name)!458 if source != '' {459 exchange.remember_source(store, source)!460 }461 snapshot.mark_restore(store)!462 store.commit()!463}464465466fn warn_flagged(flags []events.Flag) {467 for flag in flags {468 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }469 warn('[${flag.save_id}] came in flagged${reason}')470 }471}472473struct ReposJson {474 repos []string475}476477struct StolenJson {478 repo string479 forward bool480 saves int481 objects int482 events int483 clashes []string484 replayed int485 kept_mine int486 head string487 offer_failed bool488 flagged []string489 previous_head string490 kept_versions []string491}