1use std::collections::HashMap;
2use std::io::{Read, Write};
3use std::path::PathBuf;
4use std::fs::File;
5use std::sync::Arc;
6
7use rayon::prelude::*;
8use git2::{Diff, DiffFormat, DiffOptions, Repository, Tree};
9use anyhow::{Context, Result};
10use thiserror::Error;
11use num_cpus;
12
13use crate::model::Model;
14use crate::profile;
15
16const DEFAULT_STRING_CAPACITY: usize = 8192;
19const ESTIMATED_FILES_COUNT: usize = 100;
20
21type DiffData = Vec<(PathBuf, String, usize)>;
23
24#[derive(Error, Debug)]
26pub enum HookError {
27 #[error("Failed to open repository")]
28 OpenRepository,
29
30 #[error("Failed to get patch")]
31 GetPatch,
32
33 #[error("Empty diff output")]
34 EmptyDiffOutput,
35
36 #[error("Failed to write commit message")]
37 WriteCommitMessage,
38
39 #[error(transparent)]
40 Anyhow(#[from] anyhow::Error)
41}
42
43pub trait FilePath {
45 fn is_empty(&self) -> Result<bool> {
46 self.read().map(|s| s.is_empty())
47 }
48
49 fn write(&self, msg: String) -> Result<()>;
50 fn read(&self) -> Result<String>;
51}
52
53impl FilePath for PathBuf {
54 fn write(&self, msg: String) -> Result<()> {
55 File::create(self)?
56 .write_all(msg.as_bytes())
57 .map_err(Into::into)
58 }
59
60 fn read(&self) -> Result<String> {
61 let mut contents = String::new();
62 File::open(self)?.read_to_string(&mut contents)?;
63 Ok(contents)
64 }
65}
66
67trait DiffDeltaPath {
69 fn path(&self) -> PathBuf;
70}
71
72impl DiffDeltaPath for git2::DiffDelta<'_> {
73 fn path(&self) -> PathBuf {
74 self
75 .new_file()
76 .path()
77 .or_else(|| self.old_file().path())
78 .map(PathBuf::from)
79 .unwrap_or_default()
80 }
81}
82
83pub trait Utf8String {
85 fn to_utf8(&self) -> String;
86}
87
88impl Utf8String for Vec<u8> {
89 fn to_utf8(&self) -> String {
90 if let Ok(s) = std::str::from_utf8(self) {
92 return s.to_string();
93 }
94 String::from_utf8_lossy(self).into_owned()
96 }
97}
98
99impl Utf8String for [u8] {
100 fn to_utf8(&self) -> String {
101 if let Ok(s) = std::str::from_utf8(self) {
103 return s.to_string();
104 }
105 String::from_utf8_lossy(self).into_owned()
107 }
108}
109
110pub trait PatchDiff {
112 fn to_patch(&self, max_token_count: usize, model: Model) -> Result<String>;
113 fn collect_diff_data(&self) -> Result<HashMap<PathBuf, String>>;
114 fn is_empty(&self) -> Result<bool>;
115}
116
117impl PatchDiff for Diff<'_> {
118 fn to_patch(&self, max_tokens: usize, model: Model) -> Result<String> {
119 profile!("Generating patch diff");
120
121 let files = self.collect_diff_data()?;
129 if files.is_empty() {
130 return Ok(String::new());
131 }
132
133 let mut files: Vec<(PathBuf, String)> = files.into_iter().collect();
134 files.sort_by(|a, b| a.0.cmp(&b.0));
135
136 if files.len() == 1 {
138 profile!("Single file fast path");
139 let (_, content) = files
140 .into_iter()
141 .next()
142 .ok_or_else(|| HookError::EmptyDiffOutput)?;
143
144 if content.len() < max_tokens * 4 {
146 return Ok(content);
148 }
149
150 return model.truncate(&content, max_tokens);
152 }
153
154 if files.len() <= 5 && max_tokens > 500 {
156 profile!("Small diff fast path");
157 let file_count = files.len(); let mut result = String::new();
159
160 for (i, (_, content)) in files.into_iter().enumerate() {
162 if i > 0 {
163 result.push('\n');
164 }
165 let limit = (max_tokens / file_count) * 4; let truncated = if content.len() > limit {
168 let truncated = content.chars().take(limit).collect::<String>();
169 let last_space = truncated
171 .rfind(char::is_whitespace)
172 .unwrap_or(truncated.len());
173 if last_space > 0 {
174 truncated[..last_space].to_string()
175 } else {
176 truncated
177 }
178 } else {
179 content
180 };
181 result.push_str(&truncated);
182 }
183
184 return Ok(result);
185 }
186
187 if files.len() <= 20 {
193 profile!("Medium diff optimized path");
194
195 let mut files_vec: Vec<(PathBuf, String, usize)> = files
197 .into_iter()
198 .map(|(path, content)| {
199 let estimated_tokens = content.len() / 4;
201 (path, content, estimated_tokens)
202 })
203 .collect();
204
205 files_vec.sort_by_key(|(_, _, count)| *count);
207
208 let mut result = String::new();
210 let mut tokens_used = 0;
211
212 for (i, (_, content, estimated_tokens)) in files_vec.into_iter().enumerate() {
213 if tokens_used >= max_tokens {
214 break;
215 }
216
217 if i > 0 {
218 result.push('\n');
219 }
220
221 let tokens_left = max_tokens.saturating_sub(tokens_used);
222 let tokens_for_file = estimated_tokens.min(tokens_left);
223
224 let processed_content = if estimated_tokens > tokens_for_file {
226 let char_limit = tokens_for_file * 4;
228 let truncated: String = content.chars().take(char_limit).collect();
229 truncated
230 } else {
231 content
232 };
233
234 result.push_str(&processed_content);
235 tokens_used += tokens_for_file;
236 }
237
238 return Ok(result);
239 }
240
241 profile!("Converting files to vector");
252 let total_files = files.len();
253
254 let thread_pool = rayon::ThreadPoolBuilder::new()
255 .num_threads(num_cpus::get())
256 .build()
257 .context("Failed to create thread pool")?;
258
259 profile!("Parallel token counting");
261 let files_with_tokens: DiffData = thread_pool.install(|| {
262 files
263 .par_iter()
264 .map(|(path, content)| {
265 let token_count = model.count_tokens(content).unwrap_or_default();
266 (path.clone(), content.clone(), token_count)
267 })
268 .collect()
269 });
270
271 profile!("Sorting files by token count");
274 let sorted_files = if total_files > 500 {
275 files_with_tokens
276 } else {
277 let mut sorted = files_with_tokens;
278 sorted.sort_by_key(|(_, _, count)| *count);
279 sorted
280 };
281
282 profile!("Allocating token budget");
286 let mut remaining = max_tokens;
287 let mut allocations: Vec<(PathBuf, String, usize, usize)> = Vec::with_capacity(sorted_files.len());
289 let mut files_left = sorted_files.len();
290 for (path, content, token_count) in sorted_files.into_iter() {
291 if remaining == 0 {
292 break;
293 }
294 let fair_share = remaining / files_left.max(1);
296 let allocated = token_count.min(fair_share.max(1)).min(remaining);
297 remaining -= allocated;
298 files_left = files_left.saturating_sub(1);
299 allocations.push((path, content, token_count, allocated));
300 }
301
302 profile!("Parallel truncation");
305 let model = Arc::new(model);
306 let processed: Vec<(PathBuf, String)> = thread_pool.install(|| {
307 allocations
308 .par_iter()
309 .map(|(path, content, token_count, allocated)| {
310 let out = if *token_count <= *allocated {
311 content.clone()
312 } else if content.len() < 2000 || *allocated > 500 {
313 content.chars().take(allocated * 4).collect::<String>()
315 } else {
316 model.truncate(content, *allocated)?
317 };
318 Ok((path.clone(), out))
319 })
320 .collect::<Result<Vec<_>>>()
321 })?;
322
323 profile!("Combining results");
325 if processed.is_empty() {
326 return Ok(String::new());
327 }
328
329 let total_len = processed
330 .iter()
331 .map(|(_, content)| content.len())
332 .sum::<usize>();
333 let mut final_result = String::with_capacity(total_len + processed.len());
334
335 for (i, (_, content)) in processed.iter().enumerate() {
336 if i > 0 {
337 final_result.push('\n');
338 }
339 final_result.push_str(content);
340 }
341
342 Ok(final_result)
343 }
344
345 fn collect_diff_data(&self) -> Result<HashMap<PathBuf, String>> {
346 profile!("Processing diff changes");
347
348 let mut files = HashMap::with_capacity(ESTIMATED_FILES_COUNT);
350
351 thread_local! {
353 static PATH_CACHE: std::cell::RefCell<HashMap<PathBuf, ()>> =
354 std::cell::RefCell::new(HashMap::with_capacity(20));
355 }
356
357 self.print(DiffFormat::Patch, |diff, _hunk, line| {
359 let path = PATH_CACHE.with(|cache| {
361 let mut cache = cache.borrow_mut();
362 let new_path = diff.path();
363 if let Some(existing_path) = cache.keys().find(|p| *p == &new_path) {
364 existing_path.clone()
365 } else {
366 cache.insert(new_path.clone(), ());
367 new_path
368 }
369 });
370
371 let content = if let Ok(s) = std::str::from_utf8(line.content()) {
373 s.to_string()
374 } else {
375 line.content().to_utf8()
377 };
378
379 match line.origin() {
381 '+' | '-' | ' ' => {
382 let entry = files
384 .entry(path)
385 .or_insert_with(|| String::with_capacity(DEFAULT_STRING_CAPACITY));
386
387 match line.origin() {
389 '+' => entry.push('+'),
390 '-' => entry.push('-'),
391 ' ' => entry.push(' '),
392 _ => {}
393 }
394 entry.push_str(&content);
395 }
396 'F' | 'H' => {
397 let entry = files
401 .entry(path)
402 .or_insert_with(|| String::with_capacity(DEFAULT_STRING_CAPACITY));
403 entry.push_str(&content);
404 }
405 _ => {
406 log::trace!("Skipping diff line with origin: {:?}", line.origin());
407 }
408 }
409
410 true
411 })?;
412
413 Ok(files)
414 }
415
416 fn is_empty(&self) -> Result<bool> {
417 let mut has_changes = false;
418
419 self.foreach(
420 &mut |_file, _progress| {
421 has_changes = true;
422 true
423 },
424 None,
425 None,
426 None
427 )?;
428
429 Ok(!has_changes)
430 }
431}
432
433pub trait PatchRepository {
434 fn to_patch(&self, tree: Option<Tree<'_>>, max_token_count: usize, model: Model) -> Result<String>;
435 fn to_diff(&self, tree: Option<Tree<'_>>) -> Result<git2::Diff<'_>>;
436 fn to_commit_diff(&self, tree: Option<Tree<'_>>) -> Result<git2::Diff<'_>>;
437 fn configure_diff_options(&self, opts: &mut DiffOptions);
438 fn configure_commit_diff_options(&self, opts: &mut DiffOptions);
439}
440
441impl PatchRepository for Repository {
442 fn to_patch(&self, tree: Option<Tree>, max_token_count: usize, model: Model) -> Result<String> {
443 profile!("Repository patch generation");
444 self.to_commit_diff(tree)?.to_patch(max_token_count, model)
445 }
446
447 fn to_diff(&self, tree: Option<Tree<'_>>) -> Result<git2::Diff<'_>> {
448 profile!("Git diff generation");
449 let mut opts = DiffOptions::new();
450 self.configure_diff_options(&mut opts);
451
452 match tree {
453 Some(tree) => {
454 self.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))
456 }
457 None => {
458 let empty_tree = self.find_tree(self.treebuilder(None)?.write()?)?;
460 self.diff_tree_to_workdir_with_index(Some(&empty_tree), Some(&mut opts))
462 }
463 }
464 .context("Failed to get diff")
465 }
466
467 fn to_commit_diff(&self, tree: Option<Tree<'_>>) -> Result<git2::Diff<'_>> {
468 profile!("Git commit diff generation");
469 let mut opts = DiffOptions::new();
470 self.configure_commit_diff_options(&mut opts);
471
472 match tree {
473 Some(tree) => {
474 self.diff_tree_to_index(Some(&tree), None, Some(&mut opts))
476 }
477 None => {
478 let empty_tree = self.find_tree(self.treebuilder(None)?.write()?)?;
480 self.diff_tree_to_index(Some(&empty_tree), None, Some(&mut opts))
482 }
483 }
484 .context("Failed to get diff")
485 }
486
487 fn configure_diff_options(&self, opts: &mut DiffOptions) {
488 opts
489 .ignore_whitespace_change(true)
490 .recurse_untracked_dirs(true)
491 .recurse_ignored_dirs(false)
492 .ignore_whitespace_eol(true)
493 .ignore_blank_lines(true)
494 .include_untracked(true)
495 .ignore_whitespace(true)
496 .indent_heuristic(false)
497 .ignore_submodules(true)
498 .include_ignored(false)
499 .interhunk_lines(0)
500 .context_lines(0)
501 .patience(true)
502 .minimal(true);
503 }
504
505 fn configure_commit_diff_options(&self, opts: &mut DiffOptions) {
506 opts
507 .ignore_whitespace_change(false)
508 .recurse_untracked_dirs(false)
509 .recurse_ignored_dirs(false)
510 .ignore_whitespace_eol(true)
511 .ignore_blank_lines(true)
512 .include_untracked(false)
513 .ignore_whitespace(true)
514 .indent_heuristic(false)
515 .ignore_submodules(true)
516 .include_ignored(false)
517 .interhunk_lines(0)
518 .context_lines(0)
519 .patience(true)
520 .minimal(true);
521 }
522}