rusty_commit/commands/
githook.rs1use 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 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 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 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 fs::write(&hook_path, PREPARE_COMMIT_MSG_CONTENT).context("Failed to write hook file")?;
78
79 #[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 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 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 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 fs::write(&hook_path, COMMIT_MSG_CONTENT).context("Failed to write hook file")?;
127
128 #[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 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 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 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 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 if args.len() < 3 {
204 anyhow::bail!("Invalid hook arguments");
205 }
206
207 let commit_msg_file = &args[2];
208
209 let diff = git::get_staged_diff()?;
211 if diff.is_empty() {
212 return Ok(());
213 }
214
215 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 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 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 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 if !content.contains("hongkongkiwi/precommit-rusty-commit") {
275 println!("{}", "Pre-commit hook not found".yellow());
276 return Ok(());
277 }
278
279 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 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}