bcdc186ee437

add remote strip propagation, incoming strip events, and koh man

time
2026-09-23 20:19 UTC · 2 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
koh 3 implementation
parent
a15db60fd5c1
5 files src/cli/cli.v+5 −1src/cli/cmd_man.v+79 −0src/cli/cmd_strip.v+81 −8src/sync/sync.v+113 −0src/sync/sync_test.v+66 −0
src/cli/cli.v+5 −1
@@ -39,6 +39,7 @@3939   doctor [--backup <path>]                       integrity check and cleanup4040   strip <path> [--save <id>]                     remove a path from history4141 42+  man [<command>]                                 the manual, built in4243   saves / checkpoints open the history view on that tab4344   --json on any command that prints data, --yes to skip a confirm prompt'4445 @@ -53,9 +54,12 @@5354 			println('koh ${version}')5455 			return 05556 		}56-		'--help', '-h', 'help' {57+		'--help', '-h' {5758 			println(usage)5859 			return 060+		}61+		'help', 'man' {62+			return cmd_man(rest)5963 		}6064 		'init' {6165 			return cmd_init(rest)

view file

src/cli/cmd_man.v+79 −0
@@ -0,0 +1,79 @@1+//! `koh man` — the manual, built into the binary.2+//!3+//! The page lives in `docs/koh3-man.txt` and is embedded at build time, so a4+//! `koh` copied to another machine still carries its own documentation. With a5+//! command name it prints just that command's part; otherwise the whole page,6+//! through the pager on a terminal.7+8+module cli9+10+import os11+import src.fmt12+13+const manual = $embed_file('../../docs/koh3-man.txt').to_string()14+15+pub fn cmd_man(raw []string) int {16+	args := parse('man', raw, Options{}) or { return usage_error(err.msg()) }17+	mut text := manual18+	if args.positional.len > 0 {19+		name := args.positional_at(0)20+		text = section_of(name) or {21+			eprintln('  ${fmt.red(fmt.glyph_fail)} the manual has no "${name}" — try "koh man" for the whole page')22+			return 223+		}24+	}25+	page(text)26+	return 027+}28+29+// section_of returns the part of the COMMANDS section that documents one30+// command: from its synopsis to the next command's.31+fn section_of(name string) ?string {32+	lines := manual.split_into_lines()33+	mut start := -134+	for i, line in lines {35+		if line.starts_with('    koh ') && line.contains(' ${name}') {36+			start = i37+			break38+		}39+	}40+	if start < 0 {41+		return none42+	}43+	mut end := lines.len44+	for i := start + 1; i < lines.len; i++ {45+		trimmed := lines[i].trim_space()46+		if trimmed.starts_with('koh ') && lines[i].starts_with('    ') {47+			end = i48+			break49+		}50+		if lines[i].starts_with('OPTIONS') || lines[i].starts_with('ENVIRONMENT') {51+			end = i52+			break53+		}54+	}55+	return lines[start..end].join('\n')56+}57+58+// page hands the text to the pager on a terminal, and prints it plainly when59+// the output is going somewhere else. The pager gets a temporary file because60+// that is the one interface every pager understands.61+fn page(text string) {62+	pager := os.getenv('PAGER')63+	if os.is_atty(1) != 0 && pager != '' {64+		path := os.join_path(os.temp_dir(), 'koh-man-${os.getpid()}.txt')65+		os.write_file(path, text) or {66+			print(text)67+			return68+		}69+		// The path is one we built from the temp dir and our own pid, so it70+		// carries nothing the shell could interpret.71+		os.execute('${pager} ${path}')72+		os.rm(path) or {}73+		return74+	}75+	print(text)76+	if !text.ends_with('\n') {77+		println('')78+	}79+}

view file

