cmd_steal.v

15.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.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	// A stolen repo has its own remote: the address it came from, until this machine signs in to58	// that Kepr, when the bucket takes over.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	// The remote should end up with whatever this machine just decided — unless there is no remote83	// to write to: a repo still riding its source stays read-only, and quietly so.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}149150// on_terminal is when a steal asks rather than refuses: a person at both ends, and no --json.151fn on_terminal(json bool) bool {152	return !json && interactive() && os.is_atty(1) != 0153}154155// note_kept_versions names, for each file answered "theirs", the save that still holds this156// machine's version.157fn 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}312313// offer_after_steal pushes what this machine just decided. A failure here does314// not undo the steal; it is reported.315fn 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}349350// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address351// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An352// addressed value that is missing either half is an error, never a name.353fn 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}364365// clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address366// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to367// take.368fn 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}376377// clone_repo fetches a whole repo into ./<name>, for a machine that does not378// have it yet.379fn 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}383384// clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's385// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays386// local until this machine signs in there.387fn 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	// Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it398	// as "already exists", so the whole clone goes.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}438439// land_clone creates ./<name> and commits the fetched history into it. Objects440// are checked on the way in, so a damaged remote fails here.441fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {442	// SQLite creates the file, not the folder holding it.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}464465// warn_flagged prints the line the document asks for, per incoming flagged save.466fn 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}