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
↓ koh-69c3ac67.tar.gzatlas at this save
files changed7 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 −1src/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 }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: pathsrc/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+}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 }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 }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 }