69c3ac67d7c8

make an interrupted remote strip finish on re-run, give each strip its own event, and keep strip logic in sync (3.0.6)

time
2026-09-23 21:38 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh 3 review fixes
parent
259ca814c682
7 files VERSION+1 −1src/cli/cmd_strip.v+41 −137src/events/events.v+5 −20src/sync/strip.v+240 −0src/sync/sync.v+7 −108src/sync/sync_test.v+205 −18v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.51+3.0.6

view file

src/cli/cmd_strip.v+41 −137
@@ -13,7 +13,6 @@1313 1414 import json1515 import src.fmt16-import src.events1716 import src.history1817 import src.machine1918 import src.sync@@ -42,135 +41,66 @@4241 		save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) }4342 		only_save = save.id4443 	}45-	rows := affected_rows(store, path, only_save) or { return refuse(args.json(), err.msg()) }46-	if rows.len == 0 {47-		ok('nothing in history matches ${path}')48-		return 044+	scope := sync.strip_scope(store, path, only_save) or { return refuse(args.json(), err.msg()) }45+	mut local := sync.LocalStrip{}46+	if scope.files == 0 {47+		// Nothing left here, but the remote half of an earlier strip may not48+		// have finished; re-running is how it finishes.49+		if !sync.strip_pending(store, path) {50+			ok('nothing in history matches ${path}')51+			return 052+		}53+		note('${path} is already stripped here — finishing the remote')54+	} else {55+		where := if only_save == '' { 'every save' } else { 'save ${only_save}' }56+		println('  ${red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')57+		println('  ${amber('!')} ${fmt.plural(scope.files, 'file')} in ${fmt.plural(scope.saves,58+			'save')}, ${fmt.plural(scope.objects, 'object')} — ${where}')59+		println('  ${dim('strip removes the path from history; the file on disk is left alone')}')60+		approved := confirm(false) or { return refuse(args.json(), err.msg()) }61+		if !approved {62+			note('nothing was stripped')63+			return 164+		}65+		if !typed_confirm('type the path to confirm:', path) {66+			note('nothing was stripped — the path did not match')67+			return 168+		}69+		local = sync.strip_local(store, path, only_save) or {70+			return refuse(args.json(), err.msg())71+		}4972 	}50-	mut saves := map[string]bool{}51-	mut objects := map[string]bool{}52-	for row in rows {53-		saves[row.save_id] = true54-		objects[row.hash] = true55-	}56-	scope := if only_save == '' { 'every save' } else { 'save ${only_save}' }57-58-	println('  ${red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')59-	println('  ${amber('!')} ${fmt.plural(rows.len, 'file')} in ${fmt.plural(saves.len, 'save')}, ${fmt.plural(objects.len,60-		'object')} — ${scope}')61-	println('  ${dim('strip removes the path from history; the file on disk is left alone')}')62-	approved := confirm(false) or { return refuse(args.json(), err.msg()) }63-	if !approved {64-		note('nothing was stripped')65-		return 166-	}67-	if !typed_confirm('type the path to confirm:', path) {68-		note('nothing was stripped — the path did not match')69-		return 170-	}71-72-	store.begin() or { return refuse(args.json(), err.msg()) }73-	count := remove_rows(store, rows) or {74-		store.rollback()75-		return refuse(args.json(), err.msg())76-	}77-	dropped := store.drop_unreferenced_objects() or {78-		store.rollback()79-		return refuse(args.json(), err.msg())80-	}81-	events.strip(store, only_save, path) or {82-		store.rollback()83-		return refuse(args.json(), err.msg())84-	}85-	store.commit() or { return refuse(args.json(), err.msg()) }86-87-	// The remote hears about it too: the event travels, the affected save88-	// records are rewritten, and objects nobody references are deleted. A89-	// failure here is reported, never fatal: the local strip has happened.90-	push := push_strip_to_remote(store, path, touched_saves(rows), touched_objects(rows),91-		only_save == '')73+	// A remote failure is reported, never fatal: the local strip has happened,74+	// and running the same strip again finishes the remote.75+	push := push_strip_to_remote(store, path)9276 	if args.json() {9377 		emit_json(json.encode(StripJson{9478 			path:                   path9579 			save:                   only_save96-			files_removed:          count97-			objects_dropped:        int(dropped)80+			files_removed:          local.files_removed81+			objects_dropped:        int(local.objects_dropped)9882 			records_rewritten:      push.pushed.records9983 			remote_objects_dropped: push.pushed.objects10084 			remote_failed:          push.failed10185 		}))10286 		return 010387 	}104-	ok('stripped ${path} from ${fmt.plural(count, 'file')} across ${fmt.plural(saves.len, 'save')}')105-	note('${dropped} objects dropped')88+	if scope.files > 0 {89+		ok('stripped ${path} from ${fmt.plural(local.files_removed, 'file')} across ${fmt.plural(scope.saves,90+			'save')}')91+		note('${local.objects_dropped} objects dropped')92+	}10693 	if !push.skipped {10794 		if push.failed == '' {10895 			note('the remote was updated: ${push.pushed.records} save records rewritten, ${push.pushed.objects} objects dropped')10996 		} else {110-			warn('the remote could not be updated: ${push.failed} — run koh offer, then strip again')97+			warn('the remote could not be updated: ${push.failed} — run the same strip again to finish it')11198 		}11299 	}113100 	note('remember to delete ${path} or add it to ${snapshot.ignore_file}')114101 	return 0115102 }116103 117-pub struct StripRow {118-pub:119-	save_id string120-	path    string121-	hash    string122-}123-124-// affected_rows finds every saved file the path covers: the file itself, or125-// anything beneath it when it names a folder.126-fn affected_rows(store &storage.Store, path string, only_save string) ![]StripRow {127-	mut query := "SELECT save_id, path, hash FROM save_files WHERE (path = ?1 OR path LIKE ?2 ESCAPE '\\')"128-	if only_save != '' {129-		query += ' AND save_id = ?3'130-	}131-	mut stmt := store.db.prepare(query)!132-	defer {133-		stmt.finalize()134-	}135-	stmt.bind_text(1, path)!136-	stmt.bind_text(2, like_under(path))!137-	if only_save != '' {138-		stmt.bind_text(3, only_save)!139-	}140-	mut rows := []StripRow{}141-	for stmt.step()! {142-		rows << StripRow{143-			save_id: stmt.text(0)144-			path:    stmt.text(1)145-			hash:    stmt.text(2)146-		}147-	}148-	return rows149-}150-151-fn remove_rows(store &storage.Store, rows []StripRow) !int {152-	mut stmt := store.db.prepare('DELETE FROM save_files WHERE save_id = ? AND path = ?')!153-	defer {154-		stmt.finalize()155-	}156-	mut removed := 0157-	for row in rows {158-		stmt.bind_text(1, row.save_id)!159-		stmt.bind_text(2, row.path)!160-		stmt.step()!161-		removed += store.db.changes()162-		stmt.reset()163-	}164-	return removed165-}166-167-// like_under escapes LIKE's own wildcards so a path containing `%` or `_`168-// matches itself and nothing else.169-fn like_under(folder string) string {170-	escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')171-	return '${escaped}/%'172-}173-174104 struct PushOutcome {175105 mut:176106 	pushed  sync.PushReport@@ -178,34 +108,8 @@178108 	skipped bool179109 }180110 181-// touched_saves and touched_objects are what the remote needs to know about:182-// which save records to rewrite, and which objects may now be unreferenced.183-fn touched_saves(rows []StripRow) []string {184-	mut seen := map[string]bool{}185-	mut ids := []string{}186-	for row in rows {187-		if row.save_id !in seen {188-			seen[row.save_id] = true189-			ids << row.save_id190-		}191-	}192-	return ids193-}194-195-fn touched_objects(rows []StripRow) []string {196-	mut seen := map[string]bool{}197-	mut hashes := []string{}198-	for row in rows {199-		if row.hash !in seen {200-			seen[row.hash] = true201-			hashes << row.hash202-		}203-	}204-	return hashes205-}206-207111 // push_strip_to_remote tells this machine's remote about a strip.208-fn push_strip_to_remote(store &storage.Store, path string, save_ids []string, hashes []string, drop_objects bool) PushOutcome {112+fn push_strip_to_remote(store &storage.Store, path string) PushOutcome {209113 	if !machine.has_remote() {210114 		return PushOutcome{211115 			skipped: true@@ -214,7 +118,7 @@214118 	mut backend := this_machine_backend() or { return PushOutcome{215119 		failed: err.msg()216120 	} }217-	report := sync.push_strip(store, mut backend, path, save_ids, hashes, drop_objects) or {121+	report := sync.push_strip(store, mut backend, path) or {218122 		return PushOutcome{219123 			failed: err.msg()220124 		}

view file

src/events/events.v+5 −20
@@ -69,25 +69,9 @@6969 	return record7070 }7171 72-// write_once appends an event whose ID covers only what it says, not when it73-// was said. Re-running an interrupted strip therefore records the same fact74-// instead of a second one.75-pub fn write_once(store &storage.Store, event Event) !Event {76-	mut record := event77-	if record.created_at == 0 {78-		record.created_at = time.now().unix()79-	}80-	if record.machine == '' {81-		record.machine = machine.name()82-	}83-	record.id = storage.hash_hex('${record.kind}:${record.save_id}:${record.path}'.bytes())[..12]84-	insert(store, record)!85-	return record86-}87-8872 // receive stores an event exactly as another machine recorded it, ID and all.89-// Re-deriving the ID here would disagree with `write_once` for strips, and the90-// event would come back as new on every steal. Reports whether it was new.73+// An ID re-derived here that disagreed with the sender's would make the event74+// look new on every steal. Reports whether it was new.9175 pub fn receive(store &storage.Store, event Event) !bool {9276 	insert(store, event)!9377 	return store.db.changes() > 0@@ -141,9 +125,10 @@141125 }142126 143127 // strip records that a path was removed from a save (or from every save, when144-// save_id is empty).128+// save_id is empty). Each strip is its own event, even of a path stripped129+// before: a machine that applied the first must still hear about the second.145130 pub fn strip(store &storage.Store, save_id string, path string) !Event {146-	return write_once(store, Event{131+	return write(store, Event{147132 		kind:    kind_strip148133 		save_id: save_id149134 		path:    path

view file

src/sync/strip.v+240 −0
@@ -0,0 +1,240 @@1+//! Strip: removing a path from history, here and on the remote.2+//!3+//! The local half is one transaction: the saved files go, the objects nobody4+//! references go, a strip event is recorded, and so is a note of which objects5+//! the remote may now drop. The remote half reads that note, so it can be6+//! re-run after an interruption even though the local rows are long gone.7+8+module sync9+10+import json11+import os12+import src.events13+import src.remote14+import src.storage15+16+// Scope is what a strip would remove, for the warning shown before it runs.17+pub struct Scope {18+pub:19+	files   int20+	saves   int21+	objects int22+}23+24+pub struct LocalStrip {25+pub:26+	files_removed   int27+	objects_dropped i6428+}29+30+// PushReport says what a strip managed to make visible on the remote.31+pub struct PushReport {32+pub mut:33+	records    int34+	objects    int35+	event_sent bool36+}37+38+// Pending is the note a strip leaves until its remote half has finished: which39+// saves it covered ('' for every save) and which objects it freed. One note per40+// path, so strips of the same path that did not finish are finished together.41+struct Pending {42+mut:43+	saves  []string44+	hashes []string45+}46+47+fn pending_key(path string) string {48+	return 'strip_pending:${path}'49+}50+51+fn read_pending(store &storage.Store, path string) Pending {52+	text := store.state(pending_key(path)) or { return Pending{} }53+	return json.decode(Pending, text) or { Pending{} }54+}55+56+pub fn strip_pending(store &storage.Store, path string) bool {57+	return store.state(pending_key(path)) != none58+}59+60+// covers is the one rule for what a strip removes: the path itself, or61+// anything beneath it when it names a folder.62+fn covers(path string, file string) bool {63+	return file == path || file.starts_with('${path.trim_right('/')}/')64+}65+66+pub fn strip_scope(store &storage.Store, path string, only_save string) !Scope {67+	mut stmt := store.db.prepare('SELECT COUNT(*), COUNT(DISTINCT save_id), COUNT(DISTINCT hash)68+		FROM save_files WHERE ${match_clause(only_save)}')!69+	defer {70+		stmt.finalize()71+	}72+	bind_match(stmt, path, only_save)!73+	stmt.step()!74+	return Scope{75+		files:   int(stmt.int(0))76+		saves:   int(stmt.int(1))77+		objects: int(stmt.int(2))78+	}79+}80+81+// strip_local removes the path in one transaction and leaves the note the82+// remote half needs.83+pub fn strip_local(store &storage.Store, path string, only_save string) !LocalStrip {84+	store.begin()!85+	report := strip_rows(store, path, only_save) or {86+		store.rollback()87+		return err88+	}89+	store.commit()!90+	return report91+}92+93+fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {94+	mut hashes := []string{}95+	mut stmt :=96+		store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!97+	bind_match(stmt, path, only_save)!98+	for stmt.step()! {99+		hashes << stmt.text(0)100+	}101+	stmt.finalize()102+	if hashes.len == 0 {103+		// Already stripped: keep the pending note an unfinished remote needs.104+		return LocalStrip{}105+	}106+	removed := remove_path(store, path, only_save)!107+	dropped := store.drop_unreferenced_objects()!108+	events.strip(store, only_save, path)!109+	// An earlier strip of the same path may not have finished on the remote,110+	// so the note grows rather than resets.111+	mut note := read_pending(store, path)112+	if only_save !in note.saves {113+		note.saves << only_save114+	}115+	note.hashes << hashes116+	store.set_state(pending_key(path), json.encode(note))!117+	return LocalStrip{118+		files_removed:   removed119+		objects_dropped: dropped120+	}121+}122+123+// push_strip makes a strip visible on the remote: the event travels like any124+// other, every remote save record listing the path is rewritten, and objects125+// the strip freed are deleted once no remote record and no local save needs126+// them. Every step is safe to repeat, and the pending note is cleared only127+// when all of them have succeeded.128+pub fn push_strip(store &storage.Store, mut backend remote.Backend, path string) !PushReport {129+	name := repo_name(store, mut backend)!130+	mut report := PushReport{}131+	mut upload := OfferReport{132+		repo: name133+	}134+	upload_events(store, mut backend, name, mut upload)!135+	report.event_sent = upload.events > 0136+	note := read_pending(store, path)137+	mut freed := map[string]bool{}138+	for hash in note.hashes {139+		freed[hash] = true140+	}141+	every_save := '' in note.saves142+	mut needed := map[string]bool{}143+	for key in backend.list('${name}/saves')! {144+		remote_object := backend.get(key)!145+		if !remote_object.found {146+			continue147+		}148+		record := decode_save(remote_object.body)!149+		mut kept := []FileRecord{}150+		for file in record.files {151+			if (every_save || record.id in note.saves) && covers(path, file.path) {152+				freed[file.hash] = true153+				continue154+			}155+			kept << file156+			needed[file.hash] = true157+		}158+		if kept.len == record.files.len {159+			continue160+		}161+		if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {162+			return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')163+		}164+		report.records++165+	}166+	for hash, _ in freed {167+		if hash in needed || store.has_object(hash)! {168+			continue169+		}170+		backend.delete(object_key(name, hash))!171+		report.objects++172+	}173+	store.remove_state(pending_key(path))!174+	return report175+}176+177+// unapplied_strip reports whether the remote holds a strip event this repo178+// has not received yet.179+fn unapplied_strip(store &storage.Store, mut backend remote.Backend, repo string) !bool {180+	mut known := map[string]bool{}181+	for event in events.all(store)! {182+		known[event.id] = true183+	}184+	for key in backend.list(events_prefix(repo))! {185+		if os.base(key) in known {186+			continue187+		}188+		body := backend.get(key)!189+		if body.found && decode_event(body.body)!.kind == events.kind_strip {190+			return true191+		}192+	}193+	return false194+}195+196+// rewrite returns the save record with a new file list.197+fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {198+	return SaveRecord{199+		...record200+		files: files201+	}202+}203+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+215+fn remove_path(store &storage.Store, path string, only_save string) !int {216+	mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!217+	defer {218+		stmt.finalize()219+	}220+	bind_match(stmt, path, only_save)!221+	stmt.step()!222+	return store.db.changes()223+}224+225+// match_clause is `covers` in SQL. LIKE's own wildcards are escaped by226+// bind_match, so a path containing `%` or `_` matches itself and nothing else.227+fn match_clause(only_save string) string {228+	scope := if only_save == '' { '' } else { ' AND save_id = ?3' }229+	return '(path = ?1 OR path LIKE ?2 ESCAPE \'\\\')${scope}'230+}231+232+fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {233+	folder := path.trim_right('/')234+	escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')235+	stmt.bind_text(1, path)!236+	stmt.bind_text(2, '${escaped}/%')!237+	if only_save != '' {238+		stmt.bind_text(3, only_save)!239+	}240+}

view file

src/sync/sync.v+7 −108
@@ -59,6 +59,13 @@5959 		head: local.id6060 	}6161 	remote_head := backend.get(head_key(name))!62+	// Records offered by a machine that has not applied a strip would put the63+	// stripped path back on the remote, where nothing would rewrite it again.64+	// ponytail: an offer racing a strip on another machine inside the same few65+	// seconds can still slip through; closing that needs a remote lock.66+	if unapplied_strip(store, mut backend, name)! {67+		return error('the remote has a strip this repo has not applied — run koh steal')68+	}6269 	if remote_head.found {6370 		theirs := remote_head.body.bytestr().trim_space()6471 		newer := saves_after(store, local.id, theirs) or {@@ -236,112 +243,4 @@236243 		cursor = save.parent237244 	}238245 	return false239-}240-241-// PushReport says what a strip managed to make visible on the remote.242-pub struct PushReport {243-pub mut:244-	records    int245-	objects    int246-	event_sent bool247-}248-249-// push_strip makes a strip visible on the remote. The event itself travels like250-// any other event; on top of that, every remote save record that listed the251-// path is rewritten in place, and — when the strip covered the whole local252-// history — the objects nobody references any more are deleted.253-//254-// The caller passes what it stripped, because the local rows are already gone255-// by the time the remote hears about it. Every step is safe to repeat, so an256-// interrupted strip finishes on the next run.257-pub fn push_strip(store &storage.Store, mut backend remote.Backend, path string, save_ids []string, object_hashes []string, drop_objects bool) !PushReport {258-	name := repo_name(store, mut backend)!259-	mut report := PushReport{}260-	mut upload := OfferReport{261-		repo: name262-	}263-	upload_events(store, mut backend, name, mut upload)!264-	report.event_sent = upload.events > 0265-266-	for save_id in save_ids {267-		remote_object := backend.get(save_key(name, save_id))!268-		if !remote_object.found {269-			continue270-		}271-		record := decode_save(remote_object.body)!272-		mut kept := []FileRecord{}273-		for file in record.files {274-			if file.path == path || file.path.starts_with('${path.trim_right('/')}/') {275-				continue276-			}277-			kept << file278-		}279-		if kept.len == record.files.len {280-			continue281-		}282-		if !backend.put_if_match(save_key(name, save_id),283-			encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {284-			return error('the remote copy of save ${save_id} moved while stripping — run koh steal, then strip again')285-		}286-		report.records++287-	}288-	if drop_objects {289-		for hash in object_hashes {290-			// Only objects no local save refers to any more: with `--save`, the291-			// other saves still need them.292-			if !store.has_object(hash)! {293-				backend.delete(object_key(name, hash))!294-				report.objects++295-			}296-		}297-	}298-	return report299-}300-301-// rewrite returns the save record with a new file list.302-fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {303-	return SaveRecord{304-		id:            record.id305-		parent:        record.parent306-		message:       record.message307-		created_at:    record.created_at308-		machine:       record.machine309-		is_checkpoint: record.is_checkpoint310-		size_bytes:    record.size_bytes311-		agent:         record.agent312-		session:       record.session313-		restores:      record.restores314-		replayed_from: record.replayed_from315-		files:         files316-	}317-}318-319-// apply_strip is what a machine does when it hears about somebody else's strip:320-// the same removal, so both histories agree from then on. It returns how many321-// saved files it removed.322-pub fn apply_strip(store &storage.Store, event events.Event) !int {323-	mut query := "DELETE FROM save_files WHERE (path = ? OR path LIKE ? ESCAPE '\\')"324-	if event.save_id != '' {325-		query += ' AND save_id = ?'326-	}327-	mut stmt := store.db.prepare(query)!328-	stmt.bind_text(1, event.path)!329-	stmt.bind_text(2, like_under(event.path))!330-	if event.save_id != '' {331-		stmt.bind_text(3, event.save_id)!332-	}333-	stmt.step()!334-	removed := store.db.changes()335-	stmt.finalize()336-	if removed > 0 {337-		store.drop_unreferenced_objects()!338-	}339-	return removed340-}341-342-// like_under escapes LIKE's own wildcards so a path containing `%` or `_`343-// matches itself and nothing else.344-fn like_under(folder string) string {345-	escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')346-	return '${escaped}/%'347246 }

