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
↓ koh-c0fab999.tar.gzatlas at this save
files changed9 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 −1src/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 doessrc/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 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 {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 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 {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 }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 mustv.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 }