2369295a09d8
rename commit.v to save.v and its commit_* functions to save_worktree, save_walk and save_tree, dropping git vocabulary (3.0.57)
- time
- 2026-09-25 14:34 UTC · 2 weeks ago
- machine
- koh2
- agent
- claude-opus-5-5
- session
- linux build
- parent
- a1b805301c6f
↓ koh-2369295a.tar.gzatlas at this save
files changed10 files
VERSION+1 −1src/cli/cmd_back_test.v+5 −5src/cli/cmd_pluck.v+1 −1src/cli/cmd_save.v+1 −1src/events/events_test.v+1 −1src/exchange/exchange_test.v+66 −66src/history/commit.v+0 −227src/history/history_test.v+26 −26src/history/save.v+227 −0v.mod+1 −1src/cli/cmd_back_test.v+5 −5
@@ -20,11 +20,11 @@2020 }2121 path := os.join_path(dir, 'a.txt')2222 os.write_file(path, 'one\n')!23- first := history.commit_worktree(store, dir, snapshot.load(dir), history.SaveInput{23+ first := history.save_worktree(store, dir, snapshot.load(dir), history.SaveInput{2424 message: 'one'2525 })!2626 os.write_file(path, 'two\n')!27- history.commit_worktree(store, dir, snapshot.load(dir), history.SaveInput{27+ history.save_worktree(store, dir, snapshot.load(dir), history.SaveInput{2828 message: 'two'2929 })!3030 os.write_file(path, 'unsaved\n')!@@ -57,11 +57,11 @@5757 store.close()5858 }5959 os.write_file(os.join_path(dir, 'a.txt'), 'one\n')!60- first := history.commit_worktree(store, dir, snapshot.load(dir), history.SaveInput{60+ first := history.save_worktree(store, dir, snapshot.load(dir), history.SaveInput{6161 message: 'one'6262 })!6363 os.write_file(os.join_path(dir, 'a.txt'), 'two\n')!64- history.commit_worktree(store, dir, snapshot.load(dir), history.SaveInput{64+ history.save_worktree(store, dir, snapshot.load(dir), history.SaveInput{6565 message: 'two'6666 })!6767 // Any save recorded while the mark is missing aborts the restore.@@ -86,7 +86,7 @@8686 store.close()8787 }8888 os.write_file(os.join_path(dir, 'a.txt'), 'one\n')!89- first := history.commit_worktree(store, dir, snapshot.load(dir), history.SaveInput{89+ first := history.save_worktree(store, dir, snapshot.load(dir), history.SaveInput{9090 message: 'one'9191 })!9292 snapshot.mark_restore(store)!src/cli/cmd_pluck.v+1 −1
@@ -87,7 +87,7 @@8787 // The resulting tree is the working tree with the change applied, so its8888 // object bytes come from disk.8989 snapshot.mark_restore(store) or { return refuse(args.json(), err.msg()) }90- save := history.commit_files(store, store.root, fresh.files, history.SaveInput{90+ save := history.save_tree(store, store.root, fresh.files, history.SaveInput{9191 message: '${verb}: ${source.id}'9292 }) or {9393 snapshot.clear_restore_mark(store) or {}src/cli/cmd_save.v+1 −1
@@ -26,7 +26,7 @@2626 defer {2727 store.close()2828 }29- save := history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{29+ save := history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{3030 message: message3131 agent: args.value('agent')3232 session: args.value('session')src/events/events_test.v+1 −1
@@ -19,7 +19,7 @@1919 os.rmdir_all(store.root) or {}2020 }2121 os.write_file(os.join_path(store.root, 'a.txt'), 'hello\n')!22- save := history.commit_worktree(store, store.root, snapshot.Ignore{}, history.SaveInput{22+ save := history.save_worktree(store, store.root, snapshot.Ignore{}, history.SaveInput{2323 message: 'first'2424 })!2525 src/exchange/exchange_test.v+66 −66
@@ -39,8 +39,8 @@3939 os.write_file(full, text)!4040 }4141 42-fn commit(store &storage.Store, message string) !history.Save {43- return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{42+fn make_save(store &storage.Store, message string) !history.Save {43+ return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{4444 message: message4545 })!4646 }@@ -65,9 +65,9 @@6565 a.close()6666 }6767 put(a.root, 'a.txt', 'one\n')!68- commit(a, 'first')!68+ make_save(a, 'first')!6969 put(a.root, 'a.txt', 'two\n')!70- commit(a, 'second')!70+ make_save(a, 'second')!7171 mut backend := remote.new_folder(os.join_path(base, 'remote'))!7272 7373 report := offer_now(a, mut backend)!@@ -92,7 +92,7 @@9292 a.close()9393 }9494 put(a.root, 'a.txt', 'one\n')!95- commit(a, 'first')!95+ make_save(a, 'first')!9696 mut backend := remote.new_folder(os.join_path(base, 'remote'))!9797 offer_now(a, mut backend)!9898 assert a.state('remote_repo') or { '' } == 'proj'@@ -103,7 +103,7 @@103103 // A second offer, with the remote already holding our head, must not104104 // refuse: the name is remembered, so the remote is ours.105105 put(a.root, 'a.txt', 'two\n')!106- commit(a, 'second')!106+ make_save(a, 'second')!107107 offer_now(a, mut backend)!108108 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id109109 }@@ -119,7 +119,7 @@119119 a.close()120120 }121121 put(a.root, 'a.txt', 'from A\n')!122- commit(a, 'A writes')!122+ make_save(a, 'A writes')!123123 offer_now(a, mut backend)!124124 125125 // B starts empty and pulls the whole repo.@@ -150,10 +150,10 @@150150 a.close()151151 }152152 put(a.root, 'a.txt', 'one\n')!153- commit(a, 'offered')!153+ make_save(a, 'offered')!154154 offer_now(a, mut backend)!155155 put(a.root, 'a.txt', 'two\n')!156- ahead := commit(a, 'not offered yet')!156+ ahead := make_save(a, 'not offered yet')!157157 report := steal_now(a, mut backend)!158158 assert report.replayed == 0159159 assert head_of(a).id == ahead.id@@ -181,14 +181,14 @@181181 b.close()182182 }183183 put(a.root, 'a.txt', 'one\n')!184- commit(a, 'first')!184+ make_save(a, 'first')!185185 offer_now(a, mut backend)!186186 join(b, mut backend)!187187 outside := os.join_path(base, 'outside')188188 put(outside, 'a.md', 'precious\n')!189189 os.symlink(outside, os.join_path(b.root, 'docs'))!190190 put(a.root, 'docs/a.md', 'from A\n')!191- commit(a, 'A adds docs')!191+ make_save(a, 'A adds docs')!192192 offer_now(a, mut backend)!193193 head_before := head_of(b).id194194 if _ := steal_now(b, mut backend) {@@ -212,7 +212,7 @@212212 a.close()213213 }214214 put(a.root, 'a.txt', 'one\n')!215- commit(a, 'first')!215+ make_save(a, 'first')!216216 offer_now(a, mut backend)!217217 a.set_state('restoring', 'yes')!218218 if _ := steal_now(a, mut backend) {@@ -235,8 +235,8 @@235235 a.close()236236 }237237 put(a.root, 'a.txt', 'one\n')!238- parent := commit(a, 'parent')!239- child := history.commit_files(a, '', history.files_of(a, parent.id)!, history.SaveInput{238+ parent := make_save(a, 'parent')!239+ child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{240240 message: 'replayed, older than its parent'241241 created_at: parent.created_at - 3600242242 })!@@ -261,7 +261,7 @@261261 a.close()262262 }263263 put(a.root, 'run.sh', 'echo hi\n')!264- commit(a, 'script')!264+ make_save(a, 'script')!265265 offer_now(a, mut backend)!266266 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!267267 if _ := steal_now(a, mut backend) {@@ -286,15 +286,15 @@286286 b.close()287287 }288288 put(a.root, 'shared.txt', 'base\n')!289- commit(a, 'base')!289+ make_save(a, 'base')!290290 offer_now(a, mut backend)!291291 292292 // B joins, then both machines change a different file.293293 join(b, mut backend)!294294 put(a.root, 'fromA.txt', 'A\n')!295- commit(a, 'A adds a file')!295+ make_save(a, 'A adds a file')!296296 put(b.root, 'fromB.txt', 'B\n')!297- commit(b, 'B adds a file')!297+ make_save(b, 'B adds a file')!298298 offer_now(a, mut backend)!299299 300300 // B's offer is refused: the remote has moved on.@@ -346,22 +346,22 @@346346 }347347 put(a.root, 'f.txt', 'base\n')!348348 put(a.root, 'g.txt', 'base\n')!349- commit(a, 'base')!349+ make_save(a, 'base')!350350 offer_now(a, mut backend)!351351 join(b, mut backend)!352352 353353 put(a.root, 'f.txt', 'from A\n')!354354 put(a.root, 'g.txt', 'same\n')!355- commit(a, 'A edits both')!355+ make_save(a, 'A edits both')!356356 offer_now(a, mut backend)!357357 358358 put(b.root, 'f.txt', 'tried on B\n')!359359 put(b.root, 'g.txt', 'first try\n')!360- tried := commit(b, 'B tries')!360+ tried := make_save(b, 'B tries')!361361 put(b.root, 'f.txt', 'base\n')!362362 put(b.root, 'g.txt', 'same\n')!363363 put(b.root, 'h.txt', 'B\n')!364- undone := commit(b, 'B undoes f, lands on the same g, adds h')!364+ undone := make_save(b, 'B undoes f, lands on the same g, adds h')!365365 366366 report := steal_now(b, mut backend)!367367 assert report.clashes.len == 0@@ -394,14 +394,14 @@394394 b.close()395395 }396396 put(a.root, 'same.txt', 'base\n')!397- commit(a, 'base')!397+ make_save(a, 'base')!398398 offer_now(a, mut backend)!399399 join(b, mut backend)!400400 put(a.root, 'same.txt', 'A version\n')!401- commit(a, 'A edits same')!401+ make_save(a, 'A edits same')!402402 offer_now(a, mut backend)!403403 put(b.root, 'same.txt', 'B version\n')!404- commit(b, 'B edits same')!404+ make_save(b, 'B edits same')!405405 root := b.root406406 // An agent saves while the person is still choosing.407407 if _ := steal(b, mut backend, StealOptions{@@ -411,7 +411,7 @@411411 other.close()412412 }413413 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }414- history.commit_worktree(other, root, snapshot.load(root), history.SaveInput{414+ history.save_worktree(other, root, snapshot.load(root), history.SaveInput{415415 message: 'agent saves during the question'416416 }) or { panic('steal held the write lock while asking: ${err}') }417417 return .mine@@ -448,16 +448,16 @@448448 }449449 put(a.root, 'same.txt', 'base\n')!450450 put(a.root, 'other.txt', 'quiet\n')!451- commit(a, 'base')!451+ make_save(a, 'base')!452452 offer_now(a, mut backend)!453453 join(b, mut backend)!454454 455455 // Both machines edit the same file, differently.456456 put(a.root, 'same.txt', 'A version\n')!457- commit(a, 'A edits same')!457+ make_save(a, 'A edits same')!458458 offer_now(a, mut backend)!459459 put(b.root, 'same.txt', 'B version\n')!460- commit(b, 'B edits same')!460+ make_save(b, 'B edits same')!461461 462462 // Without a decision, steal refuses and says which files clash.463463 if _ := steal(b, mut backend, StealOptions{}) {@@ -491,14 +491,14 @@491491 b.close()492492 }493493 put(a.root, 'same.txt', 'base\n')!494- commit(a, 'base')!494+ make_save(a, 'base')!495495 offer_now(a, mut backend)!496496 join(b, mut backend)!497497 put(a.root, 'same.txt', 'A version\n')!498- commit(a, 'A edits')!498+ make_save(a, 'A edits')!499499 offer_now(a, mut backend)!500500 put(b.root, 'same.txt', 'B version\n')!501- commit(b, 'B edits')!501+ make_save(b, 'B edits')!502502 503503 report := steal(b, mut backend, StealOptions{504504 mine: true@@ -527,7 +527,7 @@527527 b.close()528528 }529529 put(a.root, 'a.txt', 'one\n')!530- save := commit(a, 'suspect save')!530+ save := make_save(a, 'suspect save')!531531 events.flag(a, save.id, 'look at this')!532532 offer_now(a, mut backend)!533533 @@ -555,7 +555,7 @@555555 a.close()556556 }557557 put(a.root, 'a.txt', 'one\n')!558- commit(a, 'base')!558+ make_save(a, 'base')!559559 offer_now(a, mut backend)!560560 561561 // Someone else replaces head behind our back.@@ -563,7 +563,7 @@563563 backend.get(head_key('proj'))!.tag)!564564 565565 put(a.root, 'a.txt', 'two\n')!566- commit(a, 'second')!566+ make_save(a, 'second')!567567 if _ := offer_now(a, mut backend) {568568 assert false, 'offering while the remote head moved must be refused'569569 } else {@@ -644,13 +644,13 @@644644 for i in 0 .. 20 {645645 put(a.root, 'f${i}.txt', 'file ${i}\n')!646646 }647- commit(a, 'twenty files')!647+ make_save(a, 'twenty files')!648648 mut backend := FaultyBackend{649649 inner: remote.new_folder(os.join_path(base, 'remote'))!650650 }651651 offer(a, mut backend)!652652 put(a.root, 'f3.txt', 'changed\n')!653- commit(a, 'one change')!653+ make_save(a, 'one change')!654654 before := backend.writes655655 report := offer(a, mut backend)!656656 assert report.objects == 1@@ -669,9 +669,9 @@669669 a.close()670670 }671671 put(a.root, 'a.txt', 'one\n')!672- commit(a, 'first')!672+ make_save(a, 'first')!673673 put(a.root, 'a.txt', 'two\n')!674- commit(a, 'second')!674+ make_save(a, 'second')!675675 676676 // The first offer dies after the first write.677677 mut flaky := FaultyBackend{@@ -706,7 +706,7 @@706706 b.close()707707 }708708 put(a.root, 'a.txt', 'from A\n')!709- commit(a, 'A writes')!709+ make_save(a, 'A writes')!710710 mut healthy := remote.new_folder(remote_dir)!711711 offer(a, mut healthy)!712712 @@ -746,7 +746,7 @@746746 }747747 put(a.root, 'secret.txt', 'shh\n')!748748 put(a.root, 'keep.txt', 'fine\n')!749- save := commit(a, 'add both')!749+ save := make_save(a, 'add both')!750750 751751 for _ in 0 .. 2 {752752 strip_local(a, 'secret.txt', '')!@@ -775,7 +775,7 @@775775 hash: hash776776 }777777 }778- save := history.commit_files(a, '', files, history.SaveInput{778+ save := history.save_tree(a, '', files, history.SaveInput{779779 message: 'add all'780780 })!781781 assert strip_scope(a, 'docs', '')!.files == 1@@ -799,7 +799,7 @@799799 }800800 put(a.root, 'secrets.env', 'api_key=1\n')!801801 put(a.root, 'keep.txt', 'fine\n')!802- save := commit(a, 'add both')!802+ save := make_save(a, 'add both')!803803 mut backend := remote.new_folder(os.join_path(base, 'remote'))!804804 offer_now(a, mut backend)!805805 join(b, mut backend)!@@ -847,7 +847,7 @@847847 // through B's next steal.848848 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)849849 put(b.root, 'secrets.env', 'rotated\n')!850- readded := commit(b, 'add the rotated key back')!850+ readded := make_save(b, 'add the rotated key back')!851851 offer_now(b, mut backend)!852852 assert backend.list('proj/events')!.len == 1853853 b.begin()!@@ -870,13 +870,13 @@870870 a.close()871871 }872872 put(a.root, 'secrets.env', 'api_key=1\n')!873- old := commit(a, 'leak the key')!873+ old := make_save(a, 'leak the key')!874874 offer_now(a, mut backend)!875875 strip_local(a, 'secrets.env', '')!876876 push_strip(a, mut backend, 'secrets.env')!877877 // Saves after the strip must be later in time, as they would be for real.878878 put(a.root, 'secrets.env', 'api_key=2\n')!879- rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{879+ rotated := history.save_tree(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{880880 message: 'rotate the key'881881 created_at: events.strip_events(a)!.last().created_at + 60882882 })!@@ -912,11 +912,11 @@912912 }913913 put(a.root, 'secrets.env', 'api_key=1\n')!914914 put(a.root, 'shared.env', 'same bytes on B\n')!915- save := commit(a, 'add secrets')!915+ save := make_save(a, 'add secrets')!916916 offer_now(a, mut backend)!917917 join(b, mut backend)!918918 put(b.root, 'copy.txt', 'same bytes on B\n')!919- b_save := commit(b, 'B keeps a copy')!919+ b_save := make_save(b, 'B keeps a copy')!920920 offer_now(b, mut backend)!921921 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())922922 secret_hash := storage.hash_hex('api_key=1\n'.bytes())@@ -965,13 +965,13 @@965965 b.close()966966 }967967 put(a.root, 'secrets.env', 'api_key=1\n')!968- commit(a, 'add secrets')!968+ make_save(a, 'add secrets')!969969 offer_now(a, mut backend)!970970 join(b, mut backend)!971971 strip_local(a, 'secrets.env', '')!972972 push_strip(a, mut backend, 'secrets.env')!973973 put(b.root, 'more.txt', 'B keeps working\n')!974- later := commit(b, 'B saves with the secret still in its tree')!974+ later := make_save(b, 'B saves with the secret still in its tree')!975975 if _ := offer_now(b, mut backend) {976976 assert false, 'B offered records that still list the stripped path'977977 }@@ -995,7 +995,7 @@995995 a.close()996996 }997997 put(a.root, 'secrets.env', 'first key\n')!998- commit(a, 'first key')!998+ make_save(a, 'first key')!999999 offer_now(a, mut backend)!10001000 strip_local(a, 'secrets.env', '')!10011001 mut flaky := FaultyBackend{@@ -1006,7 +1006,7 @@10061006 assert false, 'the injected delete failure should have surfaced'10071007 }10081008 put(a.root, 'secrets.env', 'second key\n')!1009- commit(a, 'second key')!1009+ make_save(a, 'second key')!10101010 offer_now(a, mut backend)!10111011 strip_local(a, 'secrets.env', '')!10121012 push_strip(a, mut backend, 'secrets.env')!@@ -1028,9 +1028,9 @@10281028 a.close()10291029 }10301030 put(a.root, 'secrets.env', 'first key\n')!1031- first := commit(a, 'first key')!1031+ first := make_save(a, 'first key')!10321032 put(a.root, 'secrets.env', 'second key\n')!1033- second := commit(a, 'second key')!1033+ second := make_save(a, 'second key')!10341034 offer_now(a, mut backend)!10351035 strip_local(a, 'secrets.env', first.id)!10361036 // Write 1 uploads the event; write 2, the record rewrite, fails.@@ -1070,7 +1070,7 @@10701070 b.close()10711071 }10721072 put(a.root, 'secrets.env', 'first key\n')!1073- commit(a, 'first key')!1073+ make_save(a, 'first key')!10741074 offer_now(a, mut backend)!10751075 join(b, mut backend)!10761076 strip_local(a, 'secrets.env', '')!@@ -1079,7 +1079,7 @@10791079 // Strip leaves the file on disk; deleting it is the user's part.10801080 os.rm(os.join_path(b.root, 'secrets.env'))!10811081 put(a.root, 'secrets.env', 'second key\n')!1082- second := commit(a, 'second key')!1082+ second := make_save(a, 'second key')!10831083 offer_now(a, mut backend)!10841084 steal_now(b, mut backend)!10851085 assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)@@ -1106,16 +1106,16 @@11061106 b.close()11071107 }11081108 put(a.root, 'secrets.env', 'key one\n')!1109- commit(a, 'base')!1109+ make_save(a, 'base')!11101110 offer_now(a, mut backend)!11111111 join(b, mut backend)!11121112 put(a.root, 'secrets.env', 'key two\n')!1113- commit(a, 'A rotates')!1113+ make_save(a, 'A rotates')!11141114 offer_now(a, mut backend)!11151115 strip_local(a, 'secrets.env', '')!11161116 push_strip(a, mut backend, 'secrets.env')!11171117 put(b.root, 'secrets.env', 'key three\n')!1118- mine := commit(b, 'B rotates')!1118+ mine := make_save(b, 'B rotates')!11191119 report := steal(b, mut backend, StealOptions{})!11201120 assert report.clashes.len == 011211121 for save in history.all_saves(b)! {@@ -1141,16 +1141,16 @@11411141 b.close()11421142 }11431143 put(a.root, 'notes.txt', 'shared\n')!1144- common := commit(a, 'base')!1144+ common := make_save(a, 'base')!11451145 offer_now(a, mut backend)!11461146 join(b, mut backend)!11471147 put(a.root, 'other.txt', 'A moves on\n')!1148- commit(a, 'A moves on')!1148+ make_save(a, 'A moves on')!11491149 offer_now(a, mut backend)!11501150 strip_local(a, 'notes.txt', common.id)!11511151 push_strip(a, mut backend, 'notes.txt')!11521152 put(b.root, 'notes.txt', 'B edits\n')!1153- commit(b, 'B edits notes')!1153+ make_save(b, 'B edits notes')!11541154 if _ := steal(b, mut backend, StealOptions{}) {11551155 assert false, 'with the base stripped, notes.txt differs on both sides'11561156 } else {@@ -1176,7 +1176,7 @@11761176 a.close()11771177 }11781178 put(a.root, 'notes.txt', 'from A\n')!1179- commit(a, 'A writes')!1179+ make_save(a, 'A writes')!11801180 offer_now(a, mut backend)!11811181 b_root := machine('unsaved_b')!11821182 mut b := storage.create(b_root)!@@ -1216,13 +1216,13 @@12161216 b.close()12171217 }12181218 put(a.root, 'a.txt', 'one\n')!1219- commit(a, 'fine')!1219+ make_save(a, 'fine')!12201220 offer_now(a, mut backend)!12211221 join(b, mut backend)!12221222 put(a.root, 'a.txt', 'two\n')!1223- suspect := commit(a, 'suspect')!1223+ suspect := make_save(a, 'suspect')!12241224 put(a.root, 'a.txt', 'three\n')!1225- commit(a, 'also fine')!1225+ make_save(a, 'also fine')!12261226 events.flag(a, suspect.id, 'broke the build')!12271227 offer_now(a, mut backend)!12281228 report := steal_now(b, mut backend)!src/history/commit.v+0 −227
@@ -1,227 +0,0 @@1-//! Creating saves.2-//!3-//! A save is a full snapshot: every tracked path with its object hash and4-//! executable bit, plus the message, time, machine and optional agent and5-//! session. Its ID is the first 12 hex characters of the Blake3 hash of the6-//! canonical record, computed once, at creation, and never recomputed — which7-//! is why `strip` can change a save's files and keep its ID.8-9-module history10-11-import strings12-import time13-import src.machine14-import src.snapshot15-import src.storage16-17-pub struct SaveInput {18-pub mut:19- message string20- agent string21- session string22- is_checkpoint bool23- restores string24- replayed_from string25- created_at i6426- machine string27-}28-29-pub const nothing_changed = 'nothing has changed since the last save'30-31-// commit_worktree walks the working tree and records it. A plain save refuses32-// when the tree matches head; a checkpoint always records. The walk runs33-// before the lock, so other commands are not held up by the hashing.34-pub fn commit_worktree(store &storage.Store, root string, ig snapshot.Ignore, input SaveInput) !Save {35- walked_head := head_id(store)36- files := snapshot.walk(store, root, ig)!37- store.begin()!38- save := commit_walk(store, root, input, walked_head, files) or {39- store.rollback()40- return err41- }42- store.commit()!43- refresh_log_md(store)44- return save45-}46-47-// commit_walk records a walk inside the caller's transaction. A restore still48-// writing its files is refused: the tree is half one save and half another.49-// When another command moved head after the walk, the walk is stale, and50-// recording it on the new head would quietly undo that command, so the tree51-// (and its ignore rules, which may have changed too) is read again under the52-// lock.53-fn commit_walk(store &storage.Store, root string, input SaveInput, walked_head string, walked []snapshot.FileEntry) !Save {54- if snapshot.restore_pending(store) {55- return error(snapshot.restore_unfinished)56- }57- mut files := walked.clone()58- moved := head_id(store) != walked_head59- if moved {60- files = snapshot.walk(store, root, snapshot.load(root))!61- }62- if input.restores == '' && !input.is_checkpoint {63- if previous := head(store) {64- if snapshot.same_tree(files, files_of(store, previous.id)!) {65- if moved {66- // Another agent's save, made while this one waited for the67- // lock, already holds these changes.68- return error('${nothing_changed} — [${previous.id}] "${previous.message}" was saved a moment ago and already holds this tree')69- }70- return error(nothing_changed)71- }72- }73- }74- return record_save(store, root, files, input)!75-}76-77-// commit_files records an already-known tree. `root` is '' when the objects are78-// already in the store — a restore, or a replay during a steal.79-pub fn commit_files(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {80- store.begin()!81- save := record_save(store, root, files, input) or {82- store.rollback()83- return err84- }85- store.commit()!86- refresh_log_md(store)87- return save88-}89-90-// record_save writes one save inside the caller's transaction. A steal uses it91-// to land a whole replay — several saves, fetched objects and fetched events —92-// in a single step, so a failure leaves no half-combined line behind.93-pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {94- if root != '' {95- snapshot.store_objects(store, root, files)!96- }97- return write_save(store, files, input)!98-}99-100-fn write_save(store &storage.Store, files []snapshot.FileEntry, input SaveInput) !Save {101- created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() }102- mut save := Save{103- parent: head_id(store)104- message: input.message105- created_at: created_at106- machine: if input.machine != '' { input.machine } else { machine.name() }107- is_checkpoint: input.is_checkpoint108- size_bytes: tree_size(store, files)109- agent: input.agent110- session: input.session111- restores: input.restores112- replayed_from: input.replayed_from113- }114- // Two saves made in the same second with the same record would collide.115- // Bumping the clock keeps one line of history and keeps the ID unique.116- for {117- save.id = save_id(save, files)118- if by_id(store, save.id) == none {119- break120- }121- save.created_at++122- }123- insert_save(store, save, files)!124- store.set_state(storage.head_key, save.id)!125- return save126-}127-128-// import_save records a save exactly as another machine made it: same ID, same129-// parent, same time, same files. `steal` and `migrate` use it; a save made here130-// goes through write_save instead, which mints the ID.131-pub fn import_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {132- insert_save(store, save, files)!133-}134-135-// set_head moves the line's head without creating a save. Only fetching another136-// machine's saves does that: nothing is erased and nothing is re-recorded,137-// because those saves are already on the line.138-pub fn set_head(store &storage.Store, id string) ! {139- store.set_state(storage.head_key, id)!140-}141-142-fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {143- mut stmt := store.db.prepare('INSERT INTO saves (id, parent, message, created_at, machine,144- is_checkpoint, size_bytes, agent, session, restores, replayed_from)145- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')!146- stmt.bind_text(1, save.id)!147- if save.parent == '' {148- stmt.bind_null(2)!149- } else {150- stmt.bind_text(2, save.parent)!151- }152- stmt.bind_text(3, save.message)!153- stmt.bind_int(4, save.created_at)!154- stmt.bind_text(5, save.machine)!155- stmt.bind_int(6, if save.is_checkpoint { 1 } else { 0 })!156- stmt.bind_int(7, save.size_bytes)!157- bind_optional(stmt, 8, save.agent)!158- bind_optional(stmt, 9, save.session)!159- bind_optional(stmt, 10, save.restores)!160- bind_optional(stmt, 11, save.replayed_from)!161- stmt.step()!162- stmt.finalize()163-164- mut files_stmt :=165- store.db.prepare('INSERT INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)')!166- defer {167- files_stmt.finalize()168- }169- for file in files {170- files_stmt.bind_text(1, save.id)!171- files_stmt.bind_text(2, file.path)!172- files_stmt.bind_text(3, file.hash)!173- files_stmt.bind_int(4, if file.executable { 1 } else { 0 })!174- files_stmt.step()!175- // A statement that has returned DONE must be reset before rebinding.176- files_stmt.reset()177- }178-}179-180-fn bind_optional(stmt &storage.Stmt, index int, value string) ! {181- if value == '' {182- stmt.bind_null(index)!183- } else {184- stmt.bind_text(index, value)!185- }186-}187-188-// save_id is the first 12 hex characters of the Blake3 hash of the canonical189-// record: parent, message, time, machine, checkpoint mark, agent, session and190-// the sorted file list.191-pub fn save_id(save Save, files []snapshot.FileEntry) string {192- return storage.hash_hex(canonical(save, files).bytes())[..12]193-}194-195-fn canonical(save Save, files []snapshot.FileEntry) string {196- mut sb := strings.Builder{}197- sb.write_string('koh3-save-v1\n')198- put_field(mut sb, 'parent', save.parent)199- put_field(mut sb, 'message', save.message)200- put_field(mut sb, 'created', save.created_at.str())201- put_field(mut sb, 'machine', save.machine)202- put_field(mut sb, 'checkpoint', if save.is_checkpoint { '1' } else { '0' })203- put_field(mut sb, 'agent', save.agent)204- put_field(mut sb, 'session', save.session)205- put_field(mut sb, 'files', files.len.str())206- for file in files {207- mark := if file.executable { 'x' } else { '-' }208- put_field(mut sb, 'file', '${mark}${file.hash} ${file.path}')209- }210- return sb.str()211-}212-213-// Length-prefixed fields keep the record unambiguous whatever a message or a214-// path contains.215-fn put_field(mut sb strings.Builder, name string, value string) {216- sb.write_string('${name}:${value.len}:')217- sb.write_string(value)218- sb.write_string('\n')219-}220-221-fn tree_size(store &storage.Store, files []snapshot.FileEntry) i64 {222- mut total := i64(0)223- for file in files {224- total += store.object_size(file.hash) or { 0 }225- }226- return total227-}src/history/history_test.v+26 −26
@@ -18,8 +18,8 @@1818 os.write_file(full, text)!1919 }2020 21-fn test_walk_then_commit() {22- mut store, dir := temp_repo('commit')!21+fn test_walk_then_save() {22+ mut store, dir := temp_repo('save')!2323 defer {2424 store.close()2525 os.rmdir_all(dir) or {}@@ -29,7 +29,7 @@2929 entries := snapshot.walk(store, dir, snapshot.Ignore{})!3030 assert entries.len == 23131 32- save := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{32+ save := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{3333 message: 'first save'3434 })!3535 assert save.id.len == 12@@ -42,12 +42,12 @@4242 assert files[1].path == 'sub/b.txt'4343 4444 // Nothing changed, so a plain save refuses and a checkpoint records.45- if _ := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'again' }) {45+ if _ := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'again' }) {4646 assert false, 'a save with no changes should refuse'4747 } else {4848 assert err.msg() == nothing_changed4949 }50- second := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{50+ second := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{5151 message: 'checked anyway'5252 is_checkpoint: true5353 })!@@ -62,14 +62,14 @@6262 os.rmdir_all(dir) or {}6363 }6464 write_file(dir, 'a.txt', 'one\n')!65- first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!65+ first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!6666 write_file(dir, 'a.txt', 'two\n')!67- second := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'two' })!67+ second := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'two' })!6868 6969 // effective position follows restores, so undo keeps stepping back.7070 assert effective(store)!.id == second.id7171 first_files := files_of(store, first.id)!72- restore := commit_files(store, '', first_files, SaveInput{72+ restore := save_tree(store, '', first_files, SaveInput{7373 message: 'undo: ${first.id}'7474 restores: first.id7575 })!@@ -108,7 +108,7 @@108108 os.rmdir_all(dir) or {}109109 }110110 write_file(dir, 'a.txt', 'one\n')!111- first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!111+ first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!112112 assert resolve(store, first.id[..6])!.id == first.id113113 if _ := resolve(store, 'abc') {114114 assert false, 'a prefix shorter than six characters should be refused'@@ -131,13 +131,13 @@131131 os.rmdir_all(dir) or {}132132 }133133 write_file(dir, 'a.txt', 'one\n')!134- kept := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{134+ kept := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{135135 message: 'on the line'136136 session: 'work'137137 is_checkpoint: true138138 })!139139 write_file(dir, 'a.txt', 'two\n')!140- commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{140+ save_worktree(store, dir, snapshot.Ignore{}, SaveInput{141141 message: 'replayed away later'142142 session: 'work'143143 is_checkpoint: true@@ -160,19 +160,19 @@160160 os.rmdir_all(dir) or {}161161 }162162 write_file(dir, 'a.txt', 'one\n')!163- first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{163+ first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{164164 message: 'first'165165 })!166166 write_file(dir, 'b.txt', 'mine\n')!167167 stale := snapshot.walk(store, dir, snapshot.Ignore{})!168168 // Another command lands a save and its files between that walk and the lock.169169 write_file(dir, 'a.txt', 'two\n')!170- other := commit_files(store, dir,170+ other := save_tree(store, dir,171171 snapshot.walk(store, dir, snapshot.Ignore{})!.filter(it.path == 'a.txt'), SaveInput{172172 message: 'somebody else'173173 })!174174 store.begin()!175- saved := commit_walk(store, dir, SaveInput{175+ saved := save_walk(store, dir, SaveInput{176176 message: 'mine'177177 }, first.id, stale)!178178 store.commit()!@@ -183,12 +183,12 @@183183 // A restore that moved head and is still writing files: no save on top.184184 stale_again := snapshot.walk(store, dir, snapshot.Ignore{})!185185 snapshot.mark_restore(store)!186- moved := commit_files(store, '', files_of(store, first.id)!, SaveInput{186+ moved := save_tree(store, '', files_of(store, first.id)!, SaveInput{187187 message: 'undo: ${first.id}'188188 restores: first.id189189 })!190190 store.begin()!191- if _ := commit_walk(store, dir, SaveInput{191+ if _ := save_walk(store, dir, SaveInput{192192 message: 'on a half tree'193193 }, saved.id, stale_again)194194 {@@ -209,16 +209,16 @@209209 os.rmdir_all(dir) or {}210210 }211211 write_file(dir, 'a.txt', 'one\n')!212- first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{212+ first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{213213 message: 'first'214214 })!215215 write_file(dir, 'b.txt', 'both agents\n')!216216 walked := snapshot.walk(store, dir, snapshot.Ignore{})!217- winner := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{217+ winner := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{218218 message: 'agent one'219219 })!220220 store.begin()!221- if _ := commit_walk(store, dir, SaveInput{221+ if _ := save_walk(store, dir, SaveInput{222222 message: 'agent two'223223 }, first.id, walked)224224 {@@ -238,20 +238,20 @@238238 os.rmdir_all(dir) or {}239239 }240240 write_file(dir, 'a.txt', 'one\n')!241- first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{241+ first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{242242 message: 'first'243243 })!244244 write_file(dir, 'a.txt', 'two\n')!245- commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{245+ save_worktree(store, dir, snapshot.Ignore{}, SaveInput{246246 message: 'second'247247 })!248248 snapshot.mark_restore(store)!249- moved := commit_files(store, '', files_of(store, first.id)!, SaveInput{249+ moved := save_tree(store, '', files_of(store, first.id)!, SaveInput{250250 message: 'undo: ${first.id}'251251 restores: first.id252252 })!253253 // Head read after the move, tree still the old one: not stale, but half.254- if _ := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{254+ if _ := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{255255 message: 'on a half tree'256256 })257257 {@@ -271,18 +271,18 @@271271 os.rmdir_all(dir) or {}272272 }273273 write_file(dir, 'a.txt', 'one\n')!274- first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{274+ first := save_worktree(store, dir, snapshot.Ignore{}, SaveInput{275275 message: 'first'276276 })!277277 write_file(dir, 'secret.env', 'key\n')!278278 stale := snapshot.walk(store, dir, snapshot.Ignore{})!279279 write_file(dir, '.kohignore', 'secret.env\n')!280- commit_files(store, dir, snapshot.walk(store, dir, snapshot.load(dir))!, SaveInput{280+ save_tree(store, dir, snapshot.walk(store, dir, snapshot.load(dir))!, SaveInput{281281 message: 'ignore the secret'282282 })!283283 write_file(dir, 'a.txt', 'two\n')!284284 store.begin()!285- saved := commit_walk(store, dir, SaveInput{285+ saved := save_walk(store, dir, SaveInput{286286 message: 'mine'287287 }, first.id, stale)!288288 store.commit()!src/history/save.v+227 −0
@@ -0,0 +1,227 @@1+//! Creating saves.2+//!3+//! A save is a full snapshot: every tracked path with its object hash and4+//! executable bit, plus the message, time, machine and optional agent and5+//! session. Its ID is the first 12 hex characters of the Blake3 hash of the6+//! canonical record, computed once, at creation, and never recomputed — which7+//! is why `strip` can change a save's files and keep its ID.8+9+module history10+11+import strings12+import time13+import src.machine14+import src.snapshot15+import src.storage16+17+pub struct SaveInput {18+pub mut:19+ message string20+ agent string21+ session string22+ is_checkpoint bool23+ restores string24+ replayed_from string25+ created_at i6426+ machine string27+}28+29+pub const nothing_changed = 'nothing has changed since the last save'30+31+// save_worktree walks the working tree and records it. A plain save refuses32+// when the tree matches head; a checkpoint always records. The walk runs33+// before the lock, so other commands are not held up by the hashing.34+pub fn save_worktree(store &storage.Store, root string, ig snapshot.Ignore, input SaveInput) !Save {35+ walked_head := head_id(store)36+ files := snapshot.walk(store, root, ig)!37+ store.begin()!38+ save := save_walk(store, root, input, walked_head, files) or {39+ store.rollback()40+ return err41+ }42+ store.commit()!43+ refresh_log_md(store)44+ return save45+}46+47+// save_walk records a walk inside the caller's transaction. A restore still48+// writing its files is refused: the tree is half one save and half another.49+// When another command moved head after the walk, the walk is stale, and50+// recording it on the new head would quietly undo that command, so the tree51+// (and its ignore rules, which may have changed too) is read again under the52+// lock.53+fn save_walk(store &storage.Store, root string, input SaveInput, walked_head string, walked []snapshot.FileEntry) !Save {54+ if snapshot.restore_pending(store) {55+ return error(snapshot.restore_unfinished)56+ }57+ mut files := walked.clone()58+ moved := head_id(store) != walked_head59+ if moved {60+ files = snapshot.walk(store, root, snapshot.load(root))!61+ }62+ if input.restores == '' && !input.is_checkpoint {63+ if previous := head(store) {64+ if snapshot.same_tree(files, files_of(store, previous.id)!) {65+ if moved {66+ // Another agent's save, made while this one waited for the67+ // lock, already holds these changes.68+ return error('${nothing_changed} — [${previous.id}] "${previous.message}" was saved a moment ago and already holds this tree')69+ }70+ return error(nothing_changed)71+ }72+ }73+ }74+ return record_save(store, root, files, input)!75+}76+77+// save_tree records an already-known tree. `root` is '' when the objects are78+// already in the store — a restore, or a replay during a steal.79+pub fn save_tree(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {80+ store.begin()!81+ save := record_save(store, root, files, input) or {82+ store.rollback()83+ return err84+ }85+ store.commit()!86+ refresh_log_md(store)87+ return save88+}89+90+// record_save writes one save inside the caller's transaction. A steal uses it91+// to land a whole replay — several saves, fetched objects and fetched events —92+// in a single step, so a failure leaves no half-combined line behind.93+pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {94+ if root != '' {95+ snapshot.store_objects(store, root, files)!96+ }97+ return write_save(store, files, input)!98+}99+100+fn write_save(store &storage.Store, files []snapshot.FileEntry, input SaveInput) !Save {101+ created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() }102+ mut save := Save{103+ parent: head_id(store)104+ message: input.message105+ created_at: created_at106+ machine: if input.machine != '' { input.machine } else { machine.name() }107+ is_checkpoint: input.is_checkpoint108+ size_bytes: tree_size(store, files)109+ agent: input.agent110+ session: input.session111+ restores: input.restores112+ replayed_from: input.replayed_from113+ }114+ // Two saves made in the same second with the same record would collide.115+ // Bumping the clock keeps one line of history and keeps the ID unique.116+ for {117+ save.id = save_id(save, files)118+ if by_id(store, save.id) == none {119+ break120+ }121+ save.created_at++122+ }123+ insert_save(store, save, files)!124+ store.set_state(storage.head_key, save.id)!125+ return save126+}127+128+// import_save records a save exactly as another machine made it: same ID, same129+// parent, same time, same files. `steal` and `migrate` use it; a save made here130+// goes through write_save instead, which mints the ID.131+pub fn import_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {132+ insert_save(store, save, files)!133+}134+135+// set_head moves the line's head without creating a save. Only fetching another136+// machine's saves does that: nothing is erased and nothing is re-recorded,137+// because those saves are already on the line.138+pub fn set_head(store &storage.Store, id string) ! {139+ store.set_state(storage.head_key, id)!140+}141+142+fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {143+ mut stmt := store.db.prepare('INSERT INTO saves (id, parent, message, created_at, machine,144+ is_checkpoint, size_bytes, agent, session, restores, replayed_from)145+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')!146+ stmt.bind_text(1, save.id)!147+ if save.parent == '' {148+ stmt.bind_null(2)!149+ } else {150+ stmt.bind_text(2, save.parent)!151+ }152+ stmt.bind_text(3, save.message)!153+ stmt.bind_int(4, save.created_at)!154+ stmt.bind_text(5, save.machine)!155+ stmt.bind_int(6, if save.is_checkpoint { 1 } else { 0 })!156+ stmt.bind_int(7, save.size_bytes)!157+ bind_optional(stmt, 8, save.agent)!158+ bind_optional(stmt, 9, save.session)!159+ bind_optional(stmt, 10, save.restores)!160+ bind_optional(stmt, 11, save.replayed_from)!161+ stmt.step()!162+ stmt.finalize()163+164+ mut files_stmt :=165+ store.db.prepare('INSERT INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)')!166+ defer {167+ files_stmt.finalize()168+ }169+ for file in files {170+ files_stmt.bind_text(1, save.id)!171+ files_stmt.bind_text(2, file.path)!172+ files_stmt.bind_text(3, file.hash)!173+ files_stmt.bind_int(4, if file.executable { 1 } else { 0 })!174+ files_stmt.step()!175+ // A statement that has returned DONE must be reset before rebinding.176+ files_stmt.reset()177+ }178+}179+180+fn bind_optional(stmt &storage.Stmt, index int, value string) ! {181+ if value == '' {182+ stmt.bind_null(index)!183+ } else {184+ stmt.bind_text(index, value)!185+ }186+}187+188+// save_id is the first 12 hex characters of the Blake3 hash of the canonical189+// record: parent, message, time, machine, checkpoint mark, agent, session and190+// the sorted file list.191+pub fn save_id(save Save, files []snapshot.FileEntry) string {192+ return storage.hash_hex(canonical(save, files).bytes())[..12]193+}194+195+fn canonical(save Save, files []snapshot.FileEntry) string {196+ mut sb := strings.Builder{}197+ sb.write_string('koh3-save-v1\n')198+ put_field(mut sb, 'parent', save.parent)199+ put_field(mut sb, 'message', save.message)200+ put_field(mut sb, 'created', save.created_at.str())201+ put_field(mut sb, 'machine', save.machine)202+ put_field(mut sb, 'checkpoint', if save.is_checkpoint { '1' } else { '0' })203+ put_field(mut sb, 'agent', save.agent)204+ put_field(mut sb, 'session', save.session)205+ put_field(mut sb, 'files', files.len.str())206+ for file in files {207+ mark := if file.executable { 'x' } else { '-' }208+ put_field(mut sb, 'file', '${mark}${file.hash} ${file.path}')209+ }210+ return sb.str()211+}212+213+// Length-prefixed fields keep the record unambiguous whatever a message or a214+// path contains.215+fn put_field(mut sb strings.Builder, name string, value string) {216+ sb.write_string('${name}:${value.len}:')217+ sb.write_string(value)218+ sb.write_string('\n')219+}220+221+fn tree_size(store &storage.Store, files []snapshot.FileEntry) i64 {222+ mut total := i64(0)223+ for file in files {224+ total += store.object_size(file.hash) or { 0 }225+ }226+ return total227+}v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.56'3+ version: '3.0.57'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }