1234567module cli89import json10import os11import src.events12import src.fmt13import src.history14import src.remote15import src.snapshot16import src.storage17import src.sync1819pub fn cmd_steal(raw []string) int {20 args := parse('steal', raw, Options{21 flags: ['list', 'mine', 'theirs', 'json']22 }) or { return usage_error(err.msg()) }23 if args.flag('list') {24 return list_repos(args)25 }26 if storage.find_root(os.getwd()) == none {27 name := args.positional_at(0)28 if name == '' {29 return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')30 }31 return clone_repo(name, args)32 }33 store := open_store() or { return refuse(args.json(), err.msg()) }34 defer {35 store.close()36 }37 if args.positional.len > 0 {38 return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')39 }40 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }41 mut options := sync.StealOptions{42 mine: args.flag('mine')43 theirs: args.flag('theirs')44 }45 if !options.mine && !options.theirs && interactive() && os.is_atty(1) != 0 {46 options.ask = ask_clash47 }48 report := sync.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }49 50 offered := offer_after_steal(store, mut backend)51 if args.json() {52 emit_json(json.encode(StolenJson{53 repo: report.repo54 forward: report.forward55 saves: report.fetched_saves56 objects: report.fetched_objects57 events: report.fetched_events58 clashes: report.clashes59 replayed: report.replayed60 kept_mine: report.kept_mine61 head: report.head62 offer_failed: !offered63 flagged: report.flagged.map(it.save_id)64 }))65 return 066 }67 if report.fetched_saves == 0 {68 ok('already up to date with "${report.repo}" — head is [${report.head}]')69 return 070 }71 if report.forward {72 ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')73 } else {74 ok('brought in ${report.fetched_saves} saves from "${report.repo}"')75 note('replayed ${report.replayed} of this machine\'s saves onto the remote head')76 if report.kept_mine > 0 {77 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')78 }79 }80 for flag in report.flagged {81 warn('[${flag.save_id}] came in flagged${if flag.reason != '' {82 ' — ${flag.reason}'83 } else {84 ''85 }}')86 }87 if report.clashes.len > 0 {88 note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')89 }90 if !offered {91 warn('the remote was not updated — run "koh offer" when it is reachable again')92 }93 return 094}9596// offer_after_steal pushes what this machine just decided. A failure here does97// not undo the steal; it is reported.98fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {99 if _ := sync.offer(store, mut backend) {100 return true101 }102 return false103}104105// ask_clash is the per-file question a terminal gets. Only a lowercase y-style106// answer is needed here: `m` for mine, `t` for theirs.107fn ask_clash(clash sync.Clash) sync.Side {108 println(' ${amber('both changed')} ${clash.path} ${clash.stat}')109 print(' ${amber('m')}ine or ${amber('t')}heirs? [t] ')110 answer := os.get_line().trim_space()111 if answer == 'm' {112 return .mine113 }114 return .theirs115}116117fn list_repos(args Args) int {118 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }119 keys := backend.list('') or { return refuse(args.json(), err.msg()) }120 mut names := map[string]bool{}121 for key in keys {122 parts := key.trim_left('/').split('/')123 if parts.len > 0 && parts[0] != '' {124 names[parts[0]] = true125 }126 }127 mut sorted := names.keys()128 sorted.sort()129 if args.json() {130 emit_json(json.encode(ReposJson{131 repos: sorted132 }))133 return 0134 }135 if sorted.len == 0 {136 ok('the remote has no repos yet')137 return 0138 }139 for name in sorted {140 println(' ${name}')141 }142 return 0143}144145// clone_repo fetches a whole repo into ./<name>, for a machine that does not146// have it yet.147fn clone_repo(name string, args Args) int {148 target := os.join_path(os.getwd(), name)149 if os.exists(target) {150 return refuse(args.json(), '${name} already exists here — run steal inside it instead')151 }152 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }153 fetched := sync.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }154 // SQLite creates the file, not the folder holding it.155 os.mkdir_all(target, os.MkdirParams{}) or { return refuse(args.json(), err.msg()) }156 mut store := storage.create(target) or { return refuse(args.json(), err.msg()) }157 defer {158 store.close()159 }160 store.begin() or { return refuse(args.json(), err.msg()) }161 sync.write_fetched(store, fetched) or {162 store.rollback()163 return refuse(args.json(), err.msg())164 }165 history.set_head(store, fetched.head) or {166 store.rollback()167 return refuse(args.json(), err.msg())168 }169 sync.remember_repo(store, name) or {170 store.rollback()171 return refuse(args.json(), err.msg())172 }173 store.commit() or { return refuse(args.json(), err.msg()) }174 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }175 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {176 return refuse(args.json(), err.msg())177 }178 if args.json() {179 emit_json(json.encode(StolenJson{180 repo: name181 forward: true182 saves: fetched.saves.len183 objects: fetched.repo_objects.len184 events: fetched.events_out.len185 head: fetched.head186 }))187 return 0188 }189 ok('fetched "${name}" into ${target}')190 note('${fetched.saves.len} saves at [${fetched.head}]')191 for save_id in events.flagged_ids(store) or { []string{} } {192 warn('[${save_id}] came in flagged')193 }194 return 0195}196197struct ReposJson {198 repos []string199}200201struct StolenJson {202 repo string203 forward bool204 saves int205 objects int206 events int207 clashes []string208 replayed int209 kept_mine int210 head string211 offer_failed bool212 flagged []string213}