a15db60fd5c1

add phase 3 sync: remote interface, folder and s3 backends, offer, steal and combine

time
2026-09-23 20:18 UTC · 2 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
koh 3 implementation
parent
942d08c78d27
24 files src/apply/apply.v+16 −4src/apply/apply_test.v+9 −9src/cli/cli.v+14 −0src/cli/cmd_offer.v+76 −0src/cli/cmd_pluck.v+1 −1src/cli/cmd_remote.v+213 −0src/cli/cmd_save.v+2 −0src/cli/cmd_steal.v+196 −0src/history/commit.v+23 −2src/history/history.v+0 −13src/machine/settings.v+111 −0src/machine/settings_test.v+42 −0src/remote/folder.v+159 −0src/remote/folder_test.v+91 −0src/remote/remote.v+50 −0src/remote/s3.v+258 −0src/remote/s3_test.v+135 −0src/remote/sigv4.v+152 −0src/storage/objects.v+35 −0src/sync/records.v+166 −0src/sync/steal.v+360 −0src/sync/sync.v+233 −0src/sync/sync_test.v+491 −0tests/cli_test.v+126 −0
src/apply/apply.v+16 −4
@@ -22,20 +22,23 @@2222 }2323 2424 // three_way applies the change from `base` to `target` onto `current`.25-// `filters` limits it to those paths or folders; an empty list means everything.26-pub fn three_way(current []snapshot.FileEntry, base []snapshot.FileEntry, target []snapshot.FileEntry, filters []string) Plan {25+//26+// `filters` limits it to those paths or folders; an empty list means27+// everything. `exclude` drops paths outright, which is how a combine leaves the28+// clash files for the caller to decide.29+pub fn three_way(current []snapshot.FileEntry, base []snapshot.FileEntry, target []snapshot.FileEntry, filters []string, exclude []string) Plan {2730 	base_map := snapshot.index_files(base)2831 	target_map := snapshot.index_files(target)2932 	current_map := snapshot.index_files(current)3033 3134 	mut paths := []string{}3235 	for path in base_map.keys() {33-		if in_scope(path, filters) {36+		if in_scope(path, filters) && !excluded(path, exclude) {3437 			paths << path3538 		}3639 	}3740 	for path in target_map.keys() {38-		if in_scope(path, filters) && path !in base_map {41+		if in_scope(path, filters) && !excluded(path, exclude) && path !in base_map {3942 			paths << path4043 		}4144 	}@@ -86,6 +89,15 @@8689 	for raw in filters {8790 		filter := raw.trim_right('/')8891 		if path == filter || path.starts_with('${filter}/') {92+			return true93+		}94+	}95+	return false96+}97+98+fn excluded(path string, exclude []string) bool {99+	for name in exclude {100+		if path == name {89101 			return true90102 		}91103 	}

view file

src/apply/apply_test.v+9 −9
@@ -22,7 +22,7 @@2222 	base := [entry('a.txt', 'aaa'), entry('b.txt', 'bbb')]2323 	target := [entry('a.txt', 'AAA'), entry('b.txt', 'bbb')]2424 25-	plan := three_way(current, base, target, [])25+	plan := three_way(current, base, target, [], [])2626 	assert !conflicts(plan)2727 	assert plan.applied == ['a.txt']2828 	assert hashes(plan.files) == {@@ -36,7 +36,7 @@3636 	base := [entry('a.txt', 'aaa')]3737 	target := [entry('a.txt', 'AAA')]3838 39-	plan := three_way(current, base, target, [])39+	plan := three_way(current, base, target, [], [])4040 	assert !conflicts(plan)4141 	assert plan.applied.len == 04242 	assert hashes(plan.files) == {@@ -49,7 +49,7 @@4949 	base := [entry('a.txt', 'aaa')]5050 	target := [entry('a.txt', 'theirs')]5151 52-	plan := three_way(current, base, target, [])52+	plan := three_way(current, base, target, [], [])5353 	assert conflicts(plan)5454 	assert plan.conflict == 'a.txt'5555 }@@ -59,7 +59,7 @@5959 	base := [entry('gone.txt', 'bye')]6060 	target := [entry('added.txt', 'new')]6161 62-	plan := three_way(current, base, target, [])62+	plan := three_way(current, base, target, [], [])6363 	assert !conflicts(plan)6464 	assert plan.applied == ['added.txt', 'gone.txt']6565 	assert hashes(plan.files) == {@@ -73,7 +73,7 @@7373 	base := [entry('src/a.txt', 'aaa'), entry('docs/b.txt', 'bbb')]7474 	target := [entry('src/a.txt', 'AAA'), entry('docs/b.txt', 'BBB')]7575 76-	plan := three_way(current, base, target, ['src'])76+	plan := three_way(current, base, target, ['src'], [])7777 	assert plan.applied == ['src/a.txt']7878 	assert hashes(plan.files) == {7979 		'src/a.txt':  'AAA'@@ -82,7 +82,7 @@8282 8383 	// A folder filter also covers everything beneath it, with or without the8484 	// trailing slash.85-	plan2 := three_way(current, base, target, ['docs/'])85+	plan2 := three_way(current, base, target, ['docs/'], [])8686 	assert plan2.applied == ['docs/b.txt']8787 }8888 @@ -93,10 +93,10 @@9393 	original := [entry('a.txt', 'aaa')]9494 	changed := [entry('a.txt', 'AAA')]9595 96-	forward := three_way(current, original, changed, [])96+	forward := three_way(current, original, changed, [], [])9797 	assert forward.applied.len == 09898 99-	back := three_way(current, changed, original, [])99+	back := three_way(current, changed, original, [], [])100100 	assert !conflicts(back)101101 	assert hashes(back.files) == {102102 		'a.txt': 'aaa'@@ -120,7 +120,7 @@120120 		},121121 	]122122 123-	plan := three_way(current, base, target, [])123+	plan := three_way(current, base, target, [], [])124124 	assert !conflicts(plan)125125 	assert plan.applied == ['run.sh']126126 	assert plan.files[0].executable

view file

src/cli/cli.v+14 −0
@@ -24,6 +24,11 @@2424   revert                                         discard unsaved changes2525   load <id> | --checkpoint                       restore any save2626 27+sync28+  remote                                         choose S3 or a folder, once per machine29+  offer                                          send new saves to the remote30+  steal [<name>] [--list] [--mine|--theirs]      bring in saves from the remote31+2732 occasional2833   flag <id> [--reason "..."] | --clear <id>      mark a save unsafe2934   pluck <id> [--reverse] [--dry-run] [path...]   apply one save, or its inverse@@ -99,6 +104,15 @@99104 		}100105 		'strip' {101106 			return cmd_strip(rest)107+		}108+		'remote' {109+			return cmd_remote(rest)110+		}111+		'offer' {112+			return cmd_offer(rest)113+		}114+		'steal' {115+			return cmd_steal(rest)102116 		}103117 		else {104118 			eprintln('  koh: unknown command "${argv[1]}"')

view file

src/cli/cmd_offer.v+76 −0
@@ -0,0 +1,76 @@1+//! `koh offer` — send the local saves to this machine's remote.2+3+module cli4+5+import json6+import src.machine7+import src.remote8+import src.storage9+import src.sync10+11+pub fn cmd_offer(raw []string) int {12+	args := parse('offer', raw, Options{13+		flags: ['json']14+	}) or { return usage_error(err.msg()) }15+	store := open_store() or { return refuse(args.json(), err.msg()) }16+	defer {17+		store.close()18+	}19+	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }20+	report := sync.offer(store, mut backend) or { return refuse(args.json(), err.msg()) }21+	if args.json() {22+		emit_json(json.encode(OfferedJson{23+			repo:    report.repo24+			saves:   report.saves25+			objects: report.objects26+			events:  report.events27+			head:    report.head28+		}))29+		return 030+	}31+	if report.saves + report.objects + report.events == 0 {32+		ok('the remote already has everything from [${report.head}]')33+		return 034+	}35+	ok('offered ${report.saves} saves · ${report.objects} objects · ${report.events} events to "${report.repo}"')36+	note('remote head is now [${report.head}]')37+	return 038+}39+40+// this_machine_backend builds the backend from the machine's saved remote.41+pub fn this_machine_backend() !remote.Backend {42+	settings := machine.load_remote()!43+	if settings.kind == machine.kind_folder {44+		return remote.new_folder(settings.path)!45+	}46+	credentials := machine.load_credentials()!47+	return remote.new_s3(settings.endpoint, settings.bucket, settings.region,48+		credentials.access_key, credentials.secret_key)!49+}50+51+// auto_offer runs after every save and checkpoint. A remote that is not set up,52+// or not reachable, must never fail the save: it prints one line and stops.53+pub fn auto_offer(store &storage.Store) {54+	if !machine.has_remote() {55+		return56+	}57+	mut backend := this_machine_backend() or {58+		note('not offered: ${err.msg()}')59+		return60+	}61+	report := sync.offer(store, mut backend) or {62+		note('not offered: ${err.msg()}')63+		return64+	}65+	if report.saves + report.objects + report.events > 0 {66+		note('offered to "${report.repo}" — ${report.saves} saves · ${report.objects} objects')67+	}68+}69+70+struct OfferedJson {71+	repo    string72+	saves   int73+	objects int74+	events  int75+	head    string76+}

view file

src/cli/cmd_pluck.v+1 −1
@@ -43,7 +43,7 @@4343 	base := if reverse { source_tree } else { parent_tree }4444 	target := if reverse { parent_tree } else { source_tree }4545 46-	plan := apply.three_way(current.files, base.files, target.files, filters)46+	plan := apply.three_way(current.files, base.files, target.files, filters, [])4747 	if apply.conflicts(plan) {4848 		return refuse(args.json(),4949 			'${plan.conflict} has changed since [${source.id}]; koh does not merge lines — resolve it by hand, or load the save and re-edit')

view file

src/cli/cmd_remote.v+213 −0
@@ -0,0 +1,213 @@1+//! `koh remote` — the machine's remote, set up once and used by every repo.2+//!3+//! Setup is interactive by default, because it ends with a live connection4+//! test: a remote is only saved once it has proved it can store an object and5+//! refuse to overwrite it. `folder` and `s3` forms are available for a machine6+//! being set up by a script, and an agent can read the current setting with7+//! `--json` (which never prints the secret key).8+9+module cli10+11+import json12+import os13+import src.machine14+import src.remote15+16+pub fn cmd_remote(raw []string) int {17+	args := parse('remote', raw, Options{18+		flags: ['json', 'forget']19+	}) or { return usage_error(err.msg()) }20+	if args.flag('forget') {21+		if !machine.has_remote() {22+			return refuse(args.json(), 'this machine has no remote to forget')23+		}24+		machine.forget_remote() or { return refuse(args.json(), err.msg()) }25+		if args.json() {26+			emit_json(json.encode(RemoteJson{27+				configured: false28+			}))29+			return 030+		}31+		ok("forgot this machine's remote")32+		return 033+	}34+	if args.positional.len == 0 {35+		if !machine.has_remote() {36+			if args.json() {37+				emit_json(json.encode(RemoteJson{38+					configured: false39+				}))40+				return 041+			}42+			return setup_wizard()43+		}44+		settings := machine.load_remote() or { return refuse(args.json(), err.msg()) }45+		if args.json() {46+			emit_json(json.encode(RemoteJson{47+				configured: true48+				kind:       settings.kind49+				target:     describe(settings)50+			}))51+			return 052+		}53+		ok('remote: ${describe(settings)}')54+		note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')55+		return 056+	}57+	// A non-interactive form, still tested before it is saved.58+	return set_remote(args)59+}60+61+fn set_remote(args Args) int {62+	kind := args.positional_at(0)63+	match kind {64+		machine.kind_folder {65+			path := args.positional_at(1)66+			if path == '' {67+				return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')68+			}69+			settings := machine.RemoteSettings{70+				kind: machine.kind_folder71+				path: os.real_path(path)72+			}73+			return save_after_check(settings, args, '', '')74+		}75+		machine.kind_s3 {76+			endpoint := args.positional_at(1)77+			bucket := args.positional_at(2)78+			region := args.positional_at(3)79+			if endpoint == '' || bucket == '' || region == '' {80+				return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')81+			}82+			access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }83+			settings := machine.RemoteSettings{84+				kind:     machine.kind_s385+				endpoint: endpoint86+				bucket:   bucket87+				region:   region88+			}89+			return save_after_check(settings, args, access_key, secret_key)90+		}91+		else {92+			return usage_error('remote takes "folder" or "s3", not "${kind}"')93+		}94+	}95+}96+97+// save_after_check is the whole point: nothing is written to ~/.config/koh98+// until the remote has passed the test.99+fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {100+	mut backend := build_backend(settings, access_key, secret_key) or {101+		return refuse(args.json(), err.msg())102+	}103+	remote.check(mut backend) or {104+		return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')105+	}106+	machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }107+	if settings.kind == machine.kind_s3 {108+		machine.save_credentials(machine.Credentials{109+			access_key: access_key110+			secret_key: secret_key111+		}) or { return refuse(args.json(), err.msg()) }112+	}113+	if args.json() {114+		emit_json(json.encode(RemoteJson{115+			configured: true116+			kind:       settings.kind117+			target:     describe(settings)118+		}))119+		return 0120+	}121+	ok('remote set to ${describe(settings)}')122+	note('every repo on this machine now offers to it')123+	return 0124+}125+126+fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {127+	if settings.kind == machine.kind_folder {128+		return remote.new_folder(settings.path)!129+	}130+	return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,131+		secret_key)!132+}133+134+fn setup_wizard() int {135+	if !interactive() {136+		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')137+	}138+	println('  choose a remote:')139+	println('    ${amber('1')} folder   a directory, on a disk or a NAS')140+	println('    ${amber('2')} s3       an S3-compatible bucket')141+	print('  which? [1] ')142+	if os.get_line().trim_space() == '2' {143+		return wizard_s3()144+	}145+	print('  folder path: ')146+	path := os.get_line().trim_space()147+	if path == '' {148+		return fail('no path given — nothing was saved')149+	}150+	settings := machine.RemoteSettings{151+		kind: machine.kind_folder152+		path: os.real_path(path)153+	}154+	return save_after_check(settings, Args{}, '', '')155+}156+157+fn wizard_s3() int {158+	print('  endpoint [https://s3.fr-par.scw.cloud]: ')159+	endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')160+	print('  bucket: ')161+	bucket := os.get_line().trim_space()162+	print('  region [fr-par]: ')163+	region := or_default(os.get_line().trim_space(), 'fr-par')164+	if bucket == '' {165+		return fail('no bucket given — nothing was saved')166+	}167+	access_key, secret_key := s3_keys() or { return fail(err.msg()) }168+	settings := machine.RemoteSettings{169+		kind:     machine.kind_s3170+		endpoint: endpoint171+		bucket:   bucket172+		region:   region173+	}174+	return save_after_check(settings, Args{}, access_key, secret_key)175+}176+177+// s3_keys takes the keys from the environment when a script supplies them, and178+// otherwise asks, so they never have to be typed into a shell history.179+fn s3_keys() !(string, string) {180+	access_key := os.getenv('KOH_ACCESS_KEY')181+	secret_key := os.getenv('KOH_SECRET_KEY')182+	if access_key != '' && secret_key != '' {183+		return access_key, secret_key184+	}185+	if !interactive() {186+		return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')187+	}188+	print('  access key: ')189+	access_key_typed := os.get_line().trim_space()190+	print('  secret key: ')191+	secret_key_typed := os.get_line().trim_space()192+	if access_key_typed == '' || secret_key_typed == '' {193+		return error('both keys are needed — nothing was saved')194+	}195+	return access_key_typed, secret_key_typed196+}197+198+fn describe(settings machine.RemoteSettings) string {199+	if settings.kind == machine.kind_folder {200+		return 'folder ${settings.path}'201+	}202+	return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'203+}204+205+fn or_default(value string, fallback string) string {206+	return if value == '' { fallback } else { value }207+}208+209+struct RemoteJson {210+	configured bool211+	kind       string212+	target     string213+}

view file

src/cli/cmd_save.v+2 −0
@@ -47,6 +47,7 @@4747 			files:         files.len4848 			size_bytes:    save.size_bytes4949 		}))50+		auto_offer(store)5051 		return 05152 	}5253 	ok('saved [${save.id}]  ${save.message}')@@ -68,6 +69,7 @@6869 	if attribution.len > 0 {6970 		note(attribution.join(' · '))7071 	}72+	auto_offer(store)7173 	return 07274 }7375 

