c99a73d26672
rename commit, push, pull, merge, clone and stage out of the source, tests and docs: transactions begin and end, strips are sent, a repo is stolen (3.1.2)
- time
- 2026-09-28 12:20 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- remove git terminology from source
- parent
- 1ca868c479bf
↓ koh-c99a73d2.tar.gzatlas at this save
files changed24 files
README.md+1 −1VERSION+1 −1src/cli/cmd_back.v+1 −1src/cli/cmd_pluck.v+1 −1src/cli/cmd_steal.v+21 −21src/cli/cmd_strip.v+18 −18src/exchange/exchange.v+2 −2src/exchange/exchange_test.v+26 −26src/exchange/steal.v+6 −6src/exchange/strip.v+6 −6src/history/history_test.v+2 −2src/history/log_md.v+1 −1src/history/save.v+2 −2src/migrate/migrate.v+11 −11src/migrate/migrate_test.v+2 −2src/remote/s3.v+1 −1src/storage/cache.v+1 −1src/storage/storage.v+4 −4src/update/update.v+10 −10src/update/update_test.v+7 −7src/view/view_test.v+1 −1tests/cli_test.v+24 −24tests/library_check_test.v+1 −1v.mod+1 −1README.md+1 −1
@@ -25,7 +25,7 @@2525 **Nothing is erased.** Your history is one straight line, and every command adds2626 to it. Undo, revert and load don't rewind anything. They record a new save2727 that puts the files back, so the save you stepped away from is still there28-tomorrow. The only exception is `strip`, for the day a secret gets committed,28+tomorrow. The only exception is `strip`, for the day a secret gets saved,2929 and it makes you type the path twice before it does anything.3030 3131 **Everything lives in one file.** A repo's whole history is `.koh`, a singlesrc/cli/cmd_back.v+1 −1
@@ -187,7 +187,7 @@187187 store.rollback()188188 return err189189 }190- store.commit()!190+ store.end()!191191 history.refresh_log_md(store)192192 return save193193 }src/cli/cmd_pluck.v+1 −1
@@ -124,7 +124,7 @@124124 target := if reverse { parent_tree } else { source_tree }125125 plan := apply.three_way(current.files, base.files, target.files, filters, [])126126 if apply.conflicts(plan) {127- return error('${plan.conflict} has changed since [${source.id}]; koh does not merge lines — resolve it by hand, or load the save and re-edit')127+ return error('${plan.conflict} has changed since [${source.id}]; koh does not blend lines — resolve it by hand, or load the save and re-edit')128128 }129129 snapshot.check_links(store.root, plan.files)!130130 return plan, current.filessrc/cli/cmd_steal.v+21 −21
@@ -1,6 +1,6 @@11 //! `koh steal` — bring another machine's saves in.22 //!3-//! Inside a repo this pulls the remote's new saves and combines when both3+//! Inside a repo this takes the remote's new saves and combines when both44 //! machines moved. Outside one it fetches a whole repo into a new folder.55 //! `--list` shows what the remote holds.66 @@ -38,9 +38,9 @@3838 }3939 address := web_address(name) or { return refuse(args.json(), err.msg()) }4040 if address.len > 0 {41- return clone_web(address[0], address[1], args)41+ return steal_web(address[0], address[1], args)4242 }43- return clone_repo(name, args)43+ return steal_repo(name, args)4444 }4545 store := open_store() or { return refuse(args.json(), err.msg()) }4646 defer {@@ -346,7 +346,7 @@346346 view.page_text(lines, clash.path)347347 }348348 349-// offer_after_steal pushes what this machine just decided. A failure here does349+// offer_after_steal sends what this machine just decided. A failure here does350350 // not undo the steal; it is reported.351351 fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {352352 if _ := exchange.offer(store, mut backend) {@@ -404,40 +404,40 @@404404 return [host, repo]405405 }406406 407-// clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address407+// steal_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address408408 // names this machine's own bucket instead, so the bucket's copy — private or not — is the one to409409 // take.410-fn clone_web(host string, repo string, args Args) int {410+fn steal_web(host string, repo string, args Args) int {411411 base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }412412 if exchange.signed_in_to(base) {413- return clone_repo(repo, args)413+ return steal_repo(repo, args)414414 }415415 mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }416- return clone_with(repo, args, mut backend, '${base}/${repo}')416+ return steal_whole(repo, args, mut backend, '${base}/${repo}')417417 }418418 419-// clone_repo fetches a whole repo into ./<name>, for a machine that does not419+// steal_repo fetches a whole repo into ./<name>, for a machine that does not420420 // have it yet.421-fn clone_repo(name string, args Args) int {421+fn steal_repo(name string, args Args) int {422422 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }423- return clone_with(name, args, mut backend, '')423+ return steal_whole(name, args, mut backend, '')424424 }425425 426-// clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's426+// steal_whole is that fetch, from whichever remote was chosen: the whole history in, then the head's427427 // files onto the disk. A non-empty `source` means the repo came from a read-only address and stays428428 // local until this machine signs in there.429-fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {429+fn steal_whole(name string, args Args, mut backend remote.Backend, source string) int {430430 target := os.join_path(os.getwd(), name)431431 if os.exists(target) {432432 return refuse(args.json(), '${name} already exists here — run steal inside it instead')433433 }434434 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }435- store := land_clone(target, name, fetched, source) or {435+ store := land_repo(target, name, fetched, source) or {436436 os.rmdir_all(target) or {}437437 return refuse(args.json(), err.msg())438438 }439439 // Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it440- // as "already exists", so the whole clone goes.440+ // as "already exists", so the whole new folder goes.441441 mut finished := false442442 defer {443443 store.close()@@ -472,19 +472,19 @@472472 ok('fetched "${name}" into ${target}')473473 note('${fetched.saves.len} saves at [${fetched.head}]')474474 if source != '' {475- note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')475+ note('read-only: this copy keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')476476 }477477 warn_flagged(flagged)478478 return 0479479 }480480 481-// land_clone creates ./<name> and commits the fetched history into it. Objects481+// land_repo creates ./<name> and writes the fetched history into it. Objects482482 // are checked on the way in, so a damaged remote fails here.483-fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {483+fn land_repo(target string, name string, fetched exchange.Fetched, source string) !storage.Store {484484 // SQLite creates the file, not the folder holding it.485485 os.mkdir_all(target, os.MkdirParams{})!486486 mut store := storage.create(target)!487- fill_clone(store, name, fetched, source) or {487+ fill_repo(store, name, fetched, source) or {488488 store.rollback()489489 store.close()490490 return err@@ -492,7 +492,7 @@492492 return store493493 }494494 495-fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {495+fn fill_repo(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {496496 store.begin()!497497 exchange.write_fetched(store, fetched)!498498 history.set_head(store, fetched.head)!@@ -501,7 +501,7 @@501501 exchange.remember_source(store, source)!502502 }503503 snapshot.mark_restore(store)!504- store.commit()!504+ store.end()!505505 }506506 507507 // warn_flagged prints the line the document asks for, per incoming flagged save.src/cli/cmd_strip.v+18 −18
@@ -70,7 +70,7 @@7070 }7171 // A remote failure is reported, never fatal: the local strip has happened,7272 // and running the same strip again finishes the remote.73- push := push_strip_to_remote(store, path)73+ sent := send_strip_to_remote(store, path)7474 // A whole-history strip bans the path; the rule in `.kohignore` says so where a person reads,7575 // and travels with the next save.7676 ignored := only_save == '' && (snapshot.add_ignore(store.root, path) or {@@ -82,9 +82,9 @@8282 save: only_save8383 files_removed: local.files_removed8484 objects_dropped: int(local.objects_dropped)85- records_rewritten: push.pushed.records86- remote_objects_dropped: push.pushed.objects87- remote_failed: push.failed85+ records_rewritten: sent.remote.records86+ remote_objects_dropped: sent.remote.objects87+ remote_failed: sent.failed8888 }))8989 return 09090 }@@ -92,11 +92,11 @@9292 ok('stripped ${path} from ${fmt.plural(local.files_removed, 'file')} across ${fmt.plural(scope.saves, 'save')}')9393 note('${local.objects_dropped} objects dropped')9494 }95- if !push.skipped {96- if push.failed == '' {97- note('the remote was updated: ${push.pushed.records} save records rewritten, ${push.pushed.objects} objects dropped')95+ if !sent.skipped {96+ if sent.failed == '' {97+ note('the remote was updated: ${sent.remote.records} save records rewritten, ${sent.remote.objects} objects dropped')9898 } else {99- warn('the remote could not be updated: ${push.failed} — run the same strip again to finish it')99+ warn('the remote could not be updated: ${sent.failed} — run the same strip again to finish it')100100 }101101 }102102 if only_save != '' {@@ -107,32 +107,32 @@107107 return 0108108 }109109 110-struct PushOutcome {110+struct SendOutcome {111111 mut:112- pushed exchange.PushReport112+ remote exchange.SendReport113113 failed string114114 skipped bool115115 }116116 117-// push_strip_to_remote tells this machine's remote about a strip.118-fn push_strip_to_remote(store &storage.Store, path string) PushOutcome {117+// send_strip_to_remote tells this machine's remote about a strip.118+fn send_strip_to_remote(store &storage.Store, path string) SendOutcome {119119 if !machine.has_remote() {120- return PushOutcome{120+ return SendOutcome{121121 skipped: true122122 }123123 }124124 mut backend := this_machine_backend() or {125- return PushOutcome{125+ return SendOutcome{126126 failed: err.msg()127127 }128128 }129- report := exchange.push_strip(store, mut backend, path) or {130- return PushOutcome{129+ report := exchange.send_strip(store, mut backend, path) or {130+ return SendOutcome{131131 failed: err.msg()132132 }133133 }134- return PushOutcome{135- pushed: report134+ return SendOutcome{135+ remote: report136136 }137137 }138138 src/exchange/exchange.v+2 −2
@@ -4,7 +4,7 @@44 //! the backends stay thin and this logic is written once and tested offline.55 //!66 //! The remote is a line of saves, the same shape as the local one. `offer`7-//! pushes local saves the remote lacks; `steal` pulls remote saves and, when7+//! sends local saves the remote lacks; `steal` takes remote saves and, when88 //! both sides moved, replays the local work on top of the remote's. Divergence99 //! is resolved per file, never per line.1010 @@ -64,7 +64,7 @@6464 head string6565 }6666 67-// remember_repo binds a repo to its name on the remote. A clone does it at67+// remember_repo binds a repo to its name on the remote. A steal into a new folder does it at6868 // once: its folder may be renamed before it ever offers.6969 pub fn remember_repo(store &storage.Store, name string) ! {7070 store.set_state(repo_state_key, name)!src/exchange/exchange_test.v+26 −26
@@ -122,7 +122,7 @@122122 make_save(a, 'A writes')!123123 offer_now(a, mut backend)!124124 125- // B starts empty and pulls the whole repo.125+ // B starts empty and takes the whole repo.126126 mut b := open_repo(machine('fwd_b')!)!127127 defer {128128 b.close()@@ -131,7 +131,7 @@131131 b.begin()!132132 write_fetched(b, fetched)!133133 history.set_head(b, fetched.head)!134- b.commit()!134+ b.end()!135135 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!136136 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'137137 assert head_of(b).id == fetched.head@@ -223,9 +223,9 @@223223 }224224 225225 // A replayed save keeps its original time but sits on a newer parent, so a226-// clone cannot order saves by time.227-fn test_a_clone_imports_parents_before_children() {228- base := scratch('clone_order')!226+// steal cannot order saves by time.227+fn test_a_steal_imports_parents_before_children() {228+ base := scratch('steal_order')!229229 defer {230230 os.rmdir_all(base) or {}231231 }@@ -581,7 +581,7 @@581581 b.begin()!582582 write_fetched(b, fetched)!583583 history.set_head(b, fetched.head)!584- b.commit()!584+ b.end()!585585 state := events.flag_state(b, save.id)!586586 assert state.flagged587587 assert state.reason == 'look at this'@@ -620,7 +620,7 @@620620 store.begin()!621621 write_fetched(store, fetched)!622622 history.set_head(store, fetched.head)!623- store.commit()!623+ store.end()!624624 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,625625 fetched.head)!, [])!626626 store.set_state('remote_repo', 'proj')!@@ -779,7 +779,7 @@779779 b.begin()!780780 write_fetched(b, fetched)!781781 history.set_head(b, fetched.head)!782- b.commit()!782+ b.end()!783783 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!784784 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'785785 }@@ -867,7 +867,7 @@867867 }868868 assert secret_hash != ''869869 strip_local(a, 'secrets.env', '')!870- report := push_strip(a, mut backend, 'secrets.env')!870+ report := send_strip(a, mut backend, 'secrets.env')!871871 assert report.event_sent872872 assert report.records == 1873873 @@ -886,7 +886,7 @@886886 b.begin()!887887 fetched := fetch_back_to_known(b, mut backend, 'proj')!888888 write_fetched(b, fetched)!889- b.commit()!889+ b.end()!890890 mut paths := []string{}891891 for file in history.files_of(b, save.id)! {892892 paths << file.path@@ -929,7 +929,7 @@929929 offer_now(a, mut backend)!930930 join(b, mut backend)!931931 strip_local(a, 'secrets.env', '')!932- push_strip(a, mut backend, 'secrets.env')!932+ send_strip(a, mut backend, 'secrets.env')!933933 // B has not heard, and saves the file again an hour after the strip.934934 put(b.root, 'secrets.env', 'api_key=2\n')!935935 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{@@ -939,7 +939,7 @@939939 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)940940 b.begin()!941941 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!942- b.commit()!942+ b.end()!943943 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)944944 offer_now(b, mut backend)!945945 @@ -980,13 +980,13 @@980980 offer_now(a, mut backend)!981981 b.begin()!982982 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!983- b.commit()!983+ b.end()!984984 paths := history.files_of(b, later.id)!.map(it.path)985985 assert 'secrets.env' !in paths986986 assert 'keep.txt' in paths987987 }988988 989-// The local half commits before the remote hears anything, so a remote failure989+// The local half lands before the remote hears anything, so a remote failure990990 // leaves nothing to find locally; the pending note is what lets a re-run991991 // finish. Objects a remote-only save still needs must survive the strip.992992 fn test_an_interrupted_remote_strip_finishes_when_re_run() {@@ -1022,12 +1022,12 @@10221022 inner: remote.new_folder(remote_dir)!10231023 fail_write: 310241024 }1025- if _ := push_strip(a, mut flaky, 'secrets.env') {1025+ if _ := send_strip(a, mut flaky, 'secrets.env') {10261026 assert false, 'the injected failure should have surfaced'10271027 }10281028 assert strip_pending(a, 'secrets.env')10291029 assert strip_local(a, 'secrets.env', '')!.files_removed == 01030- report := push_strip(a, mut backend, 'secrets.env')!1030+ report := send_strip(a, mut backend, 'secrets.env')!10311031 assert report.records == 110321032 assert !strip_pending(a, 'secrets.env')10331033 assert !backend.get(object_key('proj', secret_hash))!.found@@ -1038,7 +1038,7 @@10381038 10391039 // B's save still names the shared bytes, so stripping A's copy keeps them.10401040 strip_local(a, 'shared.env', '')!1041- push_strip(a, mut backend, 'shared.env')!1041+ send_strip(a, mut backend, 'shared.env')!10421042 assert backend.get(object_key('proj', shared_hash))!.found10431043 }10441044 @@ -1063,7 +1063,7 @@10631063 offer_now(a, mut backend)!10641064 join(b, mut backend)!10651065 strip_local(a, 'secrets.env', '')!1066- push_strip(a, mut backend, 'secrets.env')!1066+ send_strip(a, mut backend, 'secrets.env')!10671067 put(b.root, 'more.txt', 'B keeps working\n')!10681068 later := make_save(b, 'B saves with the secret still in its tree')!10691069 if _ := offer_now(b, mut backend) {@@ -1096,14 +1096,14 @@10961096 inner: remote.new_folder(remote_dir)!10971097 fail_delete: true10981098 }1099- if _ := push_strip(a, mut flaky, 'secrets.env') {1099+ if _ := send_strip(a, mut flaky, 'secrets.env') {11001100 assert false, 'the injected delete failure should have surfaced'11011101 }11021102 put(a.root, 'secrets.env', 'second key\n')!11031103 put(a.root, 'other.txt', 'more work\n')!11041104 make_save(a, 'more work')!11051105 offer_now(a, mut backend)!1106- push_strip(a, mut backend, 'secrets.env')!1106+ send_strip(a, mut backend, 'secrets.env')!11071107 for key in ['first key\n', 'second key\n'] {11081108 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found11091109 }@@ -1132,11 +1132,11 @@11321132 inner: remote.new_folder(remote_dir)!11331133 fail_write: 211341134 }1135- if _ := push_strip(a, mut flaky, 'secrets.env') {1135+ if _ := send_strip(a, mut flaky, 'secrets.env') {11361136 assert false, 'the injected failure should have surfaced'11371137 }11381138 strip_local(a, 'secrets.env', second.id)!1139- push_strip(a, mut backend, 'secrets.env')!1139+ send_strip(a, mut backend, 'secrets.env')!11401140 assert !strip_pending(a, 'secrets.env')11411141 for save in [first, second] {11421142 record := decode_save(backend.get(save_key('proj', save.id))!.body)!@@ -1168,7 +1168,7 @@11681168 offer_now(a, mut backend)!11691169 join(b, mut backend)!11701170 strip_local(a, 'secrets.env', '')!1171- push_strip(a, mut backend, 'secrets.env')!1171+ send_strip(a, mut backend, 'secrets.env')!11721172 steal_now(b, mut backend)!11731173 for repo in [a, b] {11741174 put(repo.root, 'secrets.env', 'second key\n')!@@ -1202,7 +1202,7 @@12021202 make_save(a, 'A rotates')!12031203 offer_now(a, mut backend)!12041204 strip_local(a, 'secrets.env', '')!1205- push_strip(a, mut backend, 'secrets.env')!1205+ send_strip(a, mut backend, 'secrets.env')!12061206 put(b.root, 'secrets.env', 'key three\n')!12071207 mine := make_save(b, 'B rotates')!12081208 report := steal(b, mut backend, StealOptions{})!@@ -1237,7 +1237,7 @@12371237 make_save(a, 'A moves on')!12381238 offer_now(a, mut backend)!12391239 strip_local(a, 'notes.txt', common.id)!1240- push_strip(a, mut backend, 'notes.txt')!1240+ send_strip(a, mut backend, 'notes.txt')!12411241 put(b.root, 'notes.txt', 'B edits\n')!12421242 make_save(b, 'B edits notes')!12431243 if _ := steal(b, mut backend, StealOptions{}) {@@ -1401,7 +1401,7 @@14011401 assert err.msg().contains('koh-remote 2')14021402 }14031403 if _ := fetch_repo(mut backend, 'proj') {1404- assert false, 'a clone must refuse a format it does not speak'1404+ assert false, 'a steal must refuse a format it does not speak'14051405 } else {14061406 assert err.msg().contains('koh-remote 2')14071407 }src/exchange/steal.v+6 −6
@@ -4,7 +4,7 @@44 //! The order is always the same, and it is what makes an interrupted steal55 //! harmless: read everything from the remote first, then land it in one66 //! transaction, then write the working tree. Divergence is resolved per file —7-//! a clash is never merged line by line, it is chosen.7+//! a clash is never blended line by line, it is chosen.88 99 module exchange1010 @@ -138,7 +138,7 @@138138 store.rollback()139139 return err140140 }141- store.commit()!141+ store.end()!142142 }143143 144144 strips_before := events.strip_events(store)!.len@@ -188,8 +188,8 @@188188 store.rollback()189189 return err190190 }191- // The files follow the commit; the mark lands with it, in one step.192- // No head before means a fresh clone, whose whole tree is new.191+ // The files follow the transaction; the mark lands with it, in one step.192+ // No head before means a fresh steal, whose whole tree is new.193193 moved := if head := head_before { final.id != head.id } else { true }194194 if moved {195195 snapshot.mark_restore(store) or {@@ -197,7 +197,7 @@197197 return err198198 }199199 }200- store.commit() or { return err }200+ store.end() or { return err }201201 report.head = final.id202202 if local_only.len > 0 && report.fetched_saves > 0 {203203 report.previous_head = local_only.last().id@@ -551,7 +551,7 @@551551 return files.any(it.path != snapshot.ignore_file || it.hash != seed)552552 }553553 554-// fetch_repo pulls a whole repo — head, saves, objects and events — for554+// fetch_repo takes a whole repo — head, saves, objects and events — for555555 // `koh steal <name>`, which lands a copy in a new folder.556556 pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {557557 check_format(mut backend, name)!src/exchange/strip.v+6 −6
@@ -27,8 +27,8 @@2727 objects_dropped i642828 }2929 30-// PushReport says what a strip managed to make visible on the remote.31-pub struct PushReport {30+// SendReport says what a strip managed to make visible on the remote.31+pub struct SendReport {3232 pub mut:3333 records int3434 objects int@@ -86,7 +86,7 @@8686 store.rollback()8787 return err8888 }89- store.commit()!89+ store.end()!9090 return report9191 }9292 @@ -120,14 +120,14 @@120120 }121121 }122122 123-// push_strip makes a strip visible on the remote: the event travels like any123+// send_strip makes a strip visible on the remote: the event travels like any124124 // other, every remote save record listing the path is rewritten, and objects125125 // the strip freed are deleted once no remote record and no local save needs126126 // them. Every step is safe to repeat, and the pending note is cleared only127127 // when all of them have succeeded.128-pub fn push_strip(store &storage.Store, mut backend remote.Backend, path string) !PushReport {128+pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {129129 name := repo_name(store, mut backend)!130- mut report := PushReport{}130+ mut report := SendReport{}131131 mut upload := OfferReport{132132 repo: name133133 }src/history/history_test.v+2 −2
@@ -175,7 +175,7 @@175175 saved := save_walk(store, dir, SaveInput{176176 message: 'mine'177177 }, first.id, stale)!178- store.commit()!178+ store.end()!179179 assert saved.parent == other.id180180 a := files_of(store, saved.id)!.filter(it.path == 'a.txt')181181 assert a.len == 1 && a[0].hash == storage.hash_hex('two\n'.bytes())@@ -285,7 +285,7 @@285285 saved := save_walk(store, dir, SaveInput{286286 message: 'mine'287287 }, first.id, stale)!288- store.commit()!288+ store.end()!289289 assert !files_of(store, saved.id)!.any(it.path == 'secret.env')290290 }291291 src/history/log_md.v+1 −1
@@ -10,7 +10,7 @@1010 const log_marker = '<!-- Koh maintains this file. Do not edit. Read before starting work. -->'1111 const log_shown = 201212 13-// refresh_log_md rewrites LOG.md after a change to history has committed.13+// refresh_log_md rewrites LOG.md after a change to history has landed.1414 // The history is what matters, so a LOG.md that cannot be written is a warning1515 // on stderr, never a failed command: a save that exits 1 after landing would1616 // send an agent to save again.src/history/save.v+2 −2
@@ -39,7 +39,7 @@3939 store.rollback()4040 return err4141 }42- store.commit()!42+ store.end()!4343 refresh_log_md(store)4444 return save4545 }@@ -82,7 +82,7 @@8282 store.rollback()8383 return err8484 }85- store.commit()!85+ store.end()!8686 refresh_log_md(store)8787 return save8888 }src/migrate/migrate.v+11 −11
@@ -23,7 +23,7 @@2323 // Koh 2's build file, renamed to `koh.build` (build.config_name) by the2424 // migration, which is the name Koh 3 reads.2525 const kepr_build_file = 'kepr.build'26-pub const staging_file = '.koh-migrate'26+pub const draft_file = '.koh-migrate'2727 pub const default_lane = 'dev'2828 2929 // One file in a Koh 2 manifest. Small files were inlined as base64 instead of@@ -98,21 +98,21 @@9898 lane: chosen9999 head: head.id100100 }101- // Import into a staging file first: nothing is touched until it verifies.102- staging := os.join_path(root, staging_file)103- if os.exists(staging) {104- return error('${staging} already exists — a previous migrate did not finish; move it away and try again')101+ // Import into a draft file first: nothing is touched until it verifies.102+ draft_path := os.join_path(root, draft_file)103+ if os.exists(draft_path) {104+ return error('${draft_path} already exists — a previous migrate did not finish; move it away and try again')105105 }106- mut staged := storage.create_at(staging, root)!107- // The staged file must be closed before it is moved: SQLite keeps committed106+ mut draft := storage.create_at(draft_path, root)!107+ // The draft must be closed before it is moved: SQLite keeps finished108108 // work in its WAL until the last connection goes away, and moving the file109109 // alone would leave that work behind.110110 mut failure := ''111- if _ := import_and_verify(staged, root, line, mut report) {111+ if _ := import_and_verify(draft, root, line, mut report) {112112 } else {113113 failure = err.msg()114114 }115- staged.close()115+ draft.close()116116 if failure != '' {117117 return error(failure)118118 }@@ -306,7 +306,7 @@306306 fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report) ! {307307 store.begin()!308308 if _ := import_line(store, root, line, mut report) {309- store.commit()!309+ store.end()!310310 } else {311311 store.rollback()312312 return err@@ -406,7 +406,7 @@406406 return error('${archive} already exists — move it away first')407407 }408408 os.mv(old, archive, os.MvParams{})!409- os.mv(os.join_path(root, staging_file), os.join_path(root, storage.file_name), os.MvParams{})!409+ os.mv(os.join_path(root, draft_file), os.join_path(root, storage.file_name), os.MvParams{})!410410 // Koh 2 kept its ignore rules under .koh/; carry them to the root, which is411411 // where Koh 3 reads them from.412412 ignore_source := os.join_path(archive, 'ignore')src/migrate/migrate_test.v+2 −2
@@ -257,10 +257,10 @@257257 } else {258258 assert err.msg().contains('save with koh2 first')259259 }260- // Nothing was touched: still a Koh 2 repo, no archive, no staging file.260+ // Nothing was touched: still a Koh 2 repo, no archive, no draft file.261261 assert os.is_dir(os.join_path(fixture.dir, '.koh'))262262 assert !os.exists(os.join_path(fixture.dir, '.koh-v2'))263- assert !os.exists(os.join_path(fixture.dir, staging_file))263+ assert !os.exists(os.join_path(fixture.dir, draft_file))264264 }265265 266266 // A damaged manifest naming a path outside the repo must never be read there.src/remote/s3.v+1 −1
@@ -226,7 +226,7 @@226226 return body[..200]227227 }228228 229-// elements pulls every text between <name> and </name> out of a response. The229+// elements takes every text between <name> and </name> out of a response. The230230 // only response Koh reads is S3's ListObjectsV2, which is flat and small, so a231231 // scanner is honest here where a full XML parser would be a dependency.232232 pub fn elements(xml string, name string) []string {src/storage/cache.v+1 −1
@@ -50,7 +50,7 @@5050 return err5151 }5252 }53- s.commit()!53+ s.end()!5454 }5555 5656 fn (s &Store) save_cache(row CacheRow) ! {src/storage/storage.v+4 −4
@@ -131,7 +131,7 @@131131 db.exec('BEGIN IMMEDIATE')!132132 db.exec(schema)!133133 db.exec('PRAGMA user_version = ${schema_version}')!134- db.exec('COMMIT')!134+ db.exec('END')!135135 return Store{136136 db: db137137 root: root@@ -143,7 +143,7 @@143143 }144144 145145 fn apply_settings(db &Db) ! {146- // WAL for crash safety, full sync so a committed save is on disk, foreign146+ // WAL for crash safety, full sync so a finished save is on disk, foreign147147 // keys so a half-written save cannot exist.148148 db.exec('PRAGMA journal_mode=WAL')!149149 db.exec('PRAGMA synchronous=FULL')!@@ -169,8 +169,8 @@169169 s.db.exec('BEGIN IMMEDIATE')!170170 }171171 172-pub fn (s &Store) commit() ! {173- s.db.exec('COMMIT')!172+pub fn (s &Store) end() ! {173+ s.db.exec('END')!174174 }175175 176176 pub fn (s &Store) rollback() {src/update/update.v+10 −10
@@ -131,19 +131,19 @@131131 bytes := download(available.url)!132132 verify(bytes, available.blake3)!133133 target := os.executable()134- staged := stage(os.dir(target), bytes)!134+ fresh := write_beside(os.dir(target), bytes)!135135 if os.user_os() == 'macos' {136136 // An unsigned binary cannot run on Apple silicon at all, so a refused137137 // signature is a refusal to install, not a warning.138- signed := os.execute('codesign -f -s - ${sh_quote(staged)}')138+ signed := os.execute('codesign -f -s - ${sh_quote(fresh)}')139139 if signed.exit_code != 0 {140- os.rm(staged) or {}140+ os.rm(fresh) or {}141141 return error('codesign refused the downloaded binary: ${signed.output.trim_space()}')142142 }143143 }144144 // rename, not move: both paths are in one folder, so this is the atomic step145145 // and a failure is a failure, never a half-written binary.146- os.rename(staged, target)!146+ os.rename(fresh, target)!147147 return target148148 }149149 @@ -191,16 +191,16 @@191191 }192192 }193193 194-// stage writes the new binary beside the one it will replace, so the rename is194+// write_beside writes the new binary beside the one it will replace, so the rename is195195 // within one filesystem and therefore atomic. A folder that cannot be written196196 // is refused here, with the write's own error.197-fn stage(dir string, bytes []u8) !string {198- staged := os.join_path(dir, '.koh-new-${os.getpid()}')199- os.write_file_array(staged, bytes) or {197+fn write_beside(dir string, bytes []u8) !string {198+ fresh := os.join_path(dir, '.koh-new-${os.getpid()}')199+ os.write_file_array(fresh, bytes) or {200200 return error('cannot write beside ${dir} — put koh where this user can replace it (${err.msg()})')201201 }202- os.chmod(staged, 0o755)!203- return staged202+ os.chmod(fresh, 0o755)!203+ return fresh204204 }205205 206206 // sh_quote keeps a path with a space in it one argument, and cannot be brokensrc/update/update_test.v+7 −7
@@ -55,16 +55,16 @@5555 }5656 }5757 58-fn test_staging_writes_beside_the_target_and_refuses_a_locked_folder() {59- dir := scratch('stage')!58+fn test_write_beside_writes_beside_the_target_and_refuses_a_locked_folder() {59+ dir := scratch('write_beside')!6060 defer {6161 os.rmdir_all(dir) or {}6262 }63- staged := stage(dir, 'binary'.bytes())!64- assert os.dir(staged) == dir65- assert os.read_file(staged)! == 'binary'63+ fresh := write_beside(dir, 'binary'.bytes())!64+ assert os.dir(fresh) == dir65+ assert os.read_file(fresh)! == 'binary'6666 // 0755: the file is about to become the koh binary.67- assert (os.stat(staged) or { panic(err) }).mode & 0o777 == 0o75567+ assert (os.stat(fresh) or { panic(err) }).mode & 0o777 == 0o7556868 6969 locked := os.join_path(dir, 'locked')7070 os.mkdir_all(locked, os.MkdirParams{})!@@ -72,7 +72,7 @@7272 defer {7373 os.chmod(locked, 0o700) or {}7474 }75- if _ := stage(locked, 'binary'.bytes()) {75+ if _ := write_beside(locked, 'binary'.bytes()) {7676 assert false, 'a folder that cannot be written must be refused'7777 } else {7878 assert err.msg().contains('cannot write beside')src/view/view_test.v+1 −1
@@ -86,7 +86,7 @@8686 assert move_selection(3, -1, 3) == 28787 }8888 89-// A long message used to wrap, which scrolled the terminal and pushed the tab89+// A long message used to wrap, which scrolled the terminal and shoved the tab9090 // bar off the top: every line of a frame must fit, and the frame must fill9191 // the screen exactly.9292 fn test_frame_fills_the_screen_and_never_wraps() {tests/cli_test.v+24 −24
@@ -384,7 +384,7 @@384384 assert !run(dir, 'status').out.contains('LOG.md')385385 }386386 387-// LOG.md is a convenience written after the save commits. If it cannot be387+// LOG.md is a convenience written after the save lands. If it cannot be388388 // written, the save still happened, and an agent that retried on a failure389389 // would only be told nothing had changed.390390 fn test_a_save_succeeds_when_log_md_cannot_be_written() {@@ -953,11 +953,11 @@953953 assert run_home(dir, home, 'offer').code == 0954954 }955955 956-// A clone belongs to the repo it was stolen from, whatever its folder is956+// A stolen repo belongs to the repo it was stolen from, whatever its folder is957957 // called later: renaming it must not start a second repo on the remote.958-fn test_a_renamed_clone_keeps_offering_to_its_repo() {959- syncroot := temp_repo('rename-clone')960- home := temp_repo('rename-clone-home')958+fn test_a_renamed_stolen_repo_keeps_offering_to_its_repo() {959+ syncroot := temp_repo('rename-steal')960+ home := temp_repo('rename-steal-home')961961 defer {962962 os.rmdir_all(syncroot) or {}963963 os.rmdir_all(home) or {}@@ -981,9 +981,9 @@981981 assert os.read_file(os.join_path(a, 'b.txt'))! == 'from B\n'982982 }983983 984-// A damaged object on the remote stops a clone, names the object, and leaves984+// A damaged object on the remote stops a steal, names the object, and leaves985985 // no half-made folder that would make the retry refuse.986-fn test_clone_of_a_damaged_remote_leaves_nothing() {986+fn test_steal_of_a_damaged_remote_leaves_nothing() {987987 syncroot := temp_repo('damaged')988988 home := temp_repo('damaged-home')989989 defer {@@ -1004,9 +1004,9 @@10041004 os.chmod(os.join_path(objects, name), 0o644) or {}10051005 os.write_file(os.join_path(objects, name), 'junk') or { panic(err) }10061006 }1007- clone := run_home(b_parent, home, 'steal', 'proj')1008- assert clone.code == 11009- assert clone.err.contains('is damaged')1007+ stolen := run_home(b_parent, home, 'steal', 'proj')1008+ assert stolen.code == 11009+ assert stolen.err.contains('is damaged')10101010 assert !os.exists(os.join_path(b_parent, 'proj'))10111011 }10121012 @@ -1026,8 +1026,8 @@10261026 write(a, 'a.txt', 'from A\n')10271027 assert run_home(a, home, 'remote', 'folder', remote).code == 010281028 assert run_home(a, home, 'save', 'A first').code == 01029- clone := run_home(b_parent, home, 'steal', 'proj')1030- assert clone.code == 01029+ stolen := run_home(b_parent, home, 'steal', 'proj')1030+ assert stolen.code == 010311031 b := os.join_path(b_parent, 'proj')10321032 assert os.read_file(os.join_path(b, 'a.txt'))! == 'from A\n'10331033 write(b, 'b.txt', 'from B\n')@@ -1153,11 +1153,11 @@11531153 }11541154 }11551155 1156-// A clone brings in the whole history, flags included; both output forms must1156+// A steal brings in the whole history, flags included; both output forms must11571157 // say which saves came in flagged, and why.1158-fn test_a_clone_reports_flagged_saves() {1159- syncroot := temp_repo('clone-flags')1160- home := temp_repo('clone-flags-home')1158+fn test_a_steal_reports_flagged_saves() {1159+ syncroot := temp_repo('steal-flags')1160+ home := temp_repo('steal-flags-home')11611161 defer {11621162 os.rmdir_all(syncroot) or {}11631163 os.rmdir_all(home) or {}@@ -1173,12 +1173,12 @@11731173 for name in ['B', 'C'] {11741174 os.mkdir_all(os.join_path(syncroot, name)) or { panic(err) }11751175 }1176- json_clone := run_home(os.join_path(syncroot, 'B'), home, 'steal', 'proj', '--json')1177- assert json_clone.code == 01178- assert json_clone.out.contains('"flagged":["${id}"]')1179- text_clone := run_home(os.join_path(syncroot, 'C'), home, 'steal', 'proj')1180- assert text_clone.code == 01181- assert text_clone.out.contains('[${id}] came in flagged — broke the build')1176+ json_stolen := run_home(os.join_path(syncroot, 'B'), home, 'steal', 'proj', '--json')1177+ assert json_stolen.code == 01178+ assert json_stolen.out.contains('"flagged":["${id}"]')1179+ text_stolen := run_home(os.join_path(syncroot, 'C'), home, 'steal', 'proj')1180+ assert text_stolen.code == 01181+ assert text_stolen.out.contains('[${id}] came in flagged — broke the build')11821182 }11831183 11841184 // A folder remote on a disk or share that is not mounted is simply not there.@@ -1325,7 +1325,7 @@13251325 report := run_home(dir, home, 'migrate')13261326 assert report.code == 013271327 assert report.out.contains('migrated 3 saves')1328- // The swap: `.koh` is a file, the old data is archived, nothing is staging.1328+ // The swap: `.koh` is a file, the old data is archived, nothing is left in the draft.13291329 assert os.is_file(os.join_path(dir, '.koh'))13301330 assert os.is_dir(os.join_path(dir, '.koh-v2'))13311331 assert os.is_file(os.join_path(dir, '.kohignore'))@@ -1361,7 +1361,7 @@13611361 refused := run_home(dir, home, 'migrate')13621362 assert refused.code == 113631363 assert refused.err.contains('save with koh2 first')1364- // Nothing moved: still Koh 2, no archive, no staging file.1364+ // Nothing moved: still Koh 2, no archive, no draft file.13651365 assert os.is_dir(os.join_path(dir, '.koh'))13661366 assert !os.exists(os.join_path(dir, '.koh-v2'))13671367 assert !os.exists(os.join_path(dir, '.koh-migrate'))tests/library_check_test.v+1 −1
@@ -16,7 +16,7 @@1616 import net.http1717 import time1818 19-// Koh 3 is built and tested on V master at b9a2d87, and that commit is the pin:19+// Koh 3 is built and tested on V master at b9a2d87, and that revision is the pin:2020 // V master moves, and a newer V may break Koh at any time. Building with any2121 // other compiler fails here rather than somewhere stranger.2222 fn test_built_with_the_pinned_v() {v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.1.1'3+ version: '3.1.2'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }