cmd_steal.v

16.4 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.view19import time2021pub fn cmd_steal(raw []string) int {22	args := parse('steal', raw, Options{23		flags: ['list', 'mine', 'theirs', 'json', 'yes']24	}) or { return usage_error(err.msg()) }25	if args.flag('list') {26		if args.positional.len > 0 {27			return usage_error('steal --list takes no arguments')28		}29		return list_repos(args)30	}31	if storage.find_root(os.getwd()) == none {32		name := args.positional_at(0)33		if name == '' {34			return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')35		}36		if args.positional.len > 1 {37			return usage_error('steal takes one repo: koh steal myproject, or koh steal kepr.uk/nina')38		}39		address := web_address(name) or { return refuse(args.json(), err.msg()) }40		if address.len > 0 {41			return clone_web(address[0], address[1], args)42		}43		return clone_repo(name, args)44	}45	store := open_store() or { return refuse(args.json(), err.msg()) }46	defer {47		store.close()48	}49	if args.positional.len > 0 {50		return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')51	}52	guided := on_terminal(args.json())53	if guided {54		if !ready_to_steal() {55			return 156		}57	}58	// A stolen repo has its own remote: the address it came from, until this machine signs in to59	// that Kepr, when the bucket takes over.60	source := exchange.source_of(store)61	upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))62	read_only := source != '' && !upgraded63	mut backend := if read_only {64		exchange.read_only(source) or { return refuse(args.json(), err.msg()) }65	} else {66		this_machine_backend() or { return refuse(args.json(), err.msg()) }67	}68	mut options := exchange.StealOptions{69		mine:   args.flag('mine')70		theirs: args.flag('theirs')71		yes:    args.flag('yes')72	}73	if guided {74		options.guide = guide_steal75	}76	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }77	if upgraded {78		exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }79		if !args.json() {80			ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')81		}82	}83	// The remote should end up with whatever this machine just decided — unless there is no remote84	// to write to: a repo still riding its source stays read-only, and quietly so.85	offer_failed := !read_only && !offer_after_steal(store, mut backend)86	if args.json() {87		emit_json(json2.encode(StolenJson{88			repo:          report.repo89			forward:       report.forward90			saves:         report.fetched_saves91			objects:       report.fetched_objects92			events:        report.fetched_events93			clashes:       report.clashes94			replayed:      report.replayed95			kept_mine:     report.kept_mine96			head:          report.head97			offer_failed:  offer_failed98			flagged:       report.flagged.map(it.save_id)99			previous_head: report.previous_head100			kept_versions: report.kept_versions101		}))102		return 0103	}104	if report.fetched_saves == 0 {105		ok('already up to date with "${report.repo}" — head is [${report.head}]')106		return 0107	}108	if report.forward {109		if guided {110			ok('${report.repo} is up to date · ${report.fetched_saves} new saves')111		} else {112			ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')113		}114	} else {115		ok(if !guided {116			'brought in ${report.fetched_saves} saves from "${report.repo}"'117		} else if read_only || offer_failed {118			'combined'119		} else {120			'combined · offered to ${report.repo}'121		})122		if read_only {123			note('your saves stay on this machine (read-only source)')124		} else if !guided {125			note("replayed ${report.replayed} of this machine's saves onto the remote head")126		}127		if guided {128			note_choices(report)129		} else if report.kept_mine > 0 {130			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')131		}132		if guided {133			note('to undo all of it: koh load ${report.previous_head}')134		} else {135			note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')136		}137		note_kept_versions(report)138	}139	warn_flagged(report.flagged)140	if report.clashes.len > 0 {141		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')142	}143	if offer_failed {144		warn('the remote was not updated — run "koh offer" when it is reachable again')145	}146	return 0147}148149// on_terminal is when a steal asks rather than refuses: a person at both ends, and no --json.150fn on_terminal(json bool) bool {151	return !json && interactive() && os.is_atty(1) != 0152}153154// note_kept_versions names, for each file answered "theirs", the save that still holds this155// machine's version.156fn note_kept_versions(report exchange.StealReport) {157	// `<path>:<save>`: a save ID has no colon, a path may.158	for kept in report.kept_versions {159		path := kept.all_before_last(':')160		save := kept.all_after_last(':')161		note('your ${path} is in ${save} · koh peek ${save}')162	}163}164165// note_choices is the done screen's line of answers: `kept mine: a · took theirs: b`.166fn note_choices(report exchange.StealReport) {167	theirs := report.kept_versions.map(it.all_before_last(':'))168	mine := report.clashes.filter(it !in theirs)169	mut parts := []string{}170	if mine.len > 0 {171		parts << 'kept mine: ${mine.join(' · ')}'172	}173	if theirs.len > 0 {174		parts << 'took theirs: ${theirs.join(' · ')}'175	}176	if parts.len > 0 {177		note(parts.join(' · '))178	}179}180181fn ready_to_steal() bool {182	store := open_store() or { return true }183	head := history.head(store)184	dirty := exchange.dirty(store, head) or {185		store.close()186		return true187	}188	if !dirty {189		store.close()190		return true191	}192	paths := unsaved_paths(store, head) or { []string{} }193	name := os.base(store.root)194	store.close()195	println('  ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')196	println('')197	println('  you have ${fmt.plural(paths.len, 'unsaved file')}')198	if paths.len > 0 {199		println('    ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')200		if paths.len > 5 {201			println('    and ${paths.len - 5} more')202		}203	}204	println('')205	println('  [c] checkpoint them and continue')206	println('  [s] save them with a message and continue')207	println('  [q] stop here')208	print('  › ')209	match os.get_line().trim_space() {210		'c' { return cmd_save(['before steal'], true) == 0 }211		's' {212			print('  message: ')213			message := os.get_line().trim_space()214			if message == '' {215				println('  no message — nothing was saved')216				return false217			}218			return cmd_save([message], false) == 0219		}220		else { return false }221	}222}223224fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {225	now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)226	before := if save := head {227		snapshot.index_files(history.files_of(store, save.id)!)228	} else {229		map[string]snapshot.FileEntry{}230	}231	mut paths := []string{}232	for path, file in now {233		old := before[path] or { snapshot.FileEntry{} }234		if file.hash != old.hash || file.executable != old.executable {235			paths << path236		}237	}238	for path, _ in before {239		if path !in now {240			paths << path241		}242	}243	paths.sort()244	return paths245}246247fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {248	_, columns := view.terminal_size()249	width := if columns > 0 && columns < 80 { columns } else { 80 }250	println('')251	println('  ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')252	println('')253	println(overview_row('    ${fmt.gold('you')}      ${fmt.plural(preview.local.len, 'save')}  ${messages(preview.local.map(it.message))}',254		width))255	println(overview_row('    ${fmt.blue('remote')}   ${fmt.plural(preview.remote.len, 'save')}  ${messages(preview.remote.map(it.message))}',256		width))257	println('')258	println('  ${fmt.plural(preview.changed, 'file')} changed')259	println('    ${fmt.green('✓')} ${preview.automatic} combine by themselves')260	println('    ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')261	println('    ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {262		'needs'263	} else {264		'need'265	}} you')266	mut answers := map[string]exchange.Side{}267	if preview.all_side != '' {268		for clash in preview.clashes {269			answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }270		}271	}272	mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }273	for at < preview.clashes.len {274		clash := preview.clashes[at]275		println('')276		println('  ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')277		println(fmt.fit('    ${fmt.gold('you')}      ${clash.mine_stat}   ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',278			width))279		println(fmt.fit('    ${fmt.blue('remote')}   ${clash.theirs_stat}   ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',280			width))281		println('')282		println('  [m] keep mine   [t] take theirs   [d] see both changes')283		println('  [b] back   [?] help')284		print('  › ')285		match os.get_line().trim_space() {286			'm' {287				answers[clash.path] = .mine288				at++289			}290			't' {291				answers[clash.path] = .theirs292				at++293			}294			'b' {295				if at > 0 { at-- }296			}297			'd' { show_clash(clash) }298			'?' {299				println('  mine keeps your version; theirs takes the remote version.')300				println('  Nothing is lost either way; both versions stay in history.')301			}302			'q' { return error('steal cancelled') }303			else {}304		}305	}306	println('')307	println('  ${fmt.gold('ready to combine')}')308	for clash in preview.clashes {309		choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }310		println('    ${choice}  ${clash.path}')311	}312	println('    replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')313	println('    your originals stay in history: ${preview.local.map(it.id).join(' · ')}')314	if !confirm(preview.yes)! {315		return error('steal cancelled')316	}317	return answers318}319320fn messages(items []string) string {321	return items.map('"${it}"').join(' · ')322}323324// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.325fn overview_row(text string, width int) string {326	if fmt.visible_width(text) <= width {327		return text328	}329	return fmt.fit(text, width - 4) + ' · …'330}331332fn ago(unix_seconds i64) string {333	return time.unix(unix_seconds).relative()334}335336fn show_clash(clash exchange.Clash) {337	if clash.mine_patch == '' && clash.theirs_patch == '' {338		println('  binary file')339		return340	}341	mut lines := ['your changes']342	lines << clash.mine_patch.trim_right('\n').split_into_lines()343	lines << ''344	lines << 'remote changes'345	lines << clash.theirs_patch.trim_right('\n').split_into_lines()346	view.page_text(lines, clash.path)347}348349// offer_after_steal pushes what this machine just decided. A failure here does350// not undo the steal; it is reported.351fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {352	if _ := exchange.offer(store, mut backend) {353		return true354	}355	return false356}357358fn list_repos(args Args) int {359	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }360	keys := backend.list('') or { return refuse(args.json(), err.msg()) }361	mut names := map[string]bool{}362	for key in keys {363		parts := key.trim_left('/').split('/')364		if parts.len > 0 && parts[0] != '' {365			names[parts[0]] = true366		}367	}368	mut sorted := names.keys()369	sorted.sort()370	if args.json() {371		emit_json(json2.encode(ReposJson{372			repos: sorted373		}))374		return 0375	}376	if sorted.len == 0 {377		ok('the remote has no repos yet')378		return 0379	}380	for name in sorted {381		println('  ${name}')382	}383	return 0384}385386// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address387// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An388// addressed value that is missing either half is an error, never a name.389fn web_address(value string) ![]string {390	if !value.contains('/') {391		return []string{}392	}393	repo := value.all_after_last('/')394	host := value.all_before_last('/')395	if repo == '' || host == '' {396		return error('"${value}" is not <host>/<repo>')397	}398	return [host, repo]399}400401// clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address402// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to403// take.404fn clone_web(host string, repo string, args Args) int {405	base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }406	if exchange.signed_in_to(base) {407		return clone_repo(repo, args)408	}409	mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }410	return clone_with(repo, args, mut backend, '${base}/${repo}')411}412413// clone_repo fetches a whole repo into ./<name>, for a machine that does not414// have it yet.415fn clone_repo(name string, args Args) int {416	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }417	return clone_with(name, args, mut backend, '')418}419420// clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's421// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays422// local until this machine signs in there.423fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {424	target := os.join_path(os.getwd(), name)425	if os.exists(target) {426		return refuse(args.json(), '${name} already exists here — run steal inside it instead')427	}428	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }429	store := land_clone(target, name, fetched, source) or {430		os.rmdir_all(target) or {}431		return refuse(args.json(), err.msg())432	}433	// Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it434	// as "already exists", so the whole clone goes.435	mut finished := false436	defer {437		store.close()438		if !finished {439			os.rmdir_all(target) or {}440		}441	}442	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }443	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {444		return refuse(args.json(), err.msg())445	}446	mut flagged := []events.Flag{}447	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {448		if flag.flagged {449			flagged << flag450		}451	}452	flagged.sort(a.save_id < b.save_id)453	finished = true454	if args.json() {455		emit_json(json2.encode(StolenJson{456			repo:    name457			forward: true458			saves:   fetched.saves.len459			objects: fetched.repo_objects.len460			events:  fetched.events_out.len461			head:    fetched.head462			flagged: flagged.map(it.save_id)463		}))464		return 0465	}466	ok('fetched "${name}" into ${target}')467	note('${fetched.saves.len} saves at [${fetched.head}]')468	if source != '' {469		note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')470	}471	warn_flagged(flagged)472	return 0473}474475// land_clone creates ./<name> and commits the fetched history into it. Objects476// are checked on the way in, so a damaged remote fails here.477fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {478	// SQLite creates the file, not the folder holding it.479	os.mkdir_all(target, os.MkdirParams{})!480	mut store := storage.create(target)!481	fill_clone(store, name, fetched, source) or {482		store.rollback()483		store.close()484		return err485	}486	return store487}488489fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {490	store.begin()!491	exchange.write_fetched(store, fetched)!492	history.set_head(store, fetched.head)!493	exchange.remember_repo(store, name)!494	if source != '' {495		exchange.remember_source(store, source)!496	}497	snapshot.mark_restore(store)!498	store.commit()!499}500501// warn_flagged prints the line the document asks for, per incoming flagged save.502fn warn_flagged(flags []events.Flag) {503	for flag in flags {504		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }505		warn('[${flag.save_id}] came in flagged${reason}')506	}507}508509struct ReposJson {510	repos []string511}512513struct StolenJson {514	repo          string515	forward       bool516	saves         int517	objects       int518	events        int519	clashes       []string520	replayed      int521	kept_mine     int522	head          string523	offer_failed  bool524	flagged       []string525	previous_head string526	kept_versions []string527}