view file

src/cli/cmd_steal.v+196 −0
@@ -0,0 +1,196 @@1+//! `koh steal` — bring another machine's saves in.2+//!3+//! Inside a repo this pulls the remote's new saves and combines when both4+//! machines moved. Outside one it fetches a whole repo into a new folder.5+//! `--list` shows what the remote holds.6+7+module cli8+9+import json10+import os11+import src.fmt12+import src.history13+import src.remote14+import src.snapshot15+import src.storage16+import src.sync17+18+pub fn cmd_steal(raw []string) int {19+	args := parse('steal', raw, Options{20+		flags: ['list', 'mine', 'theirs', 'json']21+	}) or { return usage_error(err.msg()) }22+	if args.flag('list') {23+		return list_repos(args)24+	}25+	if storage.find_root(os.getwd()) == none {26+		name := args.positional_at(0)27+		if name == '' {28+			return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')29+		}30+		return clone_repo(name, args)31+	}32+	store := open_store() or { return refuse(args.json(), err.msg()) }33+	defer {34+		store.close()35+	}36+	if args.positional.len > 0 {37+		return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')38+	}39+	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }40+	mut options := sync.StealOptions{41+		mine:   args.flag('mine')42+		theirs: args.flag('theirs')43+	}44+	if !options.mine && !options.theirs && interactive() && os.is_atty(1) != 0 {45+		options.ask = ask_clash46+	}47+	report := sync.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }48+	// The remote should end up with whatever this machine just decided.49+	offered := offer_after_steal(store, mut backend)50+	if args.json() {51+		emit_json(json.encode(StolenJson{52+			repo:         report.repo53+			forward:      report.forward54+			saves:        report.fetched_saves55+			objects:      report.fetched_objects56+			events:       report.fetched_events57+			clashes:      report.clashes58+			replayed:     report.replayed59+			kept_mine:    report.kept_mine60+			head:         report.head61+			offer_failed: !offered62+		}))63+		return 064+	}65+	if report.fetched_saves == 0 {66+		ok('already up to date with "${report.repo}" — head is [${report.head}]')67+		return 068+	}69+	if report.forward {70+		ok('brought in ${report.fetched_saves} saves from "${report.repo}" — head is now [${report.head}]')71+	} else {72+		ok('brought in ${report.fetched_saves} saves from "${report.repo}"')73+		note('replayed ${report.replayed} of this machine\'s saves onto the remote head')74+		if report.kept_mine > 0 {75+			note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')76+		}77+	}78+	if report.clashes.len > 0 {79+		note('${report.clashes.len} changed on both machines: ${report.clashes.join(', ')}')80+	}81+	if !offered {82+		warn('the remote was not updated — run "koh offer" when it is reachable again')83+	}84+	return 085+}86+87+// offer_after_steal pushes what this machine just decided. A failure here does88+// not undo the steal; it is reported.89+fn offer_after_steal(store &storage.Store, mut backend remote.Backend) bool {90+	if _ := sync.offer(store, mut backend) {91+		return true92+	}93+	return false94+}95+96+// ask_clash is the per-file question a terminal gets. Only a lowercase y-style97+// answer is needed here: `m` for mine, `t` for theirs.98+fn ask_clash(clash sync.Clash) sync.Side {99+	println('  ${amber('both changed')} ${clash.path}   ${clash.stat}')100+	print('    ${amber('m')}ine or ${amber('t')}heirs? [t] ')101+	answer := os.get_line().trim_space()102+	if answer == 'm' {103+		return .mine104+	}105+	return .theirs106+}107+108+fn list_repos(args Args) int {109+	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }110+	keys := backend.list('') or { return refuse(args.json(), err.msg()) }111+	mut names := map[string]bool{}112+	for key in keys {113+		parts := key.trim_left('/').split('/')114+		if parts.len > 0 && parts[0] != '' {115+			names[parts[0]] = true116+		}117+	}118+	mut sorted := names.keys()119+	sorted.sort()120+	if args.json() {121+		emit_json(json.encode(ReposJson{122+			repos: sorted123+		}))124+		return 0125+	}126+	if sorted.len == 0 {127+		ok('the remote has no repos yet')128+		return 0129+	}130+	for name in sorted {131+		println('  ${name}')132+	}133+	return 0134+}135+136+// clone_repo fetches a whole repo into ./<name>, for a machine that does not137+// have it yet.138+fn clone_repo(name string, args Args) int {139+	target := os.join_path(os.getwd(), name)140+	if os.exists(target) {141+		return refuse(args.json(), '${name} already exists here — run steal inside it instead')142+	}143+	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }144+	fetched := sync.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }145+	// SQLite creates the file, not the folder holding it.146+	os.mkdir_all(target, os.MkdirParams{}) or { return refuse(args.json(), err.msg()) }147+	mut store := storage.create(target) or { return refuse(args.json(), err.msg()) }148+	defer {149+		store.close()150+	}151+	store.begin() or { return refuse(args.json(), err.msg()) }152+	sync.write_fetched(store, fetched) or {153+		store.rollback()154+		return refuse(args.json(), err.msg())155+	}156+	history.set_head(store, fetched.head) or {157+		store.rollback()158+		return refuse(args.json(), err.msg())159+	}160+	store.commit() or { return refuse(args.json(), err.msg()) }161+	files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }162+	snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {163+		return refuse(args.json(), err.msg())164+	}165+	if args.json() {166+		emit_json(json.encode(StolenJson{167+			repo:    name168+			forward: true169+			saves:   fetched.saves.len170+			objects: fetched.repo_objects.len171+			events:  fetched.events_out.len172+			head:    fetched.head173+		}))174+		return 0175+	}176+	ok('fetched "${name}" into ${target}')177+	note('${fetched.saves.len} saves at [${fetched.head}]')178+	return 0179+}180+181+struct ReposJson {182+	repos []string183+}184+185+struct StolenJson {186+	repo         string187+	forward      bool188+	saves        int189+	objects      int190+	events       int191+	clashes      []string192+	replayed     int193+	kept_mine    int194+	head         string195+	offer_failed bool196+}

view file

src/history/commit.v+23 −2
@@ -46,7 +46,7 @@4646 // already in the store — a restore, or a replay during a steal.4747 pub fn commit_files(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {4848 	store.begin()!49-	save := write_save(store, root, files, input) or {49+	save := record_save(store, root, files, input) or {5050 		store.rollback()5151 		return err5252 	}@@ -55,10 +55,17 @@5555 	return save5656 }5757 58-fn write_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {58+// record_save writes one save inside the caller's transaction. A steal uses it59+// to land a whole replay — several saves, fetched objects and fetched events —60+// in a single step, so a failure leaves no half-combined line behind.61+pub fn record_save(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {5962 	if root != '' {6063 		snapshot.store_objects(store, root, files)!6164 	}65+	return write_save(store, files, input)!66+}67+68+fn write_save(store &storage.Store, files []snapshot.FileEntry, input SaveInput) !Save {6269 	created_at := if input.created_at > 0 { input.created_at } else { time.now().unix() }6370 	mut save := Save{6471 		parent:        head_id(store)@@ -84,6 +91,20 @@8491 	insert_save(store, save, files)!8592 	store.set_state(storage.head_key, save.id)!8693 	return save94+}95+96+// import_save records a save exactly as another machine made it: same ID, same97+// parent, same time, same files. `steal` and `migrate` use it; a save made here98+// goes through write_save instead, which mints the ID.99+pub fn import_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {100+	insert_save(store, save, files)!101+}102+103+// set_head moves the line's head without creating a save. Only fetching another104+// machine's saves does that: nothing is erased and nothing is re-recorded,105+// because those saves are already on the line.106+pub fn set_head(store &storage.Store, id string) ! {107+	store.set_state(storage.head_key, id)!87108 }88109 89110 fn insert_save(store &storage.Store, save Save, files []snapshot.FileEntry) ! {

view file

src/history/history.v+0 −13
@@ -110,19 +110,6 @@110110 	return saves111111 }112112 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-126113 // effective is where the line actually sits: `head.restores` when the newest127114 // save was a going-back, else `head`. Going back repeatedly therefore keeps128115 // stepping further back instead of undoing the undo.

view file

src/machine/settings.v+111 −0
@@ -0,0 +1,111 @@1+//! Machine-wide remote settings.2+//!3+//! Everything machine-specific lives in `~/.config/koh/`, never inside a repo:4+//! one remote per machine, and the S3 keys in a file only the user can read.5+//! The format is plain `key=value` lines so it can be read and edited by hand.6+7+module machine8+9+import os10+11+pub const remote_file = 'remote'12+pub const credentials_file = 'credentials'13+14+pub const kind_folder = 'folder'15+pub const kind_s3 = 's3'16+17+pub struct RemoteSettings {18+pub:19+	kind     string20+	path     string // folder backend21+	endpoint string // s3 backend22+	bucket   string23+	region   string24+}25+26+pub struct Credentials {27+pub:28+	access_key string29+	secret_key string30+}31+32+pub fn save_remote(settings RemoteSettings) ! {33+	os.mkdir_all(config_dir(), os.MkdirParams{})!34+	mut text := '# written by koh remote — the remote for every repo on this machine\n'35+	text += 'kind=${settings.kind}\n'36+	if settings.kind == kind_folder {37+		text += 'path=${settings.path}\n'38+	} else {39+		text += 'endpoint=${settings.endpoint}\n'40+		text += 'bucket=${settings.bucket}\n'41+		text += 'region=${settings.region}\n'42+	}43+	os.write_file(config_path(remote_file), text)!44+}45+46+pub fn load_remote() !RemoteSettings {47+	text := os.read_file(config_path(remote_file)) or {48+		return error('this machine has no remote yet — run "koh remote"')49+	}50+	values := parse_config(text)51+	settings := RemoteSettings{52+		kind:     values['kind'] or { '' }53+		path:     values['path'] or { '' }54+		endpoint: values['endpoint'] or { '' }55+		bucket:   values['bucket'] or { '' }56+		region:   values['region'] or { '' }57+	}58+	if settings.kind !in [kind_folder, kind_s3] {59+		return error('the remote setting in ${config_path(remote_file)} is not readable — run "koh remote" again')60+	}61+	return settings62+}63+64+pub fn has_remote() bool {65+	return os.is_file(config_path(remote_file))66+}67+68+pub fn forget_remote() ! {69+	if os.exists(config_path(remote_file)) {70+		os.rm(config_path(remote_file))!71+	}72+}73+74+// save_credentials writes the keys so that only the user can read them.75+pub fn save_credentials(credentials Credentials) ! {76+	os.mkdir_all(config_dir(), os.MkdirParams{})!77+	mut text := '# written by koh remote — keep this file private\n'78+	text += 'access_key=${credentials.access_key}\n'79+	text += 'secret_key=${credentials.secret_key}\n'80+	path := config_path(credentials_file)81+	os.write_file(path, text)!82+	os.chmod(path, 0o600)!83+}84+85+pub fn load_credentials() !Credentials {86+	text := os.read_file(config_path(credentials_file)) or {87+		return error('this machine has no S3 keys yet — run "koh remote"')88+	}89+	values := parse_config(text)90+	credentials := Credentials{91+		access_key: values['access_key'] or { '' }92+		secret_key: values['secret_key'] or { '' }93+	}94+	if credentials.access_key == '' || credentials.secret_key == '' {95+		return error('the S3 keys in ${config_path(credentials_file)} are incomplete — run "koh remote" again')96+	}97+	return credentials98+}99+100+fn parse_config(text string) map[string]string {101+	mut values := map[string]string{}102+	for line in text.split_into_lines() {103+		trimmed := line.trim_space()104+		if trimmed == '' || trimmed.starts_with('#') {105+			continue106+		}107+		at := trimmed.index('=') or { continue }108+		values[trimmed[..at].trim_space()] = trimmed[at + 1..].trim_space()109+	}110+	return values111+}

