gitai/features/commit/
service.rs

1use super::prompt::{create_system_prompt, create_user_prompt};
2use super::review::GeneratedReview;
3use super::types::GeneratedMessage;
4use crate::config::Config;
5use crate::core::context::CommitContext;
6use crate::core::llm;
7use crate::core::token_optimizer::TokenOptimizer;
8use crate::debug;
9use crate::git::{CommitResult, GitRepo};
10use crate::instruction_presets::{PresetType, get_instruction_preset_library};
11
12use anyhow::Result;
13use std::path::Path;
14use std::sync::Arc;
15use tokio::sync::{RwLock, mpsc};
16
17/// Service for handling Git commit operations with AI assistance
18pub struct CommitService {
19    config: Config,
20    repo: Arc<GitRepo>,
21    provider_name: String,
22    use_emoji: bool,
23    verify: bool,
24    cached_context: Arc<RwLock<Option<CommitContext>>>,
25}
26
27impl CommitService {
28    /// Create a new `CommitService` 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_emoji` - Whether to use emoji 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 `CommitService` instance or an error
42    pub fn new(
43        config: Config,
44        _repo_path: &Path,
45        provider_name: &str,
46        use_emoji: 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_emoji,
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                && !include_unstaged
103            {
104                return Ok(context.clone());
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        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,
165                    "anthropic" => 100_000,
166                    "groq" => 32_000,
167                    "openrouter" => 2_000_000,
168                    "google" => 1_000_000,
169                    _ => 8_000,
170                }
171            });
172
173        // Create a token optimizer to count tokens
174        let optimizer = TokenOptimizer::new(token_limit).expect("Failed to create TokenOptimizer");
175        let system_tokens = optimizer.count_tokens(system_prompt);
176
177        debug!("Token limit: {}", token_limit);
178        debug!("System prompt tokens: {}", system_tokens);
179
180        // Reserve tokens for system prompt and some buffer for formatting
181        // 1000 token buffer provides headroom for model responses and formatting
182        let context_token_limit = token_limit.saturating_sub(system_tokens + 1000);
183        debug!("Available tokens for context: {}", context_token_limit);
184
185        // Count tokens before optimization
186        let user_prompt_before = create_user_prompt_fn(&context);
187        let total_tokens_before = system_tokens + optimizer.count_tokens(&user_prompt_before);
188        debug!("Total tokens before optimization: {}", total_tokens_before);
189
190        // Optimize the context with remaining token budget
191        context.optimize(context_token_limit);
192
193        let user_prompt = create_user_prompt_fn(&context);
194        let user_tokens = optimizer.count_tokens(&user_prompt);
195        let total_tokens = system_tokens + user_tokens;
196
197        debug!("User prompt tokens after optimization: {}", user_tokens);
198        debug!("Total tokens after optimization: {}", total_tokens);
199
200        // If we're still over the limit, truncate the user prompt directly
201        // 100 token safety buffer ensures we stay under the limit
202        let final_user_prompt = if total_tokens > token_limit {
203            debug!(
204                "Total tokens {} still exceeds limit {}, truncating user prompt",
205                total_tokens, token_limit
206            );
207            let max_user_tokens = token_limit.saturating_sub(system_tokens + 100);
208            optimizer
209                .truncate_string(&user_prompt, max_user_tokens)
210                .expect("Failed to truncate user prompt")
211        } else {
212            user_prompt
213        };
214
215        let final_tokens = system_tokens + optimizer.count_tokens(&final_user_prompt);
216        debug!(
217            "Final total tokens after potential truncation: {}",
218            final_tokens
219        );
220
221        (context, final_user_prompt)
222    }
223
224    /// Generate a commit message using AI
225    ///
226    /// # Arguments
227    ///
228    /// * `preset` - The instruction preset to use
229    /// * `instructions` - Custom instructions for the AI
230    ///
231    /// # Returns
232    ///
233    /// A Result containing the generated commit message or an error
234    pub async fn generate_message(
235        &self,
236        preset: &str,
237        instructions: &str,
238    ) -> anyhow::Result<GeneratedMessage> {
239        let mut config_clone = self.config.clone();
240
241        // Check if the preset exists and is valid for commits
242        let effective_preset = if preset.is_empty() {
243            config_clone.instruction_preset = "default".to_string();
244            "default"
245        } else {
246            let library = get_instruction_preset_library();
247            if let Some(preset_info) = library.get_preset(preset) {
248                if preset_info.preset_type == PresetType::Review {
249                    debug!(
250                        "Warning: Preset '{}' is review-specific, not ideal for commits",
251                        preset
252                    );
253                }
254                config_clone.instruction_preset = preset.to_string();
255                preset
256            } else {
257                debug!("Preset '{}' not found, using default", preset);
258                config_clone.instruction_preset = "default".to_string();
259                "default"
260            }
261        };
262
263        config_clone.instructions = instructions.to_string();
264
265        let context = self.get_git_info().await?;
266
267        // Create system prompt
268        let system_prompt = create_system_prompt(&config_clone)?;
269
270        // Use the shared optimization logic
271        let (_, final_user_prompt) =
272            self.optimize_prompt(&config_clone, &system_prompt, context, create_user_prompt);
273
274        let mut generated_message = llm::get_message::<GeneratedMessage>(
275            &config_clone,
276            &self.provider_name,
277            &system_prompt,
278            &final_user_prompt,
279        )
280        .await?;
281
282        // Apply emoji setting - automatically disable for conventional commits
283        let should_use_emoji = self.use_emoji && effective_preset != "conventional";
284        if !should_use_emoji {
285            generated_message.emoji = None;
286        }
287
288        Ok(generated_message)
289    }
290
291    /// Generate a review for unstaged changes
292    ///
293    /// # Arguments
294    ///
295    /// * `preset` - The instruction preset to use
296    /// * `instructions` - Custom instructions for the AI
297    /// * `include_unstaged` - Whether to include unstaged changes in the review
298    ///
299    /// # Returns
300    ///
301    /// A Result containing the generated code review or an error
302    pub async fn generate_review_with_unstaged(
303        &self,
304        preset: &str,
305        instructions: &str,
306        include_unstaged: bool,
307    ) -> anyhow::Result<GeneratedReview> {
308        let mut config_clone = self.config.clone();
309
310        // Set the preset and instructions
311        if preset.is_empty() {
312            config_clone.instruction_preset = "default".to_string();
313        } else {
314            let library = get_instruction_preset_library();
315            if let Some(preset_info) = library.get_preset(preset) {
316                if preset_info.preset_type == PresetType::Commit {
317                    debug!(
318                        "Warning: Preset '{}' is commit-specific, not ideal for reviews",
319                        preset
320                    );
321                }
322                config_clone.instruction_preset = preset.to_string();
323            } else {
324                debug!("Preset '{}' not found, using default", preset);
325                config_clone.instruction_preset = "default".to_string();
326            }
327        }
328
329        config_clone.instructions = instructions.to_string();
330
331        // Get context including unstaged changes if requested
332        let context = self.get_git_info_with_unstaged(include_unstaged).await?;
333
334        // Create system prompt
335        let system_prompt = super::prompt::create_review_system_prompt(&config_clone)?;
336
337        // Use the shared optimization logic
338        let (_, final_user_prompt) = self.optimize_prompt(
339            &config_clone,
340            &system_prompt,
341            context,
342            super::prompt::create_review_user_prompt,
343        );
344
345        llm::get_message::<GeneratedReview>(
346            &config_clone,
347            &self.provider_name,
348            &system_prompt,
349            &final_user_prompt,
350        )
351        .await
352    }
353
354    /// Generate a review for a specific commit
355    ///
356    /// # Arguments
357    ///
358    /// * `preset` - The instruction preset to use
359    /// * `instructions` - Custom instructions for the AI
360    /// * `commit_id` - The ID of the commit to review
361    ///
362    /// # Returns
363    ///
364    /// A Result containing the generated code review or an error
365    pub async fn generate_review_for_commit(
366        &self,
367        preset: &str,
368        instructions: &str,
369        commit_id: &str,
370    ) -> anyhow::Result<GeneratedReview> {
371        let mut config_clone = self.config.clone();
372
373        // Set the preset and instructions
374        if preset.is_empty() {
375            config_clone.instruction_preset = "default".to_string();
376        } else {
377            let library = get_instruction_preset_library();
378            if let Some(preset_info) = library.get_preset(preset) {
379                if preset_info.preset_type == PresetType::Commit {
380                    debug!(
381                        "Warning: Preset '{}' is commit-specific, not ideal for reviews",
382                        preset
383                    );
384                }
385                config_clone.instruction_preset = preset.to_string();
386            } else {
387                debug!("Preset '{}' not found, using default", preset);
388                config_clone.instruction_preset = "default".to_string();
389            }
390        }
391
392        config_clone.instructions = instructions.to_string();
393
394        // Get context for the specific commit
395        let context = self.get_git_info_for_commit(commit_id).await?;
396
397        // Create system prompt
398        let system_prompt = super::prompt::create_review_system_prompt(&config_clone)?;
399
400        // Use the shared optimization logic
401        let (_, final_user_prompt) = self.optimize_prompt(
402            &config_clone,
403            &system_prompt,
404            context,
405            super::prompt::create_review_user_prompt,
406        );
407
408        llm::get_message::<GeneratedReview>(
409            &config_clone,
410            &self.provider_name,
411            &system_prompt,
412            &final_user_prompt,
413        )
414        .await
415    }
416
417    /// Generate a review for branch comparison
418    ///
419    /// # Arguments
420    ///
421    /// * `preset` - The instruction preset to use
422    /// * `instructions` - Custom instructions for the AI
423    /// * `base_branch` - The base branch (e.g., "main")
424    /// * `target_branch` - The target branch (e.g., "feature-branch")
425    ///
426    /// # Returns
427    ///
428    /// A Result containing the generated code review or an error
429    pub async fn generate_review_for_branch_diff(
430        &self,
431        preset: &str,
432        instructions: &str,
433        base_branch: &str,
434        target_branch: &str,
435    ) -> anyhow::Result<GeneratedReview> {
436        let mut config_clone = self.config.clone();
437
438        // Set the preset and instructions
439        if preset.is_empty() {
440            config_clone.instruction_preset = "default".to_string();
441        } else {
442            let library = get_instruction_preset_library();
443            if let Some(preset_info) = library.get_preset(preset) {
444                if preset_info.preset_type == PresetType::Commit {
445                    debug!(
446                        "Warning: Preset '{}' is commit-specific, not ideal for reviews",
447                        preset
448                    );
449                }
450                config_clone.instruction_preset = preset.to_string();
451            } else {
452                debug!("Preset '{}' not found, using default", preset);
453                config_clone.instruction_preset = "default".to_string();
454            }
455        }
456
457        config_clone.instructions = instructions.to_string();
458
459        // Get context for the branch comparison
460        let context = self
461            .repo
462            .get_git_info_for_branch_diff(&self.config, base_branch, target_branch)
463            .await?;
464
465        // Create system prompt
466        let system_prompt = super::prompt::create_review_system_prompt(&config_clone)?;
467
468        // Use the shared optimization logic
469        let (_, final_user_prompt) = self.optimize_prompt(
470            &config_clone,
471            &system_prompt,
472            context,
473            super::prompt::create_review_user_prompt,
474        );
475
476        llm::get_message::<GeneratedReview>(
477            &config_clone,
478            &self.provider_name,
479            &system_prompt,
480            &final_user_prompt,
481        )
482        .await
483    }
484
485    /// Generate a code review using AI
486    ///
487    /// # Arguments
488    ///
489    /// * `preset` - The instruction preset to use
490    /// * `instructions` - Custom instructions for the AI
491    ///
492    /// # Returns
493    ///
494    /// A Result containing the generated code review or an error
495    pub async fn generate_review(
496        &self,
497        preset: &str,
498        instructions: &str,
499    ) -> anyhow::Result<GeneratedReview> {
500        let mut config_clone = self.config.clone();
501
502        // Check if the preset exists and is valid for reviews
503        if preset.is_empty() {
504            config_clone.instruction_preset = "default".to_string();
505        } else {
506            let library = get_instruction_preset_library();
507            if let Some(preset_info) = library.get_preset(preset) {
508                if preset_info.preset_type == PresetType::Commit {
509                    debug!(
510                        "Warning: Preset '{}' is commit-specific, not ideal for reviews",
511                        preset
512                    );
513                }
514                config_clone.instruction_preset = preset.to_string();
515            } else {
516                debug!("Preset '{}' not found, using default", preset);
517                config_clone.instruction_preset = "default".to_string();
518            }
519        }
520
521        config_clone.instructions = instructions.to_string();
522
523        let context = self.get_git_info().await?;
524
525        // Create system prompt
526        let system_prompt = super::prompt::create_review_system_prompt(&config_clone)?;
527
528        // Use the shared optimization logic
529        let (_, final_user_prompt) = self.optimize_prompt(
530            &config_clone,
531            &system_prompt,
532            context,
533            super::prompt::create_review_user_prompt,
534        );
535
536        llm::get_message::<GeneratedReview>(
537            &config_clone,
538            &self.provider_name,
539            &system_prompt,
540            &final_user_prompt,
541        )
542        .await
543    }
544
545    /// Generate a PR description for a commit range
546    ///
547    /// # Arguments
548    ///
549    /// * `preset` - The instruction preset to use
550    /// * `instructions` - Custom instructions for the AI
551    /// * `from` - The starting Git reference (exclusive)
552    /// * `to` - The ending Git reference (inclusive)
553    ///
554    /// # Returns
555    ///
556    /// A Result containing the generated PR description or an error
557    pub async fn generate_pr_for_commit_range(
558        &self,
559        preset: &str,
560        instructions: &str,
561        from: &str,
562        to: &str,
563    ) -> anyhow::Result<super::types::GeneratedPullRequest> {
564        let mut config_clone = self.config.clone();
565
566        // Set the preset and instructions
567        if preset.is_empty() {
568            config_clone.instruction_preset = "default".to_string();
569        } else {
570            let library = get_instruction_preset_library();
571            if let Some(preset_info) = library.get_preset(preset) {
572                if preset_info.preset_type == PresetType::Commit {
573                    debug!(
574                        "Warning: Preset '{}' is commit-specific, may not be ideal for PRs",
575                        preset
576                    );
577                }
578                config_clone.instruction_preset = preset.to_string();
579            } else {
580                debug!("Preset '{}' not found, using default", preset);
581                config_clone.instruction_preset = "default".to_string();
582            }
583        }
584
585        config_clone.instructions = instructions.to_string();
586
587        // Get context for the commit range
588        let context = self
589            .repo
590            .get_git_info_for_commit_range(&self.config, from, to)
591            .await?;
592
593        // Get commit messages for the PR
594        let commit_messages = self.repo.get_commits_for_pr(from, to)?;
595
596        // Create system prompt
597        let system_prompt = super::prompt::create_pr_system_prompt(&config_clone)?;
598
599        // Use the shared optimization logic
600        let (_, final_user_prompt) =
601            self.optimize_prompt(&config_clone, &system_prompt, context, |ctx| {
602                super::prompt::create_pr_user_prompt(ctx, &commit_messages)
603            });
604
605        let mut generated_pr = llm::get_message::<super::types::GeneratedPullRequest>(
606            &config_clone,
607            &self.provider_name,
608            &system_prompt,
609            &final_user_prompt,
610        )
611        .await?;
612
613        // Apply emoji setting
614        if !self.use_emoji {
615            generated_pr.emoji = None;
616        }
617
618        Ok(generated_pr)
619    }
620
621    /// Generate a PR description for branch comparison
622    ///
623    /// # Arguments
624    ///
625    /// * `preset` - The instruction preset to use
626    /// * `instructions` - Custom instructions for the AI
627    /// * `base_branch` - The base branch (e.g., "main")
628    /// * `target_branch` - The target branch (e.g., "feature-branch")
629    ///
630    /// # Returns
631    ///
632    /// A Result containing the generated PR description or an error
633    pub async fn generate_pr_for_branch_diff(
634        &self,
635        preset: &str,
636        instructions: &str,
637        base_branch: &str,
638        target_branch: &str,
639    ) -> anyhow::Result<super::types::GeneratedPullRequest> {
640        let mut config_clone = self.config.clone();
641
642        // Set the preset and instructions
643        if preset.is_empty() {
644            config_clone.instruction_preset = "default".to_string();
645        } else {
646            let library = get_instruction_preset_library();
647            if let Some(preset_info) = library.get_preset(preset) {
648                if preset_info.preset_type == PresetType::Commit {
649                    debug!(
650                        "Warning: Preset '{}' is commit-specific, may not be ideal for PRs",
651                        preset
652                    );
653                }
654                config_clone.instruction_preset = preset.to_string();
655            } else {
656                debug!("Preset '{}' not found, using default", preset);
657                config_clone.instruction_preset = "default".to_string();
658            }
659        }
660
661        config_clone.instructions = instructions.to_string();
662
663        // Get context for the branch comparison
664        let context = self
665            .repo
666            .get_git_info_for_branch_diff(&self.config, base_branch, target_branch)
667            .await?;
668
669        // Get commit messages for the PR (commits in target_branch not in base_branch)
670        let commit_messages = self.repo.get_commits_for_pr(base_branch, target_branch)?;
671
672        // Create system prompt
673        let system_prompt = super::prompt::create_pr_system_prompt(&config_clone)?;
674
675        // Use the shared optimization logic
676        let (_, final_user_prompt) =
677            self.optimize_prompt(&config_clone, &system_prompt, context, |ctx| {
678                super::prompt::create_pr_user_prompt(ctx, &commit_messages)
679            });
680
681        let mut generated_pr = llm::get_message::<super::types::GeneratedPullRequest>(
682            &config_clone,
683            &self.provider_name,
684            &system_prompt,
685            &final_user_prompt,
686        )
687        .await?;
688
689        // Apply emoji setting
690        if !self.use_emoji {
691            generated_pr.emoji = None;
692        }
693
694        Ok(generated_pr)
695    }
696
697    /// Performs a commit with the given message.
698    ///
699    /// # Arguments
700    ///
701    /// * `message` - The commit message.
702    ///
703    /// # Returns
704    ///
705    /// A Result containing the `CommitResult` or an error.
706    pub fn perform_commit(&self, message: &str) -> Result<CommitResult> {
707        // Check if this is a remote repository
708        if self.is_remote_repository() {
709            return Err(anyhow::anyhow!("Cannot commit to a remote repository"));
710        }
711
712        debug!("Performing commit with message: {}", message);
713
714        if !self.verify {
715            debug!("Skipping pre-commit hook (verify=false)");
716            return self.repo.commit(message);
717        }
718
719        // Execute pre-commit hook
720        debug!("Executing pre-commit hook");
721        if let Err(e) = self.repo.execute_hook("pre-commit") {
722            debug!("Pre-commit hook failed: {}", e);
723            return Err(e);
724        }
725        debug!("Pre-commit hook executed successfully");
726
727        // Perform the commit
728        match self.repo.commit(message) {
729            Ok(result) => {
730                // Execute post-commit hook
731                debug!("Executing post-commit hook");
732                if let Err(e) = self.repo.execute_hook("post-commit") {
733                    debug!("Post-commit hook failed: {}", e);
734                    // We don't fail the commit if post-commit hook fails
735                }
736                debug!("Commit performed successfully");
737                Ok(result)
738            }
739            Err(e) => {
740                debug!("Commit failed: {}", e);
741                Err(e)
742            }
743        }
744    }
745
746    /// Execute the pre-commit hook if verification is enabled
747    pub fn pre_commit(&self) -> Result<()> {
748        // Skip pre-commit hook for remote repositories
749        if self.is_remote_repository() {
750            debug!("Skipping pre-commit hook for remote repository");
751            return Ok(());
752        }
753
754        if self.verify {
755            self.repo.execute_hook("pre-commit")
756        } else {
757            Ok(())
758        }
759    }
760
761    /// Create a channel for message generation
762    pub fn create_message_channel(
763        &self,
764    ) -> (
765        mpsc::Sender<Result<GeneratedMessage>>,
766        mpsc::Receiver<Result<GeneratedMessage>>,
767    ) {
768        mpsc::channel(1)
769    }
770}