76be2198e257
add local core: .koh schema, tree walk and restore, one-line history, and the everyday commands
- time
- 2026-09-23 19:53 UTC · 2 weeks ago
- machine
- koh2
- agent
- deepseek/deepseek-flash
- session
- koh 3 implementation
- parent
- f319f052973b
↓ koh-76be2198.tar.gzatlas at this save
files changed29 files
src/cli/args.v+90 −0src/cli/cli.v+48 −11src/cli/cmd_back.v+212 −0src/cli/cmd_diff.v+148 −0src/cli/cmd_init.v+42 −0src/cli/cmd_log.v+193 −0src/cli/cmd_save.v+84 −0src/cli/cmd_status.v+106 −0src/cli/format.v+41 −0src/cli/output.v+86 −0src/cli/prompt.v+36 −0src/cli/repo.v+39 −0src/history/commit.v+175 −0src/history/history.v+260 −0src/history/history_test.v+123 −0src/history/log_md.v+37 −0src/machine/machine.v+18 −0src/snapshot/diff.v+150 −0src/snapshot/fs.v+62 −0src/snapshot/ignore.v+210 −0src/snapshot/restore.v+59 −0src/snapshot/tree.v+100 −0src/storage/cache.v+64 −0src/storage/objects.v+100 −0src/storage/sqlite.v+15 −5src/storage/sqlite_test.v+1 −1src/storage/storage.v+222 −0vendor/shim/koh_stat.c+32 −0vendor/shim/koh_stat.h+25 −0src/cli/args.v+90 −0
@@ -0,0 +1,90 @@1+//! Command line parsing.2+//!3+//! Each command declares the options it takes, so a typo is refused with a4+//! usage error instead of being ignored — an agent's mistake should be loud.5+6+module cli7+8+pub struct Options {9+pub:10+ values []string // options that take a value11+ flags []string // options that take none12+}13+14+pub struct Args {15+pub mut:16+ positional []string17+ values map[string]string18+ flags map[string]bool19+}20+21+pub fn parse(command string, raw []string, spec Options) !Args {22+ mut args := Args{}23+ mut index := 024+ for index < raw.len {25+ item := raw[index]26+ if item == '--' {27+ args.positional << raw[index + 1..]28+ break29+ }30+ if item.starts_with('--') {31+ mut name := item[2..]32+ mut inline := ''33+ if at := name.index('=') {34+ inline = name[at + 1..]35+ name = name[..at]36+ }37+ if name in spec.values {38+ mut value := inline39+ if value == '' {40+ index++41+ if index >= raw.len {42+ return error('--${name} needs a value')43+ }44+ value = raw[index]45+ }46+ args.values[name] = value47+ } else if name in spec.flags {48+ if inline != '' {49+ return error('--${name} takes no value')50+ }51+ args.flags[name] = true52+ } else {53+ return error('unknown option "--${name}" for "koh ${command}"')54+ }55+ } else if item.starts_with('-') && item.len > 1 {56+ return error('unknown option "${item}" for "koh ${command}"')57+ } else {58+ args.positional << item59+ }60+ index++61+ }62+ return args63+}64+65+pub fn (args &Args) flag(name string) bool {66+ return args.flags[name]67+}68+69+pub fn (args &Args) value(name string) string {70+ return args.values[name] or { '' }71+}72+73+pub fn (args &Args) value_or_none(name string) ?string {74+ if name in args.values {75+ return args.values[name]76+ }77+ return none78+}79+80+pub fn (args &Args) json() bool {81+ return args.flag('json')82+}83+84+// positional_at returns the argument at `index`, or '' when it is missing.85+pub fn (args &Args) positional_at(index int) string {86+ if index >= args.positional.len {87+ return ''88+ }89+ return args.positional[index]90+}src/cli/cli.v+48 −11
@@ -12,20 +12,30 @@1212 1313 usage: koh <command> [options]1414 15- save, checkpoint, status, log, diff16- undo, revert, load17- flag, pluck, peek18- offer, steal, remote19- init, doctor, strip, migrate15+everyday16+ save <message> [--agent <m>] [--session <s>] snapshot the working tree17+ checkpoint <message> save, even with no changes18+ status what changed since the last save19+ log [--since <expr>] [--session <s>] the line, newest first20+ diff [<id>] [<id1> <id2>] [--session last|<s>] unified diff2021 21-run "koh help <command>" for one command\'s options.'22+going back23+ undo [n] [--session] step back n saves24+ revert discard unsaved changes25+ load <id> | --checkpoint restore any save2226 23-pub fn run(args []string) int {24- if args.len < 2 {27+setup28+ init create .koh here29+30+ --json on any command that prints data, --yes to skip a confirm prompt'31+32+pub fn run(argv []string) int {33+ if argv.len < 2 {2534 eprintln(usage)2635 return 22736 }28- match args[1] {37+ rest := argv[2..]38+ match argv[1] {2939 '--version', '-v' {3040 println('koh ${version}')3141 return 0@@ -34,9 +44,36 @@3444 println(usage)3545 return 03646 }47+ 'init' {48+ return cmd_init(rest)49+ }50+ 'save' {51+ return cmd_save(rest, false)52+ }53+ 'checkpoint' {54+ return cmd_save(rest, true)55+ }56+ 'status' {57+ return cmd_status(rest)58+ }59+ 'log' {60+ return cmd_log(rest)61+ }62+ 'diff' {63+ return cmd_diff(rest)64+ }65+ 'undo' {66+ return cmd_undo(rest)67+ }68+ 'revert' {69+ return cmd_revert(rest)70+ }71+ 'load' {72+ return cmd_load(rest)73+ }3774 else {38- eprintln('koh: unknown command "${args[1]}"')39- eprintln('run "koh --help" for the command list')75+ eprintln(' koh: unknown command "${argv[1]}"')76+ eprintln(' run "koh --help" for the command list')4077 return 24178 }4279 }src/cli/cmd_back.v+212 −0
@@ -0,0 +1,212 @@1+//! Going back: `koh undo`, `koh revert` and `koh load`.2+//!3+//! Nothing here rewinds history. A going-back command restores an earlier tree4+//! and records that as a new save whose `restores` field names the save it5+//! brought back, so the move itself can be undone.6+//!7+//! The order matters and is the same everywhere: show what will change, ask,8+//! record the new save, then write the files. Recording before writing means a9+//! crash mid-restore shows up in `status` and `revert` finishes it.10+11+module cli12+13+import json14+import src.history15+import src.snapshot16+import src.storage17+18+pub fn cmd_undo(raw []string) int {19+ args := parse('undo', raw, Options{20+ flags: ['session', 'yes', 'json']21+ }) or { return usage_error(err.msg()) }22+ store := open_store() or { return refuse(args.json(), err.msg()) }23+ defer {24+ store.close()25+ }26+ effective := history.effective(store) or { return refuse(args.json(), err.msg()) }27+ mut target := history.Save{}28+ if args.flag('session') {29+ start := history.session_start(store, effective.id) or {30+ return refuse(args.json(), err.msg())31+ }32+ if start.parent == '' {33+ return refuse(args.json(), 'there is nothing before session "${start.session}"')34+ }35+ target = history.by_id(store, start.parent) or { return refuse(args.json(), err.msg()) }36+ } else {37+ mut steps := 138+ if args.positional.len > 0 {39+ steps = args.positional_at(0).int()40+ if steps < 1 {41+ return usage_error('undo takes a positive number of saves')42+ }43+ }44+ target = history.step_back(store, effective.id, steps) or {45+ return refuse(args.json(), err.msg())46+ }47+ }48+ return going_back(store, target, 'undo', args, false)49+}50+51+pub fn cmd_revert(raw []string) int {52+ args := parse('revert', raw, Options{53+ flags: ['yes', 'json']54+ }) or { return usage_error(err.msg()) }55+ store := open_store() or { return refuse(args.json(), err.msg()) }56+ defer {57+ store.close()58+ }59+ head := history.head(store) or {60+ return refuse(args.json(), 'this repo has no saves yet — nothing to revert to')61+ }62+ changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) }63+ if !changes.dirty {64+ if args.json() {65+ emit_json(json.encode(RevertJson{66+ reverted: false67+ head_id: head.id68+ }))69+ return 070+ }71+ ok('working tree already matches [${head.id}]')72+ return 073+ }74+ // The state about to be discarded is recorded as a checkpoint, so a revert75+ // is itself undoable. `record_discard` does that only after the answer.76+ return going_back(store, head, 'revert', args, true)77+}78+79+pub fn cmd_load(raw []string) int {80+ args := parse('load', raw, Options{81+ flags: ['checkpoint', 'yes', 'json']82+ }) or { return usage_error(err.msg()) }83+ store := open_store() or { return refuse(args.json(), err.msg()) }84+ defer {85+ store.close()86+ }87+ mut target := history.Save{}88+ if args.flag('checkpoint') {89+ target = history.latest_checkpoint(store) or {90+ return refuse(args.json(), 'this repo has no checkpoints')91+ }92+ } else {93+ id := args.positional_at(0)94+ if id == '' {95+ return usage_error('load needs a save ID, or --checkpoint')96+ }97+ target = history.resolve(store, id) or { return refuse(args.json(), err.msg()) }98+ }99+ return going_back(store, target, 'load', args, false)100+}101+102+// going_back is the shared path. `discard_first` is revert's extra step: record103+// the changes being thrown away before restoring.104+fn going_back(store &storage.Store, target history.Save, verb string, args Args, discard_first bool) int {105+ as_json := args.json()106+ if as_json && !args.flag('yes') {107+ return refuse(true, 'needs --yes when not interactive')108+ }109+ plan := plan_restore(store, target, discard_first) or { return refuse(as_json, err.msg()) }110+ if !as_json {111+ ok('${verb}: [${target.id}] ${target.message}')112+ for delta in plan.deltas {113+ println(' ${change_mark(delta.kind)} ${delta.path}')114+ }115+ note('${plural(plan.deltas.len, 'file')} change · ${plural(plan.remove.len, 'file')} removed')116+ }117+ approved := confirm(args.flag('yes')) or { return refuse(as_json, err.msg()) }118+ if !approved {119+ return 1120+ }121+ if discard_first {122+ history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{123+ message: 'revert: discarded changes'124+ is_checkpoint: true125+ }) or { return refuse(as_json, err.msg()) }126+ }127+ save := history.commit_files(store, '', plan.files, history.SaveInput{128+ message: '${verb}: ${target.id}'129+ restores: target.id130+ }) or { return refuse(as_json, err.msg()) }131+ report := snapshot.restore_files(store, store.root, plan.files, plan.remove) or {132+ return refuse(as_json, err.msg())133+ }134+ if as_json {135+ emit_json(json.encode(BackJson{136+ id: save.id137+ restores: target.id138+ written: report.written139+ removed: report.removed140+ discarded: discard_first141+ }))142+ return 0143+ }144+ ok('restored [${save.id}] ${save.message}')145+ return 0146+}147+148+struct RestorePlan {149+ files []snapshot.FileEntry150+ remove []string151+ deltas []snapshot.Delta152+}153+154+// plan_restore works out what writing `target` back onto disk involves.155+//156+// `prune_untracked` is revert's rule: revert promises to discard unsaved157+// changes, so a file that was never saved goes too. undo and load are more158+// conservative and only remove paths history already knows about; a file the159+// user never saved is not theirs to delete.160+fn plan_restore(store &storage.Store, target history.Save, prune_untracked bool) !RestorePlan {161+ files := history.files_of(store, target.id)!162+ mut wanted := map[string]bool{}163+ for file in files {164+ wanted[file.path] = true165+ }166+ from := working_tree(store)!167+ mut remove := []string{}168+ if prune_untracked {169+ for file in from.files {170+ if file.path !in wanted {171+ remove << file.path172+ }173+ }174+ } else if head := history.head(store) {175+ for file in history.files_of(store, head.id) or { []snapshot.FileEntry{} } {176+ if file.path !in wanted {177+ remove << file.path178+ }179+ }180+ }181+ deltas := snapshot.diff_trees(store, from, snapshot.Tree{182+ files: files183+ })!184+ return RestorePlan{185+ files: files186+ remove: remove187+ deltas: deltas188+ }189+}190+191+fn change_mark(kind string) string {192+ if kind == 'added' {193+ return green('+')194+ }195+ if kind == 'removed' {196+ return red('-')197+ }198+ return amber('~')199+}200+201+struct BackJson {202+ id string203+ restores string204+ written int205+ removed int206+ discarded bool207+}208+209+struct RevertJson {210+ reverted bool211+ head_id string212+}src/cli/cmd_diff.v+148 −0
@@ -0,0 +1,148 @@1+//! `koh diff` — unified diff of the working tree against a save, of one save2+//! against another, or across a session.3+4+module cli5+6+import json7+import src.history8+import src.snapshot9+import src.storage10+11+pub fn cmd_diff(raw []string) int {12+ args := parse('diff', raw, Options{13+ values: ['session']14+ flags: ['json']15+ }) or { return usage_error(err.msg()) }16+ store := open_store() or { return refuse(args.json(), err.msg()) }17+ defer {18+ store.close()19+ }20+ sides := resolve_sides(store, args) or { return refuse(args.json(), err.msg()) }21+ deltas := snapshot.diff_trees(store, sides.from, sides.to) or {22+ return refuse(args.json(), err.msg())23+ }24+ if args.json() {25+ emit_json(json.encode(DiffJson{26+ from: sides.from_name27+ to: sides.to_name28+ files: deltas.map(delta_json(it))29+ }))30+ return 031+ }32+ if deltas.len == 0 {33+ ok('no changes')34+ return 035+ }36+ for delta in deltas {37+ print(delta.patch)38+ if !delta.patch.ends_with('\n') {39+ println('')40+ }41+ }42+ mut added := 043+ mut removed := 044+ for delta in deltas {45+ added += delta.added46+ removed += delta.removed47+ }48+ note('${plural(deltas.len, 'file')} changed · ${green('+' + added.str())} ${red('-' +49+ removed.str())}')50+ return 051+}52+53+struct Sides {54+pub:55+ from snapshot.Tree56+ to snapshot.Tree57+ from_name string58+ to_name string59+}60+61+struct DiffJson {62+ from string63+ to string64+ files []DeltaJson65+}66+67+struct DeltaJson {68+ path string69+ change string70+ added int71+ removed int72+ binary bool73+ patch string74+}75+76+fn delta_json(delta snapshot.Delta) DeltaJson {77+ return DeltaJson{78+ path: delta.path79+ change: delta.kind80+ added: delta.added81+ removed: delta.removed82+ binary: delta.binary83+ patch: delta.patch84+ }85+}86+87+fn resolve_sides(store &storage.Store, args Args) !Sides {88+ if expr := args.value_or_none('session') {89+ return session_sides(store, expr)!90+ }91+ match args.positional.len {92+ 0 {93+ head := history.head(store) or { return error('this repo has no saves yet') }94+ return Sides{95+ from: tree_of(store, head.id)!96+ to: working_tree(store)!97+ from_name: head.id98+ to_name: 'working'99+ }100+ }101+ 1 {102+ save := history.resolve(store, args.positional_at(0))!103+ return Sides{104+ from: tree_of(store, save.id)!105+ to: working_tree(store)!106+ from_name: save.id107+ to_name: 'working'108+ }109+ }110+ 2 {111+ first := history.resolve(store, args.positional_at(0))!112+ second := history.resolve(store, args.positional_at(1))!113+ return Sides{114+ from: tree_of(store, first.id)!115+ to: tree_of(store, second.id)!116+ from_name: first.id117+ to_name: second.id118+ }119+ }120+ else {121+ return error('diff takes one save ID, or two to compare saves')122+ }123+ }124+}125+126+// session_sides spans a whole session: from the tree just before its first127+// save to its last save.128+fn session_sides(store &storage.Store, expr string) !Sides {129+ name := if expr == 'last' { history.last_session(store)! } else { expr }130+ saves := history.session_saves(store, name)!131+ if saves.len == 0 {132+ return error('no saves in session "${name}"')133+ }134+ first := saves[0]135+ last := saves[saves.len - 1]136+ mut from := snapshot.Tree{}137+ mut from_name := 'empty'138+ if first.parent != '' {139+ from = tree_of(store, first.parent)!140+ from_name = first.parent141+ }142+ return Sides{143+ from: from144+ to: tree_of(store, last.id)!145+ from_name: from_name146+ to_name: last.id147+ }148+}src/cli/cmd_init.v+42 −0
@@ -0,0 +1,42 @@1+//! `koh init` — write the `.koh` file and a default ignore list.2+3+module cli4+5+import json6+import os7+import src.snapshot8+import src.storage9+10+pub fn cmd_init(raw []string) int {11+ args := parse('init', raw, Options{12+ flags: ['json']13+ }) or { return usage_error(err.msg()) }14+ root := os.getwd()15+ store := storage.create(root) or { return refuse(args.json(), err.msg()) }16+ store.close()17+ ignore_path := os.join_path(root, snapshot.ignore_file)18+ mut wrote_ignore := false19+ if !os.exists(ignore_path) {20+ os.write_file(ignore_path, snapshot.default_ignore) or {21+ return refuse(args.json(), err.msg())22+ }23+ wrote_ignore = true24+ }25+ if args.json() {26+ emit_json(json.encode(InitJson{27+ repo: root28+ ignore_created: wrote_ignore29+ }))30+ return 031+ }32+ ok('initialised koh in ${root}')33+ if wrote_ignore {34+ note('wrote ${snapshot.ignore_file} — edit it to suit the project')35+ }36+ return 037+}38+39+struct InitJson {40+ repo string41+ ignore_created bool42+}src/cli/cmd_log.v+193 −0
@@ -0,0 +1,193 @@1+//! `koh log` — the line, newest first.2+//!3+//! On a terminal this opens the history view; piped or with `--json` it prints4+//! plain text, so an agent always gets something it can parse.5+6+module cli7+8+import json9+import time10+import src.history11+12+pub fn cmd_log(raw []string) int {13+ args := parse('log', raw, Options{14+ values: ['since', 'session']15+ flags: ['json']16+ }) or { return usage_error(err.msg()) }17+ store := open_store() or { return refuse(args.json(), err.msg()) }18+ defer {19+ store.close()20+ }21+ mut cutoff := i64(0)22+ if expr := args.value_or_none('since') {23+ cutoff = parse_since(expr) or { return refuse(args.json(), err.msg()) }24+ }25+ session := args.value('session')26+ head_id := history.head_id(store)27+ mut shown := []history.Save{}28+ for save in history.line(store) or { return refuse(args.json(), err.msg()) } {29+ if cutoff > 0 && save.created_at < cutoff {30+ continue31+ }32+ if session != '' && save.session != session {33+ continue34+ }35+ shown << save36+ }37+ if args.json() {38+ emit_json(json.encode(LogJson{39+ head_id: head_id40+ saves: shown.map(to_json(it))41+ }))42+ return 043+ }44+ if shown.len == 0 {45+ ok('no saves in that range')46+ return 047+ }48+ for save in shown {49+ println(log_line(save, save.id == head_id))50+ }51+ return 052+}53+54+struct SaveJson {55+ id string56+ parent ?string57+ message string58+ timestamp i6459+ machine string60+ agent ?string61+ session ?string62+ is_checkpoint bool63+ restores ?string64+ replayed_from ?string65+}66+67+struct LogJson {68+ head_id string69+ saves []SaveJson70+}71+72+fn to_json(save history.Save) SaveJson {73+ return SaveJson{74+ id: save.id75+ parent: optional(save.parent)76+ message: save.message77+ timestamp: save.created_at78+ machine: save.machine79+ agent: optional(save.agent)80+ session: optional(save.session)81+ is_checkpoint: save.is_checkpoint82+ restores: optional(save.restores)83+ replayed_from: optional(save.replayed_from)84+ }85+}86+87+fn optional(value string) ?string {88+ if value == '' {89+ return none90+ }91+ return value92+}93+94+fn log_line(save history.Save, is_head bool) string {95+ marker := if is_head { amber('▸') } else { ' ' }96+ mut line := ' ${marker} ${dim('[' + save.id + ']')} ${stamp(save.created_at)} ${save.message}'97+ mut tags := []string{}98+ if save.agent != '' || save.session != '' {99+ mut inner := []string{}100+ if save.agent != '' {101+ inner << save.agent102+ }103+ if save.session != '' {104+ inner << save.session105+ }106+ tags << '[${inner.join(' · ')}]'107+ }108+ if save.is_checkpoint {109+ tags << '◐'110+ }111+ if save.restores != '' {112+ tags << '↩ ${save.restores}'113+ }114+ if save.replayed_from != '' {115+ tags << 'replayed'116+ }117+ if tags.len > 0 {118+ line += ' ${dim(tags.join(' '))}'119+ }120+ return line121+}122+123+// parse_since turns the natural-language expressions Koh has always accepted124+// into a unix timestamp.125+pub fn parse_since(expr string) !i64 {126+ now := time.now().unix()127+ text := expr.trim_space().to_lower()128+ match text {129+ 'today' { return start_of_day(now) }130+ 'yesterday' { return start_of_day(now) - 86400 }131+ 'last week' { return now - 7 * 86400 }132+ 'last month' { return now - 30 * 86400 }133+ 'last year' { return now - 365 * 86400 }134+ else {}135+ }136+137+ if date := parse_date(text) {138+ return date139+ }140+ parts := text.split(' ')141+ if parts.len >= 2 {142+ amount := parts[0].int()143+ if amount > 0 {144+ if unit := unit_seconds(parts[1]) {145+ return now - i64(amount) * unit146+ }147+ }148+ return error('cannot understand "--since ${expr}"')149+ }150+ days := text.int()151+ if days > 0 {152+ return now - i64(days) * 86400153+ }154+ return error('cannot understand "--since ${expr}"')155+}156+157+fn unit_seconds(unit string) ?i64 {158+ return match unit {159+ 'm', 'min', 'mins', 'minute', 'minutes' { i64(60) }160+ 'h', 'hr', 'hrs', 'hour', 'hours' { i64(3600) }161+ 'd', 'day', 'days' { i64(86400) }162+ 'w', 'week', 'weeks' { i64(7 * 86400) }163+ 'month', 'months' { i64(30 * 86400) }164+ 'y', 'year', 'years' { i64(365 * 86400) }165+ else { none }166+ }167+}168+169+fn parse_date(text string) ?i64 {170+ parts := text.split('-')171+ if parts.len != 3 || parts[0].len != 4 {172+ return none173+ }174+ if parts[0].int() == 0 || parts[1].int() == 0 || parts[2].int() == 0 {175+ return none176+ }177+ return midnight(parts[0].int(), parts[1].int(), parts[2].int())178+}179+180+fn start_of_day(unix_seconds i64) i64 {181+ moment := time.unix(unix_seconds).local()182+ return midnight(moment.year, moment.month, moment.day)183+}184+185+// midnight builds a local-time timestamp for a calendar day.186+fn midnight(year int, month int, day int) i64 {187+ return time.Time{188+ year: year189+ month: month190+ day: day191+ is_local: true192+ }.unix()193+}src/cli/cmd_save.v+84 −0
@@ -0,0 +1,84 @@1+//! `koh save` and `koh checkpoint`.2+3+module cli4+5+import json6+import src.history7+import src.snapshot8+9+pub fn cmd_save(raw []string, is_checkpoint bool) int {10+ verb := if is_checkpoint { 'checkpoint' } else { 'save' }11+ args := parse(verb, raw, Options{12+ values: ['agent', 'session']13+ flags: ['json']14+ }) or { return usage_error(err.msg()) }15+ message := args.positional_at(0)16+ if message == '' {17+ example := if is_checkpoint {18+ 'before combine replay engine'19+ } else {20+ 'fix off-by-one in chunk boundary'21+ }22+ return usage_error('${verb} needs a message, e.g. koh ${verb} "${example}"')23+ }24+ store := open_store() or { return refuse(args.json(), err.msg()) }25+ defer {26+ store.close()27+ }28+ save := history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{29+ message: message30+ agent: args.value('agent')31+ session: args.value('session')32+ is_checkpoint: is_checkpoint33+ }) or { return refuse(args.json(), err.msg()) }34+35+ files := history.files_of(store, save.id) or { []snapshot.FileEntry{} }36+ if args.json() {37+ emit_json(json.encode(SaveOut{38+ id: save.id39+ parent: optional(save.parent)40+ message: save.message41+ created_at: save.created_at42+ machine: save.machine43+ is_checkpoint: save.is_checkpoint44+ agent: optional(save.agent)45+ session: optional(save.session)46+ files: files.len47+ size_bytes: save.size_bytes48+ }))49+ return 050+ }51+ ok('saved [${save.id}] ${save.message}')52+ mut detail := []string{}53+ if save.is_checkpoint {54+ detail << 'checkpoint'55+ }56+ detail << stamp(save.created_at)57+ detail << plural(files.len, 'file')58+ detail << human_bytes(save.size_bytes)59+ note(detail.join(' · '))60+ mut attribution := []string{}61+ if save.agent != '' {62+ attribution << 'agent: ${save.agent}'63+ }64+ if save.session != '' {65+ attribution << 'session: ${save.session}'66+ }67+ if attribution.len > 0 {68+ note(attribution.join(' · '))69+ }70+ return 071+}72+73+struct SaveOut {74+ id string75+ parent ?string76+ message string77+ created_at i6478+ machine string79+ is_checkpoint bool80+ agent ?string81+ session ?string82+ files int83+ size_bytes i6484+}src/cli/cmd_status.v+106 −0
@@ -0,0 +1,106 @@1+//! `koh status` — what the working tree has that head does not.2+3+module cli4+5+import json6+import src.history7+import src.snapshot8+import src.storage9+10+pub fn cmd_status(raw []string) int {11+ args := parse('status', raw, Options{12+ flags: ['json']13+ }) or { return usage_error(err.msg()) }14+ store := open_store() or { return refuse(args.json(), err.msg()) }15+ defer {16+ store.close()17+ }18+ head := history.head(store) or {19+ return refuse(args.json(), 'this repo has no saves yet — run "koh save \\"first save\\""')20+ }21+ changes := compare_with_head(store, head) or { return refuse(args.json(), err.msg()) }22+23+ if args.json() {24+ emit_json(json.encode(StatusJson{25+ head_id: head.id26+ head_msg: head.message27+ dirty: changes.dirty28+ modified: changes.modified29+ added: changes.added30+ deleted: changes.removed31+ ignore_changed: changes.ignore_changed32+ }))33+ return 034+ }35+ if !changes.dirty {36+ ok('no changes')37+ if changes.ignore_changed {38+ note('${snapshot.ignore_file} changed since [${head.id}]')39+ }40+ return 041+ }42+ for path in changes.added {43+ println(' ${green('+')} ${path}')44+ }45+ for path in changes.modified {46+ println(' ${amber('~')} ${path}')47+ }48+ for path in changes.removed {49+ println(' ${red('-')} ${path}')50+ }51+ note('${changes.added.len} added · ${changes.modified.len} modified · ${changes.removed.len} removed')52+ if changes.ignore_changed {53+ warn('${snapshot.ignore_file} changed since [${head.id}] — the next save records the new rules')54+ }55+ return 056+}57+58+pub struct Changes {59+pub:60+ dirty bool61+ added []string62+ modified []string63+ removed []string64+ ignore_changed bool65+}66+67+struct StatusJson {68+ head_id string69+ head_msg string70+ dirty bool71+ modified []string72+ added []string73+ deleted []string74+ ignore_changed bool75+}76+77+pub fn compare_with_head(store &storage.Store, head history.Save) !Changes {78+ before := snapshot.index_files(history.files_of(store, head.id)!)79+ after := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)80+ mut added := []string{}81+ mut modified := []string{}82+ mut removed := []string{}83+ for path in after.keys() {84+ if path !in before {85+ added << path86+ } else if before[path].hash != after[path].hash {87+ modified << path88+ }89+ }90+ for path in before.keys() {91+ if path !in after {92+ removed << path93+ }94+ }95+ added.sort()96+ modified.sort()97+ removed.sort()98+ ignore_changed := snapshot.ignore_file in added || snapshot.ignore_file in modified99+ return Changes{100+ dirty: added.len + modified.len + removed.len > 0101+ added: added102+ modified: modified103+ removed: removed104+ ignore_changed: ignore_changed105+ }106+}src/cli/format.v+41 −0
@@ -0,0 +1,41 @@1+//! Small formatting helpers shared by the commands.2+3+module cli4+5+import time6+7+pub fn stamp(unix_seconds i64) string {8+ return time.unix(unix_seconds).local().format()9+}10+11+pub fn day(unix_seconds i64) string {12+ return time.unix(unix_seconds).local().ymmdd()13+}14+15+pub fn clock(unix_seconds i64) string {16+ return time.unix(unix_seconds).local().format_ss()17+}18+19+pub fn human_bytes(bytes i64) string {20+ mut value := f64(bytes)21+ mut unit := 'B'22+ for suffix in ['KiB', 'MiB', 'GiB'] {23+ if value < 1024 {24+ break25+ }26+ value /= 102427+ unit = suffix28+ }29+ if unit == 'B' {30+ return '${bytes} B'31+ }32+ return '${value:.1f} ${unit}'33+}34+35+// plural renders "1 file" / "3 files".36+pub fn plural(count int, noun string) string {37+ if count == 1 {38+ return '1 ${noun}'39+ }40+ return '${count} ${noun}s'41+}src/cli/output.v+86 −0
@@ -0,0 +1,86 @@1+//! Output formatting: the ✦ / ✗ habit, colour that can be turned off, and the2+//! `--json` shapes agents read.3+//!4+//! Every command that prints data has a JSON form with stable field names, and5+//! every error is `{"error": "..."}` on stderr. Exit codes are fixed: 06+//! success, 1 refusal or error, 2 bad usage.7+8+module cli9+10+import json11+import os12+13+const glyph_ok = '✦'14+const glyph_fail = '✗'15+16+pub fn color_enabled() bool {17+ if os.getenv('NO_COLOR') != '' || os.getenv('KOH_NO_COLOR') != '' {18+ return false19+ }20+ return os.is_atty(1) != 021+}22+23+fn paint(text string, code string) string {24+ if !color_enabled() {25+ return text26+ }27+ return '\x1b[${code}m${text}\x1b[0m'28+}29+30+pub fn amber(text string) string {31+ return paint(text, '38;5;214')32+}33+34+pub fn red(text string) string {35+ return paint(text, '38;5;203')36+}37+38+pub fn dim(text string) string {39+ return paint(text, '38;5;245')40+}41+42+pub fn green(text string) string {43+ return paint(text, '38;5;114')44+}45+46+pub fn ok(message string) {47+ println(' ${amber(glyph_ok)} ${message}')48+}49+50+pub fn note(message string) {51+ println(' ${dim(message)}')52+}53+54+pub fn warn(message string) {55+ println(' ${amber('!')} ${message}')56+}57+58+// fail prints a refusal and returns the exit code to hand back.59+pub fn fail(message string) int {60+ eprintln(' ${red(glyph_fail)} ${message}')61+ return 162+}63+64+pub fn fail_json(message string) int {65+ eprintln(json.encode({66+ 'error': message67+ }))68+ return 169+}70+71+pub fn usage_error(message string) int {72+ eprintln(' ${red(glyph_fail)} ${message}')73+ return 274+}75+76+// refuse reports an error in whichever shape the command was asked for.77+pub fn refuse(as_json bool, message string) int {78+ if as_json {79+ return fail_json(message)80+ }81+ return fail(message)82+}83+84+pub fn emit_json(value string) {85+ println(value)86+}src/cli/prompt.v+36 −0
@@ -0,0 +1,36 @@1+//! The strict confirm prompt, carried over from Koh 2 unchanged.2+//!3+//! Before anything that rewrites the working tree, Koh shows what will happen4+//! and asks one question. Only a lowercase `y` confirms: `Y`, `yes`, or a5+//! padded ` y ` all cancel. `--yes` skips the question; without a terminal and6+//! without `--yes` the command refuses rather than hanging.7+8+module cli9+10+import os11+12+pub fn interactive() bool {13+ return os.is_atty(0) != 014+}15+16+pub fn confirm(yes bool) !bool {17+ if yes {18+ return true19+ }20+ if !interactive() {21+ return error('needs --yes when not interactive')22+ }23+ print(' continue? [y/N] ')24+ answer := os.get_line()25+ println('')26+ return answer.trim_right('\r') == 'y'27+}28+29+// require_true is confirm for a second, stricter question: the answer has to30+// match `expected` exactly. `strip` uses it for the typed path.31+pub fn typed_confirm(prompt_text string, expected string) bool {32+ print(' ${prompt_text} ')33+ answer := os.get_line()34+ println('')35+ return answer.trim_right('\r') == expected36+}src/cli/repo.v+39 −0
@@ -0,0 +1,39 @@1+//! Opening the repo a command acts on, and the tree lookups commands share.2+3+module cli4+5+import os6+import src.history7+import src.snapshot8+import src.storage9+10+pub fn repo_root() !string {11+ root := storage.find_root(os.getwd()) or {12+ return error('not inside a koh repo — run "koh init" here, or "koh steal <name>"')13+ }14+ return root15+}16+17+pub fn open_store() !storage.Store {18+ root := repo_root()!19+ return storage.open(root)!20+}21+22+// tree_of builds the tree a save records, for diffing.23+pub fn tree_of(store &storage.Store, save_id string) !snapshot.Tree {24+ return snapshot.Tree{25+ files: history.files_of(store, save_id)!26+ }27+}28+29+pub fn working_tree(store &storage.Store) !snapshot.Tree {30+ return snapshot.Tree{31+ root: store.root32+ files: snapshot.walk(store, store.root, snapshot.load(store.root))!33+ }34+}35+36+pub fn label(store &storage.Store, id string) string {37+ save := history.by_id(store, id) or { return id }38+ return '${save.id} ${save.message}'39+}src/history/commit.v+175 −0
@@ -0,0 +1,175 @@1+//! Creating saves.2+//!3+//! A save is a full snapshot: every tracked path with its object hash and4+//! executable bit, plus the message, time, machine and optional agent and5+//! session. Its ID is the first 12 hex characters of the Blake3 hash of the6+//! canonical record, computed once, at creation, and never recomputed — which7+//! is why `strip` can change a save's files and keep its ID.8+9+module history10+11+import strings12+import time13+import src.machine14+import src.snapshot15+import src.storage16+17+pub struct SaveInput {18+pub mut:19+ message string20+ agent string21+ session string22+ is_checkpoint bool23+ restores string24+ replayed_from string25+ created_at i6426+ machine string27+}28+29+pub const nothing_changed = 'nothing has changed since the last save'30+31+// commit_worktree walks the working tree and records it. A plain save refuses32+// when the tree matches head; a checkpoint always records.33+pub fn commit_worktree(store &storage.Store, root string, ig snapshot.Ignore, input SaveInput) !Save {34+ files := snapshot.walk(store, root, ig)!35+ if input.restores == '' && !input.is_checkpoint {36+ if previous := head(store) {37+ if snapshot.same_tree(files, files_of(store, previous.id)!) {38+ return error(nothing_changed)39+ }40+ }41+ }42+ save := commit_files(store, root, files, input)!43+ write_log_md(store, root)!44+ return save45+}46+47+// commit_files records an already-known tree. `root` is '' when the objects are48+// already in the store — a replay during a steal, for instance.49+pub fn commit_files(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {50+ store.begin()!51+ save := write_save(store, root, files, input) or {52+ store.rollback()53+ return err54+ }55+ store.commit()!56+ return save57+}58+59+fn write_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {60+ if root != '' {61+ snapshot.store_objects(store, root, files)!62+ }63+ created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() }64+ mut save := Save{65+ parent: head_id(store)66+ message: input.message67+ created_at: created_at68+ machine: if input.machine != '' { input.machine } else { machine.name() }69+ is_checkpoint: input.is_checkpoint70+ size_bytes: tree_size(store, files)71+ agent: input.agent72+ session: input.session73+ restores: input.restores74+ replayed_from: input.replayed_from75+ }76+ // Two saves made in the same second with the same record would collide.77+ // Bumping the clock keeps one line of history and keeps the ID unique.78+ for {79+ save.id = save_id(save, files)80+ if by_id(store, save.id) == none {81+ break82+ }83+ save.created_at++84+ }85+ insert_save(store, save, files)!86+ store.set_state(storage.head_key, save.id)!87+ return save88+}89+90+fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {91+ mut stmt := store.db.prepare('INSERT INTO saves (id, parent, message, created_at, machine,92+ is_checkpoint, size_bytes, agent, session, restores, replayed_from)93+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')!94+ stmt.bind_text(1, save.id)!95+ if save.parent == '' {96+ stmt.bind_null(2)!97+ } else {98+ stmt.bind_text(2, save.parent)!99+ }100+ stmt.bind_text(3, save.message)!101+ stmt.bind_int(4, save.created_at)!102+ stmt.bind_text(5, save.machine)!103+ stmt.bind_int(6, if save.is_checkpoint { 1 } else { 0 })!104+ stmt.bind_int(7, save.size_bytes)!105+ bind_optional(stmt, 8, save.agent)!106+ bind_optional(stmt, 9, save.session)!107+ bind_optional(stmt, 10, save.restores)!108+ bind_optional(stmt, 11, save.replayed_from)!109+ stmt.step()!110+ stmt.finalize()111+112+ mut files_stmt :=113+ store.db.prepare('INSERT INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)')!114+ defer {115+ files_stmt.finalize()116+ }117+ for file in files {118+ files_stmt.bind_text(1, save.id)!119+ files_stmt.bind_text(2, file.path)!120+ files_stmt.bind_text(3, file.hash)!121+ files_stmt.bind_int(4, if file.executable { 1 } else { 0 })!122+ files_stmt.step()!123+ // A statement that has returned DONE must be reset before rebinding.124+ files_stmt.reset()125+ }126+}127+128+fn bind_optional(stmt &storage.Stmt, index int, value string) ! {129+ if value == '' {130+ stmt.bind_null(index)!131+ } else {132+ stmt.bind_text(index, value)!133+ }134+}135+136+// save_id is the first 12 hex characters of the Blake3 hash of the canonical137+// record: parent, message, time, machine, checkpoint mark, agent, session and138+// the sorted file list.139+pub fn save_id(save Save, files []snapshot.FileEntry) string {140+ return storage.hash_hex(canonical(save, files).bytes())[..12]141+}142+143+fn canonical(save Save, files []snapshot.FileEntry) string {144+ mut sb := strings.Builder{}145+ sb.write_string('koh3-save-v1\n')146+ put_field(mut sb, 'parent', save.parent)147+ put_field(mut sb, 'message', save.message)148+ put_field(mut sb, 'created', save.created_at.str())149+ put_field(mut sb, 'machine', save.machine)150+ put_field(mut sb, 'checkpoint', if save.is_checkpoint { '1' } else { '0' })151+ put_field(mut sb, 'agent', save.agent)152+ put_field(mut sb, 'session', save.session)153+ put_field(mut sb, 'files', files.len.str())154+ for file in files {155+ mark := if file.executable { 'x' } else { '-' }156+ put_field(mut sb, 'file', '${mark}${file.hash} ${file.path}')157+ }158+ return sb.str()159+}160+161+// Length-prefixed fields keep the record unambiguous whatever a message or a162+// path contains.163+fn put_field(mut sb strings.Builder, name string, value string) {164+ sb.write_string('${name}:${value.len}:')165+ sb.write_string(value)166+ sb.write_string('\n')167+}168+169+fn tree_size(store &storage.Store, files []snapshot.FileEntry) i64 {170+ mut total := i64(0)171+ for file in files {172+ total += store.object_size(file.hash) or { 0 }173+ }174+ return total175+}src/history/history.v+260 −0
@@ -0,0 +1,260 @@1+//! History: one line of saves, and the queries every command needs.2+3+module history4+5+import src.storage6+import src.snapshot7+8+// A save is a full snapshot of the tree plus what made it.9+pub struct Save {10+pub mut:11+ id string12+ parent string13+ message string14+ created_at i6415+ machine string16+ is_checkpoint bool17+ size_bytes i6418+ agent string19+ session string20+ restores string21+ replayed_from string22+}23+24+const select_save = 'SELECT id, parent, message, created_at, machine, is_checkpoint, size_bytes,25+ agent, session, restores, replayed_from FROM saves'26+27+fn read_save(stmt &storage.Stmt) Save {28+ return Save{29+ id: stmt.text(0)30+ parent: stmt.text_or_none(1) or { '' }31+ message: stmt.text(2)32+ created_at: stmt.int(3)33+ machine: stmt.text(4)34+ is_checkpoint: stmt.int(5) != 035+ size_bytes: stmt.int(6)36+ agent: stmt.text_or_none(7) or { '' }37+ session: stmt.text_or_none(8) or { '' }38+ restores: stmt.text_or_none(9) or { '' }39+ replayed_from: stmt.text_or_none(10) or { '' }40+ }41+}42+43+// head is the newest save on the line.44+pub fn head(store &storage.Store) ?Save {45+ id := store.state(storage.head_key) or { return none }46+ return by_id(store, id) or { none }47+}48+49+pub fn head_id(store &storage.Store) string {50+ return store.state(storage.head_key) or { '' }51+}52+53+pub fn by_id(store &storage.Store, id string) ?Save {54+ mut stmt := store.db.prepare('${select_save} WHERE id = ?') or { return none }55+ defer {56+ stmt.finalize()57+ }58+ stmt.bind_text(1, id) or { return none }59+ if stmt.step() or { return none } {60+ return read_save(stmt)61+ }62+ return none63+}64+65+// resolve accepts any unique prefix of six characters or more, the way Koh 266+// did, so an agent can name a save without quoting the whole ID.67+pub fn resolve(store &storage.Store, prefix string) !Save {68+ if prefix.len < 6 {69+ return error('"${prefix}" is too short — give at least 6 characters of a save ID')70+ }71+ mut stmt := store.db.prepare('${select_save} WHERE id LIKE ? ORDER BY id')!72+ defer {73+ stmt.finalize()74+ }75+ stmt.bind_text(1, '${prefix}%')!76+ mut found := []Save{}77+ for stmt.step()! {78+ found << read_save(stmt)79+ }80+ match found.len {81+ 0 { return error('no save starts with "${prefix}"') }82+ 1 { return found[0] }83+ else { return error('"${prefix}" matches ${found.len} saves — give more characters') }84+ }85+}86+87+// all_saves returns every save Koh knows, newest first. This is the "all" tab:88+// it includes saves that were replayed away and are no longer on the line.89+pub fn all_saves(store &storage.Store) ![]Save {90+ mut stmt := store.db.prepare('${select_save} ORDER BY created_at DESC, id DESC')!91+ defer {92+ stmt.finalize()93+ }94+ mut saves := []Save{}95+ for stmt.step()! {96+ saves << read_save(stmt)97+ }98+ return saves99+}100+101+// line returns the saves that are on the line, newest first.102+pub fn line(store &storage.Store) ![]Save {103+ mut saves := []Save{}104+ mut cursor := head_id(store)105+ for cursor != '' {106+ save := by_id(store, cursor) or { break }107+ saves << save108+ cursor = save.parent109+ }110+ return saves111+}112+113+// ancestry returns the saves behind `id`, oldest first.114+pub fn ancestry(store &storage.Store, id string) ![]Save {115+ mut saves := []Save{}116+ mut cursor := id117+ for cursor != '' {118+ save := by_id(store, cursor) or { break }119+ saves << save120+ cursor = save.parent121+ }122+ saves.reverse()123+ return saves124+}125+126+// effective is where the line actually sits: `head.restores` when the newest127+// save was a going-back, else `head`. Going back repeatedly therefore keeps128+// stepping further back instead of undoing the undo.129+pub fn effective(store &storage.Store) !Save {130+ current := head(store) or { return error('this repo has no saves yet') }131+ if current.restores != '' {132+ return by_id(store, current.restores) or {133+ return error('save ${current.restores} is missing')134+ }135+ }136+ return current137+}138+139+// step_back walks `n` parents back from `id`.140+pub fn step_back(store &storage.Store, id string, n int) !Save {141+ mut current := by_id(store, id) or { return error('save ${id} is missing') }142+ for _ in 0 .. n {143+ if current.parent == '' {144+ return error('there is nothing before ${current.id} — it is the first save')145+ }146+ current = by_id(store, current.parent) or {147+ return error('save ${current.parent} is missing')148+ }149+ }150+ return current151+}152+153+// session_start returns the oldest save of the session `id` belongs to.154+pub fn session_start(store &storage.Store, id string) !Save {155+ current := by_id(store, id) or { return error('save ${id} is missing') }156+ if current.session == '' {157+ return error('save ${current.id} is not part of a session')158+ }159+ mut oldest := current160+ mut cursor := current.parent161+ for cursor != '' {162+ previous := by_id(store, cursor) or { break }163+ if previous.session != current.session {164+ break165+ }166+ oldest = previous167+ cursor = previous.parent168+ }169+ return oldest170+}171+172+// last_session finds the most recent session name on the line.173+pub fn last_session(store &storage.Store) !string {174+ for save in line(store)! {175+ if save.session != '' {176+ return save.session177+ }178+ }179+ return error('no save on the line belongs to a session')180+}181+182+// session_saves returns every save in a session, oldest first.183+pub fn session_saves(store &storage.Store, name string) ![]Save {184+ mut stmt := store.db.prepare('${select_save} WHERE session = ? ORDER BY created_at, id')!185+ defer {186+ stmt.finalize()187+ }188+ stmt.bind_text(1, name)!189+ mut saves := []Save{}190+ for stmt.step()! {191+ saves << read_save(stmt)192+ }193+ return saves194+}195+196+pub fn latest_checkpoint(store &storage.Store) ?Save {197+ mut stmt := store.db.prepare('${select_save} WHERE is_checkpoint = 1 ORDER BY created_at DESC, id DESC LIMIT 1') or {198+ return none199+ }200+ defer {201+ stmt.finalize()202+ }203+ if stmt.step() or { return none } {204+ return read_save(stmt)205+ }206+ return none207+}208+209+pub fn files_of(store &storage.Store, save_id string) ![]snapshot.FileEntry {210+ mut stmt :=211+ store.db.prepare('SELECT path, hash, executable FROM save_files WHERE save_id = ? ORDER BY path')!212+ defer {213+ stmt.finalize()214+ }215+ stmt.bind_text(1, save_id)!216+ mut files := []snapshot.FileEntry{}217+ for stmt.step()! {218+ hash := stmt.text(1)219+ files << snapshot.FileEntry{220+ path: stmt.text(0)221+ hash: hash222+ size: store.object_size(hash) or { 0 }223+ executable: stmt.int(2) != 0224+ }225+ }226+ return files227+}228+229+pub fn save_count(store &storage.Store) !i64 {230+ return count(store, 'saves')231+}232+233+pub fn checkpoint_count(store &storage.Store) !i64 {234+ return count(store, 'saves WHERE is_checkpoint = 1')235+}236+237+fn count(store &storage.Store, what string) !i64 {238+ mut stmt := store.db.prepare('SELECT COUNT(*) FROM ${what}')!239+ defer {240+ stmt.finalize()241+ }242+ if !stmt.step()! {243+ return 0244+ }245+ return stmt.int(0)246+}247+248+// is_on_line reports whether `id` is reachable from head, so combine can tell249+// replayed-away saves from live ones.250+pub fn is_on_line(store &storage.Store, id string) bool {251+ mut cursor := head_id(store)252+ for cursor != '' {253+ if cursor == id {254+ return true255+ }256+ save := by_id(store, cursor) or { return false }257+ cursor = save.parent258+ }259+ return false260+}src/history/history_test.v+123 −0
@@ -0,0 +1,123 @@1+module history2+3+import os4+import src.snapshot5+import src.storage6+7+fn temp_repo(name string) !(storage.Store, string) {8+ dir := os.join_path(os.temp_dir(), 'koh3_hist_${os.getpid()}_${name}')9+ os.rmdir_all(dir) or {}10+ os.mkdir_all(dir, os.MkdirParams{})!11+ store := storage.create(dir)!12+ return store, dir13+}14+15+fn write_file(root string, rel string, text string) ! {16+ full := os.join_path(root, rel)17+ os.mkdir_all(os.dir(full), os.MkdirParams{})!18+ os.write_file(full, text)!19+}20+21+fn test_walk_then_commit() {22+ mut store, dir := temp_repo('commit')!23+ defer {24+ store.close()25+ os.rmdir_all(dir) or {}26+ }27+ write_file(dir, 'a.txt', 'hello\n')!28+ write_file(dir, 'sub/b.txt', 'world\n')!29+ entries := snapshot.walk(store, dir, snapshot.Ignore{})!30+ assert entries.len == 231+32+ save := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{33+ message: 'first save'34+ })!35+ assert save.id.len == 1236+ assert save.parent == ''37+ assert head_id(store) == save.id38+39+ files := files_of(store, save.id)!40+ assert files.len == 241+ assert files[0].path == 'a.txt'42+ assert files[1].path == 'sub/b.txt'43+44+ // Nothing changed, so a plain save refuses and a checkpoint records.45+ if _ := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'again' }) {46+ assert false, 'a save with no changes should refuse'47+ } else {48+ assert err.msg() == nothing_changed49+ }50+ second := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{51+ message: 'checked anyway'52+ is_checkpoint: true53+ })!54+ assert second.parent == save.id55+ assert second.is_checkpoint56+}57+58+fn test_going_back_is_recorded_not_rewound() {59+ mut store, dir := temp_repo('back')!60+ defer {61+ store.close()62+ os.rmdir_all(dir) or {}63+ }64+ write_file(dir, 'a.txt', 'one\n')!65+ first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!66+ write_file(dir, 'a.txt', 'two\n')!67+ second := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'two' })!68+69+ // effective position follows restores, so undo keeps stepping back.70+ assert effective(store)!.id == second.id71+ first_files := files_of(store, first.id)!72+ restore := commit_files(store, '', first_files, SaveInput{73+ message: 'undo: ${first.id}'74+ restores: first.id75+ })!76+ assert restore.parent == second.id77+ assert restore.restores == first.id78+ assert effective(store)!.id == first.id79+ // Walking back from the first save has nowhere to go; undo says so rather80+ // than looping.81+ if _ := step_back(store, effective(store)!.id, 1) {82+ assert false, 'there is nothing before the first save'83+ } else {84+ assert err.msg().contains('nothing before')85+ }86+87+ // The tree at head is the restored one.88+ assert same_files(store, head_id(store), first_files)!89+}90+91+fn same_files(store &storage.Store, id string, expected []snapshot.FileEntry) !bool {92+ actual := files_of(store, id)!93+ if actual.len != expected.len {94+ return false95+ }96+ for i in 0 .. actual.len {97+ if actual[i].path != expected[i].path || actual[i].hash != expected[i].hash {98+ return false99+ }100+ }101+ return true102+}103+104+fn test_resolve_accepts_a_unique_prefix() {105+ mut store, dir := temp_repo('resolve')!106+ defer {107+ store.close()108+ os.rmdir_all(dir) or {}109+ }110+ write_file(dir, 'a.txt', 'one\n')!111+ first := commit_worktree(store, dir, snapshot.Ignore{}, SaveInput{ message: 'one' })!112+ assert resolve(store, first.id[..6])!.id == first.id113+ if _ := resolve(store, 'abc') {114+ assert false, 'a prefix shorter than six characters should be refused'115+ } else {116+ assert err.msg().contains('at least 6')117+ }118+ if _ := resolve(store, 'zzzzzz') {119+ assert false, 'an unknown prefix should be refused'120+ } else {121+ assert err.msg().contains('no save starts with')122+ }123+}src/history/log_md.v+37 −0
@@ -0,0 +1,37 @@1+//! LOG.md: the one-screen history Koh keeps at the repo root for people (and2+//! agents) who want the last few saves without running a command.3+4+module history5+6+import os7+import time8+import src.storage9+10+const log_marker = '<!-- Koh maintains this file. Do not edit. Read before starting work. -->'11+const log_shown = 2012+13+pub fn write_log_md(store &storage.Store, root string) ! {14+ path := os.join_path(root, 'LOG.md')15+ if os.is_file(path) {16+ existing := os.read_file(path)!17+ if !existing.starts_with(log_marker) {18+ // Someone else's log file; leave it alone.19+ return20+ }21+ }22+ saves := line(store)!23+ shown := if saves.len < log_shown { saves.len } else { log_shown }24+ mut text := '${log_marker}\n\n# Koh Log\n\n'25+ for save in saves[..shown] {26+ text += '✦ [${save.id}] ${save.message} · ${clock(save.created_at)}\n'27+ }28+ if saves.len > shown {29+ text += '\n… ${saves.len - shown} older saves\n'30+ }31+ text += '\n*full history: koh log*\n'32+ os.write_file(path, text)!33+}34+35+fn clock(unix_seconds i64) string {36+ return time.unix(unix_seconds).local().format_ss()37+}src/machine/machine.v+18 −0
@@ -0,0 +1,18 @@1+//! Machine-local facts: the name recorded on saves, and where Koh keeps the2+//! settings that belong to this machine rather than to a repo.3+4+module machine5+6+import os7+8+pub fn name() string {9+ return os.hostname() or { 'unknown' }10+}11+12+pub fn config_dir() string {13+ return os.join_path(os.home_dir(), '.config', 'koh')14+}15+16+pub fn config_path(name string) string {17+ return os.join_path(config_dir(), name)18+}src/snapshot/diff.v+150 −0
@@ -0,0 +1,150 @@1+//! Comparing trees: which files changed, how much, and the unified diff.2+//!3+//! Both sides of a comparison are `Tree`s — the working tree (files on disk)4+//! or a save (files in the object store) — so every diff Koh offers, working5+//! tree against a save, save against save, is the same call.6+//!7+//! The unified diff itself comes from `diff(1)`: it is present on both target8+//! platforms, handles the edge cases, and keeps a thousand lines of line-matching9+//! code out of Koh. Koh only parses its output for the +/- counts.10+11+module snapshot12+13+import os14+import time15+import src.storage16+17+pub struct Tree {18+pub:19+ root string // '' when the tree's bytes come from the object store20+ files []FileEntry21+}22+23+pub struct Delta {24+pub mut:25+ path string26+ kind string // added, removed or modified27+ added int28+ removed int29+ binary bool30+ patch string31+}32+33+pub const binary_notice = 'Binary files differ'34+35+// diff_trees reports every path whose content differs, in path order.36+pub fn diff_trees(store &storage.Store, from Tree, to Tree) ![]Delta {37+ from_map := index_files(from.files)38+ to_map := index_files(to.files)39+ mut paths := []string{}40+ for path in from_map.keys() {41+ paths << path42+ }43+ for path in to_map.keys() {44+ if path !in from_map {45+ paths << path46+ }47+ }48+ paths.sort()49+ mut deltas := []Delta{}50+ work := temp_workspace()!51+ defer {52+ os.rmdir_all(work) or {}53+ }54+ for path in paths {55+ has_before := path in from_map56+ has_after := path in to_map57+ if has_before && has_after && from_map[path].hash == to_map[path].hash {58+ continue59+ }60+ kind := if !has_before {61+ 'added'62+ } else if !has_after {63+ 'removed'64+ } else {65+ 'modified'66+ }67+ mut from_bytes := []u8{}68+ if has_before {69+ from_bytes = bytes_of(store, from, from_map[path])!70+ }71+ mut to_bytes := []u8{}72+ if has_after {73+ to_bytes = bytes_of(store, to, to_map[path])!74+ }75+ mut delta := Delta{76+ path: path77+ kind: kind78+ }79+ if looks_binary(from_bytes) || looks_binary(to_bytes) {80+ delta.binary = true81+ delta.patch = binary_notice82+ } else {83+ delta.patch = run_diff(work, path, from_bytes, to_bytes)!84+ delta.added, delta.removed = count_lines(delta.patch)85+ }86+ deltas << delta87+ }88+ return deltas89+}90+91+pub fn index_files(files []FileEntry) map[string]FileEntry {92+ mut out := map[string]FileEntry{}93+ for file in files {94+ out[file.path] = file95+ }96+ return out97+}98+99+fn bytes_of(store &storage.Store, tree Tree, entry FileEntry) ![]u8 {100+ if tree.root == '' {101+ return store.get_object(entry.hash)!102+ }103+ return os.read_bytes(os.join_path(tree.root, entry.path))!104+}105+106+fn looks_binary(data []u8) bool {107+ limit := if data.len < 8000 { data.len } else { 8000 }108+ for i in 0 .. limit {109+ if data[i] == 0 {110+ return true111+ }112+ }113+ return false114+}115+116+fn count_lines(patch string) (int, int) {117+ mut added := 0118+ mut removed := 0119+ for line in patch.split_into_lines() {120+ if line.starts_with('+') && !line.starts_with('+++') {121+ added++122+ } else if line.starts_with('-') && !line.starts_with('---') {123+ removed++124+ }125+ }126+ return added, removed127+}128+129+fn temp_workspace() !string {130+ dir := os.join_path(os.temp_dir(), 'koh-diff-${os.getpid()}-${time.now().unix_nano()}')131+ os.mkdir_all(dir, os.MkdirParams{})!132+ return dir133+}134+135+fn run_diff(work string, path string, from_bytes []u8, to_bytes []u8) !string {136+ from_path := os.join_path(work, 'before')137+ to_path := os.join_path(work, 'after')138+ os.write_file_array(from_path, from_bytes)!139+ os.write_file_array(to_path, to_bytes)!140+ command := 'diff -u -L ${shell_quote('a/' + path)} -L ${shell_quote('b/' + path)} ${shell_quote(from_path)} ${shell_quote(to_path)}'141+ result := os.execute(command)142+ if result.exit_code < 0 {143+ return error('cannot run diff(1)')144+ }145+ return result.output146+}147+148+fn shell_quote(text string) string {149+ return "'" + text.replace("'", "'\\''") + "'"150+}src/snapshot/fs.v+62 −0
@@ -0,0 +1,62 @@1+//! Filesystem facts the walk needs: kind, size, mode and nanosecond times.2+//!3+//! V's own `os.Stat` reports mtime in whole seconds, which is too coarse for4+//! the file cache — a file edited twice inside one second would keep a stale5+//! hash and the next save would record the wrong content. `vendor/shim` gives6+//! the nanosecond times and tells symlinks apart from files in one call.7+8+module snapshot9+10+#flag -I@VMODROOT/vendor/shim11+#flag @VMODROOT/vendor/shim/koh_stat.c12+#include "koh_stat.h"13+14+pub struct C.koh_stat {15+ size i6416+ mtime_ns i6417+ ctime_ns i6418+ mode u3219+ kind int20+}21+22+fn C.koh_lstat(path &char, out &C.koh_stat) int23+24+pub enum Kind {25+ missing26+ file27+ dir28+ symlink29+ other30+}31+32+pub struct Stat {33+pub:34+ size i6435+ mtime_ns i6436+ ctime_ns i6437+ mode u3238+ kind Kind39+}40+41+pub fn lstat(path string) ?Stat {42+ mut raw := C.koh_stat{}43+ if C.koh_lstat(unsafe { &char(path.str) }, &raw) == 0 {44+ return none45+ }46+ return Stat{47+ size: raw.size48+ mtime_ns: raw.mtime_ns49+ ctime_ns: raw.ctime_ns50+ mode: raw.mode51+ kind: match raw.kind {52+ 1 { .file }53+ 2 { .dir }54+ 3 { .symlink }55+ else { .other }56+ }57+ }58+}59+60+pub fn is_executable(mode u32) bool {61+ return mode & 0o111 != 062+}src/snapshot/ignore.v+210 −0
@@ -0,0 +1,210 @@1+//! Ignore rules.2+//!3+//! `.kohignore` lives at the repo root and is itself snapshotted, so the rules4+//! travel between machines with the history. Patterns are matched against5+//! repo-relative paths: `*` matches inside one path segment, `**` spans6+//! segments, a trailing `/` restricts a pattern to directories, and a leading7+//! `/` anchors it to the repo root.8+9+module snapshot10+11+import os12+13+// The seed written by `koh init`, carried over from Koh 2's default list.14+pub const default_ignore = '# koh ignore rules (.kohignore)15+# Generated by koh init. Edit to fit the project.16+17+# Patterns are matched against repo-relative paths. Suffix `/` restricts18+# to directories. Leading `/` anchors to the repo root. A pattern like19+# `*.log` matches any file with that extension anywhere in the tree.20+#21+# koh already ignores .koh, .koh-wal, .koh-shm, .koh-v2/ and .git/ automatically.22+23+# Build output24+zig-out/25+.zig-cache/26+zig-cache/27+target/28+build/29+dist/30+out/31+32+# Dependencies33+node_modules/34+35+# OS noise36+.DS_Store37+Thumbs.db38+39+# Logs40+*.log41+42+# Koh artifacts43+*.bundle44+*.koh.offer45+'46+47+pub const ignore_file = '.kohignore'48+49+struct Pattern {50+ segments []string51+ dir_only bool52+ anchored bool53+}54+55+pub struct Ignore {56+ patterns []Pattern57+}58+59+// load reads `.kohignore`; a repo without one simply has no extra rules.60+pub fn load(root string) Ignore {61+ path := os.join_path(root, ignore_file)62+ if !os.is_file(path) {63+ return Ignore{}64+ }65+ return parse(os.read_file(path) or { return Ignore{} })66+}67+68+pub fn parse(text string) Ignore {69+ mut patterns := []Pattern{}70+ for raw in text.split_into_lines() {71+ line := raw.trim_space()72+ if line == '' || line.starts_with('#') {73+ continue74+ }75+ mut body := line76+ mut dir_only := false77+ if body.ends_with('/') {78+ dir_only = true79+ body = body.trim_right('/')80+ }81+ mut anchored := false82+ if body.starts_with('/') {83+ anchored = true84+ body = body.trim_left('/')85+ }86+ if body == '' {87+ continue88+ }89+ patterns << Pattern{90+ segments: body.split('/')91+ dir_only: dir_only92+ anchored: anchored93+ }94+ }95+ return Ignore{96+ patterns: patterns97+ }98+}99+100+// hard_ignored covers the files Koh writes into a repo as part of working101+// there. They are never snapshotted whatever `.kohignore` says.102+//103+// LOG.md is rewritten after every save from the history itself. Tracking it104+// would make the tree dirty the moment a save lands, so `status` could never105+// report a clean tree and every diff would carry the log's own churn.106+pub fn hard_ignored(rel string) bool {107+ if rel == '.koh' || rel == '.koh-wal' || rel == '.koh-shm' {108+ return true109+ }110+ if rel == '.koh-v2' || rel.starts_with('.koh-v2/') {111+ return true112+ }113+ if rel == '.git' || rel.starts_with('.git/') {114+ return true115+ }116+ if rel == 'LOG.md' {117+ return true118+ }119+ return false120+}121+122+pub fn (ig Ignore) matches(rel string) bool {123+ if hard_ignored(rel) {124+ return true125+ }126+ parts := rel.split('/')127+ for pat in ig.patterns {128+ if ig.matches_pattern(parts, pat) {129+ return true130+ }131+ }132+ return false133+}134+135+fn (ig Ignore) matches_pattern(parts []string, pat Pattern) bool {136+ if pat.anchored {137+ // A directory pattern matches any path below that directory.138+ if pat.dir_only {139+ return parts.len >= 2 && match_segments(parts, 0, pat.segments, 0, true)140+ }141+ return match_segments(parts, 0, pat.segments, 0, false)142+ }143+ if pat.dir_only {144+ for start in 0 .. parts.len {145+ if parts.len - start >= 2 && match_segments(parts, start, pat.segments, 0, true) {146+ return true147+ }148+ }149+ return false150+ }151+ for start in 0 .. parts.len {152+ if match_segments(parts, start, pat.segments, 0, false) {153+ return true154+ }155+ }156+ return false157+}158+159+// match_segments consumes `pat` from `gi` over `path` from `pi`.160+// `allow_trailing` lets the path keep segments the pattern does not name,161+// which is what makes a directory pattern cover everything beneath it.162+fn match_segments(path []string, pi int, pat []string, gi int, allow_trailing bool) bool {163+ if gi == pat.len {164+ return allow_trailing || pi == path.len165+ }166+ seg := pat[gi]167+ if seg == '**' {168+ for skip in pi .. path.len + 1 {169+ if match_segments(path, skip, pat, gi + 1, allow_trailing) {170+ return true171+ }172+ }173+ return false174+ }175+ if pi >= path.len {176+ return false177+ }178+ if !match_segment(path[pi], seg) {179+ return false180+ }181+ return match_segments(path, pi + 1, pat, gi + 1, allow_trailing)182+}183+184+// match_segment is shell-style matching within one path segment.185+fn match_segment(name string, pat string) bool {186+ mut n := 0187+ mut p := 0188+ mut star := -1189+ mut mark := 0190+ for n < name.len {191+ if p < pat.len && (pat[p] == name[n] || pat[p] == `?`) {192+ n++193+ p++194+ } else if p < pat.len && pat[p] == `*` {195+ star = p196+ mark = n197+ p++198+ } else if star >= 0 {199+ p = star + 1200+ mark++201+ n = mark202+ } else {203+ return false204+ }205+ }206+ for p < pat.len && pat[p] == `*` {207+ p++208+ }209+ return p == pat.len210+}src/snapshot/restore.v+59 −0
@@ -0,0 +1,59 @@1+//! Writing a tree back onto disk.2+//!3+//! A restore writes exactly the target's files and deletes the tracked files4+//! the target does not have. Ignored paths are never touched, and every file5+//! lands through a temp file and a rename, so an interrupted restore leaves a6+//! readable tree rather than half-written files.7+8+module snapshot9+10+import os11+import src.storage12+13+pub struct RestoreReport {14+pub:15+ written int16+ removed int17+}18+19+pub fn restore_files(store &storage.Store, root string, target []FileEntry, remove []string) !RestoreReport {20+ mut removed := 021+ for path in remove {22+ full := os.join_path(root, path)23+ if !os.is_file(full) {24+ continue25+ }26+ os.rm(full)!27+ removed++28+ prune_empty_dirs(root, os.dir(full))29+ }30+ for entry in target {31+ data := store.get_object(entry.hash)!32+ write_file(os.join_path(root, entry.path), data, entry.executable)!33+ }34+ return RestoreReport{35+ written: target.len36+ removed: removed37+ }38+}39+40+fn write_file(path string, data []u8, executable bool) ! {41+ os.mkdir_all(os.dir(path), os.MkdirParams{})!42+ tmp := '${path}.koh-tmp-${os.getpid()}'43+ os.write_file_array(tmp, data)!44+ mode := if executable { 0o755 } else { 0o644 }45+ os.chmod(tmp, mode)!46+ os.mv(tmp, path, os.MvParams{})!47+}48+49+// prune_empty_dirs clears directories a restore emptied, never the repo root.50+fn prune_empty_dirs(root string, dir string) {51+ mut current := dir52+ for current.len > root.len && current.starts_with(root) {53+ if (os.ls(current) or { return }).len > 0 {54+ return55+ }56+ os.rmdir(current) or { return }57+ current = os.dir(current)58+ }59+}src/snapshot/tree.v+100 −0
@@ -0,0 +1,100 @@1+//! Walking the working tree: ignore rules, hashing, and the file cache.2+3+module snapshot4+5+import os6+import src.storage7+8+// One tracked file in one tree.9+pub struct FileEntry {10+pub:11+ path string12+ hash string13+ size i6414+ executable bool15+}16+17+// walk hashes every tracked file under `root` and returns them sorted by path.18+// Symlinks are skipped, as Koh 2 skipped them, so migration stays lossless.19+pub fn walk(store &storage.Store, root string, ig Ignore) ![]FileEntry {20+ cache := store.load_cache()!21+ mut entries := []FileEntry{}22+ walk_dir(store, root, '', ig, cache, mut entries)!23+ entries.sort(a.path < b.path)24+ return entries25+}26+27+fn walk_dir(store &storage.Store, root string, rel string, ig Ignore, cache map[string]storage.CacheRow, mut out []FileEntry) ! {28+ dir := if rel == '' { root } else { os.join_path(root, rel) }29+ for name in os.ls(dir)! {30+ child := if rel == '' { name } else { '${rel}/${name}' }31+ if ig.matches(child) {32+ continue33+ }34+ st := lstat(os.join_path(root, child)) or { continue }35+ match st.kind {36+ .dir {37+ walk_dir(store, root, child, ig, cache, mut out)!38+ }39+ .file {40+ if st.size > storage.max_file_size {41+ return error('${child} is ${st.size} bytes; koh stores code and text, not files this large')42+ }43+ hash := hash_file(store, root, child, st, cache)!44+ out << FileEntry{45+ path: child46+ hash: hash47+ size: st.size48+ executable: is_executable(st.mode)49+ }50+ }51+ else {}52+ }53+ }54+}55+56+fn hash_file(store &storage.Store, root string, rel string, st Stat, cache map[string]storage.CacheRow) !string {57+ if row := cache[rel] {58+ if row.size == st.size && row.mtime_ns == st.mtime_ns && row.ctime_ns == st.ctime_ns {59+ return row.hash60+ }61+ }62+ data := os.read_bytes(os.join_path(root, rel))!63+ hash := storage.hash_hex(data)64+ store.save_cache(storage.CacheRow{65+ path: rel66+ size: st.size67+ mtime_ns: st.mtime_ns68+ ctime_ns: st.ctime_ns69+ hash: hash70+ })!71+ return hash72+}73+74+// store_objects writes the bytes of every entry the database does not have75+// yet, so a save only pays for content it has never seen.76+pub fn store_objects(store &storage.Store, root string, entries []FileEntry) ! {77+ for entry in entries {78+ if store.has_object(entry.hash)! {79+ continue80+ }81+ data := os.read_bytes(os.join_path(root, entry.path))!82+ stored := store.put_object(data)!83+ if stored != entry.hash {84+ return error('${entry.path} changed while saving')85+ }86+ }87+}88+89+// same_tree compares two sorted entry lists by path, hash and executable bit.90+pub fn same_tree(a []FileEntry, b []FileEntry) bool {91+ if a.len != b.len {92+ return false93+ }94+ for i in 0 .. a.len {95+ if a[i].path != b[i].path || a[i].hash != b[i].hash || a[i].executable != b[i].executable {96+ return false97+ }98+ }99+ return true100+}src/storage/cache.v+64 −0
@@ -0,0 +1,64 @@1+//! The file cache: a machine-local shortcut so `status` and `save` can skip2+//! files whose size, mtime and ctime are unchanged. It caches the working3+//! tree, never history, and is safe to drop at any time.4+5+module storage6+7+pub struct CacheRow {8+pub:9+ path string10+ size i6411+ mtime_ns i6412+ ctime_ns i6413+ hash string14+}15+16+pub fn (s &Store) load_cache() !map[string]CacheRow {17+ mut rows := map[string]CacheRow{}18+ mut stmt := s.db.prepare('SELECT path, size, mtime_ns, ctime_ns, hash FROM file_cache')!19+ defer {20+ stmt.finalize()21+ }22+ for stmt.step()! {23+ row := CacheRow{24+ path: stmt.text(0)25+ size: stmt.int(1)26+ mtime_ns: stmt.int(2)27+ ctime_ns: stmt.int(3)28+ hash: stmt.text(4)29+ }30+ rows[row.path] = row31+ }32+ return rows33+}34+35+pub fn (s &Store) save_cache(row CacheRow) ! {36+ mut stmt := s.db.prepare('INSERT INTO file_cache (path, size, mtime_ns, ctime_ns, hash)37+ VALUES (?, ?, ?, ?, ?)38+ ON CONFLICT(path) DO UPDATE SET size = excluded.size, mtime_ns = excluded.mtime_ns,39+ ctime_ns = excluded.ctime_ns, hash = excluded.hash')!40+ defer {41+ stmt.finalize()42+ }43+ stmt.bind_text(1, row.path)!44+ stmt.bind_int(2, row.size)!45+ stmt.bind_int(3, row.mtime_ns)!46+ stmt.bind_int(4, row.ctime_ns)!47+ stmt.bind_text(5, row.hash)!48+ stmt.step()!49+}50+51+pub fn (s &Store) drop_cache() ! {52+ s.db.exec('DELETE FROM file_cache')!53+}54+55+pub fn (s &Store) cache_count() !i64 {56+ mut stmt := s.db.prepare('SELECT COUNT(*) FROM file_cache')!57+ defer {58+ stmt.finalize()59+ }60+ if !stmt.step()! {61+ return 062+ }63+ return stmt.int(0)64+}src/storage/objects.v+100 −0
@@ -0,0 +1,100 @@1+//! The object table: content-addressed file bytes.2+//!3+//! Objects are Blake3-addressed and stored zlib-compressed, exactly as Koh 24+//! stored them on disk, so the same bytes migrate without a re-hash and a5+//! future reader can decompress them with any zlib. Objects are shared between6+//! saves: identical content is stored once.7+8+module storage9+10+import compress.zlib11+import crypto.blake312+13+pub const max_file_size = 512 * 1024 * 102414+15+pub fn hash_hex(data []u8) string {16+ return blake3.sum256(data).hex()17+}18+19+// put_object stores `data` and returns its hash. Writing an object that is20+// already present is a no-op, which is what makes repeated saves cheap.21+pub fn (s &Store) put_object(data []u8) !string {22+ hash := hash_hex(data)23+ if s.has_object(hash)! {24+ return hash25+ }26+ packed := zlib.compress(data)!27+ mut stmt := s.db.prepare('INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)')!28+ defer {29+ stmt.finalize()30+ }31+ stmt.bind_text(1, hash)!32+ stmt.bind_int(2, i64(data.len))!33+ stmt.bind_blob(3, packed)!34+ stmt.step()!35+ return hash36+}37+38+pub fn (s &Store) has_object(hash string) !bool {39+ mut stmt := s.db.prepare('SELECT 1 FROM objects WHERE hash = ?')!40+ defer {41+ stmt.finalize()42+ }43+ stmt.bind_text(1, hash)!44+ return stmt.step()!45+}46+47+// get_object returns the uncompressed bytes and refuses anything that does not48+// hash back to the key it was asked for.49+pub fn (s &Store) get_object(hash string) ![]u8 {50+ mut stmt := s.db.prepare('SELECT data FROM objects WHERE hash = ?')!51+ defer {52+ stmt.finalize()53+ }54+ stmt.bind_text(1, hash)!55+ if !stmt.step()! {56+ return error('object ${hash} is missing')57+ }58+ packed := stmt.blob(0)59+ data := zlib.decompress(packed)!60+ if hash_hex(data) != hash {61+ return error('object ${hash} does not match its hash')62+ }63+ return data64+}65+66+pub fn (s &Store) object_size(hash string) !i64 {67+ mut stmt := s.db.prepare('SELECT size FROM objects WHERE hash = ?')!68+ defer {69+ stmt.finalize()70+ }71+ stmt.bind_text(1, hash)!72+ if !stmt.step()! {73+ return error('object ${hash} is missing')74+ }75+ return stmt.int(0)76+}77+78+pub fn (s &Store) object_count() !i64 {79+ mut stmt := s.db.prepare('SELECT COUNT(*) FROM objects')!80+ defer {81+ stmt.finalize()82+ }83+ if !stmt.step()! {84+ return 085+ }86+ return stmt.int(0)87+}88+89+// drop_unreferenced_objects removes objects no save points at any more. Only90+// `strip` leaves objects behind, so this is what makes a strip permanent.91+pub fn (s &Store) drop_unreferenced_objects() !i64 {92+ before := s.object_count()!93+ mut stmt := s.db.prepare('DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)')!94+ defer {95+ stmt.finalize()96+ }97+ stmt.step()!98+ after := s.object_count()!99+ return before - after100+}src/storage/sqlite.v+15 −5
@@ -87,9 +87,9 @@8787 conn &C.sqlite3 = unsafe { nil }8888 }8989 90-// open a database file, creating it when missing. `busy_timeout_ms` is how91-// long a second writer waits before giving up.92-pub fn open(path string, busy_timeout_ms int) !Db {90+// connect opens a database file, creating it when missing. `busy_timeout_ms`91+// is how long a second writer waits before giving up.92+pub fn connect(path string, busy_timeout_ms int) !Db {9393 path_c := cstring(path)9494 defer {9595 unsafe { free(path_c) }@@ -196,7 +196,9 @@196196 }197197 }198198 199-// step advances the statement. Returns true while rows are available.199+// step advances the statement. Returns true while rows are available. Rows of200+// a result are read with text/blob/int between steps; a statement that has201+// returned false must be reset() before it can be bound and run again.200202 pub fn (stmt &Stmt) step() !bool {201203 code := C.sqlite3_step(stmt.stmt)202204 match code {@@ -220,13 +222,21 @@220222 return C.sqlite3_column_type(stmt.stmt, idx) == sqlite_null221223 }222224 225+// text copies the column out of SQLite's row buffer. It must copy: SQLite owns226+// that memory only until the statement is stepped again or finalized, and a V227+// string built by aliasing it would point at freed bytes.223228 pub fn (stmt &Stmt) text(idx int) string {224229 ptr := C.sqlite3_column_text(stmt.stmt, idx)225230 if ptr == unsafe { nil } {226231 return ''227232 }228233 n := C.sqlite3_column_bytes(stmt.stmt, idx)229- return unsafe { ptr.vstring_with_len(n) }234+ if n == 0 {235+ return ''236+ }237+ mut out := []u8{len: n}238+ unsafe { vmemcpy(out.data, ptr, n) }239+ return out.bytestr()230240 }231241 232242 pub fn (stmt &Stmt) blob(idx int) []u8 {src/storage/sqlite_test.v+1 −1
@@ -5,7 +5,7 @@55 fn temp_db(name string) !(Db, string) {66 dir := os.join_path(os.temp_dir(), 'koh3_test_${os.getpid()}_${name}')77 os.mkdir_all(dir)!8- db := open(os.join_path(dir, 'test.db'), 5000)!8+ db := connect(os.join_path(dir, 'test.db'), 5000)!99 return db, dir1010 }1111 src/storage/storage.v+222 −0
@@ -0,0 +1,222 @@1+//! The `.koh` file: schema, connection settings and machine-local state.2+//!3+//! One file at the repo root holds everything — objects, saves, events and4+//! bookkeeping. There is no second source of truth, so there is nothing to5+//! rebuild, verify against, or recover.6+7+module storage8+9+import os10+11+pub const schema_version = 112+pub const file_name = '.koh'13+pub const busy_timeout_ms = 500014+15+// Every fact Koh knows lives in these six tables. `state` and `file_cache` are16+// machine-local: they are never synced and may be dropped at any time.17+const schema = '18+CREATE TABLE objects (19+ hash TEXT PRIMARY KEY,20+ size INTEGER NOT NULL,21+ data BLOB NOT NULL22+);23+24+CREATE TABLE saves (25+ id TEXT PRIMARY KEY,26+ parent TEXT REFERENCES saves(id),27+ message TEXT NOT NULL,28+ created_at INTEGER NOT NULL,29+ machine TEXT NOT NULL,30+ is_checkpoint INTEGER NOT NULL DEFAULT 0,31+ size_bytes INTEGER NOT NULL DEFAULT 0,32+ agent TEXT,33+ session TEXT,34+ restores TEXT,35+ replayed_from TEXT36+);37+38+CREATE TABLE save_files (39+ save_id TEXT NOT NULL REFERENCES saves(id),40+ path TEXT NOT NULL,41+ hash TEXT NOT NULL REFERENCES objects(hash),42+ executable INTEGER NOT NULL DEFAULT 0,43+ PRIMARY KEY (save_id, path)44+);45+46+CREATE TABLE events (47+ id TEXT PRIMARY KEY,48+ kind TEXT NOT NULL,49+ save_id TEXT,50+ path TEXT,51+ reason TEXT,52+ created_at INTEGER NOT NULL,53+ machine TEXT NOT NULL54+);55+56+CREATE TABLE state (57+ key TEXT PRIMARY KEY,58+ value TEXT NOT NULL59+);60+61+CREATE TABLE file_cache (62+ path TEXT PRIMARY KEY,63+ size INTEGER NOT NULL,64+ mtime_ns INTEGER NOT NULL,65+ ctime_ns INTEGER NOT NULL,66+ hash TEXT NOT NULL67+);68+69+CREATE INDEX saves_parent ON saves(parent);70+CREATE INDEX save_files_hash ON save_files(hash);71+'72+73+pub const head_key = 'head'74+75+@[heap]76+pub struct Store {77+pub mut:78+ db Db79+ root string80+}81+82+// find_root walks up from `start` until it finds a `.koh` file.83+pub fn find_root(start string) ?string {84+ mut dir := os.real_path(start)85+ for {86+ if os.is_file(os.join_path(dir, file_name)) {87+ return dir88+ }89+ parent := os.dir(dir)90+ if parent == dir || parent == '' {91+ return none92+ }93+ dir = parent94+ }95+ return none96+}97+98+// open connects to the repo at `root`. A missing `.koh` is an error, not a99+// silent creation: `koh init` is the only thing that creates a repo.100+pub fn open(root string) !Store {101+ path := os.join_path(root, file_name)102+ if !os.is_file(path) {103+ return error('no ${file_name} in ${root} — run "koh init"')104+ }105+ db := connect(path, busy_timeout_ms)!106+ apply_settings(db)!107+ check_version(db)!108+ return Store{109+ db: db110+ root: root111+ }112+}113+114+// create writes a new `.koh` in `root` and seeds the default ignore file.115+pub fn create(root string) !Store {116+ path := os.join_path(root, file_name)117+ if os.is_file(path) {118+ return error('${path} already exists')119+ }120+ db := connect(path, busy_timeout_ms)!121+ apply_settings(db)!122+ db.exec('BEGIN IMMEDIATE')!123+ db.exec(schema)!124+ db.exec('PRAGMA user_version = ${schema_version}')!125+ db.exec('COMMIT')!126+ return Store{127+ db: db128+ root: root129+ }130+}131+132+pub fn (s &Store) close() {133+ s.db.close()134+}135+136+fn apply_settings(db &Db) ! {137+ // WAL for crash safety, full sync so a committed save is on disk, foreign138+ // keys so a half-written save cannot exist.139+ db.exec('PRAGMA journal_mode=WAL')!140+ db.exec('PRAGMA synchronous=FULL')!141+ db.exec('PRAGMA foreign_keys=ON')!142+}143+144+fn check_version(db &Db) ! {145+ mut stmt := db.prepare('PRAGMA user_version')!146+ version := if stmt.step()! { stmt.int(0) } else { 0 }147+ stmt.finalize()148+ if version > schema_version {149+ return error('this .koh was written by a newer koh (schema ${version}, this build knows ${schema_version})')150+ }151+ if version < schema_version {152+ return error('this .koh needs an upgrade koh does not know how to do (schema ${version})')153+ }154+}155+156+pub fn (s &Store) begin() ! {157+ s.db.exec('BEGIN IMMEDIATE')!158+}159+160+pub fn (s &Store) commit() ! {161+ s.db.exec('COMMIT')!162+}163+164+pub fn (s &Store) rollback() {165+ s.db.exec('ROLLBACK') or {}166+}167+168+pub fn (s &Store) state(key string) ?string {169+ mut stmt := s.db.prepare('SELECT value FROM state WHERE key = ?') or { return none }170+ defer {171+ stmt.finalize()172+ }173+ stmt.bind_text(1, key) or { return none }174+ if stmt.step() or { return none } {175+ return stmt.text(0)176+ }177+ return none178+}179+180+pub fn (s &Store) set_state(key string, value string) ! {181+ mut stmt := s.db.prepare('INSERT INTO state (key, value) VALUES (?, ?)182+ ON CONFLICT(key) DO UPDATE SET value = excluded.value')!183+ defer {184+ stmt.finalize()185+ }186+ stmt.bind_text(1, key)!187+ stmt.bind_text(2, value)!188+ stmt.step()!189+}190+191+pub fn (s &Store) remove_state(key string) ! {192+ mut stmt := s.db.prepare('DELETE FROM state WHERE key = ?')!193+ defer {194+ stmt.finalize()195+ }196+ stmt.bind_text(1, key)!197+ stmt.step()!198+}199+200+// backup writes a consistent copy of the database. `VACUUM INTO` never copies201+// a WAL mid-write, which a raw file copy would.202+pub fn (s &Store) backup(path string) ! {203+ if os.exists(path) {204+ return error('${path} already exists')205+ }206+ s.db.exec("VACUUM INTO '${path.replace("'", "''")}'")!207+}208+209+pub fn (s &Store) integrity_check() ![]string {210+ mut problems := []string{}211+ mut stmt := s.db.prepare('PRAGMA integrity_check')!212+ defer {213+ stmt.finalize()214+ }215+ for stmt.step()! {216+ line := stmt.text(0)217+ if line != 'ok' {218+ problems << line219+ }220+ }221+ return problems222+}vendor/shim/koh_stat.c+32 −0
@@ -0,0 +1,32 @@1+#include "koh_stat.h"2+3+#include <sys/stat.h>4+5+#if defined(__APPLE__)6+#define KOH_SEC_NS(tv) ((long long)(tv).tv_sec * 1000000000LL + (long long)(tv).tv_nsec)7+#define KOH_MTIME_NS(st) KOH_SEC_NS((st).st_mtimespec)8+#define KOH_CTIME_NS(st) KOH_SEC_NS((st).st_ctimespec)9+#else10+#define KOH_SEC_NS(tv) ((long long)(tv).tv_sec * 1000000000LL + (long long)(tv).tv_nsec)11+#define KOH_MTIME_NS(st) KOH_SEC_NS((st).st_mtim)12+#define KOH_CTIME_NS(st) KOH_SEC_NS((st).st_ctim)13+#endif14+15+int koh_lstat(const char *path, koh_stat *out) {16+ struct stat st;17+ if (lstat(path, &st) != 0) {18+ out->kind = 0;19+ return 0;20+ }21+ out->size = (long long)st.st_size;22+ out->mtime_ns = KOH_MTIME_NS(st);23+ out->ctime_ns = KOH_CTIME_NS(st);24+ out->mode = (unsigned int)st.st_mode;25+ switch (st.st_mode & S_IFMT) {26+ case S_IFREG: out->kind = 1; break;27+ case S_IFDIR: out->kind = 2; break;28+ case S_IFLNK: out->kind = 3; break;29+ default: out->kind = 4; break;30+ }31+ return 1;32+}vendor/shim/koh_stat.h+25 −0
@@ -0,0 +1,25 @@1+/* Nanosecond-precision lstat for the working-tree walk.2+ *3+ * V's `os.Stat` exposes mtime and ctime in whole seconds. The file cache in4+ * `src/snapshot` decides whether a file may skip hashing by comparing those5+ * timestamps, so second precision would let a file edited twice inside one6+ * second keep a stale hash — and a save would then record the wrong content.7+ * This shim reports nanoseconds, plus the file kind, which the walker needs to8+ * skip symlinks and directories without a second stat call.9+ */10+11+#ifndef KOH_STAT_H12+#define KOH_STAT_H13+14+typedef struct koh_stat {15+ long long size;16+ long long mtime_ns;17+ long long ctime_ns;18+ unsigned int mode;19+ int kind; /* 0 missing, 1 file, 2 dir, 3 symlink, 4 other */20+} koh_stat;21+22+/* Fills `out`; returns 1 on success, 0 when the path cannot be lstat'ed. */23+int koh_lstat(const char *path, koh_stat *out);24+25+#endif