view file

src/machine/settings_test.v+42 −0
@@ -0,0 +1,42 @@1+module machine2+3+import os4+5+fn with_temp_config(name string) string {6+	dir := os.join_path(os.temp_dir(), 'koh3_machine_${os.getpid()}_${name}')7+	os.rmdir_all(dir) or {}8+	return dir9+}10+11+// The settings file is machine-wide and lives outside every repo, so these12+// tests write into a temporary home instead.13+fn test_remote_settings_round_trip() {14+	home := with_temp_config('remote')15+	defer {16+		os.rmdir_all(home) or {}17+	}18+	// load_remote reads the real machine config, so only the parse and the19+	// shape are checked here.20+	values := parse_config('# comment\nkind=folder\npath=/tmp/remote\n\n')21+	assert values['kind'] == 'folder'22+	assert values['path'] == '/tmp/remote'23+	assert values.len == 224+}25+26+fn test_parse_config_ignores_junk_and_keeps_later_values() {27+	values := parse_config('28+# a comment29+kind=s330+endpoint = https://s3.fr-par.scw.cloud31+kind=folder32+')33+	assert values['kind'] == 'folder'34+	assert values['endpoint'] == 'https://s3.fr-par.scw.cloud'35+	assert values.len == 236+}37+38+fn test_config_paths_are_outside_the_repo() {39+	assert config_dir().ends_with('.config/koh')40+	assert config_path(remote_file).ends_with('.config/koh/remote')41+	assert config_path(credentials_file).ends_with('.config/koh/credentials')42+}

view file

src/remote/folder.v+159 −0
@@ -0,0 +1,159 @@1+//! The folder backend: a remote that is just a directory.2+//!3+//! Every sync test in the project runs against this backend, because it needs4+//! no network and no account. It is also a real backend in its own right — a5+//! sync folder on an external disk or a NAS is a perfectly good remote.6+//!7+//! Publish is a hard link, which fails when the name already exists: no partial8+//! file and no overwrite is ever visible to a reader.9+10+module remote11+12+import crypto.blake313+import time14+import os15+16+pub struct Folder {17+pub:18+	base string19+}20+21+// new_folder points a backend at a directory, creating it when missing.22+pub fn new_folder(base string) !Folder {23+	os.mkdir_all(base, os.MkdirParams{})!24+	return Folder{25+		base: base26+	}27+}28+29+pub fn (mut f Folder) get(key string) !Object {30+	path := f.path_of(key)31+	if !os.is_file(path) {32+		return Object{}33+	}34+	body := os.read_bytes(path)!35+	return Object{36+		found: true37+		body:  body38+		tag:   tag_of(body)39+	}40+}41+42+pub fn (mut f Folder) list(prefix string) ![]string {43+	dir := os.join_path(f.base, prefix)44+	if !os.is_dir(dir) {45+		return []string{}46+	}47+	mut keys := []string{}48+	mut walk := [prefix]49+	for walk.len > 0 {50+		relative := walk.pop()51+		for name in os.ls(f.path_of(relative))! {52+			child := if relative == '' { name } else { '${relative}/${name}' }53+			if os.is_dir(f.path_of(child)) {54+				walk << child55+			} else {56+				keys << child57+			}58+		}59+	}60+	keys.sort()61+	return keys62+}63+64+// put_if_absent writes a temp file in the target directory and hard-links it65+// into place. `link` fails when the name is taken, so a racing writer loses66+// cleanly instead of overwriting.67+pub fn (mut f Folder) put_if_absent(key string, body []u8) !bool {68+	path := f.path_of(key)69+	dir := os.dir(path)70+	os.mkdir_all(dir, os.MkdirParams{})!71+	temporary := '${path}.tmp-${os.getpid()}'72+	os.write_file_array(temporary, body)!73+	defer {74+		if os.exists(temporary) {75+			os.rm(temporary) or {}76+		}77+	}78+	os.link(temporary, path) or {79+		if os.exists(path) {80+			return false81+		}82+		return err83+	}84+	return true85+}86+87+// put_if_match compares the current content's tag to the one the caller read88+// before writing. A lock file taken with an exclusive create serialises the89+// compare-and-swap, so two machines cannot both believe they won.90+pub fn (mut f Folder) put_if_match(key string, body []u8, tag string) !bool {91+	path := f.path_of(key)92+	os.mkdir_all(os.dir(path), os.MkdirParams{})!93+	guard := '${path}.lock'94+	if !take_lock(guard)! {95+		clear_stale_lock(guard)96+		if !take_lock(guard)! {97+			return false98+		}99+	}100+	defer {101+		release_lock(guard)102+	}103+	if tag != '' {104+		current := f.get(key)!105+		if !current.found || current.tag != tag {106+			return false107+		}108+	} else if f.get(key)!.found {109+		return false110+	}111+	temporary := '${path}.tmp-${os.getpid()}'112+	os.write_file_array(temporary, body)!113+	os.mv(temporary, path, os.MvParams{})!114+	return true115+}116+117+// take_lock claims a lock path by hard-linking a fresh file into place. `link`118+// fails when the name exists, so exactly one writer can hold it.119+fn take_lock(path string) !bool {120+	fresh := '${path}.${os.getpid()}.${time.now().unix_nano()}'121+	os.write_file(fresh, '${os.getpid()}')!122+	defer {123+		os.rm(fresh) or {}124+	}125+	os.link(fresh, path) or { return false }126+	return true127+}128+129+fn release_lock(path string) {130+	os.rm(path) or {}131+}132+133+// clear_stale_lock removes a lock left behind by a writer that died mid-swap.134+// A lock a minute old is not a swap in progress; it is debris, and leaving it135+// would block this remote for ever.136+fn clear_stale_lock(path string) {137+	info := os.stat(path) or { return }138+	if time.now().unix() - info.mtime > 60 {139+		os.rm(path) or {}140+	}141+}142+143+pub fn (mut f Folder) delete(key string) ! {144+	path := f.path_of(key)145+	if os.exists(path) {146+		os.rm(path)!147+	}148+}149+150+fn (f &Folder) path_of(key string) string {151+	return os.join_path(f.base, key)152+}153+154+// tag_of is the version tag of a folder object: the hash of its bytes. A155+// reader can compute it without asking anyone, which is what the lock-free156+// compare-and-swap needs.157+pub fn tag_of(body []u8) string {158+	return blake3.sum256(body).hex()159+}

view file

