1f6a60e93364
explain storage, events, commands and the history view (3.2.19)
- time
- 2026-09-29 02:45 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-sonnet-5-5
- session
- comment and tidy pass
- parent
- 4fedb4de22e5
↓ koh-1f6a60e9.tar.gzatlas at this save
files changed19 files
VERSION+1 −1src/cli/args.v+2 −0src/cli/cmd_back.v+5 −0src/cli/cmd_offer.v+2 −0src/cli/cmd_pluck.v+2 −0src/cli/cmd_remote.v+6 −0src/cli/cmd_save.v+2 −0src/cli/cmd_steal.v+6 −0src/cli/cmd_strip.v+1 −0src/cli/cmd_update.v+2 −0src/cli/output.v+1 −0src/events/events.v+5 −0src/fmt/fmt.v+1 −0src/history/history.v+3 −0src/storage/cache.v+2 −0src/storage/sqlite.v+2 −0src/storage/storage.v+6 −0src/view/view.v+9 −0v.mod+1 −1src/cli/args.v+2 −0
@@ -21,6 +21,8 @@2121 multi map[string][]string2222 }2323 24+// parse refuses what the command did not declare: an unknown option, a value given to a flag, a missing25+// value. `--` ends the options.2426 pub fn parse(command string, raw []string, spec Options) !Args {2527 mut args := Args{}2628 mut index := 0src/cli/cmd_back.v+5 −0
@@ -16,6 +16,7 @@1616 import src.snapshot1717 import src.storage1818 19+// cmd_undo goes back one save, N saves, or to before the current --session. Unsaved changes refuse it.1920 pub fn cmd_undo(raw []string) int {2021 args := parse('undo', raw, Options{2122 flags: ['session', 'yes', 'json']@@ -49,6 +50,8 @@4950 return going_back(store, target, 'undo', args, false)5051 }5152 53+// cmd_revert puts the tree back to head, checkpointing what is there first, and finishes a restore that54+// stopped part way.5255 pub fn cmd_revert(raw []string) int {5356 args := parse('revert', raw, Options{5457 flags: ['yes', 'json']@@ -79,6 +82,8 @@7982 return going_back(store, head, 'revert', args, true)8083 }8184 85+// cmd_load restores any save, or the newest checkpoint with --checkpoint. Unsaved changes refuse it;86+// --json needs --yes.8287 pub fn cmd_load(raw []string) int {8388 args := parse('load', raw, Options{8489 flags: ['checkpoint', 'yes', 'json']src/cli/cmd_offer.v+2 −0
@@ -14,6 +14,8 @@1414 import src.remote1515 import src.storage1616 17+// cmd_offer sends new saves and events, and with --builds a release. A repo still on a read-only source18+// refuses, and so does a clash met while upgrading it: "run koh steal".1719 pub fn cmd_offer(raw []string) int {1820 args := parse('offer', raw, Options{1921 values: ['notes']src/cli/cmd_pluck.v+2 −0
@@ -14,6 +14,8 @@1414 import src.snapshot1515 import src.storage1616 17+// cmd_pluck puts one save's change on the current tree (--reverse takes it off, --dry-run only reports).18+// A real pluck refuses unsaved changes, and any conflict refuses the whole pluck.1719 pub fn cmd_pluck(raw []string) int {1820 args := parse('pluck', raw, Options{1921 flags: ['reverse', 'dry-run', 'yes', 'json']src/cli/cmd_remote.v+6 −0
@@ -19,6 +19,8 @@1919 import src.storage2020 import term.termios2121 22+// cmd_remote shows the machine's remote (a wizard when none is set), or sets it from `folder`, `s3` or a Kepr23+// host, or forgets it with --forget. `--code` belongs to the Kepr form only.2224 pub fn cmd_remote(raw []string) int {2325 args := parse('remote', raw, Options{2426 values: ['code']@@ -97,6 +99,8 @@9799 return set_remote(args)98100 }99101 102+// set_remote handles `folder` and `s3`, and passes any other first word to set_kepr_remote as a host.103+// Every form goes through save_after_check.100104 fn set_remote(args Args) int {101105 if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {102106 return set_kepr_remote(args)@@ -139,6 +143,8 @@139143 }140144 }141145 146+// set_kepr_remote signs in to a Kepr: its connect page gives a one-time code, /api/connect swaps the code147+// for S3 settings, and save_after_check tests them before they are kept.142148 fn set_kepr_remote(args Args) int {143149 if args.positional.len != 1 {144150 return usage_error('koh remote <host> accepts one Kepr host')src/cli/cmd_save.v+2 −0
@@ -7,6 +7,8 @@77 import src.history88 import src.snapshot99 10+// cmd_save records the tree as a save, or as a checkpoint, which also records an unchanged tree. It needs11+// a message and refuses when nothing has changed.1012 pub fn cmd_save(raw []string, is_checkpoint bool) int {1113 verb := if is_checkpoint { 'checkpoint' } else { 'save' }1214 args := parse(verb, raw, Options{src/cli/cmd_steal.v+6 −0
@@ -188,6 +188,8 @@188188 }189189 }190190 191+// ready_to_steal is the guided steal's first step: with unsaved work it offers checkpoint, save or stop,192+// and answers whether to go on. A tree it cannot read counts as ready.191193 fn ready_to_steal() bool {192194 store := open_store() or { return true }193195 head := history.head(store)@@ -254,6 +256,9 @@254256 return paths255257 }256258 259+// guide_steal is the terminal walk-through steal calls as `guide`: overview, one prompt per clash (`b`260+// steps back), then the summary and confirm. It returns each clash's side; quitting or declining cancels261+// with an error, and the line and tree are unchanged. --mine/--theirs answer the clashes, not the summary.257262 fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {258263 done_working()259264 _, columns := view.terminal_size()@@ -366,6 +371,7 @@366371 return false367372 }368373 374+// list_repos names the repos on this machine's remote: the first path segment of every key.369375 fn list_repos(args Args) int {370376 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }371377 keys := backend.list('') or { return refuse(args.json(), err.msg()) }src/cli/cmd_strip.v+1 −0
@@ -18,6 +18,7 @@1818 import src.storage1919 import src.exchange2020 21+// cmd_strip needs a terminal and takes no --yes: a person confirms twice, and declining exits 1.2122 pub fn cmd_strip(raw []string) int {2223 args := parse('strip', raw, Options{2324 values: ['save']src/cli/cmd_update.v+2 −0
@@ -10,6 +10,8 @@1010 import src.build1111 import src.update1212 13+// cmd_update installs a newer koh when one is published. `--check` only reports, and exits 1 when there14+// is an update; --json needs --yes when it would install.1315 pub fn cmd_update(raw []string) int {1416 args := parse('update', raw, Options{1517 flags: ['check', 'json', 'yes']src/cli/output.v+1 −0
@@ -12,6 +12,7 @@1212 import os1313 import src.fmt1414 15+// ok, note and warn print on stdout after clearing the progress line; errors go to stderr.1516 pub fn ok(message string) {1617 done_working()1718 println(' ${fmt.amber(fmt.glyph_ok)} ${message}')src/events/events.v+5 −0
@@ -77,6 +77,7 @@7777 return store.db.changes() > 07878 }7979 80+// next_timestamp is now, or one second past the newest event when that is not earlier.8081 fn next_timestamp(store &storage.Store) !i64 {8182 now := time.now().unix()8283 mut stmt := store.db.prepare('SELECT MAX(created_at) FROM events')!@@ -93,6 +94,7 @@9394 return now9495 }9596 97+// insert ignores an ID already stored, which is how receive tells a repeat from a new event.9698 fn insert(store &storage.Store, record Event) ! {9799 mut stmt := store.db.prepare('INSERT OR IGNORE INTO events98100 (id, kind, save_id, path, reason, created_at, machine) VALUES (?, ?, ?, ?, ?, ?, ?)')!@@ -135,6 +137,7 @@135137 })!136138 }137139 140+// event_id hashes each field with its length, so two different facts cannot spell the same input.138141 pub fn event_id(event Event) string {139142 mut sb := strings.Builder{}140143 put(mut sb, 'kind', event.kind)@@ -197,6 +200,7 @@197200 return flags198201 }199202 203+// flag_state is latest_flags for one save; a save never flagged reads as not flagged.200204 pub fn flag_state(store &storage.Store, save_id string) !Flag {201205 flags := latest_flags(store)!202206 return flags[save_id] or {@@ -206,6 +210,7 @@206210 }207211 }208212 213+// strip_events lists every strip event, whole-history or one save, oldest first.209214 pub fn strip_events(store &storage.Store) ![]Event {210215 mut stmt := store.db.prepare('${select_event} WHERE kind = ? ORDER BY created_at, id')!211216 defer {src/fmt/fmt.v+1 −0
@@ -93,6 +93,7 @@9393 return out9494 }9595 96+// visible_width counts characters, not bytes or colour codes; a double-width character counts as one.9697 pub fn visible_width(text string) int {9798 return strip_ansi(text).runes().len9899 }src/history/history.v+3 −0
@@ -46,6 +46,7 @@4646 return by_id(store, id) or { none }4747 }4848 49+// head_id is the head save's ID, or '' before the first save.4950 pub fn head_id(store &storage.Store) string {5051 return store.state(storage.head_key) or { '' }5152 }@@ -186,6 +187,7 @@186187 return none187188 }188189 190+// files_of lists a save's files sorted by path, each with its object's size.189191 pub fn files_of(store &storage.Store, save_id string) ![]snapshot.FileEntry {190192 mut stmt :=191193 store.db.prepare('SELECT path, hash, executable FROM save_files WHERE save_id = ? ORDER BY path')!@@ -206,6 +208,7 @@206208 return files207209 }208210 211+// save_count counts every save Koh holds, replayed-away ones included, unlike `line`.209212 pub fn save_count(store &storage.Store) !i64 {210213 return count(store, 'saves')211214 }src/storage/cache.v+2 −0
@@ -4,6 +4,8 @@44 55 module storage66 7+// CacheRow is what a file looked like when last hashed. It is stale as soon as the file's size, mtime8+// or ctime differ; snapshot.hash_file makes that check.79 pub struct CacheRow {810 pub:911 path stringsrc/storage/sqlite.v+2 −0
@@ -159,6 +159,7 @@159159 db &Db = unsafe { nil }160160 }161161 162+// prepare compiles one statement. The error names the query, since SQLite's message alone does not.162163 pub fn (db &Db) prepare(query string) !Stmt {163164 query_c := cstring(query)164165 defer {@@ -247,6 +248,7 @@247248 return out.bytestr()248249 }249250 251+// blob copies for the same reason as text.250252 pub fn (stmt &Stmt) blob(idx int) []u8 {251253 ptr := C.sqlite3_column_blob(stmt.stmt, idx)252254 n := C.sqlite3_column_bytes(stmt.stmt, idx)src/storage/storage.v+6 −0
@@ -165,14 +165,18 @@165165 // What any write says after waiting busy_timeout_ms for another writer.166166 pub const busy = 'another koh command is writing to this repo — wait for it to finish, then run this again'167167 168+// begin takes the write lock at once (IMMEDIATE), so a second writer waits busy_timeout_ms here169+// instead of failing part way through.168170 pub fn (s &Store) begin() ! {169171 s.db.exec('BEGIN IMMEDIATE')!170172 }171173 174+// end commits what begin opened.172175 pub fn (s &Store) end() ! {173176 s.db.exec('END')!174177 }175178 179+// rollback drops its own failure: the caller is already returning the error that sent it here.176180 pub fn (s &Store) rollback() {177181 s.db.exec('ROLLBACK') or {}178182 }@@ -191,6 +195,7 @@191195 return paths192196 }193197 198+// state reads a machine-local fact. A read error answers none, the same as unset.194199 pub fn (s &Store) state(key string) ?string {195200 mut stmt := s.db.prepare('SELECT value FROM state WHERE key = ?') or { return none }196201 defer {@@ -232,6 +237,7 @@232237 s.db.exec("VACUUM INTO '${path.replace("'", "''")}'")!233238 }234239 240+// integrity_check returns SQLite's complaints; empty means the file is sound.235241 pub fn (s &Store) integrity_check() ![]string {236242 mut problems := []string{}237243 mut stmt := s.db.prepare('PRAGMA integrity_check')!src/view/view.v+9 −0
@@ -149,6 +149,9 @@149149 }150150 }151151 152+// list_key: Tab switches tabs; up/down and j/k move; g is the oldest save, G the newest (the list is newest153+// first); Enter opens the detail; / searches; c copies the ID; q leaves, and Esc drops a kept filter before154+// it leaves. Only selection, tab, query and mode change here.152155 fn list_key(mut session Session, key Key) bool {153156 count := session.screen.items.len154157 match key.kind {@@ -237,6 +240,8 @@237240 return false238241 }239242 243+// detail_key: l loads this save, and f flags it, or clears an existing flag. Both go on to confirm_key, which244+// is the only place an action runs; an unflagged save asks for a reason first. c copies the ID, Esc goes back.240245 fn detail_key(mut session Session, key Key) bool {241246 match key.kind {242247 .escape {@@ -340,6 +345,7 @@340345 return out341346 }342347 348+// searchable is what a search matches: ID, message, agent and session, without regard to case.343349 fn searchable(save history.Save) string {344350 return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()345351 }@@ -631,6 +637,7 @@631637 return (screen.flags[save_id] or { events.Flag{} }).flagged632638 }633639 640+// diff_of compares a save with its parent. A failed read shows an empty diff instead of closing the screen.634641 fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {635642 return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {636643 []snapshot.Delta{}@@ -667,6 +674,7 @@667674 }668675 }669676 677+// on_line_set marks the saves on the current line; the all tab also lists those replayed off it.670678 fn on_line_set(store &storage.Store) map[string]bool {671679 mut ids := map[string]bool{}672680 for save in history.line(store) or { []history.Save{} } {@@ -675,6 +683,7 @@675683 return ids676684 }677685 686+// enter_screen and leave_screen are a pair: run defers leave_screen so every exit restores the terminal.678687 fn enter_screen() {679688 paint('\x1b[?1049h\x1b[?25l')680689 raw_mode(true)v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.2.18'3+ version: '3.2.19'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }