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
8 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.2.121+3.2.13

view file

src/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{} }

view file

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{}

view file

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 the

view file

src/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 }

view file

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

view file

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 		}

view file

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 }

view file