steal.v

11.3 KB · raw

1//! Stealing: bringing another machine's saves in, and combining when both2//! machines moved.3//!4//! The order is always the same, and it is what makes an interrupted steal5//! harmless: read everything from the remote first, then land it in one6//! transaction, then write the working tree. Divergence is resolved per file —7//! a clash is never merged line by line, it is chosen.89module sync1011import os12import src.apply13import src.history14import src.remote15import src.snapshot16import src.storage1718pub enum Side {19	mine20	theirs21}2223// Clash is one file both machines changed differently. `stat` is the short24// diff stat the terminal prompt shows, so the choice is informed.25pub struct Clash {26pub mut:27	path   string28	mine   snapshot.FileEntry29	theirs snapshot.FileEntry30	base   snapshot.FileEntry31	stat   string32}3334pub struct StealOptions {35pub mut:36	mine   bool // resolve every clash as mine37	theirs bool // resolve every clash as theirs38	// Asked once per clash when neither flag is set. Nil means "nobody to ask".39	ask fn (clash Clash) Side = unsafe { nil }40}4142pub struct StealReport {43pub mut:44	repo            string45	forward         bool46	fetched_saves   int47	fetched_objects int48	fetched_events  int49	clashes         []string50	kept_mine       int51	replayed        int52	removed         int53	head            string54}5556pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'5758// steal brings the remote's new saves in. Inside a repo with its own work this59// may have to combine; a repo with nothing of its own simply fast-forwards.60pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {61	name := repo_name(store, mut backend)!62	head_before := history.head(store)63	if head := head_before {64		// Combining rewrites the working tree, so unsaved work would be lost.65		changed := dirty(store, head)!66		if changed {67			return error(dirty_tree)68		}69	}70	fetched := fetch_back_to_known(store, mut backend, name)!71	mut report := StealReport{72		repo:            name73		fetched_saves:   fetched.saves.len74		fetched_objects: fetched.repo_objects.len75		fetched_events:  fetched.events_out.len76		head:            fetched.head77	}78	mut local_only := []history.Save{}79	if head := head_before {80		// The remote builds on `base`. If that save is here but no longer on81		// the line, neither line contains the other; jumping to the remote82		// would silently leave this line behind.83		local_only = saves_after(store, head.id, fetched.base) or {84			return error('the remote builds on save ${fetched.base}, which is not on this line — load the save you want, then offer')85		}86	}87	if fetched.base == '' && local_only.len > 0 {88		return error('the remote keeps a different history — it shares no save with this repo')89	}9091	store.begin() or { return err }92	final := (write_and_combine(store, fetched, local_only, opts, mut report) or {93		store.rollback()94		return err95	})96	store.commit() or { return err }97	report.head = final.id9899	if head := head_before {100		if final.id == head.id {101			// The line did not move, so neither does the working tree.102			return report103		}104		files := history.files_of(store, final.id)!105		mut wanted := map[string]bool{}106		for file in files {107			wanted[file.path] = true108		}109		mut remove := []string{}110		for file in history.files_of(store, head.id)! {111			if file.path !in wanted {112				remove << file.path113			}114		}115		result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,116			remove)!117		report.removed = result.removed118	} else {119		files := history.files_of(store, final.id)!120		snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!121	}122	history.write_log_md(store)!123	return report124}125126// write_and_combine lands a fetch and, when the local line has saves the remote127// lacks, replays them on top of the remote head. It runs inside the caller's128// transaction and returns the new head.129fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save {130	write_fetched(store, fetched)!131	if fetched.saves.len == 0 {132		// The remote head is already on this line: level, or this machine is133		// ahead and the offer that follows a steal sends the rest.134		return history.head(store) or { return error('this repo has no head') }135	}136	if local_only.len == 0 {137		// Nothing of ours is missing from the remote: the line simply moves on.138		history.set_head(store, fetched.head)!139		report.forward = true140		return history.head(store) or { return error('the remote head did not land') }141	}142	remote_files := history.files_of(store, fetched.head)!143	base_files := if fetched.base != '' {144		history.files_of(store, fetched.base)!145	} else {146		[]snapshot.FileEntry{}147	}148	mut mine_files := []snapshot.FileEntry{}149	if our_head := history.head(store) {150		mine_files = history.files_of(store, our_head.id)!151	}152	clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files))153	report.clashes = clashes.map(it.path)154	side_of := choose_sides(clashes, opts)!155156	// Take the remote's line first, so the replayed saves chain onto it.157	history.set_head(store, fetched.head)!158	mut current := remote_files.clone()159	for save in local_only {160		parent_files := if save.parent != '' {161			history.files_of(store, save.parent)!162		} else {163			[]snapshot.FileEntry{}164		}165		target_files := history.files_of(store, save.id)!166		plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))167		if apply.conflicts(plan) {168			return error('combine could not apply ${plan.conflict} — it is not a clash file')169		}170		if plan.applied.len == 0 {171			// Everything this save touched was a clash resolved as theirs.172			continue173		}174		history.record_save(store, '', plan.files, history.SaveInput{175			message:       save.message176			created_at:    save.created_at177			machine:       save.machine178			agent:         save.agent179			session:       save.session180			is_checkpoint: save.is_checkpoint181			replayed_from: save.id182		})!183		current = plan.files.clone()184		report.replayed++185	}186187	mut mine_paths := []string{}188	for clash in clashes {189		if side_of[clash.path] == .mine {190			mine_paths << clash.path191		}192	}193	if mine_paths.len > 0 {194		// One last save puts my versions of the chosen files back.195		final_plan := apply.three_way(current, current, mine_files, mine_paths, [])196		if final_plan.applied.len > 0 {197			history.record_save(store, '', final_plan.files, history.SaveInput{198				message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {199					'file'200				} else {201					'files'202				}}'203			})!204			report.kept_mine = mine_paths.len205		}206	}207	return history.head(store) or { return error('the combined line has no head') }208}209210// find_clashes lists the files both sides changed, differently. Files changed211// identically on both sides are not clashes: there is nothing to decide.212pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {213	base_map := snapshot.index_files(base)214	mine_map := snapshot.index_files(mine)215	theirs_map := snapshot.index_files(theirs)216	mut paths := mine_map.keys()217	for path in theirs_map.keys() {218		if path !in mine_map {219			paths << path220		}221	}222	paths.sort()223	mut clashes := []Clash{}224	for path in paths {225		here := mine_map[path] or {226			snapshot.FileEntry{227				path: path228			}229		}230		there := theirs_map[path] or {231			snapshot.FileEntry{232				path: path233			}234		}235		was := base_map[path] or {236			snapshot.FileEntry{237				path: path238			}239		}240		if same(here, there) {241			continue242		}243		if same(here, was) || same(there, was) {244			continue245		}246		clashes << Clash{247			path:   path248			mine:   here249			theirs: there250			base:   was251		}252	}253	return clashes254}255256// describe_clashes adds the diff stat each prompt needs.257fn describe_clashes(store &storage.Store, clashes []Clash) []Clash {258	mut out := []Clash{}259	for clash in clashes {260		mut decorated := clash261		decorated.stat = clash_stat(store, clash)262		out << decorated263	}264	return out265}266267fn clash_stat(store &storage.Store, clash Clash) string {268	deltas := snapshot.diff_trees(store, snapshot.Tree{269		files: present(clash.mine)270	}, snapshot.Tree{271		files: present(clash.theirs)272	}) or { return '' }273	if deltas.len == 0 {274		return 'no textual difference'275	}276	return '+${deltas[0].added} -${deltas[0].removed}'277}278279// present drops an absent side, so a comparison never asks the object store for280// an empty hash.281fn present(entry snapshot.FileEntry) []snapshot.FileEntry {282	if entry.hash == '' {283		return []snapshot.FileEntry{}284	}285	return [entry]286}287288fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {289	mut sides := map[string]Side{}290	if clashes.len == 0 {291		return sides292	}293	for clash in clashes {294		if opts.mine {295			sides[clash.path] = .mine296			continue297		}298		if opts.theirs {299			sides[clash.path] = .theirs300			continue301		}302		if opts.ask != unsafe { nil } {303			sides[clash.path] = opts.ask(clash)304			continue305		}306		return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')307	}308	return sides309}310311fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {312	if a.hash == '' && b.hash == '' {313		return true314	}315	return a.hash == b.hash && a.executable == b.executable316}317318// dirty reports whether the working tree differs from `head` in a way a steal319// would overwrite.320fn dirty(store &storage.Store, head history.Save) !bool {321	return !snapshot.same_tree(history.files_of(store, head.id)!, snapshot.walk(store, store.root,322		snapshot.load(store.root))!)323}324325// fetch_repo pulls a whole repo — head, saves, objects and events — for326// `koh steal <name>`, which lands a copy in a new folder.327pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {328	remote_head := backend.get(head_key(name))!329	if !remote_head.found {330		return error('the remote has no repo called "${name}"')331	}332	save_ids := backend.list('${name}/saves')!333	object_keys := backend.list('${name}/objects')!334	event_keys := backend.list('${name}/events')!335	mut fetched := Fetched{336		head:            remote_head.body.bytestr().trim_space()337		remote_save_ids: map[string]bool{}338	}339	for key in object_keys {340		blob := backend.get(key)!341		if blob.found {342			fetched.repo_objects[os.base(key)] = blob.body343		}344	}345	for key in save_ids {346		body := backend.get(key)!347		if !body.found {348			continue349		}350		record := decode_save(body.body)!351		fetched.remote_save_ids[record.id] = true352		fetched.saves << record353	}354	fetched.saves = parent_first(fetched.saves)!355	for key in event_keys {356		body := backend.get(key)!357		if body.found {358			fetched.events_out << decode_event(body.body)!359		}360	}361	return fetched362}363364// parent_first orders saves so every parent lands before its children, as the365// foreign key on `parent` demands. Time cannot do it: a replayed save keeps its366// original, older time but sits on a newer parent.367fn parent_first(records []SaveRecord) ![]SaveRecord {368	mut by_id := map[string]SaveRecord{}369	for record in records {370		by_id[record.id] = record371	}372	mut placed := map[string]bool{}373	mut ordered := []SaveRecord{}374	for record in records {375		mut chain := []SaveRecord{}376		mut cursor := record.id377		for cursor != '' && cursor !in placed {378			if chain.len > records.len {379				return error('the save records on the remote form a loop')380			}381			current := by_id[cursor] or { return error('the remote is missing save ${cursor}') }382			chain << current383			cursor = current.parent384		}385		chain.reverse_in_place()386		for current in chain {387			placed[current.id] = true388			ordered << current389		}390	}391	return ordered392}