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
10 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.561+3.0.57

view file

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

view file

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 {}

view file

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

view file

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 

view file

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

view file

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

view file at the parent

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

view file

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+}

view file

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 }

view file