Skip to content

Commit cb2a9fe

Browse files
committed
feat(cli): Add install-hook subcommand 🔧
- introduce InstallHook command in CLI with repo_path argument Signed-off-by: mingcheng <mingcheng@apache.org>
1 parent facc4f5 commit cb2a9fe

3 files changed

Lines changed: 80 additions & 12 deletions

File tree

src/cli.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
*/
1414

1515
use crate::built_info;
16-
use clap::Parser;
16+
use clap::{Parser, Subcommand};
1717

1818
#[derive(Debug, Parser)]
1919
#[command(name = built_info::PKG_NAME, about = built_info::PKG_DESCRIPTION, version = built_info::PKG_VERSION, author = built_info::PKG_AUTHORS)]
2020
pub struct Cli {
21+
#[command(subcommand)]
22+
pub command: Option<Command>,
2123
#[arg(
2224
default_value = ".",
2325
help = r#"Specify the file path to repository directory.
@@ -110,5 +112,21 @@ If not specified, the current directory will be used"#,
110112
pub save: String,
111113
}
112114

115+
#[derive(Debug, Subcommand)]
116+
pub enum Command {
117+
#[command(
118+
name = "install-hook",
119+
about = "Install git hook script into the specified repository directory"
120+
)]
121+
InstallHook {
122+
#[arg(
123+
default_value = ".",
124+
help = "Repository directory to install the git hook into",
125+
required = false
126+
)]
127+
repo_path: String,
128+
},
129+
}
130+
113131
#[cfg(test)]
114132
mod tests {}

src/main.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
*/
1414

1515
use aigitcommit::built_info::{PKG_NAME, PKG_VERSION};
16-
use aigitcommit::cli::Cli;
16+
use aigitcommit::cli::{Cli, Command};
1717
use aigitcommit::git::message::GitMessage;
1818
use aigitcommit::git::repository::Repository;
1919
use aigitcommit::openai::OpenAI;
@@ -27,23 +27,35 @@ use std::io::Write;
2727
use std::path::Path;
2828
use tracing::{Level, debug, error, info, trace};
2929

30-
use aigitcommit::utils::{OutputFormat, check_env_variables, env, save_to_file, should_signoff};
30+
use aigitcommit::utils::{
31+
self, OutputFormat, check_env_variables, env, install_hook, save_to_file, should_signoff,
32+
};
3133

3234
// Constants for better performance and maintainability
3335
const DEFAULT_MODEL: &str = "gpt-5";
3436
const DEFAULT_LOG_COUNT: usize = 5;
3537
const SYSTEM_PROMPT: &str = include_str!("../templates/system.txt");
3638

37-
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
39+
// Constant for git hook installation
40+
const HOOK_NAME: &str = "prepare-commit-msg";
41+
const HOOK_CONTENT: &str = include_str!("../hooks/prepare-commit-msg");
3842

3943
#[tokio::main]
40-
async fn main() -> Result<()> {
44+
async fn main() -> utils::Result<()> {
4145
// Parse command line arguments
4246
let cli = Cli::parse();
4347

4448
// Initialize logging
4549
init_logging(cli.verbose);
4650

51+
// Handle subcommands early and exit
52+
if let Some(Command::InstallHook { repo_path }) = &cli.command {
53+
trace!("install-hook subcommand invoked");
54+
install_hook(repo_path, HOOK_NAME, HOOK_CONTENT)?;
55+
println!("git hook `{}` has been installed successfully.", HOOK_NAME);
56+
return Ok(());
57+
}
58+
4759
// Get the specified model name from environment variable, default constant
4860
let model_name = env::get("OPENAI_MODEL_NAME", DEFAULT_MODEL);
4961

@@ -197,7 +209,7 @@ fn init_logging(verbose: bool) {
197209
}
198210

199211
/// Check if the model is available
200-
async fn check_model_availability(client: &OpenAI, model_name: &str) -> Result<()> {
212+
async fn check_model_availability(client: &OpenAI, model_name: &str) -> utils::Result<()> {
201213
client.check_model(model_name).await?;
202214
println!(
203215
"the model name `{}` is available, {} is ready for use!",

src/utils.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@
1414

1515
use crate::git::message::GitMessage;
1616
use crate::git::repository::Repository;
17+
use std::fs;
1718
use std::io::Write;
19+
use tracing::trace;
1820

19-
// Get environment variable with default value fallback
21+
/// Generic result type for utility functions
22+
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
23+
24+
/// Get environment variable with default value fallback
2025
pub mod env {
2126
use std::env;
2227

@@ -84,7 +89,7 @@ impl OutputFormat {
8489
}
8590

8691
/// Write the message in the specified format
87-
pub fn write(&self, message: &GitMessage) -> Result<(), Box<dyn std::error::Error>> {
92+
pub fn write(&self, message: &GitMessage) -> Result<()> {
8893
match self {
8994
Self::Stdout => {
9095
writeln!(std::io::stdout(), "{}", message)?;
@@ -149,10 +154,7 @@ pub fn format_openai_error(error: async_openai::error::OpenAIError) -> String {
149154
}
150155

151156
/// Save content to a file
152-
pub fn save_to_file(
153-
path: &str,
154-
content: &dyn std::fmt::Display,
155-
) -> Result<(), Box<dyn std::error::Error>> {
157+
pub fn save_to_file(path: &str, content: &dyn std::fmt::Display) -> Result<()> {
156158
use std::fs::File;
157159
use std::io::Write;
158160

@@ -162,6 +164,36 @@ pub fn save_to_file(
162164
Ok(())
163165
}
164166

167+
/// Install the prepare-commit-msg git hook into the target repository.
168+
pub fn install_hook(path: &str, name: &str, content: &str) -> Result<()> {
169+
let repo_dir =
170+
fs::canonicalize(path).map_err(|e| format!("resolve repository path failed: {e}"))?;
171+
let git_dir = repo_dir.join(".git");
172+
if !git_dir.is_dir() {
173+
return Err("not a git repository (missing .git directory)".into());
174+
}
175+
176+
let hooks_dir = git_dir.join("hooks");
177+
fs::create_dir_all(&hooks_dir).map_err(|e| format!("create hooks dir failed: {e}"))?;
178+
179+
let hook_path = hooks_dir.join(name);
180+
fs::write(&hook_path, content).map_err(|e| format!("write hook file failed: {e}"))?;
181+
182+
#[cfg(unix)]
183+
{
184+
use std::os::unix::fs::PermissionsExt;
185+
let mut perms = fs::metadata(&hook_path)
186+
.map_err(|e| format!("get hook metadata failed: {e}"))?
187+
.permissions();
188+
perms.set_mode(0o755);
189+
fs::set_permissions(&hook_path, perms)
190+
.map_err(|e| format!("set executable permission failed: {e}"))?;
191+
}
192+
193+
trace!("hook installed at {:?}", hook_path);
194+
Ok(())
195+
}
196+
165197
#[cfg(test)]
166198
mod tests {
167199
use super::*;
@@ -194,4 +226,10 @@ Signed-off-by: mingcheng <mingcheng@apache.org>
194226
let result = env::get("NONEXISTENT_VAR_XYZ", "default_value");
195227
assert_eq!(result, "default_value");
196228
}
229+
230+
#[test]
231+
fn test_install_hook() {
232+
let result = install_hook(".", "test-hook", "#!/bin/sh\necho 'Test Hook'");
233+
assert!(result.is_ok());
234+
}
197235
}

0 commit comments

Comments
 (0)