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
↓ koh-a15db60f.tar.gzatlas at this save
files changed24 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 −0src/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 }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].executablesrc/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]}"')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+}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')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+}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 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+}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) ! {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.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+}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+}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+}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+}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+}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('&', '&').replace('<', '<').replace('>', '>').replace('"',257+ '"').replace(''', "'")258+}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&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+}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+}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 {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+}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+}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+}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+}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+}