Skip to main content

rusty_commit/commands/
githook.rs

1use anyhow::{Context, Result};
2use colored::Colorize;
3use std::fs;
4use std::io::Write;
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7use std::path::Path;
8
9use crate::cli::{HookAction, HookCommand};
10use crate::git;
11
12const PREPARE_COMMIT_MSG_HOOK: &str = "prepare-commit-msg";
13const PREPARE_COMMIT_MSG_CONTENT: &str = r#"#!/bin/sh
14# Rusty Commit Git Hook
15exec < /dev/tty && rco --hook "$@" || true
16"#;
17
18const COMMIT_MSG_HOOK: &str = "commit-msg";
19const COMMIT_MSG_CONTENT: &str = r#"#!/bin/sh
20# Rusty Commit Git Hook - Non-interactive commit message generation
21# This hook generates a commit message and lets you edit it
22rco --hook "$@" || true
23"#;
24
25const PRECOMMIT_HOOK_CONTENT: &str = r#"- repo: https://github.com/hongkongkiwi/precommit-rusty-commit
26  rev: v1.0.18  # TODO: Update with the latest tag
27  hooks:
28    - id: rusty-commit-msg"#;
29
30pub async fn execute(cmd: HookCommand) -> Result<()> {
31    match cmd.action {
32        HookAction::PrepareCommitMsg => install_prepare_commit_msg_hook(),
33        HookAction::CommitMsg => install_commit_msg_hook(),
34        HookAction::Unset => uninstall_all_hooks(),
35        HookAction::Precommit { set, unset } => {
36            if set {
37                install_precommit_hook()?;
38            } else if unset {
39                uninstall_precommit_hook()?;
40            } else {
41                anyhow::bail!("Please specify either --set or --unset for pre-commit hooks");
42            }
43            Ok(())
44        }
45    }
46}
47
48fn install_prepare_commit_msg_hook() -> Result<()> {
49    git::assert_git_repo()?;
50
51    let repo_root = git::get_repo_root()?;
52    let hooks_dir = Path::new(&repo_root).join(".git").join("hooks");
53
54    // Create hooks directory if it doesn't exist
55    fs::create_dir_all(&hooks_dir).context("Failed to create .git/hooks directory")?;
56
57    let hook_path = hooks_dir.join(PREPARE_COMMIT_MSG_HOOK);
58
59    // Check if hook already exists
60    if hook_path.exists() {
61        let existing_content = fs::read_to_string(&hook_path)?;
62        if existing_content.contains("rco --hook") {
63            println!("{}", "prepare-commit-msg hook already installed".yellow());
64            return Ok(());
65        }
66
67        // Backup existing hook
68        let backup_path = hook_path.with_extension("backup");
69        fs::copy(&hook_path, &backup_path).context("Failed to backup existing hook")?;
70        println!(
71            "{}",
72            format!("Backed up existing hook to {}", backup_path.display()).yellow()
73        );
74    }
75
76    // Write the hook file
77    fs::write(&hook_path, PREPARE_COMMIT_MSG_CONTENT).context("Failed to write hook file")?;
78
79    // Make it executable
80    #[cfg(unix)]
81    {
82        let mut perms = fs::metadata(&hook_path)?.permissions();
83        perms.set_mode(0o755);
84        fs::set_permissions(&hook_path, perms).context("Failed to make hook executable")?;
85    }
86
87    println!(
88        "{}",
89        "✅ prepare-commit-msg hook installed successfully!".green()
90    );
91    println!("The hook will run automatically when you use 'git commit'");
92    println!("Note: This hook is interactive (prompts for confirmation)");
93
94    Ok(())
95}
96
97fn install_commit_msg_hook() -> Result<()> {
98    git::assert_git_repo()?;
99
100    let repo_root = git::get_repo_root()?;
101    let hooks_dir = Path::new(&repo_root).join(".git").join("hooks");
102
103    // Create hooks directory if it doesn't exist
104    fs::create_dir_all(&hooks_dir).context("Failed to create .git/hooks directory")?;
105
106    let hook_path = hooks_dir.join(COMMIT_MSG_HOOK);
107
108    // Check if hook already exists
109    if hook_path.exists() {
110        let existing_content = fs::read_to_string(&hook_path)?;
111        if existing_content.contains("rco --hook") {
112            println!("{}", "commit-msg hook already installed".yellow());
113            return Ok(());
114        }
115
116        // Backup existing hook
117        let backup_path = hook_path.with_extension("backup");
118        fs::copy(&hook_path, &backup_path).context("Failed to backup existing hook")?;
119        println!(
120            "{}",
121            format!("Backed up existing hook to {}", backup_path.display()).yellow()
122        );
123    }
124
125    // Write the hook file
126    fs::write(&hook_path, COMMIT_MSG_CONTENT).context("Failed to write hook file")?;
127
128    // Make it executable
129    #[cfg(unix)]
130    {
131        let mut perms = fs::metadata(&hook_path)?.permissions();
132        perms.set_mode(0o755);
133        fs::set_permissions(&hook_path, perms).context("Failed to make hook executable")?;
134    }
135
136    println!("{}", "✅ commit-msg hook installed successfully!".green());
137    println!("This hook generates commit messages without prompting (non-interactive)");
138
139    Ok(())
140}
141
142fn uninstall_all_hooks() -> Result<()> {
143    git::assert_git_repo()?;
144
145    let repo_root = git::get_repo_root()?;
146    let hooks_dir = Path::new(&repo_root).join(".git").join("hooks");
147
148    let mut uninstalled = Vec::new();
149
150    // Uninstall prepare-commit-msg
151    let prepare_hook_path = hooks_dir.join(PREPARE_COMMIT_MSG_HOOK);
152    if prepare_hook_path.exists() {
153        let content = fs::read_to_string(&prepare_hook_path)?;
154        if content.contains("rco --hook") {
155            fs::remove_file(&prepare_hook_path)
156                .context("Failed to remove prepare-commit-msg hook")?;
157            uninstalled.push("prepare-commit-msg");
158
159            // Restore backup if it exists
160            let backup_path = prepare_hook_path.with_extension("backup");
161            if backup_path.exists() {
162                fs::rename(&backup_path, &prepare_hook_path).ok();
163            }
164        }
165    }
166
167    // Uninstall commit-msg
168    let commit_msg_path = hooks_dir.join(COMMIT_MSG_HOOK);
169    if commit_msg_path.exists() {
170        let content = fs::read_to_string(&commit_msg_path)?;
171        if content.contains("rco --hook") {
172            fs::remove_file(&commit_msg_path).context("Failed to remove commit-msg hook")?;
173            uninstalled.push("commit-msg");
174
175            // Restore backup if it exists
176            let backup_path = commit_msg_path.with_extension("backup");
177            if backup_path.exists() {
178                fs::rename(&backup_path, &commit_msg_path).ok();
179            }
180        }
181    }
182
183    if uninstalled.is_empty() {
184        println!("{}", "No Rusty Commit hooks installed".yellow());
185    } else {
186        println!(
187            "{}",
188            format!("✅ Uninstalled hooks: {}", uninstalled.join(", ")).green()
189        );
190    }
191
192    Ok(())
193}
194
195pub fn is_hook_called(args: &[String]) -> bool {
196    args.iter().any(|arg| arg == "--hook")
197}
198
199pub async fn prepare_commit_msg_hook(args: &[String]) -> Result<()> {
200    // This function is called when git invokes the prepare-commit-msg hook
201    // It should generate a commit message and write it to the file specified in args
202
203    if args.len() < 3 {
204        anyhow::bail!("Invalid hook arguments");
205    }
206
207    let commit_msg_file = &args[2];
208
209    // Get staged diff
210    let diff = git::get_staged_diff()?;
211    if diff.is_empty() {
212        return Ok(());
213    }
214
215    // Generate commit message
216    let config = crate::config::Config::load()?;
217    let provider = crate::providers::create_provider(&config)?;
218    let message = provider
219        .generate_commit_message(&diff, None, false, &config)
220        .await?;
221
222    // Write to commit message file
223    fs::write(commit_msg_file, message).context("Failed to write commit message")?;
224
225    Ok(())
226}
227
228fn install_precommit_hook() -> Result<()> {
229    git::assert_git_repo()?;
230
231    let repo_root = git::get_repo_root()?;
232    let config_path = Path::new(&repo_root).join(".pre-commit-config.yaml");
233
234    // Check if hook already exists
235    if config_path.exists() {
236        let content = fs::read_to_string(&config_path)?;
237        if content.contains("hongkongkiwi/precommit-rusty-commit") {
238            println!("{}", "Pre-commit hook already installed".yellow());
239            println!("To update, run: pre-commit autoupdate");
240            return Ok(());
241        }
242    }
243
244    // Create or append to .pre-commit-config.yaml
245    let hook_entry = format!("\n{}", PRECOMMIT_HOOK_CONTENT);
246    fs::OpenOptions::new()
247        .create(true)
248        .append(true)
249        .open(&config_path)
250        .and_then(|mut f| f.write_all(hook_entry.as_bytes()))
251        .context("Failed to write to .pre-commit-config.yaml")?;
252
253    println!("{}", "✅ Pre-commit hook installed successfully!".green());
254    println!("Run 'pre-commit install' to activate the hook");
255    println!("Then use 'git commit' as normal - the hook will generate commit messages");
256
257    Ok(())
258}
259
260fn uninstall_precommit_hook() -> Result<()> {
261    git::assert_git_repo()?;
262
263    let repo_root = git::get_repo_root()?;
264    let config_path = Path::new(&repo_root).join(".pre-commit-config.yaml");
265
266    if !config_path.exists() {
267        println!("{}", "No .pre-commit-config.yaml found".yellow());
268        return Ok(());
269    }
270
271    let content = fs::read_to_string(&config_path)?;
272
273    // Check if our hook exists
274    if !content.contains("hongkongkiwi/precommit-rusty-commit") {
275        println!("{}", "Pre-commit hook not found".yellow());
276        return Ok(());
277    }
278
279    // Remove the hook entry (lines containing our repo)
280    let new_content: Vec<&str> = content
281        .lines()
282        .filter(|line| {
283            !line
284                .trim_start()
285                .starts_with("hongkongkiwi/precommit-rusty-commit")
286                && !line.trim_start().starts_with("rev:")
287                && !line.trim_start().starts_with("hooks:")
288                && !line.trim_start().starts_with("- id:")
289        })
290        .collect();
291
292    // Clean up multiple blank lines
293    let cleaned: Vec<&str> = new_content
294        .iter()
295        .filter(|line| !line.trim().is_empty())
296        .copied()
297        .collect();
298
299    fs::write(&config_path, cleaned.join("\n") + "\n")
300        .context("Failed to update .pre-commit-config.yaml")?;
301
302    println!("{}", "✅ Pre-commit hook uninstalled successfully!".green());
303
304    Ok(())
305}