cmd_steal.v

10.0 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		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	// A stolen repo has its own remote: the address it came from, until this machine signs in to51	// that Kepr, when the bucket takes over.52	source := exchange.source_of(store)53	mut upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))54	mut backend := remote.Backend(unsafe { nil })55	if source != '' && !upgraded {56		backend = exchange.read_only(source) or { return refuse(args.json(), err.msg()) }57	} else {58		backend = this_machine_backend() or { return refuse(args.json(), err.msg()) }59	}60	mut options := exchange.StealOptions{61		mine:   args.flag('mine')62		theirs: args.flag('theirs')63	}64	if !options.mine && !options.theirs && interactive() && os.is_atty(1) != 0 {65		options.ask = ask_clash66	}67	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }68	if upgraded {69		exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }70		if !args.json() {71			ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')72		}73	}74	// The remote should end up with whatever this machine just decided — unless there is no remote75	// to write to: a repo still riding its source stays read-only, and quietly so.76	offered := if source != '' && !upgraded { true } else { offer_after_steal(store, mut backend) }77	if args.json() {78		emit_json(json2.encode(StolenJson{79			repo:          report.repo80			forward:       report.forward81			saves:         report.fetched_saves82			objects:       report.fetched_objects83			events:        report.fetched_events84			clashes:       report.clashes85			replayed:      report.replayed86			kept_mine:     report.kept_mine87			head:          report.head88			offer_failed:  !offered89			flagged:       report.flagged.map(it.save_id)90			previous_head: report.previous_head91		}))92		return 093	}94	if report.fetched_saves == 0 {95		ok('already up to date with "${report.repo}" — head is [${report.head}]')96		return 097	}98	if report.forward {99		ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')100	} else {101		ok('brought in ${report.fetched_saves} saves from "${report.repo}"')102		note("replayed ${report.replayed} of this machine's saves onto the remote head")103		if report.kept_mine > 0 {104			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')105		}106		note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')107	}108	warn_flagged(report.flagged)109	if report.clashes.len > 0 {110		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')111	}112	if !offered {113		warn('the remote was not updated — run "koh offer" when it is reachable again')114	}115	return 0116}117118// offer_after_steal pushes what this machine just decided. A failure here does119// not undo the steal; it is reported.120fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {121	if _ := exchange.offer(store, mut backend) {122		return true123	}124	return false125}126127// ask_clash is the per-file question a terminal gets. Only a lowercase y-style128// answer is needed here: `m` for mine, `t` for theirs.129fn ask_clash(clash exchange.Clash) exchange.Side {130	println('  ${fmt.amber('both changed')} ${clash.path}   ${clash.stat}')131	print('    ${fmt.amber('m')}ine or ${fmt.amber('t')}heirs? [t] ')132	answer := os.get_line().trim_space()133	if answer == 'm' {134		return .mine135	}136	return .theirs137}138139fn list_repos(args Args) int {140	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }141	keys := backend.list('') or { return refuse(args.json(), err.msg()) }142	mut names := map[string]bool{}143	for key in keys {144		parts := key.trim_left('/').split('/')145		if parts.len > 0 && parts[0] != '' {146			names[parts[0]] = true147		}148	}149	mut sorted := names.keys()150	sorted.sort()151	if args.json() {152		emit_json(json2.encode(ReposJson{153			repos: sorted154		}))155		return 0156	}157	if sorted.len == 0 {158		ok('the remote has no repos yet')159		return 0160	}161	for name in sorted {162		println('  ${name}')163	}164	return 0165}166167// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address168// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An169// addressed value that is missing either half is an error, never a name.170fn web_address(value string) ![]string {171	if !value.contains('/') {172		return []string{}173	}174	repo := value.all_after_last('/')175	host := value.all_before_last('/')176	if repo == '' || host == '' {177		return error('"${value}" is not <host>/<repo>')178	}179	return [host, repo]180}181182// clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address183// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to184// take.185fn clone_web(host string, repo string, args Args) int {186	base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }187	if exchange.signed_in_to(base) {188		return clone_repo(repo, args)189	}190	mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }191	return clone_with(repo, args, mut backend, '${base}/${repo}')192}193194// clone_repo fetches a whole repo into ./<name>, for a machine that does not195// have it yet.196fn clone_repo(name string, args Args) int {197	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }198	return clone_with(name, args, mut backend, '')199}200201// clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's202// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays203// local until this machine signs in there.204fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {205	target := os.join_path(os.getwd(), name)206	if os.exists(target) {207		return refuse(args.json(), '${name} already exists here — run steal inside it instead')208	}209	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }210	store := land_clone(target, name, fetched, source) or {211		os.rmdir_all(target) or {}212		return refuse(args.json(), err.msg())213	}214	// Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it215	// as "already exists", so the whole clone goes.216	mut finished := false217	defer {218		store.close()219		if !finished {220			os.rmdir_all(target) or {}221		}222	}223	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }224	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {225		return refuse(args.json(), err.msg())226	}227	mut flagged := []events.Flag{}228	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {229		if flag.flagged {230			flagged << flag231		}232	}233	flagged.sort(a.save_id < b.save_id)234	finished = true235	if args.json() {236		emit_json(json2.encode(StolenJson{237			repo:    name238			forward: true239			saves:   fetched.saves.len240			objects: fetched.repo_objects.len241			events:  fetched.events_out.len242			head:    fetched.head243			flagged: flagged.map(it.save_id)244		}))245		return 0246	}247	ok('fetched "${name}" into ${target}')248	note('${fetched.saves.len} saves at [${fetched.head}]')249	if source != '' {250		note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')251	}252	warn_flagged(flagged)253	return 0254}255256// land_clone creates ./<name> and commits the fetched history into it. Objects257// are checked on the way in, so a damaged remote fails here.258fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {259	// SQLite creates the file, not the folder holding it.260	os.mkdir_all(target, os.MkdirParams{})!261	mut store := storage.create(target)!262	fill_clone(store, name, fetched, source) or {263		store.rollback()264		store.close()265		return err266	}267	return store268}269270fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {271	store.begin()!272	exchange.write_fetched(store, fetched)!273	history.set_head(store, fetched.head)!274	exchange.remember_repo(store, name)!275	if source != '' {276		exchange.remember_source(store, source)!277	}278	snapshot.mark_restore(store)!279	store.commit()!280}281282// warn_flagged prints the line the document asks for, per incoming flagged save.283fn warn_flagged(flags []events.Flag) {284	for flag in flags {285		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }286		warn('[${flag.save_id}] came in flagged${reason}')287	}288}289290struct ReposJson {291	repos []string292}293294struct StolenJson {295	repo          string296	forward       bool297	saves         int298	objects       int299	events        int300	clashes       []string301	replayed      int302	kept_mine     int303	head          string304	offer_failed  bool305	flagged       []string306	previous_head string307}