Skip to content

Commit 9ae228b

Browse files
committed
feat(signoff): add auto sign-off for commits
- implement should_signoff in prepare-commit-msg hook for env and config checks - update README with AIGITCOMMIT_SIGNOFF env var and git config options - add should_signoff method to Repository for centralized sign-off logic - refactor utils should_signoff to use repository and update save_to_file interface - enhance main.rs sign-off detection and file save error handling Signed-off-by: mingcheng <mingcheng@apache.org>
1 parent b1b90f5 commit 9ae228b

File tree

5 files changed

+98
-98
lines changed

5 files changed

+98
-98
lines changed

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ It inspects your diffs, summarizes the intent of your changes, and produces clea
2626
- Easy-to-use command-line interface with sensible defaults and confirm prompts (can be skipped with `--yes`).
2727
- Uses libgit2 via the `git2` crate, avoiding external git commands for improved security and performance.
2828
- Supports multiple OpenAI-compatible models and configurable API base, token, and proxy settings.
29-
- Optional auto sign-off of commits when `GIT_AUTO_SIGNOFF=true`.
29+
- Optional auto sign-off of commits when `AIGITCOMMIT_SIGNOFF=true` or `git config --bool aigitcommit.signoff true`.
3030
- Proxy support: HTTP and SOCKS5 (set via `OPENAI_API_PROXY`).
3131

3232

@@ -128,7 +128,16 @@ Before using AIGitCommit, export the following environment variables (for exampl
128128
- `OPENAI_API_BASE`: The API base URL (useful for alternative providers or local proxies).
129129
- `OPENAI_MODEL_NAME`: The model name to query (e.g., a GPT-compatible model).
130130
- `OPENAI_API_PROXY`: Optional. Proxy address for network access (e.g., `http://127.0.0.1:1080` or `socks://127.0.0.1:1086`).
131-
- `GIT_AUTO_SIGNOFF`: Optional. Set to `true` to append a Signed-off-by line to commits.
131+
- `AIGITCOMMIT_SIGNOFF`: Optional. Set to `true` (or any truthy value) to append a Signed-off-by line to commits.
132+
133+
You can also enable sign-off via Git configuration:
134+
135+
```bash
136+
git config aigitcommit.signoff true # repository only
137+
git config --global aigitcommit.signoff true
138+
```
139+
140+
The Git configuration takes precedence over the environment variable.
132141

133142
### Check the configuration
134143

hooks/prepare-commit-msg

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,56 @@
11
#!/bin/sh
22

3-
set -eu
4-
5-
log() {
6-
printf '%s\n' "$1" >&2
7-
}
8-
9-
COMMIT_MSG_FILE=${1:-}
10-
COMMIT_MSG_TYPE=${2:-}
11-
12-
if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
13-
log "Error: commit message file missing."
3+
# This script is a Git hook that generates a commit message using the `aigitcommit` command.
4+
# It is triggered before the commit message editor is opened.
5+
# Usage: Place this script in the `.git/hooks/` directory of your repository and make it executable.
6+
COMMIT_MSG_FILE=$1
7+
COMMIT_MSG_TYPE=$2
8+
REPO_ROOT=$(git rev-parse --show-toplevel)
9+
10+
# Check the repository root directory is valid
11+
# The `git rev-parse --show-toplevel` command returns the absolute path to the root of the repository
12+
# If the command fails, it means we are not in a Git repository
13+
if [ ! -d "$REPO_ROOT" ]; then
14+
echo "Error: Repository root not found."
1415
exit 1
1516
fi
1617

17-
if ! REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then
18-
log "Error: repository root not found."
19-
exit 1
18+
# Check if aigitcommit is installed
19+
if ! type aigitcommit >/dev/null 2>&1; then
20+
echo "Error: aigitcommit is not installed. Please install it first."
21+
exit 0
2022
fi
2123

22-
if ! command -v aigitcommit >/dev/null 2>&1; then
23-
log "Warning: aigitcommit not installed; skipping hook."
24+
# Only run for interactive commits without a pre-populated message
25+
if [ -n "$COMMIT_MSG_TYPE" ]; then
2426
exit 0
2527
fi
2628

27-
case "$COMMIT_MSG_TYPE" in
28-
""|"message") ;;
29-
*) exit 0 ;;
30-
esac
31-
32-
if git diff --cached --quiet --exit-code; then
33-
log "Warning: no staged changes detected; aborting commit."
34-
exit 1
29+
# Skip if the commit message already contains non-comment content
30+
if grep -Eq '^[[:space:]]*[^#[:space:]]' "$COMMIT_MSG_FILE"; then
31+
exit 0
3532
fi
3633

