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
386 #[command(
388 about = "Manage project-specific configuration",
389 long_about = "Create or update a project-specific .irisconfig file in the repository root."
390 )]
391 ProjectConfig {
392 #[command(flatten)]
393 common: CommonParams,
394
395 #[arg(
397 long,
398 help = "Set fast model for the specified provider (used for status updates and simple tasks)"
399 )]
400 fast_model: Option<String>,
401
402 #[arg(long, help = "Set token limit for the specified provider")]
404 token_limit: Option<usize>,
405
406 #[arg(
408 long,
409 help = "Set additional parameters for the specified provider (key=value)"
410 )]
411 param: Option<Vec<String>>,
412
413 #[arg(
415 long,
416 help = "Set timeout in seconds for parallel subagent tasks (default: 120)"
417 )]
418 subagent_timeout: Option<u64>,
419
420 #[arg(short, long, help = "Print the current project configuration")]
422 print: bool,
423 },
424
425 #[command(about = "List available instruction presets")]
427 ListPresets,
428
429 #[command(about = "List available themes")]
431 Themes,
432
433 #[command(
435 about = "Generate shell completions",
436 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"
437 )]
438 Completions {
439 #[arg(value_enum)]
441 shell: Shell,
442 },
443
444 #[command(
446 about = "Install or uninstall the prepare-commit-msg Git hook",
447 long_about = "Install or uninstall a prepare-commit-msg Git hook that automatically generates commit messages using git-iris when you run 'git commit'."
448 )]
449 Hook {
450 #[command(subcommand)]
452 action: HookAction,
453 },
454}
455
456#[derive(Subcommand)]
458pub enum HookAction {
459 #[command(about = "Install the prepare-commit-msg hook")]
461 Install {
462 #[arg(long, help = "Overwrite an existing hook not installed by git-iris")]
464 force: bool,
465 },
466 #[command(about = "Uninstall the prepare-commit-msg hook")]
468 Uninstall,
469}
470
471fn get_styles() -> Styles {
473 Styles::styled()
474 .header(AnsiColor::Magenta.on_default().bold())
475 .usage(AnsiColor::Cyan.on_default().bold())
476 .literal(AnsiColor::Green.on_default().bold())
477 .placeholder(AnsiColor::Yellow.on_default())
478 .valid(AnsiColor::Blue.on_default().bold())
479 .invalid(AnsiColor::Red.on_default().bold())
480 .error(AnsiColor::Red.on_default().bold())
481}
482
483#[must_use]
485pub fn parse_args() -> Cli {
486 Cli::parse()
487}
488
489fn get_dynamic_help() -> String {
491 let providers_list = Provider::all_names()
492 .iter()
493 .map(|p| format!("{}", p.bold()))
494 .collect::<Vec<_>>()
495 .join(" • ");
496
497 format!("\nAvailable LLM Providers: {providers_list}")
498}
499
500pub async fn main() -> anyhow::Result<()> {
506 let cli = parse_args();
507
508 if cli.version {
509 ui::print_version(crate_version!());
510 return Ok(());
511 }
512
513 if let Err(e) = crate::logger::init(cli.log) {
515 eprintln!("Warning: Failed to initialize logging: {e}");
516 }
517
518 if cli.log {
519 crate::logger::enable_logging();
520 crate::logger::set_log_to_stdout(true);
521 let log_file = cli.log_file.as_deref().unwrap_or(LOG_FILE);
522 crate::logger::set_log_file(log_file)?;
523 log_debug!("Debug logging enabled");
524 } else {
525 crate::logger::disable_logging();
526 }
527
528 if cli.quiet {
530 crate::ui::set_quiet_mode(true);
531 }
532
533 initialize_theme(cli.theme.as_deref());
535
536 if cli.debug {
538 crate::agents::debug::enable_debug_mode();
539 crate::agents::debug::debug_header("🔮 IRIS DEBUG MODE ACTIVATED 🔮");
540 }
541
542 if let Some(command) = cli.command {
543 handle_command(command, cli.repository_url).await
544 } else {
545 handle_studio(
547 CommonParams::default(),
548 None,
549 None,
550 None,
551 cli.repository_url,
552 )
553 .await
554 }
555}
556
557fn initialize_theme(cli_theme: Option<&str>) {
559 use crate::config::Config;
560
561 let theme_name = if let Some(name) = cli_theme {
563 Some(name.to_string())
564 } else {
565 Config::load().ok().and_then(|c| {
567 if c.theme.is_empty() {
568 None
569 } else {
570 Some(c.theme)
571 }
572 })
573 };
574
575 if let Some(name) = theme_name {
577 if let Err(e) = theme::load_theme_by_name(&name) {
578 ui::print_warning(&format!(
579 "Failed to load theme '{}': {}. Using default.",
580 name, e
581 ));
582 } else {
583 log_debug!("Loaded theme: {}", name);
584 }
585 }
586}
587
588#[allow(clippy::struct_excessive_bools)]
590struct GenConfig {
591 auto_commit: bool,
592 use_gitmoji: bool,
593 print_only: bool,
594 verify: bool,
595 amend: bool,
596}
597
598#[allow(clippy::too_many_lines)]
600async fn handle_gen_with_agent(
601 common: CommonParams,
602 config: GenConfig,
603 repository_url: Option<String>,
604) -> anyhow::Result<()> {
605 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
606 use crate::config::Config;
607 use crate::git::GitRepo;
608 use crate::instruction_presets::PresetType;
609 use crate::output::format_commit_result;
610 use crate::services::GitCommitService;
611 use crate::studio::{Mode, run_studio};
612 use crate::types::format_commit_message;
613 use anyhow::Context;
614 use std::sync::Arc;
615
616 if !common.is_valid_preset_for_type(PresetType::Commit) {
618 ui::print_warning(
619 "The specified preset may not be suitable for commit messages. Consider using a commit or general preset instead.",
620 );
621 ui::print_info("Run 'git-iris list-presets' to see available presets for commits.");
622 }
623
624 if config.amend && !config.print_only && !config.auto_commit {
626 ui::print_warning("--amend requires --print or --auto-commit for now.");
627 ui::print_info("Example: git-iris gen --amend --auto-commit");
628 return Ok(());
629 }
630
631 let mut cfg = Config::load()?;
632 common.apply_to_config(&mut cfg)?;
633
634 let repo_url = repository_url.clone().or(common.repository_url.clone());
636 let git_repo = Arc::new(GitRepo::new_from_url(repo_url).context("Failed to create GitRepo")?);
637 let use_gitmoji = config.use_gitmoji && cfg.use_gitmoji;
638
639 let commit_service = Arc::new(GitCommitService::new(
641 git_repo.clone(),
642 use_gitmoji,
643 config.verify,
644 ));
645
646 let agent_service = Arc::new(IrisAgentService::from_common_params(
648 &common,
649 repository_url.clone(),
650 )?);
651
652 let git_info = git_repo.get_git_info(&cfg)?;
654
655 if config.print_only || config.auto_commit {
657 if git_info.staged_files.is_empty() && !config.amend {
660 ui::print_warning(
661 "No staged changes. Please stage your changes before generating a commit message.",
662 );
663 ui::print_info("You can stage changes using 'git add <file>' or 'git add .'");
664 return Ok(());
665 }
666
667 if let Err(e) = commit_service.pre_commit() {
669 ui::print_error(&format!("Pre-commit failed: {e}"));
670 return Err(e);
671 }
672
673 let spinner_msg = if config.amend {
675 "Generating amended commit message..."
676 } else {
677 "Generating commit message..."
678 };
679 let spinner = ui::create_spinner(spinner_msg);
680
681 let context = if config.amend {
684 let original_message = commit_service.get_head_commit_message().unwrap_or_default();
685 TaskContext::for_amend(original_message)
686 } else {
687 TaskContext::for_gen()
688 };
689 let response = agent_service.execute_task("commit", context).await?;
690
691 let StructuredResponse::CommitMessage(generated_message) = response else {
693 return Err(anyhow::anyhow!("Expected commit message response"));
694 };
695
696 spinner.finish_and_clear();
698
699 if config.print_only {
700 println!("{}", format_commit_message(&generated_message));
701 return Ok(());
702 }
703
704 if commit_service.is_remote() {
706 ui::print_error(
707 "Cannot automatically commit to a remote repository. Use --print instead.",
708 );
709 return Err(anyhow::anyhow!(
710 "Auto-commit not supported for remote repositories"
711 ));
712 }
713
714 let commit_result = if config.amend {
715 commit_service.perform_amend(&format_commit_message(&generated_message))
716 } else {
717 commit_service.perform_commit(&format_commit_message(&generated_message))
718 };
719
720 match commit_result {
721 Ok(result) => {
722 let output =
723 format_commit_result(&result, &format_commit_message(&generated_message));
724 println!("{output}");
725 }
726 Err(e) => {
727 let action = if config.amend { "amend" } else { "commit" };
728 eprintln!("Failed to {action}: {e}");
729 return Err(e);
730 }
731 }
732 return Ok(());
733 }
734
735 if commit_service.is_remote() {
737 ui::print_warning(
738 "Interactive commit not available for remote repositories. Use --print instead.",
739 );
740 return Ok(());
741 }
742
743 run_studio(
745 cfg,
746 Some(git_repo),
747 Some(commit_service),
748 Some(agent_service),
749 Some(Mode::Commit),
750 None,
751 None,
752 )
753}
754
755async fn handle_gen(
757 common: CommonParams,
758 config: GenConfig,
759 repository_url: Option<String>,
760) -> anyhow::Result<()> {
761 log_debug!(
762 "Handling 'gen' command with common: {:?}, auto_commit: {}, use_gitmoji: {}, print: {}, verify: {}, amend: {}",
763 common,
764 config.auto_commit,
765 config.use_gitmoji,
766 config.print_only,
767 config.verify,
768 config.amend
769 );
770
771 ui::print_version(crate_version!());
772 ui::print_newline();
773
774 handle_gen_with_agent(common, config, repository_url).await
775}
776
777fn handle_config(
779 common: &CommonParams,
780 api_key: Option<String>,
781 model: Option<String>,
782 fast_model: Option<String>,
783 token_limit: Option<usize>,
784 param: Option<Vec<String>>,
785 subagent_timeout: Option<u64>,
786) -> anyhow::Result<()> {
787 log_debug!(
788 "Handling 'config' command with common: {:?}, api_key: {}, model: {:?}, token_limit: {:?}, param: {:?}, subagent_timeout: {:?}",
789 common,
790 if api_key.is_some() {
791 "[REDACTED]"
792 } else {
793 "<none>"
794 },
795 model,
796 token_limit,
797 param,
798 subagent_timeout
799 );
800 commands::handle_config_command(
801 common,
802 api_key,
803 model,
804 fast_model,
805 token_limit,
806 param,
807 subagent_timeout,
808 )
809}
810
811struct ReviewOutputOptions {
813 mode: OutputMode,
814 github_review: bool,
815 github_inline_comments: bool,
816 github_review_event: GitHubReviewEvent,
817}
818
819#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
820pub enum GitHubReviewEvent {
821 Comment,
822 RequestChanges,
823 Approve,
824}
825
826impl From<GitHubReviewEvent> for octocrab::models::pulls::ReviewAction {
827 fn from(event: GitHubReviewEvent) -> Self {
828 match event {
829 GitHubReviewEvent::Comment => Self::Comment,
830 GitHubReviewEvent::RequestChanges => Self::RequestChanges,
831 GitHubReviewEvent::Approve => Self::Approve,
832 }
833 }
834}
835
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837enum OutputMode {
838 Default,
839 Print,
840 Raw,
841}
842
843impl OutputMode {
844 const fn from_flags(print: bool, raw: bool) -> Self {
845 if raw {
846 Self::Raw
847 } else if print {
848 Self::Print
849 } else {
850 Self::Default
851 }
852 }
853}
854
855async fn handle_review(
856 common: CommonParams,
857 output: ReviewOutputOptions,
858 repository_url: Option<String>,
859 include_unstaged: bool,
860 commit: Option<String>,
861 from: Option<String>,
862 to: Option<String>,
863 pull_number: Option<u64>,
864) -> anyhow::Result<()> {
865 log_debug!(
866 "Handling 'review' command with common: {:?}, print: {}, raw: {}, include_unstaged: {}, commit: {:?}, from: {:?}, to: {:?}",
867 common,
868 output.mode == OutputMode::Print,
869 output.mode == OutputMode::Raw,
870 include_unstaged,
871 commit,
872 from,
873 to
874 );
875
876 if output.mode != OutputMode::Raw {
878 ui::print_version(crate_version!());
879 ui::print_newline();
880 }
881
882 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
883 use crate::github::{GitHubClient, ReviewPublishOptions};
884
885 let spinner = if output.mode == OutputMode::Raw {
887 None
888 } else {
889 Some(ui::create_spinner("Initializing Iris..."))
890 };
891
892 let service = IrisAgentService::from_common_params(&common, repository_url)?;
894 let default_base = service
895 .git_repo()
896 .and_then(|repo| repo.get_default_base_ref().ok())
897 .unwrap_or_else(|| "main".to_string());
898 let context =
899 TaskContext::for_review_with_base(commit, from, to, include_unstaged, &default_base)?;
900 let response = service.execute_task("review", context).await?;
901
902 if let Some(s) = spinner {
904 s.finish_and_clear();
905 }
906
907 let review_content = match &response {
908 StructuredResponse::MarkdownReview(review) => review.content.clone(),
909 _ => response.to_string(),
910 };
911
912 if output.github_review {
913 let git_repo = service
914 .git_repo()
915 .ok_or_else(|| anyhow::anyhow!("GitHub publishing requires a git repository"))?;
916 let github = GitHubClient::from_git_repo(git_repo)?;
917 let number = github.resolve_pull_number(pull_number, git_repo).await?;
918 github
919 .publish_review(
920 number,
921 &review_content,
922 ReviewPublishOptions {
923 event: output.github_review_event.into(),
924 inline_comments: output.github_inline_comments,
925 },
926 )
927 .await?;
928 if output.mode != OutputMode::Raw {
929 ui::print_success(&format!(
930 "Published review to {}/{} PR #{}",
931 github.repo().owner,
932 github.repo().name,
933 number
934 ));
935 }
936 }
937
938 if output.mode == OutputMode::Raw {
939 println!("{review_content}");
940 } else if output.mode == OutputMode::Print {
941 println!("{response}");
942 } else {
943 ui::print_success("Code review completed successfully");
944 println!("{response}");
945 }
946 Ok(())
947}
948
949#[allow(clippy::too_many_arguments)]
951async fn handle_changelog(
952 common: CommonParams,
953 from: String,
954 to: Option<String>,
955 raw: bool,
956 repository_url: Option<String>,
957 update: bool,
958 file: Option<String>,
959 version_name: Option<String>,
960) -> anyhow::Result<()> {
961 log_debug!(
962 "Handling 'changelog' command with common: {:?}, from: {}, to: {:?}, raw: {}, update: {}, file: {:?}, version_name: {:?}",
963 common,
964 from,
965 to,
966 raw,
967 update,
968 file,
969 version_name
970 );
971
972 if !raw {
974 ui::print_version(crate_version!());
975 ui::print_newline();
976 }
977
978 use crate::agents::{IrisAgentService, TaskContext};
979 use crate::changelog::ChangelogGenerator;
980 use crate::git::GitRepo;
981 use anyhow::Context;
982 use std::sync::Arc;
983
984 let context = TaskContext::for_changelog(from.clone(), to.clone(), version_name.clone(), None);
986 let to_ref = to.unwrap_or_else(|| "HEAD".to_string());
987
988 let spinner = if raw {
990 None
991 } else {
992 Some(ui::create_spinner("Initializing Iris..."))
993 };
994
995 let service = IrisAgentService::from_common_params(&common, repository_url.clone())?;
997 let response = service.execute_task("changelog", context).await?;
998
999 if let Some(s) = spinner {
1001 s.finish_and_clear();
1002 }
1003
1004 println!("{response}");
1006
1007 if update {
1008 let formatted_content = response.to_string();
1010 let changelog_path = file.unwrap_or_else(|| "CHANGELOG.md".to_string());
1011 let repo_url_for_update = repository_url.or(common.repository_url.clone());
1012
1013 let git_repo = if let Some(url) = repo_url_for_update {
1015 Arc::new(
1016 GitRepo::clone_remote_repository(&url)
1017 .context("Failed to clone repository for changelog update")?,
1018 )
1019 } else {
1020 let repo_path = std::env::current_dir()?;
1021 Arc::new(
1022 GitRepo::new(&repo_path)
1023 .context("Failed to create GitRepo for changelog update")?,
1024 )
1025 };
1026
1027 let update_spinner =
1029 ui::create_spinner(&format!("Updating changelog file at {changelog_path}..."));
1030
1031 match ChangelogGenerator::update_changelog_file(
1032 &formatted_content,
1033 &changelog_path,
1034 &git_repo,
1035 &to_ref,
1036 version_name,
1037 ) {
1038 Ok(()) => {
1039 update_spinner.finish_and_clear();
1040 ui::print_success(&format!(
1041 "✨ Changelog successfully updated at {}",
1042 changelog_path.bright_green()
1043 ));
1044 }
1045 Err(e) => {
1046 update_spinner.finish_and_clear();
1047 ui::print_error(&format!("Failed to update changelog file: {e}"));
1048 return Err(e);
1049 }
1050 }
1051 }
1052 Ok(())
1053}
1054
1055#[allow(clippy::too_many_arguments)]
1057async fn handle_release_notes(
1058 common: CommonParams,
1059 from: String,
1060 to: Option<String>,
1061 raw: bool,
1062 repository_url: Option<String>,
1063 update: bool,
1064 file: Option<String>,
1065 version_name: Option<String>,
1066) -> anyhow::Result<()> {
1067 log_debug!(
1068 "Handling 'release-notes' command with common: {:?}, from: {}, to: {:?}, raw: {}, update: {}, file: {:?}, version_name: {:?}",
1069 common,
1070 from,
1071 to,
1072 raw,
1073 update,
1074 file,
1075 version_name
1076 );
1077
1078 if !raw {
1080 ui::print_version(crate_version!());
1081 ui::print_newline();
1082 }
1083
1084 use crate::agents::{IrisAgentService, TaskContext};
1085 use std::fs;
1086 use std::path::Path;
1087
1088 let context = TaskContext::for_changelog(from, to, version_name, None);
1090
1091 let spinner = if raw {
1093 None
1094 } else {
1095 Some(ui::create_spinner("Initializing Iris..."))
1096 };
1097
1098 let service = IrisAgentService::from_common_params(&common, repository_url)?;
1100 let response = service.execute_task("release_notes", context).await?;
1101
1102 if let Some(s) = spinner {
1104 s.finish_and_clear();
1105 }
1106
1107 println!("{response}");
1108
1109 if update {
1111 let release_notes_path = file.unwrap_or_else(|| "RELEASE_NOTES.md".to_string());
1112 let formatted_content = response.to_string();
1113
1114 let update_spinner = ui::create_spinner(&format!(
1115 "Updating release notes file at {release_notes_path}..."
1116 ));
1117
1118 let path = Path::new(&release_notes_path);
1120 let result = if path.exists() {
1121 let existing = fs::read_to_string(path)?;
1123 fs::write(path, format!("{formatted_content}\n\n---\n\n{existing}"))
1124 } else {
1125 fs::write(path, &formatted_content)
1127 };
1128
1129 match result {
1130 Ok(()) => {
1131 update_spinner.finish_and_clear();
1132 ui::print_success(&format!(
1133 "✨ Release notes successfully updated at {}",
1134 release_notes_path.bright_green()
1135 ));
1136 }
1137 Err(e) => {
1138 update_spinner.finish_and_clear();
1139 ui::print_error(&format!("Failed to update release notes file: {e}"));
1140 return Err(e.into());
1141 }
1142 }
1143 }
1144
1145 Ok(())
1146}
1147
1148#[allow(clippy::too_many_lines)]
1150pub async fn handle_command(
1155 command: Commands,
1156 repository_url: Option<String>,
1157) -> anyhow::Result<()> {
1158 match command {
1159 Commands::Gen {
1160 common,
1161 auto_commit,
1162 print,
1163 no_verify,
1164 amend,
1165 } => {
1166 let use_gitmoji = common.resolved_gitmoji().unwrap_or(true);
1169 handle_gen(
1170 common,
1171 GenConfig {
1172 auto_commit,
1173 use_gitmoji,
1174 print_only: print,
1175 verify: !no_verify,
1176 amend,
1177 },
1178 repository_url,
1179 )
1180 .await
1181 }
1182 Commands::Config {
1183 common,
1184 api_key,
1185 fast_model,
1186 token_limit,
1187 param,
1188 subagent_timeout,
1189 } => handle_config(
1190 &common,
1191 api_key,
1192 common.model.clone(),
1193 fast_model,
1194 token_limit,
1195 param,
1196 subagent_timeout,
1197 ),
1198 Commands::Review {
1199 common,
1200 print,
1201 raw,
1202 include_unstaged,
1203 commit,
1204 from,
1205 to,
1206 github_review,
1207 github_inline_comments,
1208 github_review_event,
1209 pull_number,
1210 } => {
1211 handle_review(
1212 common,
1213 ReviewOutputOptions {
1214 mode: OutputMode::from_flags(print, raw),
1215 github_review,
1216 github_inline_comments,
1217 github_review_event,
1218 },
1219 repository_url,
1220 include_unstaged,
1221 commit,
1222 from,
1223 to,
1224 pull_number,
1225 )
1226 .await
1227 }
1228 Commands::Changelog {
1229 common,
1230 from,
1231 to,
1232 raw,
1233 update,
1234 file,
1235 version_name,
1236 } => {
1237 handle_changelog(
1238 common,
1239 from,
1240 to,
1241 raw,
1242 repository_url,
1243 update,
1244 file,
1245 version_name,
1246 )
1247 .await
1248 }
1249 Commands::ReleaseNotes {
1250 common,
1251 from,
1252 to,
1253 raw,
1254 update,
1255 file,
1256 version_name,
1257 } => {
1258 handle_release_notes(
1259 common,
1260 from,
1261 to,
1262 raw,
1263 repository_url,
1264 update,
1265 file,
1266 version_name,
1267 )
1268 .await
1269 }
1270 Commands::ProjectConfig {
1271 common,
1272 fast_model,
1273 token_limit,
1274 param,
1275 subagent_timeout,
1276 print,
1277 } => commands::handle_project_config_command(
1278 &common,
1279 common.model.clone(),
1280 fast_model,
1281 token_limit,
1282 param,
1283 subagent_timeout,
1284 print,
1285 ),
1286 Commands::ListPresets => commands::handle_list_presets_command(),
1287 Commands::Themes => {
1288 handle_themes();
1289 Ok(())
1290 }
1291 Commands::Completions { shell } => {
1292 handle_completions(shell);
1293 Ok(())
1294 }
1295 Commands::Hook { action } => commands::handle_hook_command(&action),
1296 Commands::Pr {
1297 common,
1298 print,
1299 raw,
1300 copy,
1301 from,
1302 to,
1303 github_update,
1304 pull_number,
1305 } => {
1306 handle_pr(
1307 common,
1308 PrOutputOptions {
1309 mode: OutputMode::from_flags(print, raw),
1310 copy,
1311 github_update,
1312 },
1313 from,
1314 to,
1315 pull_number,
1316 repository_url,
1317 )
1318 .await
1319 }
1320 Commands::Studio {
1321 common,
1322 mode,
1323 from,
1324 to,
1325 } => handle_studio(common, mode, from, to, repository_url).await,
1326 }
1327}
1328
1329fn handle_themes() {
1331 ui::print_version(crate_version!());
1332 ui::print_newline();
1333
1334 let available = theme::list_available_themes();
1335 let current = theme::current();
1336 let current_name = ¤t.meta.name;
1337
1338 let header_color = theme::current().color(tokens::ACCENT_PRIMARY);
1340 println!(
1341 "{}",
1342 "Available Themes:"
1343 .truecolor(header_color.r, header_color.g, header_color.b)
1344 .bold()
1345 );
1346 println!();
1347
1348 for info in available {
1349 let is_current = info.display_name == *current_name;
1350 let marker = if is_current { "● " } else { " " };
1351
1352 let name_color = if is_current {
1353 theme::current().color(tokens::SUCCESS)
1354 } else {
1355 theme::current().color(tokens::ACCENT_SECONDARY)
1356 };
1357
1358 let desc_color = theme::current().color(tokens::TEXT_SECONDARY);
1359
1360 print!(
1361 "{}{}",
1362 marker.truecolor(name_color.r, name_color.g, name_color.b),
1363 info.name
1364 .truecolor(name_color.r, name_color.g, name_color.b)
1365 .bold()
1366 );
1367
1368 if info.display_name != info.name {
1370 print!(
1371 " ({})",
1372 info.display_name
1373 .truecolor(desc_color.r, desc_color.g, desc_color.b)
1374 );
1375 }
1376
1377 let variant_str = match info.variant {
1379 theme::ThemeVariant::Dark => "dark",
1380 theme::ThemeVariant::Light => "light",
1381 };
1382 let dim_color = theme::current().color(tokens::TEXT_DIM);
1383 print!(
1384 " [{}]",
1385 variant_str.truecolor(dim_color.r, dim_color.g, dim_color.b)
1386 );
1387
1388 if is_current {
1389 let active_color = theme::current().color(tokens::SUCCESS);
1390 print!(
1391 " {}",
1392 "(active)".truecolor(active_color.r, active_color.g, active_color.b)
1393 );
1394 }
1395
1396 println!();
1397 }
1398
1399 println!();
1400
1401 let hint_color = theme::current().color(tokens::TEXT_DIM);
1403 println!(
1404 "{}",
1405 "Use --theme <name> to override, or set 'theme' in config.toml".truecolor(
1406 hint_color.r,
1407 hint_color.g,
1408 hint_color.b
1409 )
1410 );
1411}
1412
1413fn handle_completions(shell: Shell) {
1415 let mut cmd = Cli::command();
1416 generate(shell, &mut cmd, "git-iris", &mut io::stdout());
1417}
1418
1419struct PrOutputOptions {
1421 mode: OutputMode,
1422 copy: bool,
1423 github_update: bool,
1424}
1425
1426struct GitHubUpdateContext {
1427 github: crate::github::GitHubClient,
1428 number: u64,
1429 existing_body: String,
1430}
1431
1432async fn github_update_context(
1433 service: &crate::agents::IrisAgentService,
1434 pull_number: Option<u64>,
1435) -> anyhow::Result<GitHubUpdateContext> {
1436 let git_repo = service
1437 .git_repo()
1438 .ok_or_else(|| anyhow::anyhow!("GitHub publishing requires a git repository"))?;
1439 let github = crate::github::GitHubClient::from_git_repo(git_repo)?;
1440 let number = github.resolve_pull_number(pull_number, git_repo).await?;
1441 let existing_body = github.pull_body(number).await?;
1442
1443 Ok(GitHubUpdateContext {
1444 github,
1445 number,
1446 existing_body,
1447 })
1448}
1449
1450fn pull_request_template_context(
1451 service: &crate::agents::IrisAgentService,
1452) -> anyhow::Result<Option<crate::agents::context::PullRequestTemplateContext>> {
1453 let Some(repo) = service.git_repo() else {
1454 return Ok(None);
1455 };
1456
1457 Ok(
1458 crate::github::find_pull_request_template(repo.repo_path())?.map(|template| {
1459 crate::agents::context::PullRequestTemplateContext {
1460 path: template.path,
1461 body: template.body,
1462 }
1463 }),
1464 )
1465}
1466
1467async fn handle_pr_with_agent(
1468 common: CommonParams,
1469 output: PrOutputOptions,
1470 from: Option<String>,
1471 to: Option<String>,
1472 pull_number: Option<u64>,
1473 repository_url: Option<String>,
1474) -> anyhow::Result<()> {
1475 use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
1476 use crate::instruction_presets::PresetType;
1477 use arboard::Clipboard;
1478
1479 if output.mode != OutputMode::Raw
1481 && !common.is_valid_preset_for_type(PresetType::Review)
1482 && !common.is_valid_preset_for_type(PresetType::Both)
1483 {
1484 ui::print_warning(
1485 "The specified preset may not be suitable for PR descriptions. Consider using a review or general preset instead.",
1486 );
1487 ui::print_info("Run 'git-iris list-presets' to see available presets for PRs.");
1488 }
1489
1490 let spinner = if output.mode == OutputMode::Raw {
1492 None
1493 } else {
1494 Some(ui::create_spinner("Initializing Iris..."))
1495 };
1496
1497 let service = IrisAgentService::from_common_params(&common, repository_url)?;
1499 let default_base = service
1500 .git_repo()
1501 .and_then(|repo| repo.get_default_base_ref().ok())
1502 .unwrap_or_else(|| "main".to_string());
1503 let github_context = if output.github_update {
1504 Some(github_update_context(&service, pull_number).await?)
1505 } else {
1506 None
1507 };
1508 let existing_body = github_context.as_ref().and_then(|context| {
1509 (!context.existing_body.trim().is_empty()).then(|| context.existing_body.clone())
1510 });
1511 let template = pull_request_template_context(&service)?;
1512 let context =
1513 TaskContext::for_pr_update_with_base(from, to, &default_base, existing_body, template);
1514 let response = service.execute_task("pr", context).await?;
1515
1516 if let Some(s) = spinner {
1518 s.finish_and_clear();
1519 }
1520
1521 let StructuredResponse::PullRequest(generated_pr) = response else {
1523 return Err(anyhow::anyhow!("Expected pull request response"));
1524 };
1525 let raw_content = generated_pr.raw_content();
1526
1527 if output.github_update {
1528 let github_context = github_context.expect("GitHub update context should be loaded");
1529 github_context
1530 .github
1531 .update_pull_body(github_context.number, raw_content)
1532 .await?;
1533 if output.mode != OutputMode::Raw {
1534 ui::print_success(&format!(
1535 "Updated {}/{} PR #{}",
1536 github_context.github.repo().owner,
1537 github_context.github.repo().name,
1538 github_context.number
1539 ));
1540 }
1541 }
1542
1543 if output.copy {
1545 match Clipboard::new() {
1546 Ok(mut clipboard) => match clipboard.set_text(raw_content) {
1547 Ok(()) => {
1548 ui::print_success("PR description copied to clipboard");
1549 }
1550 Err(e) => {
1551 ui::print_error(&format!("Failed to copy to clipboard: {e}"));
1552 println!("{raw_content}");
1554 }
1555 },
1556 Err(e) => {
1557 ui::print_error(&format!("Clipboard unavailable: {e}"));
1558 println!("{raw_content}");
1560 }
1561 }
1562 } else if output.mode == OutputMode::Raw {
1563 println!("{}", generated_pr.raw_content());
1565 } else if output.mode == OutputMode::Print {
1566 println!("{}", generated_pr.format());
1568 } else {
1569 ui::print_success("PR description generated successfully");
1570 println!("{}", generated_pr.format());
1571 }
1572
1573 Ok(())
1574}
1575
1576async fn handle_pr(
1578 common: CommonParams,
1579 output: PrOutputOptions,
1580 from: Option<String>,
1581 to: Option<String>,
1582 pull_number: Option<u64>,
1583 repository_url: Option<String>,
1584) -> anyhow::Result<()> {
1585 log_debug!(
1586 "Handling 'pr' command with common: {:?}, print: {}, raw: {}, copy: {}, from: {:?}, to: {:?}",
1587 common,
1588 output.mode == OutputMode::Print,
1589 output.mode == OutputMode::Raw,
1590 output.copy,
1591 from,
1592 to
1593 );
1594
1595 if output.mode != OutputMode::Raw {
1598 ui::print_version(crate_version!());
1599 ui::print_newline();
1600 }
1601
1602 handle_pr_with_agent(common, output, from, to, pull_number, repository_url).await
1603}
1604
1605#[allow(clippy::unused_async)] async fn handle_studio(
1608 common: CommonParams,
1609 mode: Option<String>,
1610 from: Option<String>,
1611 to: Option<String>,
1612 repository_url: Option<String>,
1613) -> anyhow::Result<()> {
1614 use crate::agents::IrisAgentService;
1615 use crate::config::Config;
1616 use crate::git::GitRepo;
1617 use crate::services::GitCommitService;
1618 use crate::studio::{Mode, run_studio};
1619 use anyhow::Context;
1620 use std::sync::Arc;
1621
1622 crate::logger::set_log_to_stdout(false);
1624
1625 log_debug!(
1626 "Handling 'studio' command with common: {:?}, mode: {:?}, from: {:?}, to: {:?}",
1627 common,
1628 mode,
1629 from,
1630 to
1631 );
1632
1633 let mut cfg = Config::load()?;
1634 common.apply_to_config(&mut cfg)?;
1635
1636 let repo_url = repository_url.clone().or(common.repository_url.clone());
1638 let git_repo =
1639 Arc::new(GitRepo::new_from_url(repo_url.clone()).context("Failed to create GitRepo")?);
1640
1641 let commit_service = Arc::new(GitCommitService::new(
1643 git_repo.clone(),
1644 cfg.use_gitmoji,
1645 true, ));
1647
1648 let agent_service = Arc::new(IrisAgentService::from_common_params(
1649 &common,
1650 repository_url,
1651 )?);
1652
1653 let initial_mode = mode
1655 .as_deref()
1656 .and_then(|m| match m.to_lowercase().as_str() {
1657 "explore" => Some(Mode::Explore),
1658 "commit" => Some(Mode::Commit),
1659 "review" => Some(Mode::Review),
1660 "pr" => Some(Mode::PR),
1661 "changelog" => Some(Mode::Changelog),
1662 "release-notes" | "release_notes" => Some(Mode::ReleaseNotes),
1663 _ => {
1664 ui::print_warning(&format!("Unknown mode '{}', using auto-detect", m));
1665 None
1666 }
1667 });
1668
1669 run_studio(
1670 cfg,
1671 Some(git_repo),
1672 Some(commit_service),
1673 Some(agent_service),
1674 initial_mode,
1675 from,
1676 to,
1677 )
1678}