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