view file

src/sync/sync_test.v+205 −18
@@ -422,6 +422,8 @@422422 	reads      int423423 	fail_write int // fail when the write counter reaches this, 0 never424424 	fail_read  int425+	// fail every delete while set426+	fail_delete bool425427 }426428 427429 fn (mut f FaultyBackend) get(key string) !remote.Object {@@ -453,6 +455,9 @@453455 }454456 455457 fn (mut f FaultyBackend) delete(key string) ! {458+	if f.fail_delete {459+		return error('injected delete failure')460+	}456461 	f.inner.delete(key)!457462 }458463 @@ -546,16 +551,8 @@546551 	put(a.root, 'keep.txt', 'fine\n')!547552 	save := commit(a, 'add both')!548553 549-	// What `koh strip` does locally, twice over.550554 	for _ in 0 .. 2 {551-		a.begin()!552-		mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!553-		stmt.bind_text(1, 'secret.txt')!554-		stmt.step()!555-		stmt.finalize()556-		a.drop_unreferenced_objects()!557-		events.strip(a, '', 'secret.txt')!558-		a.commit()!555+		strip_local(a, 'secret.txt', '')!559556 	}560557 	files := history.files_of(a, save.id)!561558 	// The repo's own `.kohignore` is tracked content, so it stays; only the@@ -594,15 +591,8 @@594591 		}595592 	}596593 	assert secret_hash != ''597-	a.begin()!598-	mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!599-	stmt.bind_text(1, 'secrets.env')!600-	stmt.step()!601-	stmt.finalize()602-	a.drop_unreferenced_objects()!603-	events.strip(a, '', 'secrets.env')!604-	a.commit()!605-	report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!594+	strip_local(a, 'secrets.env', '')!595+	report := push_strip(a, mut backend, 'secrets.env')!606596 	assert report.event_sent607597 	assert report.records == 1608598 @@ -641,4 +631,201 @@641631 	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!642632 	b.commit()!643633 	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)634+}635+636+// The local half commits before the remote hears anything, so a remote failure637+// leaves nothing to find locally; the pending note is what lets a re-run638+// finish. Objects a remote-only save still needs must survive the strip.639+fn test_an_interrupted_remote_strip_finishes_when_re_run() {640+	base := scratch('strip_resume')!641+	defer {642+		os.rmdir_all(base) or {}643+	}644+	remote_dir := os.join_path(base, 'remote')645+	mut backend := remote.new_folder(remote_dir)!646+	mut a := open_repo(machine('resume_a')!)!647+	defer {648+		a.close()649+	}650+	mut b := open_repo(machine('resume_b')!)!651+	defer {652+		b.close()653+	}654+	put(a.root, 'secrets.env', 'api_key=1\n')!655+	put(a.root, 'shared.env', 'same bytes on B\n')!656+	save := commit(a, 'add secrets')!657+	offer_now(a, mut backend)!658+	join(b, mut backend)!659+	put(b.root, 'copy.txt', 'same bytes on B\n')!660+	b_save := commit(b, 'B keeps a copy')!661+	offer_now(b, mut backend)!662+	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())663+	secret_hash := storage.hash_hex('api_key=1\n'.bytes())664+665+	strip_local(a, 'secrets.env', '')!666+	// Write 1 uploads the event and write 2 rewrites the first record; B's save,667+	// made from A's tree, lists the file too, and its rewrite (write 3) fails.668+	mut flaky := FaultyBackend{669+		inner:      remote.new_folder(remote_dir)!670+		fail_write: 3671+	}672+	if _ := push_strip(a, mut flaky, 'secrets.env') {673+		assert false, 'the injected failure should have surfaced'674+	}675+	assert strip_pending(a, 'secrets.env')676+	assert strip_local(a, 'secrets.env', '')!.files_removed == 0677+	report := push_strip(a, mut backend, 'secrets.env')!678+	assert report.records == 1679+	assert !strip_pending(a, 'secrets.env')680+	assert !backend.get(object_key('proj', secret_hash))!.found681+	record := decode_save(backend.get(save_key('proj', save.id))!.body)!682+	assert 'secrets.env' !in record.files.map(it.path)683+	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!684+	assert 'secrets.env' !in b_record.files.map(it.path)685+686+	// B's save still names the shared bytes, so stripping A's copy keeps them.687+	strip_local(a, 'shared.env', '')!688+	push_strip(a, mut backend, 'shared.env')!689+	assert backend.get(object_key('proj', shared_hash))!.found690+}691+692+// A machine that has not heard of a strip must not offer: its records still693+// list the stripped path, and nothing would rewrite them on the remote again.694+fn test_offer_waits_for_an_unapplied_strip() {695+	base := scratch('strip_first')!696+	defer {697+		os.rmdir_all(base) or {}698+	}699+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!700+	mut a := open_repo(machine('strip_first_a')!)!701+	defer {702+		a.close()703+	}704+	mut b := open_repo(machine('strip_first_b')!)!705+	defer {706+		b.close()707+	}708+	put(a.root, 'secrets.env', 'api_key=1\n')!709+	commit(a, 'add secrets')!710+	offer_now(a, mut backend)!711+	join(b, mut backend)!712+	strip_local(a, 'secrets.env', '')!713+	push_strip(a, mut backend, 'secrets.env')!714+	put(b.root, 'more.txt', 'B keeps working\n')!715+	later := commit(b, 'B saves with the secret still in its tree')!716+	if _ := offer_now(b, mut backend) {717+		assert false, 'B offered records that still list the stripped path'718+	}719+	steal_now(b, mut backend)!720+	offer_now(b, mut backend)!721+	record := decode_save(backend.get(save_key('proj', later.id))!.body)!722+	assert 'secrets.env' !in record.files.map(it.path)723+}724+725+// A strip whose object deletions failed, followed by a second strip of the same726+// path, must still delete the first strip's objects when it finishes.727+fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {728+	base := scratch('strip_twice')!729+	defer {730+		os.rmdir_all(base) or {}731+	}732+	remote_dir := os.join_path(base, 'remote')733+	mut backend := remote.new_folder(remote_dir)!734+	mut a := open_repo(machine('strip_twice_a')!)!735+	defer {736+		a.close()737+	}738+	put(a.root, 'secrets.env', 'first key\n')!739+	commit(a, 'first key')!740+	offer_now(a, mut backend)!741+	strip_local(a, 'secrets.env', '')!742+	mut flaky := FaultyBackend{743+		inner:       remote.new_folder(remote_dir)!744+		fail_delete: true745+	}746+	if _ := push_strip(a, mut flaky, 'secrets.env') {747+		assert false, 'the injected delete failure should have surfaced'748+	}749+	put(a.root, 'secrets.env', 'second key\n')!750+	commit(a, 'second key')!751+	offer_now(a, mut backend)!752+	strip_local(a, 'secrets.env', '')!753+	push_strip(a, mut backend, 'secrets.env')!754+	for key in ['first key\n', 'second key\n'] {755+		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found756+	}757+}758+759+// Unfinished strips of one path in different scopes finish together.760+fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {761+	base := scratch('strip_scopes')!762+	defer {763+		os.rmdir_all(base) or {}764+	}765+	remote_dir := os.join_path(base, 'remote')766+	mut backend := remote.new_folder(remote_dir)!767+	mut a := open_repo(machine('strip_scopes_a')!)!768+	defer {769+		a.close()770+	}771+	put(a.root, 'secrets.env', 'first key\n')!772+	first := commit(a, 'first key')!773+	put(a.root, 'secrets.env', 'second key\n')!774+	second := commit(a, 'second key')!775+	offer_now(a, mut backend)!776+	strip_local(a, 'secrets.env', first.id)!777+	// Write 1 uploads the event; write 2, the record rewrite, fails.778+	mut flaky := FaultyBackend{779+		inner:      remote.new_folder(remote_dir)!780+		fail_write: 2781+	}782+	if _ := push_strip(a, mut flaky, 'secrets.env') {783+		assert false, 'the injected failure should have surfaced'784+	}785+	strip_local(a, 'secrets.env', second.id)!786+	push_strip(a, mut backend, 'secrets.env')!787+	assert !strip_pending(a, 'secrets.env')788+	for save in [first, second] {789+		record := decode_save(backend.get(save_key('proj', save.id))!.body)!790+		assert 'secrets.env' !in record.files.map(it.path)791+	}792+	for key in ['first key\n', 'second key\n'] {793+		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found794+	}795+}796+797+// Stripping a path, re-adding it, and stripping it again is two strips; a798+// machine that heard about the first must still apply the second.799+fn test_a_second_strip_of_the_same_path_reaches_other_machines() {800+	base := scratch('strip_again')!801+	defer {802+		os.rmdir_all(base) or {}803+	}804+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!805+	mut a := open_repo(machine('strip_again_a')!)!806+	defer {807+		a.close()808+	}809+	mut b := open_repo(machine('strip_again_b')!)!810+	defer {811+		b.close()812+	}813+	put(a.root, 'secrets.env', 'first key\n')!814+	commit(a, 'first key')!815+	offer_now(a, mut backend)!816+	join(b, mut backend)!817+	strip_local(a, 'secrets.env', '')!818+	push_strip(a, mut backend, 'secrets.env')!819+	steal_now(b, mut backend)!820+	// Strip leaves the file on disk; deleting it is the user's part.821+	os.rm(os.join_path(b.root, 'secrets.env'))!822+	put(a.root, 'secrets.env', 'second key\n')!823+	second := commit(a, 'second key')!824+	offer_now(a, mut backend)!825+	steal_now(b, mut backend)!826+	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)827+	strip_local(a, 'secrets.env', '')!828+	push_strip(a, mut backend, 'secrets.env')!829+	steal_now(b, mut backend)!830+	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)644831 }

view file

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

view file