4763d42de261
default destructive changes to No and require explicit consent (3.7.0)
- time
- 2026-10-05 19:46 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- gpt-6
- session
- safe destructive confirmations
- parent
- fd90e3699437
↓ koh-4763d42d.tar.gzatlas at this save
files changed13 files
VERSION+1 −1shell/koh3.plugin.zsh+5 −4src/cli/cmd_doctor.v+8 −5src/cli/cmd_offer.v+6 −2src/cli/cmd_remote.v+62 −15src/cli/cmd_steal.v+17 −3src/cli/cmd_strip.v+19 −17src/cli/prompt.v+8 −8src/exchange/steal.v+10 −3src/view/view.v+2 −2src/view/view_test.v+15 −2tests/cli_test.v+112 −17v.mod+1 −1shell/koh3.plugin.zsh+5 −4
@@ -162,11 +162,12 @@162162 flag) _arguments '--reason[why]:reason:' '--clear' '--json' '*:save:_koh3_ids' ;;163163 undo) _arguments '--session' '--yes' ;;164164 revert) _arguments '--yes' ;;165- remote) _arguments '1:kind:(folder s3)' '--forget' '--json' ;;166- steal) _arguments '--list' '--mine' '--theirs' '--json' ;;165+ remote) _arguments '1:kind:(folder s3)' '--forget' '--yes' '--json' ;;166+ offer) _arguments '--yes' '--builds' '--notes:notes:' '--json' ;;167+ steal) _arguments '--yes' '--list' '--mine' '--theirs' '--json' ;;167168 ignore) _arguments '--global[edit ~/.kohignore for every repo]' '--yes[skip the question]' '--json' '*:path:_files' ;;168- strip) _arguments '--save[one save]:save:_koh3_ids' '*:path:_files' ;;169- doctor) _arguments '--backup[copy .koh to]:path:_files' ;;169+ strip) _arguments '--yes[skip the yes/no question; paths still typed]' '--save[one save]:save:_koh3_ids' '*:path:_files' ;;170+ doctor) _arguments '--yes[remove old peek folders]' '--backup[copy .koh to]:path:_files' ;;170171 build) _arguments '*--platform[target platform]:platform:(macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64)' '--all' ;;171172 update) _arguments '--check' '--yes' '--json' ;;172173 migrate) _arguments '--lane[Koh 2 lane]:lane:(dev main)' '*--with[a lost version, from a backup]:file:_files' '--yes[skip the question]' '--json' ;;src/cli/cmd_doctor.v+8 −5
@@ -3,7 +3,7 @@33 //! One source of truth means there is nothing to rebuild; what is left to check44 //! is that the file is sound and that every object a save names is really55 //! there. Doctor also tidies the things that are safe to lose: the file cache6-//! and old peek folders.6+//! and, with --yes, old peek folders.77 88 module cli99 @@ -18,7 +18,7 @@1818 pub fn cmd_doctor(raw []string) int {1919 args := parse('doctor', raw, Options{2020 values: ['backup']21- flags: ['json']21+ flags: ['json', 'yes']2222 }) or { return usage_error(err.msg()) }2323 store := open_store() or { return refuse(args.json(), err.msg()) }2424 defer {@@ -38,7 +38,9 @@3838 report.saves = int(history.save_count(store) or { 0 })3939 report.checkpoints = int(history.checkpoint_count(store) or { 0 })4040 report.cached_files = int(store.cache_count() or { 0 })41- report.peek_folders_removed = sweep_peek_folders()41+ if args.flag('yes') {42+ report.peek_folders_removed = sweep_peek_folders()43+ }4244 store.drop_cache() or { return refuse(args.json(), err.msg()) }4345 4446 if path := args.value_or_none('backup') {@@ -95,8 +97,9 @@9597 mut removed := 09698 for name in os.ls(os.temp_dir()) or { return 0 } {9799 path := os.join_path(os.temp_dir(), name)98- if !name.starts_with(peek_prefix)99- || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {100+ info := os.lstat(path) or { continue }101+ if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)102+ || info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {100103 continue101104 }102105 os.rmdir_all(path) or { continue }src/cli/cmd_offer.v+6 −2
@@ -19,7 +19,7 @@1919 pub fn cmd_offer(raw []string) int {2020 args := parse('offer', raw, Options{2121 values: ['notes']22- flags: ['json', 'builds']22+ flags: ['json', 'builds', 'yes']2323 }) or { return usage_error(err.msg()) }2424 // `--notes ""` is still `--notes`, so the flag's presence decides, not its2525 // value; and offer takes no arguments at all.@@ -46,7 +46,11 @@4646 mut bucket := watched_backend('bringing in your bucket') or {4747 return refuse(args.json(), err.msg())4848 }49- mut options := exchange.StealOptions{}49+ mut options := exchange.StealOptions{50+ yes: args.flag('yes')51+ json: args.json()52+ approve: approve_sync53+ }5054 if on_terminal(args.json()) {5155 options.guide = guide_steal5256 }src/cli/cmd_remote.v+62 −15
@@ -15,6 +15,7 @@1515 import src.exchange1616 import src.fmt1717 import src.machine18+import src.snapshot1819 import src.remote1920 import src.storage2021 import term.termios@@ -24,8 +25,24 @@2425 pub fn cmd_remote(raw []string) int {2526 args := parse('remote', raw, Options{2627 values: ['code']27- flags: ['json', 'forget']28+ flags: ['json', 'forget', 'yes']2829 }) or { return usage_error(err.msg()) }30+ if args.positional.len > 0 && !args.flag('forget') {31+ kind := args.positional_at(0)32+ expected := if kind == machine.kind_folder {33+ 234+ } else if kind == machine.kind_s3 { 4 } else { 1 }35+ if args.positional.len != expected {36+ return usage_error('remote ${kind} needs ${expected - 1} arguments')37+ }38+ if kind in [machine.kind_folder, machine.kind_s3] {39+ if args.value_or_none('code') != none {40+ return usage_error('--code belongs to "koh remote <host>"')41+ }42+ } else {43+ remote.kepr_host(kind) or { return refuse(args.json(), err.msg()) }44+ }45+ }2946 // A repo stolen from a Kepr has an address of its own, and it belongs above the machine's — as3047 // a line for a person: `--json` stays one document.3148 if !args.json() {@@ -44,9 +61,20 @@4461 return usage_error('--code belongs to "koh remote <host>"')4562 }4663 if args.flag('forget') {64+ if args.positional.len > 0 {65+ return usage_error('--forget takes no remote target')66+ }4767 if !machine.has_remote() {4868 return refuse(args.json(), 'this machine has no remote to forget')4969 }70+ if !args.json() { note("forget this machine's remote") }71+ if args.json() && !args.flag('yes') {72+ return refuse(true, 'needs --yes with --json')73+ }74+ approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }75+ if !approved {76+ return refuse(args.json(), 'nothing was changed')77+ }5078 machine.forget_remote() or { return refuse(args.json(), err.msg()) }5179 if args.json() {5280 emit_json(json2.encode(RemoteJson{@@ -71,6 +99,10 @@7199 // Otherwise the message to run "koh remote" again would loop.72100 if !args.json() && interactive() {73101 warn(err.msg())102+ approved := confirm(args.flag('yes')) or { return fail(err.msg()) }103+ if !approved {104+ return fail('nothing was changed')105+ }74106 return setup_wizard()75107 }76108 return refuse(args.json(), err.msg())@@ -88,13 +120,27 @@88120 note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')89121 return 090122 }91- print(' replace it? [y/N] ')92- if os.get_line().trim_right('\r') != 'y' {93- return 0123+ if !args.flag('yes') {124+ eprint(' replace it? [y/N] ')125+ if os.get_line().trim_right('\r') !in ['y', 'Y'] {126+ return 0127+ }94128 }95129 // The old remote stays until the new one passes its test.96130 return setup_wizard()97131 }132+ if machine.has_remote() {133+ if !args.json() {134+ note('replace the machine remote with ${args.positional.map(snapshot.escape_path(it)).join(' ')}')135+ }136+ if args.json() && !args.flag('yes') {137+ return refuse(true, 'needs --yes with --json')138+ }139+ approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }140+ if !approved {141+ return refuse(args.json(), 'nothing was changed')142+ }143+ }98144 // A non-interactive form, still tested before it is saved.99145 return set_remote(args)100146 }@@ -151,11 +197,11 @@151197 }152198 host := remote.kepr_host(args.positional_at(0)) or { return refuse(args.json(), err.msg()) }153199 connect_url := '${host}/connect?machine=${urllib.query_escape(machine.name())}'154- println('Open ${connect_url} and sign in')200+ if !args.json() { println('Open ${connect_url} and sign in') }155201 mut code := args.value('code')156202 if code == '' {157203 if !interactive() {158- return fail('setting up a Kepr remote needs a terminal, or use --code <code>')204+ return refuse(args.json(), 'setting up a Kepr remote needs a terminal, or use --code <code>')159205 }160206 // Only a person about to type the code gets a browser opened for them: with `--code` the161207 // page has been read already, and a script must never take over the desktop. `open` or@@ -165,7 +211,7 @@165211 mut browser := os.new_process(if os.user_os() == 'macos' { 'open' } else { 'xdg-open' })166212 browser.set_args([connect_url])167213 browser.run()168- print('paste the code: ')214+ eprint('paste the code: ')169215 code = os.get_line().trim_space()170216 }171217 // Redirects are refused: a 3xx would hand the one-time code to whatever it points at.@@ -267,11 +313,11 @@267313 println(' choose a remote:')268314 println(' ${fmt.amber('1')} folder a directory, on a disk or a NAS')269315 println(' ${fmt.amber('2')} s3 an S3-compatible bucket')270- print(' which? [1] ')316+ eprint(' which? [1] ')271317 if os.get_line().trim_space() == '2' {272318 return wizard_s3()273319 }274- print(' folder path: ')320+ eprint(' folder path: ')275321 path := os.get_line().trim_space()276322 if path == '' {277323 return fail('no path given — nothing was saved')@@ -284,11 +330,11 @@284330 }285331 286332 fn wizard_s3() int {287- print(' endpoint [https://s3.fr-par.scw.cloud]: ')333+ eprint(' endpoint [https://s3.fr-par.scw.cloud]: ')288334 endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')289- print(' bucket: ')335+ eprint(' bucket: ')290336 bucket := os.get_line().trim_space()291- print(' region [fr-par]: ')337+ eprint(' region [fr-par]: ')292338 region := or_default(os.get_line().trim_space(), 'fr-par')293339 if bucket == '' {294340 return fail('no bucket given — nothing was saved')@@ -314,7 +360,7 @@314360 if !interactive() {315361 return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')316362 }317- print(' access key: ')363+ eprint(' access key: ')318364 access_key_typed := os.get_line().trim_space()319365 // Hidden input needs a terminal on both ends; with output redirected, the320366 // key is read like any other line.@@ -324,7 +370,8 @@324370 // V's hidden read returns on end of input without turning echo back325371 // on, so the terminal's state is kept here and put back. A failed326372 // read is an empty key, which is refused below.327- secret_key_typed = os.input_password(' secret key: ') or {373+ eprint(' secret key: ')374+ secret_key_typed = os.input_password('') or {328375 println('')329376 if termios.set_state(0, saved) != 0 {330377 warn('the terminal may no longer echo — run "stty sane"')@@ -332,7 +379,7 @@332379 ''333380 }334381 } else {335- print(' secret key: ')382+ eprint(' secret key: ')336383 secret_key_typed = os.get_line()337384 }338385 secret_key_typed = secret_key_typed.trim_space()src/cli/cmd_steal.v+17 −3
@@ -68,9 +68,11 @@6868 watched_backend('stealing') or { return refuse(args.json(), err.msg()) }6969 }7070 mut options := exchange.StealOptions{71- mine: args.flag('mine')72- theirs: args.flag('theirs')73- yes: args.flag('yes')71+ mine: args.flag('mine')72+ theirs: args.flag('theirs')73+ yes: args.flag('yes')74+ json: args.json()75+ approve: approve_sync7476 }7577 if guided {7678 options.guide = guide_steal@@ -567,4 +569,16 @@567569 previous_head string568570 kept_versions []string569571 unsafe_paths []string572+}573+574+fn approve_sync(saves int, incoming_events int, yes bool, json bool) ! {575+ if json && !yes {576+ return error('needs --yes with --json')577+ }578+ if interactive() && !json {579+ note('apply ${saves} incoming saves and ${incoming_events} events; working files and stripped history may change')580+ }581+ if !confirm(yes)! {582+ return error('nothing was applied')583+ }570584 }src/cli/cmd_strip.v+19 −17
@@ -28,12 +28,12 @@2828 pub fn cmd_strip(raw []string) int {2929 args := parse('strip', raw, Options{3030 values: ['save']31- flags: ['json']31+ flags: ['json', 'yes']3232 }) or { return usage_error(err.msg()) }3333 if args.positional.len == 0 {3434 return usage_error('strip needs paths, e.g. koh strip secrets.env private/')3535 }36- // There is no --yes: a person has to be at the keyboard for this one.36+ // Even --yes cannot replace the exact typed paths.3737 if !interactive() {3838 return refuse(args.json(), 'strip needs a terminal — an agent can never strip')3939 }@@ -67,30 +67,28 @@6767 for plan in plans {6868 if plan.scope.files == 0 {6969 if plan.pending {70- note('${snapshot.escape_path(plan.path)} is already stripped here — finishing the remote')70+ strip_note(args.json(), '${snapshot.escape_path(plan.path)} is already stripped here — finishing the remote')7171 } else {72- note('nothing in history matches ${snapshot.escape_path(plan.path)}')72+ strip_note(args.json(), 'nothing in history matches ${snapshot.escape_path(plan.path)}')7373 }7474 } else {75- note('strip ${snapshot.escape_path(plan.path)}: ${fmt.plural(plan.scope.files, 'file')} in ${fmt.plural(plan.scope.saves, 'save')}, ${fmt.plural(plan.scope.objects, 'object')} — ${where}')75+ strip_note(args.json(), 'strip ${snapshot.escape_path(plan.path)}: ${fmt.plural(plan.scope.files, 'file')} in ${fmt.plural(plan.scope.saves, 'save')}, ${fmt.plural(plan.scope.objects, 'object')} — ${where}')7676 }7777 }7878 if plans.any(it.scope.files > 0) {79- println(' ${fmt.red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')80- note('strip removes paths from history; the files on disk are left alone')81- approved := confirm(false) or { return refuse(args.json(), err.msg()) }79+ strip_note(args.json(), '${fmt.red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')80+ strip_note(args.json(), 'strip removes paths from history; the files on disk are left alone')81+ approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }8282 if !approved {83- note('nothing was stripped')84- return 183+ return refuse(args.json(), 'nothing was stripped')8584 }8685 for plan in plans {8786 if plan.scope.files == 0 {8887 continue8988 }90- note('confirm ${snapshot.escape_path(plan.path)}')89+ strip_note(args.json(), 'confirm ${snapshot.escape_path(plan.path)}')9190 if !typed_confirm('type the path to confirm:', plan.path) {92- note('nothing was stripped — the path did not match')93- return 191+ return refuse(args.json(), 'nothing was stripped — the path did not match')9492 }9593 }9694 }@@ -133,11 +131,11 @@133131 }134132 if plan.scope.files > 0 {135133 ok('stripped ${snapshot.escape_path(path)} from ${fmt.plural(local.files_removed, 'file')} across ${fmt.plural(plan.scope.saves, 'save')}')136- note('${fmt.plural(int(local.objects_dropped), 'object')} dropped')134+ strip_note(args.json(), '${fmt.plural(int(local.objects_dropped), 'object')} dropped')137135 }138136 if !sent.skipped {139137 if sent.failed == '' {140- note('the remote was updated: ${fmt.plural(sent.remote.records, 'save record')} rewritten, ${fmt.plural(sent.remote.objects, 'object')} dropped')138+ strip_note(args.json(), 'the remote was updated: ${fmt.plural(sent.remote.records, 'save record')} rewritten, ${fmt.plural(sent.remote.objects, 'object')} dropped')141139 } else {142140 warn('the remote could not be updated: ${sent.failed} — run the same strip again to finish it')143141 }@@ -146,9 +144,9 @@146144 warn('could not update ${snapshot.ignore_file} for ${snapshot.escape_path(path)}: ${ignore_failed}')147145 }148146 if only_save != '' {149- note('remember to delete ${snapshot.escape_path(path)} or add it to ${snapshot.ignore_file}')147+ strip_note(args.json(), 'remember to delete ${snapshot.escape_path(path)} or add it to ${snapshot.ignore_file}')150148 } else if ignored {151- note('added /${snapshot.escape_path(path)} to ${snapshot.ignore_file} — it can never be saved again')149+ strip_note(args.json(), 'added /${snapshot.escape_path(path)} to ${snapshot.ignore_file} — it can never be saved again')152150 }153151 }154152 if args.json() {@@ -201,4 +199,8 @@201199 remote_objects_dropped int202200 remote_failed string203201 ignore_failed string202+}203+204+fn strip_note(as_json bool, message string) {205+ if as_json { eprintln(' ${message}') } else { note(message) }204206 }src/cli/prompt.v+8 −8
@@ -1,7 +1,7 @@1-//! The strict confirm prompt, carried over from Koh 2 unchanged.1+//! The strict confirm prompt, shared by destructive commands.22 //!33 //! Before anything that rewrites the working tree, Koh shows what will happen4-//! and asks one question. Only a lowercase `y` confirms: `Y`, `yes`, or a4+//! and asks one question. Only `y` or `Y` confirms: `yes`, or a55 //! padded ` y ` all cancel. `--yes` skips the question; without a terminal and66 //! without `--yes` the command refuses rather than hanging.77 @@ -21,18 +21,18 @@2121 return error('needs --yes when not interactive')2222 }2323 done_working()24- print(' continue? [y/N] ')24+ eprint(' continue? [y/N] ')2525 answer := os.get_line()26- println('')27- return answer.trim_right('\r') == 'y'26+ eprintln('')27+ return answer.trim_right('\r') in ['y', 'Y']2828 }2929 30-// require_true is confirm for a second, stricter question: the answer has to30+// typed_confirm is confirm for a second, stricter question: the answer has to3131 // match `expected` exactly. `strip` uses it for the typed path.3232 pub fn typed_confirm(prompt_text string, expected string) bool {3333 done_working()34- print(' ${prompt_text} ')34+ eprint(' ${prompt_text} ')3535 answer := os.get_line()36- println('')36+ eprintln('')3737 return answer.trim_right('\r') == expected3838 }src/exchange/steal.v+10 −3
@@ -58,8 +58,10 @@5858 mine bool // resolve every clash as mine5959 theirs bool // resolve every clash as theirs6060 // Shown the whole combine and asked about every clash. Nil means "nobody to ask".61- guide fn (preview StealPreview) !map[string]Side = unsafe { nil }62- yes bool61+ guide fn (preview StealPreview) !map[string]Side = unsafe { nil }62+ yes bool63+ json bool64+ approve fn (saves int, events int, yes bool, json bool) ! = unsafe { nil }6365 }6466 6567 pub struct StealReport {@@ -93,7 +95,7 @@9395 }9496 9597 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'98+ return '${e.paths.join(', ')} changed on both machines — run on a terminal to choose, or pass --mine or --theirs with --yes'9799 }98100 99101 pub const no_saves_here = 'this folder has files but no saves — steal a repo into an empty folder with "koh steal <name>"'@@ -133,6 +135,11 @@133135 }134136 if fetched.base == '' && local_only.len > 0 {135137 return error('the remote keeps a different history — it shares no save with this repo')138+ }139+ if (fetched.saves.len > 0 || fetched.events_out.len > 0)140+ && !(fetched.saves.len > 0 && local_only.len > 0 && opts.guide != unsafe { nil })141+ && opts.approve != unsafe { nil } {142+ opts.approve(fetched.saves.len, fetched.events_out.len, opts.yes, opts.json)!136143 }137144 if fetched.saves.len > 0 {138145 store.begin()!src/view/view.v+2 −2
@@ -312,9 +312,9 @@312312 return false313313 }314314 315-// confirm_key is the strict confirm: only a lowercase y goes ahead.315+// confirm_key is the strict confirm: only y or Y goes ahead.316316 fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {317- if key.kind == .rune && key.text == 'y' {317+ if key.kind == .rune && key.text in ['y', 'Y'] {318318 session.screen.status = act(store, session.pending)319319 session.stale = true320320 }src/view/view_test.v+15 −2
@@ -227,8 +227,8 @@227227 assert session.pending.kind == .flag228228 assert session.pending.reason == 'quick'229229 assert plain(frame_lines(session)).last().contains('flag [aaa]? y yes · n/esc cancel')230- // Only a lowercase y goes ahead; anything else cancels.231- handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'Y' }, never_act)230+ // Enter cancels without invoking the action.231+ handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)232232 assert session.mode == .list233233 assert session.pending.kind == .none234234 }@@ -366,4 +366,17 @@366366 assert frame.len == 4367367 assert frame[0] == 'body' && frame[1] == ''368368 assert fmt.strip_ansi(frame[3]).starts_with('FILES · page 4 of 4')369+}370+371+fn confirmed_action(_ &storage.Store, action Action) string {372+ return 'confirmed ${action.save_id}'373+}374+375+fn test_both_cases_of_y_confirm_view_actions() {376+ for answer in ['y', 'Y'] {377+ mut session := Session{ mode: .confirm, pending: Action{ kind: .load, save_id: 'aaa' } }378+ confirm_key(mut session, unsafe { nil }, Key{ kind: .rune, text: answer }, confirmed_action)379+ assert session.screen.status == 'confirmed aaa'380+ assert session.mode == .list381+ }369382 }tests/cli_test.v+112 −17
@@ -534,10 +534,14 @@534534 // the real temp directory under names no save could have.535535 fresh := os.join_path(os.temp_dir(), 'koh-peek-fresh${os.getpid()}')536536 old := os.join_path(os.temp_dir(), 'koh-peek-old${os.getpid()}')537+ linked := os.join_path(os.temp_dir(), 'koh-peek-link${os.getpid()}')538+ target := temp_repo('peek-link-target')537539 defer {538540 os.rmdir_all(dir) or {}539541 os.rmdir_all(fresh) or {}540542 os.rmdir_all(old) or {}543+ os.rm(linked) or {}544+ os.rmdir_all(target) or {}541545 }542546 init_repo(dir)543547 write(dir, 'a.txt', 'one\n')@@ -546,9 +550,16 @@546550 os.mkdir_all(old) or { panic(err) }547551 two_days_ago := time.now().unix() - 2 * 86400548552 os.utime(old, two_days_ago, two_days_ago) or { panic(err) }553+ write(target, 'keep.txt', 'keep')554+ os.utime(target, two_days_ago, two_days_ago) or { panic(err) }555+ os.symlink(target, linked) or { panic(err) }549556 assert run(dir, 'doctor', '--json').code == 0557+ assert os.exists(old)558+ assert run(dir, 'doctor', '--json', '--yes').code == 0550559 assert os.exists(fresh)551560 assert !os.exists(old)561+ assert os.is_link(linked)562+ assert os.read_file(os.join_path(target, 'keep.txt'))! == 'keep'552563 }553564 554565 // A file saved long ago and ignored since holds work no save has. Loading the@@ -871,7 +882,9 @@871882 write(dir, 'secret.txt', 'leaked\n')872883 write(dir, 'keep.txt', 'fine\n')873884 assert run_home(dir, home, 'save', 'leak').code == 0874- stripped := run_pty_home(dir, home, ['y', 'secret.txt'], 'strip', 'secret.txt')885+ assert run(dir, 'strip', 'secret.txt', '--yes').code == 1886+ assert run_pty_home(dir, home, [''], 'strip', 'secret.txt', '--yes').code == 1887+ stripped := run_pty_home(dir, home, ['secret.txt'], 'strip', 'secret.txt', '--yes')875888 assert stripped.code == 0, stripped.out876889 assert stripped.out.contains('added /secret.txt to .kohignore')877890 ignore_path := os.join_path(dir, '.kohignore')@@ -942,8 +955,16 @@942955 assert state.out.contains(target)943956 assert run_home(dir, home, 'remote', 's3', 'http://127.0.0.1:1', 'bucket', 'region').code == 1944957 assert run_home(dir, home, 'remote', '--json').out.contains(target)945- assert run_home(dir, home, 'remote', 's3', 'http://127.0.0.1:1', 'bucket', 'region').err.contains('KOH_ACCESS_KEY')946- assert run_home(dir, home, 'remote', '--forget').code == 0958+ assert run_home(dir, home, 'remote', 's3', 'http://127.0.0.1:1', 'bucket', 'region', '--yes').err.contains('KOH_ACCESS_KEY')959+ assert run_home(dir, home, 'remote', '--forget').code == 1960+ assert run_home(dir, home, 'remote', '--forget', '--json').code == 1961+ assert run_pty_home(dir, home, [''], 'remote', '--forget').code == 1962+ assert run_home(dir, home, 'remote', '--json').out.contains(target)963+ assert run_home(dir, home, 'remote', 'folder', target + '-other').code == 1964+ assert run_pty_home(dir, home, [''], 'remote', 'folder', target + '-other').code == 1965+ assert !os.exists(target + '-other')966+ assert run_pty_home(dir, home, ['Y'], 'remote', 'folder', target + '-other').code == 0967+ assert run_pty_home(dir, home, ['Y'], 'remote', '--forget').code == 0947968 assert run_home(dir, home, 'remote', '--json').out.contains('"configured":false')948969 }949970 @@ -986,7 +1007,7 @@9861007 unreadable := run_home(dir, home, 'offer')9871008 assert unreadable.code == 19881009 assert unreadable.err.contains('not readable')989- assert run_home(dir, home, 'remote', 'folder', target).code == 01010+ assert run_home(dir, home, 'remote', 'folder', target, '--yes').code == 09901011 assert run_home(dir, home, 'offer').code == 09911012 }9921013 @@ -1014,7 +1035,7 @@10141035 write(renamed, 'b.txt', 'from B\n')10151036 assert run_home(renamed, home, 'save', 'B first').code == 010161037 assert !os.exists(os.join_path(remote, 'renamed'))1017- assert run_home(a, home, 'steal').code == 01038+ assert run_home(a, home, 'steal', '--yes').code == 010181039 assert os.read_file(os.join_path(a, 'b.txt'))! == 'from B\n'10191040 }10201041 @@ -1114,7 +1135,7 @@11141135 assert run_home(b, home, 'save', 'B first').code == 011151136 write(a, 'a2.txt', 'second\n')11161137 assert run_home(a, home, 'save', 'A second').code == 01117- assert run_home(a, home, 'steal').code == 01138+ assert run_home(a, home, 'steal', '--yes').code == 011181139 assert os.exists(os.join_path(a, 'b.txt'))11191140 assert run_home(b_parent, home, 'steal', '--list').out.contains('proj')11201141 // Kepr keeps its own files in the same remote; a folder without a `head` is no repo.@@ -1123,16 +1144,16 @@11231144 assert run_home(b_parent, home, 'steal', '--list', '--json').out.contains('"repos":["proj"]')11241145 write(a, 'clash.txt', 'base\n')11251146 assert run_home(a, home, 'save', 'clash base').code == 01126- assert run_home(b, home, 'steal').code == 01147+ assert run_home(b, home, 'steal', '--yes').code == 011271148 write(a, 'clash.txt', 'A version\n')11281149 assert run_home(a, home, 'save', 'A clash').code == 011291150 write(b, 'clash.txt', 'B version\n')11301151 assert run_home(b, home, 'save', 'B clash').code == 01131- refused := run_home(b, home, 'steal')1152+ refused := run_home(b, home, 'steal', '--yes')11321153 assert refused.code == 111331154 assert refused.err.contains('--mine') || refused.err.contains('--theirs')11341155 b_head := run_home(b, home, 'status', '--json').out.all_after('"head_id":"')[..12]1135- theirs := run_home(b, home, 'steal', '--theirs', '--json')1156+ theirs := run_home(b, home, 'steal', '--theirs', '--json', '--yes')11361157 assert theirs.code == 011371158 // The pre-combine tree is off the line now; the way back must be named,11381159 // or an agent without the history view cannot find it.@@ -1147,7 +1168,7 @@11471168 assert run_home(a, home, 'save', 'A second clash').code == 011481169 write(b, 'clash.txt', 'B second\n')11491170 assert run_home(b, home, 'save', 'B second clash').code == 01150- assert run_home(b, home, 'steal', '--mine').code == 01171+ assert run_home(b, home, 'steal', '--mine', '--yes').code == 011511172 assert os.read_file(os.join_path(b, 'clash.txt'))! == 'B second\n'11521173 assert run_home(b, home, 'view').out.contains('combine: kept mine for')11531174 }@@ -1193,12 +1214,18 @@11931214 assert checkpoint.code == 0, checkpoint.err11941215 assert run_home(b, home, 'view', '--checkpoints').out.contains('before steal')11951216 1196- assert run_home(a, home, 'steal').code == 01217+ assert run_home(a, home, 'steal', '--yes').code == 011971218 write(a, 'forward.txt', 'new\n')11981219 assert run_home(a, home, 'save', 'fast forward').code == 01199- forward := run_pty_home(b, home, []string{}, 'steal')1220+ unchanged := run_home(b, home, 'status', '--json').out1221+ all_before := run_home(b, home, 'view', '--json').out1222+ assert run_home(b, home, 'steal').code == 11223+ assert run_pty_home(b, home, [''], 'steal').code == 11224+ assert run_home(b, home, 'status', '--json').out == unchanged1225+ assert run_home(b, home, 'view', '--json').out == all_before1226+ forward := run_pty_home(b, home, ['Y'], 'steal')12001227 assert forward.out.contains('proj is up to date · 1 new save')1201- assert shown(forward.out).len == 1, forward.out1228+ assert forward.code == 0, forward.out12021229 write(a, 'clash.txt', 'remote\n')12031230 write(a, 'other.txt', 'remote\n')12041231 write(a, 'same.txt', 'identical\n')@@ -1655,7 +1682,7 @@16551682 // A refused code, a missing terminal and a wrong reply all leave both files as they were.16561683 before := os.read_file(remote_path)!16571684 before_keys := os.read_file(credentials_path)!1658- bad := run_home(repo, home, 'remote', host, '--code', 'bad')1685+ bad := run_home(repo, home, 'remote', host, '--yes', '--code', 'bad')16591686 assert bad.code == 116601687 assert bad.err.contains('wrong or has expired')16611688 // The 404 could also mean a Kepr that hands out nothing, so the connect page is asked for@@ -1663,7 +1690,7 @@16631690 // that part of the query cannot be exercised here.)16641691 // `machine.name()` is `os.hostname() or { 'unknown' }`, so the query carries the same text.16651692 assert (fake.seen['connect'] or { '' }).contains('machine=${os.hostname() or { 'unknown' }}')1666- no_terminal := run_home(repo, home, 'remote', host)1693+ no_terminal := run_home(repo, home, 'remote', host, '--yes')16671694 assert no_terminal.code == 116681695 assert no_terminal.err.contains('needs a terminal')16691696 assert os.read_file(remote_path)! == before@@ -1678,13 +1705,13 @@16781705 init_repo(repo)16791706 for host in ['kepr.uk/foo', 'https://kepr.uk?x=1', 'kepr.uk#frag', '[email protected]', 'kepr uk',16801707 'http://127.0.0.1:1/foo', 'https://'] {1681- refused := run_home(repo, home, 'remote', host, '--code', 'good')1708+ refused := run_home(repo, home, 'remote', host, '--yes', '--code', 'good')16821709 assert refused.code == 1, host16831710 assert refused.err.contains('is not a Kepr address'), '${host}: ${refused.err}'16841711 }16851712 // The reply carries the bucket's secret key, so plain http is only for this machine's own Kepr.16861713 for host in ['http://kepr.uk', 'http://10.0.0.2:3000', 'http://KEPR.uk:80'] {1687- refused := run_home(repo, home, 'remote', host, '--code', 'good')1714+ refused := run_home(repo, home, 'remote', host, '--yes', '--code', 'good')16881715 assert refused.code == 1, host16891716 assert refused.err.contains('needs https'), '${host}: ${refused.err}'16901717 stolen := run_home(home, home, 'steal', '${host}/nina')@@ -1978,4 +2005,72 @@19782005 assert exchange.strip_scope(store, 'one', '')!.files == 019792006 assert exchange.strip_scope(store, 'two', '')!.files == 019802007 assert !exchange.strip_pending(store, 'one') && !exchange.strip_pending(store, 'two')2008+}2009+2010+fn test_destructive_restore_enter_cancels_and_uppercase_y_confirms() {2011+ dir := temp_repo('restore-default-no')2012+ home := temp_repo('restore-default-no-home')2013+ defer {2014+ os.rmdir_all(dir) or {}2015+ os.rmdir_all(home) or {}2016+ }2017+ assert run_home(dir, home, 'init').code == 02018+ write(dir, 'file.txt', 'old\n')2019+ assert run_home(dir, home, 'save', 'old').code == 02020+ id := run_home(dir, home, 'status', '--json').out.all_after('"head_id":"')[..12]2021+ write(dir, 'file.txt', 'new\n')2022+ assert run_home(dir, home, 'save', 'new').code == 02023+ before := run_home(dir, home, 'status', '--json').out2024+ current := before.all_after('"head_id":"')[..12]2025+ for args in [['undo'], ['load', id], ['pluck', current, '--reverse']] {2026+ cancel := run_pty_home(dir, home, [''], ...args)2027+ assert cancel.code == 12028+ assert cancel.out.contains('continue? [y/N]')2029+ assert run_home(dir, home, 'status', '--json').out == before2030+ assert os.read_file(os.join_path(dir, 'file.txt'))! == 'new\n'2031+ }2032+ write(dir, 'file.txt', 'unsaved\n')2033+ assert run_pty_home(dir, home, [''], 'revert').code == 12034+ assert os.read_file(os.join_path(dir, 'file.txt'))! == 'unsaved\n'2035+ assert run_pty_home(dir, home, ['Y'], 'revert').code == 02036+ assert os.read_file(os.join_path(dir, 'file.txt'))! == 'new\n'2037+}2038+2039+fn test_events_only_sync_requires_consent_and_noop_does_not() {2040+ base := temp_repo('event-consent')2041+ home := temp_repo('event-consent-home')2042+ defer {2043+ os.rmdir_all(base) or {}2044+ os.rmdir_all(home) or {}2045+ }2046+ a := os.join_path(base, 'A', 'proj')2047+ parent := os.join_path(base, 'B')2048+ os.mkdir_all(a)!2049+ os.mkdir_all(parent)!2050+ assert run_home(a, home, 'init').code == 02051+ assert run_home(a, home, 'remote', 'folder', os.join_path(base, 'remote')).code == 02052+ write(a, 'secret.txt', 'secret')2053+ assert run_home(a, home, 'save', 'base').code == 02054+ assert run_home(parent, home, 'steal', 'proj').code == 02055+ b := os.join_path(parent, 'proj')2056+ assert run_home(b, home, 'steal').code == 02057+ assert run_home(b, home, 'offer').code == 02058+ assert run_pty_home(a, home, ['y', 'secret.txt'], 'strip', 'secret.txt').code == 02059+ before := run_home(b, home, 'view', '--json').out2060+ assert run_home(b, home, 'steal', '--json').code == 12061+ assert run_pty_home(b, home, [''], 'steal').code == 12062+ assert run_home(b, home, 'view', '--json').out == before2063+ store := storage.open(b)!2064+ held := store.db.prepare("SELECT COUNT(*) FROM save_files WHERE path = 'secret.txt'")!2065+ assert held.step()!2066+ assert held.int(0) > 02067+ held.finalize()2068+ store.close()2069+ assert run_home(b, home, 'steal', '--yes').code == 02070+ checked := storage.open(b)!2071+ removed := checked.db.prepare("SELECT COUNT(*) FROM save_files WHERE path = 'secret.txt'")!2072+ assert removed.step()!2073+ assert removed.int(0) == 02074+ removed.finalize()2075+ checked.close()19812076 }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.6.0'3+ version: '3.7.0'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }