cmd_steal.v

18.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 · ${fmt.plural(report.fetched_saves, 'new save')}')121		} else {122			ok('brought in ${fmt.plural(report.fetched_saves, 'save')} from "${report.repo}" — head is now [${report.head}]')123		}124	} else {125		ok(if !guided {126			'brought in ${fmt.plural(report.fetched_saves, 'save')} 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}190191// ready_to_steal is the guided steal's first step: with unsaved work it offers checkpoint, save or stop,192// and answers whether to go on. A tree it cannot read counts as ready.193fn ready_to_steal() bool {194	store := open_store() or { return true }195	head := history.head(store)196	dirty := exchange.dirty(store, head) or {197		store.close()198		return true199	}200	if !dirty {201		store.close()202		return true203	}204	paths := unsaved_paths(store, head) or { []string{} }205	name := os.base(store.root)206	store.close()207	println('  ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')208	println('')209	println('  you have ${fmt.plural(paths.len, 'unsaved file')}')210	if paths.len > 0 {211		println('    ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')212		if paths.len > 5 {213			println('    and ${paths.len - 5} more')214		}215	}216	println('')217	println('  [c] checkpoint them and continue')218	println('  [s] save them with a message and continue')219	println('  [q] stop here')220	print('  › ')221	match os.get_line().trim_space() {222		'c' { return cmd_save(['before steal'], true) == 0 }223		's' {224			print('  message: ')225			message := os.get_line().trim_space()226			if message == '' {227				println('  no message — nothing was saved')228				return false229			}230			return cmd_save([message], false) == 0231		}232		else { return false }233	}234}235236fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {237	now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)238	before := if save := head {239		snapshot.index_files(history.files_of(store, save.id)!)240	} else {241		map[string]snapshot.FileEntry{}242	}243	mut paths := []string{}244	for path, file in now {245		old := before[path] or { snapshot.FileEntry{} }246		if file.hash != old.hash || file.executable != old.executable {247			paths << path248		}249	}250	for path, _ in before {251		if path !in now {252			paths << path253		}254	}255	paths.sort()256	return paths257}258259// guide_steal is the terminal walk-through steal calls as `guide`: overview, one prompt per clash (`b`260// steps back), then the summary and confirm. It returns each clash's side; quitting or declining cancels261// with an error, and the line and tree are unchanged. --mine/--theirs answer the clashes, not the summary.262fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {263	done_working()264	_, columns := view.terminal_size()265	width := if columns > 0 && columns < 80 { columns } else { 80 }266	println('')267	println('  ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')268	println('')269	println(overview_row('    ${fmt.gold('you')}      ${fmt.plural(preview.local.len, 'save')}  ${messages(preview.local.map(it.message))}',270		width))271	println(overview_row('    ${fmt.blue('remote')}   ${fmt.plural(preview.remote.len, 'save')}  ${messages(preview.remote.map(it.message))}',272		width))273	println('')274	println('  ${fmt.plural(preview.changed, 'file')} changed')275	println('    ${fmt.green('✓')} ${preview.automatic} combine by themselves')276	println('    ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')277	println('    ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {278		'needs'279	} else {280		'need'281	}} you')282	mut answers := map[string]exchange.Side{}283	if preview.all_side != '' {284		for clash in preview.clashes {285			answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }286		}287	}288	mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }289	for at < preview.clashes.len {290		clash := preview.clashes[at]291		println('')292		println('  ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')293		println(fmt.fit('    ${fmt.gold('you')}      ${clash.mine_stat}   ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',294			width))295		println(fmt.fit('    ${fmt.blue('remote')}   ${clash.theirs_stat}   ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',296			width))297		println('')298		println('  [m] keep mine   [t] take theirs   [d] see both changes')299		println('  [b] back   [?] help')300		print('  › ')301		match os.get_line().trim_space() {302			'm' {303				answers[clash.path] = .mine304				at++305			}306			't' {307				answers[clash.path] = .theirs308				at++309			}310			'b' {311				if at > 0 { at-- }312			}313			'd' { show_clash(clash) }314			'?' {315				println('  mine keeps your version; theirs takes the remote version.')316				println('  Nothing is lost either way; both versions stay in history.')317			}318			'q' { return error('steal cancelled') }319			else {}320		}321	}322	println('')323	println('  ${fmt.gold('ready to combine')}')324	for clash in preview.clashes {325		choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }326		println('    ${choice}  ${clash.path}')327	}328	println('    replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')329	println('    your originals stay in history: ${preview.local.map(it.id).join(' · ')}')330	if !confirm(preview.yes)! {331		return error('steal cancelled')332	}333	return answers334}335336fn messages(items []string) string {337	return items.map('"${it}"').join(' · ')338}339340// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.341fn overview_row(text string, width int) string {342	if fmt.visible_width(text) <= width {343		return text344	}345	return fmt.fit(text, width - 4) + ' · …'346}347348fn ago(unix_seconds i64) string {349	return time.unix(unix_seconds).relative()350}351352fn show_clash(clash exchange.Clash) {353	if clash.mine_patch == '' && clash.theirs_patch == '' {354		println('  binary file')355		return356	}357	mut lines := ['your changes']358	lines << clash.mine_patch.trim_right('\n').split_into_lines()359	lines << ''360	lines << 'remote changes'361	lines << clash.theirs_patch.trim_right('\n').split_into_lines()362	view.page_text(lines, clash.path)363}364365// offer_after_steal sends what this machine just decided. A failure here does366// not undo the steal; it is reported.367fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {368	if _ := exchange.offer(store, mut backend) {369		return true370	}371	return false372}373374// list_repos names the repos on this machine's remote: the first path segment of every key.375fn list_repos(args Args) int {376	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }377	keys := backend.list('') or { return refuse(args.json(), err.msg()) }378	mut names := map[string]bool{}379	for key in keys {380		// A repo is a folder with a `head`. Anything else at the top of the remote, such as381		// Kepr's own `.kepr/` folder of uploads, is not one and cannot be stolen.382		parts := key.trim_left('/').split('/')383		if parts.len == 2 && parts[0] != '' && parts[1] == 'head' {384			names[parts[0]] = true385		}386	}387	mut sorted := names.keys()388	sorted.sort()389	if args.json() {390		emit_json(json2.encode(ReposJson{391			repos: sorted392		}))393		return 0394	}395	if sorted.len == 0 {396		ok('the remote has no repos yet')397		return 0398	}399	for name in sorted {400		println('  ${name}')401	}402	return 0403}404405// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address406// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An407// addressed value that is missing either half is an error, never a name. The repo half becomes a408// URL path segment and a folder here, so it must be a name Kepr serves: letters, digits, `.`, `_`409// and `-`, and never `.` or `..`.410fn web_address(value string) ![]string {411	if !value.contains('/') {412		return []string{}413	}414	repo := value.all_after_last('/')415	host := value.all_before_last('/')416	if host == '' || repo in ['', '.', '..'] || !repo.bytes().all(it.is_alnum() || it in [417		`.`,418		`_`,419		`-`,420	]) {421		return error('"${value}" is not <host>/<repo> — a repo name is letters, digits, ., _ and -')422	}423	return [host, repo]424}425426// steal_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address427// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to428// take.429fn steal_web(host string, repo string, args Args) int {430	base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }431	if exchange.signed_in_to(base) {432		return steal_repo(repo, args)433	}434	mut web := remote.new_web(base) or { return refuse(args.json(), err.msg()) }435	web.on_body = fn [repo] (received u64) {436		working('stealing ${repo} · ${fmt.human_bytes(i64(received))} received')437	}438	return steal_whole(repo, args, web, '${base}/${repo}')439}440441// steal_repo fetches a whole repo into ./<name>, for a machine that does not442// have it yet.443fn steal_repo(name string, args Args) int {444	// Counted against the listings, so the line can say how many of each kind are in.445	backend := Watched{446		label: 'stealing ${name}'447		whole: true448		inner: this_machine_backend() or { return refuse(args.json(), err.msg()) }449	}450	return steal_whole(name, args, backend, '')451}452453// steal_whole is that fetch, from whichever remote was chosen: the whole history in, then the head's454// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays455// local until this machine signs in there.456fn steal_whole(name string, args Args, chosen remote.Backend, source string) int {457	target := os.join_path(os.getwd(), name)458	if os.exists(target) {459		return refuse(args.json(), '${name} already exists here — run steal inside it instead')460	}461	mut backend := chosen462	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }463	store := land_repo(target, name, fetched, source) or {464		os.rmdir_all(target) or {}465		return refuse(args.json(), err.msg())466	}467	// Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it468	// as "already exists", so the whole new folder goes.469	mut finished := false470	defer {471		store.close()472		if !finished {473			os.rmdir_all(target) or {}474		}475	}476	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }477	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {478		return refuse(args.json(), err.msg())479	}480	mut flagged := []events.Flag{}481	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {482		if flag.flagged {483			flagged << flag484		}485	}486	flagged.sort(a.save_id < b.save_id)487	finished = true488	if args.json() {489		emit_json(json2.encode(StolenJson{490			repo:         name491			forward:      true492			saves:        fetched.saves.len493			objects:      fetched.repo_objects.len494			events:       fetched.events_out.len495			head:         fetched.head496			flagged:      flagged.map(it.save_id)497			unsafe_paths: fetched.unsafe_paths498		}))499		return 0500	}501	ok('fetched "${name}" into ${target}')502	note('${fmt.plural(fetched.saves.len, 'save')} at [${fetched.head}]')503	if source != '' {504		note('read-only: steal keeps this copy up to date from ${source}; run "koh remote ${exchange.source_host(source)}" to offer from it')505	}506	warn_flagged(flagged)507	warn_unsafe_paths(fetched.unsafe_paths)508	return 0509}510511// land_repo creates ./<name> and writes the fetched history into it. Objects512// are checked on the way in, so a damaged remote fails here.513fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {514	// SQLite creates the file, not the folder holding it.515	os.mkdir_all(target, os.MkdirParams{})!516	mut store := storage.create(target)!517	fill_repo(store, name, fetched, source) or {518		store.rollback()519		store.close()520		return err521	}522	return store523}524525fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {526	store.begin()!527	exchange.write_fetched(store, fetched)!528	history.set_head(store, fetched.head)!529	exchange.remember_repo(store, name)!530	if source != '' {531		exchange.remember_source(store, source)!532	}533	snapshot.mark_restore(store)!534	store.end()!535}536537// warn_flagged prints the line the document asks for, per incoming flagged save.538fn warn_flagged(flags []events.Flag) {539	for flag in flags {540		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }541		warn('[${flag.save_id}] came in flagged${reason}')542	}543}544545fn warn_unsafe_paths(paths []string) {546	for path in paths {547		warn('dropped unsafe path ${path}')548	}549}550551struct ReposJson {552	repos []string553}554555struct StolenJson {556	repo          string557	forward       bool558	saves         int559	objects       int560	events        int561	clashes       []string562	replayed      int563	kept_mine     int564	head          string565	offer_failed  bool566	flagged       []string567	previous_head string568	kept_versions []string569	unsafe_paths  []string570}