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
7 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.591+3.0.60

view file

src/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.

view file

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: true

view file

src/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 }

view file

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 {

view file

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')

view file

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 }

view file