Skip to content

Commit c30242c

Browse files
committed
feat: add ai-powered commit message helpers
1 parent fd46b9a commit c30242c

File tree

6 files changed

+536
-2
lines changed

6 files changed

+536
-2
lines changed

commit_helpers.ron.example

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Example configuration for GitUI commit helpers
2+
// Copy this file to your GitUI config directory as "commit_helpers.ron"
3+
//
4+
// Config directory locations:
5+
// - Linux: ~/.config/gitui/
6+
// - macOS: ~/Library/Application Support/gitui/
7+
// - Windows: %APPDATA%/gitui/
8+
//
9+
// Template variables available in commands:
10+
// - {staged_diff} - Output of 'git diff --staged --no-color'
11+
// - {staged_files} - List of staged files from 'git diff --staged --name-only'
12+
// - {branch_name} - Current branch name
13+
//
14+
// Helper navigation:
15+
// - Ctrl+G: Open helper selection (if multiple helpers configured)
16+
// - Arrow keys: Navigate between helpers in selection mode
17+
// - Enter: Execute selected helper
18+
// - Hotkeys: Press configured hotkey to run helper directly
19+
// - ESC: Cancel selection or running helper
20+
21+
CommitHelpers(
22+
helpers: [
23+
// Claude AI helper example (using template variables)
24+
CommitHelper(
25+
name: "Claude AI",
26+
command: "echo '{staged_diff}' | claude -p 'Based on the following git diff of staged changes, generate a concise, conventional commit message. Follow this format:\n\n<type>: <description>\n\nWhere <type> is one of: feat, fix, docs, style, refactor, test, chore\nThe <description> should be lowercase and concise (50 chars or less).\n\nFor multiple types of changes, use the most significant one.\nOutput ONLY the commit message, no explanation or quotes.'",
27+
description: Some("Generate conventional commit messages using Claude AI"),
28+
hotkey: Some('c'),
29+
timeout_secs: Some(30),
30+
),
31+
32+
// OpenAI ChatGPT helper example (using template variables)
33+
CommitHelper(
34+
name: "ChatGPT",
35+
command: "echo '{staged_diff}' | chatgpt 'Generate a concise conventional commit message for this diff. Format: <type>: <description>. Types: feat, fix, docs, style, refactor, test, chore. Max 50 chars.'",
36+
description: Some("Generate commit messages using ChatGPT"),
37+
hotkey: Some('g'),
38+
timeout_secs: Some(25),
39+
),
40+
41+
// Local AI helper example (using template variables)
42+
CommitHelper(
43+
name: "Local AI",
44+
command: "echo '{staged_diff}' | ollama run codellama 'Generate a conventional commit message for this git diff. Use format: type: description. Keep under 50 characters.'",
45+
description: Some("Generate commit messages using local Ollama model"),
46+
hotkey: Some('l'),
47+
timeout_secs: Some(45),
48+
),
49+
50+
// Branch-specific helper example
51+
CommitHelper(
52+
name: "Branch Fix",
53+
command: "echo 'fix({branch_name}): address issues in {staged_files}'",
54+
description: Some("Generate branch-specific fix message"),
55+
hotkey: Some('b'),
56+
timeout_secs: Some(5),
57+
),
58+
59+
// Simple template-based helper
60+
CommitHelper(
61+
name: "Quick Fix",
62+
command: "echo 'fix: address code issues'",
63+
description: Some("Quick fix commit message"),
64+
hotkey: Some('f'),
65+
timeout_secs: Some(5),
66+
),
67+
]
68+
)

