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
↓ koh-fc918ffa.tar.gzatlas at this save
files changed6 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 −1src/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')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+}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}'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}"')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 }