37-
TEMP_FILE=$(mktemp) || exit 1
38-
39-
cleanup() {
40-
rm -f "$TEMP_FILE"
41-
}
42-
43-
trap cleanup EXIT INT TERM
34+
# Get only the diff of what has already been staged
35+
GIT_DIFF_OUTPUT=$(git diff --cached)
4436

45-
log "Generating commit message with aigitcommit..."
46-
47-
if ! aigitcommit "$REPO_ROOT" --save "$TEMP_FILE" >/dev/null 2>&1; then
48-
log "Error: aigitcommit failed to generate commit message."
37+
# Check if there are any staged changes to commit
38+
if [ -z "$GIT_DIFF_OUTPUT" ]; then
39+
echo "No staged changes detected. Aborting."
4940
exit 1
5041
fi
5142

52-
if [ -s "$COMMIT_MSG_FILE" ]; then
53-
printf '\n' >>"$TEMP_FILE"
54-
cat "$COMMIT_MSG_FILE" >>"$TEMP_FILE"
43+
# Generate a temporary file for the commit message
44+
TEMP_FILE=$(mktemp)
45+
46+
# Execute aigitcommit to generate the commit message and append existing content
47+
echo "Generating commit message by using AIGitCommit..."
48+
echo "This may take a few seconds..."
49+
aigitcommit $REPO_ROOT --save $TEMP_FILE >/dev/null 2>&1
50+
if [ $? -ne 0 ]; then
51+
echo "Error: aigitcommit failed to generate commit message."
52+
rm -f $TEMP_FILE
53+
exit 1
5554
fi
5655

57-
mv -f "$TEMP_FILE" "$COMMIT_MSG_FILE"
58-
trap - EXIT INT TERM
56+
cat $COMMIT_MSG_FILE >>$TEMP_FILE && mv -f $TEMP_FILE $COMMIT_MSG_FILE

src/git/repository.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* File Created: 2025-10-16 15:07:05
1010
*
1111
* Modified By: mingcheng <mingcheng@apache.org>
12-
* Last Modified: 2025-11-06 15:00:29
12+
* Last Modified: 2025-11-07 11:09:51
1313
*/
1414

1515
use git2::{Oid, Repository as _Repo, RepositoryOpenFlags, Signature};
@@ -19,7 +19,7 @@ use std::fmt::{Display, Formatter};
1919
use tracing::{trace, warn};
2020

2121
use crate::git::message::GitMessage;
22-
use crate::utils::get_env;
22+
use crate::utils::{self, get_env};
2323