src/cli/cmd_strip.v+81 −8
@@ -15,6 +15,8 @@1515 import src.fmt1616 import src.events1717 import src.history18+import src.machine19+import src.sync1820 import src.snapshot1921 import src.storage2022 @@ -82,17 +84,32 @@8284 	}8385 	store.commit() or { return refuse(args.json(), err.msg()) }8486 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 == '')8592 	if args.json() {8693 		emit_json(json.encode(StripJson{87-			path:            path88-			save:            only_save89-			files_removed:   count90-			objects_dropped: int(dropped)94+			path:                   path95+			save:                   only_save96+			files_removed:          count97+			objects_dropped:        int(dropped)98+			records_rewritten:      push.pushed.records99+			remote_objects_dropped: push.pushed.objects100+			remote_failed:          push.failed91101 		}))92102 		return 093103 	}94104 	ok('stripped ${path} from ${fmt.plural(count, 'file')} across ${fmt.plural(saves.len, 'save')}')95105 	note('${dropped} objects dropped')106+	if !push.skipped {107+		if push.failed == '' {108+			note('the remote was updated: ${push.pushed.records} save records rewritten, ${push.pushed.objects} objects dropped')109+		} else {110+			warn('the remote could not be updated: ${push.failed} — run koh offer, then strip again')111+		}112+	}96113 	note('remember to delete ${path} or add it to ${snapshot.ignore_file}')97114 	return 098115 }@@ -154,9 +171,65 @@154171 	return '${escaped}/%'155172 }156173 174+struct PushOutcome {175+mut:176+	pushed  sync.PushReport177+	failed  string178+	skipped bool179+}180+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+207+// 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 {209+	if !machine.has_remote() {210+		return PushOutcome{211+			skipped: true212+		}213+	}214+	mut backend := this_machine_backend() or { return PushOutcome{215+		failed: err.msg()216+	} }217+	report := sync.push_strip(store, mut backend, path, save_ids, hashes, drop_objects) or {218+		return PushOutcome{219+			failed: err.msg()220+		}221+	}222+	return PushOutcome{223+		pushed: report224+	}225+}226+157227 struct StripJson {158-	path            string159-	save            string160-	files_removed   int161-	objects_dropped int228+	path                   string229+	save                   string230+	files_removed          int231+	objects_dropped        int232+	records_rewritten      int233+	remote_objects_dropped int234+	remote_failed          string162235 }

view file

