aece2a878d50

ask or refuse on a clash when offer upgrades a stolen repo, as steal does, instead of quietly taking theirs (3.0.103)

time
2026-09-28 04:47 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
fix a2 review findings
parent
c0fab999e938
6 files VERSION+1 −1src/cli/cmd_offer.v+18 −9src/cli/cmd_steal.v+17 −6src/exchange/exchange_test.v+6 −0src/exchange/steal.v+16 −2v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.1021+3.0.103

view file

src/cli/cmd_offer.v+18 −9
@@ -37,18 +37,27 @@3737 		if !exchange.signed_in_to(host) {3838 			return refuse(args.json(), 'this repo came from ${source}, which is read-only. Sign in with "koh remote ${host}" if it\'s yours.')3939 		}40-		// Signed in since it was stolen: take whatever the bucket has first, then this is an41-		// ordinary repo. This machine's own saves replay on top, so nothing is lost.40+		// Signed in since it was stolen: the bucket's saves come in first, by the steal a person41+		// would run, so a clash is asked about on a terminal and refused without one.4242 		mut bucket := this_machine_backend() or { return refuse(args.json(), err.msg()) }43-		if _ := exchange.steal(store, mut bucket, exchange.StealOptions{44-			theirs: true45-		}) {46-			exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }47-			if !args.json() {48-				ok('signed in to ${host}: this repo now syncs with your bucket')43+		mut options := exchange.StealOptions{}44+		if on_terminal(args.json()) {45+			options.guide = guide_steal46+		}47+		report := exchange.steal(store, mut bucket, options) or {48+			if err is exchange.ClashError {49+				return refuse(args.json(), '${err.paths.join(', ')} changed on both machines — run "koh steal" to choose, then offer')4950 			}50-		} else {5151 			return refuse(args.json(), err.msg())52+		}53+		exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }54+		if !args.json() {55+			ok('signed in to ${host}: this repo now syncs with your bucket')56+			if report.previous_head != '' {57+				note('to undo the combine: koh load ${report.previous_head}')58+			}59+			note_kept_versions(report)60+			warn_flagged(report.flagged)5261 		}5362 	}5463 	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }

view file

src/cli/cmd_steal.v+17 −6
@@ -48,7 +48,8 @@4848 	if args.positional.len > 0 {4949 		return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')5050 	}51-	if interactive() && os.is_atty(1) != 0 && !args.json() {51+	guided := on_terminal(args.json())52+	if guided {5253 		if !ready_to_steal() {5354 			return 15455 		}@@ -68,7 +69,6 @@6869 		theirs: args.flag('theirs')6970 		yes:    args.flag('yes')7071 	}71-	guided := !args.json() && interactive() && os.is_atty(1) != 07272 	if guided {7373 		options.guide = guide_steal7474 	}@@ -135,10 +135,7 @@135135 		} else {136136 			note('the tree from before is kept — koh load ${report.previous_head} brings it back, less anything stripped')137137 		}138-		for kept in report.kept_versions {139-			parts := kept.split_nth(':', 2)140-			note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}')141-		}138+		note_kept_versions(report)142139 	}143140 	warn_flagged(report.flagged)144141 	if report.clashes.len > 0 {@@ -148,6 +145,20 @@148145 		warn('the remote was not updated — run "koh offer" when it is reachable again')149146 	}150147 	return 0148+}149+150+// on_terminal is when a steal asks rather than refuses: a person at both ends, and no --json.151+fn on_terminal(json bool) bool {152+	return !json && interactive() && os.is_atty(1) != 0153+}154+155+// note_kept_versions names, for each file answered "theirs", the save that still holds this156+// machine's version.157+fn note_kept_versions(report exchange.StealReport) {158+	for kept in report.kept_versions {159+		parts := kept.split_nth(':', 2)160+		note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}')161+	}151162 }152163 153164 fn ready_to_steal() bool {

view file

src/exchange/exchange_test.v+6 −0
@@ -464,6 +464,12 @@464464 		assert false, 'an undecided clash must be refused'465465 	} else {466466 		assert err.msg().contains('--mine') || err.msg().contains('--theirs')467+		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.468+		if err is ClashError {469+			assert err.paths == ['same.txt']470+		} else {471+			assert false, 'an undecided clash must be a ClashError, not ${err}'472+		}467473 	}468474 	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,469475 		head_of(b).id)!).len == 0

view file

src/exchange/steal.v+16 −2
@@ -83,6 +83,19 @@8383 }8484 8585 pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'86+87+// ClashError is a clash nobody was there to decide. It carries the paths because the advice88+// depends on the command: `koh steal` takes --mine or --theirs, `koh offer` does not.89+pub struct ClashError {90+	Error91+pub:92+	paths []string93+}94+95+pub fn (e ClashError) msg() string {96+	return '${e.paths.join(', ')} changed on both machines — run on a terminal to choose, or pass --mine or --theirs'97+}98+8699 pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'87100 88101 // steal brings the remote's new saves in. Inside a repo with its own work this@@ -508,8 +521,9 @@508521 			sides[clash.path] = opts.ask(clash)509522 			continue510523 		}511-		paths := clashes.map(it.path).join(', ')512-		return error('${paths} changed on both machines — run on a terminal to choose, or pass --mine or --theirs')524+		return ClashError{525+			paths: clashes.map(it.path)526+		}513527 	}514528 	return sides515529 }

view file

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

view file