f053194e7074

remove dead sync and history code, move doctor SQL into storage, drop forwarding colour wrappers (3.0.22, codex review pending)

time
2026-09-23 22:32 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh 3 review fixes
parent
65f2423754f1
15 files src/cli/cmd_back.v+3 −3src/cli/cmd_diff.v+2 −2src/cli/cmd_doctor.v+2 −37src/cli/cmd_flag.v+3 −3src/cli/cmd_pluck.v+3 −3src/cli/cmd_remote.v+3 −2src/cli/cmd_status.v+4 −3src/cli/cmd_steal.v+2 −2src/cli/cmd_strip.v+4 −4src/cli/output.v+0 −16src/history/history.v+0 −14src/storage/objects.v+34 −0src/sync/steal.v+1 −3src/sync/sync.v+7 −30src/sync/sync_test.v+3 −3
src/cli/cmd_back.v+3 −3
@@ -219,12 +219,12 @@219219 220220 fn change_mark(kind string) string {221221 	if kind == 'added' {222-		return green('+')222+		return fmt.green('+')223223 	}224224 	if kind == 'removed' {225-		return red('-')225+		return fmt.red('-')226226 	}227-	return amber('~')227+	return fmt.amber('~')228228 }229229 230230 struct BackJson {

view file

src/cli/cmd_diff.v+2 −2
@@ -46,8 +46,8 @@4646 		added += delta.added4747 		removed += delta.removed4848 	}49-	note('${fmt.plural(deltas.len, 'file')} changed  ·  ${green('+' + added.str())} ${red('-' +50-		removed.str())}')49+	note('${fmt.plural(deltas.len, 'file')} changed  ·  ${fmt.green('+' + added.str())} ${fmt.red(50+		'-' + removed.str())}')5151 	return 05252 }5353 

view file

src/cli/cmd_doctor.v+2 −37
@@ -11,7 +11,6 @@1111 import os1212 import time1313 import src.history14-import src.storage1514 1615 pub fn cmd_doctor(raw []string) int {1716 	args := parse('doctor', raw, Options{@@ -27,8 +26,8 @@2726 	}2827 2928 	report.problems << store.integrity_check() or { return refuse(args.json(), err.msg()) }30-	report.problems << check_objects(store) or { return refuse(args.json(), err.msg()) }31-	report.problems << check_save_files(store) or { return refuse(args.json(), err.msg()) }29+	report.problems << store.check_objects() or { return refuse(args.json(), err.msg()) }30+	report.problems << store.check_save_files() or { return refuse(args.json(), err.msg()) }3231 3332 	report.objects = int(store.object_count() or { 0 })3433 	report.saves = int(history.save_count(store) or { 0 })@@ -64,40 +63,6 @@6463 		return 16564 	}6665 	return 067-}68-69-// check_objects reads every object and hashes it back. Nothing else proves the70-// store is intact; the hash is the point of the store.71-fn check_objects(store &storage.Store) ![]string {72-	mut problems := []string{}73-	mut stmt := store.db.prepare('SELECT hash FROM objects ORDER BY hash')!74-	defer {75-		stmt.finalize()76-	}77-	mut hashes := []string{}78-	for stmt.step()! {79-		hashes << stmt.text(0)80-	}81-	for hash in hashes {82-		store.get_object(hash) or { problems << 'object ${hash}: ${err.msg()}' }83-	}84-	return problems85-}86-87-// check_save_files catches a save naming an object that is gone — the one88-// inconsistency the schema cannot prevent on its own, because objects are89-// shared between saves.90-fn check_save_files(store &storage.Store) ![]string {91-	mut problems := []string{}92-	mut stmt := store.db.prepare('SELECT DISTINCT f.save_id, f.hash FROM save_files f93-		LEFT JOIN objects o ON o.hash = f.hash WHERE o.hash IS NULL')!94-	defer {95-		stmt.finalize()96-	}97-	for stmt.step()! {98-		problems << 'save ${stmt.text(0)} needs missing object ${stmt.text(1)}'99-	}100-	return problems10166 }10267 10368 // peek_age is how old a peek folder must be before doctor clears it.

