cmd_steal.v

7.0 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']22	}) or { return usage_error(err.msg()) }23	if args.flag('list') {24		return list_repos(args)25	}26	if storage.find_root(os.getwd()) == none {27		name := args.positional_at(0)28		if name == '' {29			return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')30		}31		return clone_repo(name, args)32	}33	store := open_store() or { return refuse(args.json(), err.msg()) }34	defer {35		store.close()36	}37	if args.positional.len > 0 {38		return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')39	}40	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }41	mut options := exchange.StealOptions{42		mine:   args.flag('mine')43		theirs: args.flag('theirs')44	}45	if !options.mine && !options.theirs && interactive() && os.is_atty(1) != 0 {46		options.ask = ask_clash47	}48	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }49	// The remote should end up with whatever this machine just decided.50	offered := offer_after_steal(store, mut backend)51	if args.json() {52		emit_json(json2.encode(StolenJson{53			repo:          report.repo54			forward:       report.forward55			saves:         report.fetched_saves56			objects:       report.fetched_objects57			events:        report.fetched_events58			clashes:       report.clashes59			replayed:      report.replayed60			kept_mine:     report.kept_mine61			head:          report.head62			offer_failed:  !offered63			flagged:       report.flagged.map(it.save_id)64			previous_head: report.previous_head65		}))66		return 067	}68	if report.fetched_saves == 0 {69		ok('already up to date with "${report.repo}" — head is [${report.head}]')70		return 071	}72	if report.forward {73		ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')74	} else {75		ok('brought in ${report.fetched_saves} saves from "${report.repo}"')76		note("replayed ${report.replayed} of this machine's saves onto the remote head")77		if report.kept_mine > 0 {78			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')79		}80		note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')81	}82	warn_flagged(report.flagged)83	if report.clashes.len > 0 {84		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')85	}86	if !offered {87		warn('the remote was not updated — run "koh offer" when it is reachable again')88	}89	return 090}9192// offer_after_steal pushes what this machine just decided. A failure here does93// not undo the steal; it is reported.94fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {95	if _ := exchange.offer(store, mut backend) {96		return true97	}98	return false99}100101// ask_clash is the per-file question a terminal gets. Only a lowercase y-style102// answer is needed here: `m` for mine, `t` for theirs.103fn ask_clash(clash exchange.Clash) exchange.Side {104	println('  ${fmt.amber('both changed')} ${clash.path}   ${clash.stat}')105	print('    ${fmt.amber('m')}ine or ${fmt.amber('t')}heirs? [t] ')106	answer := os.get_line().trim_space()107	if answer == 'm' {108		return .mine109	}110	return .theirs111}112113fn list_repos(args Args) int {114	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }115	keys := backend.list('') or { return refuse(args.json(), err.msg()) }116	mut names := map[string]bool{}117	for key in keys {118		parts := key.trim_left('/').split('/')119		if parts.len > 0 && parts[0] != '' {120			names[parts[0]] = true121		}122	}123	mut sorted := names.keys()124	sorted.sort()125	if args.json() {126		emit_json(json2.encode(ReposJson{127			repos: sorted128		}))129		return 0130	}131	if sorted.len == 0 {132		ok('the remote has no repos yet')133		return 0134	}135	for name in sorted {136		println('  ${name}')137	}138	return 0139}140141// clone_repo fetches a whole repo into ./<name>, for a machine that does not142// have it yet.143fn clone_repo(name string, args Args) int {144	target := os.join_path(os.getwd(), name)145	if os.exists(target) {146		return refuse(args.json(), '${name} already exists here — run steal inside it instead')147	}148	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }149	fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }150	store := land_clone(target, name, fetched) or {151		// A folder left behind would make the retry refuse as "already exists".152		os.rmdir_all(target) or {}153		return refuse(args.json(), err.msg())154	}155	defer {156		store.close()157	}158	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }159	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {160		return refuse(args.json(), err.msg())161	}162	mut flagged := []events.Flag{}163	for _, flag in events.latest_flags(store) or { return refuse(args.json(), err.msg()) } {164		if flag.flagged {165			flagged << flag166		}167	}168	flagged.sort(a.save_id < b.save_id)169	if args.json() {170		emit_json(json2.encode(StolenJson{171			repo:    name172			forward: true173			saves:   fetched.saves.len174			objects: fetched.repo_objects.len175			events:  fetched.events_out.len176			head:    fetched.head177			flagged: flagged.map(it.save_id)178		}))179		return 0180	}181	ok('fetched "${name}" into ${target}')182	note('${fetched.saves.len} saves at [${fetched.head}]')183	warn_flagged(flagged)184	return 0185}186187// land_clone creates ./<name> and commits the fetched history into it. Objects188// are checked on the way in, so a damaged remote fails here.189fn land_clone(target string, name string, fetched exchange.Fetched) !storage.Store {190	// SQLite creates the file, not the folder holding it.191	os.mkdir_all(target, os.MkdirParams{})!192	mut store := storage.create(target)!193	fill_clone(store, name, fetched) or {194		store.rollback()195		store.close()196		return err197	}198	return store199}200201fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched) ! {202	store.begin()!203	exchange.write_fetched(store, fetched)!204	history.set_head(store, fetched.head)!205	exchange.remember_repo(store, name)!206	snapshot.mark_restore(store)!207	store.commit()!208}209210// warn_flagged prints the line the document asks for, per incoming flagged save.211fn warn_flagged(flags []events.Flag) {212	for flag in flags {213		reason := if flag.reason != '' { ' — ${flag.reason}' } else { '' }214		warn('[${flag.save_id}] came in flagged${reason}')215	}216}217218struct ReposJson {219	repos []string220}221222struct StolenJson {223	repo          string224	forward       bool225	saves         int226	objects       int227	events        int228	clashes       []string229	replayed      int230	kept_mine     int231	head          string232	offer_failed  bool233	flagged       []string234	previous_head string235}