Merge pull request #27 from jnsahaj/feature/explain-range-diff

Feature/explain range diff
This commit is contained in:
Sahaj Jain 2024-11-22 01:34:55 +05:30 committed by GitHub
commit ede23bcdff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 187 additions and 21 deletions

View file

@ -1,6 +1,6 @@
use crate::{
command::{draft::DraftCommand, explain::ExplainCommand},
git_entity::GitEntity,
git_entity::{diff::Diff, GitEntity},
};
use indoc::{formatdoc, indoc};
use thiserror::Error;
@ -39,15 +39,14 @@ impl AIPrompt {
diff = commit.diff
}
}
GitEntity::Diff(diff) => {
GitEntity::Diff(Diff::WorkingTree { diff, .. } | Diff::CommitsRange { diff, .. }) => {
formatdoc! {"
Context - Changes:
```diff
{diff}
```
",
diff = diff.diff
"
}
}
};
@ -72,7 +71,7 @@ impl AIPrompt {
2. Direct impact
"
},
GitEntity::Diff(_) => formatdoc! {"
GitEntity::Diff(Diff::WorkingTree { .. }) => formatdoc! {"
{base_content}
Provide:
@ -80,6 +79,14 @@ impl AIPrompt {
2. Notable concerns (if any)
"
},
GitEntity::Diff(Diff::CommitsRange { .. }) => formatdoc! {"
{base_content}
Provide:
1. Core changes made
2. Direct impact
"
},
},
};
@ -90,8 +97,10 @@ impl AIPrompt {
}
pub fn build_draft_prompt(command: &DraftCommand) -> Result<Self, AIPromptError> {
let GitEntity::Diff(diff) = &command.git_entity else {
return Err(AIPromptError("`draft` is only supported for diffs".into()));
let GitEntity::Diff(Diff::WorkingTree { diff, .. }) = &command.git_entity else {
return Err(AIPromptError(
"`draft` is only supported for working tree diffs".into(),
));
};
let system_prompt = String::from(indoc! {"
@ -131,7 +140,6 @@ impl AIPrompt {
```
",
commit_types = command.draft_config.commit_types,
diff = diff.diff
});
Ok(AIPrompt {

124
src/commit_reference.rs Normal file
View file

@ -0,0 +1,124 @@
use std::str::FromStr;
use thiserror::Error;
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum CommitReference {
Single(String),
Range { from: String, to: String },
}
#[derive(Debug, Error)]
pub enum ReferenceParseError {
#[error("empty reference string")]
Empty,
}
impl FromStr for CommitReference {
type Err = ReferenceParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Err(ReferenceParseError::Empty);
}
// Handle the .. cases
if let Some((from, to)) = s.split_once("..") {
let from = if from.is_empty() { "HEAD" } else { from };
let to = if to.is_empty() { "HEAD" } else { to };
Ok(CommitReference::Range {
from: from.to_string(),
to: to.to_string(),
})
} else {
Ok(CommitReference::Single(s.to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser, Debug)]
struct TestCli {
reference: CommitReference,
}
#[test]
fn test_single_commit() {
assert_eq!(
"HEAD".parse::<CommitReference>().unwrap(),
CommitReference::Single("HEAD".to_string())
);
}
#[test]
fn test_full_range() {
assert_eq!(
"main..feature".parse::<CommitReference>().unwrap(),
CommitReference::Range {
from: "main".to_string(),
to: "feature".to_string(),
}
);
}
#[test]
fn test_from_only_range() {
assert_eq!(
"develop..".parse::<CommitReference>().unwrap(),
CommitReference::Range {
from: "develop".to_string(),
to: "HEAD".to_string(),
}
);
}
#[test]
fn test_to_only_range() {
assert_eq!(
"..feature".parse::<CommitReference>().unwrap(),
CommitReference::Range {
from: "HEAD".to_string(),
to: "feature".to_string(),
}
);
}
#[test]
fn test_clap_integration() {
// Test full range
let cli = TestCli::try_parse_from(&["test", "main..feature"]).unwrap();
assert!(matches!(
cli.reference,
CommitReference::Range { from, to }
if from == "main" && to == "feature"
));
// Test from-only range
let cli = TestCli::try_parse_from(&["test", "develop.."]).unwrap();
assert!(matches!(
cli.reference,
CommitReference::Range { from, to }
if from == "develop" && to == "HEAD"
));
// Test to-only range
let cli = TestCli::try_parse_from(&["test", "..feature"]).unwrap();
assert!(matches!(
cli.reference,
CommitReference::Range { from, to }
if from == "HEAD" && to == "feature"
));
}
#[test]
fn test_empty_reference() {
assert!(matches!(
"".parse::<CommitReference>(),
Err(ReferenceParseError::Empty)
));
}
}

View file

@ -1,6 +1,8 @@
use clap::{command, Parser, Subcommand, ValueEnum};
use std::str::FromStr;
use crate::commit_reference::CommitReference;
#[derive(Parser)]
#[command(name = "lumen")]
#[command(about = "AI-powered CLI tool for git commit summaries", long_about = None)]
@ -30,7 +32,7 @@ pub enum ProviderType {
Groq,
Claude,
Ollama,
Openrouter
Openrouter,
}
impl FromStr for ProviderType {
@ -54,8 +56,8 @@ pub enum Commands {
/// Explain the changes in a commit, or the current diff
Explain {
/// The commit hash to use
#[arg(group = "target")]
sha: Option<String>,
#[arg(group = "target", value_parser = clap::value_parser!(CommitReference))]
reference: Option<CommitReference>,
/// Explain current diff
#[arg(long, group = "target")]

View file

@ -1,4 +1,4 @@
pub mod configuration;
pub mod cli;
pub mod configuration;
pub use configuration::LumenConfig;

View file

@ -8,9 +8,16 @@ pub enum DiffError {
}
#[derive(Clone, Debug)]
pub struct Diff {
pub staged: bool,
pub diff: String,
pub enum Diff {
WorkingTree {
staged: bool,
diff: String,
},
CommitsRange {
from: String,
to: String,
diff: String,
},
}
impl Diff {
@ -28,6 +35,23 @@ impl Diff {
return Err(DiffError::EmptyDiff { staged }.into());
}
Ok(Diff { staged, diff })
Ok(Diff::WorkingTree { staged, diff })
}
pub fn from_commits_range(from: &str, to: &str) -> Result<Self, LumenError> {
let output = std::process::Command::new("git")
.args(["diff", from, to])
.output()?;
let diff = String::from_utf8(output.stdout)?;
if diff.is_empty() {
return Err(DiffError::EmptyDiff { staged: false }.into());
}
Ok(Diff::CommitsRange {
from: from.to_string(),
to: to.to_string(),
diff,
})
}
}

View file

@ -26,10 +26,14 @@ impl GitEntity {
date = commit.date,
message = commit.message,
},
GitEntity::Diff(diff) => formatdoc! {"
# Entity: Diff{staged}",
staged = if diff.staged { " (staged)" } else { "" }
GitEntity::Diff(Diff::WorkingTree { staged, .. }) => formatdoc! {"
# Entity: Working Tree Diff{staged}",
staged = if *staged { " (staged)" } else { "" }
},
GitEntity::Diff(Diff::CommitsRange { from, to, .. }) => formatdoc! {"
# Entity: Range
`{from}` -> `{to}`
"},
}
}
}

View file

@ -1,4 +1,5 @@
use clap::Parser;
use commit_reference::CommitReference;
use config::cli::{Cli, Commands};
use config::LumenConfig;
use error::LumenError;
@ -7,6 +8,7 @@ use std::process;
mod ai_prompt;
mod command;
mod commit_reference;
mod config;
mod error;
mod git_entity;
@ -35,15 +37,17 @@ async fn run() -> Result<(), LumenError> {
match cli.command {
Commands::Explain {
sha,
reference,
diff,
staged,
query,
} => {
let git_entity = if diff {
GitEntity::Diff(Diff::from_working_tree(staged)?)
} else if let Some(sha) = sha {
} else if let Some(CommitReference::Single(sha)) = reference {
GitEntity::Commit(Commit::new(sha)?)
} else if let Some(CommitReference::Range { from, to }) = reference {
GitEntity::Diff(Diff::from_commits_range(&from, &to)?)
} else {
return Err(LumenError::InvalidArguments(
"`explain` expects SHA-1 or --diff to be present".into(),