src/remote/folder_test.v+91 −0
@@ -0,0 +1,91 @@1+module remote2+3+import os4+5+fn scratch(name string) !string {6+	dir := os.join_path(os.temp_dir(), 'koh3_remote_${os.getpid()}_${name}')7+	os.rmdir_all(dir) or {}8+	os.mkdir_all(dir, os.MkdirParams{})!9+	return dir10+}11+12+fn test_a_missing_key_is_not_an_error() {13+	base := scratch('missing')!14+	defer {15+		os.rmdir_all(base) or {}16+	}17+	mut backend := new_folder(base)!18+	object := backend.get('repo/nothing')!19+	assert !object.found20+	assert object.body.len == 021+}22+23+fn test_put_if_absent_refuses_a_second_write() {24+	base := scratch('absent')!25+	defer {26+		os.rmdir_all(base) or {}27+	}28+	mut backend := new_folder(base)!29+	assert backend.put_if_absent('repo/objects/abc', 'first'.bytes())!30+	assert !backend.put_if_absent('repo/objects/abc', 'second'.bytes())!31+	object := backend.get('repo/objects/abc')!32+	assert object.body == 'first'.bytes()33+	assert object.tag == tag_of('first'.bytes())34+}35+36+fn test_put_if_match_only_swaps_the_version_that_was_read() {37+	base := scratch('match')!38+	defer {39+		os.rmdir_all(base) or {}40+	}41+	mut backend := new_folder(base)!42+	backend.put_if_absent('repo/head', 'one'.bytes())!43+	tag := backend.get('repo/head')!.tag44+45+	// A stale tag loses.46+	assert !backend.put_if_match('repo/head', 'two'.bytes(), 'a-stale-tag')!47+	assert backend.get('repo/head')!.body == 'one'.bytes()48+49+	// The current tag wins.50+	assert backend.put_if_match('repo/head', 'two'.bytes(), tag)!51+	assert backend.get('repo/head')!.body == 'two'.bytes()52+53+	// An absent key can only be created when the caller read it as absent.54+	assert !backend.put_if_match('repo/fresh', 'x'.bytes(), tag)!55+	assert backend.put_if_match('repo/created', 'x'.bytes(), '')!56+	assert backend.get('repo/created')!.found57+}58+59+fn test_list_walks_the_prefix() {60+	base := scratch('list')!61+	defer {62+		os.rmdir_all(base) or {}63+	}64+	mut backend := new_folder(base)!65+	backend.put_if_absent('repo/saves/aaa', 'a'.bytes())!66+	backend.put_if_absent('repo/saves/bbb', 'b'.bytes())!67+	backend.put_if_absent('repo/objects/ccc', 'c'.bytes())!68+	assert backend.list('repo/saves')! == ['repo/saves/aaa', 'repo/saves/bbb']69+	assert backend.list('repo/nothing')!.len == 070+}71+72+fn test_delete_removes_the_key() {73+	base := scratch('delete')!74+	defer {75+		os.rmdir_all(base) or {}76+	}77+	mut backend := new_folder(base)!78+	backend.put_if_absent('repo/objects/abc', 'gone'.bytes())!79+	backend.delete('repo/objects/abc')!80+	assert !backend.get('repo/objects/abc')!.found81+}82+83+fn test_check_passes_on_a_working_remote_and_leaves_nothing_behind() {84+	base := scratch('check')!85+	defer {86+		os.rmdir_all(base) or {}87+	}88+	mut backend := new_folder(base)!89+	check(mut backend)!90+	assert !backend.get(probe_key)!.found91+}

view file

src/remote/remote.v+50 −0
@@ -0,0 +1,50 @@1+//! The remote interface: five operations, two backends.2+//!3+//! A remote is dumb storage that can do one safe swap. Everything clever —4+//! what to upload, what diverged, how to combine — lives in `src/sync`, above5+//! this interface, so the folder and S3 backends stay thin and the sync logic6+//! is written once and tested offline.7+8+module remote9+10+// get not finding a key is a normal answer, not an error, so `Object.found`11+// carries it and errors mean real trouble (a refused connection, bad keys).12+pub struct Object {13+pub:14+	found bool15+	body  []u816+	tag   string17+}18+19+// Backend is one place to put bytes. `put_if_absent` never overwrites and20+// `put_if_match` only overwrites when the version still matches, which is what21+// makes two machines offering at once safe.22+pub interface Backend {23+mut:24+	get(key string) !Object25+	list(prefix string) ![]string26+	put_if_absent(key string, body []u8) !bool27+	put_if_match(key string, body []u8, tag string) !bool28+	delete(key string) !29+}30+31+pub const probe_key = 'koh-probe'32+33+// check runs the connection test `koh remote` insists on before saving a34+// setting: write a probe object, confirm a second write of it is refused, then35+// delete it. A backend that cannot do that cannot keep two machines honest.36+pub fn check(mut backend Backend) ! {37+	payload := 'koh probe'.bytes()38+	if !backend.put_if_absent(probe_key, payload)! {39+		return error('the remote already has a probe object — it is not empty, or it cannot refuse a second write')40+	}41+	if backend.put_if_absent(probe_key, payload)! {42+		backend.delete(probe_key)!43+		return error('the remote overwrote an existing object — conditional writes are not working')44+	}45+	probe := backend.get(probe_key)!46+	if !probe.found || probe.body != payload {47+		return error('the remote did not give back what was written')48+	}49+	backend.delete(probe_key)!50+}

view file

src/remote/s3.v+258 −0
@@ -0,0 +1,258 @@1+//! The S3 backend: plain HTTPS with SigV4 signing against any S3 endpoint.2+//!3+//! Only the two conditional writes matter for correctness — `If-None-Match: *`4+//! for objects and saves, `If-Match` for `head` — because they are what stops5+//! two machines from silently overwriting each other. Everything else is6+//! ordinary GET, DELETE and ListObjectsV2.7+8+module remote9+10+import net.http11+import time12+13+pub const s3_service = 's3'14+pub const retry_attempts = 315+16+pub struct S3 {17+pub:18+	endpoint   string19+	bucket     string20+	region     string21+	access_key string22+	secret_key string23+mut:24+	signer Signer25+	root   string // scheme://host[:port][/base path]26+}27+28+pub fn new_s3(endpoint string, bucket string, region string, access_key string, secret_key string) !S3 {29+	if endpoint == '' || bucket == '' || region == '' {30+		return error('the S3 remote needs an endpoint, a bucket and a region')31+	}32+	if access_key == '' || secret_key == '' {33+		return error('the S3 remote needs an access key and a secret key')34+	}35+	if !endpoint.starts_with('http://') && !endpoint.starts_with('https://') {36+		return error('the S3 endpoint should start with https:// — got "${endpoint}"')37+	}38+	root := endpoint.trim_right('/')39+	return S3{40+		endpoint:   root41+		bucket:     bucket42+		region:     region43+		access_key: access_key44+		secret_key: secret_key45+		signer:     Signer{46+			access_key:   access_key47+			secret_key:   secret_key48+			region:       region49+			service:      s3_service50+			sign_payload: true51+		}52+		root:       root53+	}54+}55+56+pub fn (mut s S3) get(key string) !Object {57+	response := s.send('GET', key, '', map[string]string{}, []u8{})!58+	match response.status_code {59+		404 {60+			return Object{}61+		}62+		200 {63+			return Object{64+				found: true65+				body:  response.body.bytes()66+				tag:   clean_etag(header_of(response, 'ETag'))67+			}68+		}69+		else {70+			return error('s3 GET ${key} failed: ${response.status_code} ${short_body(response)}')71+		}72+	}73+}74+75+pub fn (mut s S3) list(prefix string) ![]string {76+	mut keys := []string{}77+	mut token := ''78+	for {79+		// The query is passed raw; url_for and the signer encode it the same80+		// way, which is what keeps the URL and the signature in step.81+		mut query := 'list-type=2&prefix=${prefix}'82+		if token != '' {83+			query += '&continuation-token=${token}'84+		}85+		response := s.send('GET', '', query, map[string]string{}, []u8{})!86+		if response.status_code != 200 {87+			return error('s3 list failed: ${response.status_code} ${short_body(response)}')88+		}89+		keys << elements(response.body, 'Key')90+		if !response.body.contains('<IsTruncated>true</IsTruncated>') {91+			break92+		}93+		token = first_element(response.body, 'NextContinuationToken') or { break }94+	}95+	return keys96+}97+98+pub fn (mut s S3) put_if_absent(key string, body []u8) !bool {99+	response := s.send('PUT', key, '', {100+		'If-None-Match': '*'101+	}, body)!102+	if response.status_code == 412 {103+		return false104+	}105+	if !success(response.status_code) {106+		return error('s3 PUT ${key} failed: ${response.status_code} ${short_body(response)}')107+	}108+	return true109+}110+111+pub fn (mut s S3) put_if_match(key string, body []u8, tag string) !bool {112+	mut headers := map[string]string{}113+	if tag == '' {114+		// The key must not exist yet.115+		headers['If-None-Match'] = '*'116+	} else {117+		headers['If-Match'] = tag118+	}119+	response := s.send('PUT', key, '', headers, body)!120+	if response.status_code == 412 {121+		return false122+	}123+	if !success(response.status_code) {124+		return error('s3 PUT ${key} failed: ${response.status_code} ${short_body(response)}')125+	}126+	return true127+}128+129+pub fn (mut s S3) delete(key string) ! {130+	response := s.send('DELETE', key, '', map[string]string{}, []u8{})!131+	if !success(response.status_code) && response.status_code != 404 {132+		return error('s3 DELETE ${key} failed: ${response.status_code} ${short_body(response)}')133+	}134+}135+136+// send signs one request and retries the failures worth retrying: a network137+// error, or a 5xx. A 412 is an answer, not a fault, and is never retried.138+fn (mut s S3) send(method string, key string, query string, headers map[string]string, body []u8) !http.Response {139+	url := s.url_for(key, query)140+	mut last := ''141+	for attempt in 1 .. retry_attempts + 1 {142+		signed := s.signer.sign(method, url, query, headers, body, time.now().local_to_utc())143+		mut outbound := http.new_header()144+		for name, value in signed {145+			outbound.add_custom(header_case(name), value) or {}146+		}147+		response := http.fetch(148+			url:    url149+			method: method_from(method)150+			header: outbound151+			data:   body.bytestr()152+		) or {153+			last = err.msg()154+			time.sleep(backoff(attempt))155+			continue156+		}157+		if response.status_code >= 500 {158+			last = '${response.status_code}'159+			time.sleep(backoff(attempt))160+			continue161+		}162+		return response163+	}164+	return error('s3 ${method} ${key} failed after ${retry_attempts} attempts: ${last}')165+}166+167+// url_for builds the path-style URL. The path is encoded here exactly as the168+// signer encodes it for the canonical request.169+fn (s &S3) url_for(key string, query string) string {170+	mut url := '${s.root}/${encode(s.bucket, false)}'171+	if key != '' {172+		url += '/${encode(key, true)}'173+	}174+	if query != '' {175+		url += '?${canonical_query(query)}'176+	}177+	return url178+}179+180+fn backoff(attempt int) time.Duration {181+	return time.Duration(attempt) * 200 * time.millisecond182+}183+184+fn method_from(method string) http.Method {185+	return match method {186+		'GET' { .get }187+		'PUT' { .put }188+		'POST' { .post }189+		'DELETE' { .delete }190+		'HEAD' { .head }191+		else { .get }192+	}193+}194+195+fn success(status int) bool {196+	return status >= 200 && status < 300197+}198+199+fn header_of(response http.Response, name string) string {200+	return response.header.get_custom(name, http.HeaderQueryConfig{}) or { '' }201+}202+203+// clean_etag strips the quotes S3 puts around an ETag: the value is sent back204+// verbatim in `If-Match`, and a quoted form would not match.205+pub fn clean_etag(value string) string {206+	return value.trim_space().trim('"')207+}208+209+fn header_case(name string) string {210+	return match name {211+		'authorization' { 'Authorization' }212+		'host' { 'Host' }213+		'x-amz-date' { 'X-Amz-Date' }214+		'x-amz-content-sha256' { 'X-Amz-Content-Sha256' }215+		'if-none-match' { 'If-None-Match' }216+		'if-match' { 'If-Match' }217+		else { name }218+	}219+}220+221+fn short_body(response http.Response) string {222+	body := response.body223+	if body.len <= 200 {224+		return body225+	}226+	return body[..200]227+}228+229+// elements pulls every text between <name> and </name> out of a response. The230+// only response Koh reads is S3's ListObjectsV2, which is flat and small, so a231+// scanner is honest here where a full XML parser would be a dependency.232+pub fn elements(xml string, name string) []string {233+	open := '<${name}>'234+	close := '</${name}>'235+	mut out := []string{}236+	mut rest := xml237+	for {238+		start := rest.index(open) or { break }239+		after := rest[start + open.len..]240+		end := after.index(close) or { break }241+		out << unescape(after[..end])242+		rest = after[end + close.len..]243+	}244+	return out245+}246+247+fn first_element(xml string, name string) ?string {248+	values := elements(xml, name)249+	if values.len == 0 {250+		return none251+	}252+	return values[0]253+}254+255+fn unescape(text string) string {256+	return text.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>').replace('&quot;',257+		'"').replace('&#39;', "'")258+}

view file

src/remote/s3_test.v+135 −0
@@ -0,0 +1,135 @@1+module remote2+3+import time4+5+// The vectors below come from two independent sources:6+//7+//   1. the official AWS `get-vanilla` case from the SigV4 test suite, whose8+//      signature is published with the specification;9+//   2. an S3-shaped conditional PUT, computed by a separate implementation10+//      written from the same specification in another language.11+//12+// Between them they cover the canonical request, the key derivation chain, the13+// string-to-sign and the header set that S3 actually sends.14+fn example_time() time.Time {15+	return time.Time{16+		year:     201517+		month:    818+		day:      3019+		hour:     1220+		minute:   3621+		second:   022+		is_local: false23+	}24+}25+26+fn test_the_official_aws_get_vanilla_vector() {27+	signer := Signer{28+		access_key:   'AKIDEXAMPLE'29+		secret_key:   'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY'30+		region:       'us-east-1'31+		service:      'service'32+		sign_payload: false33+	}34+	headers := signer.sign('GET', 'https://example.amazonaws.com/', '', map[string]string{},35+		[]u8{}, example_time())36+	assert headers['authorization'] == 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31'37+	assert headers['host'] == 'example.amazonaws.com'38+	assert headers['x-amz-date'] == '20150830T123600Z'39+}40+41+fn test_an_s3_conditional_put_signs_the_payload_and_the_condition() {42+	signer := Signer{43+		access_key: 'SCWACCESSKEY'44+		secret_key: 'scw-secret-key'45+		region:     'fr-par'46+		service:    s3_service47+	}48+	headers := signer.sign('PUT', 'https://s3.fr-par.scw.cloud/bucket/repo/objects/abc', '', {49+		'If-None-Match': '*'50+	}, 'compressed-bytes'.bytes(), time.Time{51+		year:     202652+		month:    953+		day:      2354+		hour:     1255+		minute:   056+		second:   057+		is_local: false58+	})59+	assert headers['authorization'] == 'AWS4-HMAC-SHA256 Credential=SCWACCESSKEY/20260923/fr-par/s3/aws4_request, SignedHeaders=host;if-none-match;x-amz-content-sha256;x-amz-date, Signature=9790aa767fc3e4c36fce70c92476933a90b401200068f06c340bf1108435580a'60+	// The body is part of the signature: a different payload must change it.61+	other := signer.sign('PUT', 'https://s3.fr-par.scw.cloud/bucket/repo/objects/abc', '', {62+		'If-None-Match': '*'63+	}, 'different-bytes'.bytes(), time.Time{64+		year:     202665+		month:    966+		day:      2367+		hour:     1268+		minute:   069+		second:   070+		is_local: false71+	})72+	assert other['authorization'] != headers['authorization']73+}74+75+fn test_urls_are_split_into_host_and_encoded_path() {76+	parts := split_url('https://s3.fr-par.scw.cloud/bucket/repo/objects/abc')77+	assert parts.host == 's3.fr-par.scw.cloud'78+	assert parts.path == '/bucket/repo/objects/abc'79+80+	// Slashes in the key stay slashes; anything else is encoded.81+	spaces := split_url('https://example.com/bucket/a file.txt')82+	assert spaces.path == '/bucket/a%20file.txt'83+84+	// An endpoint with a path prefix keeps it.85+	prefixed := split_url('https://example.com/prefix/bucket/key?x=1')86+	assert prefixed.host == 'example.com'87+	assert prefixed.path == '/prefix/bucket/key'88+}89+90+fn test_canonical_query_is_sorted_and_encoded() {91+	assert canonical_query('') == ''92+	// The query arrives raw, so a slash in a key is encoded here and only here.93+	assert canonical_query('prefix=b/a&list-type=2') == 'list-type=2&prefix=b%2Fa'94+	assert canonical_query('prefix=repo/objects/x&continuation-token=a=b') == 'continuation-token=a%3Db&prefix=repo%2Fobjects%2Fx'95+	assert canonical_query('prefix=a b') == 'prefix=a%20b'96+}97+98+fn test_etags_lose_their_quotes() {99+	assert clean_etag('"3b1f"') == '3b1f'100+	assert clean_etag('  "abc123"  ') == 'abc123'101+	assert clean_etag('abc123') == 'abc123'102+	assert clean_etag('') == ''103+}104+105+fn test_list_objects_responses_are_read() {106+	body := '<ListBucketResult><IsTruncated>false</IsTruncated>' +107+		'<Contents><Key>repo/objects/aaa</Key></Contents>' +108+		'<Contents><Key>repo/objects/bbb</Key></Contents></ListBucketResult>'109+	assert elements(body, 'Key') == ['repo/objects/aaa', 'repo/objects/bbb']110+	assert first_element(body, 'NextContinuationToken') == none111+	assert !body.contains('<IsTruncated>true</IsTruncated>')112+113+	paged := '<ListBucketResult><IsTruncated>true</IsTruncated>' +114+		'<NextContinuationToken>aGVsbG8=</NextContinuationToken>' +115+		'<Contents><Key>repo/objects/c&amp;c</Key></Contents></ListBucketResult>'116+	assert first_element(paged, 'NextContinuationToken') or { '' } == 'aGVsbG8='117+	assert elements(paged, 'Key') == ['repo/objects/c&c']118+	assert paged.contains('<IsTruncated>true</IsTruncated>')119+}120+121+fn test_the_constructor_refuses_settings_that_cannot_work() {122+	if _ := new_s3('', 'bucket', 'region', 'ak', 'sk') {123+		assert false, 'a missing endpoint must be refused'124+	}125+	if _ := new_s3('s3.fr-par.scw.cloud', 'bucket', 'region', 'ak', 'sk') {126+		assert false, 'an endpoint without a scheme must be refused'127+	}128+	if _ := new_s3('https://s3.fr-par.scw.cloud', 'bucket', 'region', '', '') {129+		assert false, 'missing keys must be refused'130+	}131+	backend := new_s3('https://s3.fr-par.scw.cloud/', 'bucket', 'fr-par', 'ak', 'sk')!132+	assert backend.root == 'https://s3.fr-par.scw.cloud'133+	assert backend.url_for('repo/objects/abc', '') == 'https://s3.fr-par.scw.cloud/bucket/repo/objects/abc'134+	assert backend.url_for('', 'list-type=2&prefix=x') == 'https://s3.fr-par.scw.cloud/bucket?list-type=2&prefix=x'135+}

view file

src/remote/sigv4.v+152 −0
@@ -0,0 +1,152 @@1+//! AWS Signature Version 4, the part S3 needs.2+//!3+//! Signing is a fixed recipe: canonicalise the request, hash it, derive a key4+//! from the secret for that day and region, and sign the string-to-sign. It is5+//! all here in the open, and pinned to the published AWS `get-vanilla` vector,6+//! so the signer is checked rather than trusted.7+8+module remote9+10+import crypto.hmac11+import crypto.sha25612+import time13+14+pub struct Signer {15+pub:16+	access_key string17+	secret_key string18+	region     string19+	service    string20+	// S3 signs the payload hash as well; the AWS test vectors for the generic21+	// service do not. The field exists so the rest of the algorithm can be22+	// checked against those vectors.23+	sign_payload bool = true24+}25+26+// sign returns the headers that authenticate one request: every header passed27+// in, plus host, x-amz-date and (for S3) x-amz-content-sha256, plus the28+// Authorization header itself. `now` is used exactly as given, so the caller29+// decides the zone — `time.now()` is local, and S3 wants UTC.30+pub fn (signer Signer) sign(method string, url string, query string, headers map[string]string, body []u8, now time.Time) map[string]string {31+	parts := split_url(url)32+	payload_hash := sha256_hex(body)33+	amz_date := amz_timestamp(now)34+35+	mut signed := map[string]string{}36+	for name, value in headers {37+		signed[name.to_lower().trim_space()] = value.trim_space()38+	}39+	signed['host'] = parts.host40+	signed['x-amz-date'] = amz_date41+	if signer.sign_payload {42+		signed['x-amz-content-sha256'] = payload_hash43+	}44+45+	mut names := signed.keys()46+	names.sort()47+	mut canonical_headers := ''48+	for name in names {49+		canonical_headers += '${name}:${signed[name]}\n'50+	}51+	signed_names := names.join(';')52+	canonical_request := '${method}\n${parts.path}\n${canonical_query(query)}\n${canonical_headers}\n${signed_names}\n${payload_hash}'53+54+	date := amz_date[..8]55+	scope := '${date}/${signer.region}/${signer.service}/aws4_request'56+	string_to_sign := 'AWS4-HMAC-SHA256\n${amz_date}\n${scope}\n${sha256_hex(canonical_request.bytes())}'57+	signature :=58+		hmac.new(signing_key(signer, date), string_to_sign.bytes(), sha256.sum256, 64).hex()59+60+	mut out := signed.clone()61+	out['authorization'] = 'AWS4-HMAC-SHA256 Credential=${signer.access_key}/${scope}, SignedHeaders=${signed_names}, Signature=${signature}'62+	return out63+}64+65+// signing_key is the HMAC chain: the secret becomes a key for the day, then for66+// the region, then for the service, then for the request.67+fn signing_key(signer Signer, date string) []u8 {68+	k_date := hmac.new('AWS4${signer.secret_key}'.bytes(), date.bytes(), sha256.sum256, 64)69+	k_region := hmac.new(k_date, signer.region.bytes(), sha256.sum256, 64)70+	k_service := hmac.new(k_region, signer.service.bytes(), sha256.sum256, 64)71+	return hmac.new(k_service, 'aws4_request'.bytes(), sha256.sum256, 64)72+}73+74+// sha256_hex is the digest form SigV4 puts in the canonical request.75+fn sha256_hex(data []u8) string {76+	return sha256.sum256(data).hex()77+}78+79+// amz_timestamp is the one date format SigV4 uses.80+pub fn amz_timestamp(moment time.Time) string {81+	return '${moment.year}${pad2(moment.month)}${pad2(moment.day)}T${pad2(moment.hour)}${pad2(moment.minute)}${pad2(moment.second)}Z'82+}83+84+fn pad2(value int) string {85+	return if value < 10 { '0${value}' } else { '${value}' }86+}87+88+pub struct UrlParts {89+pub:90+	host string // host[:port], as it appears in the Host header91+	path string // canonical URI, already encoded92+}93+94+// split_url takes a URL apart for the canonical request. The path is encoded95+// here, and the caller builds its URL from these same parts, so the URL the96+// server sees and the URL that was signed cannot drift.97+pub fn split_url(url string) UrlParts {98+	mut rest := url99+	if at := rest.index('://') {100+		rest = rest[at + 3..]101+	}102+	mut host := rest103+	mut path := '/'104+	if slash := rest.index('/') {105+		host = rest[..slash]106+		path = rest[slash..]107+	}108+	if question := path.index('?') {109+		path = path[..question]110+	}111+	return UrlParts{112+		host: host113+		path: encode(path, true)114+	}115+}116+117+// canonical_query sorts the query parameters and encodes them the way SigV4118+// wants them.119+pub fn canonical_query(query string) string {120+	if query == '' {121+		return ''122+	}123+	mut pairs := []string{}124+	for part in query.split('&') {125+		if part == '' {126+			continue127+		}128+		if at := part.index('=') {129+			pairs << '${encode(part[..at], false)}=${encode(part[at + 1..], false)}'130+		} else {131+			pairs << '${encode(part, false)}='132+		}133+	}134+	pairs.sort()135+	return pairs.join('&')136+}137+138+// encode is RFC 3986: unreserved characters stay, everything else becomes %XX.139+// `keep_slash` is for paths, where a slash separates key segments.140+pub fn encode(text string, keep_slash bool) string {141+	mut out := ''142+	for byte in text.bytes() {143+		if (byte >= `A` && byte <= `Z`) || (byte >= `a` && byte <= `z`)144+			|| (byte >= `0` && byte <= `9`) || byte in [`-`, `_`, `.`, `~`]145+			|| (keep_slash && byte == `/`) {146+			out += byte.ascii_str()147+		} else {148+			out += '%${byte.hex().to_upper()}'149+		}150+	}151+	return out152+}

view file

src/storage/objects.v+35 −0
@@ -35,6 +35,41 @@3535 	return hash3636 }3737 38+// object_blob returns the bytes exactly as they sit in the table — the39+// zlib-compressed form a remote receives.40+pub fn (s &Store) object_blob(hash string) ![]u8 {41+	mut stmt := s.db.prepare('SELECT data FROM objects WHERE hash = ?')!42+	defer {43+		stmt.finalize()44+	}45+	stmt.bind_text(1, hash)!46+	if !stmt.step()! {47+		return error('object ${hash} is missing')48+	}49+	return stmt.blob(0)50+}51+52+// put_object_blob stores a blob that came from a remote. The content is53+// decompressed to check it hashes back to its key, and the blob itself is kept54+// as it arrived.55+pub fn (s &Store) put_object_blob(hash string, blob []u8) ! {56+	if s.has_object(hash)! {57+		return58+	}59+	data := zlib.decompress(blob)!60+	if hash_hex(data) != hash {61+		return error('object ${hash} does not match its hash')62+	}63+	mut stmt := s.db.prepare('INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)')!64+	defer {65+		stmt.finalize()66+	}67+	stmt.bind_text(1, hash)!68+	stmt.bind_int(2, i64(data.len))!69+	stmt.bind_blob(3, blob)!70+	stmt.step()!71+}72+3873 pub fn (s &Store) has_object(hash string) !bool {3974 	mut stmt := s.db.prepare('SELECT 1 FROM objects WHERE hash = ?')!4075 	defer {

