9519d1769dd2
resolve the ten 3.8.5 Doctor safety findings with permanent regressions (3.8.6)
- time
- 2026-10-07 16:16 UTC · 5 days ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- resolve doctor safety findings
- parent
- 2b16b6628a15
↓ koh-9519d176.tar.gzatlas at this save
files changed13 files
VERSION+1 −1src/cli/cmd_doctor.v+11 −4src/cli/cmd_doctor_test.v+142 −0src/cli/doctor_checks.v+5 −2src/cli/doctor_repair.v+42 −4src/events/events.v+6 −0src/exchange/exchange.v+12 −6src/exchange/exchange_test.v+65 −0src/exchange/repair.v+35 −20src/exchange/strip.v+34 −14src/storage/objects.v+19 −0tests/cli_test.v+19 −0v.mod+1 −1src/cli/cmd_doctor.v+11 −4
@@ -45,7 +45,9 @@4545 report.repairs = repairs.map(it.action)4646 mut repaired := []string{}4747 mut repair_errors := []string{}48- if repairs.len > 0 && (yes || (interactive() && !args.json())) {48+ mut attempted := false49+ // A requested backup that failed leaves nothing to fall back on.50+ if repairs.len > 0 && backup_error == '' && (yes || (interactive() && !args.json())) {4951 if !yes {5052 print_doctor(report, false)5153 println('')@@ -57,13 +59,18 @@5759 if !(confirm(yes) or { false }) {5860 continue5961 }62+ attempted = true6063 repair.run() or {6164 repair_errors << '${repair.action}: ${err.msg()}'65+ if err is PartialRepair {66+ repaired << err.done67+ }6268 continue6369 }6470 repaired << repair.action6571 }66- if repaired.len > 0 {72+ // A repair that failed may still have written part of its work.73+ if attempted {6774 report = diagnose(store, fast)6875 report.repairs = plan_repairs(store, report).map(it.action)6976 }@@ -83,7 +90,7 @@8390 } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }8491 if args.json() {8592 emit_json(json2.encode(report))86- } else if repairs.len == 0 || yes || repaired.len > 0 || !interactive() {93+ } else if repairs.len == 0 || yes || attempted || !interactive() {8794 print_doctor(report, true)8895 }8996 return if report.problems.len > 0 { 1 } else { 0 }@@ -164,7 +171,7 @@164171 'koh doctor fetches missing saves from the remote; a cycle needs a backup or the remote copy'165172 }166173 'saved paths and events' {167- 'run the koh strip command shown for an unsafe path, or strip one side of a file/folder collision; koh doctor re-applies recorded strips; a bad event needs a backup or the remote copy'174+ 'run the koh strip command shown for an unsafe path, or strip one side of a file/folder collision; koh doctor re-applies recorded strips; a bad event or unfinished strip note needs a backup or the remote copy'168175 }169176 'restore state' {170177 'run koh revert; it keeps the current files in a checkpoint first'src/cli/cmd_doctor_test.v+142 −0
@@ -12,6 +12,10 @@1212 import src.snapshot1313 import src.storage1414 15+fn testsuite_begin() {16+ os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)17+}18+1519 fn test_doctor_checks_cycles_and_missing_heads_without_walking_forever() {1620 saves := [history.Save{ id: 'aaaaaaaaaaaa', parent: 'bbbbbbbbbbbb' },1721 history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' }]@@ -275,4 +279,142 @@275279 assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2276280 assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data277281 assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash282+}283+284+// An invalid strip event authorizes nothing, and a strip frees only the objects285+// it removed: an unrelated unused object may be needed for recovery.286+fn test_doctor_strip_repair_ignores_invalid_events_and_keeps_unrelated_objects() {287+ store, dir := doctor_store('strip_safety')!288+ defer {289+ store.close()290+ os.rmdir_all(dir) or {}291+ }292+ app := store.put_object('app'.bytes())!293+ secret := store.put_object('secret'.bytes())!294+ orphan := store.put_object('orphan'.bytes())!295+ store.begin()!296+ history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [297+ snapshot.FileEntry{ path: 'app', hash: app },298+ snapshot.FileEntry{ path: 'secret', hash: secret },299+ ])!300+ events.receive(store, events.Event{301+ id: 'invalidstrip'302+ kind: events.kind_strip303+ path: 'app'304+ created_at: -1305+ machine: 'test'306+ })!307+ events.strip(store, '', 'secret')!308+ store.end()!309+ mut report := DoctorJson{}310+ doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: 'app' }], events.all(store)!, mut311+ report)312+ assert !report.strip_residue313+ assert exchange.reapply_strips(store)! == 1314+ assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']315+ assert store.has_object(app)!316+ assert store.has_object(orphan)!317+ assert !store.has_object(secret)!318+}319+320+// With a remote, only a full audit that found no other head there lets Doctor321+// guess, and the guess is checked again when it is written.322+fn test_doctor_guesses_head_only_when_nothing_knows_better() {323+ store, dir := doctor_store('only_tip')!324+ defer {325+ store.close()326+ os.rmdir_all(dir) or {}327+ }328+ store.begin()!329+ history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!330+ store.end()!331+ mut report := DoctorJson{332+ metadata_available: true333+ saves: 1334+ source: 'folder/repo'335+ remote_head: 'bbbbbbbbbbbb'336+ }337+ assert !plan_repairs(store, report).any(it.action.contains('point head'))338+ report.remote_head = ''339+ report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}340+ assert !plan_repairs(store, report).any(it.action.contains('point head'))341+ report.checks = []342+ repairs := plan_repairs(store, report).filter(it.action.contains('point head'))343+ assert repairs.len == 1344+ history.set_head(store, 'cccccccccccc')!345+ if _ := repairs[0].run() {346+ assert false, 'a head another command set must not be replaced'347+ } else {348+ assert err.msg().contains('changed'), err.msg()349+ }350+ assert store.state(storage.head_key) or { '' } == 'cccccccccccc'351+ store.remove_state(storage.head_key)!352+ repairs[0].run()!353+ assert store.state(storage.head_key) or { '' } == 'aaaaaaaaaaaa'354+}355+356+fn test_doctor_reports_objects_it_restored_before_one_it_could_not() {357+ store, dir := doctor_store('partial')!358+ defer {359+ store.close()360+ os.rmdir_all(dir) or {}361+ }362+ kept := store.put_object('kept'.bytes())!363+ lost := store.put_object('lost'.bytes())!364+ store.begin()!365+ history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [366+ snapshot.FileEntry{ path: 'kept', hash: kept },367+ snapshot.FileEntry{ path: 'lost', hash: lost },368+ ])!369+ store.end()!370+ store.db.exec("UPDATE objects SET data = x'00'")!371+ os.write_file(os.join_path(dir, 'kept'), 'kept')!372+ if _ := repair_objects(store, [kept, lost], '', '') {373+ assert false, 'an object with no verified copy must fail its repair'374+ } else {375+ assert err is PartialRepair376+ assert (err as PartialRepair).done.contains('1 damaged or missing object')377+ assert err.msg().contains(lost)378+ }379+ assert store.get_object(kept)! == 'kept'.bytes()380+}381+382+fn test_doctor_refuses_a_remote_save_with_invalid_metadata() {383+ store, dir := doctor_store('invalid_fetch')!384+ defer {385+ store.close()386+ os.rmdir_all(dir) or {}387+ }388+ mut backend := DoctorReadOnly{389+ items: {390+ 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{391+ id: 'aaaaaaaaaaaa'392+ created_at: -1393+ }).bytes()394+ }395+ }396+ if _ := exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa']) {397+ assert false, 'a record with invalid metadata must not be imported'398+ } else {399+ assert err.msg().contains('invalid metadata')400+ }401+ assert history.all_saves(store)!.len == 0402+}403+404+// An empty file's Blake3 is the hash of empty bytes, so bytes that fail to405+// decompress must not pass for it.406+fn test_doctor_replaces_a_damaged_remote_copy_of_an_empty_file() {407+ store, dir := doctor_store('empty_object')!408+ defer {409+ store.close()410+ os.rmdir_all(dir) or {}411+ }412+ hash := store.put_object([]u8{})!413+ mut backend := DoctorMemory{414+ items: {415+ 'repo/objects/${hash}': 'not zlib'.bytes()416+ }417+ }418+ assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1419+ assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0278420 }src/cli/doctor_checks.v+5 −2
@@ -3,6 +3,7 @@33 44 import os55 import src.build6+import src.exchange67 import src.fmt78 import src.events89 import src.history@@ -81,8 +82,7 @@8182 parent = os.dir(parent)8283 }8384 for event in event_list {84- if event.kind == events.kind_strip && event.path != ''85- && (event.save_id == '' || event.save_id == id)85+ if events.valid_strip(event) && (event.save_id == '' || event.save_id == id)8686 && (file.path == event.path || file.path.starts_with('${event.path.trim_right('/')}/')) {8787 report.problems << 'save ${id} still contains stripped path ${snapshot.escape_path(file.path)}'8888 report.strip_residue = true@@ -169,6 +169,9 @@169169 if save.replayed_from != '' && save.replayed_from !in known {170170 report.warnings << 'save ${save.id} was replayed from unavailable save ${save.replayed_from}; nothing to do'171171 }172+ }173+ for path in stats.pending {174+ exchange.read_pending(store, path) or { report.problems << err.msg() }172175 }173176 report.check('saved paths and events', report.problems[before..].clone())174177 if report.restore_unfinished {src/cli/doctor_repair.v+42 −4
@@ -41,9 +41,19 @@4141 exchange.fetch_missing(store, mut backend, repo, ids)!4242 }}4343 } else if report.metadata_available && report.saves > 0 && report.head.id == '' {44- if tip := only_tip(store) {45- repairs << DoctorRepair{'point head at [${tip}], the only save nothing follows', fn [store, tip] () ! {46- history.set_head(store, tip)!44+ // A remote may hold a newer head; only a full audit that found none45+ // other there lets Doctor guess.46+ audited := !report.checks.any(it.name == 'remote audit')47+ tip := only_tip(store) or { '' }48+ seen := report.head_id49+ if tip != '' && (!has_remote || (audited && report.remote_head in ['', tip])) {50+ repairs << DoctorRepair{'point head at [${tip}], the only save nothing follows', fn [store, tip, seen] () ! {51+ store.begin()!52+ set_only_tip(store, tip, seen) or {53+ store.rollback()54+ return err55+ }56+ store.end()!4757 }}4858 }4959 }@@ -90,6 +100,18 @@90100 return repairs91101 }92102 103+// PartialRepair is a repair that wrote some of its changes before it failed;104+// Doctor reports both halves.105+struct PartialRepair {106+ Error107+ done string108+ reason string109+}110+111+fn (e PartialRepair) msg() string {112+ return e.reason113+}114+93115 // Each copy must hash back to its key before it replaces anything: first the94116 // stored bytes (only the size was wrong), then working files, then the remote.95117 fn repair_objects(store &storage.Store, hashes []string, source string, remote_repo string) ! {@@ -113,7 +135,14 @@113135 store.repair_object(hash, data)!114136 }115137 if lost.len > 0 {116- return error('no verified copy here or on the remote of ${lost.join(', ')}')138+ reason := 'no verified copy here or on the remote of ${lost.join(', ')}'139+ if lost.len < hashes.len {140+ return PartialRepair{141+ done: 'restore ${fmt.plural(hashes.len - lost.len, 'damaged or missing object')} from verified copies'142+ reason: reason143+ }144+ }145+ return error(reason)117146 }118147 }119148 @@ -148,6 +177,15 @@148177 }149178 tips := saves.filter(it.id !in parents)150179 return if tips.len == 1 { tips[0].id } else { none }180+}181+182+// Another koh command may have saved while Doctor asked; the tip seen then183+// is the head only if nothing has changed since.184+fn set_only_tip(store &storage.Store, tip string, seen string) ! {185+ if (store.state(storage.head_key) or { '' }) != seen || (only_tip(store) or { '' }) != tip {186+ return error('history changed since Doctor checked it; run koh doctor again')187+ }188+ history.set_head(store, tip)!151189 }152190 153191 // The same steps as koh revert, without its output, which would break --json.src/events/events.v+6 −0
@@ -137,6 +137,12 @@137137 })!138138 }139139 140+// valid_strip is a strip event Doctor may act on. Doctor reports an invalid one141+// and leaves the saves alone (spec §7.6.2).142+pub fn valid_strip(event Event) bool {143+ return event.kind == kind_strip && event.path != '' && event.created_at >= 0144+}145+140146 // event_id hashes each field with its length, so two different facts cannot spell the same input.141147 pub fn event_id(event Event) string {142148 mut sb := strings.Builder{}src/exchange/exchange.v+12 −6
@@ -194,16 +194,22 @@194194 objects map[string]bool195195 }196196 197-// take_back deletes the records an offer put, which head never reached, and198-// every object it put that a strip reaches in one of them.197+// take_back deletes the records an offer or Doctor's resend put, which head198+// never reached, and every object it put that a strip reaches in a save here.199199 fn take_back(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, strips []events.Event) ! {200- for id in uploaded.saves {201- for file in history.files_of(store, id)! {202- if file.hash in uploaded.objects && strips.any(reaches(it, id) && covers(it.path, file.path)) {203- backend.delete(object_key(repo, file.hash))!200+ mut reached := map[string]bool{}201+ for save in history.all_saves(store)! {202+ for file in history.files_of(store, save.id)! {203+ if file.hash in uploaded.objects && strips.any(reaches(it, save.id) && covers(it.path, file.path)) {204+ reached[file.hash] = true204205 }205206 }207+ }208+ for id in uploaded.saves {206209 backend.delete(save_key(repo, id))!210+ }211+ for hash, _ in reached {212+ backend.delete(object_key(repo, hash))!207213 }208214 }209215 src/exchange/exchange_test.v+65 −0
@@ -1189,6 +1189,71 @@11891189 }11901190 }11911191 1192+// Doctor's resend of a missing record can race a strip just as an offer can,1193+// and takes back what the strip reaches in the same way.1194+fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1195+ base := scratch('resend_race')!1196+ defer {1197+ os.rmdir_all(base) or {}1198+ }1199+ mut backend := remote.new_folder(os.join_path(base, 'remote'))!1200+ mut a := open_repo(machine('resend_race_a')!)!1201+ defer {1202+ a.close()1203+ }1204+ mut b := open_repo(machine('resend_race_b')!)!1205+ defer {1206+ b.close()1207+ }1208+ put(a.root, 'secrets.env', 'api_key=1\n')!1209+ first := make_save(a, 'leak the key')!1210+ offer_now(a, mut backend)!1211+ join(b, mut backend)!1212+ backend.delete(save_key('proj', first.id))!1213+ mut race := StripRace{1214+ inner: backend1215+ other: &b1216+ path: 'secrets.env'1217+ }1218+ if _ := resend(a, mut race, 'proj', [], [first.id]) {1219+ assert false, 'a resend that raced a strip must refuse'1220+ } else {1221+ assert err.msg().contains('koh steal'), err.msg()1222+ }1223+ assert !backend.get(save_key('proj', first.id))!.found1224+ assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1225+}1226+1227+// A note that does not parse, or that no recorded strip of its path backs, is1228+// kept and changes nothing remote: sending it would lose the retry or strip1229+// where no strip asked.1230+fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1231+ base := scratch('strip_note')!1232+ defer {1233+ os.rmdir_all(base) or {}1234+ }1235+ mut backend := remote.new_folder(os.join_path(base, 'remote'))!1236+ mut a := open_repo(machine('strip_note_a')!)!1237+ defer {1238+ a.close()1239+ }1240+ put(a.root, 'app', 'app\n')!1241+ save := make_save(a, 'app')!1242+ offer_now(a, mut backend)!1243+ events.strip(a, '', 'other')!1244+ for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1245+ a.set_state(pending_key('app'), note)!1246+ if _ := send_strip(a, mut backend, 'app') {1247+ assert false, 'an untrusted note must be refused'1248+ } else {1249+ assert err.msg().contains('kept for recovery'), err.msg()1250+ }1251+ assert a.state(pending_key('app')) or { '' } == note1252+ assert backend.list(events_prefix('proj'))!.len == 01253+ assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1254+ }1255+}1256+11921257 // The local half lands before the remote hears anything, so a remote failure11931258 // leaves nothing to find locally; the pending note is what lets a re-run11941259 // finish. Objects a remote-only save still needs must survive the strip.src/exchange/repair.v+35 −20
@@ -31,6 +31,11 @@3131 if record.id != id {3232 return error('remote save ${id} holds the record of ${record.id}')3333 }34+ // The same scalar rules Doctor checks locally: an invalid record is35+ // refused here, not imported for Doctor to report afterwards.36+ if record.created_at < 0 || record.size_bytes < 0 {37+ return error('remote save ${id} has invalid metadata')38+ }3439 for file in record.files {3540 if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects {3641 blob := backend.get(object_key(repo, file.hash))!@@ -77,21 +82,23 @@7782 if unapplied_strips(store, mut backend, repo)!.len > 0 {7883 return error(strip_not_applied)7984 }80- mut sent := 085+ mut uploaded := Uploaded{}8186 for hash in hashes {8287 key := object_key(repo, hash)8388 existing := backend.get(key)!8489 if existing.found {85- data := zlib.decompress(existing.body) or { []u8{} }86- // Another machine may have put it right since the audit.87- if storage.hash_hex(data) == hash {88- continue90+ // Another machine may have put it right since the audit. Bytes that do91+ // not decompress are damaged, even where empty bytes would hash right.92+ if data := zlib.decompress(existing.body) {93+ if storage.hash_hex(data) == hash {94+ continue95+ }8996 }9097 backend.delete(key)!9198 }9299 store.get_object(hash)!93100 if backend.put_if_absent(key, store.object_blob(hash)!)! {94- sent++101+ uploaded.objects[hash] = true95102 }96103 }97104 for id in save_ids {@@ -99,34 +106,42 @@99106 for file in history.files_of(store, id)! {100107 store.get_object(file.hash)!101108 if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {102- sent++109+ uploaded.objects[file.hash] = true103110 }104111 }105112 if backend.put_if_absent(save_key(repo, id), encode_save(save_record(store, save)!).bytes())! {106- sent++113+ uploaded.saves << id107114 }108115 }109- return sent116+ // As in offer: a strip that landed during the uploads may have missed them.117+ late := unapplied_strips(store, mut backend, repo)!118+ if late.len > 0 {119+ take_back(store, mut backend, repo, uploaded, late)!120+ return error(strip_not_applied)121+ }122+ return uploaded.objects.len + uploaded.saves.len110123 }111124 112125 /// Remove what recorded strips cover but a save still holds, without113126 /// recording a new strip.114127 pub fn reapply_strips(store &storage.Store) !int {115128 store.begin()!116- mut removed := 0117- for event in events.strip_events(store) or {129+ removed := reapply_valid_strips(store) or {118130 store.rollback()119131 return err120- } {121- removed += remove_path(store, event.path, event.save_id) or {122- store.rollback()123- return err124- }125132 }126- store.drop_unreferenced_objects() or {127- store.rollback()128- return err129- }130133 store.end()!134+ return removed135+}136+137+// An invalid strip event is reported, never applied, and only the objects a138+// strip frees go with it.139+fn reapply_valid_strips(store &storage.Store) !int {140+ mut removed := 0141+ for event in events.strip_events(store)!.filter(events.valid_strip(it)) {142+ hashes := stripped_hashes(store, event.path, event.save_id)!143+ removed += remove_path(store, event.path, event.save_id)!144+ store.drop_unused(hashes)!145+ }131146 return removed132147 }src/exchange/strip.v+34 −14
@@ -38,7 +38,7 @@3838 // Pending is the note a strip leaves until its remote half has finished: which3939 // saves it covered ('' for every save) and which objects it freed. One note per4040 // path, so strips of the same path that did not finish are finished together.41-struct Pending {41+pub struct Pending {4242 mut:4343 saves []string4444 hashes []string@@ -48,9 +48,21 @@4848 return 'strip_pending:${path}'4949 }5050 51-fn read_pending(store &storage.Store, path string) Pending {51+// read_pending refuses a note it cannot parse, or one claiming a scope no52+// recorded strip of the path has: sending it would lose the retry or remove53+// the path where no strip asked. The note stays for recovery either way.54+pub fn read_pending(store &storage.Store, path string) !Pending {5255 text := store.state(pending_key(path)) or { return Pending{} }53- return json2.decode[Pending](text) or { Pending{} }56+ note := json2.decode[Pending](text) or {57+ return error('the unfinished strip note for ${path} is unreadable; it is kept for recovery')58+ }59+ recorded := events.strip_events(store)!.filter(events.valid_strip(it) && it.path == path)60+ if note.saves.len == 0 || !note.saves.all(fn [recorded] (scope string) bool {61+ return recorded.any(it.save_id == scope)62+ }) {63+ return error('no recorded strip matches the unfinished strip note for ${path}; it is kept for recovery')64+ }65+ return note5466 }5567 5668 // strip_pending is true while the remote half of a strip has not finished.@@ -113,24 +125,17 @@113125 // strip_rows is the body of strip_local's transaction. Its event has no save id for a whole-history114126 // strip (spec §10.2), and that empty id is what bans the path from every later walk.115127 fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {116- mut hashes := []string{}117- mut stmt :=118- store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!119- bind_match(stmt, path, only_save)!120- for stmt.step()! {121- hashes << stmt.text(0)122- }123- stmt.finalize()128+ hashes := stripped_hashes(store, path, only_save)!124129 if hashes.len == 0 {125130 // Already stripped: keep the pending note an unfinished remote needs.126131 return LocalStrip{}127132 }128133 removed := remove_path(store, path, only_save)!129- dropped := store.drop_unreferenced_objects()!134+ dropped := store.drop_unused(hashes)!130135 events.strip(store, only_save, path)!131136 // An earlier strip of the same path may not have finished on the remote,132137 // so the note grows rather than resets.133- mut note := read_pending(store, path)138+ mut note := read_pending(store, path)!134139 if only_save !in note.saves {135140 note.saves << only_save136141 }@@ -148,6 +153,7 @@148153 // them. Every step is safe to repeat, and the pending note is cleared only149154 // when all of them have succeeded.150155 pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {156+ note := read_pending(store, path)!151157 name := repo_name(store, mut backend)!152158 mut report := SendReport{}153159 mut upload := OfferReport{@@ -155,7 +161,6 @@155161 }156162 upload_events(store, mut backend, name, mut upload)!157163 report.event_sent = upload.events > 0158- note := read_pending(store, path)159164 mut freed := map[string]bool{}160165 for hash in note.hashes {161166 freed[hash] = true@@ -227,6 +232,21 @@227232 ...record228233 files: files229234 }235+}236+237+// stripped_hashes is the content of the rows remove_path will delete.238+fn stripped_hashes(store &storage.Store, path string, only_save string) ![]string {239+ mut stmt :=240+ store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!241+ defer {242+ stmt.finalize()243+ }244+ bind_match(stmt, path, only_save)!245+ mut hashes := []string{}246+ for stmt.step()! {247+ hashes << stmt.text(0)248+ }249+ return hashes230250 }231251 232252 // remove_path deletes the rows and counts them; the objects go afterwards, once nothing references them.src/storage/objects.v+19 −0
@@ -134,6 +134,25 @@134134 return before - after135135 }136136 137+// drop_unused removes those of `hashes` no save points at any more. Other138+// unused objects stay: with damaged rows one may be the only copy of saved139+// content, and Doctor offers their removal separately.140+pub fn (s &Store) drop_unused(hashes []string) !i64 {141+ mut stmt := s.db.prepare('DELETE FROM objects WHERE hash = ?1142+ AND NOT EXISTS (SELECT 1 FROM save_files WHERE hash = ?1)')!143+ defer {144+ stmt.finalize()145+ }146+ mut dropped := i64(0)147+ for hash in hashes {148+ stmt.reset()149+ stmt.bind_text(1, hash)!150+ stmt.step()!151+ dropped += s.db.changes()152+ }153+ return dropped154+}155+137156 /// An object Doctor found damaged or missing.138157 pub struct ObjectProblem {139158 pub:tests/cli_test.v+19 −0
@@ -856,6 +856,25 @@856856 os.rmdir_all(peek_path) or {}857857 }858858 859+// Repairs follow a requested backup, so a failed backup stops them.860+fn test_doctor_repairs_nothing_when_the_requested_backup_fails() {861+ dir := temp_repo('doctor-backup-failure')862+ defer { os.rmdir_all(dir) or {} }863+ init_repo(dir)864+ write(dir, 'app', 'app\n')865+ assert run(dir, 'save', 'app').code == 0866+ store := storage.open(dir)!867+ store.db.exec('UPDATE objects SET size = size + 1')!868+ store.close()869+ existing := os.join_path(dir, 'existing.backup')870+ os.write_file(existing, 'precious')!871+ refused := run(dir, 'doctor', '--backup', existing, '--yes', '--json')872+ assert refused.code == 1873+ assert refused.out.contains('"repaired":[]'), refused.out874+ assert os.read_file(existing)! == 'precious'875+ assert run(dir, 'doctor', '--json').code == 1876+}877+859878 fn test_strip_refuses_without_terminal() {860879 dir := temp_repo('strip')861880 defer { os.rmdir_all(dir) or {} }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.8.5'3+ version: '3.8.6'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }