From 9ada9c79fffa0929ba35f9d0082c0108584e9982 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:10:09 +0000 Subject: [PATCH 1/3] fix: copy-ignored handles non-ASCII filenames (git quotePath) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git ls-files quotes non-ASCII paths by default (core.quotePath=true), returning e.g. "data/Report \342\200\224 Q1.txt" — surrounding quotes and octal escapes included — which was then treated as a literal filesystem path and never found, so the entry was silently dropped and the metadata read failed with 'No such file or directory', exiting 1. Pass -z to git ls-files and split on NUL, which disables quoting and also sidesteps the newline-in-filename edge case. Closes #3486 --- src/commands/step/shared.rs | 59 +++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/commands/step/shared.rs b/src/commands/step/shared.rs index 0c0339d5e..b184d9c46 100644 --- a/src/commands/step/shared.rs +++ b/src/commands/step/shared.rs @@ -191,15 +191,23 @@ pub(super) fn list_and_filter_ignored_entries( /// List ignored entries using git ls-files /// -/// Uses `git ls-files --ignored --exclude-standard -o --directory` which: +/// Uses `git ls-files -z --ignored --exclude-standard -o --directory` which: /// - Handles all gitignore sources (global, .gitignore, .git/info/exclude, nested) /// - Stops at directory boundaries (--directory) to avoid listing thousands of files +/// +/// `-z` NUL-terminates the entries and, crucially, disables path quoting. Without +/// it, `core.quotePath` (git's default) renders a non-ASCII name like +/// `data/Report — Q1.txt` as `"data/Report \342\200\224 Q1.txt"` — surrounding +/// quotes and octal escapes included — which is then treated as a literal path and +/// never found on disk. NUL splitting also sidesteps the newline-in-filename edge +/// case that line splitting can't represent. fn list_ignored_entries( worktree_path: &Path, context: &str, ) -> anyhow::Result> { let args = [ "ls-files", + "-z", "--ignored", "--exclude-standard", "-o", @@ -216,12 +224,13 @@ fn list_ignored_entries( return Err(worktrunk::git::CommandError::from_failed_output("git", &args, &output).into()); } - // Parse output: directories end with / + // Parse output: NUL-separated entries; directories end with / let entries = String::from_utf8_lossy(&output.stdout) - .lines() - .map(|line| { - let is_dir = line.ends_with('/'); - let path = worktree_path.join(line.trim_end_matches('/')); + .split('\0') + .filter(|entry| !entry.is_empty()) + .map(|entry| { + let is_dir = entry.ends_with('/'); + let path = worktree_path.join(entry.trim_end_matches('/')); (path, is_dir) }) .collect(); @@ -245,4 +254,42 @@ mod tests { let cmd_err = CommandError::find_in(&err).expect("error should carry a CommandError"); assert!(cmd_err.command_string().starts_with("git ls-files")); } + + /// An ignored file whose name contains non-ASCII characters must be + /// discovered with a real filesystem path. With `core.quotePath` (git's + /// default), `git ls-files` renders such a name as `"data/Report + /// \342\200\224 Q1.txt"` — quotes and octal escapes included — which does + /// not exist on disk. Discovery must return the actual path. + #[test] + fn list_ignored_entries_handles_non_ascii_names() { + let test = TestRepo::with_initial_commit(); + let root = test.root_path(); + + // A tracked file in data/ keeps `--directory` from collapsing the + // directory into a single entry, so the individual filename is listed. + std::fs::create_dir(root.join("data")).unwrap(); + std::fs::write(root.join("data/keep.md"), "tracked").unwrap(); + std::fs::write(root.join(".gitignore"), "*.txt\n").unwrap(); + test.run_git(&["add", "-A"]); + test.run_git(&["commit", "-m", "add tracked"]); + + // Ignored file with an em dash (U+2014) in its name. + let ignored_name = "Report — Q1.txt"; + let ignored_path = root.join("data").join(ignored_name); + std::fs::write(&ignored_path, "x").unwrap(); + + let entries = super::list_ignored_entries(root, "test").unwrap(); + let found = entries.iter().find(|(path, _)| { + path.file_name().and_then(|n| n.to_str()) == Some(ignored_name) + }); + + let (path, is_dir) = found.unwrap_or_else(|| { + panic!("non-ASCII ignored file not discovered; entries: {entries:?}") + }); + assert!(!is_dir); + assert!( + path.exists(), + "discovered path does not exist on disk: {path:?}" + ); + } } From f098551aec15459a0a1a8032a202c9f553a74d03 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:14:22 +0000 Subject: [PATCH 2/3] style: rustfmt the non-ASCII test closure --- src/commands/step/shared.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/step/shared.rs b/src/commands/step/shared.rs index b184d9c46..9fcd08d58 100644 --- a/src/commands/step/shared.rs +++ b/src/commands/step/shared.rs @@ -279,9 +279,9 @@ mod tests { std::fs::write(&ignored_path, "x").unwrap(); let entries = super::list_ignored_entries(root, "test").unwrap(); - let found = entries.iter().find(|(path, _)| { - path.file_name().and_then(|n| n.to_str()) == Some(ignored_name) - }); + let found = entries + .iter() + .find(|(path, _)| path.file_name().and_then(|n| n.to_str()) == Some(ignored_name)); let (path, is_dir) = found.unwrap_or_else(|| { panic!("non-ASCII ignored file not discovered; entries: {entries:?}") From eb4fc9c317e41f7ec10c48c098ab20f5b769786d Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:34:30 +0000 Subject: [PATCH 3/3] test: avoid failure-only branch in non-ASCII test Use .expect() instead of unwrap_or_else(|| panic!(...)) so the assertion has no failure-only line that codecov/patch flags as uncovered. --- src/commands/step/shared.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/commands/step/shared.rs b/src/commands/step/shared.rs index 9fcd08d58..5d71fdf21 100644 --- a/src/commands/step/shared.rs +++ b/src/commands/step/shared.rs @@ -279,13 +279,10 @@ mod tests { std::fs::write(&ignored_path, "x").unwrap(); let entries = super::list_ignored_entries(root, "test").unwrap(); - let found = entries + let (path, is_dir) = entries .iter() - .find(|(path, _)| path.file_name().and_then(|n| n.to_str()) == Some(ignored_name)); - - let (path, is_dir) = found.unwrap_or_else(|| { - panic!("non-ASCII ignored file not discovered; entries: {entries:?}") - }); + .find(|(path, _)| path.file_name().and_then(|n| n.to_str()) == Some(ignored_name)) + .expect("non-ASCII ignored file should be discovered"); assert!(!is_dir); assert!( path.exists(),