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
↓ koh-1f6a834a.tar.gzatlas at this save
files changed6 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 −1src/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)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 }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 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 }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: firsttests/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 }