fc918ffae554

write <repo>/format on the first offer and refuse a remote that speaks another layout on offer, steal and clone (3.0.73)

time
2026-09-27 09:51 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
koh 3 phases 6-8
parent
c543b1f61ba2
6 files VERSION+1 −1src/exchange/exchange.v+25 −0src/exchange/exchange_test.v+83 −0src/exchange/records.v+11 −6src/exchange/steal.v+1 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.721+3.0.73

view file

src/exchange/exchange.v+25 −0
@@ -26,6 +26,25 @@2626 2727 pub const no_saves = 'this repo has no saves yet — nothing to offer'2828 29+// The only remote layout this koh reads or writes. A repo offered before the30+// key existed has no `format` key and counts as this version.31+const format_value = 'koh-remote 1'32+33+// check_format reads <repo>/format and refuses a remote written by a koh that34+// speaks another layout, so two versions never half-understand one bucket. An35+// empty answer means the key is missing: version 1, which `offer` records.36+fn check_format(mut backend remote.Backend, repo string) !string {37+	existing := backend.get(format_key(repo))!38+	if !existing.found {39+		return ''40+	}41+	value := existing.body.bytestr().trim_space()42+	if value != format_value {43+		return error('the remote "${repo}" is "${value}" — this koh speaks "${format_value}"; update koh')44+	}45+	return value46+}47+2948 pub struct OfferReport {3049 pub mut:3150 	repo    string@@ -68,6 +87,11 @@6887 		repo: name6988 		head: local.id7089 	}90+	if check_format(mut backend, name)! == '' {91+		// Offered before this key existed: record the layout now, without92+		// overwriting one another machine wrote first.93+		backend.put_if_absent(format_key(name), format_value.bytes())!94+	}7195 	remote_head := backend.get(head_key(name))!7296 	// Records offered by a machine that has not applied a strip would put the7397 	// stripped path back on the remote, where nothing would rewrite it again.@@ -173,6 +197,7 @@173197 }174198 175199 pub fn fetch_back_to_known(store &storage.Store, mut backend remote.Backend, repo string) !Fetched {200+	check_format(mut backend, repo)!176201 	remote_head := backend.get(head_key(repo))!177202 	if !remote_head.found {178203 		return error('the remote has no "${repo}" yet — run koh offer here first')

view file

src/exchange/exchange_test.v+83 −0
@@ -1234,3 +1234,86 @@12341234 fn head_of(store &storage.Store) history.Save {12351235 	return history.head(store) or { panic('this repo has no head') }12361236 }1237+1238+// A remote says what it is, so two koh versions never half-understand one1239+// bucket. The first offer of a repo records the layout.1240+fn test_the_first_offer_records_the_remote_format() {1241+	base := scratch('format_write')!1242+	defer {1243+		os.rmdir_all(base) or {}1244+	}1245+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1246+	mut a := open_repo(machine('format_write_a')!)!1247+	defer {1248+		a.close()1249+	}1250+	put(a.root, 'a.txt', 'one\n')!1251+	make_save(a, 'first')!1252+	offer_now(a, mut backend)!1253+	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1254+}1255+1256+// A repo offered before the key existed is version 1: it still syncs, and the1257+// next offer records the key then.1258+fn test_a_remote_without_a_format_still_syncs() {1259+	base := scratch('format_old')!1260+	defer {1261+		os.rmdir_all(base) or {}1262+	}1263+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1264+	mut a := open_repo(machine('format_old_a')!)!1265+	defer {1266+		a.close()1267+	}1268+	put(a.root, 'a.txt', 'one\n')!1269+	make_save(a, 'first')!1270+	offer_now(a, mut backend)!1271+	// Stand in for a bucket offered before the key existed.1272+	backend.delete(format_key('proj'))!1273+	mut b := open_repo(machine('format_old_b')!)!1274+	defer {1275+		b.close()1276+	}1277+	join(b, mut backend)!1278+	assert head_of(b).id == head_of(a).id1279+	put(a.root, 'a.txt', 'two\n')!1280+	make_save(a, 'second')!1281+	offer_now(a, mut backend)!1282+	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1283+	report := steal_now(b, mut backend)!1284+	assert report.head == head_of(a).id1285+}1286+1287+// A remote that speaks another layout is refused loudly, on offer and on1288+// steal, rather than half-read.1289+fn test_an_unknown_remote_format_is_refused() {1290+	base := scratch('format_unknown')!1291+	defer {1292+		os.rmdir_all(base) or {}1293+	}1294+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1295+	mut a := open_repo(machine('format_bad_a')!)!1296+	defer {1297+		a.close()1298+	}1299+	put(a.root, 'a.txt', 'one\n')!1300+	make_save(a, 'first')!1301+	offer_now(a, mut backend)!1302+	backend.delete(format_key('proj'))!1303+	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1304+	if _ := offer_now(a, mut backend) {1305+		assert false, 'offer must refuse a format it does not speak'1306+	} else {1307+		assert err.msg().contains('koh-remote 2')1308+	}1309+	if _ := steal_now(a, mut backend) {1310+		assert false, 'steal must refuse a format it does not speak'1311+	} else {1312+		assert err.msg().contains('koh-remote 2')1313+	}1314+	if _ := fetch_repo(mut backend, 'proj') {1315+		assert false, 'a clone must refuse a format it does not speak'1316+	} else {1317+		assert err.msg().contains('koh-remote 2')1318+	}1319+}

view file

src/exchange/records.v+11 −6
@@ -3,12 +3,13 @@33 //! Records are JSON so a person can read the bucket, and the layout is the44 //! same for every backend:55 //!6-//!   <repo>/objects/<hash>   the object's compressed bytes, as in the table7-//!   <repo>/saves/<id>       the full save record, including its file list8-//!   <repo>/events/<id>      one flag, clear or strip event9-//!   <repo>/head             the ID of the newest save on the line10-//!11-//! Only `head` is ever overwritten, and only with a matching version tag.6+//   <repo>/format          the remote layout, `koh-remote 1`7+//   <repo>/objects/<hash>   the object's compressed bytes, as in the table8+//   <repo>/saves/<id>       the full save record, including its file list9+//   <repo>/events/<id>      one flag, clear or strip event10+//   <repo>/head             the ID of the newest save on the line11+//12+// Only `head` is ever overwritten, and only with a matching version tag.1213 1314 module exchange1415 @@ -17,6 +18,10 @@1718 import src.history1819 import src.snapshot1920 import src.storage21+22+pub fn format_key(repo string) string {23+	return '${repo}/format'24+}2025 2126 pub fn object_key(repo string, hash string) string {2227 	return '${repo}/objects/${hash}'

view file

src/exchange/steal.v+1 −0
@@ -433,6 +433,7 @@433433 // fetch_repo pulls a whole repo — head, saves, objects and events — for434434 // `koh steal <name>`, which lands a copy in a new folder.435435 pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {436+	check_format(mut backend, name)!436437 	remote_head := backend.get(head_key(name))!437438 	if !remote_head.found {438439 		return error('the remote has no repo called "${name}"')

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.72'3+	version: '3.0.73'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file