view file

src/sync/records.v+166 −0
@@ -0,0 +1,166 @@1+//! What a save and an event look like on the remote.2+//!3+//! Records are JSON so a person can read the bucket, and the layout is the4+//! same for every backend:5+//!6+//!   <repo>/objects/<hash>   the object's compressed bytes, as in the table7+//!   <repo>/saves/<id>       the full save record, including its file list8+//!   <repo>/events/<id>      one flag, clear or strip event9+//!   <repo>/head             the ID of the newest save on the line10+//!11+//! Only `head` is ever overwritten, and only with a matching version tag.12+13+module sync14+15+import json16+import src.events17+import src.history18+import src.snapshot19+import src.storage20+21+pub fn object_key(repo string, hash string) string {22+	return '${repo}/objects/${hash}'23+}24+25+pub fn save_key(repo string, id string) string {26+	return '${repo}/saves/${id}'27+}28+29+pub fn event_key(repo string, id string) string {30+	return '${repo}/events/${id}'31+}32+33+pub fn head_key(repo string) string {34+	return '${repo}/head'35+}36+37+pub fn events_prefix(repo string) string {38+	return '${repo}/events'39+}40+41+pub struct FileRecord {42+pub:43+	path       string44+	hash       string45+	executable bool46+}47+48+pub struct SaveRecord {49+pub:50+	id            string51+	parent        string52+	message       string53+	created_at    i6454+	machine       string55+	is_checkpoint bool56+	size_bytes    i6457+	agent         string58+	session       string59+	restores      string60+	replayed_from string61+	files         []FileRecord62+}63+64+// save_record turns a local save into what travels. The file list is part of65+// the record, which is why a save needs one round trip, not one per file.66+pub fn save_record(store &storage.Store, save history.Save) !SaveRecord {67+	files := history.files_of(store, save.id)!68+	mut records := []FileRecord{}69+	for file in files {70+		records << FileRecord{71+			path:       file.path72+			hash:       file.hash73+			executable: file.executable74+		}75+	}76+	return SaveRecord{77+		id:            save.id78+		parent:        save.parent79+		message:       save.message80+		created_at:    save.created_at81+		machine:       save.machine82+		is_checkpoint: save.is_checkpoint83+		size_bytes:    save.size_bytes84+		agent:         save.agent85+		session:       save.session86+		restores:      save.restores87+		replayed_from: save.replayed_from88+		files:         records89+	}90+}91+92+pub fn encode_save(record SaveRecord) string {93+	return json.encode(record)94+}95+96+pub fn decode_save(body []u8) !SaveRecord {97+	return json.decode(SaveRecord, body.bytestr())!98+}99+100+pub fn to_save(record SaveRecord) history.Save {101+	return history.Save{102+		id:            record.id103+		parent:        record.parent104+		message:       record.message105+		created_at:    record.created_at106+		machine:       record.machine107+		is_checkpoint: record.is_checkpoint108+		size_bytes:    record.size_bytes109+		agent:         record.agent110+		session:       record.session111+		restores:      record.restores112+		replayed_from: record.replayed_from113+	}114+}115+116+pub fn to_files(record SaveRecord) []snapshot.FileEntry {117+	mut files := []snapshot.FileEntry{}118+	for file in record.files {119+		files << snapshot.FileEntry{120+			path:       file.path121+			hash:       file.hash122+			executable: file.executable123+		}124+	}125+	return files126+}127+128+pub struct EventRecord {129+pub:130+	id         string131+	kind       string132+	save_id    string133+	path       string134+	reason     string135+	created_at i64136+	machine    string137+}138+139+pub fn event_record(event events.Event) EventRecord {140+	return EventRecord{141+		id:         event.id142+		kind:       event.kind143+		save_id:    event.save_id144+		path:       event.path145+		reason:     event.reason146+		created_at: event.created_at147+		machine:    event.machine148+	}149+}150+151+pub fn encode_event(event events.Event) string {152+	return json.encode(event_record(event))153+}154+155+pub fn decode_event(body []u8) !events.Event {156+	record := json.decode(EventRecord, body.bytestr())!157+	return events.Event{158+		id:         record.id159+		kind:       record.kind160+		save_id:    record.save_id161+		path:       record.path162+		reason:     record.reason163+		created_at: record.created_at164+		machine:    record.machine165+	}166+}

view file

