5222a7c6691a
keep saves made after a strip out of its reach when they arrive, so a clone no longer loses a file saved again after stripping (3.0.28, codex review pending)
- time
- 2026-09-23 22:43 UTC · 2 weeks ago
- machine
- koh2
- agent
- claude-opus-5-5
- session
- koh 3 reliability campaign
- parent
- 55fa35690dab
↓ koh-5222a7c6.tar.gzatlas at this save
files changed6 files
VERSION+1 −1src/sync/steal.v+10 −12src/sync/strip.v+0 −11src/sync/sync.v+28 −7src/sync/sync_test.v+36 −0v.mod+1 −1src/sync/steal.v+10 −12
@@ -318,9 +318,11 @@318318 // clashes are found on the trees as they will be then, or the answers319319 // would be about files that are no longer there.320320 strips := fetched.events_out.filter(it.kind == events.kind_strip)321- mut clashes := find_clashes(after_strips(base_files, fetched.base, strips), after_strips(history.files_of(store,322- head.id)!, head.id, strips), after_strips(to_files(fetched.saves.last()), fetched.head,323- strips))321+ // Base and mine are here already, so every strip reaches them (created 0).322+ remote_head := fetched.saves.last()323+ mut clashes := find_clashes(after_strips(base_files, fetched.base, 0, strips), after_strips(history.files_of(store,324+ head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,325+ remote_head.created_at, strips))324326 for mut clash in clashes {325327 mine := fetched_bytes(store, fetched, clash.mine)!326328 theirs := fetched_bytes(store, fetched, clash.theirs)!@@ -330,15 +332,11 @@330332 return clashes331333 }332334 333-// after_strips is a save's file list once the given strip events are applied.334-fn after_strips(files []snapshot.FileEntry, save_id string, strips []events.Event) []snapshot.FileEntry {335- return files.filter(fn [strips, save_id] (file snapshot.FileEntry) bool {336- for strip in strips {337- if (strip.save_id == '' || strip.save_id == save_id) && covers(strip.path, file.path) {338- return false339- }340- }341- return true335+// after_strips is a save's file list once the given strip events are applied,336+// by the same rule write_fetched uses.337+fn after_strips(files []snapshot.FileEntry, save_id string, created_at i64, strips []events.Event) []snapshot.FileEntry {338+ return files.filter(fn [strips, save_id, created_at] (file snapshot.FileEntry) bool {339+ return !strips.any(reaches(it, save_id, created_at) && covers(it.path, file.path))342340 })343341 }344342 src/sync/strip.v+0 −11
@@ -201,17 +201,6 @@201201 }202202 }203203 204-// apply_strip is what a machine does when it hears about somebody else's strip:205-// the same removal, so both histories agree from then on. It returns how many206-// saved files it removed.207-pub fn apply_strip(store &storage.Store, event events.Event) !int {208- removed := remove_path(store, event.path, event.save_id)!209- if removed > 0 {210- store.drop_unreferenced_objects()!211- }212- return removed213-}214-215204 fn remove_path(store &storage.Store, path string, only_save string) !int {216205 mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!217206 defer {src/sync/sync.v+28 −7
@@ -14,6 +14,7 @@1414 import src.events1515 import src.history1616 import src.remote17+import src.snapshot1718 import src.storage1819 1920 // Where the repo's name on the remote is remembered.@@ -207,23 +208,43 @@207208 return incoming208209 }209210 210-// write_fetched lands a fetch in one transaction: objects first (verified), then211-// the saves they belong to, then the events.211+// write_fetched lands a fetch in one transaction: objects first (verified),212+// then the events, then the saves they belong to. A new strip reaches every213+// save already here, but an arriving save only when `reaches` says so: one214+// made after the strip is new work, not what the strip removed.212215 pub fn write_fetched(store &storage.Store, fetched Fetched) ! {213216 for hash, blob in fetched.repo_objects {214217 store.put_object_blob(hash, blob)!215218 }216- for record in fetched.saves {217- history.import_save(store, to_save(record), to_files(record))!218- }219+ mut strips := []events.Event{}219220 for event in fetched.events_out {220221 if !events.receive(store, event)! {221222 continue222223 }223224 if event.kind == events.kind_strip {224225 // Somebody stripped a path: remove it here too, so the two225- // histories agree from now on.226- apply_strip(store, event)!226+ // histories agree from now on. Objects go once the saves are in.227+ remove_path(store, event.path, event.save_id)!228+ strips << event227229 }228230 }231+ for record in fetched.saves {232+ files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {233+ return !strips.any(reaches(it, record.id, record.created_at)234+ && covers(it.path, file.path))235+ })236+ history.import_save(store, to_save(record), files)!237+ }238+ if strips.len > 0 {239+ store.drop_unreferenced_objects()!240+ }241+}242+243+// reaches reports whether a strip event applies to a save arriving from the244+// remote. A strip of every save covers the saves made up to its own time.245+fn reaches(strip events.Event, save_id string, created_at i64) bool {246+ if strip.save_id != '' {247+ return strip.save_id == save_id248+ }249+ return created_at <= strip.created_at229250 }src/sync/sync_test.v+36 −0
@@ -758,6 +758,42 @@758758 assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)759759 }760760 761+// A strip removes a path from the saves that existed when it was made. A key762+// rotated and saved again afterwards is new work: a machine that clones the763+// repo later must get it, not strip it on arrival.764+fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {765+ base := scratch('strip_clone')!766+ defer {767+ os.rmdir_all(base) or {}768+ }769+ mut backend := remote.new_folder(os.join_path(base, 'remote'))!770+ mut a := open_repo(machine('strip_clone_a')!)!771+ defer {772+ a.close()773+ }774+ put(a.root, 'secrets.env', 'api_key=1\n')!775+ old := commit(a, 'leak the key')!776+ offer_now(a, mut backend)!777+ strip_local(a, 'secrets.env', '')!778+ push_strip(a, mut backend, 'secrets.env')!779+ // Saves after the strip must be later in time, as they would be for real.780+ put(a.root, 'secrets.env', 'api_key=2\n')!781+ rotated := history.commit_files(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{782+ message: 'rotate the key'783+ created_at: events.strip_events(a)!.last().created_at + 60784+ })!785+ offer_now(a, mut backend)!786+787+ mut c := open_repo(machine('strip_clone_c')!)!788+ defer {789+ c.close()790+ }791+ join(c, mut backend)!792+ assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)793+ assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)794+ assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'795+}796+761797 // The local half commits before the remote hears anything, so a remote failure762798 // leaves nothing to find locally; the pending note is what lets a re-run763799 // finish. Objects a remote-only save still needs must survive the strip.v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.27'3+ version: '3.0.28'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }