Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions src/commands/git_ai_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,11 +391,18 @@ fn handle_checkpoint(args: &[String]) {
hook_input = Some(strip_utf8_bom(args[i + 1].clone()));
if hook_input.as_ref().unwrap() == "stdin" {
let mut stdin = std::io::stdin();
let mut buffer = String::new();
if let Err(e) = stdin.read_to_string(&mut buffer) {
let mut buffer = Vec::new();
if let Err(e) = stdin.read_to_end(&mut buffer) {
eprintln!("Failed to read stdin for hook input: {}", e);
std::process::exit(0);
}
let buffer = match decode_hook_input_bytes(buffer) {
Ok(buffer) => buffer,
Err(e) => {
eprintln!("Failed to decode stdin for hook input: {}", e);
std::process::exit(0);
}
};
if buffer.trim().is_empty() {
eprintln!("No hook input provided (via --hook-input or stdin).");
std::process::exit(0);
Expand Down Expand Up @@ -574,6 +581,65 @@ fn strip_utf8_bom(input: String) -> String {
}
}

fn decode_hook_input_bytes(bytes: Vec<u8>) -> Result<String, String> {
if bytes.starts_with(&[0xFF, 0xFE]) {
return decode_utf16_hook_input(&bytes[2..], Utf16Endian::Little);
}
if bytes.starts_with(&[0xFE, 0xFF]) {
return decode_utf16_hook_input(&bytes[2..], Utf16Endian::Big);
}

match likely_utf16_endian(&bytes) {
Some(endian) => decode_utf16_hook_input(&bytes, endian),
None => String::from_utf8(bytes).map_err(|e| e.to_string()),
}
}

#[derive(Clone, Copy)]
enum Utf16Endian {
Little,
Big,
}

fn likely_utf16_endian(bytes: &[u8]) -> Option<Utf16Endian> {
let sample_len = bytes.len().min(512);
if sample_len < 8 {
return None;
}

let sample = &bytes[..sample_len];
let even_nuls = sample.iter().step_by(2).filter(|&&b| b == 0).count();
let odd_nuls = sample
.iter()
.skip(1)
.step_by(2)
.filter(|&&b| b == 0)
.count();
let min_nuls = sample_len / 8;

if odd_nuls > min_nuls && odd_nuls > even_nuls.saturating_mul(4) {
Some(Utf16Endian::Little)
} else if even_nuls > min_nuls && even_nuls > odd_nuls.saturating_mul(4) {
Some(Utf16Endian::Big)
} else {
None
}
}

fn decode_utf16_hook_input(bytes: &[u8], endian: Utf16Endian) -> Result<String, String> {
let chunks = bytes.chunks_exact(2);
if !chunks.remainder().is_empty() {
return Err("UTF-16 hook input has an odd byte length".to_string());
}

let code_units = chunks.map(|chunk| match endian {
Utf16Endian::Little => u16::from_le_bytes([chunk[0], chunk[1]]),
Utf16Endian::Big => u16::from_be_bytes([chunk[0], chunk[1]]),
});

String::from_utf16(&code_units.collect::<Vec<u16>>()).map_err(|e| e.to_string())
}

#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct EffectiveIgnorePatternsRequest {
Expand Down
55 changes: 55 additions & 0 deletions tests/integration/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,61 @@ fn test_cursor_checkpoint_stdin_with_utf8_bom() {
);
}

fn utf16le_bytes(input: &str) -> Vec<u8> {
input
.encode_utf16()
.flat_map(|unit| unit.to_le_bytes())
.collect()
}

#[test]
fn test_cursor_checkpoint_stdin_with_utf16le_odd_cjk_content() {
use std::fs;

let repo = TestRepo::new();

let src_dir = repo.path().join("src");
fs::create_dir_all(&src_dir).unwrap();

let file_path = repo.path().join("src/文案文.js");
fs::write(&file_path, "const a = \"base\";\n").unwrap();
repo.stage_all_and_commit("Initial commit").unwrap();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
let mut file = repo.filename("src/文案文.js");
file.assert_committed_lines(crate::lines!["const a = \"base\";".unattributed_human(),]);

let edited_content = "const a = \"文案文\"; // 3 chars\n";
fs::write(&file_path, edited_content).unwrap();

let hook_input = serde_json::json!({
"conversation_id": TEST_CONVERSATION_ID,
"workspace_roots": [repo.canonical_path().to_string_lossy().to_string()],
"hook_event_name": "postToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": file_path.to_string_lossy().to_string(),
"content": edited_content,
},
"model": "model-name-from-hook-test"
})
.to_string();

let output = repo
.git_ai_with_stdin(
&["checkpoint", "cursor", "--hook-input", "stdin"],
&utf16le_bytes(&hook_input),
)
.expect("checkpoint should parse UTF-16LE stdin payload with odd CJK content");

assert!(
!output.contains("Invalid JSON in hook_input"),
"Should not fail JSON parsing for odd CJK content in UTF-16LE stdin. Output: {output}"
);

repo.stage_all_and_commit("Add cursor CJK edit").unwrap();

file.assert_lines_and_blame(crate::lines!["const a = \"文案文\"; // 3 chars".ai(),]);
}

#[test]
fn test_cursor_e2e_with_attribution() {
use std::fs;
Expand Down
Loading