src/sync/steal.v+360 −0
@@ -0,0 +1,360 @@1+//! Stealing: bringing another machine's saves in, and combining when both2+//! machines moved.3+//!4+//! The order is always the same, and it is what makes an interrupted steal5+//! harmless: read everything from the remote first, then land it in one6+//! transaction, then write the working tree. Divergence is resolved per file —7+//! a clash is never merged line by line, it is chosen.8+9+module sync10+11+import os12+import src.apply13+import src.events14+import src.history15+import src.remote16+import src.snapshot17+import src.storage18+19+pub enum Side {20+	mine21+	theirs22+}23+24+// Clash is one file both machines changed differently. `stat` is the short25+// diff stat the terminal prompt shows, so the choice is informed.26+pub struct Clash {27+pub mut:28+	path   string29+	mine   snapshot.FileEntry30+	theirs snapshot.FileEntry31+	base   snapshot.FileEntry32+	stat   string33+}34+35+pub struct StealOptions {36+pub mut:37+	mine   bool // resolve every clash as mine38+	theirs bool // resolve every clash as theirs39+	// Asked once per clash when neither flag is set. Nil means "nobody to ask".40+	ask fn (clash Clash) Side = unsafe { nil }41+}42+43+pub struct StealReport {44+pub mut:45+	repo            string46+	forward         bool47+	fetched_saves   int48+	fetched_objects int49+	fetched_events  int50+	clashes         []string51+	kept_mine       int52+	replayed        int53+	removed         int54+	head            string55+}56+57+pub const dirty_tree = 'save or checkpoint first — steal needs a clean tree'58+59+// steal brings the remote's new saves in. Inside a repo with its own work this60+// may have to combine; a repo with nothing of its own simply fast-forwards.61+pub fn steal(store &storage.Store, mut backend remote.Backend, opts StealOptions) !StealReport {62+	name := repo_name(store, mut backend)!63+	head_before := history.head(store)64+	if head := head_before {65+		// Combining rewrites the working tree, so unsaved work would be lost.66+		changed := dirty(store, head)!67+		if changed {68+			return error(dirty_tree)69+		}70+	}71+	fetched := fetch_back_to_known(store, mut backend, name)!72+	mut report := StealReport{73+		repo:            name74+		fetched_saves:   fetched.saves.len75+		fetched_objects: fetched.repo_objects.len76+		fetched_events:  fetched.events_out.len77+		head:            fetched.head78+	}79+	mut local_only := []history.Save{}80+	if head := head_before {81+		local_only = saves_after(store, head.id, fetched.base) or { []history.Save{} }82+	}83+	if fetched.base == '' && local_only.len > 0 {84+		return error('the remote keeps a different history — it shares no save with this repo')85+	}86+87+	store.begin() or { return err }88+	final := (write_and_combine(store, fetched, local_only, opts, mut report) or {89+		store.rollback()90+		return err91+	})92+	store.commit() or { return err }93+94+	if head := head_before {95+		files := history.files_of(store, final.id)!96+		mut wanted := map[string]bool{}97+		for file in files {98+			wanted[file.path] = true99+		}100+		mut remove := []string{}101+		for file in history.files_of(store, head.id)! {102+			if file.path !in wanted {103+				remove << file.path104+			}105+		}106+		result := snapshot.restore_files(store, store.root, snapshot.load(store.root), files,107+			remove)!108+		report.removed = result.removed109+	} else {110+		files := history.files_of(store, final.id)!111+		snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!112+	}113+	report.head = final.id114+	history.write_log_md(store)!115+	return report116+}117+118+// write_and_combine lands a fetch and, when the local line has saves the remote119+// lacks, replays them on top of the remote head. It runs inside the caller's120+// transaction and returns the new head.121+fn write_and_combine(store &storage.Store, fetched Fetched, local_only []history.Save, opts StealOptions, mut report StealReport) !history.Save {122+	write_fetched(store, fetched)!123+	if local_only.len == 0 {124+		// Nothing of ours is missing from the remote: the line simply moves on.125+		history.set_head(store, fetched.head)!126+		report.forward = true127+		return history.head(store) or { return error('the remote head did not land') }128+	}129+	remote_files := history.files_of(store, fetched.head)!130+	base_files := if fetched.base != '' {131+		history.files_of(store, fetched.base)!132+	} else {133+		[]snapshot.FileEntry{}134+	}135+	mut mine_files := []snapshot.FileEntry{}136+	if our_head := history.head(store) {137+		mine_files = history.files_of(store, our_head.id)!138+	}139+	clashes := describe_clashes(store, find_clashes(base_files, mine_files, remote_files))140+	report.clashes = clashes.map(it.path)141+	side_of := choose_sides(clashes, opts)!142+143+	// Take the remote's line first, so the replayed saves chain onto it.144+	history.set_head(store, fetched.head)!145+	mut current := remote_files.clone()146+	for save in local_only {147+		parent_files := if save.parent != '' {148+			history.files_of(store, save.parent)!149+		} else {150+			[]snapshot.FileEntry{}151+		}152+		target_files := history.files_of(store, save.id)!153+		plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))154+		if apply.conflicts(plan) {155+			return error('combine could not apply ${plan.conflict} — it is not a clash file')156+		}157+		if plan.applied.len == 0 {158+			// Everything this save touched was a clash resolved as theirs.159+			continue160+		}161+		history.record_save(store, '', plan.files, history.SaveInput{162+			message:       save.message163+			created_at:    save.created_at164+			machine:       save.machine165+			agent:         save.agent166+			session:       save.session167+			is_checkpoint: save.is_checkpoint168+			replayed_from: save.id169+		})!170+		current = plan.files.clone()171+		report.replayed++172+	}173+174+	mut mine_paths := []string{}175+	for clash in clashes {176+		if side_of[clash.path] == .mine {177+			mine_paths << clash.path178+		}179+	}180+	if mine_paths.len > 0 {181+		// One last save puts my versions of the chosen files back.182+		final_plan := apply.three_way(current, current, mine_files, mine_paths, [])183+		if final_plan.applied.len > 0 {184+			history.record_save(store, '', final_plan.files, history.SaveInput{185+				message: 'combine: kept mine for ${mine_paths.len} ${if mine_paths.len == 1 {186+					'file'187+				} else {188+					'files'189+				}}'190+			})!191+			report.kept_mine = mine_paths.len192+		}193+	}194+	return history.head(store) or { return error('the combined line has no head') }195+}196+197+// find_clashes lists the files both sides changed, differently. Files changed198+// identically on both sides are not clashes: there is nothing to decide.199+pub fn find_clashes(base []snapshot.FileEntry, mine []snapshot.FileEntry, theirs []snapshot.FileEntry) []Clash {200+	base_map := snapshot.index_files(base)201+	mine_map := snapshot.index_files(mine)202+	theirs_map := snapshot.index_files(theirs)203+	mut paths := mine_map.keys()204+	for path in theirs_map.keys() {205+		if path !in mine_map {206+			paths << path207+		}208+	}209+	paths.sort()210+	mut clashes := []Clash{}211+	for path in paths {212+		here := mine_map[path] or {213+			snapshot.FileEntry{214+				path: path215+			}216+		}217+		there := theirs_map[path] or {218+			snapshot.FileEntry{219+				path: path220+			}221+		}222+		was := base_map[path] or {223+			snapshot.FileEntry{224+				path: path225+			}226+		}227+		if same(here, there) {228+			continue229+		}230+		if same(here, was) || same(there, was) {231+			continue232+		}233+		clashes << Clash{234+			path:   path235+			mine:   here236+			theirs: there237+			base:   was238+		}239+	}240+	return clashes241+}242+243+// describe_clashes adds the diff stat each prompt needs.244+fn describe_clashes(store &storage.Store, clashes []Clash) []Clash {245+	mut out := []Clash{}246+	for clash in clashes {247+		mut decorated := clash248+		decorated.stat = clash_stat(store, clash)249+		out << decorated250+	}251+	return out252+}253+254+fn clash_stat(store &storage.Store, clash Clash) string {255+	deltas := snapshot.diff_trees(store, snapshot.Tree{256+		files: present(clash.mine)257+	}, snapshot.Tree{258+		files: present(clash.theirs)259+	}) or { return '' }260+	if deltas.len == 0 {261+		return 'no textual difference'262+	}263+	return '+${deltas[0].added} -${deltas[0].removed}'264+}265+266+// present drops an absent side, so a comparison never asks the object store for267+// an empty hash.268+fn present(entry snapshot.FileEntry) []snapshot.FileEntry {269+	if entry.hash == '' {270+		return []snapshot.FileEntry{}271+	}272+	return [entry]273+}274+275+fn choose_sides(clashes []Clash, opts StealOptions) !map[string]Side {276+	mut sides := map[string]Side{}277+	if clashes.len == 0 {278+		return sides279+	}280+	for clash in clashes {281+		if opts.mine {282+			sides[clash.path] = .mine283+			continue284+		}285+		if opts.theirs {286+			sides[clash.path] = .theirs287+			continue288+		}289+		if opts.ask != unsafe { nil } {290+			sides[clash.path] = opts.ask(clash)291+			continue292+		}293+		return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')294+	}295+	return sides296+}297+298+fn same(a snapshot.FileEntry, b snapshot.FileEntry) bool {299+	if a.hash == '' && b.hash == '' {300+		return true301+	}302+	return a.hash == b.hash && a.executable == b.executable303+}304+305+// dirty reports whether the working tree differs from `head` in a way a steal306+// would overwrite.307+fn dirty(store &storage.Store, head history.Save) !bool {308+	before := snapshot.index_files(history.files_of(store, head.id)!)309+	after := snapshot.index_files(snapshot.walk(store, store.root, snapshot.load(store.root))!)310+	for path in after.keys() {311+		if path !in before || before[path].hash != after[path].hash {312+			return true313+		}314+	}315+	for path in before.keys() {316+		if path !in after {317+			return true318+		}319+	}320+	return false321+}322+323+// fetch_repo pulls a whole repo — head, saves, objects and events — for324+// `koh steal <name>`, which lands a copy in a new folder.325+pub fn fetch_repo(mut backend remote.Backend, name string) !Fetched {326+	remote_head := backend.get(head_key(name))!327+	if !remote_head.found {328+		return error('the remote has no repo called "${name}"')329+	}330+	save_ids := backend.list('${name}/saves')!331+	object_keys := backend.list('${name}/objects')!332+	event_keys := backend.list('${name}/events')!333+	mut fetched := Fetched{334+		head:            remote_head.body.bytestr().trim_space()335+		remote_save_ids: map[string]bool{}336+	}337+	for key in object_keys {338+		blob := backend.get(key)!339+		if blob.found {340+			fetched.repo_objects[os.base(key)] = blob.body341+		}342+	}343+	for key in save_ids {344+		body := backend.get(key)!345+		if !body.found {346+			continue347+		}348+		record := decode_save(body.body)!349+		fetched.remote_save_ids[record.id] = true350+		fetched.saves << record351+	}352+	fetched.saves.sort(a.created_at < b.created_at)353+	for key in event_keys {354+		body := backend.get(key)!355+		if body.found {356+			fetched.events_out << decode_event(body.body)!357+		}358+	}359+	return fetched360+}

view file

