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, 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
172 #[command(
174 about = "Generate a pull request description using AI",
175 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."
176 )]
177 Pr {
178 #[command(flatten)]
179 common: CommonParams,
180
181 #[arg(
183 short,
184 long,
185 help = "Print the generated PR description to stdout and exit"
186 )]
187 print: bool,
188
189 #[arg(long, help = "Output raw markdown without any console formatting")]
191 raw: bool,
192
193 #[arg(
195 short,
196 long,
197 help = "Copy raw markdown to clipboard (for pasting into GitHub/GitLab)"
198 )]
199 copy: bool,
200
201 #[arg(
203 long,
204 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)"
205 )]
206 from: Option<String>,
207
208 #[arg(
210 long,
211 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)"
212 )]
213 to: Option<String>,
214 },
215
216 #[command(
218 about = "Generate a changelog",
219 long_about = "Generate a changelog between two specified Git references."
220 )]
221 Changelog {
222 #[command(flatten)]
223 common: CommonParams,
224
225 #[arg(long, required = true)]
227 from: String,
228
229 #[arg(long)]
231 to: Option<String>,
232
233 #[arg(long, help = "Output raw markdown without any console formatting")]
235 raw: bool,
236
237 #[arg(long, help = "Update the changelog file with the new changes")]
239 update: bool,
240
241 #[arg(long, help = "Path to the changelog file (defaults to CHANGELOG.md)")]
243 file: Option<String>,
244
245 #[arg(long, help = "Explicit version name to use in the changelog")]
247 version_name: Option<String>,
248 },
249
250 #[command(
252 about = "Generate release notes",
253 long_about = "Generate comprehensive release notes between two specified Git references."
254 )]
255 ReleaseNotes {
256 #[command(flatten)]
257 common: CommonParams,
258
259 #[arg(long, required = true)]
261 from: String,
262
263 #[arg(long)]
265 to: Option<String>,
266
267 #[arg(long, help = "Output raw markdown without any console formatting")]
269 raw: bool,
270
271 #[arg(long, help = "Update the release notes file with the new content")]
273 update: bool,
274
275 #[arg(
277 long,
278 help = "Path to the release notes file (defaults to RELEASE_NOTES.md)"
279 )]
280 file: Option<String>,
281
282 #[arg(long, help = "Explicit version name to use in the release notes")]
284 version_name: Option<String>,
285 },
286
287 #[command(
289 about = "Launch Iris Studio TUI",
290 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."
291 )]
292 Studio {
293 #[command(flatten)]
294 common: CommonParams,
295
296 #[arg(
298 long,
299 value_name = "MODE",
300 help = "Initial mode: explore, commit, review, pr, changelog, release-notes"
301 )]
302 mode: Option<String>,
303
304 #[arg(long, value_name = "REF", help = "Starting ref for comparison")]
306 from: Option<String>,
307
308 #[arg(long, value_name = "REF", help = "Ending ref for comparison")]
310 to: Option<String>,
311 },
312
313 #[command(about = "Configure Git-Iris settings and providers")]
316 Config {
317 #[command(flatten)]
318 common: CommonParams,
319
320 #[arg(long, help = "Set API key for the specified provider")]
322 api_key: Option<String>,
323
324 #[arg(
326 long,
327 help = "Set fast model for the specified provider (used for status updates and simple tasks)"
328 )]
329 fast_model: Option<String>,
330
331 #[arg(long, help = "Set token limit for the specified provider")]
333 token_limit: Option<usize>,
334
335 #[arg(
337 long,
338 help = "Set additional parameters for the specified provider (key=value)"
339 )]
340 param: Option<Vec<String>>,
341
342 #[arg(
344 long,
345 help = "Set timeout in seconds for parallel subagent tasks (default: 120)"
346 )]
347 subagent_timeout: Option<u64>,
348 },
349
350 #[command(
352 about = "Manage project-specific configuration",
353 long_about = "Create or update a project-specific .irisconfig file in the repository root."
354 )]
355 ProjectConfig {
356 #[command(flatten)]
357 common: CommonParams,
358
359 #[arg(
361 long,
362 help = "Set fast model for the specified provider (used for status updates and simple tasks)"
363 )]
364 fast_model: Option<String>,
365
366 #[arg(long, help = "Set token limit for the specified provider")]
368 token_limit: Option<usize>,
369
370 #[arg(
372 long,
373 help = "Set additional parameters for the specified provider (key=value)"
374 )]
375 param: Option<Vec<String>>,
376
377 #[arg(
379 long,
380 help = "Set timeout in seconds for parallel subagent tasks (default: 120)"
381 )]
382 subagent_timeout: Option<u64>,
383
384 #[arg(short, long, help = "Print the current project configuration")]
386 print: bool,
387 },
388
389 #[command(about = "List available instruction presets")]
391 ListPresets,
392
393 #[command(about = "List available themes")]
395 Themes,
396
397 #[command(
399 about = "Generate shell completions",
400 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"
401 )]
402 Completions {
403 #[arg(value_enum)]
405 shell: Shell,
406 },
407
408 #[command(
410 about = "Install or uninstall the prepare-commit-msg Git hook",
411 long_about = "Install or uninstall a prepare-commit-msg Git hook that automatically generates commit messages using git-iris when you run 'git commit'."
412 )]
413 Hook {
414 #[command(subcommand)]
416 action: HookAction,
417 },
418}
419
420#[derive(Subcommand)]
422pub enum HookAction {
423 #[command(about = "Install the prepare-commit-msg hook")]
425 Install {
426 #[arg(long, help = "Overwrite an existing hook not installed by git-iris")]
428 force: bool,
429 },
430 #[command(about = "Uninstall the prepare-commit-msg hook")]
432 Uninstall,
433}
434
435fn get_styles() -> Styles {
437 Styles::styled()
438 .header(AnsiColor::Magenta.on_default().bold())
439 .usage(AnsiColor::Cyan.on_default().bold())
440 .literal(AnsiColor::Green.on_default().bold())
441 .placeholder(AnsiColor::Yellow.on_default())
442 .valid(AnsiColor::Blue.on_default().bold())
443 .invalid(AnsiColor::Red.on_default().bold())
444 .error(AnsiColor::Red.on_default().bold())
445}
446
447#[must_use]
449pub fn parse_args() -> Cli {
450 Cli::parse()
451}
452
453fn get_dynamic_help() -> String {
455 let providers_list = Provider::all_names()
456 .iter()
457 .map(|p| format!("{}", p.bold()))
458 .collect::<Vec<_>>()
459 .join(" • ");
460
461 format!("\nAvailable LLM Providers: {providers_list}")
462}
463
464pub async fn main() -> anyhow::Result<()> {
470 let cli = parse_args();
471
472 if cli.version {
473 ui::print_version(crate_version!());
474 return Ok(());
475 }
476
477 if let Err(e) = crate::logger::init(cli.log) {
479 eprintln!("Warning: Failed to initialize logging: {e}");
480 }
481
482 if cli.log {
483 crate::logger::enable_logging();
484 crate::logger::set_log_to_stdout(true);
485 let log_file = cli.log_file.as_deref().unwrap_or(LOG_FILE);
486 crate::logger::set_log_file(log_file)?;
487 log_debug!("Debug logging enabled");
488 } else {
489 crate::logger::disable_logging();
490 }
491
492 if cli.quiet {
494 crate::ui::set_quiet_mode(true);
495 }
496
497 initialize_theme(cli.theme.as_deref());
499
500 if cli.debug {
502 crate::agents::debug::enable_debug_mode();
503 crate::agents::debug::debug_header("🔮 IRIS DEBUG MODE ACTIVATED 🔮");
504 }
505
506 if let Some(command) = cli.command {
507 handle_command(command, cli.repository_url).await
508 } else {
509 handle_studio(
511 CommonParams::default(),
512 None,
513 None,
514 None,
515 cli.repository_url,
516 )
517 .await
518 }
519}
520
521fn initialize_theme(cli_theme: Option<&str>) {
523 use crate::config::Config;
524
525 let theme_name = if let Some(name) = cli_theme {
527 Some(name.to_string())
528 } else {
529 Config::load().ok().and_then(|c| {
531 if c.theme.is_empty() {
532 None
533 } else {
534 Some(c.theme)
535 }
536 })
537 };
538
539 if let Some(name) = theme_name {
541 if let Err(e) = theme::load_theme_by_name(&name) {
542 ui::print_warning(&format!(
543 "Failed to load theme '{}': {}. Using default.",
544 name, e
545 ));
546 } else {
547 log_debug!("Loaded theme: {}", name);
548 }
549 }
550}
551
552#[allow(clippy::struct_excessive_bools)]
554struct GenConfig {
555 auto_commit: bool,
556 use_gitmoji: bool,
557 print_only: bool,
558 verify: bool,
559 amend: bool,
560}
561
562#[allow(clippy::too_many_lines)]
564async fn handle_gen_with_agent(
565 common: CommonParams,
566 config: GenConfig,
567 repository_url: Option<String>,
568) -> anyhow::Result<()> {
569 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
570 use crate::config::Config;
571 use crate::git::GitRepo;
572 use crate::instruction_presets::PresetType;
573 use crate::output::format_commit_result;
574 use crate::services::GitCommitService;
575 use crate::studio::{Mode, run_studio};
576 use crate::types::format_commit_message;
577 use anyhow::Context;
578 use std::sync::Arc;
579
580 if !common.is_valid_preset_for_type(PresetType::Commit) {
582 ui::print_warning(
583 "The specified preset may not be suitable for commit messages. Consider using a commit or general preset instead.",
584 );
585 ui::print_info("Run 'git-iris list-presets' to see available presets for commits.");
586 }
587
588 if config.amend && !config.print_only && !config.auto_commit {
590 ui::print_warning("--amend requires --print or --auto-commit for now.");
591 ui::print_info("Example: git-iris gen --amend --auto-commit");
592 return Ok(());
593 }
594
595 let mut cfg = Config::load()?;
596 common.apply_to_config(&mut cfg)?;
597
598 let repo_url = repository_url.clone().or(common.repository_url.clone());
600 let git_repo = Arc::new(GitRepo::new_from_url(repo_url).context("Failed to create GitRepo")?);
601 let use_gitmoji = config.use_gitmoji && cfg.use_gitmoji;
602
603 let commit_service = Arc::new(GitCommitService::new(
605 git_repo.clone(),
606 use_gitmoji,
607 config.verify,
608 ));
609
610 let agent_service = Arc::new(IrisAgentService::from_common_params(
612 &common,
613 repository_url.clone(),
614 )?);
615
616 let git_info = git_repo.get_git_info(&cfg)?;
618
619 if config.print_only || config.auto_commit {
621 if git_info.staged_files.is_empty() && !config.amend {
624 ui::print_warning(
625 "No staged changes. Please stage your changes before generating a commit message.",
626 );
627 ui::print_info("You can stage changes using 'git add <file>' or 'git add .'");
628 return Ok(());
629 }
630
631 if let Err(e) = commit_service.pre_commit() {
633 ui::print_error(&format!("Pre-commit failed: {e}"));
634 return Err(e);
635 }
636
637 let spinner_msg = if config.amend {
639 "Generating amended commit message..."
640 } else {
641 "Generating commit message..."
642 };
643 let spinner = ui::create_spinner(spinner_msg);
644
645 let context = if config.amend {
648 let original_message = commit_service.get_head_commit_message().unwrap_or_default();
649 TaskContext::for_amend(original_message)
650 } else {
651 TaskContext::for_gen()
652 };
653 let response = agent_service.execute_task("commit", context).await?;
654
655 let StructuredResponse::CommitMessage(generated_message) = response else {
657 return Err(anyhow::anyhow!("Expected commit message response"));
658 };
659
660 spinner.finish_and_clear();
662
663 if config.print_only {
664 println!("{}", format_commit_message(&generated_message));
665 return Ok(());
666 }
667
668 if commit_service.is_remote() {
670 ui::print_error(
671 "Cannot automatically commit to a remote repository. Use --print instead.",
672 );
673 return Err(anyhow::anyhow!(
674 "Auto-commit not supported for remote repositories"
675 ));
676 }
677
678 let commit_result = if config.amend {
679 commit_service.perform_amend(&format_commit_message(&generated_message))
680 } else {
681 commit_service.perform_commit(&format_commit_message(&generated_message))
682 };
683
684 match commit_result {
685 Ok(result) => {
686 let output =
687 format_commit_result(&result, &format_commit_message(&generated_message));
688 println!("{output}");
689 }
690 Err(e) => {
691 let action = if config.amend { "amend" } else { "commit" };
692 eprintln!("Failed to {action}: {e}");
693 return Err(e);
694 }
695 }
696 return Ok(());
697 }
698
699 if commit_service.is_remote() {
701 ui::print_warning(
702 "Interactive commit not available for remote repositories. Use --print instead.",
703 );
704 return Ok(());
705 }
706
707 run_studio(
709 cfg,
710 Some(git_repo),
711 Some(commit_service),
712 Some(agent_service),
713 Some(Mode::Commit),
714 None,
715 None,
716 )
717}
718
719async fn handle_gen(
721 common: CommonParams,
722 config: GenConfig,
723 repository_url: Option<String>,
724) -> anyhow::Result<()> {
725 log_debug!(
726 "Handling 'gen' command with common: {:?}, auto_commit: {}, use_gitmoji: {}, print: {}, verify: {}, amend: {}",
727 common,
728 config.auto_commit,
729 config.use_gitmoji,
730 config.print_only,
731 config.verify,
732 config.amend
733 );
734
735 ui::print_version(crate_version!());
736 ui::print_newline();
737
738 handle_gen_with_agent(common, config, repository_url).await
739}
740
741fn handle_config(
743 common: &CommonParams,
744 api_key: Option<String>,
745 model: Option<String>,
746 fast_model: Option<String>,
747 token_limit: Option<usize>,
748 param: Option<Vec<String>>,
749 subagent_timeout: Option<u64>,
750) -> anyhow::Result<()> {
751 log_debug!(
752 "Handling 'config' command with common: {:?}, api_key: {}, model: {:?}, token_limit: {:?}, param: {:?}, subagent_timeout: {:?}",
753 common,
754 if api_key.is_some() {
755 "[REDACTED]"
756 } else {
757 "<none>"
758 },
759 model,
760 token_limit,
761 param,
762 subagent_timeout
763 );
764 commands::handle_config_command(
765 common,
766 api_key,
767 model,
768 fast_model,
769 token_limit,
770 param,
771 subagent_timeout,
772 )
773}
774
775#[allow(clippy::too_many_arguments)]
777async fn handle_review(
778 common: CommonParams,
779 print: bool,
780 raw: bool,
781 repository_url: Option<String>,
782 include_unstaged: bool,
783 commit: Option<String>,
784 from: Option<String>,
785 to: Option<String>,
786) -> anyhow::Result<()> {
787 log_debug!(
788 "Handling 'review' command with common: {:?}, print: {}, raw: {}, include_unstaged: {}, commit: {:?}, from: {:?}, to: {:?}",
789 common,
790 print,
791 raw,
792 include_unstaged,
793 commit,
794 from,
795 to
796 );
797
798 if !raw {
800 ui::print_version(crate_version!());
801 ui::print_newline();
802 }
803
804 use crate::agents::{IrisAgentService, TaskContext};
805
806 let spinner = if raw {
808 None
809 } else {
810 Some(ui::create_spinner("Initializing Iris..."))
811 };
812
813 let service = IrisAgentService::from_common_params(&common, repository_url)?;
815 let default_base = service
816 .git_repo()
817 .and_then(|repo| repo.get_default_base_ref().ok())
818 .unwrap_or_else(|| "main".to_string());
819 let context =
820 TaskContext::for_review_with_base(commit, from, to, include_unstaged, &default_base)?;
821 let response = service.execute_task("review", context).await?;
822
823 if let Some(s) = spinner {
825 s.finish_and_clear();
826 }
827
828 if raw || print {
829 println!("{response}");
830 } else {
831 ui::print_success("Code review completed successfully");
832 println!("{response}");
833 }
834 Ok(())
835}
836
837#[allow(clippy::too_many_arguments)]
839async fn handle_changelog(
840 common: CommonParams,
841 from: String,
842 to: Option<String>,
843 raw: bool,
844 repository_url: Option<String>,
845 update: bool,
846 file: Option<String>,
847 version_name: Option<String>,
848) -> anyhow::Result<()> {
849 log_debug!(
850 "Handling 'changelog' command with common: {:?}, from: {}, to: {:?}, raw: {}, update: {}, file: {:?}, version_name: {:?}",
851 common,
852 from,
853 to,
854 raw,
855 update,
856 file,
857 version_name
858 );
859
860 if !raw {
862 ui::print_version(crate_version!());
863 ui::print_newline();
864 }
865
866 use crate::agents::{IrisAgentService, TaskContext};
867 use crate::changelog::ChangelogGenerator;
868 use crate::git::GitRepo;
869 use anyhow::Context;
870 use std::sync::Arc;
871
872 let context = TaskContext::for_changelog(from.clone(), to.clone(), version_name.clone(), None);
874 let to_ref = to.unwrap_or_else(|| "HEAD".to_string());
875
876 let spinner = if raw {
878 None
879 } else {
880 Some(ui::create_spinner("Initializing Iris..."))
881 };
882
883 let service = IrisAgentService::from_common_params(&common, repository_url.clone())?;
885 let response = service.execute_task("changelog", context).await?;
886
887 if let Some(s) = spinner {
889 s.finish_and_clear();
890 }
891
892 println!("{response}");
894
895 if update {
896 let formatted_content = response.to_string();
898 let changelog_path = file.unwrap_or_else(|| "CHANGELOG.md".to_string());
899 let repo_url_for_update = repository_url.or(common.repository_url.clone());
900
901 let git_repo = if let Some(url) = repo_url_for_update {
903 Arc::new(
904 GitRepo::clone_remote_repository(&url)
905 .context("Failed to clone repository for changelog update")?,
906 )
907 } else {
908 let repo_path = std::env::current_dir()?;
909 Arc::new(
910 GitRepo::new(&repo_path)
911 .context("Failed to create GitRepo for changelog update")?,
912 )
913 };
914
915 let update_spinner =
917 ui::create_spinner(&format!("Updating changelog file at {changelog_path}..."));
918
919 match ChangelogGenerator::update_changelog_file(
920 &formatted_content,
921 &changelog_path,
922 &git_repo,
923 &to_ref,
924 version_name,
925 ) {
926 Ok(()) => {
927 update_spinner.finish_and_clear();
928 ui::print_success(&format!(
929 "✨ Changelog successfully updated at {}",
930 changelog_path.bright_green()
931 ));
932 }
933 Err(e) => {
934 update_spinner.finish_and_clear();
935 ui::print_error(&format!("Failed to update changelog file: {e}"));
936 return Err(e);
937 }
938 }
939 }
940 Ok(())
941}
942
943#[allow(clippy::too_many_arguments)]
945async fn handle_release_notes(
946 common: CommonParams,
947 from: String,
948 to: Option<String>,
949 raw: bool,
950 repository_url: Option<String>,
951 update: bool,
952 file: Option<String>,
953 version_name: Option<String>,
954) -> anyhow::Result<()> {
955 log_debug!(
956 "Handling 'release-notes' command with common: {:?}, from: {}, to: {:?}, raw: {}, update: {}, file: {:?}, version_name: {:?}",
957 common,
958 from,
959 to,
960 raw,
961 update,
962 file,
963 version_name
964 );
965
966 if !raw {
968 ui::print_version(crate_version!());
969 ui::print_newline();
970 }
971
972 use crate::agents::{IrisAgentService, TaskContext};
973 use std::fs;
974 use std::path::Path;
975
976 let context = TaskContext::for_changelog(from, to, version_name, None);
978
979 let spinner = if raw {
981 None
982 } else {
983 Some(ui::create_spinner("Initializing Iris..."))
984 };
985
986 let service = IrisAgentService::from_common_params(&common, repository_url)?;
988 let response = service.execute_task("release_notes", context).await?;
989
990 if let Some(s) = spinner {
992 s.finish_and_clear();
993 }
994
995 println!("{response}");
996
997 if update {
999 let release_notes_path = file.unwrap_or_else(|| "RELEASE_NOTES.md".to_string());
1000 let formatted_content = response.to_string();
1001
1002 let update_spinner = ui::create_spinner(&format!(
1003 "Updating release notes file at {release_notes_path}..."
1004 ));
1005
1006 let path = Path::new(&release_notes_path);
1008 let result = if path.exists() {
1009 let existing = fs::read_to_string(path)?;
1011 fs::write(path, format!("{formatted_content}\n\n---\n\n{existing}"))
1012 } else {
1013 fs::write(path, &formatted_content)
1015 };
1016
1017 match result {
1018 Ok(()) => {
1019 update_spinner.finish_and_clear();
1020 ui::print_success(&format!(
1021 "✨ Release notes successfully updated at {}",
1022 release_notes_path.bright_green()
1023 ));
1024 }
1025 Err(e) => {
1026 update_spinner.finish_and_clear();
1027 ui::print_error(&format!("Failed to update release notes file: {e}"));
1028 return Err(e.into());
1029 }
1030 }
1031 }
1032
1033 Ok(())
1034}
1035
1036#[allow(clippy::too_many_lines)]
1038pub async fn handle_command(
1043 command: Commands,
1044 repository_url: Option<String>,
1045) -> anyhow::Result<()> {
1046 match command {
1047 Commands::Gen {
1048 common,
1049 auto_commit,
1050 print,
1051 no_verify,
1052 amend,
1053 } => {
1054 let use_gitmoji = common.resolved_gitmoji().unwrap_or(true);
1057 handle_gen(
1058 common,
1059 GenConfig {
1060 auto_commit,
1061 use_gitmoji,
1062 print_only: print,
1063 verify: !no_verify,
1064 amend,
1065 },
1066 repository_url,
1067 )
1068 .await
1069 }
1070 Commands::Config {
1071 common,
1072 api_key,
1073 fast_model,
1074 token_limit,
1075 param,
1076 subagent_timeout,
1077 } => handle_config(
1078 &common,
1079 api_key,
1080 common.model.clone(),
1081 fast_model,
1082 token_limit,
1083 param,
1084 subagent_timeout,
1085 ),
1086 Commands::Review {
1087 common,
1088 print,
1089 raw,
1090 include_unstaged,
1091 commit,
1092 from,
1093 to,
1094 } => {
1095 handle_review(
1096 common,
1097 print,
1098 raw,
1099 repository_url,
1100 include_unstaged,
1101 commit,
1102 from,
1103 to,
1104 )
1105 .await
1106 }
1107 Commands::Changelog {
1108 common,
1109 from,
1110 to,
1111 raw,
1112 update,
1113 file,
1114 version_name,
1115 } => {
1116 handle_changelog(
1117 common,
1118 from,
1119 to,
1120 raw,
1121 repository_url,
1122 update,
1123 file,
1124 version_name,
1125 )
1126 .await
1127 }
1128 Commands::ReleaseNotes {
1129 common,
1130 from,
1131 to,
1132 raw,
1133 update,
1134 file,
1135 version_name,
1136 } => {
1137 handle_release_notes(
1138 common,
1139 from,
1140 to,
1141 raw,
1142 repository_url,
1143 update,
1144 file,
1145 version_name,
1146 )
1147 .await
1148 }
1149 Commands::ProjectConfig {
1150 common,
1151 fast_model,
1152 token_limit,
1153 param,
1154 subagent_timeout,
1155 print,
1156 } => commands::handle_project_config_command(
1157 &common,
1158 common.model.clone(),
1159 fast_model,
1160 token_limit,
1161 param,
1162 subagent_timeout,
1163 print,
1164 ),
1165 Commands::ListPresets => commands::handle_list_presets_command(),
1166 Commands::Themes => {
1167 handle_themes();
1168 Ok(())
1169 }
1170 Commands::Completions { shell } => {
1171 handle_completions(shell);
1172 Ok(())
1173 }
1174 Commands::Hook { action } => commands::handle_hook_command(&action),
1175 Commands::Pr {
1176 common,
1177 print,
1178 raw,
1179 copy,
1180 from,
1181 to,
1182 } => handle_pr(common, print, raw, copy, from, to, repository_url).await,
1183 Commands::Studio {
1184 common,
1185 mode,
1186 from,
1187 to,
1188 } => handle_studio(common, mode, from, to, repository_url).await,
1189 }
1190}
1191
1192fn handle_themes() {
1194 ui::print_version(crate_version!());
1195 ui::print_newline();
1196
1197 let available = theme::list_available_themes();
1198 let current = theme::current();
1199 let current_name = ¤t.meta.name;
1200
1201 let header_color = theme::current().color(tokens::ACCENT_PRIMARY);
1203 println!(
1204 "{}",
1205 "Available Themes:"
1206 .truecolor(header_color.r, header_color.g, header_color.b)
1207 .bold()
1208 );
1209 println!();
1210
1211 for info in available {
1212 let is_current = info.display_name == *current_name;
1213 let marker = if is_current { "● " } else { " " };
1214
1215 let name_color = if is_current {
1216 theme::current().color(tokens::SUCCESS)
1217 } else {
1218 theme::current().color(tokens::ACCENT_SECONDARY)
1219 };
1220
1221 let desc_color = theme::current().color(tokens::TEXT_SECONDARY);
1222
1223 print!(
1224 "{}{}",
1225 marker.truecolor(name_color.r, name_color.g, name_color.b),
1226 info.name
1227 .truecolor(name_color.r, name_color.g, name_color.b)
1228 .bold()
1229 );
1230
1231 if info.display_name != info.name {
1233 print!(
1234 " ({})",
1235 info.display_name
1236 .truecolor(desc_color.r, desc_color.g, desc_color.b)
1237 );
1238 }
1239
1240 let variant_str = match info.variant {
1242 theme::ThemeVariant::Dark => "dark",
1243 theme::ThemeVariant::Light => "light",
1244 };
1245 let dim_color = theme::current().color(tokens::TEXT_DIM);
1246 print!(
1247 " [{}]",
1248 variant_str.truecolor(dim_color.r, dim_color.g, dim_color.b)
1249 );
1250
1251 if is_current {
1252 let active_color = theme::current().color(tokens::SUCCESS);
1253 print!(
1254 " {}",
1255 "(active)".truecolor(active_color.r, active_color.g, active_color.b)
1256 );
1257 }
1258
1259 println!();
1260 }
1261
1262 println!();
1263
1264 let hint_color = theme::current().color(tokens::TEXT_DIM);
1266 println!(
1267 "{}",
1268 "Use --theme <name> to override, or set 'theme' in config.toml".truecolor(
1269 hint_color.r,
1270 hint_color.g,
1271 hint_color.b
1272 )
1273 );
1274}
1275
1276fn handle_completions(shell: Shell) {
1278 let mut cmd = Cli::command();
1279 generate(shell, &mut cmd, "git-iris", &mut io::stdout());
1280}
1281
1282async fn handle_pr_with_agent(
1284 common: CommonParams,
1285 print: bool,
1286 raw: bool,
1287 copy: bool,
1288 from: Option<String>,
1289 to: Option<String>,
1290 repository_url: Option<String>,
1291) -> anyhow::Result<()> {
1292 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
1293 use crate::instruction_presets::PresetType;
1294 use arboard::Clipboard;
1295
1296 if !raw
1298 && !common.is_valid_preset_for_type(PresetType::Review)
1299 && !common.is_valid_preset_for_type(PresetType::Both)
1300 {
1301 ui::print_warning(
1302 "The specified preset may not be suitable for PR descriptions. Consider using a review or general preset instead.",
1303 );
1304 ui::print_info("Run 'git-iris list-presets' to see available presets for PRs.");
1305 }
1306
1307 let spinner = if raw {
1309 None
1310 } else {
1311 Some(ui::create_spinner("Initializing Iris..."))
1312 };
1313
1314 let service = IrisAgentService::from_common_params(&common, repository_url)?;
1316 let default_base = service
1317 .git_repo()
1318 .and_then(|repo| repo.get_default_base_ref().ok())
1319 .unwrap_or_else(|| "main".to_string());
1320 let context = TaskContext::for_pr_with_base(from, to, &default_base);
1321 let response = service.execute_task("pr", context).await?;
1322
1323 if let Some(s) = spinner {
1325 s.finish_and_clear();
1326 }
1327
1328 let StructuredResponse::PullRequest(generated_pr) = response else {
1330 return Err(anyhow::anyhow!("Expected pull request response"));
1331 };
1332
1333 if copy {
1335 let raw_content = generated_pr.raw_content();
1336 match Clipboard::new() {
1337 Ok(mut clipboard) => match clipboard.set_text(raw_content) {
1338 Ok(()) => {
1339 ui::print_success("PR description copied to clipboard");
1340 }
1341 Err(e) => {
1342 ui::print_error(&format!("Failed to copy to clipboard: {e}"));
1343 println!("{raw_content}");
1345 }
1346 },
1347 Err(e) => {
1348 ui::print_error(&format!("Clipboard unavailable: {e}"));
1349 println!("{raw_content}");
1351 }
1352 }
1353 } else if raw {
1354 println!("{}", generated_pr.raw_content());
1356 } else if print {
1357 println!("{}", generated_pr.format());
1359 } else {
1360 ui::print_success("PR description generated successfully");
1361 println!("{}", generated_pr.format());
1362 }
1363
1364 Ok(())
1365}
1366
1367async fn handle_pr(
1369 common: CommonParams,
1370 print: bool,
1371 raw: bool,
1372 copy: bool,
1373 from: Option<String>,
1374 to: Option<String>,
1375 repository_url: Option<String>,
1376) -> anyhow::Result<()> {
1377 log_debug!(
1378 "Handling 'pr' command with common: {:?}, print: {}, raw: {}, copy: {}, from: {:?}, to: {:?}",
1379 common,
1380 print,
1381 raw,
1382 copy,
1383 from,
1384 to
1385 );
1386
1387 if !raw {
1390 ui::print_version(crate_version!());
1391 ui::print_newline();
1392 }
1393
1394 handle_pr_with_agent(common, print, raw, copy, from, to, repository_url).await
1395}
1396
1397#[allow(clippy::unused_async)] async fn handle_studio(
1400 common: CommonParams,
1401 mode: Option<String>,
1402 from: Option<String>,
1403 to: Option<String>,
1404 repository_url: Option<String>,
1405) -> anyhow::Result<()> {
1406 use crate::agents::IrisAgentService;
1407 use crate::config::Config;
1408 use crate::git::GitRepo;
1409 use crate::services::GitCommitService;
1410 use crate::studio::{Mode, run_studio};
1411 use anyhow::Context;
1412 use std::sync::Arc;
1413
1414 crate::logger::set_log_to_stdout(false);
1416
1417 log_debug!(
1418 "Handling 'studio' command with common: {:?}, mode: {:?}, from: {:?}, to: {:?}",
1419 common,
1420 mode,
1421 from,
1422 to
1423 );
1424
1425 let mut cfg = Config::load()?;
1426 common.apply_to_config(&mut cfg)?;
1427
1428 let repo_url = repository_url.clone().or(common.repository_url.clone());
1430 let git_repo =
1431 Arc::new(GitRepo::new_from_url(repo_url.clone()).context("Failed to create GitRepo")?);
1432
1433 let commit_service = Arc::new(GitCommitService::new(
1435 git_repo.clone(),
1436 cfg.use_gitmoji,
1437 true, ));
1439
1440 let agent_service = Arc::new(IrisAgentService::from_common_params(
1441 &common,
1442 repository_url,
1443 )?);
1444
1445 let initial_mode = mode
1447 .as_deref()
1448 .and_then(|m| match m.to_lowercase().as_str() {
1449 "explore" => Some(Mode::Explore),
1450 "commit" => Some(Mode::Commit),
1451 "review" => Some(Mode::Review),
1452 "pr" => Some(Mode::PR),
1453 "changelog" => Some(Mode::Changelog),
1454 "release-notes" | "release_notes" => Some(Mode::ReleaseNotes),
1455 _ => {
1456 ui::print_warning(&format!("Unknown mode '{}', using auto-detect", m));
1457 None
1458 }
1459 });
1460
1461 run_studio(
1462 cfg,
1463 Some(git_repo),
1464 Some(commit_service),
1465 Some(agent_service),
1466 initial_mode,
1467 from,
1468 to,
1469 )
1470}