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
24 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 −1
README.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 single

view file

VERSION+1 −1
@@ -1 +1 @@1-3.1.11+3.1.2

view file

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

view file

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.files

view file

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

view file

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 

view file

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

view file

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 	}

view file

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

view file

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 	}

view file

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 

view file

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.

view file

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 }

view file

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

view file

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.

view file

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 {

view file

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

view file

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() {

view file

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 broken

view file

src/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')

view file

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() {

view file

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

view file

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() {

view file

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 }

view file