From d7173bbe780bd318417be2baf894680b2e6009f0 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:51:17 +0000 Subject: [PATCH 1/2] fix(llm): keep quoted-path diff sections in the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git quotes a `diff --git` path pair when the name is non-ASCII (`core.quotePath`, on by default) or holds `"`/`\\`. Neither header contains a bare ` b/`, so `parse_diff_sections` found no path and dropped that file's diff from the over-budget prompt entirely. Parse the quoted form as well, and open a section on any `diff --git` header whether or not its path parses — the name only feeds lock-file filtering. --- src/llm.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/llm.rs b/src/llm.rs index a870715e2..154fccba6 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -185,6 +185,27 @@ fn is_lock_file(filename: &str) -> bool { .any(|pattern| filename.ends_with(pattern)) } +/// Extract the destination path from a `diff --git` header line. +/// +/// [`DIFF_PREFIX_OVERRIDES`] pins the prefixes to `a/` and `b/`, so the +/// destination begins at the last ` b/` — or, when git quotes the pair, +/// at the last ` "b/`. Quoting is not optional: `core.quotePath` escapes a +/// non-ASCII name, and a name holding `"` or `\` is quoted whatever that +/// setting says, so a parser that only knows the bare form fails on both. +/// +/// The escaped form is what comes back for a quoted name — it feeds +/// [`is_lock_file`]'s suffix match, not the filesystem — and a path that +/// itself contains ` b/` stays ambiguous, exactly as it is in git's own +/// plain-text output. +fn parse_diff_header_path(line: &str) -> Option<&str> { + let rest = line.strip_prefix("diff --git ")?; + if let Some(index) = rest.rfind(" \"b/") { + let path = &rest[index + 4..]; + return Some(path.strip_suffix('"').unwrap_or(path)); + } + rest.rfind(" b/").map(|index| &rest[index + 3..]) +} + /// Parse a diff into individual file sections /// /// Returns Vec of (filename, diff_content) pairs @@ -212,8 +233,10 @@ fn parse_diff_sections(diff: &str) -> Vec<(&str, &str)> { sections.push((file, &diff[section_start_byte..current_byte])); } - // Extract filename from "diff --git a/path b/path" - current_file = line.split(" b/").nth(1); + // A header opens a section whether or not its path parses: the + // name only feeds lock-file filtering, while treating the section + // as absent drops the file's diff from the prompt entirely. + current_file = Some(parse_diff_header_path(line).unwrap_or("")); section_start_byte = current_byte; } current_byte += full_line.len(); @@ -1950,6 +1973,66 @@ index 111..222 100644 "); } + #[test] + fn test_parse_diff_sections_quoted_paths() { + // `core.quotePath` (git's default) quotes and octal-escapes a + // non-ASCII name, and a name holding `"` is quoted whatever that + // setting says. Neither header contains a bare ` b/`, so a parser + // that only knows the unquoted form found no path and dropped the + // file's diff from the prompt. + let diff = concat!( + "diff --git \"a/\\303\\251.txt\" \"b/\\303\\251.txt\"\n", + "+accented\n", + "diff --git \"a/we\\\"ird.lock\" \"b/we\\\"ird.lock\"\n", + "+quoted\n", + "diff --git a/plain.rs b/plain.rs\n", + "+plain\n", + ); + + let sections = parse_diff_sections(diff); + assert_eq!(sections.len(), 3); + assert_eq!(sections[0].0, "\\303\\251.txt"); + assert_eq!(sections[1].0, "we\\\"ird.lock"); + assert_eq!(sections[2].0, "plain.rs"); + + // No bytes dropped: every section's content survives to the prompt. + let combined: String = sections.iter().map(|(_, s)| *s).collect(); + assert_eq!(combined, diff); + } + + #[test] + fn test_parse_diff_sections_unparsable_header_keeps_content() { + // A header we can't read a path out of still opens a section — the + // name only drives lock-file filtering, so losing it must not lose + // the diff. + let diff = "diff --git weird\n+kept\ndiff --git a/plain.rs b/plain.rs\n+plain\n"; + + let sections = parse_diff_sections(diff); + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].0, ""); + assert_eq!(sections[1].0, "plain.rs"); + let combined: String = sections.iter().map(|(_, s)| *s).collect(); + assert_eq!(combined, diff); + } + + #[test] + fn test_prepare_diff_keeps_quoted_path_sections() { + // Over budget, the truncating path is what the section list feeds. + // A quoted-path section used to vanish from it entirely. + let big = "x".repeat(DIFF_BUDGET); + let diff = format!( + "diff --git \"a/\\303\\251.rs\" \"b/\\303\\251.rs\"\n+accented\n\ + diff --git a/plain.rs b/plain.rs\n+{big}\n" + ); + + let prepared = prepare_diff(diff, "stat".to_string()); + assert!( + prepared.diff.contains("\\303\\251.rs"), + "quoted-path section must survive truncation:\n{}", + prepared.diff + ); + } + #[test] fn test_parse_diff_sections_crlf_with_multibyte_utf8() { // Regression: CRLF line endings combined with multi-byte UTF-8 From ecbe45a1f2f57c86e852159de159ee4ede0b4704 Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:09:09 +0000 Subject: [PATCH 2/2] test(llm): use raw string literals for quoted-path diff fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md rules out `concat!()` and `\` continuation for multiline strings. Both new fixtures are dense with " and \, so the r#"…"# form the rest of the module already uses is both the mandated one and the readable one — the escaped git output now reads verbatim. --- src/llm.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/llm.rs b/src/llm.rs index 154fccba6..1d27cc4ed 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -1980,14 +1980,13 @@ index 111..222 100644 // setting says. Neither header contains a bare ` b/`, so a parser // that only knows the unquoted form found no path and dropped the // file's diff from the prompt. - let diff = concat!( - "diff --git \"a/\\303\\251.txt\" \"b/\\303\\251.txt\"\n", - "+accented\n", - "diff --git \"a/we\\\"ird.lock\" \"b/we\\\"ird.lock\"\n", - "+quoted\n", - "diff --git a/plain.rs b/plain.rs\n", - "+plain\n", - ); + let diff = r#"diff --git "a/\303\251.txt" "b/\303\251.txt" ++accented +diff --git "a/we\"ird.lock" "b/we\"ird.lock" ++quoted +diff --git a/plain.rs b/plain.rs ++plain +"#; let sections = parse_diff_sections(diff); assert_eq!(sections.len(), 3); @@ -2021,8 +2020,11 @@ index 111..222 100644 // A quoted-path section used to vanish from it entirely. let big = "x".repeat(DIFF_BUDGET); let diff = format!( - "diff --git \"a/\\303\\251.rs\" \"b/\\303\\251.rs\"\n+accented\n\ - diff --git a/plain.rs b/plain.rs\n+{big}\n" + r#"diff --git "a/\303\251.rs" "b/\303\251.rs" ++accented +diff --git a/plain.rs b/plain.rs ++{big} +"# ); let prepared = prepare_diff(diff, "stat".to_string());