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
↓ koh-bcdc186e.tar.gzatlas at this save
files changed5 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 −0src/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)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+}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 }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 }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+}