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
13 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.8.51+3.8.6

view file

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

view file

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 }

view file

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 {

view file

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.

view file

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{}

view file

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 

view file

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.

view file

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 }

view file

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.

view file

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:

view file

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 {} }

view file

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 }

view file