Skip to content

Commit b06b59f

Browse files
committed
Merge branch 'release/v1.9.0'
2 parents 8725b20 + 88a7a65 commit b06b59f

10 files changed

Lines changed: 747 additions & 534 deletions

File tree

‎Cargo.lock‎

Lines changed: 247 additions & 151 deletions
Large diffs are not rendered by default.

‎Cargo.toml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "aigitcommit"
3-
version = "1.7.2"
3+
version = "1.9.0"
44
edition = "2024"
55
description = "A simple git commit message generator by OpenAI compaction model."
66
license-file = "LICENSE"
@@ -9,11 +9,11 @@ keywords = ["git", "tools", "ai", "development"]
99
authors = ["mingcheng <mingcheng@apache.org>"]
1010
repository = "https://github.com/mingcheng/aigitcommit"
1111
homepage = "https://github.com/mingcheng/aigitcommit"
12-
exclude = ["assets/*", "hooks/*"]
12+
exclude = ["assets/*"]
1313
categories = ["command-line-utilities", "development-tools"]
1414

1515
[dependencies]
16-
reqwest = { version = "0.12", features = [
16+
reqwest = { version = "0.13", features = [
1717
"json",
1818
"stream",
1919
"socks",
@@ -23,8 +23,8 @@ reqwest = { version = "0.12", features = [
2323
"charset",
2424
"native-tls-vendored",
2525
] }
26-
askama = "0.14"
27-
async-openai = "0.30"
26+
askama = "0.16"
27+
async-openai = { version = "0.37", features = ["chat-completion", "model"] }
2828
git2 = "0.20"
2929
tokio = { version = "1.52.2", features = ["full"] }
3030
serde = { version = "1.0", features = ["derive"] }
@@ -35,7 +35,7 @@ tracing = "0.1"
3535
tracing-subscriber = "0.3"
3636
arboard = "3.6"
3737
tabled = "0.20"
38-
cliclack = "0.3.8"
38+
cliclack = "0.5.4"
3939
regex = "1.12.3"
4040

4141
[[bin]]

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ Install into a specific repository:
131131
aigitcommit install-hook /path/to/repo
132132
```
133133

134+
If a `prepare-commit-msg` hook already exists, it is renamed to
135+
`prepare-commit-msg.bak` before the new hook is written, so your previous
136+
hook is preserved (any prior `.bak` is overwritten).
137+
134138
Prerequisite: `aigitcommit` is installed and available in your `PATH`.
135139

136140
**Prerequisites**
@@ -191,8 +195,7 @@ Configure AIGitCommit by setting these environment variables (in your shell prof
191195

192196
**Optional:**
193197
- `OPENAI_API_PROXY`: HTTP/SOCKS5 proxy URL (e.g., `http://127.0.0.1:1080`, `socks5://127.0.0.1:1086`)
194-
- `OPENAI_API_TIMEOUT`: Request timeout in seconds (default: 30)
195-
- `OPENAI_API_MAX_TOKENS`: Maximum tokens in response (default: model-specific)
198+
- `OPENAI_API_TIMEOUT`: HTTP request timeout in seconds. Unset, empty, invalid, or `0` means "use the HTTP client default (no timeout)"
196199
- `AIGITCOMMIT_SIGNOFF`: Enable auto sign-off (`true`, `1`, `yes`, `on`)
197200

198201
**Example configuration:**

‎src/cache.rs‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,24 @@ mod tests {
191191
}
192192

193193
#[test]
194-
fn put_then_get_roundtrip() {
195-
let tmp = std::env::temp_dir().join(format!(
196-
"aigitcommit-cache-roundtrip-{}",
197-
std::process::id()
198-
));
194+
fn clear_removes_entries_and_returns_count() {
195+
let tmp =
196+
std::env::temp_dir().join(format!("aigitcommit-cache-clear-{}", std::process::id()));
199197
let _ = fs::remove_dir_all(&tmp);
200198
let cache = Cache::new(&tmp);
201-
cache.put("abc", "title\n\nbody");
202-
assert_eq!(cache.get("abc").as_deref(), Some("title\n\nbody"));
203-
let _ = cache.clear();
199+
cache.put("a", "1");
200+
cache.put("b", "2");
201+
cache.put("c", "3");
202+
let removed = cache.clear().unwrap();
203+
assert_eq!(removed, 3);
204+
// Subsequent clear on a non-existent dir is a no-op.
205+
assert_eq!(cache.clear().unwrap(), 0);
206+
}
207+
208+
#[test]
209+
fn build_key_format_is_16_hex_chars() {
210+
let k = Cache::build_key("m", "s", &[], &[]);
211+
assert_eq!(k.len(), 16);
212+
assert!(k.chars().all(|c| c.is_ascii_hexdigit()));
204213
}
205214
}

‎src/cli.rs‎

Lines changed: 54 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -15,134 +15,96 @@
1515
use crate::built_info;
1616
use clap::{Parser, Subcommand};
1717

18+
/// Command-line interface for `aigitcommit`.
19+
///
20+
/// Boolean flags omit `default_value_t = false` because that is already the
21+
/// default for `bool`. Optional positional arguments omit `required = false`
22+
/// for the same reason. This keeps the attribute noise down and makes the
23+
/// definition easier to scan.
1824
#[derive(Debug, Parser)]
19-
#[command(name = built_info::PKG_NAME, about = built_info::PKG_DESCRIPTION, version = built_info::PKG_VERSION, author = built_info::PKG_AUTHORS)]
25+
#[command(
26+
name = built_info::PKG_NAME,
27+
about = built_info::PKG_DESCRIPTION,
28+
version = built_info::PKG_VERSION,
29+
author = built_info::PKG_AUTHORS,
30+
)]
2031
pub struct Cli {
2132
#[command(subcommand)]
2233
pub command: Option<Command>,
23-
#[arg(
24-
default_value = ".",
25-
help = r#"Specify the file path to repository directory.
26-
If not specified, the current directory will be used"#,
27-
required = false
28-
)]
34+
35+
/// Path to the repository directory. Defaults to the current directory.
36+
#[arg(default_value = ".")]
2937
pub repo_path: String,
3038

31-
#[arg(
32-
long,
33-
short,
34-
help = "Verbose mode",
35-
default_value_t = false,
36-
required = false
37-
)]
39+
/// Enable verbose (TRACE-level) logging.
40+
#[arg(long, short)]
3841
pub verbose: bool,
3942

