Skip to main content

ai/
hook.rs

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
16// Constants
17
18const DEFAULT_STRING_CAPACITY: usize = 8192;
19const ESTIMATED_FILES_COUNT: usize = 100;
20
21// Types
22type DiffData = Vec<(PathBuf, String, usize)>;
23
24// Error definitions
25#[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
43// File operations traits
44pub 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
67// Git operations traits
68trait 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
83// String conversion traits
84pub trait Utf8String {
85  fn to_utf8(&self) -> String;
86}
87
88impl Utf8String for Vec<u8> {
89  fn to_utf8(&self) -> String {
90    // Fast path for valid UTF-8 (most common case)
91    if let Ok(s) = std::str::from_utf8(self) {
92      return s.to_string();
93    }
94    // Fallback for invalid UTF-8
95    String::from_utf8_lossy(self).into_owned()
96  }
97}
98
99impl Utf8String for [u8] {
100  fn to_utf8(&self) -> String {
101    // Fast path for valid UTF-8 (most common case)
102    if let Ok(s) = std::str::from_utf8(self) {
103      return s.to_string();
104    }
105    // Fallback for invalid UTF-8
106    String::from_utf8_lossy(self).into_owned()
107  }
108}
109
110// Patch generation traits
111pub 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    // Step 1: Collect diff data (non-parallel).
122    //
123    // `collect_diff_data` returns a `HashMap`, whose iteration order is randomized per
124    // instance (std `RandomState`). Iterating it directly made the combined patch's file
125    // order non-deterministic, so identical staged input could yield different patches and
126    // therefore different commit messages. We sort by path *once* here and feed the ordered
127    // `Vec` to every downstream path, making all of them deterministic. (C4)
128    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    // Fast path for small diffs - skip tokenization entirely
137    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 is small enough to fit, just return it directly
145      if content.len() < max_tokens * 4 {
146        // Estimate 4 chars per token
147        return Ok(content);
148      }
149
150      // Otherwise do a simple truncation
151      return model.truncate(&content, max_tokens);
152    }
153
154    // Optimization: Skip token counting entirely for small diffs
155    if files.len() <= 5 && max_tokens > 500 {
156      profile!("Small diff fast path");
157      let file_count = files.len(); // Capture before consuming `files`.
158      let mut result = String::new();
159
160      // Just combine the files with a limit on total size
161      for (i, (_, content)) in files.into_iter().enumerate() {
162        if i > 0 {
163          result.push('\n');
164        }
165        // Only add as much as we can estimate will fit
166        let limit = (max_tokens / file_count) * 4; // ~4 chars per token
167        let truncated = if content.len() > limit {
168          let truncated = content.chars().take(limit).collect::<String>();
169          // Find last space to avoid cutting words
170          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    // Step 2: Prepare files for processing - optimized path for medium diffs.
188    //
189    // Files are already in deterministic path order. We keep a stable secondary sort by
190    // estimated token count (smaller files first, to fit as many whole files as possible)
191    // while preserving path order among equal-sized files via `sort_by_key`'s stability.
192    if files.len() <= 20 {
193      profile!("Medium diff optimized path");
194
195      // Convert to vector with simple heuristic for token count
196      let mut files_vec: Vec<(PathBuf, String, usize)> = files
197        .into_iter()
198        .map(|(path, content)| {
199          // Estimate token count as character count / 4
200          let estimated_tokens = content.len() / 4;
201          (path, content, estimated_tokens)
202        })
203        .collect();
204
205      // Stable sort by estimated size (path order preserved among ties => deterministic).
206      files_vec.sort_by_key(|(_, _, count)| *count);
207
208      // Allocate tokens to files and process
209      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        // Only truncate if needed
225        let processed_content = if estimated_tokens > tokens_for_file {
226          // Simple character-based truncation for speed
227          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    // Step 3: Complex diff path - parallel processing with deterministic output.
242    //
243    // CPU-bound work (token counting, truncation) runs on rayon. The previous design used a
244    // shared atomic token budget plus a shared result `Vec` written from `try_for_each`, which
245    // made both the *truncation* (budget races) and the *output order* (completion order)
246    // non-deterministic. We replace that with:
247    //   1. order-preserving parallel token counting (`par_iter().map().collect()`),
248    //   2. a sequential budget pass over the path-sorted files (cheap arithmetic),
249    //   3. order-preserving parallel truncation,
250    // so identical input always yields byte-identical output. (C3b + C4)
251    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    // Token counting (CPU-bound, parallel, order-preserving).
260    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    // Stable sort by token count (smaller first), preserving path order among ties. For very
272    // large diffs we skip the sort but keep the deterministic path order from Step 1.
273    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    // Step 4: Sequential budget allocation (deterministic). Decide how many tokens each file
283    // may keep, in order, with no cross-thread races. We carry the already-computed
284    // `token_count` forward so the truncation pass does not recount.
285    profile!("Allocating token budget");
286    let mut remaining = max_tokens;
287    // (path, content, token_count, allocated)
288    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      // Even share of the remaining budget, capped at what the file actually needs.
295      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    // Truncation (CPU-bound, parallel, order-preserving). Errors propagate rather than being
303    // silently swallowed into an empty file.
304    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            // Character-based truncation is much faster than tokenization.
314            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    // Step 5: Combine results in the (deterministic) order produced above.
324    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    // Pre-allocate HashMap with estimated capacity
349    let mut files = HashMap::with_capacity(ESTIMATED_FILES_COUNT);
350
351    // Create thread-local cache for paths to avoid allocations
352    thread_local! {
353      static PATH_CACHE: std::cell::RefCell<HashMap<PathBuf, ()>> =
354        std::cell::RefCell::new(HashMap::with_capacity(20));
355    }
356
357    // Process diffs with optimized buffer handling
358    self.print(DiffFormat::Patch, |diff, _hunk, line| {
359      // Get path with potential reuse from cache for better performance
360      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      // Fast path for UTF-8 content - avoid expensive conversions
372      let content = if let Ok(s) = std::str::from_utf8(line.content()) {
373        s.to_string()
374      } else {
375        // Fallback for non-UTF8 content
376        line.content().to_utf8()
377      };
378
379      // Process line by line origin more efficiently
380      match line.origin() {
381        '+' | '-' | ' ' => {
382          // Added, removed, or context lines - append with origin prefix
383          let entry = files
384            .entry(path)
385            .or_insert_with(|| String::with_capacity(DEFAULT_STRING_CAPACITY));
386
387          // Add the origin character for proper diff format
388          match line.origin() {
389            '+' => entry.push('+'),
390            '-' => entry.push('-'),
391            ' ' => entry.push(' '),
392            _ => {}
393          }
394          entry.push_str(&content);
395        }
396        'F' | 'H' => {
397          // File headers (diff --git, index, ---, +++) and hunk headers (@@ ... @@) carry the
398          // structure parse_diff() needs to split the patch into per-file sections. Dropping them
399          // (the v1.1.1 regression) collapsed every diff into a single "unknown" file.
400          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        // Get the diff between tree and working directory, including staged changes
455        self.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))
456      }
457      None => {
458        // If there's no HEAD yet, compare against an empty tree
459        let empty_tree = self.find_tree(self.treebuilder(None)?.write()?)?;
460        // Get the diff between empty tree and working directory, including staged changes
461        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        // Get the diff between tree and index (staged changes only)
475        self.diff_tree_to_index(Some(&tree), None, Some(&mut opts))
476      }
477      None => {
478        // If there's no HEAD yet, compare against an empty tree
479        let empty_tree = self.find_tree(self.treebuilder(None)?.write()?)?;
480        // Get the diff between empty tree and index (staged changes only)
481        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}