fix: rename same-file detection via path canonicalization (#557) · patchloom/patchloom@a1b5573 · GitHub
Skip to content

Commit a1b5573

Browse files
authored
fix: rename same-file detection via path canonicalization (#557)
The rename command and tx engine file.rename used raw PathBuf comparison to detect same-file renames. Paths like file.md and ./file.md produce different PathBuf values but resolve to the same file. Without canonicalization, a rename with write policy transforms (e.g. --ensure-final-newline) would write the transformed content to the destination, then delete the source, destroying the file. Now compares canonicalized paths in both validate_rename_paths() and the tx engine FileRename handler. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent da76cc5 commit a1b5573

4 files changed

Lines changed: 95 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions

src/cmd/rename.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,12 @@ fn validate_rename_paths(
5757
if dst.exists() && !dst.is_file() {
5858
anyhow::bail!("destination is not a file: {dst_label}");
5959
}
60-
if src == dst {
60+
if src == dst
61+
|| matches!(
62+
(src.canonicalize(), dst.canonicalize()),
63+
(Ok(ref s), Ok(ref d)) if s == d
64+
)
65+
{
6166
return Ok(RenameValidation::NoOp);
6267
}
6368
if !force && dst.exists() {
@@ -500,6 +505,29 @@ mod tests {
500505
assert_eq!(fs::read_to_string(&file).unwrap(), "hello\n");
501506
}
502507

508+
#[test]
509+
fn rename_same_file_via_different_path_is_noop() {
510+
let dir = TempDir::new().unwrap();
511+
let file = dir.path().join("same.txt");
512+
fs::write(&file, "hello\n").unwrap();
513+
514+
let mut global = global_for(&dir);
515+
global.apply = true;
516+
517+
// Use "./same.txt" as destination - different string, same file.
518+
let args = RenameArgs {
519+
from: file.to_string_lossy().into_owned(),
520+
to: dir.path().join("./same.txt").to_string_lossy().into_owned(),
521+
force: true,
522+
write: Default::default(),
523+
};
524+
525+
let code = run(args, &global).unwrap();
526+
assert_eq!(code, exit::SUCCESS);
527+
assert!(file.exists(), "file must not be deleted");
528+
assert_eq!(fs::read_to_string(&file).unwrap(), "hello\n");
529+
}
530+
503531
#[cfg(feature = "mcp")]
504532
#[test]
505533
fn apply_rename_same_path_is_noop_without_force() {

src/cmd/tx.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,8 +1215,13 @@ fn execute_operation(op: &Operation, tx: &mut TxState<'_>) -> anyhow::Result<usi
12151215
anyhow::bail!("destination is not a file: {to}");
12161216
}
12171217

1218-
// If source and destination are the same path, no-op.
1219-
if src_path == dst_path {
1218+
// If source and destination resolve to the same file, no-op.
1219+
if src_path == dst_path
1220+
|| matches!(
1221+
(src_path.canonicalize(), dst_path.canonicalize()),
1222+
(Ok(ref s), Ok(ref d)) if s == d
1223+
)
1224+
{
12201225
return Ok(0);
12211226
}
12221227

tests/integration.rs

Lines changed: 57 additions & 0 deletions

0 commit comments

Comments
 (0)