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
↓ koh-e787dbe6.tar.gzatlas at this save
files changed5 files
VERSION+1 −1src/remote/folder.v+6 −1src/snapshot/restore.v+12 −1tests/cli_test.v+18 −0v.mod+1 −1src/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 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.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() {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 }