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
29 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 −0
src/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+}

view file

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 	}

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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

view file

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 {

view file

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 

view file

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

view file

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

view file

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

view file