Skip to content

feat(outline): Add support for generating structured commit messages with bullet points #52

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ lumen draft
# Add context for more meaningful messages
lumen draft --context "match brand guidelines"
# Output: "feat(button.tsx): Update button color to align with brand identity guidelines"

# Generate a structured commit message with bullet points
lumen outline
# Output:
# feat(button.tsx): Update button color to align with brand identity guidelines
#
# - Update primary button color to match new brand color scheme
# - Add hover state color transition for better user feedback
# - Update color variables in theme configuration for consistency

# Add context for more meaningful structured messages
lumen outline --context "match brand guidelines"
```


Expand Down Expand Up @@ -127,6 +139,8 @@ lumen draft | code -

# Directly commit using the generated message
lumen draft | git commit -F -
# Directly commit using the generated message with bullet points
lumen outline | git commit -F -
```

If you are using [lazygit](https://github.com/jesseduffield/lazygit), you can add this to the [user config](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md)
Expand Down
65 changes: 64 additions & 1 deletion src/ai_prompt.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::{
command::{draft::DraftCommand, explain::ExplainCommand},
command::{draft::DraftCommand, outline::OutlineCommand, explain::ExplainCommand},
git_entity::{diff::Diff, GitEntity},
};
use indoc::{formatdoc, indoc};
Expand Down Expand Up @@ -148,6 +148,69 @@ impl AIPrompt {
})
}

pub fn build_outline_prompt(command: &OutlineCommand) -> Result<Self, AIPromptError> {
let GitEntity::Diff(Diff::WorkingTree { diff, .. }) = &command.git_entity else {
return Err(AIPromptError(
"`outline` is only supported for working tree diffs".into(),
));
};

let system_prompt = String::from(indoc! {"
You are a commit message generator that follows these rules:
1. IMPORTANT: Each line must be no more than 65 characters (including title and bullet points)
2. Write in present tense
3. Be concise and direct
4. Output only the commit message without any explanations
5. Follow the format:
<type>(<optional scope>): <commit message>

- <bullet point describing a key change>
- <bullet point describing another key change>
"});

let context = if let Some(context) = &command.context {
formatdoc!(
"
Use the following context to understand intent:
{context}
"
)
} else {
"".to_string()
};

let user_prompt = String::from(formatdoc! {"
Generate a structured git commit message written in present tense for the following code diff with the given specifications below:

The output response must be in format:
<type>(<optional scope>): <commit message>

- <bullet point describing a key change>
- <bullet point describing another key change>

Choose a type from the type-to-description JSON below that best describes the git diff:
{commit_types}
Focus on being accurate and concise.
{context}
First line must be a maximum of 72 characters.
Each bullet point must be a maximum of 78 characters.
If a bullet point exceeds 78 characters, wrap it with 2 spaces at the start of the next line.
Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.

Code diff:
```diff
{diff}
```
",
commit_types = command.draft_config.commit_types,
});

Ok(AIPrompt {
system_prompt,
user_prompt,
})
}

pub fn build_operate_prompt(query: &str) -> Result<Self, AIPromptError> {
let system_prompt = String::from(indoc! {"
You're a Git assistant that provides commands with clear explanations.
Expand Down
8 changes: 8 additions & 0 deletions src/command/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use async_trait::async_trait;
use draft::DraftCommand;
use outline::OutlineCommand;
use explain::ExplainCommand;
use list::ListCommand;
use operate::OperateCommand;
Expand All @@ -12,6 +13,7 @@ use crate::git_entity::GitEntity;
use crate::provider::LumenProvider;

pub mod draft;
pub mod outline;
pub mod explain;
pub mod list;
pub mod operate;
Expand All @@ -24,6 +26,7 @@ pub enum CommandType {
},
List,
Draft(Option<String>, DraftConfig),
Outline(Option<String>, DraftConfig),
Operate {
query: String,
},
Expand All @@ -46,6 +49,11 @@ impl CommandType {
draft_config,
context,
}),
CommandType::Outline(context, draft_config) => Box::new(OutlineCommand {
git_entity: GitEntity::Diff(Diff::from_working_tree(true)?),
draft_config,
context,
}),
CommandType::Operate { query } => Box::new(OperateCommand { query }),
})
}
Expand Down
27 changes: 27 additions & 0 deletions src/command/outline.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
use std::io::Write;

use async_trait::async_trait;

use crate::{
config::configuration::DraftConfig, error::LumenError, git_entity::GitEntity,
provider::LumenProvider,
};

use super::Command;

pub struct OutlineCommand {
pub git_entity: GitEntity,
pub context: Option<String>,
pub draft_config: DraftConfig,
}

#[async_trait]
impl Command for OutlineCommand {
async fn execute(&self, provider: &LumenProvider) -> Result<(), LumenError> {
let result = provider.outline(self).await?;

print!("{result}");
std::io::stdout().flush()?;
Ok(())
}
}
7 changes: 6 additions & 1 deletion src/config/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ pub enum Commands {
#[arg(short, long)]
context: Option<String>,
},

/// Generate a structured commit message with bullet points for the staged changes
Outline {
/// Add context to communicate intent
#[arg(short, long)]
context: Option<String>,
},
Operate {
#[arg()]
query: String,
Expand Down
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ async fn run() -> Result<(), LumenError> {
.execute(command::CommandType::Draft(context, config.draft))
.await?
}
Commands::Outline { context } => {
command
.execute(command::CommandType::Outline(context, config.draft))
.await?
}
Commands::Operate { query } => {
command
.execute(command::CommandType::Operate { query })
Expand Down
31 changes: 24 additions & 7 deletions src/provider/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
use crate::config::cli::ProviderType;
use crate::{
ai_prompt::{AIPrompt, AIPromptError},
command::{
draft::DraftCommand,
outline::OutlineCommand,
explain::ExplainCommand,
operate::OperateCommand,
},
config::cli::ProviderType,
error::LumenError,
};
use async_trait::async_trait;
use claude::{ClaudeConfig, ClaudeProvider};
use deepseek::{DeepSeekConfig, DeepSeekProvider};
Expand All @@ -9,12 +19,6 @@ use openrouter::{OpenRouterConfig, OpenRouterProvider};
use phind::{PhindConfig, PhindProvider};
use thiserror::Error;

use crate::{
ai_prompt::{AIPrompt, AIPromptError},
command::{draft::DraftCommand, explain::ExplainCommand, operate::OperateCommand},
error::LumenError,
};

pub mod claude;
pub mod deepseek;
pub mod groq;
Expand Down Expand Up @@ -136,6 +140,19 @@ impl LumenProvider {
}
}

pub async fn outline(&self, command: &OutlineCommand) -> Result<String, ProviderError> {
let prompt = AIPrompt::build_outline_prompt(command)?;
match self {
LumenProvider::OpenAI(provider) => provider.complete(prompt).await,
LumenProvider::Phind(provider) => provider.complete(prompt).await,
LumenProvider::Groq(provider) => provider.complete(prompt).await,
LumenProvider::Claude(provider) => provider.complete(prompt).await,
LumenProvider::Ollama(provider) => provider.complete(prompt).await,
LumenProvider::OpenRouter(provider) => provider.complete(prompt).await,
LumenProvider::DeepSeek(provider) => provider.complete(prompt).await,
}
}

pub async fn operate(&self, command: &OperateCommand) -> Result<String, ProviderError> {
let prompt = AIPrompt::build_operate_prompt(command.query.as_str())?;
match self {
Expand Down