git_iris/commit/
service.rs

1use anyhow::Result;
2use std::path::Path;
3use std::sync::Arc;
4use tokio::sync::{RwLock, mpsc};
5
6use super::prompt::{create_system_prompt, create_user_prompt, process_commit_message};
7use super::review::GeneratedReview;
8use super::types::GeneratedMessage;
9use crate::config::Config;
10use crate::context::CommitContext;
11use crate::git::{CommitResult, GitRepo};
12use crate::instruction_presets::{PresetType, get_instruction_preset_library};
13use crate::llm;
14use crate::log_debug;
15use crate::token_optimizer::TokenOptimizer;
16
17/// Service for handling Git commit operations with AI assistance
18pub struct IrisCommitService {
19    config: Config,
20    repo: Arc<GitRepo>,
21    provider_name: String,
22    use_gitmoji: bool,
23    verify: bool,
24    cached_context: Arc<RwLock<Option<CommitContext>>>,
25}
26
27impl IrisCommitService {
28    /// Create a new `IrisCommitService` instance
29    ///
30    /// # Arguments
31    ///
32    /// * `config` - The configuration for the service
33    /// * `repo_path` - The path to the Git repository (unused but kept for API compatibility)
34    /// * `provider_name` - The name of the LLM provider to use
35    /// * `use_gitmoji` - Whether to use Gitmoji in commit messages
36    /// * `verify` - Whether to verify commits
37    /// * `git_repo` - An existing `GitRepo` instance
38    ///
39    /// # Returns
40    ///
41    /// A Result containing the new `IrisCommitService` instance or an error
42    pub fn new(
43        config: Config,
44        _repo_path: &Path,
45        provider_name: &str,
46        use_gitmoji: bool,
47        verify: bool,
48        git_repo: GitRepo,
49    ) -> Result<Self> {
50        Ok(Self {
51            config,
52            repo: Arc::new(git_repo),
53            provider_name: provider_name.to_string(),
54            use_gitmoji,
55            verify,
56            cached_context: Arc::new(RwLock::new(None)),
57        })
58    }
59
60    /// Check if the repository is remote
61    pub fn is_remote_repository(&self) -> bool {
62        self.repo.is_remote()
63    }
64
65    /// Check the environment for necessary prerequisites
66    pub fn check_environment(&self) -> Result<()> {
67        self.config.check_environment()
68    }
69
70    /// Get Git information for the current repository
71    pub async fn get_git_info(&self) -> Result<CommitContext> {
72        {
73            let cached_context = self.cached_context.read().await;
74            if let Some(context) = &*cached_context {
75                return Ok(context.clone());
76            }
77        }
78
79        let context = self.repo.get_git_info(&self.config).await?;
80
81        {
82            let mut cached_context = self.cached_context.write().await;
83            *cached_context = Some(context.clone());
84        }
85        Ok(context)
86    }
87
88    /// Get Git information including unstaged changes
89    pub async fn get_git_info_with_unstaged(
90        &self,
91        include_unstaged: bool,
92    ) -> Result<CommitContext> {
93        if !include_unstaged {
94            return self.get_git_info().await;
95        }
96
97        {
98            // Only use cached context if we're not including unstaged changes
99            // because unstaged changes might have changed since we last checked
100            let cached_context = self.cached_context.read().await;
101            if let Some(context) = &*cached_context {
102                if !include_unstaged {
103                    return Ok(context.clone());
104                }
105            }
106        }
107
108        let context = self
109            .repo
110            .get_git_info_with_unstaged(&self.config, include_unstaged)
111            .await?;
112
113        // Don't cache the context with unstaged changes since they can be constantly changing
114        if !include_unstaged {
115            let mut cached_context = self.cached_context.write().await;
116            *cached_context = Some(context.clone());
117        }
118
119        Ok(context)
120    }
121
122    /// Get Git information for a specific commit
123    pub async fn get_git_info_for_commit(&self, commit_id: &str) -> Result<CommitContext> {
124        log_debug!("Getting git info for commit: {}", commit_id);
125
126        let context = self
127            .repo
128            .get_git_info_for_commit(&self.config, commit_id)
129            .await?;
130
131        // We don't cache commit-specific contexts
132        Ok(context)
133    }
134
135    /// Private helper method to handle common token optimization logic
136    ///
137    /// # Arguments
138    ///
139    /// * `config_clone` - Configuration with preset and instructions
140    /// * `system_prompt` - The system prompt to use
141    /// * `context` - The commit context
142    /// * `create_user_prompt_fn` - A function that creates a user prompt from a context
143    ///
144    /// # Returns
145    ///
146    /// A tuple containing the optimized context and final user prompt
147    fn optimize_prompt<F>(
148        &self,
149        config_clone: &Config,
150        system_prompt: &str,
151        mut context: CommitContext,
152        create_user_prompt_fn: F,
153    ) -> (CommitContext, String)
154    where
155        F: Fn(&CommitContext) -> String,
156    {
157        // Get the token limit for the provider from config or default value
158        let token_limit = config_clone
159            .providers
160            .get(&self.provider_name)
161            .and_then(|p| p.token_limit)
162            .unwrap_or({
163                match self.provider_name.as_str() {
164                    "openai" => 16_000,          // Default for OpenAI
165                    "anthropic" => 100_000,      // Anthropic Claude has large context
166                    "google" | "groq" => 32_000, // Default for Google and Groq
167                    _ => 8_000,                  // Conservative default for other providers
168                }
169            });
170
171        // Create a token optimizer to count tokens
172        let optimizer = TokenOptimizer::new(token_limit);
173        let system_tokens = optimizer.count_tokens(system_prompt);
174
175        log_debug!("Token limit: {}", token_limit);
176        log_debug!("System prompt tokens: {}", system_tokens);
177
178        // Reserve tokens for system prompt and some buffer for formatting
179        // 1000 token buffer provides headroom for model responses and formatting
180        let context_token_limit = token_limit.saturating_sub(system_tokens + 1000);
181        log_debug!("Available tokens for context: {}", context_token_limit);
182
183        // Count tokens before optimization
184        let user_prompt_before = create_user_prompt_fn(&context);
185        let total_tokens_before = system_tokens + optimizer.count_tokens(&user_prompt_before);
186        log_debug!("Total tokens before optimization: {}", total_tokens_before);
187
188        // Optimize the context with remaining token budget
189        context.optimize(context_token_limit);
190
191        let user_prompt = create_user_prompt_fn(&context);
192        let user_tokens = optimizer.count_tokens(&user_prompt);
193        let total_tokens = system_tokens + user_tokens;
194
195        log_debug!("User prompt tokens after optimization: {}", user_tokens);
196        log_debug!("Total tokens after optimization: {}", total_tokens);
197
198        // If we're still over the limit, truncate the user prompt directly
199        // 100 token safety buffer ensures we stay under the limit
200        let final_user_prompt = if total_tokens > token_limit {
201            log_debug!(
202                "Total tokens {} still exceeds limit {}, truncating user prompt",
203                total_tokens,
204                token_limit
205            );
206            let max_user_tokens = token_limit.saturating_sub(system_tokens + 100);
207            optimizer.truncate_string(&user_prompt, max_user_tokens)
208        } else {
209            user_prompt
210        };
211
212        let final_tokens = system_tokens + optimizer.count_tokens(&final_user_prompt);
213        log_debug!(
214            "Final total tokens after potential truncation: {}",
215            final_tokens
216        );
217
218        (context, final_user_prompt)
219    }
220
221    /// Generate a commit message using AI
222    ///
223    /// # Arguments
224    ///
225    /// * `preset` - The instruction preset to use
226    /// * `instructions` - Custom instructions for the AI
227    ///
228    /// # Returns
229    ///
230    /// A Result containing the generated commit message or an error
231    pub async fn generate_message(
232        &self,
233        preset: &str,
234        instructions: &str,
235    ) -> anyhow::Result<GeneratedMessage> {
236        let mut config_clone = self.config.clone();
237
238        // Check if the preset exists and is valid for commits
239        if preset.is_empty() {
240            config_clone.instruction_preset = "default".to_string();
241        } else {
242            let library = get_instruction_preset_library();
243            if let Some(preset_info) = library.get_preset(preset) {
244                if preset_info.preset_type == PresetType::Review {
245                    log_debug!(
246                        "Warning: Preset '{}' is review-specific, not ideal for commits",
247                        preset
248                    );
249                }
250                config_clone.instruction_preset = preset.to_string();
251            } else {
252                log_debug!("Preset '{}' not found, using default", preset);
253                config_clone.instruction_preset = "default".to_string();
254            }
255        }
256
257        config_clone.instructions = instructions.to_string();
258
259        let context = self.get_git_info().await?;
260
261        // Create system prompt
262        let system_prompt = create_system_prompt(&config_clone)?;
263
264        // Use the shared optimization logic
265        let (_, final_user_prompt) =
266            self.optimize_prompt(&config_clone, &system_prompt, context, create_user_prompt);
267
268        let mut generated_message = llm::get_message::<GeneratedMessage>(
269            &config_clone,
270            &self.provider_name,
271            &system_prompt,
272            &final_user_prompt,
273        )
274        .await?;
275
276        // Apply gitmoji setting
277        if !self.use_gitmoji {
278            generated_message.emoji = None;
279        }
280
281        Ok(generated_message)
282    }
283
284    /// Generate a review for unstaged changes
285    ///
286    /// # Arguments
287    ///
288    /// * `preset` - The instruction preset to use
289    /// * `instructions` - Custom instructions for the AI
290    /// * `include_unstaged` - Whether to include unstaged changes in the review
291    ///
292    /// # Returns
293    ///
294    /// A Result containing the generated code review or an error
295    pub async fn generate_review_with_unstaged(
296        &self,
297        preset: &str,
298        instructions: &str,
299        include_unstaged: bool,
300    ) -> anyhow::Result<GeneratedReview> {
301        let mut config_clone = self.config.clone();
302
303        // Set the preset and instructions
304        if preset.is_empty() {
305            config_clone.instruction_preset = "default".to_string();
306        } else {
307            let library = get_instruction_preset_library();
308            if let Some(preset_info) = library.get_preset(preset) {
309                if preset_info.preset_type == PresetType::Commit {
310                    log_debug!(
311                        "Warning: Preset '{}' is commit-specific, not ideal for reviews",
312                        preset
313                    );
314                }
315                config_clone.instruction_preset = preset.to_string();
316            } else {
317                log_debug!("Preset '{}' not found, using default", preset);
318                config_clone.instruction_preset = "default".to_string();
319            }
320        }
321
322        config_clone.instructions = instructions.to_string();
323
324        // Get context including unstaged changes if requested
325        let context = self.get_git_info_with_unstaged(include_unstaged).await?;
326
327        // Create system prompt
328        let system_prompt = super::prompt::create_review_system_prompt(&config_clone)?;
329
330        // Use the shared optimization logic
331        let (_, final_user_prompt) = self.optimize_prompt(
332            &config_clone,
333            &system_prompt,
334            context,
335            super::prompt::create_review_user_prompt,
336        );
337
338        llm::get_message::<GeneratedReview>(
339            &config_clone,
340            &self.provider_name,
341            &system_prompt,
342            &final_user_prompt,
343        )
344        .await
345    }
346
347    /// Generate a review for a specific commit
348    ///
349    /// # Arguments
350    ///
351    /// * `preset` - The instruction preset to use
352    /// * `instructions` - Custom instructions for the AI
353    /// * `commit_id` - The ID of the commit to review
354    ///
355    /// # Returns
356    ///
357    /// A Result containing the generated code review or an error
358    pub async fn generate_review_for_commit(
359        &self,
360        preset: &str,
361        instructions: &str,
362        commit_id: &str,
363    ) -> anyhow::Result<GeneratedReview> {
364        let mut config_clone = self.config.clone();
365
366        // Set the preset and instructions
367        if preset.is_empty() {
368            config_clone.instruction_preset = "default".to_string();
369        } else {
370            let library = get_instruction_preset_library();
371            if let Some(preset_info) = library.get_preset(preset) {
372                if preset_info.preset_type == PresetType::Commit {
373                    log_debug!(
374                        "Warning: Preset '{}' is commit-specific, not ideal for reviews",
375                        preset
376                    );
377                }
378                config_clone.instruction_preset = preset.to_string();
379            } else {
380                log_debug!("Preset '{}' not found, using default", preset);
381                config_clone.instruction_preset = "default".to_string();
382            }
383        }
384
385        config_clone.instructions = instructions.to_string();
386
387        // Get context for the specific commit
388        let context = self.get_git_info_for_commit(commit_id).await?;
389
390        // Create system prompt
391        let system_prompt = super::prompt::create_review_system_prompt(&config_clone)?;
392
393        // Use the shared optimization logic
394        let (_, final_user_prompt) = self.optimize_prompt(
395            &config_clone,
396            &system_prompt,
397            context,
398            super::prompt::create_review_user_prompt,
399        );
400
401        llm::get_message::<GeneratedReview>(
402            &config_clone,
403            &self.provider_name,
404            &system_prompt,
405            &final_user_prompt,
406        )
407        .await
408    }
409
410    /// Generate a code review using AI
411    ///
412    /// # Arguments
413    ///
414    /// * `preset` - The instruction preset to use
415    /// * `instructions` - Custom instructions for the AI
416    ///
417    /// # Returns
418    ///
419    /// A Result containing the generated code review or an error
420    pub async fn generate_review(
421        &self,
422        preset: &str,
423        instructions: &str,
424    ) -> anyhow::Result<GeneratedReview> {
425        let mut config_clone = self.config.clone();
426
427        // Check if the preset exists and is valid for reviews
428        if preset.is_empty() {
429            config_clone.instruction_preset = "default".to_string();
430        } else {
431            let library = get_instruction_preset_library();
432            if let Some(preset_info) = library.get_preset(preset) {
433                if preset_info.preset_type == PresetType::Commit {
434                    log_debug!(
435                        "Warning: Preset '{}' is commit-specific, not ideal for reviews",
436                        preset
437                    );
438                }
439                config_clone.instruction_preset = preset.to_string();
440            } else {
441                log_debug!("Preset '{}' not found, using default", preset);
442                config_clone.instruction_preset = "default".to_string();
443            }
444        }
445
446        config_clone.instructions = instructions.to_string();
447
448        let context = self.get_git_info().await?;
449
450        // Create system prompt
451        let system_prompt = super::prompt::create_review_system_prompt(&config_clone)?;
452
453        // Use the shared optimization logic
454        let (_, final_user_prompt) = self.optimize_prompt(
455            &config_clone,
456            &system_prompt,
457            context,
458            super::prompt::create_review_user_prompt,
459        );
460
461        llm::get_message::<GeneratedReview>(
462            &config_clone,
463            &self.provider_name,
464            &system_prompt,
465            &final_user_prompt,
466        )
467        .await
468    }
469
470    /// Performs a commit with the given message.
471    ///
472    /// # Arguments
473    ///
474    /// * `message` - The commit message.
475    ///
476    /// # Returns
477    ///
478    /// A Result containing the `CommitResult` or an error.
479    pub fn perform_commit(&self, message: &str) -> Result<CommitResult> {
480        // Check if this is a remote repository
481        if self.is_remote_repository() {
482            return Err(anyhow::anyhow!("Cannot commit to a remote repository"));
483        }
484
485        let processed_message = process_commit_message(message.to_string(), self.use_gitmoji);
486        log_debug!("Performing commit with message: {}", processed_message);
487
488        if !self.verify {
489            log_debug!("Skipping pre-commit hook (verify=false)");
490            return self.repo.commit(&processed_message);
491        }
492
493        // Execute pre-commit hook
494        log_debug!("Executing pre-commit hook");
495        if let Err(e) = self.repo.execute_hook("pre-commit") {
496            log_debug!("Pre-commit hook failed: {}", e);
497            return Err(e);
498        }
499        log_debug!("Pre-commit hook executed successfully");
500
501        // Perform the commit
502        match self.repo.commit(&processed_message) {
503            Ok(result) => {
504                // Execute post-commit hook
505                log_debug!("Executing post-commit hook");
506                if let Err(e) = self.repo.execute_hook("post-commit") {
507                    log_debug!("Post-commit hook failed: {}", e);
508                    // We don't fail the commit if post-commit hook fails
509                }
510                log_debug!("Commit performed successfully");
511                Ok(result)
512            }
513            Err(e) => {
514                log_debug!("Commit failed: {}", e);
515                Err(e)
516            }
517        }
518    }
519
520    /// Execute the pre-commit hook if verification is enabled
521    pub fn pre_commit(&self) -> Result<()> {
522        // Skip pre-commit hook for remote repositories
523        if self.is_remote_repository() {
524            log_debug!("Skipping pre-commit hook for remote repository");
525            return Ok(());
526        }
527
528        if self.verify {
529            self.repo.execute_hook("pre-commit")
530        } else {
531            Ok(())
532        }
533    }
534
535    /// Create a channel for message generation
536    pub fn create_message_channel(
537        &self,
538    ) -> (
539        mpsc::Sender<Result<GeneratedMessage>>,
540        mpsc::Receiver<Result<GeneratedMessage>>,
541    ) {
542        mpsc::channel(1)
543    }
544}