40-
#[arg(
41-
long,
42-
help = "Check the openai api key and model name whether is available",
43-
default_value_t = false,
44-
required = false
45-
)]
43+
/// Verify that the configured OpenAI model is available.
44+
#[arg(long)]
4645
pub check_model: bool,
4746

48-
#[arg(
49-
long,
50-
help = "Prompt the commit after generating the message",
51-
default_value_t = false,
52-
required = false
53-
)]
47+
/// Prompt to commit after generating the message.
48+
#[arg(long)]
5449
pub commit: bool,
5550

56-
#[arg(
57-
long,
58-
help = "Mark whether the commit is a signoff commit",
59-
default_value_t = false,
60-
required = false
61-
)]
51+
/// Append a `Signed-off-by` trailer to the commit message.
52+
#[arg(long)]
6253
pub signoff: bool,
6354

64-
#[arg(
65-
long,
66-
short,
67-
help = "Accept the commit message without prompting",
68-
default_value_t = false,
69-
required = false
70-
)]
55+
/// Accept the generated commit message without prompting.
56+
#[arg(long, short)]
7157
pub yes: bool,
7258

73-
#[arg(
74-
long,
75-
help = "Copy the commit message to clipboard",
76-
default_value_t = false,
77-
required = false
78-
)]
59+
/// Copy the generated commit message to the system clipboard.
60+
#[arg(long)]
7961
pub copy_to_clipboard: bool,
8062

81-
#[arg(
82-
long,
83-
help = "Print the commit message in JSON format",
84-
default_value_t = false,
85-
required = false
86-
)]
63+
/// Print the commit message as JSON.
64+
#[arg(long)]
8765
pub json: bool,
8866

89-
#[arg(
90-
long,
91-
help = "Print the commit message in a table format",
92-
default_value_t = false,
93-
required = false
94-
)]
67+
/// Print the commit message as plain text instead of a table.
68+
#[arg(long)]
9569
pub no_table: bool,
9670

97-
#[arg(
98-
long,
99-
help = "Check current environment variables for OpenAI API key and model name",
100-
default_value_t = false,
101-
required = false
102-
)]
71+
/// Print the values of the OpenAI-related environment variables and exit.
72+
#[arg(long)]
10373
pub check_env: bool,
10474

105-
#[arg(
106-
long,
107-
short,
108-
default_value = "",
109-
help = "Save the commit message to a file",
110-
required = false
111-
)]
75+
/// Save the generated commit message to the given file.
76+
#[arg(long, short, default_value = "")]
11277
pub save: String,
11378

114-
#[arg(
115-
long,
116-
help = "Disable the local cache and always request a fresh message from the API",
117-
default_value_t = false,
118-
required = false
119-
)]
79+
/// Bypass the local cache and always request a fresh message from the API.
80+
#[arg(long)]
12081
pub no_cache: bool,
12182

