steal.v

10.1 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.events14import src.history15import src.remote16import src.snapshot17import src.storage1819pub enum Side {20	mine21	theirs22}2324// Clash is one file both machines changed differently. `stat` is the short25// diff stat the terminal prompt shows, so the choice is informed.26pub struct Clash {27pub mut:28	path   string29	mine   snapshot.FileEntry30	theirs snapshot.FileEntry31	base   snapshot.FileEntry32	stat   string33}3435pub struct StealOptions {36pub mut:37	mine   bool // resolve every clash as mine38	theirs bool // resolve every clash as theirs39	// Asked once per clash when neither flag is set. Nil means "nobody to ask".40	ask fn (clash Clash) Side = unsafe { nil }41}4243pub struct StealReport {44pub mut:45	repo            string46	forward         bool47	fetched_saves   int48	fetched_objects int49	fetched_events  int50	clashes         []string51	kept_mine       int52	replayed        int53	removed         int54	head            string55}5657pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'5859// steal brings the remote's new saves in. Inside a repo with its own work this60// may have to combine; a repo with nothing of its own simply fast-forwards.61pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {62	name := repo_name(store, mut backend)!63	head_before := history.head(store)64	if head := head_before {65		// Combining rewrites the working tree, so unsaved work would be lost.66		changed := dirty(store, head)!67		if changed {68			return error(dirty_tree)69		}70	}71	fetched := fetch_back_to_known(store, mut backend, name)!72	mut report := StealReport{73		repo:            name74		fetched_saves:   fetched.saves.len75		fetched_objects: fetched.repo_objects.len76		fetched_events:  fetched.events_out.len77		head:            fetched.head78	}79	mut local_only := []history.Save{}80	if head := head_before {81		local_only = saves_after(store, head.id, fetched.base) or { []history.Save{} }82	}83	if fetched.base == '' && local_only.len > 0 {84		return error('the remote keeps a different history — it shares no save with this repo')85	}8687	store.begin() or { return err }88	final := (write_and_combine(store, fetched, local_only, opts, mut report) or {89		store.rollback()90		return err91	})92	store.commit() or { return err }9394	if head := head_before {95		files := history.files_of(store, final.id)!96		mut wanted := map[string]bool{}97		for file in files {98			wanted[file.path] = true99		}100		mut remove := []string{}101		for file in history.files_of(store, head.id)! {102			if file.path !in wanted {103				remove << file.path104			}105		}106		result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,107			remove)!108		report.removed = result.removed109	} else {110		files := history.files_of(store, final.id)!111		snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!112	}113	report.head = final.id114	history.write_log_md(store)!115	return report116}117118// write_and_combine lands a fetch and, when the local line has saves the remote119// lacks, replays them on top of the remote head. It runs inside the caller's120// transaction and returns the new head.121fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save {122	write_fetched(store, fetched)!123	if local_only.len == 0 {124		// Nothing of ours is missing from the remote: the line simply moves on.125		history.set_head(store, fetched.head)!126		report.forward = true127		return history.head(store) or { return error('the remote head did not land') }128	}129	remote_files := history.files_of(store, fetched.head)!130	base_files := if fetched.base != '' {131		history.files_of(store, fetched.base)!132	} else {133		[]snapshot.FileEntry{}134	}135	mut mine_files := []snapshot.FileEntry{}136	if our_head := history.head(store) {137		mine_files = history.files_of(store, our_head.id)!138	}139	clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files))140	report.clashes = clashes.map(it.path)141	side_of := choose_sides(clashes, opts)!142143	// Take the remote's line first, so the replayed saves chain onto it.144	history.set_head(store, fetched.head)!145	mut current := remote_files.clone()146	for save in local_only {147		parent_files := if save.parent != '' {148			history.files_of(store, save.parent)!149		} else {150			[]snapshot.FileEntry{}151		}152		target_files := history.files_of(store, save.id)!153		plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))154		if apply.conflicts(plan) {155			return error('combine could not apply ${plan.conflict} — it is not a clash file')156		}157		if plan.applied.len == 0 {158			// Everything this save touched was a clash resolved as theirs.159			continue160		}161		history.record_save(store, '', plan.files, history.SaveInput{162			message:       save.message163			created_at:    save.created_at164			machine:       save.machine165			agent:         save.agent166			session:       save.session167			is_checkpoint: save.is_checkpoint168			replayed_from: save.id169		})!170		current = plan.files.clone()171		report.replayed++172	}173174	mut mine_paths := []string{}175	for clash in clashes {176		if side_of[clash.path] == .mine {177			mine_paths << clash.path178		}179	}180	if mine_paths.len > 0 {181		// One last save puts my versions of the chosen files back.182		final_plan := apply.three_way(current, current, mine_files, mine_paths, [])183		if final_plan.applied.len > 0 {184			history.record_save(store, '', final_plan.files, history.SaveInput{185				message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {186					'file'187				} else {188					'files'189				}}'190			})!191			report.kept_mine = mine_paths.len192		}193	}194	return history.head(store) or { return error('the combined line has no head') }195}196197// find_clashes lists the files both sides changed, differently. Files changed198// identically on both sides are not clashes: there is nothing to decide.199pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {200	base_map := snapshot.index_files(base)201	mine_map := snapshot.index_files(mine)202	theirs_map := snapshot.index_files(theirs)203	mut paths := mine_map.keys()204	for path in theirs_map.keys() {205		if path !in mine_map {206			paths << path207		}208	}209	paths.sort()210	mut clashes := []Clash{}211	for path in paths {212		here := mine_map[path] or {213			snapshot.FileEntry{214				path: path215			}216		}217		there := theirs_map[path] or {218			snapshot.FileEntry{219				path: path220			}221		}222		was := base_map[path] or {223			snapshot.FileEntry{224				path: path225			}226		}227		if same(here, there) {228			continue229		}230		if same(here, was) || same(there, was) {231			continue232		}233		clashes << Clash{234			path:   path235			mine:   here236			theirs: there237			base:   was238		}239	}240	return clashes241}242243// describe_clashes adds the diff stat each prompt needs.244fn describe_clashes(store &storage.Store, clashes []Clash) []Clash {245	mut out := []Clash{}246	for clash in clashes {247		mut decorated := clash248		decorated.stat = clash_stat(store, clash)249		out << decorated250	}251	return out252}253254fn clash_stat(store &storage.Store, clash Clash) string {255	deltas := snapshot.diff_trees(store, snapshot.Tree{256		files: present(clash.mine)257	}, snapshot.Tree{258		files: present(clash.theirs)259	}) or { return '' }260	if deltas.len == 0 {261		return 'no textual difference'262	}263	return '+${deltas[0].added} -${deltas[0].removed}'264}265266// present drops an absent side, so a comparison never asks the object store for267// an empty hash.268fn present(entry snapshot.FileEntry) []snapshot.FileEntry {269	if entry.hash == '' {270		return []snapshot.FileEntry{}271	}272	return [entry]273}274275fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {276	mut sides := map[string]Side{}277	if clashes.len == 0 {278		return sides279	}280	for clash in clashes {281		if opts.mine {282			sides[clash.path] = .mine283			continue284		}285		if opts.theirs {286			sides[clash.path] = .theirs287			continue288		}289		if opts.ask != unsafe { nil } {290			sides[clash.path] = opts.ask(clash)291			continue292		}293		return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')294	}295	return sides296}297298fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {299	if a.hash == '' && b.hash == '' {300		return true301	}302	return a.hash == b.hash && a.executable == b.executable303}304305// dirty reports whether the working tree differs from `head` in a way a steal306// would overwrite.307fn dirty(store &storage.Store, head history.Save) !bool {308	before := snapshot.index_files(history.files_of(store, head.id)!)309	after := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)310	for path in after.keys() {311		if path !in before || before[path].hash != after[path].hash {312			return true313		}314	}315	for path in before.keys() {316		if path !in after {317			return true318		}319	}320	return false321}322323// fetch_repo pulls a whole repo — head, saves, objects and events — for324// `koh steal <name>`, which lands a copy in a new folder.325pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {326	remote_head := backend.get(head_key(name))!327	if !remote_head.found {328		return error('the remote has no repo called "${name}"')329	}330	save_ids := backend.list('${name}/saves')!331	object_keys := backend.list('${name}/objects')!332	event_keys := backend.list('${name}/events')!333	mut fetched := Fetched{334		head:            remote_head.body.bytestr().trim_space()335		remote_save_ids: map[string]bool{}336	}337	for key in object_keys {338		blob := backend.get(key)!339		if blob.found {340			fetched.repo_objects[os.base(key)] = blob.body341		}342	}343	for key in save_ids {344		body := backend.get(key)!345		if !body.found {346			continue347		}348		record := decode_save(body.body)!349		fetched.remote_save_ids[record.id] = true350		fetched.saves << record351	}352	fetched.saves.sort(a.created_at < b.created_at)353	for key in event_keys {354		body := backend.get(key)!355		if body.found {356			fetched.events_out << decode_event(body.body)!357		}358	}359	return fetched360}