view file

src/cli/cmd_flag.v+3 −3
@@ -7,8 +7,8 @@77 module cli88 99 import json10-import src.fmt1110 import src.events11+import src.fmt1212 import src.history1313 import src.storage1414 @@ -82,9 +82,9 @@8282 	}8383 	for entry in flagged {8484 		save := history.by_id(store, entry.save_id) or { continue }85-		mut line := '  ${amber('⚑')} ${dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)}  ${save.message}'85+		mut line := '  ${fmt.amber('⚑')} ${fmt.dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)}  ${save.message}'8686 		if entry.reason != '' {87-			line += '  ${dim(entry.reason)}'87+			line += '  ${fmt.dim(entry.reason)}'8888 		}8989 		println(line)9090 	}

view file

src/cli/cmd_pluck.v+3 −3
@@ -8,8 +8,8 @@88 module cli99 1010 import json11-import src.fmt1211 import src.apply12+import src.fmt1313 import src.history1414 import src.snapshot1515 import src.storage@@ -59,7 +59,7 @@5959 		}6060 		ok('${verb} [${source.id}] would change:')6161 		for path in plan.applied {62-			println('  ${amber('~')} ${path}')62+			println('  ${fmt.amber('~')} ${path}')6363 		}6464 		note('${fmt.plural(plan.applied.len, 'file')} — run again without --dry-run to apply')6565 		return 0@@ -67,7 +67,7 @@6767 6868 	ok('${verb}: [${source.id}]  ${source.message}')6969 	for path in plan.applied {70-		println('  ${amber('~')} ${path}')70+		println('  ${fmt.amber('~')} ${path}')7171 	}7272 	note('${fmt.plural(plan.applied.len, 'file')} change')7373 	approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }

view file

src/cli/cmd_remote.v+3 −2
@@ -10,6 +10,7 @@1010 1111 import json1212 import os13+import src.fmt1314 import src.machine1415 import src.remote1516 @@ -136,8 +137,8 @@136137 		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')137138 	}138139 	println('  choose a remote:')139-	println('    ${amber('1')} folder   a directory, on a disk or a NAS')140-	println('    ${amber('2')} s3       an S3-compatible bucket')140+	println('    ${fmt.amber('1')} folder   a directory, on a disk or a NAS')141+	println('    ${fmt.amber('2')} s3       an S3-compatible bucket')141142 	print('  which? [1] ')142143 	if os.get_line().trim_space() == '2' {143144 		return wizard_s3()

view file

