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
↓ koh-1cace06d.tar.gzatlas at this save
files changed6 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 −1src/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 }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 }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,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'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 }