e787dbe6181a

refuse to restore a file into a folder of ignored files instead of dropping it inside (3.0.17, codex review pending)

time
2026-09-23 22:28 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh 3 review fixes
parent
a63030364948
5 files VERSION+1 −1src/remote/folder.v+6 −1src/snapshot/restore.v+12 −1tests/cli_test.v+18 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.161+3.0.17

view file

src/remote/folder.v+6 −1
@@ -110,7 +110,12 @@110110 	}111111 	temporary := '${path}.tmp-${os.getpid()}'112112 	os.write_file_array(temporary, body)!113-	os.mv(temporary, path, os.MvParams{})!113+	// V's rename, like mv(1), would move the file INTO a directory there.114+	if os.is_dir(path) {115+		os.rm(temporary) or {}116+		return error('${key} is a directory on the remote')117+	}118+	os.rename(temporary, path)!114119 	return true115120 }116121 

view file

src/snapshot/restore.v+12 −1
@@ -36,7 +36,18 @@3636 		removed++3737 		prune_empty_dirs(root, os.dir(full))3838 	}39+	// A folder where a file belongs: gone if the removals emptied it, else it40+	// holds ignored files, which are never touched. Refuse before writing, as41+	// V's move would otherwise drop the file inside the folder.3942 	for entry in target {43+		full := os.join_path(root, entry.path)44+		if os.is_dir(full) {45+			os.rmdir(full) or {46+				return error('${entry.path} is a folder of ignored files where a saved file belongs — move it, then run the command again')47+			}48+		}49+	}50+	for entry in target {4051 		data := store.get_object(entry.hash)!4152 		write_file(os.join_path(root, entry.path), data, entry.executable)!4253 	}@@ -52,7 +63,7 @@5263 	os.write_file_array(tmp, data)!5364 	mode := if executable { 0o755 } else { 0o644 }5465 	os.chmod(tmp, mode)!55-	os.mv(tmp, path, os.MvParams{})!66+	os.rename(tmp, path)!5667 }5768 5869 // prune_empty_dirs clears directories a restore emptied, never the repo root.

view file

tests/cli_test.v+18 −0
@@ -171,6 +171,24 @@171171 	assert run(dir, 'log', '--json').out.contains('"message":"first"')172172 }173173 174+// A folder of ignored files can sit where a restored file should go. Koh175+// never touches ignored files, so the restore must stop and say so, not drop176+// the file inside the folder under a temporary name.177+fn test_a_restore_refuses_a_folder_of_ignored_files_in_the_way() {178+	dir := temp_repo('dir-in-way')179+	defer { os.rmdir_all(dir) or {} }180+	init_repo(dir)181+	write(dir, 'out', 'a file\n')182+	first := run(dir, 'save', 'out is a file', '--json').out.all_after('"id":"')[..12]183+	os.rm(os.join_path(dir, 'out')) or { panic(err) }184+	write(dir, 'out/build.log', 'ignored\n')185+	assert run(dir, 'save', 'out is gone').code == 0186+	r := run(dir, 'load', first, '--yes')187+	assert r.code == 1188+	assert r.err.contains('out')189+	assert os.ls(os.join_path(dir, 'out'))! == ['build.log']190+}191+174192 // `save` records the executable bit, so `status` has to see it too, or a175193 // chmod would be invisible until it turned up in a save.176194 fn test_status_sees_an_executable_bit_change() {

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.16'3+	version: '3.0.17'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file