src/sync/sync.v+113 −0
@@ -211,6 +211,11 @@211211 	}212212 	for event in fetched.events_out {213213 		events.write(store, event)!214+		if event.kind == events.kind_strip {215+			// Somebody stripped a path: remove it here too, so the two216+			// histories agree from now on.217+			apply_strip(store, event)!218+		}214219 	}215220 }216221 @@ -230,4 +235,112 @@230235 		cursor = save.parent231236 	}232237 	return false238+}239+240+// PushReport says what a strip managed to make visible on the remote.241+pub struct PushReport {242+pub mut:243+	records    int244+	objects    int245+	event_sent bool246+}247+248+// push_strip makes a strip visible on the remote. The event itself travels like249+// any other event; on top of that, every remote save record that listed the250+// path is rewritten in place, and — when the strip covered the whole local251+// history — the objects nobody references any more are deleted.252+//253+// The caller passes what it stripped, because the local rows are already gone254+// by the time the remote hears about it. Every step is safe to repeat, so an255+// interrupted strip finishes on the next run.256+pub fn push_strip(store &storage.Store, mut backend remote.Backend, path string, save_ids []string, object_hashes []string, drop_objects bool) !PushReport {257+	name := repo_name(store, mut backend)!258+	mut report := PushReport{}259+	mut upload := OfferReport{260+		repo: name261+	}262+	upload_events(store, mut backend, name, mut upload)!263+	report.event_sent = upload.events > 0264+265+	for save_id in save_ids {266+		remote_object := backend.get(save_key(name, save_id))!267+		if !remote_object.found {268+			continue269+		}270+		record := decode_save(remote_object.body)!271+		mut kept := []FileRecord{}272+		for file in record.files {273+			if file.path == path || file.path.starts_with('${path.trim_right('/')}/') {274+				continue275+			}276+			kept << file277+		}278+		if kept.len == record.files.len {279+			continue280+		}281+		if !backend.put_if_match(save_key(name, save_id),282+			encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {283+			return error('the remote copy of save ${save_id} moved while stripping — run koh steal, then strip again')284+		}285+		report.records++286+	}287+	if drop_objects {288+		for hash in object_hashes {289+			// Only objects no local save refers to any more: with `--save`, the290+			// other saves still need them.291+			if !store.has_object(hash)! {292+				backend.delete(object_key(name, hash))!293+				report.objects++294+			}295+		}296+	}297+	return report298+}299+300+// rewrite returns the save record with a new file list.301+fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {302+	return SaveRecord{303+		id:            record.id304+		parent:        record.parent305+		message:       record.message306+		created_at:    record.created_at307+		machine:       record.machine308+		is_checkpoint: record.is_checkpoint309+		size_bytes:    record.size_bytes310+		agent:         record.agent311+		session:       record.session312+		restores:      record.restores313+		replayed_from: record.replayed_from314+		files:         files315+	}316+}317+318+// apply_strip is what a machine does when it hears about somebody else's strip:319+// the same removal, so both histories agree from then on. It returns how many320+// saved files it removed.321+pub fn apply_strip(store &storage.Store, event events.Event) !int {322+	mut query := "DELETE FROM save_files WHERE (path = ? OR path LIKE ? ESCAPE '\\')"323+	if event.save_id != '' {324+		query += ' AND save_id = ?'325+	}326+	mut stmt := store.db.prepare(query)!327+	stmt.bind_text(1, event.path)!328+	stmt.bind_text(2, like_under(event.path))!329+	if event.save_id != '' {330+		stmt.bind_text(3, event.save_id)!331+	}332+	stmt.step()!333+	removed := store.db.changes()334+	stmt.finalize()335+	if removed > 0 {336+		store.drop_unreferenced_objects()!337+	}338+	return removed339+}340+341+// like_under escapes LIKE's own wildcards so a path containing `%` or `_`342+// matches itself and nothing else.343+fn like_under(folder string) string {344+	escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')345+	return '${escaped}/%'233346 }

view file

src/sync/sync_test.v+66 −0
@@ -489,3 +489,69 @@489489 	assert files.map(it.path) == ['.kohignore', 'keep.txt']490490 	assert events.strip_events(a)!.len == 1491491 }492+493+fn test_a_strip_reaches_the_remote_and_other_machines() {494+	base := scratch('strip_sync')!495+	defer {496+		os.rmdir_all(base) or {}497+	}498+	mut a := open_repo(machine('strip_a')!)!499+	defer {500+		a.close()501+	}502+	mut b := open_repo(machine('strip_b')!)!503+	defer {504+		b.close()505+	}506+	put(a.root, 'secrets.env', 'api_key=1\n')!507+	put(a.root, 'keep.txt', 'fine\n')!508+	save := commit(a, 'add both')!509+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!510+	offer_now(a, mut backend)!511+	join(b, mut backend)!512+	assert b.object_count()! > 0513+514+	// A strips the path locally, the way `koh strip` does, then tells the515+	// remote.516+	mut secret_hash := ''517+	for file in history.files_of(a, save.id)! {518+		if file.path == 'secrets.env' {519+			secret_hash = file.hash520+		}521+	}522+	assert secret_hash != ''523+	a.begin()!524+	mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!525+	stmt.bind_text(1, 'secrets.env')!526+	stmt.step()!527+	stmt.finalize()528+	a.drop_unreferenced_objects()!529+	events.strip(a, '', 'secrets.env')!530+	a.commit()!531+	report := push_strip(a, mut backend, 'secrets.env', [save.id], [secret_hash], true)!532+	assert report.event_sent533+	assert report.records == 1534+535+	// The remote's copy of the save no longer lists the file, and the object is536+	// gone from the remote.537+	record := decode_save(backend.get(save_key('proj', save.id))!.body)!538+	for file in record.files {539+		assert file.path != 'secrets.env'540+	}541+	// The stripped object is gone from the remote; the ones still referenced542+	// are not.543+	assert !backend.get(object_key('proj', secret_hash))!.found544+	assert backend.list('proj/objects')!.len == 2545+546+	// Another machine hears about it on its next steal and applies it.547+	b.begin()!548+	fetched := fetch_back_to_known(b, mut backend, 'proj')!549+	write_fetched(b, fetched)!550+	b.commit()!551+	mut paths := []string{}552+	for file in history.files_of(b, save.id)! {553+		paths << file.path554+	}555+	assert 'secrets.env' !in paths556+	assert 'keep.txt' in paths557+}

view file