cmd_steal.v

17.4 KB · raw

1//! `koh steal` — bring another machine's saves in.2//!3//! Inside a repo this takes 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 steal_web(address[0], address[1], args)42		}43		return steal_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		remote.Backend(watch('stealing', exchange.read_only(source) or {65			return refuse(args.json(), err.msg())66		}))67	} else {68		watched_backend('stealing') or { return refuse(args.json(), err.msg()) }69	}70	mut options := exchange.StealOptions{71		mine:   args.flag('mine')72		theirs: args.flag('theirs')73		yes:    args.flag('yes')74	}75	if guided {76		options.guide = guide_steal77	}78	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }79	if upgraded {80		exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }81		if !args.json() {82			ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')83		}84	}85	// The remote should end up with whatever this machine just decided — unless there is no remote86	// to write to: a repo still riding its source stays read-only, and quietly so.87	offer_failed := !read_only && !offer_after_steal(store, mut backend)88	if args.json() {89		emit_json(json2.encode(StolenJson{90			repo:          report.repo91			forward:       report.forward92			saves:         report.fetched_saves93			objects:       report.fetched_objects94			events:        report.fetched_events95			clashes:       report.clashes96			replayed:      report.replayed97			kept_mine:     report.kept_mine98			head:          report.head99			offer_failed:  offer_failed100			flagged:       report.flagged.map(it.save_id)101			previous_head: report.previous_head102			kept_versions: report.kept_versions103			unsafe_paths:  report.unsafe_paths104		}))105		return 0106	}107	print_steal(report, guided, read_only, offer_failed)108	return 0109}110111// print_steal tells the person how a finished steal went: what came in, what the two machines112// disagreed on, and how to get back.113fn print_steal(report exchange.StealReport, guided bool, read_only bool, offer_failed bool) {114	if report.fetched_saves == 0 {115		ok('already up to date with "${report.repo}" — head is [${report.head}]')116		return117	}118	if report.forward {119		if guided {120			ok('${report.repo} is up to date · ${report.fetched_saves} new saves')121		} else {122			ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')123		}124	} else {125		ok(if !guided {126			'brought in ${report.fetched_saves} saves from "${report.repo}"'127		} else if read_only || offer_failed {128			'combined'129		} else {130			'combined · offered to ${report.repo}'131		})132		if read_only {133			note('your saves stay on this machine (read-only source)')134		} else if !guided {135			note("replayed ${report.replayed} of this machine's saves onto the remote head")136		}137		if guided {138			note_choices(report)139		} else if report.kept_mine > 0 {140			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')141		}142		if guided {143			note('to undo all of it: koh load ${report.previous_head}')144		} else {145			note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')146		}147		note_kept_versions(report)148	}149	warn_flagged(report.flagged)150	warn_unsafe_paths(report.unsafe_paths)151	if report.clashes.len > 0 {152		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')153	}154	if offer_failed {155		warn('the remote was not updated — run "koh offer" when it is reachable again')156	}157}158159// on_terminal is when a steal asks rather than refuses: a person at both ends, and no --json.160fn on_terminal(json bool) bool {161	return !json && interactive() && os.is_atty(1) != 0162}163164// note_kept_versions names, for each file answered "theirs", the save that still holds this165// machine's version.166fn note_kept_versions(report exchange.StealReport) {167	// `<path>:<save>`: a save ID has no colon, a path may.168	for kept in report.kept_versions {169		path := kept.all_before_last(':')170		save := kept.all_after_last(':')171		note('your ${path} is in ${save} · koh peek ${save}')172	}173}174175// note_choices is the done screen's line of answers: `kept mine: a · took theirs: b`.176fn note_choices(report exchange.StealReport) {177	theirs := report.kept_versions.map(it.all_before_last(':'))178	mine := report.clashes.filter(it !in theirs)179	mut parts := []string{}180	if mine.len > 0 {181		parts << 'kept mine: ${mine.join(' · ')}'182	}183	if theirs.len > 0 {184		parts << 'took theirs: ${theirs.join(' · ')}'185	}186	if parts.len > 0 {187		note(parts.join(' · '))188	}189}190191fn ready_to_steal() bool {192	store := open_store() or { return true }193	head := history.head(store)194	dirty := exchange.dirty(store, head) or {195		store.close()196		return true197	}198	if !dirty {199		store.close()200		return true201	}202	paths := unsaved_paths(store, head) or { []string{} }203	name := os.base(store.root)204	store.close()205	println('  ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')206	println('')207	println('  you have ${fmt.plural(paths.len, 'unsaved file')}')208	if paths.len > 0 {209		println('    ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')210		if paths.len > 5 {211			println('    and ${paths.len - 5} more')212		}213	}214	println('')215	println('  [c] checkpoint them and continue')216	println('  [s] save them with a message and continue')217	println('  [q] stop here')218	print('  › ')219	match os.get_line().trim_space() {220		'c' { return cmd_save(['before steal'], true) == 0 }221		's' {222			print('  message: ')223			message := os.get_line().trim_space()224			if message == '' {225				println('  no message — nothing was saved')226				return false227			}228			return cmd_save([message], false) == 0229		}230		else { return false }231	}232}233234fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {235	now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)236	before := if save := head {237		snapshot.index_files(history.files_of(store, save.id)!)238	} else {239		map[string]snapshot.FileEntry{}240	}241	mut paths := []string{}242	for path, file in now {243		old := before[path] or { snapshot.FileEntry{} }244		if file.hash != old.hash || file.executable != old.executable {245			paths << path246		}247	}248	for path, _ in before {249		if path !in now {250			paths << path251		}252	}253	paths.sort()254	return paths255}256257fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {258	done_working()259	_, columns := view.terminal_size()260	width := if columns > 0 && columns < 80 { columns } else { 80 }261	println('')262	println('  ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')263	println('')264	println(overview_row('    ${fmt.gold('you')}      ${fmt.plural(preview.local.len, 'save')}  ${messages(preview.local.map(it.message))}',265		width))266	println(overview_row('    ${fmt.blue('remote')}   ${fmt.plural(preview.remote.len, 'save')}  ${messages(preview.remote.map(it.message))}',267		width))268	println('')269	println('  ${fmt.plural(preview.changed, 'file')} changed')270	println('    ${fmt.green('✓')} ${preview.automatic} combine by themselves')271	println('    ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')272	println('    ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {273		'needs'274	} else {275		'need'276	}} you')277	mut answers := map[string]exchange.Side{}278	if preview.all_side != '' {279		for clash in preview.clashes {280			answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }281		}282	}283	mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }284	for at < preview.clashes.len {285		clash := preview.clashes[at]286		println('')287		println('  ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')288		println(fmt.fit('    ${fmt.gold('you')}      ${clash.mine_stat}   ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',289			width))290		println(fmt.fit('    ${fmt.blue('remote')}   ${clash.theirs_stat}   ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',291			width))292		println('')293		println('  [m] keep mine   [t] take theirs   [d] see both changes')294		println('  [b] back   [?] help')295		print('  › ')296		match os.get_line().trim_space() {297			'm' {298				answers[clash.path] = .mine299				at++300			}301			't' {302				answers[clash.path] = .theirs303				at++304			}305			'b' {306				if at > 0 { at-- }307			}308			'd' { show_clash(clash) }309			'?' {310				println('  mine keeps your version; theirs takes the remote version.')311				println('  Nothing is lost either way; both versions stay in history.')312			}313			'q' { return error('steal cancelled') }314			else {}315		}316	}317	println('')318	println('  ${fmt.gold('ready to combine')}')319	for clash in preview.clashes {320		choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }321		println('    ${choice}  ${clash.path}')322	}323	println('    replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')324	println('    your originals stay in history: ${preview.local.map(it.id).join(' · ')}')325	if !confirm(preview.yes)! {326		return error('steal cancelled')327	}328	return answers329}330331fn messages(items []string) string {332	return items.map('"${it}"').join(' · ')333}334335// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.336fn overview_row(text string, width int) string {337	if fmt.visible_width(text) <= width {338		return text339	}340	return fmt.fit(text, width - 4) + ' · …'341}342343fn ago(unix_seconds i64) string {344	return time.unix(unix_seconds).relative()345}346347fn show_clash(clash exchange.Clash) {348	if clash.mine_patch == '' && clash.theirs_patch == '' {349		println('  binary file')350		return351	}352	mut lines := ['your changes']353	lines << clash.mine_patch.trim_right('\n').split_into_lines()354	lines << ''355	lines << 'remote changes'356	lines << clash.theirs_patch.trim_right('\n').split_into_lines()357	view.page_text(lines, clash.path)358}359360// offer_after_steal sends what this machine just decided. A failure here does361// not undo the steal; it is reported.362fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {363	if _ := exchange.offer(store, mut backend) {364		return true365	}366	return false367}368369fn list_repos(args Args) int {370	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }371	keys := backend.list('') or { return refuse(args.json(), err.msg()) }372	mut names := map[string]bool{}373	for key in keys {374		parts := key.trim_left('/').split('/')375		if parts.len > 0 && parts[0] != '' {376			names[parts[0]] = true377		}378	}379	mut sorted := names.keys()380	sorted.sort()381	if args.json() {382		emit_json(json2.encode(ReposJson{383			repos: sorted384		}))385		return 0386	}387	if sorted.len == 0 {388		ok('the remote has no repos yet')389		return 0390	}391	for name in sorted {392		println('  ${name}')393	}394	return 0395}396397// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address398// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An399// addressed value that is missing either half is an error, never a name. The repo half becomes a400// URL path segment and a folder here, so it must be a name Kepr serves: letters, digits, `.`, `_`401// and `-`, and never `.` or `..`.402fn web_address(value string) ![]string {403	if !value.contains('/') {404		return []string{}405	}406	repo := value.all_after_last('/')407	host := value.all_before_last('/')408	if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [409		`.`,410		`_`,411		`-`,412	]) {413		return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')414	}415	return [host, repo]416}417418// steal_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address419// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to420// take.421fn steal_web(host string, repo string, args Args) int {422	base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }423	if exchange.signed_in_to(base) {424		return steal_repo(repo, args)425	}426	mut backend := remote.Backend(watch('stealing ${repo}', exchange.read_only('${base}/${repo}') or {427		return refuse(args.json(), err.msg())428	}))429	return steal_whole(repo, args, mut backend, '${base}/${repo}')430}431432// steal_repo fetches a whole repo into ./<name>, for a machine that does not433// have it yet.434fn steal_repo(name string, args Args) int {435	mut backend := watched_backend('stealing ${name}') or { return refuse(args.json(), err.msg()) }436	return steal_whole(name, args, mut backend, '')437}438439// steal_whole is that fetch, from whichever remote was chosen: the whole history in, then the head's440// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays441// local until this machine signs in there.442fn steal_whole(name string, args Args, mut backend remote.Backend, source string) int {443	target := os.join_path(os.getwd(), name)444	if os.exists(target) {445		return refuse(args.json(), '${name} already exists here — run steal inside it instead')446	}447	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }448	store := land_repo(target, name, fetched, source) or {449		os.rmdir_all(target) or {}450		return refuse(args.json(), err.msg())451	}452	// Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it453	// as "already exists", so the whole new folder goes.454	mut finished := false455	defer {456		store.close()457		if !finished {458			os.rmdir_all(target) or {}459		}460	}461	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }462	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {463		return refuse(args.json(), err.msg())464	}465	mut flagged := []events.Flag{}466	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {467		if flag.flagged {468			flagged << flag469		}470	}471	flagged.sort(a.save_id < b.save_id)472	finished = true473	if args.json() {474		emit_json(json2.encode(StolenJson{475			repo:         name476			forward:      true477			saves:        fetched.saves.len478			objects:      fetched.repo_objects.len479			events:       fetched.events_out.len480			head:         fetched.head481			flagged:      flagged.map(it.save_id)482			unsafe_paths: fetched.unsafe_paths483		}))484		return 0485	}486	ok('fetched "${name}" into ${target}')487	note('${fetched.saves.len} saves at [${fetched.head}]')488	if source != '' {489		note('read-only: this copy keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')490	}491	warn_flagged(flagged)492	warn_unsafe_paths(fetched.unsafe_paths)493	return 0494}495496// land_repo creates ./<name> and writes the fetched history into it. Objects497// are checked on the way in, so a damaged remote fails here.498fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {499	// SQLite creates the file, not the folder holding it.500	os.mkdir_all(target, os.MkdirParams{})!501	mut store := storage.create(target)!502	fill_repo(store, name, fetched, source) or {503		store.rollback()504		store.close()505		return err506	}507	return store508}509510fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {511	store.begin()!512	exchange.write_fetched(store, fetched)!513	history.set_head(store, fetched.head)!514	exchange.remember_repo(store, name)!515	if source != '' {516		exchange.remember_source(store, source)!517	}518	snapshot.mark_restore(store)!519	store.end()!520}521522// warn_flagged prints the line the document asks for, per incoming flagged save.523fn warn_flagged(flags []events.Flag) {524	for flag in flags {525		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }526		warn('[${flag.save_id}] came in flagged${reason}')527	}528}529530fn warn_unsafe_paths(paths []string) {531	for path in paths {532		warn('dropped unsafe path ${path}')533	}534}535536struct ReposJson {537	repos []string538}539540struct StolenJson {541	repo          string542	forward       bool543	saves         int544	objects       int545	events        int546	clashes       []string547	replayed      int548	kept_mine     int549	head          string550	offer_failed  bool551	flagged       []string552	previous_head string553	kept_versions []string554	unsafe_paths  []string555}