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
↓ koh-aece2a87.tar.gzatlas at this save
files changed6 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 −1src/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()) }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 {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 == 0src/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 }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 }