2424
/// Author information from git configuration
2525
pub struct Author {
@@ -242,6 +242,24 @@ impl Repository {
242242
Ok(result)
243243
}
244244

245+
/// Check if commit should be signed off
246+
/// Returns true when git config `aigitcommit.signoff` is enabled or the
247+
/// `AIGITCOMMIT_SIGNOFF` environment variable evaluates to true
248+
pub fn should_signoff(&self) -> bool {
249+
// Define the config key for signoff
250+
const SIGNOFF_KEY: &str = "aigitcommit.signoff";
251+
252+
// Check git config first
253+
if let Ok(config) = self.repository.config() {
254+
let signoff = config.get_bool(SIGNOFF_KEY).unwrap_or(false);
255+
trace!("✍️ git config signoff: {}", signoff);
256+
return signoff;
257+
}
258+
259+
// Fall back to environment variable
260+
utils::get_env_bool("AIGITCOMMIT_SIGNOFF")
261+
}
262+
245263
/// Get the list of filenames to exclude from diffs
246264
fn get_excluded_files() -> Vec<&'static str> {
247265
vec![

src/main.rs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
/*
2-
* Copyright (c) 2025 Hangzhou Guanwaii Technology Co,.Ltd.
1+
/*!
2+
* Copyright (c) 2025 Hangzhou Guanwaii Technology Co., Ltd.
33
*
44
* This source code is licensed under the MIT License,
55
* which is located in the LICENSE file in the source tree's root directory.
66
*
77
* File: main.rs
8-
* Author: mingcheng (mingcheng@apache.org)
8+
* Author: mingcheng <mingcheng@apache.org>
99
* File Created: 2025-03-01 17:17:30
1010
*
11-
* Modified By: mingcheng (mingcheng@apache.org)
12-
* Last Modified: 2025-09-26 15:45:37
11+
* Modified By: mingcheng <mingcheng@apache.org>
12+
* Last Modified: 2025-11-07 11:37:33
1313
*/
1414

1515
use aigitcommit::built_info::{PKG_NAME, PKG_VERSION};
@@ -26,7 +26,7 @@ use clap::Parser;
2626
use std::error::Error;
2727
use std::fs;
2828
use std::io::Write;
29-
use tracing::{Level, debug, trace};
29+
use tracing::{Level, debug, error, info, trace};
3030

3131
use aigitcommit::utils::{
3232
OutputFormat, check_env_variables, format_openai_error, get_env, save_to_file, should_signoff,
@@ -143,7 +143,7 @@ async fn main() -> std::result::Result<(), Box<dyn Error>> {
143143
.ok_or("Invalid response format: expected title and content separated by double newline")?;
144144

145145
// Detect auto signoff from environment variable or CLI flag
146-
let need_signoff = should_signoff(cli.signoff);
146+
let need_signoff = should_signoff(&repository, cli.signoff);
147147

148148
let message: GitMessage = GitMessage::new(&repository, title, content, need_signoff)?;
149149

@@ -163,11 +163,11 @@ async fn main() -> std::result::Result<(), Box<dyn Error>> {
163163

164164
// directly commit the changes to the repository if the --commit option is enabled
165165
if cli.commit {
166-
trace!("commit option is enabled, will commit the changes to the repository");
166+
trace!("commit option is enabled, will commit the changes directly to the repository");
167167

168168
if cli.yes || {
169169
cliclack::intro(format!("{PKG_NAME} v{PKG_VERSION}"))?;
170-
cliclack::confirm("Are you sure to commit with those changes?").interact()?
170+
cliclack::confirm("Are you sure to commit with generated message below?").interact()?
171171
} {
172172
match repository.commit(&message) {
173173
Ok(oid) => {
@@ -185,10 +185,16 @@ async fn main() -> std::result::Result<(), Box<dyn Error>> {
185185
// If the --save option is enabled, save the commit message to a file
186186
if !cli.save.is_empty() {
187187
trace!("save option is enabled, will save the commit message to a file");
188-
debug!("the save file path is {:?}", &cli.save);
189188

190-
save_to_file(&cli.save, &result)?;
191-
writeln!(std::io::stdout(), "commit message saved to {}", cli.save)?;
189+
// Save the commit message to the specified file
190+
match save_to_file(&cli.save, &message) {
191+
Ok(f) => {
192+
info!("commit message saved to file: {:?}", f);
193+
}
194+
Err(e) => {
195+
error!("failed to save commit message to file: {}", e);
196+
}
197+
}
192198
}
193199

194200
Ok(())

src/utils.rs

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
* File Created: 2025-10-21 11:34:11
1010
*
1111
* Modified By: mingcheng <mingcheng@apache.org>
12-
* Last Modified: 2025-11-06 12:24:04
12+
* Last Modified: 2025-11-07 11:22:27
1313
*/
1414

1515
use std::env;
1616
use std::io::Write;
1717
use tracing::debug;
1818

1919
use crate::git::message::GitMessage;
20+
use crate::git::repository::Repository;
2021

2122
/// Get environment variable with default value fallback
2223
pub fn get_env(key: &str, default: &str) -> String {
@@ -37,16 +38,9 @@ pub fn get_env_bool(key: &str) -> bool {
3738
}
3839

3940
/// Check if commit should be signed off
40-
/// Returns true if either CLI flag is set or GIT_AUTO_SIGNOFF environment variable is true
41-
pub fn should_signoff(cli_signoff: bool) -> bool {
42-
let result = cli_signoff || get_env_bool("GIT_AUTO_SIGNOFF");
43-
log::trace!(
44-
"should_signoff: cli_signoff={}, GIT_AUTO_SIGNOFF={}, result={}",
45-
cli_signoff,
46-
get_env_bool("GIT_AUTO_SIGNOFF"),
47-
result
48-
);
49-
result
41+
/// Returns true if either CLI flag is set or repository/git config/env enable sign-off
42+
pub fn should_signoff(repository: &Repository, cli_signoff: bool) -> bool {
43+
cli_signoff || repository.should_signoff()
5044
}
5145

5246
/// Output format for commit messages
@@ -122,7 +116,7 @@ pub fn check_env_variables() {
122116
"OPENAI_API_PROXY",
123117
"OPENAI_API_TIMEOUT",
124118
"OPENAI_API_MAX_TOKENS",
125-
"GIT_AUTO_SIGNOFF",
119+
"AIGITCOMMIT_SIGNOFF",
126120
]
127121
.iter()
128122
.for_each(|v| check_and_print_env(v));
@@ -146,12 +140,15 @@ pub fn format_openai_error(error: async_openai::error::OpenAIError) -> String {
146140
}
147141

148142
/// Save content to a file
149-
pub fn save_to_file(path: &str, content: &str) -> Result<(), Box<dyn std::error::Error>> {
143+
pub fn save_to_file(
144+
path: &str,
145+
content: &dyn std::fmt::Display,
146+
) -> Result<(), Box<dyn std::error::Error>> {
150147
use std::fs::File;
151148
use std::io::Write;
152149

153150
let mut file = File::create(path)?;
154-
file.write_all(content.as_bytes())?;
151+
file.write_all(content.to_string().as_bytes())?;
155152
file.flush()?;
156153
Ok(())
157154
}
@@ -188,32 +185,4 @@ Signed-off-by: mingcheng <mingcheng@apache.org>
188185
let result = get_env("NONEXISTENT_VAR_XYZ", "default_value");
189186
assert_eq!(result, "default_value");
190187
}
191-
192-
#[test]
193-
fn test_should_signoff() {
194-
// Test with CLI flag true
195-
assert!(should_signoff(true));
196-
197-
// Test with CLI flag false and no env var
198-
unsafe {
199-
std::env::remove_var("GIT_AUTO_SIGNOFF");
200-
}
201-
assert!(!should_signoff(false));
202-
203-
// Test with CLI flag false but env var true
204-
unsafe {
205-
std::env::set_var("GIT_AUTO_SIGNOFF", "1");
206-
}
207-
assert!(should_signoff(false));
208-
209-
unsafe {
210-
std::env::set_var("GIT_AUTO_SIGNOFF", "true");
211-
}
212-
assert!(should_signoff(false));
213-
214-
// Clean up
215-
unsafe {
216-
std::env::remove_var("GIT_AUTO_SIGNOFF");
217-
}
218-
}
219188
}

0 commit comments

Comments
 (0)