c0fab999e938

walk through steal on a terminal: unsaved work, overview, collisions, summary and undo (3.0.102)

time
2026-09-28 04:24 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
gpt-5
session
guided steal
parent
1cace06dc178
9 files VERSION+1 −1src/cli/cmd_steal.v+38 −12src/exchange/exchange.v+35 −8src/exchange/exchange_test.v+34 −0src/exchange/steal.v+46 −9src/exchange/strip.v+20 −0src/view/manual.v+41 −0tests/cli_test.v+96 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.1011+3.0.102

view file

src/cli/cmd_steal.v+38 −12
@@ -15,6 +15,7 @@1515 import src.snapshot1616 import src.storage1717 import src.exchange18+import src.view1819 1920 pub fn cmd_steal(raw []string) int {2021 	args := parse('steal', raw, Options{@@ -68,7 +69,7 @@6869 		yes:    args.flag('yes')6970 	}7071 	guided := !args.json() && interactive() && os.is_atty(1) != 071-	if !options.mine && !options.theirs && guided {72+	if guided {7273 		options.guide = guide_steal7374 	}7475 	report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }@@ -111,7 +112,13 @@111112 		}112113 	} else {113114 		ok(if guided {114-			'combined · offered to ${report.repo}'115+			if source != '' && !upgraded {116+				'combined'117+			} else if offered {118+				'combined · offered to ${report.repo}'119+			} else {120+				'combined'121+			}115122 		} else {116123 			'brought in ${report.fetched_saves} saves from "${report.repo}"'117124 		})@@ -123,7 +130,11 @@123130 		if report.kept_mine > 0 {124131 			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')125132 		}126-		note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')133+		if guided {134+			note('to undo all of it: koh load ${report.previous_head}')135+		} else {136+			note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')137+		}127138 		for kept in report.kept_versions {128139 			parts := kept.split_nth(':', 2)129140 			note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}')@@ -201,6 +212,8 @@201212 }202213 203214 fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {215+	_, columns := view.terminal_size()216+	width := if columns > 0 && columns < 80 { columns } else { 80 }204217 	println('')205218 	println('  ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base}')206219 	println('')@@ -210,15 +223,26 @@210223 	println('  ${fmt.plural(preview.changed, 'file')} changed')211224 	println('    ${fmt.green('✓')} ${preview.automatic} combine by themselves')212225 	println('    ${fmt.green('✓')} ${preview.identical} changed the same way on both sides')213-	println('    ${fmt.amber('!')} ${preview.clashes.len} need you')226+	println('    ${fmt.amber('!')} ${preview.clashes.len} ${if preview.clashes.len == 1 {227+		'needs'228+	} else {229+		'need'230+	}} you')214231 	mut answers := map[string]exchange.Side{}215-	mut at := 0232+	if preview.all_side != '' {233+		for clash in preview.clashes {234+			answers[clash.path] = if preview.all_side == 'mine' { .mine } else { .theirs }235+		}236+	}237+	mut at := if preview.all_side == '' { 0 } else { preview.clashes.len }216238 	for at < preview.clashes.len {217239 		clash := preview.clashes[at]218240 		println('')219241 		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}')242+		println(fmt.fit('    ${fmt.gold('you')}      ${clash.mine_stat}   ${clash.mine_save.message} · ${fmt.stamp(clash.mine_save.created_at)} · ${clash.mine_save.machine}',243+			width))244+		println(fmt.fit('    ${fmt.blue('remote')}   ${clash.theirs_stat}   ${clash.theirs_save.message} · ${fmt.stamp(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',245+			width))222246 		println('')223247 		println('  [m] keep mine   [t] take theirs   [d] see both changes')224248 		println('  [b] back   [?] help')@@ -263,14 +287,16 @@263287 }264288 265289 fn show_clash(clash exchange.Clash) {266-	if clash.mine_stat == 'binary' || clash.theirs_stat == 'binary' {290+	if clash.mine_patch == '' && clash.theirs_patch == '' {267291 		println('  binary file')268292 		return269293 	}270-	println('  ${fmt.gold('your changes')}')271-	print(clash.mine_patch)272-	println('  ${fmt.blue('remote changes')}')273-	print(clash.theirs_patch)294+	mut lines := ['your changes']295+	lines << clash.mine_patch.trim_right('\n').split_into_lines()296+	lines << ''297+	lines << 'remote changes'298+	lines << clash.theirs_patch.trim_right('\n').split_into_lines()299+	view.page_text(lines, clash.path)274300 }275301 276302 // offer_after_steal pushes what this machine just decided. A failure here does

view file

src/exchange/exchange.v+35 −8
@@ -217,13 +217,21 @@217217 	mut fetched := Fetched{218218 		head: remote_head.body.bytestr().trim_space()219219 	}220+	mut on_line := map[string]bool{}221+	for save in history.line(store)! {222+		on_line[save.id] = true223+	}220224 	mut cursor := fetched.head221-	for cursor != '' && history.by_id(store, cursor) == none {222-		record_object := backend.get(save_key(repo, cursor))!223-		if !record_object.found {224-			return error('the remote is missing save ${cursor} — run koh offer there')225+	for cursor != '' && cursor !in on_line {226+		record := if saved := history.by_id(store, cursor) {227+			save_record(store, saved)!228+		} else {229+			record_object := backend.get(save_key(repo, cursor))!230+			if !record_object.found {231+				return error('the remote is missing save ${cursor} — run koh offer there')232+			}233+			decode_save(record_object.body)!225234 		}226-		record := decode_save(record_object.body)!227235 		for file in record.files {228236 			if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects {229237 				blob := backend.get(object_key(repo, file.hash))!@@ -277,13 +285,14 @@277285 			continue278286 		}279287 		if event.kind == events.kind_strip {280-			// Somebody stripped a path: remove it here too, so the two281-			// histories agree from now on. Objects go once the saves are in.282-			remove_path(store, event.path, event.save_id)!288+			remove_event_path(store, event)!283289 			strips << event284290 		}285291 	}286292 	for record in fetched.saves {293+		if history.by_id(store, record.id) != none {294+			continue295+		}287296 		files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {288297 			return !strips.any(reaches(it, record.id, record.created_at)289298 				&& covers(it.path, file.path))@@ -292,6 +301,24 @@292301 	}293302 	if strips.len > 0 {294303 		store.drop_unreferenced_objects()!304+	}305+}306+307+// write_fetched_records makes network work durable without changing the line.308+// Events wait for the combine transaction because a strip can rewrite history.309+pub fn write_fetched_records(store &storage.Store, fetched Fetched) ! {310+	for hash, blob in fetched.repo_objects {311+		store.put_object_blob(hash, blob)!312+	}313+	for record in fetched.saves {314+		if history.by_id(store, record.id) == none {315+			strips := fetched.events_out.filter(it.kind == events.kind_strip)316+			files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {317+				return !strips.any(reaches(it, record.id, record.created_at)318+					&& covers(it.path, file.path))319+			})320+			history.import_save(store, to_save(record), files)!321+		}295322 	}296323 }297324 

view file

src/exchange/exchange_test.v+34 −0
@@ -477,6 +477,40 @@477477 	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'478478 }479479 480+fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {481+	base := scratch('guided_cancel')!482+	defer { os.rmdir_all(base) or {} }483+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!484+	mut a := open_repo(machine('guided_cancel_a')!)!485+	defer { a.close() }486+	mut b := open_repo(machine('guided_cancel_b')!)!487+	defer { b.close() }488+	put(a.root, 'same.txt', 'base\n')!489+	make_save(a, 'base')!490+	offer_now(a, mut backend)!491+	join(b, mut backend)!492+	put(a.root, 'same.txt', 'remote\n')!493+	remote_head := make_save(a, 'remote change')!494+	offer_now(a, mut backend)!495+	put(b.root, 'same.txt', 'mine\n')!496+	local_head := make_save(b, 'local change')!497+498+	if _ := steal(b, mut backend, StealOptions{499+		guide: fn (preview StealPreview) !map[string]Side {500+			assert preview.clashes.len == 1501+			return error('cancelled')502+		}503+	}) {504+		assert false, 'the guide cancelled'505+	} else {506+		assert err.msg() == 'cancelled'507+	}508+	assert head_of(b).id == local_head.id509+	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'510+	assert history.by_id(b, remote_head.id) != none511+	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1512+}513+480514 fn test_choosing_mine_keeps_the_local_version() {481515 	base := scratch('mine')!482516 	defer {

view file

src/exchange/steal.v+46 −9
@@ -12,6 +12,7 @@1212 import os1313 import src.apply1414 import src.events15+import src.fmt1516 import src.history1617 import src.remote1718 import src.snapshot@@ -34,6 +35,8 @@3435 	theirs_stat  string3536 	mine_patch   string3637 	theirs_patch string38+	mine_save    history.Save39+	theirs_save  history.Save3740 }3841 3942 pub struct StealPreview {@@ -47,6 +50,7 @@4750 	identical int4851 	clashes   []Clash4952 	yes       bool53+	all_side  string5054 }5155 5256 pub struct StealOptions {@@ -116,16 +120,26 @@116120 	if fetched.base == '' && local_only.len > 0 {117121 		return error('the remote keeps a different history — it shares no save with this repo')118122 	}123+	if fetched.saves.len > 0 {124+		store.begin()!125+		write_fetched_records(store, fetched) or {126+			store.rollback()127+			return err128+		}129+		store.commit()!130+	}119131 120132 	strips_before := events.strip_events(store)!.len121133 	mut clashes := []Clash{}122134 	mut side_of := map[string]Side{}123135 	if fetched.saves.len > 0 && local_only.len > 0 {124-		clashes = combine_clashes(store, fetched)!136+		clashes = combine_clashes(store, fetched, local_only)!125137 		report.clashes = clashes.map(it.path)126-		if opts.guide != unsafe { nil } && !opts.mine && !opts.theirs {138+		if opts.guide != unsafe { nil } {127139 			side_of = opts.guide(combine_preview(store, name, fetched, local_only, clashes,128-				opts.yes)!)!140+				opts.yes, if opts.mine {141+					'mine'142+				} else if opts.theirs { 'theirs' } else { '' })!)!129143 		} else {130144 			// Asked before the write lock: a person thinking must not stall every131145 			// agent's save on this repo.@@ -360,7 +374,7 @@360374 // combine_clashes finds the files both lines changed, with the diff stat each361375 // prompt shows. It reads the remote head from the fetch, not the store, so it362376 // can run before anything is written.363-fn combine_clashes(store &storage.Store, fetched Fetched) ![]Clash {377+fn combine_clashes(store &storage.Store, fetched Fetched, local_only []history.Save) ![]Clash {364378 	base_files := if fetched.base != '' {365379 		history.files_of(store, fetched.base)!366380 	} else {@@ -382,22 +396,44 @@382396 		theirs := fetched_bytes(store, fetched, clash.theirs)!383397 		mine_delta := snapshot.diff_bytes(clash.path, 'modified', base, mine)!384398 		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)399+		clash.mine_stat = delta_stat(mine_delta, clash.mine.size)400+		clash.theirs_stat = delta_stat(theirs_delta, clash.theirs.size)387401 		clash.mine_patch = mine_delta.patch388402 		clash.theirs_patch = theirs_delta.patch403+		clash.mine_save = last_touch(store, local_only, clash.path)!404+		clash.theirs_save = last_touch(store, fetched.saves.map(to_save(it)), clash.path)!389405 	}390406 	return clashes391407 }392408 393-fn delta_stat(delta snapshot.Delta) string {409+fn delta_stat(delta snapshot.Delta, size i64) string {394410 	if delta.binary {395-		return 'binary'411+		return fmt.human_bytes(size)396412 	}397413 	return '+${delta.added} -${delta.removed}'398414 }399415 400-fn combine_preview(store &storage.Store, name string, fetched Fetched, local []history.Save, clashes []Clash, yes bool) !StealPreview {416+fn last_touch(store &storage.Store, saves []history.Save, path string) !history.Save {417+	for index := saves.len - 1; index >= 0; index-- {418+		save := saves[index]419+		current := snapshot.index_files(history.files_of(store, save.id)!)[path] or {420+			snapshot.FileEntry{}421+		}422+		parent := if save.parent != '' {423+			snapshot.index_files(history.files_of(store, save.parent)!)[path] or {424+				snapshot.FileEntry{}425+			}426+		} else {427+			snapshot.FileEntry{}428+		}429+		if !same(current, parent) {430+			return save431+		}432+	}433+	return saves.last()434+}435+436+fn combine_preview(store &storage.Store, name string, fetched Fetched, local []history.Save, clashes []Clash, yes bool, all_side string) !StealPreview {401437 	base := history.files_of(store, fetched.base)!402438 	mine := history.files_of(store, local.last().id)!403439 	theirs := history.files_of(store, fetched.head)!@@ -430,6 +466,7 @@430466 		identical: identical431467 		clashes:   clashes432468 		yes:       yes469+		all_side:  all_side433470 	}434471 }435472 

view file

src/exchange/strip.v+20 −0
@@ -211,6 +211,26 @@211211 	return store.db.changes()212212 }213213 214+// remove_event_path applies a received strip by the time rule it carried.215+fn remove_event_path(store &storage.Store, event events.Event) !int {216+	scope := if event.save_id != '' {217+		' AND save_id = ?3'218+	} else {219+		' AND save_id IN (SELECT id FROM saves WHERE created_at <= ?3)'220+	}221+	mut stmt := store.db.prepare('DELETE FROM save_files WHERE (path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}')!222+	defer { stmt.finalize() }223+	stmt.bind_text(1, event.path)!224+	stmt.bind_text(2, '${event.path.trim_right('/')}/')!225+	if event.save_id != '' {226+		stmt.bind_text(3, event.save_id)!227+	} else {228+		stmt.bind_int(3, event.created_at)!229+	}230+	stmt.step()!231+	return store.db.changes()232+}233+214234 // match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`215235 // and `%` as wildcards; a strip destroys, so the prefix is compared exactly.216236 fn match_clause(only_save string) string {

view file

src/view/manual.v+41 −0
@@ -67,6 +67,47 @@6767 	}6868 }6969 70+// page_text is the same small pager without manual sections, for diffs.71+pub fn page_text(lines []string, title string) {72+	enter_screen()73+	defer { leave_screen() }74+	mut page := 075+	for {76+		rows, cols := terminal_size()77+		size := if rows > 2 { rows - 1 } else { 1 }78+		last := last_page(lines.len, size)79+		if page > last {80+			page = last81+		}82+		start := page * size83+		end := if start + size < lines.len { start + size } else { lines.len }84+		mut frame := lines[start..end].map(fmt.fit(it, cols))85+		for frame.len < size {86+			frame << ''87+		}88+		frame << fmt.fit('${fmt.blue(title)}  ·  page ${page + 1} of ${last + 1}  ·  ↑↓ j/k  ·  q quit', cols)89+		paint(render(frame))90+		key := read_key()91+		match key.kind {92+			.interrupt, .eof, .escape, .enter { return }93+			.down, .page_down { page = if page < last { page + 1 } else { page } }94+			.up, .page_up { page = if page > 0 { page - 1 } else { 0 } }95+			.rune {96+				if key.text == 'q' {97+					return98+				}99+				if key.text == 'j' || key.text == ' ' {100+					page = if page < last { page + 1 } else { page }101+				}102+				if key.text == 'k' {103+					page = if page > 0 { page - 1 } else { 0 }104+				}105+			}106+			else {}107+		}108+	}109+}110+70111 fn last_page(count int, size int) int {71112 	return if count == 0 { 0 } else { (count - 1) / size }72113 }

view file

tests/cli_test.v+96 −0
@@ -67,6 +67,32 @@6767 	}6868 }6969 70+fn run_pty_home(repo string, home string, answers []string, args ...string) Result {71+	stamp := '${os.getpid()}-${time.now().unix_nano()}'72+	transcript := os.join_path(os.temp_dir(), 'koh3-pty-${stamp}.txt')73+	script_path := os.join_path(os.temp_dir(), 'koh3-pty-${stamp}.exp')74+	defer {75+		os.rm(transcript) or {}76+		os.rm(script_path) or {}77+	}78+	mut script := 'log_file -noappend {${transcript}}\nset timeout 20\nspawn -noecho env {HOME=${home}} NO_COLOR=1 {${binary}}'79+	for arg in args {80+		script += ' {${arg}}'81+	}82+	script += '\n'83+	for answer in answers {84+		script += 'after 250\nsend -- "${answer}\\r"\n'85+	}86+	script += 'expect eof\nset status [wait]\nexit [lindex $status 3]\n'87+	os.write_file(script_path, script) or { panic(err) }88+	result := os.execute('cd ${q(repo)} && expect -f ${q(script_path)}')89+	return Result{90+		code: result.exit_code91+		out:  os.read_file(transcript) or { '' }92+		err:  result.output93+	}94+}95+7096 fn init_repo(repo string) {7197 	r := run(repo, 'init')7298 	assert r.code == 0@@ -1013,6 +1039,76 @@10131039 	assert run_home(b, home, 'steal', '--mine').code == 010141040 	assert os.read_file(os.join_path(b, 'clash.txt'))! == 'B second\n'10151041 	assert run_home(b, home, 'view').out.contains('combine: kept mine for')1042+}1043+1044+fn test_guided_steal_walks_the_terminal_and_can_cancel() {1045+	syncroot := temp_repo('guided-steal')1046+	home := temp_repo('guided-steal-home')1047+	defer {1048+		os.rmdir_all(syncroot) or {}1049+		os.rmdir_all(home) or {}1050+	}1051+	remote := os.join_path(syncroot, 'remote')1052+	a := os.join_path(syncroot, 'A', 'proj')1053+	b_parent := os.join_path(syncroot, 'B')1054+	os.mkdir_all(a) or { panic(err) }1055+	os.mkdir_all(b_parent) or { panic(err) }1056+	init_repo(a)1057+	write(a, 'clash.txt', 'base\n')1058+	write(a, 'other.txt', 'base\n')1059+	write(a, 'same.txt', 'base\n')1060+	write(a, 'remote-only.txt', 'base\n')1061+	write(a, 'local-only.txt', 'base\n')1062+	assert run_home(a, home, 'remote', 'folder', remote).code == 01063+	assert run_home(a, home, 'save', 'base').code == 01064+	assert run_home(b_parent, home, 'steal', 'proj').code == 01065+	b := os.join_path(b_parent, 'proj')1066+1067+	write(b, 'dirty.txt', 'mine\n')1068+	before := run_home(b, home, 'status', '--json').out1069+	quit := run_pty_home(b, home, ['q'], 'steal')1070+	assert quit.out.contains('you have 1 unsaved file')1071+	assert quit.out.contains('dirty.txt')1072+	assert run_home(b, home, 'status', '--json').out == before1073+	checkpoint := run_pty_home(b, home, ['c'], 'steal')1074+	assert checkpoint.code == 0, checkpoint.err1075+	assert run_home(b, home, 'view', '--checkpoints').out.contains('before steal')1076+1077+	assert run_home(a, home, 'steal').code == 01078+	write(a, 'forward.txt', 'new\n')1079+	assert run_home(a, home, 'save', 'fast forward').code == 01080+	forward := run_pty_home(b, home, []string{}, 'steal')1081+	assert forward.out.contains('proj is up to date · 1 new save')1082+	assert forward.out.split_into_lines().filter(it.trim_space() != '').len == 11083+	write(a, 'clash.txt', 'remote\n')1084+	write(a, 'other.txt', 'remote\n')1085+	write(a, 'same.txt', 'identical\n')1086+	write(a, 'remote-only.txt', 'remote\n')1087+	assert run_home(a, home, 'save', 'remote change').code == 01088+	write(b, 'clash.txt', 'mine\n')1089+	write(b, 'other.txt', 'mine\n')1090+	write(b, 'same.txt', 'identical\n')1091+	write(b, 'local-only.txt', 'mine\n')1092+	assert run_home(b, home, 'save', 'local change').code == 01093+	local_head := run_home(b, home, 'status', '--json').out.all_after('"head_id":"')[..12]1094+	cancel := run_pty_home(b, home, ['m', 'b', 't', 't', 'n'], 'steal')1095+	assert cancel.out.contains('both moved since')1096+	assert cancel.out.contains('5 files changed')1097+	assert cancel.out.contains('2 combine by themselves')1098+	assert cancel.out.contains('1 changed the same way')1099+	assert cancel.out.contains('2 need you')1100+	assert cancel.out.contains('ready to combine')1101+	assert run_home(b, home, 'status', '--json').out.contains('"head_id":"${local_head}"')1102+	assert os.read_file(os.join_path(b, 'clash.txt'))! == 'mine\n'1103+1104+	done := run_pty_home(b, home, ['y'], 'steal', '--theirs')1105+	assert done.out.contains('combined · offered to proj')1106+	assert done.out.contains('to load') || done.out.contains('koh load')1107+	assert done.out.contains('your clash.txt is in ${local_head}')1108+	assert !done.out.contains('\x1b')1109+	for line in done.out.split_into_lines() {1110+		assert line.runes().len <= 80, line1111+	}10161112 }10171113 10181114 // A clone brings in the whole history, flags included; both output forms must

view file

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

view file