1234567module cli89import json210import os11import src.events12import src.fmt13import src.history14import src.remote15import src.snapshot16import src.storage17import src.exchange1819pub fn cmd_steal(raw []string) int {20 args := parse('steal', raw, Options{21 flags: ['list', 'mine', 'theirs', 'json', 'yes']22 }) or { return usage_error(err.msg()) }23 if args.flag('list') {24 if args.positional.len > 0 {25 return usage_error('steal --list takes no arguments')26 }27 return list_repos(args)28 }29 if storage.find_root(os.getwd()) == none {30 name := args.positional_at(0)31 if name == '' {32 return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')33 }34 if args.positional.len > 1 {35 return usage_error('steal takes one repo: koh steal myproject, or koh steal kepr.uk/nina')36 }37 address := web_address(name) or { return refuse(args.json(), err.msg()) }38 if address.len > 0 {39 return clone_web(address[0], address[1], args)40 }41 return clone_repo(name, args)42 }43 store := open_store() or { return refuse(args.json(), err.msg()) }44 defer {45 store.close()46 }47 if args.positional.len > 0 {48 return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')49 }50 if interactive() && os.is_atty(1) != 0 && !args.json() {51 if !ready_to_steal() {52 return 153 }54 }55 56 57 source := exchange.source_of(store)58 mut upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))59 mut backend := remote.Backend(unsafe { nil })60 if source != '' && !upgraded {61 backend = exchange.read_only(source) or { return refuse(args.json(), err.msg()) }62 } else {63 backend = this_machine_backend() or { return refuse(args.json(), err.msg()) }64 }65 mut options := exchange.StealOptions{66 mine: args.flag('mine')67 theirs: args.flag('theirs')68 yes: args.flag('yes')69 }70 guided := !args.json() && interactive() && os.is_atty(1) != 071 if !options.mine && !options.theirs && guided {72 options.guide = guide_steal73 }74 report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }75 if upgraded {76 exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }77 if !args.json() {78 ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')79 }80 }81 82 83 offered := if source != '' && !upgraded { true } else { offer_after_steal(store, mut backend) }84 if args.json() {85 emit_json(json2.encode(StolenJson{86 repo: report.repo87 forward: report.forward88 saves: report.fetched_saves89 objects: report.fetched_objects90 events: report.fetched_events91 clashes: report.clashes92 replayed: report.replayed93 kept_mine: report.kept_mine94 head: report.head95 offer_failed: !offered96 flagged: report.flagged.map(it.save_id)97 previous_head: report.previous_head98 kept_versions: report.kept_versions99 }))100 return 0101 }102 if report.fetched_saves == 0 {103 ok('already up to date with "${report.repo}" — head is [${report.head}]')104 return 0105 }106 if report.forward {107 if guided {108 ok('${report.repo} is up to date · ${report.fetched_saves} new saves')109 } else {110 ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')111 }112 } else {113 ok(if guided {114 'combined · offered to ${report.repo}'115 } else {116 'brought in ${report.fetched_saves} saves from "${report.repo}"'117 })118 if source != '' && !upgraded {119 note('your saves stay on this machine (read-only source)')120 } else if !guided {121 note("replayed ${report.replayed} of this machine's saves onto the remote head")122 }123 if report.kept_mine > 0 {124 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')125 }126 note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')127 for kept in report.kept_versions {128 parts := kept.split_nth(':', 2)129 note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}')130 }131 }132 warn_flagged(report.flagged)133 if report.clashes.len > 0 {134 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')135 }136 if !offered {137 warn('the remote was not updated — run "koh offer" when it is reachable again')138 }139 return 0140}141142fn ready_to_steal() bool {143 store := open_store() or { return true }144 head := history.head(store)145 dirty := exchange.dirty(store, head) or {146 store.close()147 return true148 }149 if !dirty {150 store.close()151 return true152 }153 paths := unsaved_paths(store, head) or { []string{} }154 store.close()155 println(' ${fmt.gold(fmt.glyph_ok + ' steal')}')156 println('')157 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')158 if paths.len > 0 {159 println(' ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')160 if paths.len > 5 {161 println(' and ${paths.len - 5} more')162 }163 }164 println('')165 println(' [c] checkpoint them and continue')166 println(' [s] save them with a message and continue')167 println(' [q] stop here')168 print(' › ')169 match os.get_line().trim_space() {170 'c' { return cmd_save(['before steal'], true) == 0 }171 's' {172 print(' message: ')173 message := os.get_line().trim_space()174 return message != '' && cmd_save([message], false) == 0175 }176 else { return false }177 }178}179180fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {181 now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)182 before := if save := head {183 snapshot.index_files(history.files_of(store, save.id)!)184 } else {185 map[string]snapshot.FileEntry{}186 }187 mut paths := []string{}188 for path, file in now {189 old := before[path] or { snapshot.FileEntry{} }190 if file.hash != old.hash || file.executable != old.executable {191 paths << path192 }193 }194 for path, _ in before {195 if path !in now {196 paths << path197 }198 }199 paths.sort()200 return paths201}202203fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {204 println('')205 println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base}')206 println('')207 println(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}')208 println(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}')209 println('')210 println(' ${fmt.plural(preview.changed, 'file')} changed')211 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')212 println(' ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')213 println(' ${fmt.amber('!')} ${preview.clashes.len} need you')214 mut answers := map[string]exchange.Side{}215 mut at := 0216 for at < preview.clashes.len {217 clash := preview.clashes[at]218 println('')219 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')220 println(' ${fmt.gold('you')} ${clash.mine_stat}')221 println(' ${fmt.blue('remote')} ${clash.theirs_stat}')222 println('')223 println(' [m] keep mine [t] take theirs [d] see both changes')224 println(' [b] back [?] help')225 print(' › ')226 match os.get_line().trim_space() {227 'm' {228 answers[clash.path] = .mine229 at++230 }231 't' {232 answers[clash.path] = .theirs233 at++234 }235 'b' {236 if at > 0 { at-- }237 }238 'd' { show_clash(clash) }239 '?' {240 println(' mine keeps your version; theirs takes the remote version.')241 println(' Nothing is lost either way; both versions stay in history.')242 }243 'q' { return error('steal cancelled') }244 else {}245 }246 }247 println('')248 println(' ${fmt.gold('ready to combine')}')249 for clash in preview.clashes {250 choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }251 println(' ${choice} ${clash.path}')252 }253 println(' replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')254 println(' your originals stay in history: ${preview.local.map(it.id).join(' · ')}')255 if !confirm(preview.yes)! {256 return error('steal cancelled')257 }258 return answers259}260261fn messages(items []string) string {262 return fmt.fit(items.map('"${it}"').join(' · '), 52)263}264265fn show_clash(clash exchange.Clash) {266 if clash.mine_stat == 'binary' || clash.theirs_stat == 'binary' {267 println(' binary file')268 return269 }270 println(' ${fmt.gold('your changes')}')271 print(clash.mine_patch)272 println(' ${fmt.blue('remote changes')}')273 print(clash.theirs_patch)274}275276277278fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {279 if _ := exchange.offer(store, mut backend) {280 return true281 }282 return false283}284285fn list_repos(args Args) int {286 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }287 keys := backend.list('') or { return refuse(args.json(), err.msg()) }288 mut names := map[string]bool{}289 for key in keys {290 parts := key.trim_left('/').split('/')291 if parts.len > 0 && parts[0] != '' {292 names[parts[0]] = true293 }294 }295 mut sorted := names.keys()296 sorted.sort()297 if args.json() {298 emit_json(json2.encode(ReposJson{299 repos: sorted300 }))301 return 0302 }303 if sorted.len == 0 {304 ok('the remote has no repos yet')305 return 0306 }307 for name in sorted {308 println(' ${name}')309 }310 return 0311}312313314315316fn web_address(value string) ![]string {317 if !value.contains('/') {318 return []string{}319 }320 repo := value.all_after_last('/')321 host := value.all_before_last('/')322 if repo == '' || host == '' {323 return error('"${value}" is not <host>/<repo>')324 }325 return [host, repo]326}327328329330331fn clone_web(host string, repo string, args Args) int {332 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }333 if exchange.signed_in_to(base) {334 return clone_repo(repo, args)335 }336 mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }337 return clone_with(repo, args, mut backend, '${base}/${repo}')338}339340341342fn clone_repo(name string, args Args) int {343 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }344 return clone_with(name, args, mut backend, '')345}346347348349350fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {351 target := os.join_path(os.getwd(), name)352 if os.exists(target) {353 return refuse(args.json(), '${name} already exists here — run steal inside it instead')354 }355 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }356 store := land_clone(target, name, fetched, source) or {357 os.rmdir_all(target) or {}358 return refuse(args.json(), err.msg())359 }360 361 362 mut finished := false363 defer {364 store.close()365 if !finished {366 os.rmdir_all(target) or {}367 }368 }369 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }370 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {371 return refuse(args.json(), err.msg())372 }373 mut flagged := []events.Flag{}374 for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {375 if flag.flagged {376 flagged << flag377 }378 }379 flagged.sort(a.save_id < b.save_id)380 finished = true381 if args.json() {382 emit_json(json2.encode(StolenJson{383 repo: name384 forward: true385 saves: fetched.saves.len386 objects: fetched.repo_objects.len387 events: fetched.events_out.len388 head: fetched.head389 flagged: flagged.map(it.save_id)390 }))391 return 0392 }393 ok('fetched "${name}" into ${target}')394 note('${fetched.saves.len} saves at [${fetched.head}]')395 if source != '' {396 note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')397 }398 warn_flagged(flagged)399 return 0400}401402403404fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {405 406 os.mkdir_all(target, os.MkdirParams{})!407 mut store := storage.create(target)!408 fill_clone(store, name, fetched, source) or {409 store.rollback()410 store.close()411 return err412 }413 return store414}415416fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {417 store.begin()!418 exchange.write_fetched(store, fetched)!419 history.set_head(store, fetched.head)!420 exchange.remember_repo(store, name)!421 if source != '' {422 exchange.remember_source(store, source)!423 }424 snapshot.mark_restore(store)!425 store.commit()!426}427428429fn warn_flagged(flags []events.Flag) {430 for flag in flags {431 reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }432 warn('[${flag.save_id}] came in flagged${reason}')433 }434}435436struct ReposJson {437 repos []string438}439440struct StolenJson {441 repo string442 forward bool443 saves int444 objects int445 events int446 clashes []string447 replayed int448 kept_mine int449 head string450 offer_failed bool451 flagged []string452 previous_head string453 kept_versions []string454}