122-
#[arg(
123-
long,
124-
help = "Clear the local cache for the current repository and exit",
125-
default_value_t = false,
126-
required = false
127-
)]
83+
/// Clear the local cache for the current repository and exit.
84+
#[arg(long)]
12885
pub clear_cache: bool,
12986
}
13087

13188
#[derive(Debug, Subcommand)]
13289
pub enum Command {
133-
#[command(
134-
name = "install-hook",
135-
about = "Install git hook script into the specified repository directory"
136-
)]
90+
/// Install the `prepare-commit-msg` git hook into the given repository.
91+
#[command(name = "install-hook")]
13792
InstallHook {
138-
#[arg(
139-
default_value = ".",
140-
help = "Repository directory to install the git hook into",
141-
required = false
142-
)]
93+
/// Repository directory to install the git hook into.
94+
#[arg(default_value = ".")]
14395
repo_path: String,
14496
},
14597
}
14698

14799
#[cfg(test)]
148-
mod tests {}
100+
mod tests {
101+
use super::*;
102+
use clap::CommandFactory;
103+
104+
#[test]
105+
fn cli_definition_is_valid() {
106+
// Catches programmer mistakes (duplicate short flags, bad attrs)
107+
// at test time instead of at first user invocation.
108+
Cli::command().debug_assert();
109+
}
110+
}

‎src/git/message.rs‎

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

1515
use crate::git::repository::Repository;
16+
use std::fmt::Write as _;
1617
use std::{error::Error, fmt::Display};
1718
use tracing::trace;
1819

@@ -71,12 +72,13 @@ impl GitMessage {
7172
if signoff {
7273
trace!("adding Signed-off-by line to commit message");
7374
let author = repository.get_author()?;
74-
75-
// Ensure proper spacing before signoff
76-
final_content.push_str(&format!(
75+
// Writing into the existing String avoids the intermediate alloc
76+
// that `format!` + `push_str` would create.
77+
write!(
78+
final_content,
7779
"\n\nSigned-off-by: {} <{}>",
7880
author.name, author.email
79-
));
81+
)?;
8082
}
8183

8284
trace!("created commit message with title: {}", title_trimmed);
@@ -87,21 +89,51 @@ impl GitMessage {
8789
content: final_content,
8890
})
8991
}
92+
}
9093

91-
/// Check if the commit message is empty
92-
///
93-
/// Returns true only if both title and content are empty strings
94-
pub fn is_empty(&self) -> bool {
95-
self.title.is_empty() && self.content.is_empty()
94+
#[cfg(test)]
95+
mod tests {
96+
use super::*;
97+
use std::env;
98+
99+
fn setup() -> Option<Repository> {
100+
let path = env::var("TEST_REPO_PATH").unwrap_or_else(|_| ".".to_string());
101+
Repository::new(&path).ok()
102+
}
103+
104+
#[test]
105+
fn rejects_empty_title() {
106+
let Some(repo) = setup() else { return };
107+
let err = GitMessage::new(&repo, " ", "body", false).unwrap_err();
108+
assert!(err.to_string().contains("title"));
109+
}
110+
111+
#[test]
112+
fn rejects_empty_content() {
113+
let Some(repo) = setup() else { return };
114+
let err = GitMessage::new(&repo, "title", " ", false).unwrap_err();
115+
assert!(err.to_string().contains("content"));
96116
}
97117

98-
/// Get the total character count of the commit message
99-
pub fn char_count(&self) -> usize {
100-
self.title.len() + 2 + self.content.len() // +2 for "\n\n"
118+
#[test]
119+
fn trims_inputs_and_formats_display() {
120+
let Some(repo) = setup() else { return };
121+
let msg = GitMessage::new(&repo, " feat: x ", " body line ", false).unwrap();
122+
assert_eq!(msg.title, "feat: x");
123+
assert_eq!(msg.content, "body line");
124+
assert_eq!(format!("{msg}"), "feat: x\n\nbody line");
101125
}
102126

103-
/// Get the number of lines in the commit message
104-
pub fn line_count(&self) -> usize {
105-
1 + self.content.lines().count() // +1 for title, +blank line is implicit
127+
#[test]
128+
fn appends_signoff_line_when_requested() {
129+
let Some(repo) = setup() else { return };
130+
let msg = GitMessage::new(&repo, "feat: x", "body", true).unwrap();
131+
assert!(
132+
msg.content.contains("Signed-off-by:"),
133+
"signoff line missing: {}",
134+
msg.content
135+
);
136+
// Signoff is separated from body by a blank line.
137+
assert!(msg.content.contains("\n\nSigned-off-by:"));
106138
}
107139
}

0 commit comments

Comments
 (0)