bea6866dbe3e
name the clashing files when steal refuses without a terminal, and leave no half-made folder when a clone meets a damaged remote object (3.0.60)
- time
- 2026-09-26 02:41 UTC · 2 weeks ago
- machine
- koh2
- agent
- claude-opus-5-5
- session
- live remote scenarios
- parent
- 7ca392923d38
↓ koh-bea6866d.tar.gzatlas at this save
files changed7 files
VERSION+1 −1src/cli/cmd_steal.v+28 −21src/exchange/exchange_test.v+1 −1src/exchange/steal.v+2 −1src/storage/objects.v+2 −2tests/cli_test.v+29 −0v.mod+1 −1src/cli/cmd_steal.v+28 −21
@@ -147,30 +147,14 @@147147 }148148 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }149149 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }150- // SQLite creates the file, not the folder holding it.151- os.mkdir_all(target, os.MkdirParams{}) or { return refuse(args.json(), err.msg()) }152- mut store := storage.create(target) or { return refuse(args.json(), err.msg()) }150+ store := land_clone(target, name, fetched) or {151+ // A folder left behind would make the retry refuse as "already exists".152+ os.rmdir_all(target) or {}153+ return refuse(args.json(), err.msg())154+ }153155 defer {154156 store.close()155157 }156- store.begin() or { return refuse(args.json(), err.msg()) }157- exchange.write_fetched(store, fetched) or {158- store.rollback()159- return refuse(args.json(), err.msg())160- }161- history.set_head(store, fetched.head) or {162- store.rollback()163- return refuse(args.json(), err.msg())164- }165- exchange.remember_repo(store, name) or {166- store.rollback()167- return refuse(args.json(), err.msg())168- }169- snapshot.mark_restore(store) or {170- store.rollback()171- return refuse(args.json(), err.msg())172- }173- store.commit() or { return refuse(args.json(), err.msg()) }174158 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }175159 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {176160 return refuse(args.json(), err.msg())@@ -198,6 +182,29 @@198182 note('${fetched.saves.len} saves at [${fetched.head}]')199183 warn_flagged(flagged)200184 return 0185+}186+187+// land_clone creates ./<name> and commits the fetched history into it. Objects188+// are checked on the way in, so a damaged remote fails here.189+fn land_clone(target string, name string, fetched exchange.Fetched) !storage.Store {190+ // SQLite creates the file, not the folder holding it.191+ os.mkdir_all(target, os.MkdirParams{})!192+ mut store := storage.create(target)!193+ fill_clone(store, name, fetched) or {194+ store.rollback()195+ store.close()196+ return err197+ }198+ return store199+}200+201+fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched) ! {202+ store.begin()!203+ exchange.write_fetched(store, fetched)!204+ history.set_head(store, fetched.head)!205+ exchange.remember_repo(store, name)!206+ snapshot.mark_restore(store)!207+ store.commit()!201208 }202209 203210 // warn_flagged prints the line the document asks for, per incoming flagged save.src/exchange/exchange_test.v+1 −1
@@ -1154,7 +1154,7 @@11541154 if _ := steal(b, mut backend, StealOptions{}) {11551155 assert false, 'with the base stripped, notes.txt differs on both sides'11561156 } else {1157- assert err.msg().contains('changed on both machines')1157+ assert err.msg().contains('notes.txt changed on both machines')11581158 }11591159 report := steal(b, mut backend, StealOptions{11601160 mine: truesrc/exchange/steal.v+2 −1
@@ -394,7 +394,8 @@394394 sides[clash.path] = opts.ask(clash)395395 continue396396 }397- return error('${clashes.len} files changed on both machines — run on a terminal to choose, or pass --mine or --theirs')397+ paths := clashes.map(it.path).join(', ')398+ return error('${paths} changed on both machines — run on a terminal to choose, or pass --mine or --theirs')398399 }399400 return sides400401 }src/storage/objects.v+2 −2
@@ -56,9 +56,9 @@5656 if s.has_object(hash)! {5757 return5858 }59- data := zlib.decompress(blob)!59+ data := zlib.decompress(blob) or { return error('object ${hash} is damaged: ${err.msg()}') }6060 if hash_hex(data) != hash {61- return error('object ${hash} does not match its hash')61+ return error('object ${hash} is damaged: it does not match its hash')6262 }6363 mut stmt := s.db.prepare('INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)')!6464 defer {tests/cli_test.v+29 −0
@@ -728,6 +728,35 @@728728 assert os.read_file(os.join_path(a, 'b.txt'))! == 'from B\n'729729 }730730 731+// A damaged object on the remote stops a clone, names the object, and leaves732+// no half-made folder that would make the retry refuse.733+fn test_clone_of_a_damaged_remote_leaves_nothing() {734+ syncroot := temp_repo('damaged')735+ home := temp_repo('damaged-home')736+ defer {737+ os.rmdir_all(syncroot) or {}738+ os.rmdir_all(home) or {}739+ }740+ remote := os.join_path(syncroot, 'remote')741+ a := os.join_path(syncroot, 'A', 'proj')742+ b_parent := os.join_path(syncroot, 'B')743+ os.mkdir_all(a) or { panic(err) }744+ os.mkdir_all(b_parent) or { panic(err) }745+ init_repo(a)746+ write(a, 'a.txt', 'from A\n')747+ assert run_home(a, home, 'remote', 'folder', remote).code == 0748+ assert run_home(a, home, 'save', 'A first').code == 0749+ objects := os.join_path(remote, 'proj', 'objects')750+ for name in os.ls(objects) or { panic(err) } {751+ os.chmod(os.join_path(objects, name), 0o644) or {}752+ os.write_file(os.join_path(objects, name), 'junk') or { panic(err) }753+ }754+ clone := run_home(b_parent, home, 'steal', 'proj')755+ assert clone.code == 1756+ assert clone.err.contains('is damaged')757+ assert !os.exists(os.join_path(b_parent, 'proj'))758+}759+731760 fn test_offer_steal_folder_remote() {732761 syncroot := temp_repo('sync')733762 home := temp_repo('sync-home')v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.59'3+ version: '3.0.60'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }