cmd_steal.v

18.8 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		json:    args.json()75		approve: approve_sync76	}77	if guided {78		options.guide = guide_steal79	}80	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }81	if upgraded {82		exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }83		if !args.json() {84			ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')85		}86	}87	// The remote should end up with whatever this machine just decided — unless there is no remote88	// to write to: a repo still riding its source stays read-only, and quietly so.89	offer_failed := !read_only && !offer_after_steal(store, mut backend)90	if args.json() {91		emit_json(json2.encode(StolenJson{92			repo:          report.repo93			forward:       report.forward94			saves:         report.fetched_saves95			objects:       report.fetched_objects96			events:        report.fetched_events97			clashes:       report.clashes98			replayed:      report.replayed99			kept_mine:     report.kept_mine100			head:          report.head101			offer_failed:  offer_failed102			flagged:       report.flagged.map(it.save_id)103			previous_head: report.previous_head104			kept_versions: report.kept_versions105			unsafe_paths:  report.unsafe_paths106		}))107		return 0108	}109	print_steal(report, guided, read_only, offer_failed)110	return 0111}112113// print_steal tells the person how a finished steal went: what came in, what the two machines114// disagreed on, and how to get back.115fn print_steal(report exchange.StealReport, guided bool, read_only bool, offer_failed bool) {116	if report.fetched_saves == 0 {117		ok('already up to date with "${report.repo}" — head is [${report.head}]')118		return119	}120	if report.forward {121		if guided {122			ok('${report.repo} is up to date · ${fmt.plural(report.fetched_saves, 'new save')}')123		} else {124			ok('brought in ${fmt.plural(report.fetched_saves, 'save')} from "${report.repo}" — head is now [${report.head}]')125		}126	} else {127		ok(if !guided {128			'brought in ${fmt.plural(report.fetched_saves, 'save')} from "${report.repo}"'129		} else if read_only || offer_failed {130			'combined'131		} else {132			'combined · offered to ${report.repo}'133		})134		if read_only {135			note('your saves stay on this machine (read-only source)')136		} else if !guided {137			note("replayed ${report.replayed} of this machine's saves onto the remote head")138		}139		if guided {140			note_choices(report)141		} else if report.kept_mine > 0 {142			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')143		}144		if guided {145			note('to undo all of it: koh load ${report.previous_head}')146		} else {147			note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')148		}149		note_kept_versions(report)150	}151	warn_flagged(report.flagged)152	warn_unsafe_paths(report.unsafe_paths)153	if report.clashes.len > 0 {154		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')155	}156	if offer_failed {157		warn('the remote was not updated — run "koh offer" when it is reachable again')158	}159}160161// on_terminal is when a steal asks rather than refuses: a person at both ends, and no --json.162fn on_terminal(json bool) bool {163	return !json && interactive() && os.is_atty(1) != 0164}165166// note_kept_versions names, for each file answered "theirs", the save that still holds this167// machine's version.168fn note_kept_versions(report exchange.StealReport) {169	// `<path>:<save>`: a save ID has no colon, a path may.170	for kept in report.kept_versions {171		path := kept.all_before_last(':')172		save := kept.all_after_last(':')173		note('your ${path} is in ${save} · koh peek ${save}')174	}175}176177// note_choices is the done screen's line of answers: `kept mine: a · took theirs: b`.178fn note_choices(report exchange.StealReport) {179	theirs := report.kept_versions.map(it.all_before_last(':'))180	mine := report.clashes.filter(it !in theirs)181	mut parts := []string{}182	if mine.len > 0 {183		parts << 'kept mine: ${mine.join(' · ')}'184	}185	if theirs.len > 0 {186		parts << 'took theirs: ${theirs.join(' · ')}'187	}188	if parts.len > 0 {189		note(parts.join(' · '))190	}191}192193// ready_to_steal is the guided steal's first step: with unsaved work it offers checkpoint, save or stop,194// and answers whether to go on. A tree it cannot read counts as ready.195fn ready_to_steal() bool {196	store := open_store() or { return true }197	head := history.head(store)198	dirty := exchange.dirty(store, head) or {199		store.close()200		return true201	}202	if !dirty {203		store.close()204		return true205	}206	paths := unsaved_paths(store, head) or { []string{} }207	name := os.base(store.root)208	store.close()209	println('  ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')210	println('')211	println('  you have ${fmt.plural(paths.len, 'unsaved file')}')212	if paths.len > 0 {213		println('    ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')214		if paths.len > 5 {215			println('    and ${paths.len - 5} more')216		}217	}218	println('')219	println('  [c] checkpoint them and continue')220	println('  [s] save them with a message and continue')221	println('  [q] stop here')222	print('  › ')223	match os.get_line().trim_space() {224		'c' { return cmd_save(['before steal'], true) == 0 }225		's' {226			print('  message: ')227			message := os.get_line().trim_space()228			if message == '' {229				println('  no message — nothing was saved')230				return false231			}232			return cmd_save([message], false) == 0233		}234		else { return false }235	}236}237238fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {239	now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)240	before := if save := head {241		snapshot.index_files(history.files_of(store, save.id)!)242	} else {243		map[string]snapshot.FileEntry{}244	}245	mut paths := []string{}246	for path, file in now {247		old := before[path] or { snapshot.FileEntry{} }248		if file.hash != old.hash || file.executable != old.executable {249			paths << path250		}251	}252	for path, _ in before {253		if path !in now {254			paths << path255		}256	}257	paths.sort()258	return paths259}260261// guide_steal is the terminal walk-through steal calls as `guide`: overview, one prompt per clash (`b`262// steps back), then the summary and confirm. It returns each clash's side; quitting or declining cancels263// with an error, and the line and tree are unchanged. --mine/--theirs answer the clashes, not the summary.264fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {265	done_working()266	_, columns := view.terminal_size()267	width := if columns > 0 && columns < 80 { columns } else { 80 }268	println('')269	println('  ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')270	println('')271	println(overview_row('    ${fmt.gold('you')}      ${fmt.plural(preview.local.len, 'save')}  ${messages(preview.local.map(it.message))}',272		width))273	println(overview_row('    ${fmt.blue('remote')}   ${fmt.plural(preview.remote.len, 'save')}  ${messages(preview.remote.map(it.message))}',274		width))275	println('')276	println('  ${fmt.plural(preview.changed, 'file')} changed')277	println('    ${fmt.green('✓')} ${preview.automatic} combine by themselves')278	println('    ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')279	println('    ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {280		'needs'281	} else {282		'need'283	}} you')284	mut answers := map[string]exchange.Side{}285	if preview.all_side != '' {286		for clash in preview.clashes {287			answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }288		}289	}290	mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }291	for at < preview.clashes.len {292		clash := preview.clashes[at]293		println('')294		println('  ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')295		println(fmt.fit('    ${fmt.gold('you')}      ${clash.mine_stat}   ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',296			width))297		println(fmt.fit('    ${fmt.blue('remote')}   ${clash.theirs_stat}   ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',298			width))299		println('')300		println('  [m] keep mine   [t] take theirs   [d] see both changes')301		println('  [b] back   [?] help')302		print('  › ')303		match os.get_line().trim_space() {304			'm' {305				answers[clash.path] = .mine306				at++307			}308			't' {309				answers[clash.path] = .theirs310				at++311			}312			'b' {313				if at > 0 { at-- }314			}315			'd' { show_clash(clash) }316			'?' {317				println('  mine keeps your version; theirs takes the remote version.')318				println('  Nothing is lost either way; both versions stay in history.')319			}320			'q' { return error('steal cancelled') }321			else {}322		}323	}324	println('')325	println('  ${fmt.gold('ready to combine')}')326	for clash in preview.clashes {327		choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }328		println('    ${choice}  ${clash.path}')329	}330	println('    replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')331	println('    your originals stay in history: ${preview.local.map(it.id).join(' · ')}')332	if !confirm(preview.yes)! {333		return error('steal cancelled')334	}335	return answers336}337338fn messages(items []string) string {339	return items.map('"${it}"').join(' · ')340}341342// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.343fn overview_row(text string, width int) string {344	if fmt.visible_width(text) <= width {345		return text346	}347	return fmt.fit(text, width - 4) + ' · …'348}349350fn ago(unix_seconds i64) string {351	return time.unix(unix_seconds).relative()352}353354fn show_clash(clash exchange.Clash) {355	if clash.mine_patch == '' && clash.theirs_patch == '' {356		println('  binary file')357		return358	}359	mut lines := ['your changes']360	lines << clash.mine_patch.trim_right('\n').split_into_lines()361	lines << ''362	lines << 'remote changes'363	lines << clash.theirs_patch.trim_right('\n').split_into_lines()364	view.page_text(lines, clash.path)365}366367// offer_after_steal sends what this machine just decided. A failure here does368// not undo the steal; it is reported.369fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {370	if _ := exchange.offer(store, mut backend) {371		return true372	}373	return false374}375376// list_repos names the repos on this machine's remote: the first path segment of every key.377fn list_repos(args Args) int {378	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }379	keys := backend.list('') or { return refuse(args.json(), err.msg()) }380	mut names := map[string]bool{}381	for key in keys {382		// A repo is a folder with a `head`. Anything else at the top of the remote, such as383		// Kepr's own `.kepr/` folder of uploads, is not one and cannot be stolen.384		parts := key.trim_left('/').split('/')385		if parts.len == 2 && parts[0] != '' && parts[1] == 'head' {386			names[parts[0]] = true387		}388	}389	mut sorted := names.keys()390	sorted.sort()391	if args.json() {392		emit_json(json2.encode(ReposJson{393			repos: sorted394		}))395		return 0396	}397	if sorted.len == 0 {398		ok('the remote has no repos yet')399		return 0400	}401	for name in sorted {402		println('  ${name}')403	}404	return 0405}406407// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address408// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An409// addressed value that is missing either half is an error, never a name. The repo half becomes a410// URL path segment and a folder here, so it must be a name Kepr serves: letters, digits, `.`, `_`411// and `-`, and never `.` or `..`.412fn web_address(value string) ![]string {413	if !value.contains('/') {414		return []string{}415	}416	repo := value.all_after_last('/')417	host := value.all_before_last('/')418	if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [419		`.`,420		`_`,421		`-`,422	]) {423		return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')424	}425	return [host, repo]426}427428// steal_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address429// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to430// take.431fn steal_web(host string, repo string, args Args) int {432	base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }433	if exchange.signed_in_to(base) {434		return steal_repo(repo, args)435	}436	mut web := remote.new_web(base) or { return refuse(args.json(), err.msg()) }437	web.on_body = fn [repo] (received u64) {438		working('stealing ${repo} · ${fmt.human_bytes(i64(received))} received')439	}440	return steal_whole(repo, args, web, '${base}/${repo}')441}442443// steal_repo fetches a whole repo into ./<name>, for a machine that does not444// have it yet.445fn steal_repo(name string, args Args) int {446	// Counted against the listings, so the line can say how many of each kind are in.447	backend := Watched{448		label: 'stealing ${name}'449		whole: true450		inner: this_machine_backend() or { return refuse(args.json(), err.msg()) }451	}452	return steal_whole(name, args, backend, '')453}454455// steal_whole is that fetch, from whichever remote was chosen: the whole history in, then the head's456// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays457// local until this machine signs in there.458fn steal_whole(name string, args Args, chosen remote.Backend, source string) int {459	target := os.join_path(os.getwd(), name)460	if os.exists(target) {461		return refuse(args.json(), '${name} already exists here — run steal inside it instead')462	}463	mut backend := chosen464	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }465	store := land_repo(target, name, fetched, source) or {466		os.rmdir_all(target) or {}467		return refuse(args.json(), err.msg())468	}469	// Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it470	// as "already exists", so the whole new folder goes.471	mut finished := false472	defer {473		store.close()474		if !finished {475			os.rmdir_all(target) or {}476		}477	}478	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }479	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {480		return refuse(args.json(), err.msg())481	}482	mut flagged := []events.Flag{}483	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {484		if flag.flagged {485			flagged << flag486		}487	}488	flagged.sort(a.save_id < b.save_id)489	finished = true490	if args.json() {491		emit_json(json2.encode(StolenJson{492			repo:         name493			forward:      true494			saves:        fetched.saves.len495			objects:      fetched.repo_objects.len496			events:       fetched.events_out.len497			head:         fetched.head498			flagged:      flagged.map(it.save_id)499			unsafe_paths: fetched.unsafe_paths500		}))501		return 0502	}503	ok('fetched "${name}" into ${target}')504	note('${fmt.plural(fetched.saves.len, 'save')} at [${fetched.head}]')505	if source != '' {506		note('read-only: steal keeps this copy up to date from ${source}; run "koh remote ${exchange.source_host(source)}" to offer from it')507	}508	warn_flagged(flagged)509	warn_unsafe_paths(fetched.unsafe_paths)510	return 0511}512513// land_repo creates ./<name> and writes the fetched history into it. Objects514// are checked on the way in, so a damaged remote fails here.515fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {516	// SQLite creates the file, not the folder holding it.517	os.mkdir_all(target, os.MkdirParams{})!518	mut store := storage.create(target)!519	fill_repo(store, name, fetched, source) or {520		store.rollback()521		store.close()522		return err523	}524	return store525}526527fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {528	store.begin()!529	exchange.write_fetched(store, fetched)!530	history.set_head(store, fetched.head)!531	exchange.remember_repo(store, name)!532	if source != '' {533		exchange.remember_source(store, source)!534	}535	snapshot.mark_restore(store)!536	store.end()!537}538539// warn_flagged prints the line the document asks for, per incoming flagged save.540fn warn_flagged(flags []events.Flag) {541	for flag in flags {542		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }543		warn('[${flag.save_id}] came in flagged${reason}')544	}545}546547fn warn_unsafe_paths(paths []string) {548	for path in paths {549		warn('dropped unsafe path ${path}')550	}551}552553struct ReposJson {554	repos []string555}556557struct StolenJson {558	repo          string559	forward       bool560	saves         int561	objects       int562	events        int563	clashes       []string564	replayed      int565	kept_mine     int566	head          string567	offer_failed  bool568	flagged       []string569	previous_head string570	kept_versions []string571	unsafe_paths  []string572}573574fn approve_sync(saves int, incoming_events int, yes bool, json bool) ! {575	if json && !yes {576		return error('needs --yes with --json')577	}578	if interactive() && !json {579		note('apply ${saves} incoming saves and ${incoming_events} events; working files and stripped history may change')580	}581	if !confirm(yes)! {582		return error('nothing was applied')583	}584}