1use crate::common::CommonParams;
2use crate::config::Config;
3use crate::instruction_presets::{
4 PresetType, get_instruction_preset_library, list_presets_formatted_by_type,
5};
6use crate::log_debug;
7use crate::providers::{Provider, ProviderConfig};
8use crate::ui;
9use anyhow::Context;
10use anyhow::{Result, anyhow};
11use colored::Colorize;
12use std::collections::HashMap;
13
14mod colors {
16 use crate::theme;
17 use crate::theme::names::tokens;
18
19 pub fn accent_primary() -> (u8, u8, u8) {
20 let c = theme::current().color(tokens::ACCENT_PRIMARY);
21 (c.r, c.g, c.b)
22 }
23
24 pub fn accent_secondary() -> (u8, u8, u8) {
25 let c = theme::current().color(tokens::ACCENT_SECONDARY);
26 (c.r, c.g, c.b)
27 }
28
29 pub fn accent_tertiary() -> (u8, u8, u8) {
30 let c = theme::current().color(tokens::ACCENT_TERTIARY);
31 (c.r, c.g, c.b)
32 }
33
34 pub fn warning() -> (u8, u8, u8) {
35 let c = theme::current().color(tokens::WARNING);
36 (c.r, c.g, c.b)
37 }
38
39 pub fn success() -> (u8, u8, u8) {
40 let c = theme::current().color(tokens::SUCCESS);
41 (c.r, c.g, c.b)
42 }
43
44 pub fn text_secondary() -> (u8, u8, u8) {
45 let c = theme::current().color(tokens::TEXT_SECONDARY);
46 (c.r, c.g, c.b)
47 }
48
49 pub fn text_dim() -> (u8, u8, u8) {
50 let c = theme::current().color(tokens::TEXT_DIM);
51 (c.r, c.g, c.b)
52 }
53}
54
55fn apply_config_changes(
74 config: &mut Config,
75 common: &CommonParams,
76 model: Option<String>,
77 fast_model: Option<String>,
78 token_limit: Option<usize>,
79 param: Option<Vec<String>>,
80 api_key: Option<String>,
81 subagent_timeout: Option<u64>,
82 subagent_max_turns: Option<usize>,
83) -> anyhow::Result<bool> {
84 let mut changes_made = false;
85
86 let common_changes = common.apply_to_config(config)?;
88 changes_made |= common_changes;
89
90 if let Some(provider_str) = &common.provider {
92 let provider: Provider = provider_str.parse().map_err(|_| {
93 anyhow!(
94 "Invalid provider: {}. Available: {}",
95 provider_str,
96 Provider::all_names().join(", ")
97 )
98 })?;
99
100 if !config.providers.contains_key(provider.name()) {
102 config.providers.insert(
103 provider.name().to_string(),
104 ProviderConfig::with_defaults(provider),
105 );
106 changes_made = true;
107 }
108 }
109
110 let provider_config = config
111 .providers
112 .get_mut(&config.default_provider)
113 .context("Could not get default provider")?;
114
115 if let Some(key) = api_key
117 && provider_config.api_key != key
118 {
119 provider_config.api_key = key;
120 changes_made = true;
121 }
122
123 if let Some(model) = model
125 && provider_config.model != model
126 {
127 provider_config.model = model;
128 changes_made = true;
129 }
130
131 if let Some(fast_model) = fast_model
133 && provider_config.fast_model != Some(fast_model.clone())
134 {
135 provider_config.fast_model = Some(fast_model);
136 changes_made = true;
137 }
138
139 if let Some(params) = param {
141 let additional_params = parse_additional_params(¶ms);
142 if provider_config.additional_params != additional_params {
143 provider_config.additional_params = additional_params;
144 changes_made = true;
145 }
146 }
147
148 if let Some(use_gitmoji) = common.resolved_gitmoji()
150 && config.use_gitmoji != use_gitmoji
151 {
152 config.use_gitmoji = use_gitmoji;
153 changes_made = true;
154 }
155
156 if let Some(critic_enabled) = common.resolved_critic()
157 && config.critic_enabled != critic_enabled
158 {
159 config.critic_enabled = critic_enabled;
160 changes_made = true;
161 }
162
163 if let Some(instr) = &common.instructions
165 && config.instructions != *instr
166 {
167 config.instructions.clone_from(instr);
168 changes_made = true;
169 }
170
171 if let Some(limit) = token_limit
173 && provider_config.token_limit != Some(limit)
174 {
175 provider_config.token_limit = Some(limit);
176 changes_made = true;
177 }
178
179 if let Some(preset) = &common.preset {
181 let preset_library = get_instruction_preset_library();
182 if preset_library.get_preset(preset).is_some() {
183 if config.instruction_preset != *preset {
184 config.instruction_preset.clone_from(preset);
185 changes_made = true;
186 }
187 } else {
188 return Err(anyhow!("Invalid preset: {}", preset));
189 }
190 }
191
192 if let Some(timeout) = subagent_timeout
194 && config.subagent_timeout_secs != timeout
195 {
196 config.subagent_timeout_secs = timeout;
197 changes_made = true;
198 }
199 if let Some(max_turns) = subagent_max_turns
200 && config.subagent_max_turns != max_turns
201 {
202 config.subagent_max_turns = max_turns;
203 changes_made = true;
204 }
205
206 Ok(changes_made)
207}
208
209#[allow(clippy::too_many_lines)]
211pub fn handle_config_command(
216 common: &CommonParams,
217 api_key: Option<String>,
218 model: Option<String>,
219 fast_model: Option<String>,
220 token_limit: Option<usize>,
221 param: Option<Vec<String>>,
222 subagent_timeout: Option<u64>,
223 subagent_max_turns: Option<usize>,
224) -> anyhow::Result<()> {
225 log_debug!(
226 "Starting 'config' command with common: {:?}, api_key: {}, model: {:?}, token_limit: {:?}, param: {:?}, subagent_timeout: {:?}, subagent_max_turns: {:?}",
227 common,
228 if api_key.is_some() {
229 "[REDACTED]"
230 } else {
231 "<none>"
232 },
233 model,
234 token_limit,
235 param,
236 subagent_timeout,
237 subagent_max_turns
238 );
239
240 let mut config = Config::load()?;
241
242 let changes_made = apply_config_changes(
244 &mut config,
245 common,
246 model,
247 fast_model,
248 token_limit,
249 param,
250 api_key,
251 subagent_timeout,
252 subagent_max_turns,
253 )?;
254
255 if changes_made {
256 config.save()?;
257 ui::print_success("Configuration updated successfully.");
258 ui::print_newline();
259 }
260
261 print_configuration(&config);
263
264 Ok(())
265}
266
267fn print_project_config() {
272 if let Ok(project_config) = Config::load_project_config() {
273 ui::print_message(&format!(
274 "\n{}",
275 "Current project configuration:".bright_cyan().bold()
276 ));
277 print_configuration(&project_config);
278 } else {
279 ui::print_message(&format!(
280 "\n{}",
281 "No project configuration file found.".yellow()
282 ));
283 ui::print_message("You can create one with the project-config command.");
284 }
285}
286
287pub fn handle_project_config_command(
314 common: &CommonParams,
315 model: Option<String>,
316 fast_model: Option<String>,
317 token_limit: Option<usize>,
318 param: Option<Vec<String>>,
319 subagent_timeout: Option<u64>,
320 subagent_max_turns: Option<usize>,
321 print: bool,
322) -> anyhow::Result<()> {
323 log_debug!(
324 "Starting 'project-config' command with common: {:?}, model: {:?}, token_limit: {:?}, param: {:?}, subagent_timeout: {:?}, subagent_max_turns: {:?}, print: {}",
325 common,
326 model,
327 token_limit,
328 param,
329 subagent_timeout,
330 subagent_max_turns,
331 print
332 );
333
334 println!("\n{}", "✨ Project Configuration".bright_magenta().bold());
335
336 if print {
337 print_project_config();
338 return Ok(());
339 }
340
341 let mut config = Config::load_project_config().unwrap_or_else(|_| Config {
342 default_provider: String::new(),
343 providers: HashMap::new(),
344 use_gitmoji: true,
345 instructions: String::new(),
346 instruction_preset: String::new(),
347 theme: String::new(),
348 subagent_timeout_secs: 120,
349 subagent_max_turns: 20,
350 critic_enabled: true,
351 temp_instructions: None,
352 temp_preset: None,
353 is_project_config: true,
354 gitmoji_override: None,
355 });
356
357 let mut changes_made = false;
358
359 let provider_name = apply_provider_settings(
361 &mut config,
362 common,
363 model,
364 fast_model,
365 token_limit,
366 param,
367 &mut changes_made,
368 )?;
369
370 apply_common_settings(
372 &mut config,
373 common,
374 subagent_timeout,
375 subagent_max_turns,
376 &mut changes_made,
377 )?;
378
379 display_project_config_result(&config, changes_made, &provider_name)?;
381
382 Ok(())
383}
384
385fn apply_provider_settings(
387 config: &mut Config,
388 common: &CommonParams,
389 model: Option<String>,
390 fast_model: Option<String>,
391 token_limit: Option<usize>,
392 param: Option<Vec<String>>,
393 changes_made: &mut bool,
394) -> anyhow::Result<String> {
395 if let Some(provider_str) = &common.provider {
397 let provider: Provider = provider_str.parse().map_err(|_| {
398 anyhow!(
399 "Invalid provider: {}. Available: {}",
400 provider_str,
401 Provider::all_names().join(", ")
402 )
403 })?;
404
405 if config.default_provider != provider.name() {
406 config.default_provider = provider.name().to_string();
407 config
408 .providers
409 .entry(provider.name().to_string())
410 .or_default();
411 *changes_made = true;
412 }
413 }
414
415 let provider_name = common
417 .provider
418 .clone()
419 .or_else(|| {
420 if config.default_provider.is_empty() {
421 None
422 } else {
423 Some(config.default_provider.clone())
424 }
425 })
426 .unwrap_or_else(|| Provider::default().name().to_string());
427
428 if model.is_some() || fast_model.is_some() || token_limit.is_some() || param.is_some() {
430 config.providers.entry(provider_name.clone()).or_default();
431 }
432
433 if let Some(m) = model
435 && let Some(pc) = config.providers.get_mut(&provider_name)
436 && pc.model != m
437 {
438 pc.model = m;
439 *changes_made = true;
440 }
441
442 if let Some(fm) = fast_model
443 && let Some(pc) = config.providers.get_mut(&provider_name)
444 && pc.fast_model != Some(fm.clone())
445 {
446 pc.fast_model = Some(fm);
447 *changes_made = true;
448 }
449
450 if let Some(limit) = token_limit
451 && let Some(pc) = config.providers.get_mut(&provider_name)
452 && pc.token_limit != Some(limit)
453 {
454 pc.token_limit = Some(limit);
455 *changes_made = true;
456 }
457
458 if let Some(params) = param
459 && let Some(pc) = config.providers.get_mut(&provider_name)
460 {
461 let additional_params = parse_additional_params(¶ms);
462 if pc.additional_params != additional_params {
463 pc.additional_params = additional_params;
464 *changes_made = true;
465 }
466 }
467
468 Ok(provider_name)
469}
470
471fn apply_common_settings(
473 config: &mut Config,
474 common: &CommonParams,
475 subagent_timeout: Option<u64>,
476 subagent_max_turns: Option<usize>,
477 changes_made: &mut bool,
478) -> anyhow::Result<()> {
479 if let Some(use_gitmoji) = common.resolved_gitmoji()
480 && config.use_gitmoji != use_gitmoji
481 {
482 config.use_gitmoji = use_gitmoji;
483 *changes_made = true;
484 }
485
486 if let Some(critic_enabled) = common.resolved_critic()
487 && config.critic_enabled != critic_enabled
488 {
489 config.critic_enabled = critic_enabled;
490 *changes_made = true;
491 }
492
493 if let Some(instr) = &common.instructions
494 && config.instructions != *instr
495 {
496 config.instructions.clone_from(instr);
497 *changes_made = true;
498 }
499
500 if let Some(preset) = &common.preset {
501 let preset_library = get_instruction_preset_library();
502 if preset_library.get_preset(preset).is_some() {
503 if config.instruction_preset != *preset {
504 config.instruction_preset.clone_from(preset);
505 *changes_made = true;
506 }
507 } else {
508 return Err(anyhow!("Invalid preset: {}", preset));
509 }
510 }
511
512 if let Some(timeout) = subagent_timeout
513 && config.subagent_timeout_secs != timeout
514 {
515 config.subagent_timeout_secs = timeout;
516 *changes_made = true;
517 }
518 if let Some(max_turns) = subagent_max_turns
519 && config.subagent_max_turns != max_turns
520 {
521 config.subagent_max_turns = max_turns;
522 *changes_made = true;
523 }
524
525 Ok(())
526}
527
528fn display_project_config_result(
530 config: &Config,
531 changes_made: bool,
532 _provider_name: &str,
533) -> anyhow::Result<()> {
534 if changes_made {
535 config.save_as_project_config()?;
536 ui::print_success("Project configuration created/updated successfully.");
537 println!();
538 println!(
539 "{}",
540 "Note: API keys are never stored in project configuration files."
541 .yellow()
542 .italic()
543 );
544 println!();
545 println!("{}", "Current project configuration:".bright_cyan().bold());
546 print_configuration(config);
547 } else {
548 println!("{}", "No changes made to project configuration.".yellow());
549 println!();
550
551 if let Ok(project_config) = Config::load_project_config() {
552 println!("{}", "Current project configuration:".bright_cyan().bold());
553 print_configuration(&project_config);
554 } else {
555 println!("{}", "No project configuration exists yet.".bright_yellow());
556 println!(
557 "{}",
558 "Use this command with options like --model or --provider to create one."
559 .bright_white()
560 );
561 }
562 }
563 Ok(())
564}
565
566#[allow(clippy::too_many_lines)]
568fn print_configuration(config: &Config) {
569 let purple = colors::accent_primary();
570 let cyan = colors::accent_secondary();
571 let green = colors::success();
572 let dim = colors::text_secondary();
573 let dim_sep = colors::text_dim();
574
575 println!();
576 println!(
577 "{} {} {}",
578 "━━━".truecolor(purple.0, purple.1, purple.2),
579 "IRIS CONFIGURATION"
580 .truecolor(cyan.0, cyan.1, cyan.2)
581 .bold(),
582 "━━━".truecolor(purple.0, purple.1, purple.2)
583 );
584 println!();
585
586 print_section_header("GLOBAL");
588
589 print_config_row("Provider", &config.default_provider, cyan, true);
590
591 let theme = crate::theme::current();
593 print_config_row("Theme", &theme.meta.name, purple, false);
594
595 print_config_row(
596 "Gitmoji",
597 if config.use_gitmoji {
598 "enabled"
599 } else {
600 "disabled"
601 },
602 if config.use_gitmoji { green } else { dim },
603 false,
604 );
605 print_config_row("Preset", &config.instruction_preset, dim, false);
606 print_config_row(
607 "Critic",
608 if config.critic_enabled {
609 "enabled"
610 } else {
611 "disabled"
612 },
613 if config.critic_enabled { green } else { dim },
614 false,
615 );
616 print_config_row(
617 "Timeout",
618 &format!("{}s", config.subagent_timeout_secs),
619 dim,
620 false,
621 );
622 print_config_row(
623 "Subagent Max Turns",
624 &config.subagent_max_turns.to_string(),
625 dim,
626 false,
627 );
628
629 if let Ok(config_path) = Config::get_personal_config_path() {
631 let home = dirs::home_dir()
632 .map(|h| h.to_string_lossy().to_string())
633 .unwrap_or_default();
634 let path_str = config_path.to_string_lossy().to_string();
635 let path_display = if home.is_empty() {
636 path_str
637 } else {
638 path_str.replace(&home, "~")
639 };
640 print_config_row("Config", &path_display, dim, false);
641 }
642
643 if let Ok(project_path) = Config::get_project_config_path()
645 && project_path.exists()
646 {
647 print_config_row("Project", ".irisconfig ✓", green, false);
648 }
649
650 if !config.instructions.is_empty() {
652 println!();
653 print_section_header("INSTRUCTIONS");
654 let preview: String = config
656 .instructions
657 .lines()
658 .take(3)
659 .collect::<Vec<_>>()
660 .join("\n");
661 for line in preview.lines() {
662 println!(" {}", line.truecolor(dim.0, dim.1, dim.2).italic());
663 }
664 let total_lines = config.instructions.lines().count();
665 if total_lines > 3 {
666 println!(
667 " {}",
668 format!("… ({} more lines)", total_lines - 3)
669 .truecolor(dim_sep.0, dim_sep.1, dim_sep.2)
670 .italic()
671 );
672 }
673 }
674
675 let mut provider_names: Vec<String> =
677 Provider::ALL.iter().map(|p| p.name().to_string()).collect();
678 provider_names.sort();
679 if let Some(pos) = provider_names
681 .iter()
682 .position(|n| n == &config.default_provider)
683 {
684 let active = provider_names.remove(pos);
685 provider_names.insert(0, active);
686 }
687
688 for provider_name in &provider_names {
689 println!();
690 print_provider_section(config, provider_name);
691 }
692
693 println!();
694 println!(
695 "{}",
696 "─".repeat(44).truecolor(dim_sep.0, dim_sep.1, dim_sep.2)
697 );
698 println!();
699}
700
701fn print_provider_section(config: &Config, provider_name: &str) {
703 let cyan = colors::accent_secondary();
704 let coral = colors::accent_tertiary();
705 let yellow = colors::warning();
706 let green = colors::success();
707 let dim = colors::text_secondary();
708 let error_red: (u8, u8, u8) = (255, 99, 99);
709
710 let is_active = provider_name == config.default_provider;
711 let provider: Option<Provider> = provider_name.parse().ok();
712
713 let header = if is_active {
714 format!("{} ✦", provider_name.to_uppercase())
715 } else {
716 provider_name.to_uppercase()
717 };
718 print_section_header(&header);
719
720 let provider_config = config.providers.get(provider_name);
721
722 let model = provider_config
724 .and_then(|pc| provider.map(|p| pc.effective_model(p).to_string()))
725 .or_else(|| provider.map(|p| p.default_model().to_string()))
726 .unwrap_or_default();
727 print_config_row("Model", &model, cyan, is_active);
728
729 let fast_model = provider_config
731 .and_then(|pc| provider.map(|p| pc.effective_fast_model(p).to_string()))
732 .or_else(|| provider.map(|p| p.default_fast_model().to_string()))
733 .unwrap_or_default();
734 print_config_row("Fast Model", &fast_model, dim, false);
735
736 if let Some(p) = provider {
738 let effective_limit =
739 provider_config.map_or_else(|| p.context_window(), |pc| pc.effective_token_limit(p));
740 let limit_str = format_token_count(effective_limit);
741 let is_custom = provider_config.and_then(|pc| pc.token_limit).is_some();
742 if is_custom {
743 print_config_row("Context", &format!("{limit_str} (custom)"), coral, false);
744 } else {
745 print_config_row("Context", &limit_str, dim, false);
746 }
747 }
748
749 if let Some(p) = provider {
751 let has_config_key = provider_config.is_some_and(ProviderConfig::has_api_key);
752 let has_env_key = std::env::var(p.api_key_env()).is_ok();
753 let env_var = p.api_key_env();
754
755 let (status, status_color) = if has_config_key {
756 let key = &provider_config.expect("checked above").api_key;
758 let masked = mask_api_key(key);
759 (format!("✓ {masked}"), green)
760 } else if has_env_key {
761 (format!("✓ ${env_var}"), green)
762 } else {
763 (format!("✗ not set → ${env_var}"), error_red)
764 };
765 print_config_row("API Key", &status, status_color, false);
766
767 let key_value = if has_config_key {
769 provider_config.map(|pc| pc.api_key.clone())
770 } else if has_env_key {
771 std::env::var(p.api_key_env()).ok()
772 } else {
773 None
774 };
775 if let Some(ref key) = key_value
776 && let Err(warning) = p.validate_api_key_format(key)
777 {
778 println!(
779 " {}",
780 format!("⚠ {warning}").truecolor(yellow.0, yellow.1, yellow.2)
781 );
782 }
783 }
784
785 if let Some(pc) = provider_config
787 && !pc.additional_params.is_empty()
788 {
789 for (key, value) in &pc.additional_params {
790 print_config_row(key, value, dim, false);
791 }
792 }
793}
794
795fn format_token_count(count: usize) -> String {
797 if count >= 1_000_000 && count.is_multiple_of(1_000_000) {
798 format!("{}M tokens", count / 1_000_000)
799 } else if count >= 1_000 {
800 format!("{}K tokens", count / 1_000)
801 } else {
802 format!("{count} tokens")
803 }
804}
805
806fn mask_api_key(key: &str) -> String {
808 if key.len() <= 8 {
809 return "••••".to_string();
810 }
811 let prefix_end = key.find('-').map_or(4, |i| {
813 key[..12.min(key.len())].rfind('-').map_or(i + 1, |j| j + 1)
815 });
816 let prefix = &key[..prefix_end.min(key.len())];
817 let suffix = &key[key.len() - 4..];
818 format!("{prefix}••••{suffix}")
819}
820
821fn print_section_header(name: &str) {
823 let purple = colors::accent_primary();
824 let dim_sep = colors::text_dim();
825 println!(
826 "{} {} {}",
827 "─".truecolor(purple.0, purple.1, purple.2),
828 name.truecolor(purple.0, purple.1, purple.2).bold(),
829 "─"
830 .repeat(30 - name.len().min(28))
831 .truecolor(dim_sep.0, dim_sep.1, dim_sep.2)
832 );
833}
834
835fn print_config_row(label: &str, value: &str, value_color: (u8, u8, u8), highlight: bool) {
837 let dim = colors::text_secondary();
838 let label_styled = format!("{label:>12}").truecolor(dim.0, dim.1, dim.2);
839
840 let value_styled = if highlight {
841 value
842 .truecolor(value_color.0, value_color.1, value_color.2)
843 .bold()
844 } else {
845 value.truecolor(value_color.0, value_color.1, value_color.2)
846 };
847
848 println!("{label_styled} {value_styled}");
849}
850
851fn parse_additional_params(params: &[String]) -> HashMap<String, String> {
853 params
854 .iter()
855 .filter_map(|param| {
856 let parts: Vec<&str> = param.splitn(2, '=').collect();
857 if parts.len() == 2 {
858 Some((parts[0].to_string(), parts[1].to_string()))
859 } else {
860 None
861 }
862 })
863 .collect()
864}
865
866pub fn handle_list_presets_command() -> Result<()> {
872 let library = get_instruction_preset_library();
873
874 let both_presets = list_presets_formatted_by_type(&library, Some(PresetType::Both));
876 let commit_only_presets = list_presets_formatted_by_type(&library, Some(PresetType::Commit));
877 let review_only_presets = list_presets_formatted_by_type(&library, Some(PresetType::Review));
878
879 println!(
880 "{}",
881 "\nGit-Iris Instruction Presets\n".bright_magenta().bold()
882 );
883
884 println!(
885 "{}",
886 "General Presets (usable for both commit and review):"
887 .bright_cyan()
888 .bold()
889 );
890 println!("{both_presets}\n");
891
892 if !commit_only_presets.is_empty() {
893 println!("{}", "Commit-specific Presets:".bright_green().bold());
894 println!("{commit_only_presets}\n");
895 }
896
897 if !review_only_presets.is_empty() {
898 println!("{}", "Review-specific Presets:".bright_blue().bold());
899 println!("{review_only_presets}\n");
900 }
901
902 println!("{}", "Usage:".bright_yellow().bold());
903 println!(" git-iris gen --preset <preset-key>");
904 println!(" git-iris review --preset <preset-key>");
905 println!("\nPreset types: [B] = Both commands, [C] = Commit only, [R] = Review only");
906
907 Ok(())
908}
909
910const HOOK_MARKER: &str = "# Installed by git-iris";
912
913pub fn handle_hook_command(action: &crate::cli::HookAction) -> Result<()> {
919 match action {
920 crate::cli::HookAction::Install { force } => handle_hook_install(*force),
921 crate::cli::HookAction::Uninstall => handle_hook_uninstall(),
922 }
923}
924
925fn handle_hook_install(force: bool) -> Result<()> {
927 use std::fs;
928
929 let hook_dir = find_git_hooks_dir()?;
930 let hook_path = hook_dir.join("prepare-commit-msg");
931
932 if hook_path
934 .symlink_metadata()
935 .is_ok_and(|m| m.file_type().is_symlink())
936 {
937 anyhow::bail!(
938 "Hook path is a symlink — refusing to write. Remove it manually: {}",
939 hook_path.display()
940 );
941 }
942
943 if hook_path.exists() {
945 let existing = fs::read_to_string(&hook_path).context("Failed to read existing hook")?;
946
947 if existing.contains(HOOK_MARKER) {
948 let (r, g, b) = colors::success();
949 println!(
950 "{}",
951 "✨ Git-iris hook is already installed.".truecolor(r, g, b)
952 );
953 return Ok(());
954 }
955
956 if !force {
957 let (r, g, b) = colors::warning();
958 println!(
959 "{}",
960 "⚠️ A prepare-commit-msg hook already exists and was not installed by git-iris."
961 .truecolor(r, g, b)
962 );
963 println!("{}", " Use --force to overwrite it.".truecolor(r, g, b));
964 return Ok(());
965 }
966 }
967
968 let hook_content = format!(
969 "#!/bin/sh\n{HOOK_MARKER}\n# Generates an AI commit message using git-iris\nexec git-iris gen --print > \"$1\"\n"
970 );
971
972 fs::write(&hook_path, hook_content).context("Failed to write hook file")?;
973
974 #[cfg(unix)]
976 {
977 use std::os::unix::fs::PermissionsExt;
978 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))
979 .context("Failed to set hook permissions")?;
980 }
981
982 let (r, g, b) = colors::success();
983 println!(
984 "{}",
985 "✨ prepare-commit-msg hook installed successfully!".truecolor(r, g, b)
986 );
987 println!(
988 " {}",
989 format!("Hook path: {}", hook_path.display()).truecolor(r, g, b)
990 );
991 println!(
992 " {}",
993 "AI commit messages will be generated automatically when you run 'git commit'."
994 .truecolor(r, g, b)
995 );
996
997 Ok(())
998}
999
1000fn handle_hook_uninstall() -> Result<()> {
1002 use std::fs;
1003
1004 let hook_dir = find_git_hooks_dir()?;
1005 let hook_path = hook_dir.join("prepare-commit-msg");
1006
1007 if hook_path
1009 .symlink_metadata()
1010 .is_ok_and(|m| m.file_type().is_symlink())
1011 {
1012 anyhow::bail!(
1013 "Hook path is a symlink — refusing to remove. Delete it manually: {}",
1014 hook_path.display()
1015 );
1016 }
1017
1018 if !hook_path.exists() {
1019 let (r, g, b) = colors::warning();
1020 println!("{}", "No prepare-commit-msg hook found.".truecolor(r, g, b));
1021 return Ok(());
1022 }
1023
1024 let content = fs::read_to_string(&hook_path).context("Failed to read hook file")?;
1025
1026 if !content.contains(HOOK_MARKER) {
1027 let (r, g, b) = colors::warning();
1028 println!(
1029 "{}",
1030 "⚠️ The existing prepare-commit-msg hook was not installed by git-iris."
1031 .truecolor(r, g, b)
1032 );
1033 println!(
1034 " {}",
1035 "Refusing to remove it. Delete it manually if needed.".truecolor(r, g, b)
1036 );
1037 return Ok(());
1038 }
1039
1040 fs::remove_file(&hook_path).context("Failed to remove hook file")?;
1041
1042 let (r, g, b) = colors::success();
1043 println!(
1044 "{}",
1045 "✨ prepare-commit-msg hook uninstalled successfully.".truecolor(r, g, b)
1046 );
1047
1048 Ok(())
1049}
1050
1051fn find_git_hooks_dir() -> Result<std::path::PathBuf> {
1057 use crate::git::GitRepo;
1058
1059 let repo_root = GitRepo::get_repo_root()
1060 .context("Not in a Git repository. Run this command from within a Git repository.")?;
1061
1062 let repo = git2::Repository::open(&repo_root).context("Failed to open Git repository")?;
1063
1064 let hooks_dir = repo
1066 .config()
1067 .ok()
1068 .and_then(|cfg| cfg.get_path("core.hooksPath").ok())
1069 .unwrap_or_else(|| repo.path().join("hooks"));
1070
1071 if !hooks_dir.exists() {
1073 std::fs::create_dir_all(&hooks_dir).context("Failed to create hooks directory")?;
1074 }
1075
1076 Ok(hooks_dir)
1077}