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
6 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.271+3.0.28

view file

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

view file

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 {

view file

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 }

view file

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.

view file

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 }

view file