1f6a834a1455

fix log filters on a terminal, keep json pure when offering, and steady the migration fixture

time
2026-09-23 20:26 UTC · 2 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
koh 3 implementation
parent
884f56f0f0d2
6 files src/cli/cmd_log.v+4 −1src/cli/cmd_offer.v+9 −4src/cli/cmd_save.v+2 −2src/cli/handover.v+3 −1src/migrate/migrate_test.v+5 −6tests/cli_test.v+115 −1
src/cli/cmd_log.v+4 −1
@@ -42,7 +42,10 @@4242 	defer {4343 		store.close()4444 	}45-	if !args.json() && on_a_terminal() {45+	// The view has its own search but no date filter, so a filtered log prints46+	// plain text even on a terminal rather than quietly ignoring the filter.47+	filtered := args.value('since') != '' || args.value('session') != ''48+	if !args.json() && !filtered && on_a_terminal() {4649 		return history_view(store, tab)4750 	}4851 	mut cutoff := i64(0)

view file

src/cli/cmd_offer.v+9 −4
@@ -50,19 +50,24 @@5050 5151 // auto_offer runs after every save and checkpoint. A remote that is not set up,5252 // or not reachable, must never fail the save: it prints one line and stops.53-pub fn auto_offer(store &storage.Store) {53+// `quiet` is for `--json`, where that line would land inside the document.54+pub fn auto_offer(store &storage.Store, quiet bool) {5455 	if !machine.has_remote() {5556 		return5657 	}5758 	mut backend := this_machine_backend() or {58-		note('not offered: ${err.msg()}')59+		if !quiet {60+			note('not offered: ${err.msg()}')61+		}5962 		return6063 	}6164 	report := sync.offer(store, mut backend) or {62-		note('not offered: ${err.msg()}')65+		if !quiet {66+			note('not offered: ${err.msg()}')67+		}6368 		return6469 	}65-	if report.saves + report.objects + report.events > 0 {70+	if !quiet && report.saves + report.objects + report.events > 0 {6671 		note('offered to "${report.repo}" — ${report.saves} saves · ${report.objects} objects')6772 	}6873 }

view file

src/cli/cmd_save.v+2 −2
@@ -47,7 +47,7 @@4747 			files:         files.len4848 			size_bytes:    save.size_bytes4949 		}))50-		auto_offer(store)50+		auto_offer(store, true)5151 		return 05252 	}5353 	ok('saved [${save.id}]  ${save.message}')@@ -69,7 +69,7 @@6969 	if attribution.len > 0 {7070 		note(attribution.join(' · '))7171 	}72-	auto_offer(store)72+	auto_offer(store, false)7373 	return 07474 }7575 

view file

