1use crate::commands;
2use crate::common::CommonParams;
3use crate::log_debug;
4use crate::providers::Provider;
5use crate::theme;
6use crate::theme::names::tokens;
7use crate::ui;
8use clap::builder::{Styles, styling::AnsiColor};
9use clap::{CommandFactory, Parser, Subcommand, ValueEnum, crate_version};
10use clap_complete::{Shell, generate};
11use colored::Colorize;
12use std::io;
13
14pub const LOG_FILE: &str = "git-iris-debug.log";
16
17#[derive(Parser)]
19#[command(
20 author,
21 version = crate_version!(),
22 about = "Git-Iris: AI-powered Git workflow assistant",
23 long_about = "Git-Iris enhances your Git workflow with AI-assisted commit messages, code reviews, changelogs, and more.",
24 disable_version_flag = true,
25 after_help = get_dynamic_help(),
26 styles = get_styles(),
27)]
28#[allow(clippy::struct_excessive_bools)]
29pub struct Cli {
30 #[command(subcommand)]
32 pub command: Option<Commands>,
33
34 #[arg(
36 short = 'l',
37 long = "log",
38 global = true,
39 help = "Log debug messages to a file"
40 )]
41 pub log: bool,
42
43 #[arg(
45 long = "log-file",
46 global = true,
47 help = "Specify a custom log file path"
48 )]
49 pub log_file: Option<String>,
50
51 #[arg(
53 short = 'q',
54 long = "quiet",
55 global = true,
56 help = "Suppress non-essential output"
57 )]
58 pub quiet: bool,
59
60 #[arg(
62 short = 'v',
63 long = "version",
64 global = true,
65 help = "Display the version"
66 )]
67 pub version: bool,
68
69 #[arg(
71 short = 'r',
72 long = "repo",
73 global = true,
74 help = "Repository URL to use instead of local repository"
75 )]
76 pub repository_url: Option<String>,
77
78 #[arg(
80 long = "debug",
81 global = true,
82 help = "Enable debug mode with gorgeous color-coded output showing agent execution details"
83 )]
84 pub debug: bool,
85
86 #[arg(
88 long = "theme",
89 global = true,
90 help = "Override theme for this session (use 'git-iris themes' to list available)"
91 )]
92 pub theme: Option<String>,
93}
94
95#[derive(Subcommand)]
97#[command(subcommand_negates_reqs = true)]
98#[command(subcommand_precedence_over_arg = true)]
99pub enum Commands {
100 #[command(
103 about = "Generate a commit message using AI",
104 long_about = "Generate a commit message using AI based on the current Git context.",
105 after_help = get_dynamic_help(),
106 visible_alias = "commit"
107 )]
108 Gen {
109 #[command(flatten)]
110 common: CommonParams,
111
112 #[arg(short, long, help = "Automatically commit with the generated message")]
114 auto_commit: bool,
115
116 #[arg(short, long, help = "Print the generated message to stdout and exit")]
118 print: bool,
119
120 #[arg(long, help = "Skip verification steps (pre/post commit hooks)")]
122 no_verify: bool,
123
124 #[arg(long, help = "Amend the previous commit with staged changes")]
126 amend: bool,
127 },
128
129 #[command(
131 about = "Review staged changes using AI",
132 long_about = "Generate a comprehensive multi-dimensional code review of staged changes using AI. Analyzes code across 10 dimensions including complexity, security, performance, and more."
133 )]
134 Review {
135 #[command(flatten)]
136 common: CommonParams,
137
138 #[arg(short, long, help = "Print the generated review to stdout and exit")]
140 print: bool,
141
142 #[arg(long, help = "Output raw markdown without any console formatting")]
144 raw: bool,
145
146 #[arg(long, help = "Include unstaged changes in the review")]
148 include_unstaged: bool,
149
150 #[arg(
152 long,
153 help = "Review a specific commit by ID (hash, branch, or reference)"
154 )]
155 commit: Option<String>,
156
157 #[arg(
159 long,
160 help = "Starting branch for comparison (defaults to the repository's primary branch). Used with --to for explicit branch comparison reviews"
161 )]
162 from: Option<String>,
163
164 #[arg(
166 long,
167 help = "Target branch for comparison (e.g., 'feature-branch', 'pr-branch'). Used with --from for branch comparison reviews or on its own to compare from the repository's primary branch"
168 )]
169 to: Option<String>,
170
171 #[arg(
173 long,
174 help = "Publish the generated review as a GitHub PR review comment"
175 )]
176 github_review: bool,
177
178 #[arg(long = "pr", help = "GitHub pull request number to publish to")]
180 pull_number: Option<u64>,
181
182 #[arg(long, help = "Add validated inline comments for review findings")]
184 github_inline_comments: bool,
185
186 #[arg(
188 long = "github-review-event",
189 value_enum,
190 default_value_t = GitHubReviewEvent::Comment,
191 help = "GitHub review event to submit"
192 )]
193 github_review_event: GitHubReviewEvent,
194 },
195
196 #[command(
198 about = "Generate a pull request description using AI",
199 long_about = "Generate a comprehensive pull request description based on commit ranges, branch differences, or single commits. Analyzes the overall changeset as an atomic unit and creates professional PR descriptions with summaries, detailed explanations, and testing notes.\n\nUsage examples:\n• Single commit: --from abc1234 or --to abc1234\n• Single commitish: --from HEAD~1 or --to HEAD~2\n• Multiple commits: --from HEAD~3 (reviews last 3 commits)\n• Commit range: --from abc1234 --to def5678\n• Branch comparison: --from <default-branch> --to feature-branch\n• From repository primary branch to target branch: --to feature-branch\n\nSupported commitish syntax: HEAD~2, HEAD^, @~3, <default-branch>~1, origin/<default-branch>^, etc."
200 )]
201 Pr {
202 #[command(flatten)]
203 common: CommonParams,
204
205 #[arg(
207 short,
208 long,
209 help = "Print the generated PR description to stdout and exit"
210 )]
211 print: bool,
212
213 #[arg(long, help = "Output raw markdown without any console formatting")]
215 raw: bool,
216
217 #[arg(
219 short,
220 long,
221 help = "Copy raw markdown to clipboard (for pasting into GitHub/GitLab)"
222 )]
223 copy: bool,
224
225 #[arg(
227 long,
228 help = "Starting branch, commit, or commitish for comparison. For single commit analysis, specify just this parameter with a commit hash (e.g., --from abc1234). For reviewing multiple commits, use commitish syntax (e.g., --from HEAD~3 to review last 3 commits)"
229 )]
230 from: Option<String>,
231
232 #[arg(
234 long,
235 help = "Target branch, commit, or commitish for comparison. For single commit analysis, specify just this parameter with a commit hash or commitish (e.g., --to HEAD~2)"
236 )]
237 to: Option<String>,
238
239 #[arg(
241 long = "update",
242 visible_alias = "github-update",
243 help = "Update the GitHub PR body with the generated description"
244 )]
245 github_update: bool,
246
247 #[arg(long = "pr", help = "GitHub pull request number to update")]
249 pull_number: Option<u64>,
250 },
251
252 #[command(
254 about = "Generate a changelog",
255 long_about = "Generate a changelog between two specified Git references."
256 )]
257 Changelog {
258 #[command(flatten)]
259 common: CommonParams,
260
261 #[arg(long, required = true)]
263 from: String,
264
265 #[arg(long)]
267 to: Option<String>,
268
269 #[arg(long, help = "Output raw markdown without any console formatting")]
271 raw: bool,
272
273 #[arg(long, help = "Update the changelog file with the new changes")]
275 update: bool,
276
277 #[arg(long, help = "Path to the changelog file (defaults to CHANGELOG.md)")]
279 file: Option<String>,
280
281 #[arg(long, help = "Explicit version name to use in the changelog")]
283 version_name: Option<String>,
284 },
285
286 #[command(
288 about = "Generate release notes",
289 long_about = "Generate comprehensive release notes between two specified Git references."
290 )]
291 ReleaseNotes {
292 #[command(flatten)]
293 common: CommonParams,
294
295 #[arg(long, required = true)]
297 from: String,
298
299 #[arg(long)]
301 to: Option<String>,
302
303 #[arg(long, help = "Output raw markdown without any console formatting")]
305 raw: bool,
306
307 #[arg(long, help = "Update the release notes file with the new content")]
309 update: bool,
310
311 #[arg(
313 long,
314 help = "Path to the release notes file (defaults to RELEASE_NOTES.md)"
315 )]
316 file: Option<String>,
317
318 #[arg(long, help = "Explicit version name to use in the release notes")]
320 version_name: Option<String>,
321 },
322
323 #[command(
325 about = "Launch Iris Studio TUI",
326 long_about = "Launch Iris Studio, a unified terminal user interface for exploring code, generating commits, reviewing changes, and more. The interface adapts to your repository state."
327 )]
328 Studio {
329 #[command(flatten)]
330 common: CommonParams,
331
332 #[arg(
334 long,
335 value_name = "MODE",
336 help = "Initial mode: explore, commit, review, pr, changelog, release-notes"
337 )]
338 mode: Option<String>,
339
340 #[arg(long, value_name = "REF", help = "Starting ref for comparison")]
342 from: Option<String>,
343
344 #[arg(long, value_name = "REF", help = "Ending ref for comparison")]
346 to: Option<String>,
347 },
348
349 #[command(about = "Configure Git-Iris settings and providers")]
352 Config {
353 #[command(flatten)]
354 common: CommonParams,
355
356 #[arg(long, help = "Set API key for the specified provider")]
358 api_key: Option<String>,
359
360 #[arg(
362 long,
363 help = "Set fast model for the specified provider (used for status updates and simple tasks)"
364 )]
365 fast_model: Option<String>,
366
367 #[arg(long, help = "Set token limit for the specified provider")]
369 token_limit: Option<usize>,
370
371 #[arg(
373 long,
374 help = "Set additional parameters for the specified provider (key=value)"
375 )]
376 param: Option<Vec<String>>,
377
378 #[arg(
380 long,
381 help = "Set timeout in seconds for parallel subagent tasks (default: 120)"
382 )]
383 subagent_timeout: Option<u64>,
384
385 #[arg(long, help = "Set max turns for parallel subagent tasks (default: 20)")]
387 subagent_max_turns: Option<usize>,
388 },
389
390 #[command(
392 about = "Manage project-specific configuration",
393 long_about = "Create or update a project-specific .irisconfig file in the repository root."
394 )]
395 ProjectConfig {
396 #[command(flatten)]
397 common: CommonParams,
398
399 #[arg(
401 long,
402 help = "Set fast model for the specified provider (used for status updates and simple tasks)"
403 )]
404 fast_model: Option<String>,
405
406 #[arg(long, help = "Set token limit for the specified provider")]
408 token_limit: Option<usize>,
409
410 #[arg(
412 long,
413 help = "Set additional parameters for the specified provider (key=value)"
414 )]
415 param: Option<Vec<String>>,
416
417 #[arg(
419 long,
420 help = "Set timeout in seconds for parallel subagent tasks (default: 120)"
421 )]
422 subagent_timeout: Option<u64>,
423
424 #[arg(long, help = "Set max turns for parallel subagent tasks (default: 20)")]
426 subagent_max_turns: Option<usize>,
427
428 #[arg(short, long, help = "Print the current project configuration")]
430 print: bool,
431 },
432
433 #[command(about = "List available instruction presets")]
435 ListPresets,
436
437 #[command(about = "List available themes")]
439 Themes,
440
441 #[command(
443 about = "Generate shell completions",
444 long_about = "Generate shell completion scripts for bash, zsh, fish, elvish, or powershell.\n\nUsage examples:\n• Bash: git-iris completions bash >> ~/.bashrc\n• Zsh: git-iris completions zsh >> ~/.zshrc\n• Fish: git-iris completions fish > ~/.config/fish/completions/git-iris.fish"
445 )]
446 Completions {
447 #[arg(value_enum)]
449 shell: Shell,
450 },
451
452 #[command(
454 about = "Install or uninstall the prepare-commit-msg Git hook",
455 long_about = "Install or uninstall a prepare-commit-msg Git hook that automatically generates commit messages using git-iris when you run 'git commit'."
456 )]
457 Hook {
458 #[command(subcommand)]
460 action: HookAction,
461 },
462}
463
464#[derive(Subcommand)]
466pub enum HookAction {
467 #[command(about = "Install the prepare-commit-msg hook")]
469 Install {
470 #[arg(long, help = "Overwrite an existing hook not installed by git-iris")]
472 force: bool,
473 },
474 #[command(about = "Uninstall the prepare-commit-msg hook")]
476 Uninstall,
477}
478
479fn get_styles() -> Styles {
481 Styles::styled()
482 .header(AnsiColor::Magenta.on_default().bold())
483 .usage(AnsiColor::Cyan.on_default().bold())
484 .literal(AnsiColor::Green.on_default().bold())
485 .placeholder(AnsiColor::Yellow.on_default())
486 .valid(AnsiColor::Blue.on_default().bold())
487 .invalid(AnsiColor::Red.on_default().bold())
488 .error(AnsiColor::Red.on_default().bold())
489}
490
491#[must_use]
493pub fn parse_args() -> Cli {
494 Cli::parse()
495}
496
497fn get_dynamic_help() -> String {
499 let providers_list = Provider::all_names()
500 .iter()
501 .map(|p| format!("{}", p.bold()))
502 .collect::<Vec<_>>()
503 .join(" • ");
504
505 format!("\nAvailable LLM Providers: {providers_list}")
506}
507
508pub async fn main() -> anyhow::Result<()> {
514 let cli = parse_args();
515
516 if cli.version {
517 ui::print_version(crate_version!());
518 return Ok(());
519 }
520
521 if let Err(e) = crate::logger::init(cli.log) {
523 eprintln!("Warning: Failed to initialize logging: {e}");
524 }
525
526 if cli.log {
527 crate::logger::enable_logging();
528 crate::logger::set_log_to_stdout(true);
529 let log_file = cli.log_file.as_deref().unwrap_or(LOG_FILE);
530 crate::logger::set_log_file(log_file)?;
531 log_debug!("Debug logging enabled");
532 } else {
533 crate::logger::disable_logging();
534 }
535
536 if cli.quiet {
538 crate::ui::set_quiet_mode(true);
539 }
540
541 initialize_theme(cli.theme.as_deref());
543
544 if cli.debug {
546 crate::agents::debug::enable_debug_mode();
547 crate::agents::debug::debug_header("🔮 IRIS DEBUG MODE ACTIVATED 🔮");
548 }
549
550 if let Some(command) = cli.command {
551 handle_command(command, cli.repository_url).await
552 } else {
553 handle_studio(
555 CommonParams::default(),
556 None,
557 None,
558 None,
559 cli.repository_url,
560 )
561 .await
562 }
563}
564
565fn initialize_theme(cli_theme: Option<&str>) {
567 use crate::config::Config;
568
569 let theme_name = if let Some(name) = cli_theme {
571 Some(name.to_string())
572 } else {
573 Config::load().ok().and_then(|c| {
575 if c.theme.is_empty() {
576 None
577 } else {
578 Some(c.theme)
579 }
580 })
581 };
582
583 if let Some(name) = theme_name {
585 if let Err(e) = theme::load_theme_by_name(&name) {
586 ui::print_warning(&format!(
587 "Failed to load theme '{}': {}. Using default.",
588 name, e
589 ));
590 } else {
591 log_debug!("Loaded theme: {}", name);
592 }
593 }
594}
595
596#[allow(clippy::struct_excessive_bools)]
598struct GenConfig {
599 auto_commit: bool,
600 use_gitmoji: bool,
601 print_only: bool,
602 verify: bool,
603 amend: bool,
604}
605
606#[allow(clippy::too_many_lines)]
608async fn handle_gen_with_agent(
609 common: CommonParams,
610 config: GenConfig,
611 repository_url: Option<String>,
612) -> anyhow::Result<()> {
613 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
614 use crate::config::Config;
615 use crate::git::GitRepo;
616 use crate::instruction_presets::PresetType;
617 use crate::output::format_commit_result;
618 use crate::services::GitCommitService;
619 use crate::studio::{Mode, run_studio};
620 use crate::types::format_commit_message;
621 use anyhow::Context;
622 use std::sync::Arc;
623
624 if !common.is_valid_preset_for_type(PresetType::Commit) {
626 ui::print_warning(
627 "The specified preset may not be suitable for commit messages. Consider using a commit or general preset instead.",
628 );
629 ui::print_info("Run 'git-iris list-presets' to see available presets for commits.");
630 }
631
632 if config.amend && !config.print_only && !config.auto_commit {
634 ui::print_warning("--amend requires --print or --auto-commit for now.");
635 ui::print_info("Example: git-iris gen --amend --auto-commit");
636 return Ok(());
637 }
638
639 let mut cfg = Config::load()?;
640 common.apply_to_config(&mut cfg)?;
641
642 let repo_url = repository_url.clone().or(common.repository_url.clone());
644 let git_repo = Arc::new(GitRepo::new_from_url(repo_url).context("Failed to create GitRepo")?);
645 let use_gitmoji = config.use_gitmoji && cfg.use_gitmoji;
646
647 let commit_service = Arc::new(GitCommitService::new(
649 git_repo.clone(),
650 use_gitmoji,
651 config.verify,
652 ));
653
654 let agent_service = Arc::new(IrisAgentService::from_common_params(
656 &common,
657 repository_url.clone(),
658 )?);
659
660 let git_info = git_repo.get_git_info(&cfg)?;
662
663 if config.print_only || config.auto_commit {
665 if git_info.staged_files.is_empty() && !config.amend {
668 ui::print_warning(
669 "No staged changes. Please stage your changes before generating a commit message.",
670 );
671 ui::print_info("You can stage changes using 'git add <file>' or 'git add .'");
672 return Ok(());
673 }
674
675 if let Err(e) = commit_service.pre_commit() {
677 ui::print_error(&format!("Pre-commit failed: {e}"));
678 return Err(e);
679 }
680
681 let spinner_msg = if config.amend {
683 "Generating amended commit message..."
684 } else {
685 "Generating commit message..."
686 };
687 let spinner = ui::create_spinner(spinner_msg);
688
689 let context = if config.amend {
692 let original_message = commit_service.get_head_commit_message().unwrap_or_default();
693 TaskContext::for_amend(original_message)
694 } else {
695 TaskContext::for_gen()
696 };
697 let response = agent_service.execute_task("commit", context).await?;
698
699 let StructuredResponse::CommitMessage(generated_message) = response else {
701 return Err(anyhow::anyhow!("Expected commit message response"));
702 };
703
704 spinner.finish_and_clear();
706
707 if config.print_only {
708 println!("{}", format_commit_message(&generated_message));
709 return Ok(());
710 }
711
712 if commit_service.is_remote() {
714 ui::print_error(
715 "Cannot automatically commit to a remote repository. Use --print instead.",
716 );
717 return Err(anyhow::anyhow!(
718 "Auto-commit not supported for remote repositories"
719 ));
720 }
721
722 let commit_result = if config.amend {
723 commit_service.perform_amend(&format_commit_message(&generated_message))
724 } else {
725 commit_service.perform_commit(&format_commit_message(&generated_message))
726 };
727
728 match commit_result {
729 Ok(result) => {
730 let output =
731 format_commit_result(&result, &format_commit_message(&generated_message));
732 println!("{output}");
733 }
734 Err(e) => {
735 let action = if config.amend { "amend" } else { "commit" };
736 eprintln!("Failed to {action}: {e}");
737 return Err(e);
738 }
739 }
740 return Ok(());
741 }
742
743 if commit_service.is_remote() {
745 ui::print_warning(
746 "Interactive commit not available for remote repositories. Use --print instead.",
747 );
748 return Ok(());
749 }
750
751 run_studio(
753 cfg,
754 Some(git_repo),
755 Some(commit_service),
756 Some(agent_service),
757 Some(Mode::Commit),
758 None,
759 None,
760 )
761}
762
763async fn handle_gen(
765 common: CommonParams,
766 config: GenConfig,
767 repository_url: Option<String>,
768) -> anyhow::Result<()> {
769 log_debug!(
770 "Handling 'gen' command with common: {:?}, auto_commit: {}, use_gitmoji: {}, print: {}, verify: {}, amend: {}",
771 common,
772 config.auto_commit,
773 config.use_gitmoji,
774 config.print_only,
775 config.verify,
776 config.amend
777 );
778
779 ui::print_version(crate_version!());
780 ui::print_newline();
781
782 handle_gen_with_agent(common, config, repository_url).await
783}
784
785fn handle_config(
787 common: &CommonParams,
788 api_key: Option<String>,
789 model: Option<String>,
790 fast_model: Option<String>,
791 token_limit: Option<usize>,
792 param: Option<Vec<String>>,
793 subagent_timeout: Option<u64>,
794 subagent_max_turns: Option<usize>,
795) -> anyhow::Result<()> {
796 log_debug!(
797 "Handling 'config' command with common: {:?}, api_key: {}, model: {:?}, token_limit: {:?}, param: {:?}, subagent_timeout: {:?}, subagent_max_turns: {:?}",
798 common,
799 if api_key.is_some() {
800 "[REDACTED]"
801 } else {
802 "<none>"
803 },
804 model,
805 token_limit,
806 param,
807 subagent_timeout,
808 subagent_max_turns
809 );
810 commands::handle_config_command(
811 common,
812 api_key,
813 model,
814 fast_model,
815 token_limit,
816 param,
817 subagent_timeout,
818 subagent_max_turns,
819 )
820}
821
822struct ReviewOutputOptions {
824 mode: OutputMode,
825 github_review: bool,
826 github_inline_comments: bool,
827 github_review_event: GitHubReviewEvent,
828}
829
830#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
831pub enum GitHubReviewEvent {
832 Comment,
833 RequestChanges,
834 Approve,
835}
836
837impl From<GitHubReviewEvent> for octocrab::models::pulls::ReviewAction {
838 fn from(event: GitHubReviewEvent) -> Self {
839 match event {
840 GitHubReviewEvent::Comment => Self::Comment,
841 GitHubReviewEvent::RequestChanges => Self::RequestChanges,
842 GitHubReviewEvent::Approve => Self::Approve,
843 }
844 }
845}
846
847#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848enum OutputMode {
849 Default,
850 Print,
851 Raw,
852}
853
854impl OutputMode {
855 const fn from_flags(print: bool, raw: bool) -> Self {
856 if raw {
857 Self::Raw
858 } else if print {
859 Self::Print
860 } else {
861 Self::Default
862 }
863 }
864}
865
866async fn handle_review(
867 common: CommonParams,
868 output: ReviewOutputOptions,
869 repository_url: Option<String>,
870 include_unstaged: bool,
871 commit: Option<String>,
872 from: Option<String>,
873 to: Option<String>,
874 pull_number: Option<u64>,
875) -> anyhow::Result<()> {
876 log_debug!(
877 "Handling 'review' command with common: {:?}, print: {}, raw: {}, include_unstaged: {}, commit: {:?}, from: {:?}, to: {:?}",
878 common,
879 output.mode == OutputMode::Print,
880 output.mode == OutputMode::Raw,
881 include_unstaged,
882 commit,
883 from,
884 to
885 );
886
887 if output.mode != OutputMode::Raw {
889 ui::print_version(crate_version!());
890 ui::print_newline();
891 }
892
893 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
894 use crate::github::{GitHubClient, ReviewPublishOptions};
895
896 let spinner = if output.mode == OutputMode::Raw {
898 None
899 } else {
900 Some(ui::create_spinner("Initializing Iris..."))
901 };
902
903 let service = IrisAgentService::from_common_params(&common, repository_url)?;
905 let default_base = service
906 .git_repo()
907 .and_then(|repo| repo.get_default_base_ref().ok())
908 .unwrap_or_else(|| "main".to_string());
909 let context =
910 TaskContext::for_review_with_base(commit, from, to, include_unstaged, &default_base)?;
911 let response = service.execute_task("review", context).await?;
912
913 if let Some(s) = spinner {
915 s.finish_and_clear();
916 }
917
918 let review_content = match &response {
919 StructuredResponse::Review(review) => review.raw_content(),
920 _ => response.to_string(),
921 };
922
923 if output.github_review {
924 let git_repo = service
925 .git_repo()
926 .ok_or_else(|| anyhow::anyhow!("GitHub publishing requires a git repository"))?;
927 let github = GitHubClient::from_git_repo(git_repo)?;
928 let number = github.resolve_pull_number(pull_number, git_repo).await?;
929 let publish_options = ReviewPublishOptions {
930 event: output.github_review_event.into(),
931 inline_comments: output.github_inline_comments,
932 };
933 match &response {
934 StructuredResponse::Review(review) => {
935 github
936 .publish_structured_review(number, review, publish_options)
937 .await?;
938 }
939 _ => {
940 github
941 .publish_review(number, &review_content, publish_options)
942 .await?;
943 }
944 }
945 if output.mode != OutputMode::Raw {
946 ui::print_success(&format!(
947 "Published review to {}/{} PR #{}",
948 github.repo().owner,
949 github.repo().name,
950 number
951 ));
952 }
953 }
954
955 if output.mode == OutputMode::Raw {
956 println!("{review_content}");
957 } else if output.mode == OutputMode::Print {
958 println!("{response}");
959 } else {
960 ui::print_success("Code review completed successfully");
961 println!("{response}");
962 }
963 Ok(())
964}
965
966#[allow(clippy::too_many_arguments)]
968async fn handle_changelog(
969 common: CommonParams,
970 from: String,
971 to: Option<String>,
972 raw: bool,
973 repository_url: Option<String>,
974 update: bool,
975 file: Option<String>,
976 version_name: Option<String>,
977) -> anyhow::Result<()> {
978 log_debug!(
979 "Handling 'changelog' command with common: {:?}, from: {}, to: {:?}, raw: {}, update: {}, file: {:?}, version_name: {:?}",
980 common,
981 from,
982 to,
983 raw,
984 update,
985 file,
986 version_name
987 );
988
989 if !raw {
991 ui::print_version(crate_version!());
992 ui::print_newline();
993 }
994
995 use crate::agents::{IrisAgentService, TaskContext};
996 use crate::changelog::ChangelogGenerator;
997 use crate::git::GitRepo;
998 use anyhow::Context;
999 use std::sync::Arc;
1000
1001 let context = TaskContext::for_changelog(from.clone(), to.clone(), version_name.clone(), None);
1003 let to_ref = to.unwrap_or_else(|| "HEAD".to_string());
1004
1005 let spinner = if raw {
1007 None
1008 } else {
1009 Some(ui::create_spinner("Initializing Iris..."))
1010 };
1011
1012 let service = IrisAgentService::from_common_params(&common, repository_url.clone())?;
1014 let response = service.execute_task("changelog", context).await?;
1015
1016 if let Some(s) = spinner {
1018 s.finish_and_clear();
1019 }
1020
1021 println!("{response}");
1023
1024 if update {
1025 let formatted_content = response.to_string();
1027 let changelog_path = file.unwrap_or_else(|| "CHANGELOG.md".to_string());
1028 let repo_url_for_update = repository_url.or(common.repository_url.clone());
1029
1030 let git_repo = if let Some(url) = repo_url_for_update {
1032 Arc::new(
1033 GitRepo::clone_remote_repository(&url)
1034 .context("Failed to clone repository for changelog update")?,
1035 )
1036 } else {
1037 let repo_path = std::env::current_dir()?;
1038 Arc::new(
1039 GitRepo::new(&repo_path)
1040 .context("Failed to create GitRepo for changelog update")?,
1041 )
1042 };
1043
1044 let update_spinner =
1046 ui::create_spinner(&format!("Updating changelog file at {changelog_path}..."));
1047
1048 match ChangelogGenerator::update_changelog_file(
1049 &formatted_content,
1050 &changelog_path,
1051 &git_repo,
1052 &to_ref,
1053 version_name,
1054 ) {
1055 Ok(()) => {
1056 update_spinner.finish_and_clear();
1057 ui::print_success(&format!(
1058 "✨ Changelog successfully updated at {}",
1059 changelog_path.bright_green()
1060 ));
1061 }
1062 Err(e) => {
1063 update_spinner.finish_and_clear();
1064 ui::print_error(&format!("Failed to update changelog file: {e}"));
1065 return Err(e);
1066 }
1067 }
1068 }
1069 Ok(())
1070}
1071
1072#[allow(clippy::too_many_arguments)]
1074async fn handle_release_notes(
1075 common: CommonParams,
1076 from: String,
1077 to: Option<String>,
1078 raw: bool,
1079 repository_url: Option<String>,
1080 update: bool,
1081 file: Option<String>,
1082 version_name: Option<String>,
1083) -> anyhow::Result<()> {
1084 log_debug!(
1085 "Handling 'release-notes' command with common: {:?}, from: {}, to: {:?}, raw: {}, update: {}, file: {:?}, version_name: {:?}",
1086 common,
1087 from,
1088 to,
1089 raw,
1090 update,
1091 file,
1092 version_name
1093 );
1094
1095 if !raw {
1097 ui::print_version(crate_version!());
1098 ui::print_newline();
1099 }
1100
1101 use crate::agents::{IrisAgentService, TaskContext};
1102 use std::fs;
1103 use std::path::Path;
1104
1105 let context = TaskContext::for_changelog(from, to, version_name, None);
1107
1108 let spinner = if raw {
1110 None
1111 } else {
1112 Some(ui::create_spinner("Initializing Iris..."))
1113 };
1114
1115 let service = IrisAgentService::from_common_params(&common, repository_url)?;
1117 let response = service.execute_task("release_notes", context).await?;
1118
1119 if let Some(s) = spinner {
1121 s.finish_and_clear();
1122 }
1123
1124 println!("{response}");
1125
1126 if update {
1128 let release_notes_path = file.unwrap_or_else(|| "RELEASE_NOTES.md".to_string());
1129 let formatted_content = response.to_string();
1130
1131 let update_spinner = ui::create_spinner(&format!(
1132 "Updating release notes file at {release_notes_path}..."
1133 ));
1134
1135 let path = Path::new(&release_notes_path);
1137 let result = if path.exists() {
1138 let existing = fs::read_to_string(path)?;
1140 fs::write(path, format!("{formatted_content}\n\n---\n\n{existing}"))
1141 } else {
1142 fs::write(path, &formatted_content)
1144 };
1145
1146 match result {
1147 Ok(()) => {
1148 update_spinner.finish_and_clear();
1149 ui::print_success(&format!(
1150 "✨ Release notes successfully updated at {}",
1151 release_notes_path.bright_green()
1152 ));
1153 }
1154 Err(e) => {
1155 update_spinner.finish_and_clear();
1156 ui::print_error(&format!("Failed to update release notes file: {e}"));
1157 return Err(e.into());
1158 }
1159 }
1160 }
1161
1162 Ok(())
1163}
1164
1165#[allow(clippy::too_many_lines)]
1167pub async fn handle_command(
1172 command: Commands,
1173 repository_url: Option<String>,
1174) -> anyhow::Result<()> {
1175 match command {
1176 Commands::Gen {
1177 common,
1178 auto_commit,
1179 print,
1180 no_verify,
1181 amend,
1182 } => {
1183 let use_gitmoji = common.resolved_gitmoji().unwrap_or(true);
1186 handle_gen(
1187 common,
1188 GenConfig {
1189 auto_commit,
1190 use_gitmoji,
1191 print_only: print,
1192 verify: !no_verify,
1193 amend,
1194 },
1195 repository_url,
1196 )
1197 .await
1198 }
1199 Commands::Config {
1200 common,
1201 api_key,
1202 fast_model,
1203 token_limit,
1204 param,
1205 subagent_timeout,
1206 subagent_max_turns,
1207 } => handle_config(
1208 &common,
1209 api_key,
1210 common.model.clone(),
1211 fast_model,
1212 token_limit,
1213 param,
1214 subagent_timeout,
1215 subagent_max_turns,
1216 ),
1217 Commands::Review {
1218 common,
1219 print,
1220 raw,
1221 include_unstaged,
1222 commit,
1223 from,
1224 to,
1225 github_review,
1226 github_inline_comments,
1227 github_review_event,
1228 pull_number,
1229 } => {
1230 handle_review(
1231 common,
1232 ReviewOutputOptions {
1233 mode: OutputMode::from_flags(print, raw),
1234 github_review,
1235 github_inline_comments,
1236 github_review_event,
1237 },
1238 repository_url,
1239 include_unstaged,
1240 commit,
1241 from,
1242 to,
1243 pull_number,
1244 )
1245 .await
1246 }
1247 Commands::Changelog {
1248 common,
1249 from,
1250 to,
1251 raw,
1252 update,
1253 file,
1254 version_name,
1255 } => {
1256 handle_changelog(
1257 common,
1258 from,
1259 to,
1260 raw,
1261 repository_url,
1262 update,
1263 file,
1264 version_name,
1265 )
1266 .await
1267 }
1268 Commands::ReleaseNotes {
1269 common,
1270 from,
1271 to,
1272 raw,
1273 update,
1274 file,
1275 version_name,
1276 } => {
1277 handle_release_notes(
1278 common,
1279 from,
1280 to,
1281 raw,
1282 repository_url,
1283 update,
1284 file,
1285 version_name,
1286 )
1287 .await
1288 }
1289 Commands::ProjectConfig {
1290 common,
1291 fast_model,
1292 token_limit,
1293 param,
1294 subagent_timeout,
1295 subagent_max_turns,
1296 print,
1297 } => commands::handle_project_config_command(
1298 &common,
1299 common.model.clone(),
1300 fast_model,
1301 token_limit,
1302 param,
1303 subagent_timeout,
1304 subagent_max_turns,
1305 print,
1306 ),
1307 Commands::ListPresets => commands::handle_list_presets_command(),
1308 Commands::Themes => {
1309 handle_themes();
1310 Ok(())
1311 }
1312 Commands::Completions { shell } => {
1313 handle_completions(shell);
1314 Ok(())
1315 }
1316 Commands::Hook { action } => commands::handle_hook_command(&action),
1317 Commands::Pr {
1318 common,
1319 print,
1320 raw,
1321 copy,
1322 from,
1323 to,
1324 github_update,
1325 pull_number,
1326 } => {
1327 handle_pr(
1328 common,
1329 PrOutputOptions {
1330 mode: OutputMode::from_flags(print, raw),
1331 copy,
1332 github_update,
1333 },
1334 from,
1335 to,
1336 pull_number,
1337 repository_url,
1338 )
1339 .await
1340 }
1341 Commands::Studio {
1342 common,
1343 mode,
1344 from,
1345 to,
1346 } => handle_studio(common, mode, from, to, repository_url).await,
1347 }
1348}
1349
1350fn handle_themes() {
1352 ui::print_version(crate_version!());
1353 ui::print_newline();
1354
1355 let available = theme::list_available_themes();
1356 let current = theme::current();
1357 let current_name = ¤t.meta.name;
1358
1359 let header_color = theme::current().color(tokens::ACCENT_PRIMARY);
1361 println!(
1362 "{}",
1363 "Available Themes:"
1364 .truecolor(header_color.r, header_color.g, header_color.b)
1365 .bold()
1366 );
1367 println!();
1368
1369 for info in available {
1370 let is_current = info.display_name == *current_name;
1371 let marker = if is_current { "● " } else { " " };
1372
1373 let name_color = if is_current {
1374 theme::current().color(tokens::SUCCESS)
1375 } else {
1376 theme::current().color(tokens::ACCENT_SECONDARY)
1377 };
1378
1379 let desc_color = theme::current().color(tokens::TEXT_SECONDARY);
1380
1381 print!(
1382 "{}{}",
1383 marker.truecolor(name_color.r, name_color.g, name_color.b),
1384 info.name
1385 .truecolor(name_color.r, name_color.g, name_color.b)
1386 .bold()
1387 );
1388
1389 if info.display_name != info.name {
1391 print!(
1392 " ({})",
1393 info.display_name
1394 .truecolor(desc_color.r, desc_color.g, desc_color.b)
1395 );
1396 }
1397
1398 let variant_str = match info.variant {
1400 theme::ThemeVariant::Dark => "dark",
1401 theme::ThemeVariant::Light => "light",
1402 };
1403 let dim_color = theme::current().color(tokens::TEXT_DIM);
1404 print!(
1405 " [{}]",
1406 variant_str.truecolor(dim_color.r, dim_color.g, dim_color.b)
1407 );
1408
1409 if is_current {
1410 let active_color = theme::current().color(tokens::SUCCESS);
1411 print!(
1412 " {}",
1413 "(active)".truecolor(active_color.r, active_color.g, active_color.b)
1414 );
1415 }
1416
1417 println!();
1418 }
1419
1420 println!();
1421
1422 let hint_color = theme::current().color(tokens::TEXT_DIM);
1424 println!(
1425 "{}",
1426 "Use --theme <name> to override, or set 'theme' in config.toml".truecolor(
1427 hint_color.r,
1428 hint_color.g,
1429 hint_color.b
1430 )
1431 );
1432}
1433
1434fn handle_completions(shell: Shell) {
1436 let mut cmd = Cli::command();
1437 generate(shell, &mut cmd, "git-iris", &mut io::stdout());
1438}
1439
1440struct PrOutputOptions {
1442 mode: OutputMode,
1443 copy: bool,
1444 github_update: bool,
1445}
1446
1447struct GitHubUpdateContext {
1448 github: crate::github::GitHubClient,
1449 number: u64,
1450 existing_body: String,
1451}
1452
1453async fn github_update_context(
1454 service: &crate::agents::IrisAgentService,
1455 pull_number: Option<u64>,
1456) -> anyhow::Result<GitHubUpdateContext> {
1457 let git_repo = service
1458 .git_repo()
1459 .ok_or_else(|| anyhow::anyhow!("GitHub publishing requires a git repository"))?;
1460 let github = crate::github::GitHubClient::from_git_repo(git_repo)?;
1461 let number = github.resolve_pull_number(pull_number, git_repo).await?;
1462 let existing_body = github.pull_body(number).await?;
1463
1464 Ok(GitHubUpdateContext {
1465 github,
1466 number,
1467 existing_body,
1468 })
1469}
1470
1471fn pull_request_template_context(
1472 service: &crate::agents::IrisAgentService,
1473) -> anyhow::Result<Option<crate::agents::context::PullRequestTemplateContext>> {
1474 let Some(repo) = service.git_repo() else {
1475 return Ok(None);
1476 };
1477
1478 Ok(
1479 crate::github::find_pull_request_template(repo.repo_path())?.map(|template| {
1480 crate::agents::context::PullRequestTemplateContext {
1481 path: template.path,
1482 body: template.body,
1483 }
1484 }),
1485 )
1486}
1487
1488async fn handle_pr_with_agent(
1489 common: CommonParams,
1490 output: PrOutputOptions,
1491 from: Option<String>,
1492 to: Option<String>,
1493 pull_number: Option<u64>,
1494 repository_url: Option<String>,
1495) -> anyhow::Result<()> {
1496 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
1497 use crate::instruction_presets::PresetType;
1498 use arboard::Clipboard;
1499
1500 if output.mode != OutputMode::Raw
1502 && !common.is_valid_preset_for_type(PresetType::Review)
1503 && !common.is_valid_preset_for_type(PresetType::Both)
1504 {
1505 ui::print_warning(
1506 "The specified preset may not be suitable for PR descriptions. Consider using a review or general preset instead.",
1507 );
1508 ui::print_info("Run 'git-iris list-presets' to see available presets for PRs.");
1509 }
1510
1511 let spinner = if output.mode == OutputMode::Raw {
1513 None
1514 } else {
1515 Some(ui::create_spinner("Initializing Iris..."))
1516 };
1517
1518 let service = IrisAgentService::from_common_params(&common, repository_url)?;
1520 let default_base = service
1521 .git_repo()
1522 .and_then(|repo| repo.get_default_base_ref().ok())
1523 .unwrap_or_else(|| "main".to_string());
1524 let github_context = if output.github_update {
1525 Some(github_update_context(&service, pull_number).await?)
1526 } else {
1527 None
1528 };
1529 let existing_body = github_context.as_ref().and_then(|context| {
1530 (!context.existing_body.trim().is_empty()).then(|| context.existing_body.clone())
1531 });
1532 let template = pull_request_template_context(&service)?;
1533 let context =
1534 TaskContext::for_pr_update_with_base(from, to, &default_base, existing_body, template);
1535 let response = service.execute_task("pr", context).await?;
1536
1537 if let Some(s) = spinner {
1539 s.finish_and_clear();
1540 }
1541
1542 let StructuredResponse::PullRequest(generated_pr) = response else {
1544 return Err(anyhow::anyhow!("Expected pull request response"));
1545 };
1546 let raw_content = generated_pr.raw_content();
1547
1548 if output.github_update {
1549 let github_context = github_context.expect("GitHub update context should be loaded");
1550 github_context
1551 .github
1552 .update_pull_body(github_context.number, raw_content)
1553 .await?;
1554 if output.mode != OutputMode::Raw {
1555 ui::print_success(&format!(
1556 "Updated {}/{} PR #{}",
1557 github_context.github.repo().owner,
1558 github_context.github.repo().name,
1559 github_context.number
1560 ));
1561 }
1562 }
1563
1564 if output.copy {
1566 match Clipboard::new() {
1567 Ok(mut clipboard) => match clipboard.set_text(raw_content) {
1568 Ok(()) => {
1569 ui::print_success("PR description copied to clipboard");
1570 }
1571 Err(e) => {
1572 ui::print_error(&format!("Failed to copy to clipboard: {e}"));
1573 println!("{raw_content}");
1575 }
1576 },
1577 Err(e) => {
1578 ui::print_error(&format!("Clipboard unavailable: {e}"));
1579 println!("{raw_content}");
1581 }
1582 }
1583 } else if output.mode == OutputMode::Raw {
1584 println!("{}", generated_pr.raw_content());
1586 } else if output.mode == OutputMode::Print {
1587 println!("{}", generated_pr.format());
1589 } else {
1590 ui::print_success("PR description generated successfully");
1591 println!("{}", generated_pr.format());
1592 }
1593
1594 Ok(())
1595}
1596
1597async fn handle_pr(
1599 common: CommonParams,
1600 output: PrOutputOptions,
1601 from: Option<String>,
1602 to: Option<String>,
1603 pull_number: Option<u64>,
1604 repository_url: Option<String>,
1605) -> anyhow::Result<()> {
1606 log_debug!(
1607 "Handling 'pr' command with common: {:?}, print: {}, raw: {}, copy: {}, from: {:?}, to: {:?}",
1608 common,
1609 output.mode == OutputMode::Print,
1610 output.mode == OutputMode::Raw,
1611 output.copy,
1612 from,
1613 to
1614 );
1615
1616 if output.mode != OutputMode::Raw {
1619 ui::print_version(crate_version!());
1620 ui::print_newline();
1621 }
1622
1623 handle_pr_with_agent(common, output, from, to, pull_number, repository_url).await
1624}
1625
1626#[allow(clippy::unused_async)] async fn handle_studio(
1629 common: CommonParams,
1630 mode: Option<String>,
1631 from: Option<String>,
1632 to: Option<String>,
1633 repository_url: Option<String>,
1634) -> anyhow::Result<()> {
1635 use crate::agents::IrisAgentService;
1636 use crate::config::Config;
1637 use crate::git::GitRepo;
1638 use crate::services::GitCommitService;
1639 use crate::studio::{Mode, run_studio};
1640 use anyhow::Context;
1641 use std::sync::Arc;
1642
1643 crate::logger::set_log_to_stdout(false);
1645
1646 log_debug!(
1647 "Handling 'studio' command with common: {:?}, mode: {:?}, from: {:?}, to: {:?}",
1648 common,
1649 mode,
1650 from,
1651 to
1652 );
1653
1654 let mut cfg = Config::load()?;
1655 common.apply_to_config(&mut cfg)?;
1656
1657 let repo_url = repository_url.clone().or(common.repository_url.clone());
1659 let git_repo =
1660 Arc::new(GitRepo::new_from_url(repo_url.clone()).context("Failed to create GitRepo")?);
1661
1662 let commit_service = Arc::new(GitCommitService::new(
1664 git_repo.clone(),
1665 cfg.use_gitmoji,
1666 true, ));
1668
1669 let agent_service = Arc::new(IrisAgentService::from_common_params(
1670 &common,
1671 repository_url,
1672 )?);
1673
1674 let initial_mode = mode
1676 .as_deref()
1677 .and_then(|m| match m.to_lowercase().as_str() {
1678 "explore" => Some(Mode::Explore),
1679 "commit" => Some(Mode::Commit),
1680 "review" => Some(Mode::Review),
1681 "pr" => Some(Mode::PR),
1682 "changelog" => Some(Mode::Changelog),
1683 "release-notes" | "release_notes" => Some(Mode::ReleaseNotes),
1684 _ => {
1685 ui::print_warning(&format!("Unknown mode '{}', using auto-detect", m));
1686 None
1687 }
1688 });
1689
1690 run_studio(
1691 cfg,
1692 Some(git_repo),
1693 Some(commit_service),
1694 Some(agent_service),
1695 initial_mode,
1696 from,
1697 to,
1698 )
1699}