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
13 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.6.01+3.7.0

view file

shell/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' ;;

view file

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 }

view file

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 		}

view file

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()

view file

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 }

view file

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 }

view file

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 }

view file

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()!

view file

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 	}

view file

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 }

view file

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 }

view file

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 }

view file