cmd_steal.v

9.5 KB · raw

1//! `koh steal` — bring another machine's saves in.2//!3//! Inside a repo this pulls the remote's new saves and combines when both4//! machines moved. Outside one it fetches a whole repo into a new folder.5//! `--list` shows what the remote holds.67module 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']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		if address := web_address(name) {32			return clone_web(address[0], address[1], args)33		}34		return clone_repo(name, args)35	}36	store := open_store() or { return refuse(args.json(), err.msg()) }37	defer {38		store.close()39	}40	if args.positional.len > 0 {41		return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')42	}43	// A stolen repo has its own remote: the address it came from, until this machine signs in to44	// that Kepr, when the bucket takes over.45	source := exchange.source_of(store)46	mut upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))47	mut backend := remote.Backend(unsafe { nil })48	if source != '' && !upgraded {49		backend = exchange.read_only(source) or { return refuse(args.json(), err.msg()) }50	} else {51		backend = this_machine_backend() or { return refuse(args.json(), err.msg()) }52	}53	mut options := exchange.StealOptions{54		mine:   args.flag('mine')55		theirs: args.flag('theirs')56	}57	if !options.mine && !options.theirs && interactive() && os.is_atty(1) != 0 {58		options.ask = ask_clash59	}60	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }61	if upgraded {62		exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }63		if !args.json() {64			ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')65		}66	}67	// The remote should end up with whatever this machine just decided — unless there is no remote68	// to write to: a repo still riding its source stays read-only, and quietly so.69	offered := if source != '' && !upgraded { true } else { offer_after_steal(store, mut backend) }70	if args.json() {71		emit_json(json2.encode(StolenJson{72			repo:          report.repo73			forward:       report.forward74			saves:         report.fetched_saves75			objects:       report.fetched_objects76			events:        report.fetched_events77			clashes:       report.clashes78			replayed:      report.replayed79			kept_mine:     report.kept_mine80			head:          report.head81			offer_failed:  !offered82			flagged:       report.flagged.map(it.save_id)83			previous_head: report.previous_head84		}))85		return 086	}87	if report.fetched_saves == 0 {88		ok('already up to date with "${report.repo}" — head is [${report.head}]')89		return 090	}91	if report.forward {92		ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')93	} else {94		ok('brought in ${report.fetched_saves} saves from "${report.repo}"')95		note("replayed ${report.replayed} of this machine's saves onto the remote head")96		if report.kept_mine > 0 {97			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')98		}99		note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')100	}101	warn_flagged(report.flagged)102	if report.clashes.len > 0 {103		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')104	}105	if !offered {106		warn('the remote was not updated — run "koh offer" when it is reachable again')107	}108	return 0109}110111// offer_after_steal pushes what this machine just decided. A failure here does112// not undo the steal; it is reported.113fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {114	if _ := exchange.offer(store, mut backend) {115		return true116	}117	return false118}119120// ask_clash is the per-file question a terminal gets. Only a lowercase y-style121// answer is needed here: `m` for mine, `t` for theirs.122fn ask_clash(clash exchange.Clash) exchange.Side {123	println('  ${fmt.amber('both changed')} ${clash.path}   ${clash.stat}')124	print('    ${fmt.amber('m')}ine or ${fmt.amber('t')}heirs? [t] ')125	answer := os.get_line().trim_space()126	if answer == 'm' {127		return .mine128	}129	return .theirs130}131132fn list_repos(args Args) int {133	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }134	keys := backend.list('') or { return refuse(args.json(), err.msg()) }135	mut names := map[string]bool{}136	for key in keys {137		parts := key.trim_left('/').split('/')138		if parts.len > 0 && parts[0] != '' {139			names[parts[0]] = true140		}141	}142	mut sorted := names.keys()143	sorted.sort()144	if args.json() {145		emit_json(json2.encode(ReposJson{146			repos: sorted147		}))148		return 0149	}150	if sorted.len == 0 {151		ok('the remote has no repos yet')152		return 0153	}154	for name in sorted {155		println('  ${name}')156	}157	return 0158}159160// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address161// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote.162fn web_address(value string) ?[]string {163	if !value.contains('/') {164		return none165	}166	repo := value.all_after_last('/')167	host := value.all_before_last('/')168	if repo == '' || host == '' {169		return none170	}171	return [host, repo]172}173174// clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address175// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to176// take.177fn clone_web(host string, repo string, args Args) int {178	base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }179	if exchange.signed_in_to(base) {180		return clone_repo(repo, args)181	}182	mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }183	return clone_with(repo, args, mut backend, '${base}/${repo}')184}185186// clone_repo fetches a whole repo into ./<name>, for a machine that does not187// have it yet.188fn clone_repo(name string, args Args) int {189	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }190	return clone_with(name, args, mut backend, '')191}192193// clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's194// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays195// local until this machine signs in there.196fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {197	target := os.join_path(os.getwd(), name)198	if os.exists(target) {199		return refuse(args.json(), '${name} already exists here — run steal inside it instead')200	}201	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }202	store := land_clone(target, name, fetched, source) or {203		// A folder left behind would make the retry refuse as "already exists".204		os.rmdir_all(target) or {}205		return refuse(args.json(), err.msg())206	}207	defer {208		store.close()209	}210	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }211	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {212		return refuse(args.json(), err.msg())213	}214	mut flagged := []events.Flag{}215	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {216		if flag.flagged {217			flagged << flag218		}219	}220	flagged.sort(a.save_id < b.save_id)221	if args.json() {222		emit_json(json2.encode(StolenJson{223			repo:    name224			forward: true225			saves:   fetched.saves.len226			objects: fetched.repo_objects.len227			events:  fetched.events_out.len228			head:    fetched.head229			flagged: flagged.map(it.save_id)230		}))231		return 0232	}233	ok('fetched "${name}" into ${target}')234	note('${fetched.saves.len} saves at [${fetched.head}]')235	if source != '' {236		note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')237	}238	warn_flagged(flagged)239	return 0240}241242// land_clone creates ./<name> and commits the fetched history into it. Objects243// are checked on the way in, so a damaged remote fails here.244fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {245	// SQLite creates the file, not the folder holding it.246	os.mkdir_all(target, os.MkdirParams{})!247	mut store := storage.create(target)!248	fill_clone(store, name, fetched, source) or {249		store.rollback()250		store.close()251		return err252	}253	return store254}255256fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {257	store.begin()!258	exchange.write_fetched(store, fetched)!259	history.set_head(store, fetched.head)!260	exchange.remember_repo(store, name)!261	if source != '' {262		exchange.remember_source(store, source)!263	}264	snapshot.mark_restore(store)!265	store.commit()!266}267268// warn_flagged prints the line the document asks for, per incoming flagged save.269fn warn_flagged(flags []events.Flag) {270	for flag in flags {271		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }272		warn('[${flag.save_id}] came in flagged${reason}')273	}274}275276struct ReposJson {277	repos []string278}279280struct StolenJson {281	repo          string282	forward       bool283	saves         int284	objects       int285	events        int286	clashes       []string287	replayed      int288	kept_mine     int289	head          string290	offer_failed  bool291	flagged       []string292	previous_head string293}