453157da5325

recognise a restore that stopped half-way and point every building command at revert, so a half-written tree is never saved or offered (3.0.31, codex review pending)

time
2026-09-24 03:35 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh 3 reliability campaign
parent
1d049f5cd490
9 files VERSION+1 −1src/cli/cmd_back.v+5 −0src/cli/cmd_save.v+3 −0src/cli/cmd_status.v+20 −14src/snapshot/restore.v+23 −0src/sync/steal.v+3 −0src/sync/sync_test.v+22 −0tests/cli_test.v+37 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.301+3.0.31

view file

src/cli/cmd_back.v+5 −0
@@ -62,6 +62,8 @@6262 	}6363 	changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) }6464 	if !changes.dirty {65+		// The files all landed before whatever stopped the last restore.66+		snapshot.clear_restore_mark(store) or { return refuse(args.json(), err.msg()) }6567 		if args.json() {6668 			emit_json(json.encode(RevertJson{6769 				reverted: false@@ -185,6 +187,9 @@185187 // require_clean refuses to restore over work only the disk has, as Koh 2 did.186188 // `revert` is the one going-back that may discard it, and it records it first.187189 fn require_clean(store &storage.Store) ! {190+	if snapshot.restore_pending(store) {191+		return error(snapshot.restore_unfinished)192+	}188193 	head := history.head(store) or { return }189194 	if compare_with_head(store, head)!.dirty {190195 		return error(unsaved_changes)

view file

src/cli/cmd_save.v+3 −0
@@ -26,6 +26,9 @@2626 	defer {2727 		store.close()2828 	}29+	if snapshot.restore_pending(store) {30+		return refuse(args.json(), snapshot.restore_unfinished)31+	}2932 	save := history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{3033 		message:       message3134 		agent:         args.value('agent')

view file

src/cli/cmd_status.v+20 −14
@@ -20,19 +20,24 @@2020 		return refuse(args.json(), 'this repo has no saves yet — run "koh save \\"first save\\""')2121 	}2222 	changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) }23+	unfinished := snapshot.restore_pending(store)2324 2425 	if args.json() {2526 		emit_json(json.encode(StatusJson{26-			head_id:        head.id27-			head_msg:       head.message28-			dirty:          changes.dirty29-			modified:       changes.modified30-			added:          changes.added31-			deleted:        changes.removed32-			ignore_changed: changes.ignore_changed27+			head_id:            head.id28+			head_msg:           head.message29+			dirty:              changes.dirty30+			modified:           changes.modified31+			added:              changes.added32+			deleted:            changes.removed33+			ignore_changed:     changes.ignore_changed34+			restore_unfinished: unfinished3335 		}))3436 		return 03537 	}38+	if unfinished {39+		warn(snapshot.restore_unfinished)40+	}3641 	if !changes.dirty {3742 		ok('no changes')3843 		if changes.ignore_changed {@@ -66,13 +71,14 @@6671 }6772 6873 struct StatusJson {69-	head_id        string70-	head_msg       string71-	dirty          bool72-	modified       []string73-	added          []string74-	deleted        []string75-	ignore_changed bool74+	head_id            string75+	head_msg           string76+	dirty              bool77+	modified           []string78+	added              []string79+	deleted            []string80+	ignore_changed     bool81+	restore_unfinished bool7682 }7783 7884 pub fn compare_with_head(store &storage.Store, head history.Save) !Changes {

view file

src/snapshot/restore.v+23 −0
@@ -10,6 +10,21 @@1010 import os1111 import src.storage1212 13+// Set while a restore writes into the repo. A restore records its save first,14+// so a mark still here means the files stopped part way: the tree is half one15+// save and half another, and nobody's work.16+const restoring_key = 'restoring'17+18+pub const restore_unfinished = 'an earlier restore stopped before all its files were written — run koh revert to finish it (it records the tree as it is in a checkpoint first)'19+20+pub fn restore_pending(store &storage.Store) bool {21+	return store.state(restoring_key) != none22+}23+24+pub fn clear_restore_mark(store &storage.Store) ! {25+	store.remove_state(restoring_key)!26+}27+1328 pub struct RestoreReport {1429 pub:1530 	written int@@ -23,6 +38,11 @@2338 // temp file and a rename, so an interrupted restore leaves a readable tree2439 // rather than half-written files.2540 pub fn restore_files(store &storage.Store, root string, ig Ignore, target []FileEntry, remove []string) !RestoreReport {41+	// Peek writes elsewhere; only the repo's own tree is marked.42+	marks := root == store.root43+	if marks {44+		store.set_state(restoring_key, 'yes')!45+	}2646 	mut wanted := map[string]bool{}2747 	for entry in target {2848 		wanted[entry.path] = true@@ -62,6 +82,9 @@6282 		data := store.get_object(entry.hash)!6383 		write_file(os.join_path(root, entry.path), data, entry.executable)!6484 		written++85+	}86+	if marks {87+		clear_restore_mark(store)!6588 	}6689 	return RestoreReport{6790 		written: written

view file

src/sync/steal.v+3 −0
@@ -63,6 +63,9 @@6363 // may have to combine; a repo with nothing of its own simply fast-forwards.6464 pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {6565 	name := repo_name(store, mut backend)!66+	if snapshot.restore_pending(store) {67+		return error(snapshot.restore_unfinished)68+	}6669 	head_before := history.head(store)6770 	// Combining rewrites the working tree, so unsaved work would be lost.6871 	if dirty(store, head_before)! {

view file

src/sync/sync_test.v+22 −0
@@ -196,6 +196,28 @@196196 	assert history.head(b)!.id == head_before197197 }198198 199+// A steal onto a half-restored tree would replay it as somebody's work.200+fn test_steal_refuses_after_a_restore_cut_short() {201+	base := scratch('steal_cut')!202+	defer {203+		os.rmdir_all(base) or {}204+	}205+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!206+	mut a := open_repo(machine('steal_cut_a')!)!207+	defer {208+		a.close()209+	}210+	put(a.root, 'a.txt', 'one\n')!211+	commit(a, 'first')!212+	offer_now(a, mut backend)!213+	a.set_state('restoring', 'yes')!214+	if _ := steal_now(a, mut backend) {215+		assert false, 'steal must refuse while a restore is unfinished'216+	} else {217+		assert err.msg().contains('koh revert')218+	}219+}220+199221 // A replayed save keeps its original time but sits on a newer parent, so a200222 // clone cannot order saves by time.201223 fn test_a_clone_imports_parents_before_children() {

view file

tests/cli_test.v+37 −0
@@ -214,6 +214,43 @@214214 	assert run(dir, 'status', '--json').out == head215215 }216216 217+// A restore records its save first, then writes files. When the writing stops218+// part way, the half-written tree is not somebody's work: saving it would219+// record (and offer) a tree that undoes changes nobody asked to undo. Every220+// command that would build on it points at revert, which finishes the job.221+fn test_a_restore_cut_short_points_at_revert() {222+	dir := temp_repo('cut-short')223+	defer {224+		os.chmod(os.join_path(dir, 'ro'), 0o755) or {}225+		os.rmdir_all(dir) or {}226+	}227+	init_repo(dir)228+	write(dir, 'zero', '0\n')229+	assert run(dir, 'save', 'zero').code == 0230+	write(dir, 'ro/f', 'v1\n')231+	write(dir, 'top', 'v1\n')232+	assert run(dir, 'save', 'first').code == 0233+	write(dir, 'ro/f', 'v2\n')234+	write(dir, 'top', 'v2\n')235+	assert run(dir, 'save', 'second').code == 0236+	os.chmod(os.join_path(dir, 'ro'), 0o555)!237+	assert run(dir, 'undo', '--yes').code == 1238+	os.chmod(os.join_path(dir, 'ro'), 0o755)!239+240+	assert run(dir, 'status', '--json').out.contains('"restore_unfinished":true')241+	for args in [['save', 'half'], ['checkpoint', 'half'], ['undo', '--yes']] {242+		r := run(dir, ...args)243+		assert r.code == 1244+		assert r.err.contains('koh revert')245+	}246+	assert run(dir, 'revert', '--yes').code == 0247+	assert os.read_file(os.join_path(dir, 'ro/f'))! == 'v1\n'248+	assert os.read_file(os.join_path(dir, 'top'))! == 'v1\n'249+	assert run(dir, 'status', '--json').out.contains('"restore_unfinished":false')250+	write(dir, 'top', 'v3\n')251+	assert run(dir, 'save', 'carry on').code == 0252+}253+217254 // Doctor clears peek folders left from earlier days, never one somebody may218255 // be reading right now.219256 fn test_doctor_sweeps_only_old_peek_folders() {

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.30'3+	version: '3.0.31'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file