1cace06dc178

wip: add the first green guided steal slice and record the remaining 8e work (3.0.101)

time
2026-09-28 03:46 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
gpt-5
session
guided steal
parent
128ef9e650ad
6 files VERSION+1 −1src/cli/cmd_steal.v+165 −18src/exchange/exchange_test.v+1 −0src/exchange/steal.v+88 −11tests/cli_test.v+1 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.1001+3.0.101

view file

src/cli/cmd_steal.v+165 −18
@@ -18,7 +18,7 @@1818 1919 pub fn cmd_steal(raw []string) int {2020 	args := parse('steal', raw, Options{21-		flags: ['list', 'mine', 'theirs', 'json']21+		flags: ['list', 'mine', 'theirs', 'json', 'yes']2222 	}) or { return usage_error(err.msg()) }2323 	if args.flag('list') {2424 		if args.positional.len > 0 {@@ -47,6 +47,11 @@4747 	if args.positional.len > 0 {4848 		return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')4949 	}50+	if interactive() && os.is_atty(1) != 0 && !args.json() {51+		if !ready_to_steal() {52+			return 153+		}54+	}5055 	// A stolen repo has its own remote: the address it came from, until this machine signs in to5156 	// that Kepr, when the bucket takes over.5257 	source := exchange.source_of(store)@@ -60,9 +65,11 @@6065 	mut options := exchange.StealOptions{6166 		mine:   args.flag('mine')6267 		theirs: args.flag('theirs')68+		yes:    args.flag('yes')6369 	}64-	if !options.mine && !options.theirs && interactive() && os.is_atty(1) != 0 {65-		options.ask = ask_clash70+	guided := !args.json() && interactive() && os.is_atty(1) != 071+	if !options.mine && !options.theirs && guided {72+		options.guide = guide_steal6673 	}6774 	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }6875 	if upgraded {@@ -88,6 +95,7 @@8895 			offer_failed:  !offered8996 			flagged:       report.flagged.map(it.save_id)9097 			previous_head: report.previous_head98+			kept_versions: report.kept_versions9199 		}))92100 		return 093101 	}@@ -96,14 +104,30 @@96104 		return 097105 	}98106 	if report.forward {99-		ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')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+		}100112 	} else {101-		ok('brought in ${report.fetched_saves} saves from "${report.repo}"')102-		note("replayed ${report.replayed} of this machine's saves onto the remote head")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+		}103123 		if report.kept_mine > 0 {104124 			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')105125 		}106126 		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+		}107131 	}108132 	warn_flagged(report.flagged)109133 	if report.clashes.len > 0 {@@ -115,6 +139,140 @@115139 	return 0116140 }117141 142+fn 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+}179+180+fn 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+}202+203+fn 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+}260+261+fn messages(items []string) string {262+	return fmt.fit(items.map('"${it}"').join(' · '), 52)263+}264+265+fn 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+}275+118276 // offer_after_steal pushes what this machine just decided. A failure here does119277 // not undo the steal; it is reported.120278 fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {@@ -124,18 +282,6 @@124282 	return false125283 }126284 127-// ask_clash is the per-file question a terminal gets. Only a lowercase y-style128-// answer is needed here: `m` for mine, `t` for theirs.129-fn ask_clash(clash exchange.Clash) exchange.Side {130-	println('  ${fmt.amber('both changed')} ${clash.path}   ${clash.stat}')131-	print('    ${fmt.amber('m')}ine or ${fmt.amber('t')}heirs? [t] ')132-	answer := os.get_line().trim_space()133-	if answer == 'm' {134-		return .mine135-	}136-	return .theirs137-}138-139285 fn list_repos(args Args) int {140286 	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }141287 	keys := backend.list('') or { return refuse(args.json(), err.msg()) }@@ -304,4 +450,5 @@304450 	offer_failed  bool305451 	flagged       []string306452 	previous_head string453+	kept_versions []string307454 }

view file

src/exchange/exchange_test.v+1 −0
@@ -472,6 +472,7 @@472472 	report := steal_now(b, mut backend)!473473 	assert report.clashes == ['same.txt']474474 	assert report.kept_mine == 0475+	assert report.kept_versions == ['same.txt:${report.previous_head}']475476 	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'476477 	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'477478 }

view file

src/exchange/steal.v+88 −11
@@ -26,19 +26,37 @@2626 // diff stat the terminal prompt shows, so the choice is informed.2727 pub struct Clash {2828 pub mut:29-	path   string30-	mine   snapshot.FileEntry31-	theirs snapshot.FileEntry32-	base   snapshot.FileEntry33-	stat   string29+	path         string30+	mine         snapshot.FileEntry31+	theirs       snapshot.FileEntry32+	base         snapshot.FileEntry33+	mine_stat    string34+	theirs_stat  string35+	mine_patch   string36+	theirs_patch string3437 }3538 39+pub struct StealPreview {40+pub:41+	repo      string42+	base      string43+	local     []history.Save44+	remote    []SaveRecord45+	changed   int46+	automatic int47+	identical int48+	clashes   []Clash49+	yes       bool50+}51+3652 pub struct StealOptions {3753 pub mut:3854 	mine   bool // resolve every clash as mine3955 	theirs bool // resolve every clash as theirs4056 	// Asked once per clash when neither flag is set. Nil means "nobody to ask".41-	ask fn (clash Clash) Side = unsafe { nil }57+	ask   fn (clash Clash) Side                      = unsafe { nil }58+	guide fn (preview StealPreview) !map[string]Side = unsafe { nil }59+	yes   bool4260 }4361 4462 pub struct StealReport {@@ -57,6 +75,7 @@5775 	// Set when a combine took this machine's saves off the line: loading it5876 	// brings back the tree from before the steal, less anything stripped.5977 	previous_head string78+	kept_versions []string6079 }6180 6281 pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'@@ -104,10 +123,20 @@104123 	if fetched.saves.len > 0 && local_only.len > 0 {105124 		clashes = combine_clashes(store, fetched)!106125 		report.clashes = clashes.map(it.path)107-		// Asked before the write lock: a person thinking must not stall every108-		// agent's save on this repo.109-		side_of = choose_sides(clashes, opts)!126+		if opts.guide != unsafe { nil } && !opts.mine && !opts.theirs {127+			side_of = opts.guide(combine_preview(store, name, fetched, local_only, clashes,128+				opts.yes)!)!129+		} else {130+			// Asked before the write lock: a person thinking must not stall every131+			// agent's save on this repo.132+			side_of = choose_sides(clashes, opts)!133+		}110134 	}135+	for clash in clashes {136+		if side_of[clash.path] == .theirs {137+			report.kept_versions << '${clash.path}:${local_only.last().id}'138+		}139+	}111140 112141 	store.begin() or { return err }113142 	// The questions ran without the lock. If a save or an edit landed@@ -348,12 +377,60 @@348377 		head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,349378 		remote_head.created_at, strips))350379 	for mut clash in clashes {380+		base := fetched_bytes(store, fetched, clash.base)!351381 		mine := fetched_bytes(store, fetched, clash.mine)!352382 		theirs := fetched_bytes(store, fetched, clash.theirs)!353-		delta := snapshot.diff_bytes(clash.path, 'modified', mine, theirs)!354-		clash.stat = if delta.binary { 'binary' } else { '+${delta.added} -${delta.removed}' }383+		mine_delta := snapshot.diff_bytes(clash.path, 'modified', base, mine)!384+		theirs_delta := snapshot.diff_bytes(clash.path, 'modified', base, theirs)!385+		clash.mine_stat = delta_stat(mine_delta)386+		clash.theirs_stat = delta_stat(theirs_delta)387+		clash.mine_patch = mine_delta.patch388+		clash.theirs_patch = theirs_delta.patch355389 	}356390 	return clashes391+}392+393+fn delta_stat(delta snapshot.Delta) string {394+	if delta.binary {395+		return 'binary'396+	}397+	return '+${delta.added} -${delta.removed}'398+}399+400+fn combine_preview(store &storage.Store, name string, fetched Fetched, local []history.Save, clashes []Clash, yes bool) !StealPreview {401+	base := history.files_of(store, fetched.base)!402+	mine := history.files_of(store, local.last().id)!403+	theirs := history.files_of(store, fetched.head)!404+	mine_changed := changed_paths(base, mine)405+	theirs_changed := changed_paths(base, theirs)406+	mut changed := map[string]bool{}407+	for path in mine_changed {408+		changed[path] = true409+	}410+	for path in theirs_changed {411+		changed[path] = true412+	}413+	mut identical := 0414+	base_map := snapshot.index_files(base)415+	mine_map := snapshot.index_files(mine)416+	theirs_map := snapshot.index_files(theirs)417+	for path, _ in changed {418+		if !same(mine_map[path] or { snapshot.FileEntry{} }, base_map[path] or { snapshot.FileEntry{} })419+			&& same(mine_map[path] or { snapshot.FileEntry{} }, theirs_map[path] or { snapshot.FileEntry{} }) {420+			identical++421+		}422+	}423+	return StealPreview{424+		repo:      name425+		base:      fetched.base426+		local:     local427+		remote:    fetched.saves428+		changed:   changed.len429+		automatic: changed.len - identical - clashes.len430+		identical: identical431+		clashes:   clashes432+		yes:       yes433+	}357434 }358435 359436 // after_strips is a save's file list once the given strip events are applied,

view file

tests/cli_test.v+1 −0
@@ -1000,6 +1000,7 @@10001000 	// The pre-combine tree is off the line now; the way back must be named,10011001 	// or an agent without the history view cannot find it.10021002 	assert theirs.out.contains('"previous_head":"${b_head}"')1003+	assert theirs.out.contains('"kept_versions":["clash.txt:${b_head}"]')10031004 	assert os.read_file(os.join_path(b, 'clash.txt'))! == 'A version\n'10041005 	assert run_home(b, home, 'load', b_head, '--yes').code == 010051006 	assert os.read_file(os.join_path(b, 'clash.txt'))! == 'B version\n'

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.100'3+	version: '3.0.101'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file