dfe5c9f72825
count the files a walk reads and a save stores on the terminal, so the first save of a large tree is never silent, and raise the line ceiling to 12,000 with the tripwire at 11,800, June's call (3.2.13)
- time
- 2026-09-28 19:42 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- clear feedback
- parent
- 67b039b810e5
↓ koh-dfe5c9f7.tar.gzatlas at this save
files changed8 files
VERSION+1 −1src/cli/cmd_save.v+7 −1src/cli/cmd_status.v+2 −1src/cli/progress.v+12 −0src/history/save.v+3 −1src/snapshot/snapshot_test.v+7 −4src/snapshot/tree.v+32 −10v.mod+1 −1src/cli/cmd_save.v+7 −1
@@ -26,7 +26,8 @@2626 defer {2727 store.close()2828 }29- walked := snapshot.walk_report(store, store.root, snapshot.load(store.root)) or {29+ mut hashing := reading()30+ walked := snapshot.walk_report(store, store.root, snapshot.load(store.root), mut hashing) or {3031 return refuse(args.json(), err.msg())3132 }3233 save := history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{@@ -34,6 +35,11 @@3435 agent: args.value('agent')3536 session: args.value('session')3637 is_checkpoint: is_checkpoint38+ storing: fn (done int, total int) {39+ if done % 100 == 1 || done == total {40+ working('storing ${done} / ${total} files')41+ }42+ }3743 }) or { return refuse(args.json(), err.msg()) }3844 3945 files := history.files_of(store, save.id) or { []snapshot.FileEntry{} }src/cli/cmd_status.v+2 −1
@@ -111,7 +111,8 @@111111 112112 pub fn compare_with_head(store &storage.Store, head history.Save) !Changes {113113 before := snapshot.index_files(history.files_of(store, head.id)!)114- walked := snapshot.walk_report(store, store.root, snapshot.load(store.root))!114+ mut hashing := reading()115+ walked := snapshot.walk_report(store, store.root, snapshot.load(store.root), mut hashing)!115116 after := snapshot.index_files(walked.files)116117 mut added := []string{}117118 mut modified := []string{}src/cli/progress.v+12 −0
@@ -8,6 +8,7 @@88 import os99 import src.fmt1010 import src.remote11+import src.snapshot1112 import src.view1213 1314 fn working(text string) {@@ -33,6 +34,17 @@3334 fn line_width() int {3435 _, columns := view.terminal_size()3536 return if columns > 1 { columns - 1 } else { 79 }37+}38+39+// reading is the walk's line: how many files it has had to read and hash.40+fn reading() snapshot.Hashing {41+ return snapshot.Hashing{42+ on: fn (done int, total int) {43+ if done % 100 == 1 {44+ working('reading files · ${done}')45+ }46+ }47+ }3648 }3749 3850 // Watched counts what an offer, steal or strip moves through a backend, so thesrc/history/save.v+3 −1
@@ -24,6 +24,8 @@2424 replayed_from string2525 created_at i642626 machine string27+ // storing hears how many of the tree's files have been stored.28+ storing snapshot.Counted = unsafe { nil }2729 }2830 2931 pub const nothing_changed = 'nothing has changed since the last save'@@ -92,7 +94,7 @@9294 // in a single step, so a failure leaves no half-combined line behind.9395 pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {9496 if root != '' {95- snapshot.store_objects(store, root, files)!97+ snapshot.store_objects(store, root, files, input.storing)!9698 }9799 return write_save(store, files, input)!98100 }src/snapshot/snapshot_test.v+7 −4
@@ -158,8 +158,11 @@158158 put(root, 'safe.txt', 'kept\n')!159159 os.write_file('${root}/bad\\name.txt', 'skipped\n')!160160 os.write_file('${root}/Icon\r', 'skipped\n')!161- walked := walk_report(store, root, Ignore{})!161+ mut hashing := Hashing{}162+ walked := walk_report(store, root, Ignore{}, mut hashing)!162163 assert walked.files.map(it.path) == ['safe.txt']164+ // Only a file that had to be read counts; unsafe names are never read.165+ assert hashing.count == 1163166 assert walked.unsafe_paths == ['Icon\\x0d', 'bad\\\\name.txt']164167 }165168 @@ -281,7 +284,7 @@281284 put(root, 'noise.log', 'ignored, must survive\n')!282285 put(root, '.kohignore', '*.log\n')!283286 target := walk(store, root, load(root))!284- store_objects(store, root, target)!287+ store_objects(store, root, target, unsafe { nil })!285288 286289 // Drift the working tree: clobber one file, delete another, and add a file287290 // the recorded tree has never seen.@@ -315,7 +318,7 @@315318 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!316319 os.chmod(os.join_path(root, 'run.sh'), 0o755)!317320 target := walk(store, root, Ignore{})!318- store_objects(store, root, target)!321+ store_objects(store, root, target, unsafe { nil })!319322 os.chmod(os.join_path(root, 'run.sh'), 0o644)!320323 321324 restore_files(store, root, Ignore{}, target, [])!@@ -334,7 +337,7 @@334337 put(root, 'same.txt', 'unchanged\n')!335338 put(root, 'gone.txt', 'bye\n')!336339 before := walk(store, root, Ignore{})!337- store_objects(store, root, before)!340+ store_objects(store, root, before, unsafe { nil })!338341 339342 os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!340343 os.rm(full_path(root, 'gone.txt'))!src/snapshot/tree.v+32 −10
@@ -75,19 +75,32 @@7575 return escaped7676 }7777 78+// Counted hears how far a long step has come; `total` is 0 when it is not known.79+pub type Counted = fn (done int, total int)80+81+// Hashing counts the files a walk has to read and hash (a cached one costs82+// nothing), and tells `on` each time, when there is an `on`.83+pub struct Hashing {84+pub:85+ on Counted = unsafe { nil }86+mut:87+ count int88+}89+7890 // walk hashes every tracked file under `root` and returns them sorted by path.7991 // Symlinks are skipped, as Koh 2 skipped them, so migration stays lossless.8092 pub fn walk(store &storage.Store, root string, ig Ignore) ![]FileEntry {81- return walk_report(store, root, ig)!.files93+ mut quiet := Hashing{}94+ return walk_report(store, root, ig, mut quiet)!.files8295 }8396 84-pub fn walk_report(store &storage.Store, root string, ig Ignore) !WalkReport {97+pub fn walk_report(store &storage.Store, root string, ig Ignore, mut hashing Hashing) !WalkReport {8598 cache := store.load_cache()!8699 mut entries := []FileEntry{}87100 mut fresh := []storage.CacheRow{}88101 mut unsafe_paths := []string{}89102 walk_dir(root, '', ig.banning(store.stripped_paths()!), cache, mut entries, mut fresh,90- mut unsafe_paths)!103+ mut unsafe_paths, mut hashing)!91104 // Skipped while another command writes, or inside a caller's transaction:92105 // the next walk hashes those files again, which is all a missing row costs.93106 store.save_cache_rows(fresh) or {}@@ -106,12 +119,14 @@106119 mut entries := []FileEntry{}107120 mut unused := []storage.CacheRow{}108121 mut unsafe_paths := []string{}109- walk_dir(root, '', ig, map[string]storage.CacheRow{}, mut entries, mut unused, mut unsafe_paths)!122+ mut quiet := Hashing{}123+ walk_dir(root, '', ig, map[string]storage.CacheRow{}, mut entries, mut unused, mut unsafe_paths, mut124+ quiet)!110125 entries.sort(a.path < b.path)111126 return entries112127 }113128 114-fn walk_dir(root string, rel string, ig Ignore, cache map[string]storage.CacheRow, mut out []FileEntry, mut fresh []storage.CacheRow, mut unsafe_paths []string) ! {129+fn walk_dir(root string, rel string, ig Ignore, cache map[string]storage.CacheRow, mut out []FileEntry, mut fresh []storage.CacheRow, mut unsafe_paths []string, mut hashing Hashing) ! {115130 dir := if rel == '' { root } else { os.join_path(root, rel) }116131 for name in os.ls(dir)! {117132 child := if rel == '' { name } else { '${rel}/${name}' }@@ -127,13 +142,13 @@127142 }128143 match st.kind {129144 .dir {130- walk_dir(root, child, ig, cache, mut out, mut fresh, mut unsafe_paths)!145+ walk_dir(root, child, ig, cache, mut out, mut fresh, mut unsafe_paths, mut hashing)!131146 }132147 .file {133148 if st.size > storage.max_file_size {134149 return error('${child} is ${st.size} bytes; koh stores code and text, not files this large')135150 }136- hash := hash_file(root, child, st, cache, mut fresh)!151+ hash := hash_file(root, child, st, cache, mut fresh, mut hashing)!137152 out << FileEntry{138153 path: child139154 hash: hash@@ -146,12 +161,16 @@146161 }147162 }148163 149-fn hash_file(root string, rel string, st Stat, cache map[string]storage.CacheRow, mut fresh []storage.CacheRow) !string {164+fn hash_file(root string, rel string, st Stat, cache map[string]storage.CacheRow, mut fresh []storage.CacheRow, mut hashing Hashing) !string {150165 if row := cache[rel] {151166 if row.size == st.size && row.mtime_ns == st.mtime_ns && row.ctime_ns == st.ctime_ns {152167 return row.hash153168 }154169 }170+ hashing.count++171+ if hashing.on != unsafe { nil } {172+ hashing.on(hashing.count, 0)173+ }155174 hash := storage.hash_hex(os.read_bytes(os.join_path(root, rel))!)156175 if cacheable(st, time.now().unix_nano()) {157176 fresh << storage.CacheRow{@@ -177,8 +196,11 @@177196 178197 // store_objects writes the bytes of every entry the database does not have179198 // yet, so a save only pays for content it has never seen.180-pub fn store_objects(store &storage.Store, root string, entries []FileEntry) ! {181- for entry in entries {199+pub fn store_objects(store &storage.Store, root string, entries []FileEntry, progress Counted) ! {200+ for i, entry in entries {201+ if progress != unsafe { nil } {202+ progress(i + 1, entries.len)203+ }182204 if store.has_object(entry.hash)! {183205 continue184206 }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.2.12'3+ version: '3.2.13'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }