cmd_steal.v

14.3 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.exchange1819pub fn cmd_steal(raw []string) int {20	args := parse('steal', raw, Options{21		flags: ['list', 'mine', 'theirs', 'json', 'yes']22	}) or { return usage_error(err.msg()) }23	if args.flag('list') {24		if args.positional.len > 0 {25			return usage_error('steal --list takes no arguments')26		}27		return list_repos(args)28	}29	if storage.find_root(os.getwd()) == none {30		name := args.positional_at(0)31		if name == '' {32			return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')33		}34		if args.positional.len > 1 {35			return usage_error('steal takes one repo: koh steal myproject, or koh steal kepr.uk/nina')36		}37		address := web_address(name) or { return refuse(args.json(), err.msg()) }38		if address.len > 0 {39			return clone_web(address[0], address[1], args)40		}41		return clone_repo(name, args)42	}43	store := open_store() or { return refuse(args.json(), err.msg()) }44	defer {45		store.close()46	}47	if args.positional.len > 0 {48		return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')49	}50	if interactive() && os.is_atty(1) != 0 && !args.json() {51		if !ready_to_steal() {52			return 153		}54	}55	// A stolen repo has its own remote: the address it came from, until this machine signs in to56	// that Kepr, when the bucket takes over.57	source := exchange.source_of(store)58	mut upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))59	mut backend := remote.Backend(unsafe { nil })60	if source != '' && !upgraded {61		backend = exchange.read_only(source) or { return refuse(args.json(), err.msg()) }62	} else {63		backend = this_machine_backend() or { return refuse(args.json(), err.msg()) }64	}65	mut options := exchange.StealOptions{66		mine:   args.flag('mine')67		theirs: args.flag('theirs')68		yes:    args.flag('yes')69	}70	guided := !args.json() && interactive() && os.is_atty(1) != 071	if !options.mine && !options.theirs && guided {72		options.guide = guide_steal73	}74	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }75	if upgraded {76		exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }77		if !args.json() {78			ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')79		}80	}81	// The remote should end up with whatever this machine just decided — unless there is no remote82	// to write to: a repo still riding its source stays read-only, and quietly so.83	offered := if source != '' && !upgraded { true } else { offer_after_steal(store, mut backend) }84	if args.json() {85		emit_json(json2.encode(StolenJson{86			repo:          report.repo87			forward:       report.forward88			saves:         report.fetched_saves89			objects:       report.fetched_objects90			events:        report.fetched_events91			clashes:       report.clashes92			replayed:      report.replayed93			kept_mine:     report.kept_mine94			head:          report.head95			offer_failed:  !offered96			flagged:       report.flagged.map(it.save_id)97			previous_head: report.previous_head98			kept_versions: report.kept_versions99		}))100		return 0101	}102	if report.fetched_saves == 0 {103		ok('already up to date with "${report.repo}" — head is [${report.head}]')104		return 0105	}106	if report.forward {107		if guided {108			ok('${report.repo} is up to date · ${report.fetched_saves} new saves')109		} else {110			ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')111		}112	} else {113		ok(if guided {114			'combined · offered to ${report.repo}'115		} else {116			'brought in ${report.fetched_saves} saves from "${report.repo}"'117		})118		if source != '' && !upgraded {119			note('your saves stay on this machine (read-only source)')120		} else if !guided {121			note("replayed ${report.replayed} of this machine's saves onto the remote head")122		}123		if report.kept_mine > 0 {124			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')125		}126		note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')127		for kept in report.kept_versions {128			parts := kept.split_nth(':', 2)129			note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}')130		}131	}132	warn_flagged(report.flagged)133	if report.clashes.len > 0 {134		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')135	}136	if !offered {137		warn('the remote was not updated — run "koh offer" when it is reachable again')138	}139	return 0140}141142fn ready_to_steal() bool {143	store := open_store() or { return true }144	head := history.head(store)145	dirty := exchange.dirty(store, head) or {146		store.close()147		return true148	}149	if !dirty {150		store.close()151		return true152	}153	paths := unsaved_paths(store, head) or { []string{} }154	store.close()155	println('  ${fmt.gold(fmt.glyph_ok + ' steal')}')156	println('')157	println('  you have ${fmt.plural(paths.len, 'unsaved file')}')158	if paths.len > 0 {159		println('    ${paths[..if paths.len < 5 { paths.len } else { 5 }].join(' · ')}')160		if paths.len > 5 {161			println('    and ${paths.len - 5} more')162		}163	}164	println('')165	println('  [c] checkpoint them and continue')166	println('  [s] save them with a message and continue')167	println('  [q] stop here')168	print('  › ')169	match os.get_line().trim_space() {170		'c' { return cmd_save(['before steal'], true) == 0 }171		's' {172			print('  message: ')173			message := os.get_line().trim_space()174			return message != '' && cmd_save([message], false) == 0175		}176		else { return false }177	}178}179180fn unsaved_paths(store &storage.Store, head ?history.Save) ![]string {181	now := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)182	before := if save := head {183		snapshot.index_files(history.files_of(store, save.id)!)184	} else {185		map[string]snapshot.FileEntry{}186	}187	mut paths := []string{}188	for path, file in now {189		old := before[path] or { snapshot.FileEntry{} }190		if file.hash != old.hash || file.executable != old.executable {191			paths << path192		}193	}194	for path, _ in before {195		if path !in now {196			paths << path197		}198	}199	paths.sort()200	return paths201}202203fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {204	println('')205	println('  ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base}')206	println('')207	println('    ${fmt.gold('you')}      ${fmt.plural(preview.local.len, 'save')}  ${messages(preview.local.map(it.message))}')208	println('    ${fmt.blue('remote')}   ${fmt.plural(preview.remote.len, 'save')}  ${messages(preview.remote.map(it.message))}')209	println('')210	println('  ${fmt.plural(preview.changed, 'file')} changed')211	println('    ${fmt.green('✓')} ${preview.automatic} combine by themselves')212	println('    ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')213	println('    ${fmt.amber('!')} ${preview.clashes.len} need you')214	mut answers := map[string]exchange.Side{}215	mut at := 0216	for at < preview.clashes.len {217		clash := preview.clashes[at]218		println('')219		println('  ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')220		println('    ${fmt.gold('you')}      ${clash.mine_stat}')221		println('    ${fmt.blue('remote')}   ${clash.theirs_stat}')222		println('')223		println('  [m] keep mine   [t] take theirs   [d] see both changes')224		println('  [b] back   [?] help')225		print('  › ')226		match os.get_line().trim_space() {227			'm' {228				answers[clash.path] = .mine229				at++230			}231			't' {232				answers[clash.path] = .theirs233				at++234			}235			'b' {236				if at > 0 { at-- }237			}238			'd' { show_clash(clash) }239			'?' {240				println('  mine keeps your version; theirs takes the remote version.')241				println('  Nothing is lost either way; both versions stay in history.')242			}243			'q' { return error('steal cancelled') }244			else {}245		}246	}247	println('')248	println('  ${fmt.gold('ready to combine')}')249	for clash in preview.clashes {250		choice := if answers[clash.path] == .mine { 'keep mine' } else { 'take theirs' }251		println('    ${choice}  ${clash.path}')252	}253	println('    replay your ${fmt.plural(preview.local.len, 'save')} on top of the remote')254	println('    your originals stay in history: ${preview.local.map(it.id).join(' · ')}')255	if !confirm(preview.yes)! {256		return error('steal cancelled')257	}258	return answers259}260261fn messages(items []string) string {262	return fmt.fit(items.map('"${it}"').join(' · '), 52)263}264265fn show_clash(clash exchange.Clash) {266	if clash.mine_stat == 'binary' || clash.theirs_stat == 'binary' {267		println('  binary file')268		return269	}270	println('  ${fmt.gold('your changes')}')271	print(clash.mine_patch)272	println('  ${fmt.blue('remote changes')}')273	print(clash.theirs_patch)274}275276// offer_after_steal pushes what this machine just decided. A failure here does277// not undo the steal; it is reported.278fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {279	if _ := exchange.offer(store, mut backend) {280		return true281	}282	return false283}284285fn list_repos(args Args) int {286	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }287	keys := backend.list('') or { return refuse(args.json(), err.msg()) }288	mut names := map[string]bool{}289	for key in keys {290		parts := key.trim_left('/').split('/')291		if parts.len > 0 && parts[0] != '' {292			names[parts[0]] = true293		}294	}295	mut sorted := names.keys()296	sorted.sort()297	if args.json() {298		emit_json(json2.encode(ReposJson{299			repos: sorted300		}))301		return 0302	}303	if sorted.len == 0 {304		ok('the remote has no repos yet')305		return 0306	}307	for name in sorted {308		println('  ${name}')309	}310	return 0311}312313// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address314// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An315// addressed value that is missing either half is an error, never a name.316fn web_address(value string) ![]string {317	if !value.contains('/') {318		return []string{}319	}320	repo := value.all_after_last('/')321	host := value.all_before_last('/')322	if repo == '' || host == '' {323		return error('"${value}" is not <host>/<repo>')324	}325	return [host, repo]326}327328// clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address329// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to330// take.331fn clone_web(host string, repo string, args Args) int {332	base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }333	if exchange.signed_in_to(base) {334		return clone_repo(repo, args)335	}336	mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }337	return clone_with(repo, args, mut backend, '${base}/${repo}')338}339340// clone_repo fetches a whole repo into ./<name>, for a machine that does not341// have it yet.342fn clone_repo(name string, args Args) int {343	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }344	return clone_with(name, args, mut backend, '')345}346347// clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's348// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays349// local until this machine signs in there.350fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {351	target := os.join_path(os.getwd(), name)352	if os.exists(target) {353		return refuse(args.json(), '${name} already exists here — run steal inside it instead')354	}355	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }356	store := land_clone(target, name, fetched, source) or {357		os.rmdir_all(target) or {}358		return refuse(args.json(), err.msg())359	}360	// Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it361	// as "already exists", so the whole clone goes.362	mut finished := false363	defer {364		store.close()365		if !finished {366			os.rmdir_all(target) or {}367		}368	}369	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }370	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {371		return refuse(args.json(), err.msg())372	}373	mut flagged := []events.Flag{}374	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {375		if flag.flagged {376			flagged << flag377		}378	}379	flagged.sort(a.save_id < b.save_id)380	finished = true381	if args.json() {382		emit_json(json2.encode(StolenJson{383			repo:    name384			forward: true385			saves:   fetched.saves.len386			objects: fetched.repo_objects.len387			events:  fetched.events_out.len388			head:    fetched.head389			flagged: flagged.map(it.save_id)390		}))391		return 0392	}393	ok('fetched "${name}" into ${target}')394	note('${fetched.saves.len} saves at [${fetched.head}]')395	if source != '' {396		note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')397	}398	warn_flagged(flagged)399	return 0400}401402// land_clone creates ./<name> and commits the fetched history into it. Objects403// are checked on the way in, so a damaged remote fails here.404fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {405	// SQLite creates the file, not the folder holding it.406	os.mkdir_all(target, os.MkdirParams{})!407	mut store := storage.create(target)!408	fill_clone(store, name, fetched, source) or {409		store.rollback()410		store.close()411		return err412	}413	return store414}415416fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {417	store.begin()!418	exchange.write_fetched(store, fetched)!419	history.set_head(store, fetched.head)!420	exchange.remember_repo(store, name)!421	if source != '' {422		exchange.remember_source(store, source)!423	}424	snapshot.mark_restore(store)!425	store.commit()!426}427428// warn_flagged prints the line the document asks for, per incoming flagged save.429fn warn_flagged(flags []events.Flag) {430	for flag in flags {431		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }432		warn('[${flag.save_id}] came in flagged${reason}')433	}434}435436struct ReposJson {437	repos []string438}439440struct StolenJson {441	repo          string442	forward       bool443	saves         int444	objects       int445	events        int446	clashes       []string447	replayed      int448	kept_mine     int449	head          string450	offer_failed  bool451	flagged       []string452	previous_head string453	kept_versions []string454}