steal.v

9.8 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		local_only = saves_after(store, head.id, fetched.base) or { []history.Save{} }81	}82	if fetched.base == '' && local_only.len > 0 {83		return error('the remote keeps a different history — it shares no save with this repo')84	}8586	store.begin() or { return err }87	final := (write_and_combine(store, fetched, local_only, opts, mut report) or {88		store.rollback()89		return err90	})91	store.commit() or { return err }9293	if head := head_before {94		files := history.files_of(store, final.id)!95		mut wanted := map[string]bool{}96		for file in files {97			wanted[file.path] = true98		}99		mut remove := []string{}100		for file in history.files_of(store, head.id)! {101			if file.path !in wanted {102				remove << file.path103			}104		}105		result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,106			remove)!107		report.removed = result.removed108	} else {109		files := history.files_of(store, final.id)!110		snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!111	}112	report.head = final.id113	history.write_log_md(store)!114	return report115}116117// write_and_combine lands a fetch and, when the local line has saves the remote118// lacks, replays them on top of the remote head. It runs inside the caller's119// transaction and returns the new head.120fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save {121	write_fetched(store, fetched)!122	if local_only.len == 0 {123		// Nothing of ours is missing from the remote: the line simply moves on.124		history.set_head(store, fetched.head)!125		report.forward = true126		return history.head(store) or { return error('the remote head did not land') }127	}128	remote_files := history.files_of(store, fetched.head)!129	base_files := if fetched.base != '' {130		history.files_of(store, fetched.base)!131	} else {132		[]snapshot.FileEntry{}133	}134	mut mine_files := []snapshot.FileEntry{}135	if our_head := history.head(store) {136		mine_files = history.files_of(store, our_head.id)!137	}138	clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files))139	report.clashes = clashes.map(it.path)140	side_of := choose_sides(clashes, opts)!141142	// Take the remote's line first, so the replayed saves chain onto it.143	history.set_head(store, fetched.head)!144	mut current := remote_files.clone()145	for save in local_only {146		parent_files := if save.parent != '' {147			history.files_of(store, save.parent)!148		} else {149			[]snapshot.FileEntry{}150		}151		target_files := history.files_of(store, save.id)!152		plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))153		if apply.conflicts(plan) {154			return error('combine could not apply ${plan.conflict} — it is not a clash file')155		}156		if plan.applied.len == 0 {157			// Everything this save touched was a clash resolved as theirs.158			continue159		}160		history.record_save(store, '', plan.files, history.SaveInput{161			message:       save.message162			created_at:    save.created_at163			machine:       save.machine164			agent:         save.agent165			session:       save.session166			is_checkpoint: save.is_checkpoint167			replayed_from: save.id168		})!169		current = plan.files.clone()170		report.replayed++171	}172173	mut mine_paths := []string{}174	for clash in clashes {175		if side_of[clash.path] == .mine {176			mine_paths << clash.path177		}178	}179	if mine_paths.len > 0 {180		// One last save puts my versions of the chosen files back.181		final_plan := apply.three_way(current, current, mine_files, mine_paths, [])182		if final_plan.applied.len > 0 {183			history.record_save(store, '', final_plan.files, history.SaveInput{184				message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {185					'file'186				} else {187					'files'188				}}'189			})!190			report.kept_mine = mine_paths.len191		}192	}193	return history.head(store) or { return error('the combined line has no head') }194}195196// find_clashes lists the files both sides changed, differently. Files changed197// identically on both sides are not clashes: there is nothing to decide.198pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {199	base_map := snapshot.index_files(base)200	mine_map := snapshot.index_files(mine)201	theirs_map := snapshot.index_files(theirs)202	mut paths := mine_map.keys()203	for path in theirs_map.keys() {204		if path !in mine_map {205			paths << path206		}207	}208	paths.sort()209	mut clashes := []Clash{}210	for path in paths {211		here := mine_map[path] or {212			snapshot.FileEntry{213				path: path214			}215		}216		there := theirs_map[path] or {217			snapshot.FileEntry{218				path: path219			}220		}221		was := base_map[path] or {222			snapshot.FileEntry{223				path: path224			}225		}226		if same(here, there) {227			continue228		}229		if same(here, was) || same(there, was) {230			continue231		}232		clashes << Clash{233			path:   path234			mine:   here235			theirs: there236			base:   was237		}238	}239	return clashes240}241242// describe_clashes adds the diff stat each prompt needs.243fn describe_clashes(store &storage.Store, clashes []Clash) []Clash {244	mut out := []Clash{}245	for clash in clashes {246		mut decorated := clash247		decorated.stat = clash_stat(store, clash)248		out << decorated249	}250	return out251}252253fn clash_stat(store &storage.Store, clash Clash) string {254	deltas := snapshot.diff_trees(store, snapshot.Tree{255		files: present(clash.mine)256	}, snapshot.Tree{257		files: present(clash.theirs)258	}) or { return '' }259	if deltas.len == 0 {260		return 'no textual difference'261	}262	return '+${deltas[0].added} -${deltas[0].removed}'263}264265// present drops an absent side, so a comparison never asks the object store for266// an empty hash.267fn present(entry snapshot.FileEntry) []snapshot.FileEntry {268	if entry.hash == '' {269		return []snapshot.FileEntry{}270	}271	return [entry]272}273274fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {275	mut sides := map[string]Side{}276	if clashes.len == 0 {277		return sides278	}279	for clash in clashes {280		if opts.mine {281			sides[clash.path] = .mine282			continue283		}284		if opts.theirs {285			sides[clash.path] = .theirs286			continue287		}288		if opts.ask != unsafe { nil } {289			sides[clash.path] = opts.ask(clash)290			continue291		}292		return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')293	}294	return sides295}296297fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {298	if a.hash == '' && b.hash == '' {299		return true300	}301	return a.hash == b.hash && a.executable == b.executable302}303304// dirty reports whether the working tree differs from `head` in a way a steal305// would overwrite.306fn dirty(store &storage.Store, head history.Save) !bool {307	return !snapshot.same_tree(history.files_of(store, head.id)!, snapshot.walk(store, store.root,308		snapshot.load(store.root))!)309}310311// fetch_repo pulls a whole repo — head, saves, objects and events — for312// `koh steal <name>`, which lands a copy in a new folder.313pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {314	remote_head := backend.get(head_key(name))!315	if !remote_head.found {316		return error('the remote has no repo called "${name}"')317	}318	save_ids := backend.list('${name}/saves')!319	object_keys := backend.list('${name}/objects')!320	event_keys := backend.list('${name}/events')!321	mut fetched := Fetched{322		head:            remote_head.body.bytestr().trim_space()323		remote_save_ids: map[string]bool{}324	}325	for key in object_keys {326		blob := backend.get(key)!327		if blob.found {328			fetched.repo_objects[os.base(key)] = blob.body329		}330	}331	for key in save_ids {332		body := backend.get(key)!333		if !body.found {334			continue335		}336		record := decode_save(body.body)!337		fetched.remote_save_ids[record.id] = true338		fetched.saves << record339	}340	fetched.saves.sort(a.created_at < b.created_at)341	for key in event_keys {342		body := backend.get(key)!343		if body.found {344			fetched.events_out << decode_event(body.body)!345		}346	}347	return fetched348}