src/sync/sync.v+233 −0
@@ -0,0 +1,233 @@1+//! Sync: offer and steal.2+//!3+//! All the cleverness sits here, above the five-operation remote interface, so4+//! the backends stay thin and this logic is written once and tested offline.5+//!6+//! The remote is a line of saves, the same shape as the local one. `offer`7+//! pushes local saves the remote lacks; `steal` pulls remote saves and, when8+//! both sides moved, replays the local work on top of the remote's. Divergence9+//! is resolved per file, never per line.10+11+module sync12+13+import os14+import src.events15+import src.history16+import src.remote17+import src.snapshot18+import src.storage19+20+// Where the repo's name on the remote and the last offered head are remembered.21+const repo_state_key = 'remote_repo'22+const offered_head_key = 'remote_head'23+24+pub const no_saves = 'this repo has no saves yet — nothing to offer'25+26+pub struct OfferReport {27+pub mut:28+	repo    string29+	saves   int30+	objects int31+	events  int32+	head    string33+}34+35+// repo_name is the folder's name at first offer, remembered in the repo so36+// renaming the folder later changes nothing.37+pub fn repo_name(store &storage.Store, mut backend remote.Backend) !string {38+	if remembered := store.state(repo_state_key) {39+		return remembered40+	}41+	name := os.base(store.root)42+	existing := backend.get(head_key(name))!43+	if existing.found {44+		theirs := existing.body.bytestr().trim_space()45+		if history.by_id(store, theirs) == none {46+			return error('the remote already has "${name}" with a history this repo does not share — rename the folder, or use another remote')47+		}48+	}49+	store.set_state(repo_state_key, name)!50+	return name51+}52+53+// offer sends what the remote lacks and then swaps `head`. Nothing is ever54+// queued: the next offer simply sends everything the remote does not have.55+pub fn offer(store &storage.Store, mut backend remote.Backend) !OfferReport {56+	name := repo_name(store, mut backend)!57+	local := history.head(store) or { return error(no_saves) }58+	mut report := OfferReport{59+		repo: name60+		head: local.id61+	}62+	remote_head := backend.get(head_key(name))!63+	if remote_head.found {64+		theirs := remote_head.body.bytestr().trim_space()65+		newer := saves_after(store, local.id, theirs) or {66+			return error('remote has new saves — run koh steal')67+		}68+		for save in newer {69+			upload_save(store, mut backend, name, save, mut report)!70+		}71+	} else {72+		oldest_first := history.line(store)!73+		for index := oldest_first.len - 1; index >= 0; index-- {74+			upload_save(store, mut backend, name, oldest_first[index], mut report)!75+		}76+	}77+	upload_events(store, mut backend, name, mut report)!78+	if !backend.put_if_match(head_key(name), local.id.bytes(), remote_head.tag)! {79+		return error('remote has new saves — run koh steal')80+	}81+	store.set_state(offered_head_key, local.id)!82+	return report83+}84+85+// saves_after lists the local saves newer than `theirs`, oldest first. It fails86+// when `theirs` is not on this line, which is the honest answer: we cannot87+// offer on top of a save we do not have.88+fn saves_after(store &storage.Store, ours string, theirs string) ![]history.Save {89+	mut newer := []history.Save{}90+	mut cursor := ours91+	for cursor != '' && cursor != theirs {92+		save := history.by_id(store, cursor) or { return error('save ${cursor} is missing') }93+		newer << save94+		cursor = save.parent95+	}96+	if cursor != theirs {97+		return error('${theirs} is not on this line')98+	}99+	newer.reverse_in_place()100+	return newer101+}102+103+fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport) ! {104+	files := history.files_of(store, save.id)!105+	for file in files {106+		if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {107+			report.objects++108+		}109+	}110+	record := save_record(store, save)!111+	if backend.put_if_absent(save_key(repo, save.id), encode_save(record).bytes())! {112+		report.saves++113+	}114+}115+116+// upload_events sends every event the remote does not have. Events are tiny, so117+// listing them costs less than tracking which ones were sent.118+fn upload_events(store &storage.Store, mut backend remote.Backend, repo string, mut report OfferReport) ! {119+	already := backend.list(events_prefix(repo))!120+	mut already_sent := map[string]bool{}121+	for key in already {122+		already_sent[os.base(key)] = true123+	}124+	for event in events.all(store)! {125+		if event.id in already_sent {126+			continue127+		}128+		if backend.put_if_absent(event_key(repo, event.id), encode_event(event).bytes())! {129+			report.events++130+		}131+	}132+}133+134+// fetched holds everything a steal read from the remote, before any of it is135+// written locally. Network work happens first; the database gets one136+// transaction, so a failure leaves nothing half-applied.137+pub struct Fetched {138+pub mut:139+	repo_objects    map[string][]u8140+	saves           []SaveRecord // oldest first141+	events_out      []events.Event142+	head            string143+	base            string // the newest save both sides already shared, '' when none144+	remote_save_ids map[string]bool145+}146+147+pub fn fetch_back_to_known(store &storage.Store, mut backend remote.Backend, repo string) !Fetched {148+	remote_head := backend.get(head_key(repo))!149+	if !remote_head.found {150+		return error('the remote has no "${repo}" yet — run koh offer here first')151+	}152+	mut fetched := Fetched{153+		head:            remote_head.body.bytestr().trim_space()154+		remote_save_ids: map[string]bool{}155+	}156+	mut cursor := fetched.head157+	for cursor != '' && history.by_id(store, cursor) == none {158+		record_object := backend.get(save_key(repo, cursor))!159+		if !record_object.found {160+			return error('the remote is missing save ${cursor} — run koh offer there')161+		}162+		record := decode_save(record_object.body)!163+		for file in record.files {164+			if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects {165+				blob := backend.get(object_key(repo, file.hash))!166+				if !blob.found {167+					return error('the remote is missing object ${file.hash} that save ${record.id} needs')168+				}169+				fetched.repo_objects[file.hash] = blob.body170+			}171+		}172+		fetched.remote_save_ids[record.id] = true173+		fetched.saves << record174+		cursor = record.parent175+	}176+	fetched.saves.reverse_in_place()177+	fetched.base = cursor178+	fetched.events_out = fetch_events(store, mut backend, repo)!179+	return fetched180+}181+182+fn fetch_events(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {183+	have := events.all(store)!184+	mut known := map[string]bool{}185+	for event in have {186+		known[event.id] = true187+	}188+	mut incoming := []events.Event{}189+	for key in backend.list(events_prefix(repo))! {190+		id := os.base(key)191+		if id in known {192+			continue193+		}194+		body := backend.get(event_key(repo, id))!195+		if !body.found {196+			continue197+		}198+		incoming << decode_event(body.body)!199+	}200+	return incoming201+}202+203+// write_fetched lands a fetch in one transaction: objects first (verified), then204+// the saves they belong to, then the events.205+pub fn write_fetched(store &storage.Store, fetched Fetched) ! {206+	for hash, blob in fetched.repo_objects {207+		store.put_object_blob(hash, blob)!208+	}209+	for record in fetched.saves {210+		history.import_save(store, to_save(record), to_files(record))!211+	}212+	for event in fetched.events_out {213+		events.write(store, event)!214+	}215+}216+217+// can_fast_forward reports whether our line sits behind the remote's, which is218+// the case when every local save is an ancestor of the remote head.219+pub fn can_fast_forward(store &storage.Store, remote_head string, remote_ids map[string]bool) bool {220+	mut cursor := remote_head221+	for cursor != '' {222+		if history.by_id(store, cursor) == none {223+			return false224+		}225+		save := history.by_id(store, cursor) or { return false }226+		if save.parent == '' {227+			// Reached the first save without leaving the line we have.228+			return true229+		}230+		cursor = save.parent231+	}232+	return false233+}

view file

src/sync/sync_test.v+491 −0
@@ -0,0 +1,491 @@1+//! Two-machine tests: two repos and one folder remote standing in for the Mac2+//! and the PC. This is where offer, steal and combine are actually proven —3+//! the folder backend exists so these run offline.4+5+module sync6+7+import os8+import src.events9+import src.history10+import src.remote11+import src.snapshot12+import src.storage13+14+// Machines keep their repo in a folder of the same NAME, because the remote key15+// is the folder's name; that is what makes them the same repo to the remote.16+fn machine(name string) !string {17+	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')18+	os.rmdir_all(os.dir(dir)) or {}19+	os.mkdir_all(dir, os.MkdirParams{})!20+	return dir21+}22+23+fn scratch(name string) !string {24+	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')25+	os.rmdir_all(dir) or {}26+	os.mkdir_all(dir, os.MkdirParams{})!27+	return dir28+}29+30+fn open_repo(dir string) !storage.Store {31+	mut store := storage.create(dir)!32+	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!33+	return store34+}35+36+fn put(root string, rel string, text string) ! {37+	full := os.join_path(root, rel)38+	os.mkdir_all(os.dir(full), os.MkdirParams{})!39+	os.write_file(full, text)!40+}41+42+fn commit(store &storage.Store, message string) !history.Save {43+	return history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{44+		message: message45+	})!46+}47+48+fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {49+	return offer(store, mut backend)!50+}51+52+fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {53+	return steal(store, mut backend, StealOptions{54+		theirs: true55+	})!56+}57+58+fn test_first_offer_publishes_the_whole_line() {59+	base := scratch('first')!60+	defer {61+		os.rmdir_all(base) or {}62+	}63+	mut a := open_repo(machine('first_a')!)!64+	defer {65+		a.close()66+	}67+	put(a.root, 'a.txt', 'one\n')!68+	commit(a, 'first')!69+	put(a.root, 'a.txt', 'two\n')!70+	commit(a, 'second')!71+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!72+73+	report := offer_now(a, mut backend)!74+	assert report.repo == 'proj'75+	assert report.saves == 276+	assert report.objects >= 277+	assert backend.get(head_key('proj'))!.found78+	assert backend.list('proj/saves')!.len == 279+80+	// A second offer with nothing new uploads nothing and still succeeds.81+	again := offer_now(a, mut backend)!82+	assert again.saves == 083+}84+85+fn test_the_repo_name_and_offered_head_are_remembered() {86+	base := scratch('remember')!87+	defer {88+		os.rmdir_all(base) or {}89+	}90+	mut a := open_repo(machine('remember_a')!)!91+	defer {92+		a.close()93+	}94+	put(a.root, 'a.txt', 'one\n')!95+	commit(a, 'first')!96+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!97+	offer_now(a, mut backend)!98+	assert a.state('remote_repo') or { '' } == 'proj'99+	assert a.state('remote_head') or { '' } == history.head(a)!.id100+101+	// A second offer, with the remote already holding our head, must not102+	// refuse: the name is remembered, so the remote is ours.103+	put(a.root, 'a.txt', 'two\n')!104+	commit(a, 'second')!105+	offer_now(a, mut backend)!106+	assert a.state('remote_head') or { '' } == history.head(a)!.id107+}108+109+fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {110+	base := scratch('forward')!111+	defer {112+		os.rmdir_all(base) or {}113+	}114+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!115+	mut a := open_repo(machine('fwd_a')!)!116+	defer {117+		a.close()118+	}119+	put(a.root, 'a.txt', 'from A\n')!120+	commit(a, 'A writes')!121+	offer_now(a, mut backend)!122+123+	// B starts empty and pulls the whole repo.124+	mut b := open_repo(machine('fwd_b')!)!125+	defer {126+		b.close()127+	}128+	fetched := fetch_repo(mut backend, 'proj')!129+	b.begin()!130+	write_fetched(b, fetched)!131+	history.set_head(b, fetched.head)!132+	b.commit()!133+	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!134+	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'135+	assert history.head(b)!.id == fetched.head136+}137+138+fn test_divergence_without_clashes_keeps_both_machines_work() {139+	base := scratch('diverge')!140+	defer {141+		os.rmdir_all(base) or {}142+	}143+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!144+	mut a := open_repo(machine('div_a')!)!145+	defer {146+		a.close()147+	}148+	mut b := open_repo(machine('div_b')!)!149+	defer {150+		b.close()151+	}152+	put(a.root, 'shared.txt', 'base\n')!153+	commit(a, 'base')!154+	offer_now(a, mut backend)!155+156+	// B joins, then both machines change a different file.157+	join(b, mut backend)!158+	put(a.root, 'fromA.txt', 'A\n')!159+	commit(a, 'A adds a file')!160+	put(b.root, 'fromB.txt', 'B\n')!161+	commit(b, 'B adds a file')!162+	offer_now(a, mut backend)!163+164+	// B's offer is refused: the remote has moved on.165+	if _ := offer_now(b, mut backend) {166+		assert false, 'offering on top of unknown remote saves should be refused'167+	} else {168+		assert err.msg().contains('run koh steal')169+	}170+171+	report := steal_now(b, mut backend)!172+	assert !report.forward173+	assert report.replayed == 1174+	assert report.clashes.len == 0175+	// Both files are on B's line now, and on disk.176+	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'177+	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'178+	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'179+	// B's own save was replayed, not erased.180+	mut saw_replay := false181+	for save in history.all_saves(b)! {182+		if save.replayed_from != '' {183+			saw_replay = true184+		}185+	}186+	assert saw_replay187+	// And the remote now has B's work too.188+	offer_now(b, mut backend)!189+	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()190+	assert line_head == history.head(b)!.id191+}192+193+fn test_clashing_files_are_chosen_one_at_a_time() {194+	base := scratch('clash')!195+	defer {196+		os.rmdir_all(base) or {}197+	}198+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!199+	mut a := open_repo(machine('clash_a')!)!200+	defer {201+		a.close()202+	}203+	mut b := open_repo(machine('clash_b')!)!204+	defer {205+		b.close()206+	}207+	put(a.root, 'same.txt', 'base\n')!208+	put(a.root, 'other.txt', 'quiet\n')!209+	commit(a, 'base')!210+	offer_now(a, mut backend)!211+	join(b, mut backend)!212+213+	// Both machines edit the same file, differently.214+	put(a.root, 'same.txt', 'A version\n')!215+	commit(a, 'A edits same')!216+	offer_now(a, mut backend)!217+	put(b.root, 'same.txt', 'B version\n')!218+	commit(b, 'B edits same')!219+220+	// Without a decision, steal refuses and says which files clash.221+	if _ := steal(b, mut backend, StealOptions{}) {222+		assert false, 'an undecided clash must be refused'223+	} else {224+		assert err.msg().contains('--mine') || err.msg().contains('--theirs')225+	}226+	assert find_clashes(history.files_of(b, history.head(b)!.id)!, history.files_of(b,227+		history.head(b)!.id)!, history.files_of(b, history.head(b)!.id)!).len == 0228+229+	// Choosing the remote's version ends with A's content.230+	report := steal_now(b, mut backend)!231+	assert report.clashes == ['same.txt']232+	assert report.kept_mine == 0233+	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'234+	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'235+}236+237+fn test_choosing_mine_keeps_the_local_version() {238+	base := scratch('mine')!239+	defer {240+		os.rmdir_all(base) or {}241+	}242+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!243+	mut a := open_repo(machine('mine_a')!)!244+	defer {245+		a.close()246+	}247+	mut b := open_repo(machine('mine_b')!)!248+	defer {249+		b.close()250+	}251+	put(a.root, 'same.txt', 'base\n')!252+	commit(a, 'base')!253+	offer_now(a, mut backend)!254+	join(b, mut backend)!255+	put(a.root, 'same.txt', 'A version\n')!256+	commit(a, 'A edits')!257+	offer_now(a, mut backend)!258+	put(b.root, 'same.txt', 'B version\n')!259+	commit(b, 'B edits')!260+261+	report := steal(b, mut backend, StealOptions{262+		mine: true263+	})!264+	assert report.kept_mine == 1265+	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'266+	// The final save says what it kept.267+	assert history.head(b)!.message.contains('combine: kept mine for 1 file')268+	// The pre-combine tree is still reachable in history.269+	assert history.head(b)!.restores == ''270+	assert history.all_saves(b)!.len >= 4271+}272+273+fn test_flags_travel_with_the_saves() {274+	base := scratch('flags')!275+	defer {276+		os.rmdir_all(base) or {}277+	}278+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!279+	mut a := open_repo(machine('flag_a')!)!280+	defer {281+		a.close()282+	}283+	mut b := open_repo(machine('flag_b')!)!284+	defer {285+		b.close()286+	}287+	put(a.root, 'a.txt', 'one\n')!288+	save := commit(a, 'suspect save')!289+	events.flag(a, save.id, 'look at this')!290+	offer_now(a, mut backend)!291+292+	// B has nothing yet, so a fetch brings the save and its flag across.293+	fetched := fetch_back_to_known(b, mut backend, 'proj')!294+	assert fetched.events_out.len == 1295+	assert fetched.events_out[0].kind == events.kind_flag296+	b.begin()!297+	write_fetched(b, fetched)!298+	history.set_head(b, fetched.head)!299+	b.commit()!300+	state := events.flag_state(b, save.id)!301+	assert state.flagged302+	assert state.reason == 'look at this'303+}304+305+fn test_a_second_offer_racing_head_is_refused_not_forced() {306+	base := scratch('race')!307+	defer {308+		os.rmdir_all(base) or {}309+	}310+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!311+	mut a := open_repo(machine('race_a')!)!312+	defer {313+		a.close()314+	}315+	put(a.root, 'a.txt', 'one\n')!316+	commit(a, 'base')!317+	offer_now(a, mut backend)!318+319+	// Someone else replaces head behind our back.320+	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),321+		backend.get(head_key('proj'))!.tag)!322+323+	put(a.root, 'a.txt', 'two\n')!324+	commit(a, 'second')!325+	if _ := offer_now(a, mut backend) {326+		assert false, 'offering while the remote head moved must be refused'327+	} else {328+		assert err.msg().contains('run koh steal')329+	}330+}331+332+// join makes a brand new machine share the remote's line.333+fn join(store &storage.Store, mut backend remote.Backend) ! {334+	fetched := fetch_repo(mut backend, 'proj')!335+	store.begin()!336+	write_fetched(store, fetched)!337+	history.set_head(store, fetched.head)!338+	store.commit()!339+	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,340+		fetched.head)!, [])!341+	store.set_state('remote_repo', 'proj')!342+}343+344+struct FaultyBackend {345+mut:346+	inner      remote.Backend347+	writes     int348+	reads      int349+	fail_write int // fail when the write counter reaches this, 0 never350+	fail_read  int351+}352+353+fn (mut f FaultyBackend) get(key string) !remote.Object {354+	f.reads++355+	if f.fail_read > 0 && f.reads >= f.fail_read {356+		return error('injected read failure')357+	}358+	return f.inner.get(key)!359+}360+361+fn (mut f FaultyBackend) list(prefix string) ![]string {362+	return f.inner.list(prefix)!363+}364+365+fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {366+	f.writes++367+	if f.fail_write > 0 && f.writes >= f.fail_write {368+		return error('injected write failure')369+	}370+	return f.inner.put_if_absent(key, body)!371+}372+373+fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {374+	f.writes++375+	if f.fail_write > 0 && f.writes >= f.fail_write {376+		return error('injected write failure')377+	}378+	return f.inner.put_if_match(key, body, tag)!379+}380+381+fn (mut f FaultyBackend) delete(key string) ! {382+	f.inner.delete(key)!383+}384+385+fn test_a_failed_offer_is_safe_to_re_run() {386+	base := scratch('fault_offer')!387+	defer {388+		os.rmdir_all(base) or {}389+	}390+	remote_dir := os.join_path(base, 'remote')391+	mut a := open_repo(machine('fault_a')!)!392+	defer {393+		a.close()394+	}395+	put(a.root, 'a.txt', 'one\n')!396+	commit(a, 'first')!397+	put(a.root, 'a.txt', 'two\n')!398+	commit(a, 'second')!399+400+	// The first offer dies after the first write.401+	mut flaky := FaultyBackend{402+		inner:      remote.new_folder(remote_dir)!403+		fail_write: 2404+	}405+	if _ := offer(a, mut flaky) {406+		assert false, 'the injected failure should have surfaced'407+	}408+409+	// Re-running with a healthy remote finishes the job: "already exists" is410+	// success, so nothing is lost and nothing is duplicated.411+	mut healthy := remote.new_folder(remote_dir)!412+	report := offer(a, mut healthy)!413+	assert report.saves == 2414+	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == history.head(a)!.id415+	assert healthy.list('proj/saves')!.len == 2416+}417+418+fn test_a_failed_steal_leaves_nothing_behind() {419+	base := scratch('fault_steal')!420+	defer {421+		os.rmdir_all(base) or {}422+	}423+	remote_dir := os.join_path(base, 'remote')424+	mut a := open_repo(machine('fault_s_a')!)!425+	defer {426+		a.close()427+	}428+	mut b := open_repo(machine('fault_s_b')!)!429+	defer {430+		b.close()431+	}432+	put(a.root, 'a.txt', 'from A\n')!433+	commit(a, 'A writes')!434+	mut healthy := remote.new_folder(remote_dir)!435+	offer(a, mut healthy)!436+437+	// B's fetch fails half way through reading the remote.438+	mut flaky := FaultyBackend{439+		inner:     remote.new_folder(remote_dir)!440+		fail_read: 2441+	}442+	if _ := fetch_back_to_known(b, mut flaky, 'proj') {443+		assert false, 'the injected read failure should have surfaced'444+	}445+	// Nothing was written: the store is untouched and still has no saves.446+	assert history.head(b) == none447+	assert b.object_count()! == 0448+449+	// A healthy retry works.450+	fetched := fetch_back_to_known(b, mut healthy, 'proj')!451+	b.begin()!452+	write_fetched(b, fetched)!453+	history.set_head(b, fetched.head)!454+	b.commit()!455+	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!456+	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'457+}458+459+fn test_an_interrupted_strip_is_idempotent() {460+	// The local half of a strip is one transaction, so an interrupted strip is461+	// simply a strip that did not happen; re-running it must land the same462+	// result once, not twice.463+	base := scratch('fault_strip')!464+	defer {465+		os.rmdir_all(base) or {}466+	}467+	mut a := open_repo(machine('fault_strip_a')!)!468+	defer {469+		a.close()470+	}471+	put(a.root, 'secret.txt', 'shh\n')!472+	put(a.root, 'keep.txt', 'fine\n')!473+	save := commit(a, 'add both')!474+475+	// What `koh strip` does locally, twice over.476+	for _ in 0 .. 2 {477+		a.begin()!478+		mut stmt := a.db.prepare('DELETE FROM save_files WHERE path = ?')!479+		stmt.bind_text(1, 'secret.txt')!480+		stmt.step()!481+		stmt.finalize()482+		a.drop_unreferenced_objects()!483+		events.strip(a, '', 'secret.txt')!484+		a.commit()!485+	}486+	files := history.files_of(a, save.id)!487+	// The repo's own `.kohignore` is tracked content, so it stays; only the488+	// stripped path goes.489+	assert files.map(it.path) == ['.kohignore', 'keep.txt']490+	assert events.strip_events(a)!.len == 1491+}

