Skip to content
Merged
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,19 @@ commitbot --diff my-changes.diff
# With a custom branch name for context
commitbot --diff my-changes.diff --branch feature/ISSUE-123-auth

# From stdin (pipe a diff)
# From stdin (pipe a diff)task :
git diff HEAD~3 | commitbot --diff -

# From a commit hash (looked up in git history automatically)
commitbot --diff aabcf3b6ce03e4f1503c7a0fdda7c120bf73c8bc
```

When `--diff` looks like a commit hash, Commitbot first checks whether it
exists in the current repo's history and, if so, summarizes the diff
introduced by that commit. If it doesn't match a commit, Commitbot falls
back to treating the value as a file path; if neither a matching commit nor
a file is found, it reports an error instead of guessing.

---

### Pull Request Summaries
Expand Down
3 changes: 2 additions & 1 deletion commitbot.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ max_concurrent_requests = 8
["MikeGarde/commitbot"]
provider = "lmstudio"
model = "google/gemma-4-12b-qat"
url = "http://GPU.localdomain:1234/v1"
url = "http://gpu.garde.one:1234/v1"
# Fallback / direct IP: url = "http://192.168.1.16:1234/v1"
max_concurrent_requests = 2


Expand Down
16 changes: 15 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub struct Config {
pub stream: bool,
/// HTTP request timeout in seconds for LLM calls
pub request_timeout_secs: u64,
/// Maximum size (in bytes) of a single file's diff sent to the LLM.
/// Larger diffs (e.g. notebooks with embedded base64 output, minified
/// bundles) are truncated to avoid exceeding the model's context window.
pub max_diff_bytes: usize,
}

impl Config {
Expand Down Expand Up @@ -56,6 +60,7 @@ impl Config {
let max_concurrent_requests = r.get_usize("max_concurrent_requests", 4);
let stream = r.get_bool("stream", true);
let request_timeout_secs = r.get_u64("request_timeout_secs", 300);
let max_diff_bytes = r.get_usize("max_diff_bytes", 20_000);

// Cleanup: trim stray quotes if any upstream included them
let provider = provider.trim_matches('"').to_string();
Expand All @@ -66,7 +71,12 @@ impl Config {
.map(|s| s.trim_matches('"').trim().to_string())
.filter(|s| !s.is_empty());

if provider == "openai" && openai_api_key.is_none() {
let is_custom_endpoint = base_url
.as_deref()
.map(|u| !u.starts_with("https://api.openai.com"))
.unwrap_or(false);

if provider == "openai" && openai_api_key.is_none() && !is_custom_endpoint {
return Err(anyhow!(
"OPENAI_API_KEY must be set via CLI, env var, or config file for provider=openai"
));
Expand All @@ -80,6 +90,7 @@ impl Config {
max_concurrent_requests,
stream,
request_timeout_secs,
max_diff_bytes,
})
}
}
Expand All @@ -94,6 +105,7 @@ struct FileConfig {
pub max_concurrent_requests: Option<usize>,
pub stream: Option<bool>,
pub request_timeout_secs: Option<u64>,
pub max_diff_bytes: Option<usize>,
}

/// Root of the TOML file:
Expand Down Expand Up @@ -178,6 +190,7 @@ impl<'a> ConfigResolver<'a> {
"max_concurrent_requests" => Some("COMMITBOT_MAX_CONCURRENT_REQUESTS"),
"stream" => Some("COMMITBOT_STREAM"),
"request_timeout_secs" => Some("COMMITBOT_REQUEST_TIMEOUT_SECS"),
"max_diff_bytes" => Some("COMMITBOT_MAX_DIFF_BYTES"),
_ => None,
}
}
Expand Down Expand Up @@ -207,6 +220,7 @@ impl<'a> ConfigResolver<'a> {
};
match key {
"max_concurrent_requests" => cfg.max_concurrent_requests,
"max_diff_bytes" => cfg.max_diff_bytes,
_ => None,
}
}
Expand Down
56 changes: 39 additions & 17 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,25 @@ impl RemoteRepo {
}
}

/// Run a git command and capture stdout as String.
pub fn format_git_error(args: &[&str], code: Option<i32>, stderr: &str) -> anyhow::Error {
let stderr = stderr.trim();

if stderr.contains("xcodebuild -license") || stderr.contains("Xcode license") {
return anyhow!(
"Xcode license agreement required.\n\
Apple's git cannot run until the Xcode / Command Line Tools license is accepted.\n\n\
Please run:\n sudo xcodebuild -license\n\n\
Once accepted, run commitbot again."
);
}

if stderr.is_empty() {
anyhow!("git {:?} exited with status {:?}", args, code)
} else {
anyhow!("git {:?} exited with status {:?}: {}", args, code, stderr)
}
}

pub fn git_output(args: &[&str]) -> Result<String> {
let output = GitCommand::new("git")
.args(args)
Expand All @@ -92,20 +110,7 @@ pub fn git_output(args: &[&str]) -> Result<String> {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
if stderr.is_empty() {
return Err(anyhow!(
"git {:?} exited with status {:?}",
args,
output.status.code()
));
}
return Err(anyhow!(
"git {:?} exited with status {:?}: {}",
args,
output.status.code(),
stderr
));
return Err(format_git_error(args, output.status.code(), &stderr));
}

Ok(String::from_utf8_lossy(&output.stdout).to_string())
Expand All @@ -127,15 +132,32 @@ fn remote_origin_url() -> Option<String> {
.filter(|url| !url.is_empty())
}

/// Get the current branch name.
pub fn current_branch() -> Result<String> {
let name = git_output(&["rev-parse", "--abbrev-ref", "HEAD"])?
.trim()
.to_string();
Ok(name)
}

/// Get a list of staged files.
pub fn looks_like_commit_hash(s: &str) -> bool {
let len = s.len();
(4..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
}

pub fn resolve_commit_diff(hash: &str) -> Result<Option<String>> {
let verified = GitCommand::new("git")
.args(["rev-parse", "--verify", "--quiet", &format!("{hash}^{{commit}}")])
.output()
.with_context(|| format!("failed to run git rev-parse for '{hash}'"))?;

if !verified.status.success() {
return Ok(None);
}

let diff = git_output(&["show", "--format=", hash])?;
Ok(Some(diff))
}

pub fn staged_files() -> Result<Vec<String>> {
let output = git_output(&["diff", "--cached", "--name-only"])?;
let files = output
Expand Down
50 changes: 50 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ impl FileCategory {
}
}

/// Truncates an oversized diff before it's ever sent to an LLM.
///
/// Some diffs (Jupyter notebooks with embedded base64 image outputs, minified
/// bundles, generated files) can run into the megabytes, which blows past any
/// local model's context window and can wedge the upstream server for every
/// other in-flight request. Cutting on a line boundary keeps the remaining
/// diff readable instead of ending mid-line.
pub fn truncate_diff(diff: &str, max_bytes: usize) -> String {
if diff.len() <= max_bytes {
return diff.to_string();
}

let mut cut = max_bytes;
while cut > 0 && !diff.is_char_boundary(cut) {
cut -= 1;
}

let head = match diff[..cut].rfind('\n') {
Some(idx) => &diff[..idx],
None => &diff[..cut],
};

let omitted = diff.len() - head.len();
format!(
"{head}\n... [diff truncated, {omitted} bytes omitted — file is unusually large, \
likely generated or contains embedded binary/base64 content]"
)
}

/// Represents a single staged file's change and metadata.
#[derive(Debug, Clone)]
pub struct FileChange {
Expand All @@ -86,3 +115,24 @@ pub struct FileChange {
/// [`FileCategory::Lock`] and [`FileCategory::Ignored`] files.
pub summary: Option<String>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn truncate_diff_leaves_small_diffs_untouched() {
let diff = "diff --git a/f b/f\n+hello\n";
assert_eq!(truncate_diff(diff, 1_000), diff);
}

#[test]
fn truncate_diff_cuts_oversized_diffs_on_a_line_boundary() {
let diff = "line one\nline two\nline three\n";
let result = truncate_diff(diff, 15);

assert!(result.starts_with("line one\n"));
assert!(!result.contains("line three"));
assert!(result.contains("truncated"));
}
}
Loading
Loading