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
↓ koh-453157da.tar.gzatlas at this save
files changed9 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 −1src/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)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')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 {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: writtensrc/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)! {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() {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() {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 }