src/cli/cmd_status.v+4 −3
@@ -3,6 +3,7 @@33 module cli44 55 import json6+import src.fmt67 import src.history78 import src.snapshot89 import src.storage@@ -40,13 +41,13 @@4041 		return 04142 	}4243 	for path in changes.added {43-		println('  ${green('+')} ${path}')44+		println('  ${fmt.green('+')} ${path}')4445 	}4546 	for path in changes.modified {46-		println('  ${amber('~')} ${path}')47+		println('  ${fmt.amber('~')} ${path}')4748 	}4849 	for path in changes.removed {49-		println('  ${red('-')} ${path}')50+		println('  ${fmt.red('-')} ${path}')5051 	}5152 	note('${changes.added.len} added  ·  ${changes.modified.len} modified  ·  ${changes.removed.len} removed')5253 	if changes.ignore_changed {

view file

src/cli/cmd_steal.v+2 −2
@@ -105,8 +105,8 @@105105 // ask_clash is the per-file question a terminal gets. Only a lowercase y-style106106 // answer is needed here: `m` for mine, `t` for theirs.107107 fn ask_clash(clash sync.Clash) sync.Side {108-	println('  ${amber('both changed')} ${clash.path}   ${clash.stat}')109-	print('    ${amber('m')}ine or ${amber('t')}heirs? [t] ')108+	println('  ${fmt.amber('both changed')} ${clash.path}   ${clash.stat}')109+	print('    ${fmt.amber('m')}ine or ${fmt.amber('t')}heirs? [t] ')110110 	answer := os.get_line().trim_space()111111 	if answer == 'm' {112112 		return .mine

view file

src/cli/cmd_strip.v+4 −4
@@ -15,9 +15,9 @@1515 import src.fmt1616 import src.history1717 import src.machine18-import src.sync1918 import src.snapshot2019 import src.storage20+import src.sync2121 2222 pub fn cmd_strip(raw []string) int {2323 	args := parse('strip', raw, Options{@@ -53,10 +53,10 @@5353 		note('${path} is already stripped here — finishing the remote')5454 	} else {5555 		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,56+		println('  ${fmt.red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')57+		println('  ${fmt.amber('!')} ${fmt.plural(scope.files, 'file')} in ${fmt.plural(scope.saves,5858 			'save')}, ${fmt.plural(scope.objects, 'object')} — ${where}')59-		println('  ${dim('strip removes the path from history; the file on disk is left alone')}')59+		println('  ${fmt.dim('strip removes the path from history; the file on disk is left alone')}')6060 		approved := confirm(false) or { return refuse(args.json(), err.msg()) }6161 		if !approved {6262 			note('nothing was stripped')

view file

src/cli/output.v+0 −16
@@ -11,22 +11,6 @@1111 import json1212 import src.fmt1313 14-pub fn amber(text string) string {15-	return fmt.amber(text)16-}17-18-pub fn red(text string) string {19-	return fmt.red(text)20-}21-22-pub fn green(text string) string {23-	return fmt.green(text)24-}25-26-pub fn dim(text string) string {27-	return fmt.dim(text)28-}29-3014 pub fn ok(message string) {3115 	println('  ${fmt.amber(fmt.glyph_ok)} ${message}')3216 }

view file

src/history/history.v+0 −14
@@ -222,17 +222,3 @@222222 	}223223 	return stmt.int(0)224224 }225-226-// is_on_line reports whether `id` is reachable from head, so combine can tell227-// replayed-away saves from live ones.228-pub fn is_on_line(store &storage.Store, id string) bool {229-	mut cursor := head_id(store)230-	for cursor != '' {231-		if cursor == id {232-			return true233-		}234-		save := by_id(store, cursor) or { return false }235-		cursor = save.parent236-	}237-	return false238-}

view file

src/storage/objects.v+34 −0
@@ -133,3 +133,37 @@133133 	after := s.object_count()!134134 	return before - after135135 }136+137+// check_objects reads every object and hashes it back. Nothing else proves the138+// store is intact; the hash is the point of the store.139+pub fn (s &Store) check_objects() ![]string {140+	mut problems := []string{}141+	mut stmt := s.db.prepare('SELECT hash FROM objects ORDER BY hash')!142+	defer {143+		stmt.finalize()144+	}145+	mut hashes := []string{}146+	for stmt.step()! {147+		hashes << stmt.text(0)148+	}149+	for hash in hashes {150+		s.get_object(hash) or { problems << 'object ${hash}: ${err.msg()}' }151+	}152+	return problems153+}154+155+// check_save_files catches a save naming an object that is gone — the one156+// inconsistency the schema cannot prevent on its own, because objects are157+// shared between saves.158+pub fn (s &Store) check_save_files() ![]string {159+	mut problems := []string{}160+	mut stmt := s.db.prepare('SELECT DISTINCT f.save_id, f.hash FROM save_files f161+		LEFT JOIN objects o ON o.hash = f.hash WHERE o.hash IS NULL')!162+	defer {163+		stmt.finalize()164+	}165+	for stmt.step()! {166+		problems << 'save ${stmt.text(0)} needs missing object ${stmt.text(1)}'167+	}168+	return problems169+}

view file

src/sync/steal.v+1 −3
@@ -393,8 +393,7 @@393393 	object_keys := backend.list('${name}/objects')!394394 	event_keys := backend.list('${name}/events')!395395 	mut fetched := Fetched{396-		head:            remote_head.body.bytestr().trim_space()397-		remote_save_ids: map[string]bool{}396+		head: remote_head.body.bytestr().trim_space()398397 	}399398 	for key in object_keys {400399 		blob := backend.get(key)!@@ -408,7 +407,6 @@408407 			continue409408 		}410409 		record := decode_save(body.body)!411-		fetched.remote_save_ids[record.id] = true412410 		fetched.saves << record413411 	}414412 	fetched.saves = parent_first(fetched.saves)!

view file

src/sync/sync.v+7 −30
@@ -16,9 +16,8 @@1616 import src.remote1717 import src.storage1818 19-// Where the repo's name on the remote and the last offered head are remembered.19+// Where the repo's name on the remote is remembered.2020 const repo_state_key = 'remote_repo'21-const offered_head_key = 'remote_head'2221 2322 pub const no_saves = 'this repo has no saves yet — nothing to offer'2423 @@ -90,7 +89,6 @@9089 	if !backend.put_if_match(head_key(name), local.id.bytes(), remote_head.tag)! {9190 		return error('remote has new saves — run koh steal')9291 	}93-	store.set_state(offered_head_key, local.id)!9492 	return report9593 }9694 @@ -148,12 +146,11 @@148146 // transaction, so a failure leaves nothing half-applied.149147 pub struct Fetched {150148 pub mut:151-	repo_objects    map[string][]u8152-	saves           []SaveRecord // oldest first153-	events_out      []events.Event154-	head            string155-	base            string // the newest save both sides already shared, '' when none156-	remote_save_ids map[string]bool149+	repo_objects map[string][]u8150+	saves        []SaveRecord // oldest first151+	events_out   []events.Event152+	head         string153+	base         string // the newest save both sides already shared, '' when none157154 }158155 159156 pub fn fetch_back_to_known(store &storage.Store, mut backend remote.Backend, repo string) !Fetched {@@ -162,8 +159,7 @@162159 		return error('the remote has no "${repo}" yet — run koh offer here first')163160 	}164161 	mut fetched := Fetched{165-		head:            remote_head.body.bytestr().trim_space()166-		remote_save_ids: map[string]bool{}162+		head: remote_head.body.bytestr().trim_space()167163 	}168164 	mut cursor := fetched.head169165 	for cursor != '' && history.by_id(store, cursor) == none {@@ -181,7 +177,6 @@181177 				fetched.repo_objects[file.hash] = blob.body182178 			}183179 		}184-		fetched.remote_save_ids[record.id] = true185180 		fetched.saves << record186181 		cursor = record.parent187182 	}@@ -231,22 +226,4 @@231226 			apply_strip(store, event)!232227 		}233228 	}234-}235-236-// can_fast_forward reports whether our line sits behind the remote's, which is237-// the case when every local save is an ancestor of the remote head.238-pub fn can_fast_forward(store &storage.Store, remote_head string, remote_ids map[string]bool) bool {239-	mut cursor := remote_head240-	for cursor != '' {241-		if history.by_id(store, cursor) == none {242-			return false243-		}244-		save := history.by_id(store, cursor) or { return false }245-		if save.parent == '' {246-			// Reached the first save without leaving the line we have.247-			return true248-		}249-		cursor = save.parent250-	}251-	return false252229 }

view file

src/sync/sync_test.v+3 −3
@@ -82,7 +82,7 @@8282 	assert again.saves == 08383 }8484 85-fn test_the_repo_name_and_offered_head_are_remembered() {85+fn test_the_repo_name_is_remembered_and_head_offered() {8686 	base := scratch('remember')!8787 	defer {8888 		os.rmdir_all(base) or {}@@ -96,14 +96,14 @@9696 	mut backend := remote.new_folder(os.join_path(base, 'remote'))!9797 	offer_now(a, mut backend)!9898 	assert a.state('remote_repo') or { '' } == 'proj'99-	assert a.state('remote_head') or { '' } == history.head(a)!.id99+	assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id100100 101101 	// A second offer, with the remote already holding our head, must not102102 	// refuse: the name is remembered, so the remote is ours.103103 	put(a.root, 'a.txt', 'two\n')!104104 	commit(a, 'second')!105105 	offer_now(a, mut backend)!106-	assert a.state('remote_head') or { '' } == history.head(a)!.id106+	assert backend.get(head_key('proj'))!.body.bytestr() == history.head(a)!.id107107 }108108 109109 fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {

view file