src/commit_helpers.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
use anyhow::Result;
2+
use ron::de::from_reader;
3+
use serde::{Deserialize, Serialize};
4+
use std::{
5+
fs::File,
6+
path::PathBuf,
7+
process::{Command, Stdio},
8+
sync::Arc,
9+
};
10+
11+
use crate::args::get_app_config_path;
12+
13+
pub type SharedCommitHelpers = Arc<CommitHelpers>;
14+
15+
const COMMIT_HELPERS_FILENAME: &str = "commit_helpers.ron";
16+
17+
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
pub struct CommitHelper {
19+
/// Display name for the helper
20+
pub name: String,
21+
/// Command to execute (will be run through shell)
22+
pub command: String,
23+
/// Optional description of what this helper does
24+
pub description: Option<String>,
25+
/// Optional hotkey for quick access
26+
pub hotkey: Option<char>,
27+
/// Optional timeout in seconds (defaults to 30)
28+
pub timeout_secs: Option<u64>,
29+
}
30+
31+
#[derive(Debug, Clone, Serialize, Deserialize)]
32+
pub struct CommitHelpers {
33+
pub helpers: Vec<CommitHelper>,
34+
}
35+
36+
impl Default for CommitHelpers {
37+
fn default() -> Self {
38+
Self {
39+
helpers: Vec::new(),
40+
}
41+
}
42+
}
43+
44+
impl CommitHelpers {
45+
fn get_config_file() -> Result<PathBuf> {
46+
let app_home = get_app_config_path()?;
47+
let config_file = app_home.join(COMMIT_HELPERS_FILENAME);
48+
Ok(config_file)
49+
}
50+
51+
pub fn init() -> Result<Self> {
52+
let config_file = Self::get_config_file()?;
53+
54+
if config_file.exists() {
55+
let file = File::open(&config_file).map_err(|e| {
56+
anyhow::anyhow!("Failed to open commit_helpers.ron: {}. Check file permissions.", e)
57+
})?;
58+
59+
match from_reader::<_, CommitHelpers>(file) {
60+
Ok(config) => {
61+
log::info!("Loaded {} commit helpers from config", config.helpers.len());
62+
Ok(config)
63+
},
64+
Err(e) => {
65+
log::error!("Failed to parse commit_helpers.ron: {}", e);
66+
anyhow::bail!(
67+
"Invalid RON syntax in commit_helpers.ron: {}. \
68+
Check the example file or remove the config to reset.", e
69+
)
70+
}
71+
}
72+
} else {
73+
log::info!("No commit_helpers.ron found, using empty config. \
74+
See commit_helpers.ron.example for configuration options.");
75+
Ok(Self::default())
76+
}
77+
}
78+
79+
pub fn get_helpers(&self) -> &[CommitHelper] {
80+
&self.helpers
81+
}
82+
83+
pub fn find_by_hotkey(&self, hotkey: char) -> Option<usize> {
84+
self.helpers.iter().position(|h| h.hotkey == Some(hotkey))
85+
}
86+
87+
pub fn execute_helper(&self, helper_index: usize) -> Result<String> {
88+
if helper_index >= self.helpers.len() {
89+
anyhow::bail!("Invalid helper index");
90+
}
91+
92+
let helper = &self.helpers[helper_index];
93+
94+
// Process template variables in command
95+
let processed_command = self.process_template_variables(&helper.command)?;
96+
97+
// Execute command through shell to support pipes and redirects
98+
let output = if cfg!(target_os = "windows") {
99+
Command::new("cmd")
100+
.args(["/C", &processed_command])
101+
.stdin(Stdio::null())
102+
.output()?
103+
} else {
104+
Command::new("sh")
105+
.args(["-c", &processed_command])
106+
.stdin(Stdio::null())
107+
.output()?
108+
};
109+
110+
if !output.status.success() {
111+
let error = String::from_utf8_lossy(&output.stderr);
112+
anyhow::bail!("Command failed: {}", error);
113+
}
114+
115+
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
116+
117+
if result.is_empty() {
118+
anyhow::bail!("Command returned empty output");
119+
}
120+
121+
Ok(result)
122+
}
123+
124+
fn process_template_variables(&self, command: &str) -> Result<String> {
125+
let mut processed = command.to_string();
126+
127+
// {staged_diff} - staged git diff
128+
if processed.contains("{staged_diff}") {
129+
let diff_output = Command::new("git")
130+
.args(["diff", "--staged", "--no-color"])
131+
.output()?;
132+
let diff = String::from_utf8_lossy(&diff_output.stdout);
133+
processed = processed.replace("{staged_diff}", &diff);
134+
}
135+
136+
// {staged_files} - list of staged files
137+
if processed.contains("{staged_files}") {
138+
let files_output = Command::new("git")
139+
.args(["diff", "--staged", "--name-only"])
140+
.output()?;
141+
let files = String::from_utf8_lossy(&files_output.stdout);
142+
processed = processed.replace("{staged_files}", files.trim());
143+
}
144+
145+
// {branch_name} - current branch name
146+
if processed.contains("{branch_name}") {
147+
let branch_output = Command::new("git")
148+
.args(["rev-parse", "--abbrev-ref", "HEAD"])
149+
.output()?;
150+
let branch = String::from_utf8_lossy(&branch_output.stdout);
151+
processed = processed.replace("{branch_name}", branch.trim());
152+
}
153+
154+
Ok(processed)
155+
}
156+
}

src/keys/key_list.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ pub struct KeysList {
128128
pub commit_history_next: GituiKeyEvent,
129129
pub commit: GituiKeyEvent,
130130
pub newline: GituiKeyEvent,
131+
pub commit_helper: GituiKeyEvent,
131132
}
132133

133134
#[rustfmt::skip]
@@ -225,6 +226,7 @@ impl Default for KeysList {
225226
commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
226227
commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
227228
newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
229+
commit_helper: GituiKeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL),
228230
}
229231
}
230232
}

src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ mod args;
6464
mod bug_report;
6565
mod clipboard;
6666
mod cmdbar;
67+
mod commit_helpers;
6768
mod components;
6869
mod input;
6970
mod keys;
@@ -271,6 +272,8 @@ fn run_app(
271272
if matches!(event, QueueEvent::SpinnerUpdate) {
272273
spinner.update();
273274
spinner.draw(terminal)?;
275+
// Also update app for commit helper animations
276+
app.update()?;
274277
continue;
275278
}
276279

0 commit comments

Comments
 (0)