src/cli/handover.v+3 −1
@@ -32,7 +32,9 @@3232 		eprintln('  install koh2, or run "koh migrate" to convert this repo')3333 		exit(1)3434 	}35-	os.execvp(target, argv) or {35+	// execvp builds argv as [path, ...cmdargs], so the arguments go without36+	// our own argv[0].37+	os.execvp(target, argv[1..]) or {3638 		eprintln('  ${fmt.red(fmt.glyph_fail)} could not hand this command to ${target}: ${err.msg()}')3739 		exit(1)3840 	}

view file

src/migrate/migrate_test.v+5 −6
@@ -47,22 +47,21 @@4747 	os.write_file(os.join_path(dir, 'big.txt'), 'y'.repeat(700))!4848 	sh(dir, '${koh2} save "second save"')!4949 50-	// The head comes from Koh 2's own state, because save IDs are hashes (their51-	// directory order means nothing) and koh2 timestamps are whole seconds (so52-	// a save and the checkpoint after it can share one).50+	// Identify the first save by its message: koh2 timestamps are whole seconds,51+	// so saves made in a row share one, and a filename is a hash. The head comes52+	// from Koh 2's own state for the same reason.5353 	mut first := ''54-	mut first_time := i64(9223372036854775807)5554 	for name in os.ls(os.join_path(dir, '.koh', 'snapshots'))! {5655 		if !name.ends_with('.json') {5756 			continue5857 		}5958 		record :=6059 			json.decode(Manifest, os.read_file(os.join_path(dir, '.koh', 'snapshots', name))!)!61-		if record.timestamp < first_time {62-			first_time = record.timestamp60+		if record.message == 'first save' {6361 			first = record.id6462 		}6563 	}64+	assert first != ''6665 	return Fixture{6766 		dir:     dir6867 		first:   first

view file

tests/cli_test.v+115 −1
@@ -32,12 +32,18 @@3232 }3333 3434 fn run(repo string, args ...string) Result {35+	home := temp_repo('home')36+	defer { os.rmdir_all(home) or {} }37+	return run_home(repo, home, ...args)38+}39+40+fn run_home(repo string, home string, args ...string) Result {3541 	stamp := '${os.getpid()}-${time.now().unix_nano()}'3642 	base := os.join_path(os.temp_dir(), 'koh3-cli-${stamp}')3743 	out_path := '${base}.out'3844 	err_path := '${base}.err'3945 	code_path := '${base}.code'40-	mut command := '${q(binary)}'46+	mut command := 'HOME=${q(home)} ${q(binary)}'4147 	for arg in args {4248 		command += ' ${q(arg)}'4349 	}@@ -366,4 +372,112 @@366372 	assert json_result.out.contains('"saves"')367373 	assert json_result.out.contains('"is_checkpoint":true')368374 	assert !json_result.out.contains('"is_checkpoint":false')375+}376+377+378+fn test_remote_configuration_is_isolated_and_atomic() {379+	dir := temp_repo('remote')380+	home := temp_repo('remote-home')381+	defer {382+		os.rmdir_all(dir) or {}383+		os.rmdir_all(home) or {}384+	}385+	empty := run_home(dir, home, 'remote', '--json')386+	assert empty.code == 0387+	assert empty.out.contains('"configured":false')388+	target := os.join_path(home, 'remote-store')389+	set := run_home(dir, home, 'remote', 'folder', target)390+	assert set.code == 0391+	assert os.is_dir(target)392+	state := run_home(dir, home, 'remote', '--json')393+	assert state.code == 0394+	assert state.out.contains('"kind":"folder"')395+	assert state.out.contains(target)396+	assert run_home(dir, home, 'remote', 's3', 'http://127.0.0.1:1', 'bucket', 'region').code == 1397+	assert run_home(dir, home, 'remote', '--json').out.contains(target)398+	assert run_home(dir, home, 'remote', 's3', 'http://127.0.0.1:1', 'bucket', 'region').err.contains('KOH_ACCESS_KEY')399+	assert run_home(dir, home, 'remote', '--forget').code == 0400+	assert run_home(dir, home, 'remote', '--json').out.contains('"configured":false')401+}402+403+fn test_offer_steal_folder_remote() {404+	syncroot := temp_repo('sync')405+	home := temp_repo('sync-home')406+	defer {407+		os.rmdir_all(syncroot) or {}408+		os.rmdir_all(home) or {}409+	}410+	remote := os.join_path(syncroot, 'remote')411+	a := os.join_path(syncroot, 'A', 'proj')412+	b_parent := os.join_path(syncroot, 'B')413+	os.mkdir_all(a) or { panic(err) }414+	os.mkdir_all(b_parent) or { panic(err) }415+	init_repo(a)416+	write(a, 'a.txt', 'from A\n')417+	assert run_home(a, home, 'remote', 'folder', remote).code == 0418+	assert run_home(a, home, 'save', 'A first').code == 0419+	clone := run_home(b_parent, home, 'steal', 'proj')420+	assert clone.code == 0421+	b := os.join_path(b_parent, 'proj')422+	assert os.read_file(os.join_path(b, 'a.txt'))! == 'from A\n'423+	write(b, 'b.txt', 'from B\n')424+	assert run_home(b, home, 'save', 'B first').code == 0425+	write(a, 'a2.txt', 'second\n')426+	assert run_home(a, home, 'save', 'A second').code == 0427+	assert run_home(a, home, 'steal').code == 0428+	assert os.exists(os.join_path(a, 'b.txt'))429+	assert run_home(b_parent, home, 'steal', '--list').out.contains('proj')430+}431+432+fn test_man_and_koh2_handoff() {433+	dir := temp_repo('man')434+	home := temp_repo('man-home')435+	defer {436+		os.rmdir_all(dir) or {}437+		os.rmdir_all(home) or {}438+	}439+	whole := run_home(dir, home, 'man')440+	assert whole.code == 0441+	assert whole.out.contains('NAME')442+	assert whole.out.contains('COMMANDS')443+	strip := run_home(dir, home, 'man', 'strip')444+	assert strip.code == 0445+	assert strip.out.contains('strip')446+	assert !strip.out.contains('koh save ')447+	assert run_home(dir, home, 'man', 'nonsense').code == 2448+	legacy := temp_repo('legacy')449+	defer { os.rmdir_all(legacy) or {} }450+	os.mkdir_all(os.join_path(legacy, '.koh')) or { panic(err) }451+	fake := os.join_path(home, 'bin')452+	os.mkdir_all(fake) or { panic(err) }453+	fake_koh := os.join_path(fake, 'koh2')454+	os.write_file(fake_koh, '#!/bin/sh\nprintf "handoff:%s\\n" "$*"\nexit 7\n') or { panic(err) }455+	os.chmod(fake_koh, 0o755) or { panic(err) }456+	handoff := run_home_with_path(legacy, home, fake, 'status', '--json')457+	assert handoff.code == 7458+	assert handoff.out.contains('handoff:status --json')459+}460+461+fn run_home_with_path(repo string, home string, bin_dir string, args ...string) Result {462+	stamp := '${os.getpid()}-${time.now().unix_nano()}'463+	base := os.join_path(os.temp_dir(), 'koh3-cli-${stamp}')464+	out_path := '${base}.out'465+	err_path := '${base}.err'466+	code_path := '${base}.code'467+	mut command := 'HOME=${q(home)} PATH=${q(bin_dir + ":" + os.getenv("PATH"))} ${q(binary)}'468+	for arg in args {469+		command += ' ${q(arg)}'470+	}471+	shell := 'cd ${q(repo)} && ${command} </dev/null >${q(out_path)} 2>${q(err_path)}; printf "%s" $? >${q(code_path)}'472+	result := os.execute(shell)473+	if result.exit_code != 0 {474+		panic('could not run test command: ${result.output}')475+	}476+	code := os.read_file(code_path) or { panic(err) }477+	out := os.read_file(out_path) or { '' }478+	err := os.read_file(err_path) or { '' }479+	os.rm(out_path) or {}480+	os.rm(err_path) or {}481+	os.rm(code_path) or {}482+	return Result{code: code.int(), out: out, err: err}369483 }

view file