view file

tests/cli_test.v+126 −0
@@ -241,3 +241,129 @@241241 	assert run(dir, 'load').code == 2242242 	assert run(dir, 'load', 'unknown-id', '--yes').code == 1243243 }244+245+fn test_flag_and_flagged_log() {246+	dir := temp_repo('flag')247+	defer { os.rmdir_all(dir) or {} }248+	init_repo(dir)249+	write(dir, 'flag.txt', 'one\n')250+	saved := run(dir, 'save', 'flag me', '--json')251+	id := saved.out.all_after('"id":"')[..12]252+	flagged := run(dir, 'flag', id, '--reason', 'unsafe', '--json')253+	assert flagged.code == 0254+	assert flagged.out.contains('"flagged":true')255+	assert flagged.out.contains('"reason":"unsafe"')256+	list := run(dir, 'flag', '--json')257+	assert list.code == 0258+	assert list.out.contains(id)259+	assert list.out.contains('unsafe')260+	assert run(dir, 'log').out.contains('⚑')261+	assert run(dir, 'flag', '--clear', id).code == 0262+	assert run(dir, 'flag').out.contains('nothing is flagged')263+	assert run(dir, 'flag', 'unknown-id').code == 1264+	assert run(dir, 'flag').code == 0265+}266+267+fn test_pluck_forward_reverse_dry_run_and_conflict() {268+	dir := temp_repo('pluck')269+	defer { os.rmdir_all(dir) or {} }270+	init_repo(dir)271+	write(dir, 'a.txt', 'one\n')272+	first := run(dir, 'save', 'one', '--json').out273+	id1 := first.all_after('"id":"')[..12]274+	write(dir, 'a.txt', 'two\n')275+	second := run(dir, 'save', 'two', '--json').out276+	id2 := second.all_after('"id":"')[..12]277+	assert run(dir, 'load', id1, '--yes').code == 0278+	dry := run(dir, 'pluck', id2, '--dry-run')279+	assert dry.code == 0280+	assert dry.out.contains('a.txt')281+	assert os.read_file(os.join_path(dir, 'a.txt'))! == 'one\n'282+	assert run(dir, 'pluck', id2, '--yes').code == 0283+	assert os.read_file(os.join_path(dir, 'a.txt'))! == 'two\n'284+	assert run(dir, 'pluck', '--reverse', id2, '--yes').code == 0285+	assert os.read_file(os.join_path(dir, 'a.txt'))! == 'one\n'286+	write(dir, 'a.txt', 'different\n')287+	conflict := run(dir, 'pluck', id2, '--yes')288+	assert conflict.code == 1289+	assert conflict.err.contains('a.txt')290+	assert run(dir, 'pluck').code == 2291+	assert run(dir, 'pluck', 'unknown-id').code == 1292+}293+294+fn test_peek_and_doctor() {295+	dir := temp_repo('peek-doctor')296+	defer { os.rmdir_all(dir) or {} }297+	init_repo(dir)298+	write(dir, 'peek.txt', 'peek me\n')299+	assert run(dir, 'save', 'peekable').code == 0300+	id := run(dir, 'log', '--json').out.all_after('"id":"')[..12]301+	before_log := run(dir, 'log', '--json').out302+	peek := run(dir, 'peek', id, '--json')303+	assert peek.code == 0304+	peek_path := peek.out.all_after('"path":"').before('"')305+	assert peek.out.contains('"id":"${id}"')306+	assert os.read_file(os.join_path(peek_path, 'peek.txt'))! == 'peek me\n'307+	assert run(dir, 'status').out.contains('no changes')308+	assert run(dir, 'log', '--json').out == before_log309+	assert run(dir, 'peek').code == 2310+	assert run(dir, 'peek', 'unknown-id').code == 1311+	backup := os.join_path(dir, 'doctor.backup')312+	healthy := run(dir, 'doctor')313+	assert healthy.code == 0314+	assert healthy.out.contains('is sound')315+	report := run(dir, 'doctor', '--json')316+	assert report.code == 0317+	for key in ['problems', 'objects', 'saves', 'checkpoints', 'cached_files', 'peek_folders_removed',318+		'backup'] {319+		read_json_has(report.out, key)320+	}321+	backed := run(dir, 'doctor', '--backup', backup)322+	assert backed.code == 0323+	assert os.exists(backup)324+	assert backed.out.contains(backup)325+	assert run(dir, 'doctor', '--backup', backup).code == 1326+	assert run(dir, 'peek', id, '--json').code == 0327+	removed := run(dir, 'doctor', '--json')328+	assert removed.code == 0329+	assert removed.out.contains('"peek_folders_removed":1')330+	assert !os.exists(peek_path)331+}332+333+fn test_strip_refuses_without_terminal() {334+	dir := temp_repo('strip')335+	defer { os.rmdir_all(dir) or {} }336+	init_repo(dir)337+	write(dir, 'secret.txt', 'keep\n')338+	assert run(dir, 'save', 'secret').code == 0339+	before_log := run(dir, 'log', '--json').out340+	refused := run(dir, 'strip', 'secret.txt')341+	assert refused.code == 1342+	assert refused.err.contains('needs a terminal')343+	assert run(dir, 'log', '--json').out == before_log344+	assert os.read_file(os.join_path(dir, 'secret.txt'))! == 'keep\n'345+	assert run(dir, 'log', '--json').out.contains('secret')346+}347+348+fn test_saves_and_checkpoints_aliases() {349+	dir := temp_repo('aliases')350+	defer { os.rmdir_all(dir) or {} }351+	init_repo(dir)352+	write(dir, 'alias.txt', 'one\n')353+	assert run(dir, 'save', 'plain save').code == 0354+	assert run(dir, 'checkpoint', 'checkpoint save').code == 0355+	saves := run(dir, 'saves')356+	assert saves.code == 0357+	assert saves.out.contains('plain save')358+	assert saves.out.contains('checkpoint save')359+	checkpoints := run(dir, 'checkpoints')360+	assert checkpoints.code == 0361+	assert checkpoints.out.contains('checkpoint save')362+	assert !checkpoints.out.contains('plain save')363+	json_result := run(dir, 'checkpoints', '--json')364+	assert json_result.code == 0365+	assert json_result.out.contains('"head_id"')366+	assert json_result.out.contains('"saves"')367+	assert json_result.out.contains('"is_checkpoint":true')368+	assert !json_result.out.contains('"is_checkpoint":false')369+}

view file