use anyhow::Context;
use color_print::cformat;
use shell_escape::escape;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use worktrunk::config::CommitGenerationConfig;
use worktrunk::git::Repository;
use worktrunk::path::format_path_for_display;
use worktrunk::shell_exec::{Cmd, SUBPROCESS_FULL_TARGET, ShellConfig};
use worktrunk::styling::{eprintln, warning_message};
use minijinja::Environment;
const SHELL_METACHARACTERS: &[char] = &[
'&', '|', ';', '<', '>', '$', '`', '\'', '"', '(', ')', '{', '}', '*', '?', '[', ']', '~', '!',
'\\',
];
fn format_reproduction_command(base_cmd: &str, llm_command: &str) -> String {
let needs_shell = llm_command.contains(SHELL_METACHARACTERS)
|| llm_command
.split_whitespace()
.next()
.is_some_and(|first| first.contains('='));
if needs_shell {
format!(
"{} | sh -c {}",
base_cmd,
escape(Cow::Borrowed(llm_command))
)
} else {
format!("{} | {}", base_cmd, llm_command)
}
}
static TEMPLATE_FILE_WARNING_SHOWN: AtomicBool = AtomicBool::new(false);
const DIFF_SIZE_THRESHOLD: usize = 400_000;
const MAX_LINES_PER_FILE: usize = 50;
const MAX_FILES: usize = 50;
const LOCK_FILE_PATTERNS: &[&str] = &[".lock", "-lock.json", "-lock.yaml", ".lock.hcl"];
pub(crate) struct PreparedDiff {
pub(crate) diff: String,
pub(crate) stat: String,
}
fn is_lock_file(filename: &str) -> bool {
LOCK_FILE_PATTERNS
.iter()
.any(|pattern| filename.ends_with(pattern))
}
fn parse_diff_sections(diff: &str) -> Vec<(&str, &str)> {
let mut sections = Vec::new();
let mut current_file: Option<&str> = None;
let mut section_start_byte = 0;
let mut current_byte = 0;
for line in diff.lines() {
if line.starts_with("diff --git ") {
if let Some(file) = current_file
&& current_byte > section_start_byte
{
sections.push((file, &diff[section_start_byte..current_byte]));
}
current_file = line.split(" b/").nth(1);
section_start_byte = current_byte;
}
current_byte += line.len() + 1; }
if let Some(file) = current_file
&& section_start_byte < diff.len()
{
sections.push((file, &diff[section_start_byte..]));
}
sections
}
fn truncate_diff_section(section: &str, max_lines: usize) -> String {
let lines: Vec<&str> = section.lines().collect();
if lines.len() <= max_lines {
return section.to_string();
}
let header_end = lines.iter().position(|l| l.starts_with("@@")).unwrap_or(0);
let header_lines = header_end + 1;
let content_lines = max_lines.saturating_sub(header_lines);
let total_lines = header_lines + content_lines;
let mut result: String = lines
.iter()
.take(total_lines)
.map(|l| format!("{}\n", l))
.collect();
let omitted = lines.len() - total_lines;
if omitted > 0 {
result.push_str(&format!("\n... ({} lines omitted)\n", omitted));
}
result
}
pub(crate) fn prepare_diff(diff: String, stat: String) -> PreparedDiff {
if diff.len() < DIFF_SIZE_THRESHOLD {
return PreparedDiff { diff, stat };
}
log::debug!(
"Diff size ({} chars) exceeds threshold ({}), filtering",
diff.len(),
DIFF_SIZE_THRESHOLD
);
let sections = parse_diff_sections(&diff);
let filtered_sections: Vec<_> = sections
.iter()
.filter(|(filename, _)| !is_lock_file(filename))
.collect();
let lock_files_removed = sections.len() - filtered_sections.len();
if lock_files_removed > 0 {
log::debug!("Filtered out {} lock file(s)", lock_files_removed);
}
let filtered_diff: String = filtered_sections
.iter()
.map(|(_, content)| *content)
.collect();
if filtered_diff.len() < DIFF_SIZE_THRESHOLD {
return PreparedDiff {
diff: filtered_diff,
stat,
};
}
log::debug!(
"Still too large ({} chars), truncating to {} lines/file, {} files max",
filtered_diff.len(),
MAX_LINES_PER_FILE,
MAX_FILES
);
let truncated: String = filtered_sections
.iter()
.take(MAX_FILES)
.map(|(_, content)| truncate_diff_section(content, MAX_LINES_PER_FILE))
.collect();
let files_omitted = filtered_sections.len().saturating_sub(MAX_FILES);
let final_diff = if files_omitted > 0 {
format!("{}\n... ({} files omitted)\n", truncated, files_omitted)
} else {
truncated
};
PreparedDiff {
diff: final_diff,
stat,
}
}
struct TemplateContext<'a> {
git_diff: &'a str,
git_diff_stat: &'a str,
branch: &'a str,
recent_commits: Option<&'a Vec<String>>,
repo_name: &'a str,
commits: &'a [String],
target_branch: Option<&'a str>,
}
const DEFAULT_TEMPLATE: &str = r#"<task>Write a commit message for the staged changes below.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match recent commit style (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<diffstat>
{{ git_diff_stat }}
</diffstat>
<diff>
{{ git_diff }}
</diff>
<context>
Branch: {{ branch }}
{% if recent_commits %}<recent_commits>
{% for commit in recent_commits %}- {{ commit }}
{% endfor %}</recent_commits>{% endif %}
</context>
"#;
const DEFAULT_SQUASH_TEMPLATE: &str = r#"<task>Write a commit message for the combined effect of these commits.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match the style of commits being squashed (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<commits branch="{{ branch }}" target="{{ target_branch }}">
{% for commit in commits %}- {{ commit }}
{% endfor %}</commits>
<diffstat>
{{ git_diff_stat }}
</diffstat>
<diff>
{{ git_diff }}
</diff>
"#;
pub(crate) fn execute_llm_command(command: &str, prompt: &str) -> anyhow::Result<String> {
log::debug!(target: SUBPROCESS_FULL_TARGET, " Prompt (stdin):");
for line in prompt.lines() {
log::debug!(target: SUBPROCESS_FULL_TARGET, " {}", line);
}
let shell = ShellConfig::get()?;
let output = Cmd::new(shell.executable.to_string_lossy())
.args(&shell.args)
.arg(command)
.external("commit.generation")
.stdin_bytes(prompt)
.env_remove("CLAUDECODE")
.run()
.context("Failed to spawn LLM command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
if stderr.is_empty() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
anyhow::bail!(
"LLM command failed with exit code {}",
output.status.code().unwrap_or(-1)
);
} else {
anyhow::bail!("{}", stdout);
}
} else {
anyhow::bail!("{}", stderr);
}
}
let message = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if message.is_empty() {
return Err(worktrunk::git::GitError::Other {
message: "LLM returned empty message".into(),
}
.into());
}
Ok(message)
}
enum TemplateType {
Commit,
Squash,
}
fn load_template(
inline: Option<&String>,
file: Option<&String>,
default: &str,
file_type_name: &str,
) -> anyhow::Result<String> {
match (inline, file) {
(Some(inline), None) => Ok(inline.clone()),
(None, Some(path)) => {
if !TEMPLATE_FILE_WARNING_SHOWN.swap(true, Ordering::Relaxed) {
eprintln!(
"{}",
warning_message(format!(
"{file_type_name} is deprecated and will be removed in a future release. Use inline template instead. To request this feature, comment on: https://github.com/max-sixty/worktrunk/issues/444"
))
);
}
let expanded_path = PathBuf::from(shellexpand::tilde(path).as_ref());
std::fs::read_to_string(&expanded_path).map_err(|e| {
anyhow::Error::from(worktrunk::git::GitError::Other {
message: cformat!(
"Failed to read {} <bold>{}</>: {}",
file_type_name,
format_path_for_display(&expanded_path),
e
),
})
})
}
(None, None) => Ok(default.to_string()),
(Some(_), Some(_)) => {
unreachable!(
"Config validation should prevent both {} options",
file_type_name
)
}
}
}
fn build_prompt(
config: &CommitGenerationConfig,
template_type: TemplateType,
context: &TemplateContext<'_>,
) -> anyhow::Result<String> {
let (template, type_name) = match template_type {
TemplateType::Commit => (
load_template(
config.template.as_ref(),
config.template_file.as_ref(),
DEFAULT_TEMPLATE,
"template-file",
)?,
"Template",
),
TemplateType::Squash => (
load_template(
config.squash_template.as_ref(),
config.squash_template_file.as_ref(),
DEFAULT_SQUASH_TEMPLATE,
"squash-template-file",
)?,
"Squash template",
),
};
if template.trim().is_empty() {
return Err(worktrunk::git::GitError::Other {
message: format!("{} is empty", type_name),
}
.into());
}
let env = Environment::new();
let tmpl = env.template_from_str(&template)?;
let commits_chronological: Vec<&String> = context.commits.iter().rev().collect();
let rendered = tmpl.render(minijinja::context! {
git_diff => context.git_diff,
git_diff_stat => context.git_diff_stat,
branch => context.branch,
recent_commits => context.recent_commits.unwrap_or(&vec![]),
repo => context.repo_name,
commits => commits_chronological,
target_branch => context.target_branch.unwrap_or(""),
})?;
Ok(rendered)
}
pub(crate) fn generate_commit_message(
commit_generation_config: &CommitGenerationConfig,
) -> anyhow::Result<String> {
if commit_generation_config.is_configured() {
let command = commit_generation_config.command.as_ref().unwrap();
return try_generate_commit_message(command, commit_generation_config).map_err(|e| {
worktrunk::git::GitError::LlmCommandFailed {
command: command.clone(),
error: e.to_string(),
reproduction_command: Some(format_reproduction_command(
"wt step commit --show-prompt",
command,
)),
}
.into()
});
}
let repo = Repository::current()?;
let file_list = repo.run_command(&["diff", "--staged", "--name-only", "-z"])?;
let staged_files = file_list
.split('\0')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|path| {
Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
})
.collect::<Vec<_>>();
let message = match staged_files.len() {
0 => "WIP: Changes".to_string(),
1 => format!("Changes to {}", staged_files[0]),
2 => format!("Changes to {} & {}", staged_files[0], staged_files[1]),
3 => format!(
"Changes to {}, {} & {}",
staged_files[0], staged_files[1], staged_files[2]
),
n => format!("Changes to {} files", n),
};
Ok(message)
}
fn try_generate_commit_message(
command: &str,
config: &CommitGenerationConfig,
) -> anyhow::Result<String> {
let prompt = build_commit_prompt(config)?;
execute_llm_command(command, &prompt)
}
pub(crate) fn build_commit_prompt(config: &CommitGenerationConfig) -> anyhow::Result<String> {
let repo = Repository::current()?;
let diff_output = repo.run_command(&[
"-c",
"diff.noprefix=false",
"-c",
"diff.mnemonicPrefix=false",
"--no-pager",
"diff",
"--staged",
])?;
let diff_stat = repo.run_command(&["--no-pager", "diff", "--staged", "--stat"])?;
let prepared = prepare_diff(diff_output, diff_stat);
let wt = repo.current_worktree();
let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string());
let repo_root = wt.root()?;
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
let recent_commits = repo.recent_commit_subjects(None, 5);
let context = TemplateContext {
git_diff: &prepared.diff,
git_diff_stat: &prepared.stat,
branch: ¤t_branch,
recent_commits: recent_commits.as_ref(),
repo_name,
commits: &[],
target_branch: None,
};
build_prompt(config, TemplateType::Commit, &context)
}
pub(crate) fn generate_squash_message(
target_branch: &str,
merge_base: &str,
subjects: &[String],
current_branch: &str,
repo_name: &str,
commit_generation_config: &CommitGenerationConfig,
) -> anyhow::Result<String> {
if commit_generation_config.is_configured() {
let command = commit_generation_config.command.as_ref().unwrap();
let prompt = build_squash_prompt(
target_branch,
merge_base,
subjects,
current_branch,
repo_name,
commit_generation_config,
)?;
return execute_llm_command(command, &prompt).map_err(|e| {
worktrunk::git::GitError::LlmCommandFailed {
command: command.clone(),
error: e.to_string(),
reproduction_command: Some(format_reproduction_command(
"wt step squash --show-prompt",
command,
)),
}
.into()
});
}
let mut commit_message = format!("Squash commits from {}\n\n", current_branch);
commit_message.push_str("Combined commits:\n");
for subject in subjects.iter().rev() {
commit_message.push_str(&format!("- {}\n", subject));
}
Ok(commit_message)
}
pub(crate) fn build_squash_prompt(
target_branch: &str,
merge_base: &str,
subjects: &[String],
current_branch: &str,
repo_name: &str,
config: &CommitGenerationConfig,
) -> anyhow::Result<String> {
let repo = Repository::current()?;
let diff_output = repo.run_command(&[
"-c",
"diff.noprefix=false",
"-c",
"diff.mnemonicPrefix=false",
"--no-pager",
"diff",
merge_base,
"HEAD",
])?;
let diff_stat = repo.run_command(&["--no-pager", "diff", merge_base, "HEAD", "--stat"])?;
let prepared = prepare_diff(diff_output, diff_stat);
let recent_commits = repo.recent_commit_subjects(Some(merge_base), 5);
let context = TemplateContext {
git_diff: &prepared.diff,
git_diff_stat: &prepared.stat,
branch: current_branch,
recent_commits: recent_commits.as_ref(),
repo_name,
commits: subjects,
target_branch: Some(target_branch),
};
build_prompt(config, TemplateType::Squash, &context)
}
const SYNTHETIC_DIFF: &str = r#"diff --git a/src/main.rs b/src/main.rs
index abc1234..def5678 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,6 +10,10 @@ fn main() {
println!("Hello, world!");
+
+ // Add new feature
+ let config = load_config();
+ process_data(&config);
}
"#;
const SYNTHETIC_DIFF_STAT: &str = " src/main.rs | 4 ++++
1 file changed, 4 insertions(+)";
pub(crate) fn test_commit_generation(
commit_generation_config: &CommitGenerationConfig,
) -> anyhow::Result<String> {
if !commit_generation_config.is_configured() {
anyhow::bail!(
"Commit generation is not configured. Add [commit.generation] to the config."
);
}
let command = commit_generation_config.command.as_ref().unwrap();
let recent_commits = vec![
"feat: Add user authentication".to_string(),
"fix: Handle edge case in parser".to_string(),
"docs: Update README".to_string(),
];
let context = TemplateContext {
git_diff: SYNTHETIC_DIFF,
git_diff_stat: SYNTHETIC_DIFF_STAT,
branch: "feature/example",
recent_commits: Some(&recent_commits),
repo_name: "test-repo",
commits: &[],
target_branch: None,
};
let prompt = build_prompt(commit_generation_config, TemplateType::Commit, &context)?;
execute_llm_command(command, &prompt).map_err(|e| {
worktrunk::git::GitError::LlmCommandFailed {
command: command.clone(),
error: e.to_string(),
reproduction_command: None, }
.into()
})
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
fn commit_context<'a>(
git_diff: &'a str,
branch: &'a str,
recent_commits: Option<&'a Vec<String>>,
repo_name: &'a str,
) -> TemplateContext<'a> {
TemplateContext {
git_diff,
git_diff_stat: "",
branch,
recent_commits,
repo_name,
commits: &[],
target_branch: None,
}
}
fn squash_context<'a>(
git_diff: &'a str,
branch: &'a str,
recent_commits: Option<&'a Vec<String>>,
repo_name: &'a str,
commits: &'a [String],
target_branch: &'a str,
) -> TemplateContext<'a> {
TemplateContext {
git_diff,
git_diff_stat: "",
branch,
recent_commits,
repo_name,
commits,
target_branch: Some(target_branch),
}
}
#[test]
fn test_build_commit_prompt_with_default_template() {
let config = CommitGenerationConfig::default();
let context = commit_context("diff content", "main", None, "myrepo");
let prompt = build_prompt(&config, TemplateType::Commit, &context).unwrap();
assert_snapshot!(prompt, @r#"
<task>Write a commit message for the staged changes below.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match recent commit style (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<diffstat>
</diffstat>
<diff>
diff content
</diff>
<context>
Branch: main
</context>
"#);
let commits = vec!["feat: add feature".to_string(), "fix: bug".to_string()];
let context = commit_context("diff", "main", Some(&commits), "repo");
let prompt = build_prompt(&config, TemplateType::Commit, &context).unwrap();
assert_snapshot!(prompt, @r#"
<task>Write a commit message for the staged changes below.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match recent commit style (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<diffstat>
</diffstat>
<diff>
diff
</diff>
<context>
Branch: main
<recent_commits>
- feat: add feature
- fix: bug
</recent_commits>
</context>
"#);
let commits = vec![];
let context = commit_context("diff", "main", Some(&commits), "repo");
let prompt = build_prompt(&config, TemplateType::Commit, &context).unwrap();
assert_snapshot!(prompt, @r#"
<task>Write a commit message for the staged changes below.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match recent commit style (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<diffstat>
</diffstat>
<diff>
diff
</diff>
<context>
Branch: main
</context>
"#);
}
#[test]
fn test_build_commit_prompt_with_custom_template() {
let config = CommitGenerationConfig {
command: None,
template: Some("Branch: {{ branch }}\nDiff: {{ git_diff }}".to_string()),
template_file: None,
squash_template: None,
squash_template_file: None,
};
let context = commit_context("my diff", "feature", None, "repo");
let result = build_prompt(&config, TemplateType::Commit, &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Branch: feature\nDiff: my diff");
}
#[test]
fn test_build_commit_prompt_malformed_jinja() {
let config = CommitGenerationConfig {
command: None,
template: Some("{{ unclosed".to_string()),
template_file: None,
squash_template: None,
squash_template_file: None,
};
let context = commit_context("diff", "main", None, "repo");
let result = build_prompt(&config, TemplateType::Commit, &context);
assert!(result.is_err());
}
#[test]
fn test_build_commit_prompt_empty_template() {
let config = CommitGenerationConfig {
command: None,
template: Some(" ".to_string()),
template_file: None,
squash_template: None,
squash_template_file: None,
};
let context = commit_context("diff", "main", None, "repo");
let result = build_prompt(&config, TemplateType::Commit, &context);
assert_snapshot!(result.unwrap_err().to_string(), @"[31m✗[39m [31mTemplate is empty[39m");
}
#[test]
fn test_build_commit_prompt_with_all_variables() {
let config = CommitGenerationConfig {
command: None,
template: Some(
"Repo: {{ repo }}\nBranch: {{ branch }}\nDiff: {{ git_diff }}\n{% for c in recent_commits %}{{ c }}\n{% endfor %}"
.to_string(),
),
template_file: None,
squash_template: None,
squash_template_file: None,
};
let commits = vec!["commit1".to_string(), "commit2".to_string()];
let context = commit_context("my diff", "feature", Some(&commits), "myrepo");
let result = build_prompt(&config, TemplateType::Commit, &context);
assert!(result.is_ok());
let prompt = result.unwrap();
assert_eq!(
prompt,
"Repo: myrepo\nBranch: feature\nDiff: my diff\ncommit1\ncommit2\n"
);
}
#[test]
fn test_build_squash_prompt_with_default_template() {
let config = CommitGenerationConfig::default();
let commits = vec!["feat: A".to_string(), "fix: B".to_string()];
let context = squash_context("diff content", "feature", None, "repo", &commits, "main");
let prompt = build_prompt(&config, TemplateType::Squash, &context).unwrap();
assert_snapshot!(prompt, @r#"
<task>Write a commit message for the combined effect of these commits.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match the style of commits being squashed (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<commits branch="feature" target="main">
- fix: B
- feat: A
</commits>
<diffstat>
</diffstat>
<diff>
diff content
</diff>
"#);
}
#[test]
fn test_build_squash_prompt_with_custom_template() {
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: None,
squash_template: Some(
"Target: {{ target_branch }}\n{% for c in commits %}{{ c }}\n{% endfor %}"
.to_string(),
),
squash_template_file: None,
};
let commits = vec!["A".to_string(), "B".to_string()];
let context = squash_context("diff", "feature", None, "repo", &commits, "main");
let result = build_prompt(&config, TemplateType::Squash, &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Target: main\nB\nA\n");
}
#[test]
fn test_build_squash_prompt_empty_commits() {
let config = CommitGenerationConfig::default();
let commits: Vec<String> = vec![];
let context = squash_context("diff", "feature", None, "repo", &commits, "main");
let result = build_prompt(&config, TemplateType::Squash, &context);
assert!(result.is_ok());
}
#[test]
fn test_build_squash_prompt_malformed_jinja() {
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: None,
squash_template: Some("{% for x in commits %}{{ x }".to_string()),
squash_template_file: None,
};
let commits: Vec<String> = vec![];
let context = squash_context("diff", "feature", None, "repo", &commits, "main");
let result = build_prompt(&config, TemplateType::Squash, &context);
assert!(result.is_err());
}
#[test]
fn test_build_squash_prompt_empty_template() {
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: None,
squash_template: Some(" \n ".to_string()),
squash_template_file: None,
};
let commits: Vec<String> = vec![];
let context = squash_context("diff", "feature", None, "repo", &commits, "main");
let result = build_prompt(&config, TemplateType::Squash, &context);
assert_snapshot!(result.unwrap_err().to_string(), @"[31m✗[39m [31mSquash template is empty[39m");
}
#[test]
fn test_build_squash_prompt_with_all_variables() {
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: None,
squash_template: Some(
"Repo: {{ repo }}\nBranch: {{ branch }}\nTarget: {{ target_branch }}\nDiff: {{ git_diff }}\n{% for c in commits %}{{ c }}\n{% endfor %}{% for r in recent_commits %}style: {{ r }}\n{% endfor %}"
.to_string(),
),
squash_template_file: None,
};
let commits = vec!["A".to_string(), "B".to_string()];
let recent = vec!["prev1".to_string(), "prev2".to_string()];
let context = squash_context(
"the diff",
"feature",
Some(&recent),
"myrepo",
&commits,
"main",
);
let result = build_prompt(&config, TemplateType::Squash, &context);
assert!(result.is_ok());
let prompt = result.unwrap();
assert_eq!(
prompt,
"Repo: myrepo\nBranch: feature\nTarget: main\nDiff: the diff\nB\nA\nstyle: prev1\nstyle: prev2\n"
);
}
#[test]
fn test_build_commit_prompt_with_sophisticated_jinja() {
let config = CommitGenerationConfig {
command: None,
template: Some(
r#"=== {{ repo | upper }} ===
Branch: {{ branch }}
{%- if recent_commits %}
Commits: {{ recent_commits | length }}
{%- for c in recent_commits %}
- {{ loop.index }}. {{ c }}
{%- endfor %}
{%- else %}
No recent commits
{%- endif %}
Diff follows:
{{ git_diff }}"#
.to_string(),
),
template_file: None,
squash_template: None,
squash_template_file: None,
};
let commits = vec![
"feat: add auth".to_string(),
"fix: bug".to_string(),
"docs: update".to_string(),
];
let context = commit_context("my diff content", "feature-x", Some(&commits), "myapp");
let prompt = build_prompt(&config, TemplateType::Commit, &context).unwrap();
assert_snapshot!(prompt, @"
=== MYAPP ===
Branch: feature-x
Commits: 3
- 1. feat: add auth
- 2. fix: bug
- 3. docs: update
Diff follows:
my diff content
");
let context = commit_context("diff", "main", None, "test");
let prompt = build_prompt(&config, TemplateType::Commit, &context).unwrap();
assert_snapshot!(prompt, @"
=== TEST ===
Branch: main
No recent commits
Diff follows:
diff
");
}
#[test]
fn test_build_squash_prompt_with_sophisticated_jinja() {
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: None,
squash_template: Some(
r#"Squashing {{ commits | length }} commit(s) from {{ branch }} to {{ target_branch }}
{% if commits | length > 1 -%}
Multiple commits detected:
{%- for c in commits %}
{{ loop.index }}/{{ loop.length }}: {{ c }}
{%- endfor %}
{%- else -%}
Single commit: {{ commits[0] }}
{%- endif %}"#
.to_string(),
),
squash_template_file: None,
};
let commits = vec![
"commit A".to_string(),
"commit B".to_string(),
"commit C".to_string(),
];
let context = squash_context("diff", "feature", None, "repo", &commits, "main");
let prompt = build_prompt(&config, TemplateType::Squash, &context).unwrap();
assert_snapshot!(prompt, @"
Squashing 3 commit(s) from feature to main
Multiple commits detected:
1/3: commit C
2/3: commit B
3/3: commit A
");
let single_commit = vec!["solo commit".to_string()];
let context = squash_context("diff", "feature", None, "repo", &single_commit, "main");
let prompt = build_prompt(&config, TemplateType::Squash, &context).unwrap();
assert_snapshot!(prompt, @"
Squashing 1 commit(s) from feature to main
Single commit: solo commit
");
}
#[test]
fn test_build_commit_prompt_with_template_file() {
let temp_dir = std::env::temp_dir();
let template_path = temp_dir.join("test_commit_template.txt");
std::fs::write(
&template_path,
"Branch: {{ branch }}\nRepo: {{ repo }}\nDiff: {{ git_diff }}",
)
.unwrap();
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: Some(template_path.to_string_lossy().to_string()),
squash_template: None,
squash_template_file: None,
};
let context = commit_context("my diff", "feature", None, "myrepo");
let result = build_prompt(&config, TemplateType::Commit, &context);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"Branch: feature\nRepo: myrepo\nDiff: my diff"
);
std::fs::remove_file(&template_path).ok();
}
#[test]
fn test_build_commit_prompt_with_missing_template_file() {
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: Some("/nonexistent/path/template.txt".to_string()),
squash_template: None,
squash_template_file: None,
};
let context = commit_context("diff", "main", None, "repo");
let result = build_prompt(&config, TemplateType::Commit, &context);
let err = result.unwrap_err().to_string();
assert!(err.contains("Failed to read template-file"), "{err}");
assert!(err.contains("/nonexistent/path/template.txt"), "{err}");
}
#[test]
fn test_build_squash_prompt_with_template_file() {
let temp_dir = std::env::temp_dir();
let template_path = temp_dir.join("test_squash_template.txt");
std::fs::write(
&template_path,
"Target: {{ target_branch }}\nBranch: {{ branch }}\n{% for c in commits %}{{ c }}\n{% endfor %}",
)
.unwrap();
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: None,
squash_template: None,
squash_template_file: Some(template_path.to_string_lossy().to_string()),
};
let commits = vec!["A".to_string(), "B".to_string()];
let context = squash_context("diff", "feature", None, "repo", &commits, "main");
let result = build_prompt(&config, TemplateType::Squash, &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Target: main\nBranch: feature\nB\nA\n");
std::fs::remove_file(&template_path).ok();
}
#[test]
fn test_build_commit_prompt_with_tilde_expansion() {
let config = CommitGenerationConfig {
command: None,
template: None,
template_file: Some("~/nonexistent_template_for_test.txt".to_string()),
squash_template: None,
squash_template_file: None,
};
let context = commit_context("diff", "main", None, "repo");
let result = build_prompt(&config, TemplateType::Commit, &context);
let err = result.unwrap_err().to_string();
assert!(err.contains("Failed to read template-file"), "{err}");
assert!(err.contains("~/nonexistent_template_for_test.txt"), "{err}");
}
#[test]
fn test_commit_template_can_access_squash_variables() {
let config = CommitGenerationConfig {
command: None,
template: Some(
"Branch: {{ branch }}\nTarget: {{ target_branch }}\nCommits: {{ commits | length }}"
.to_string(),
),
template_file: None,
squash_template: None,
squash_template_file: None,
};
let context = commit_context("diff", "feature", None, "repo");
let result = build_prompt(&config, TemplateType::Commit, &context);
assert!(result.is_ok());
let prompt = result.unwrap();
assert_eq!(prompt, "Branch: feature\nTarget: \nCommits: 0");
}
#[test]
fn test_is_lock_file() {
assert!(is_lock_file("Cargo.lock"));
assert!(is_lock_file("package-lock.json"));
assert!(is_lock_file("pnpm-lock.yaml"));
assert!(is_lock_file("yarn-lock.yaml"));
assert!(is_lock_file(".terraform.lock.hcl"));
assert!(is_lock_file("terraform.lock.hcl"));
assert!(is_lock_file("path/to/Cargo.lock"));
assert!(!is_lock_file("src/main.rs"));
assert!(!is_lock_file("README.md"));
assert!(!is_lock_file("config.toml"));
assert!(!is_lock_file("lockfile.txt"));
assert!(!is_lock_file("my.lock.rs")); }
#[test]
fn test_parse_diff_sections() {
assert!(parse_diff_sections("").is_empty());
let diff = "diff --git a/foo.rs b/foo.rs\nsome content\n";
let sections = parse_diff_sections(diff);
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].0, "foo.rs");
let diff = r#"diff --git a/src/foo.rs b/src/foo.rs
index abc..def 100644
--- a/src/foo.rs
+++ b/src/foo.rs
@@ -1,3 +1,4 @@
fn foo() {}
+fn bar() {}
diff --git a/Cargo.lock b/Cargo.lock
index 111..222 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,100 +1,150 @@
lots of lock content
"#;
let sections = parse_diff_sections(diff);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].0, "src/foo.rs");
assert_snapshot!(sections[0].1, @"
diff --git a/src/foo.rs b/src/foo.rs
index abc..def 100644
--- a/src/foo.rs
+++ b/src/foo.rs
@@ -1,3 +1,4 @@
fn foo() {}
+fn bar() {}
");
assert_eq!(sections[1].0, "Cargo.lock");
assert_snapshot!(sections[1].1, @"
diff --git a/Cargo.lock b/Cargo.lock
index 111..222 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,100 +1,150 @@
lots of lock content
");
}
#[test]
fn test_truncate_diff_section() {
let section = r#"diff --git a/file.rs b/file.rs
index abc..def 100644
--- a/file.rs
+++ b/file.rs
@@ -1,10 +1,15 @@
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
"#;
let truncated = truncate_diff_section(section, 8);
assert_snapshot!(truncated, @"
diff --git a/file.rs b/file.rs
index abc..def 100644
--- a/file.rs
+++ b/file.rs
@@ -1,10 +1,15 @@
line 1
line 2
line 3
... (7 lines omitted)
");
}
#[test]
fn test_prepare_diff_small_diff_passes_through() {
let diff = "small diff".to_string();
let stat = "1 file changed".to_string();
let prepared = prepare_diff(diff.clone(), stat.clone());
assert_eq!(prepared.diff, diff);
assert_eq!(prepared.stat, stat);
}
#[test]
fn test_prepare_diff_filters_lock_files() {
let regular_content = "x".repeat(100_000);
let lock_content = "y".repeat(350_000);
let diff = format!(
r#"diff --git a/src/main.rs b/src/main.rs
{}
diff --git a/Cargo.lock b/Cargo.lock
{}
"#,
regular_content, lock_content
);
let stat = "2 files changed".to_string();
let prepared = prepare_diff(diff, stat);
assert!(!prepared.diff.contains("Cargo.lock"));
assert!(prepared.diff.contains("src/main.rs"));
}
#[test]
fn test_prepare_diff_filters_then_truncates() {
let mut diff = String::new();
for i in 0..100 {
diff.push_str(&format!(
"diff --git a/file{}.rs b/file{}.rs\n{}\n",
i,
i,
"x".repeat(5000)
));
}
let stat = "100 files changed".to_string();
let prepared = prepare_diff(diff, stat);
assert!(prepared.diff.contains("files omitted"));
}
#[test]
fn test_truncate_diff_section_short() {
let section = "line1\nline2\nline3\n";
let truncated = truncate_diff_section(section, 10);
assert_eq!(truncated, section);
}
#[test]
fn test_truncate_diff_section_no_header() {
let section = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n";
let truncated = truncate_diff_section(section, 3);
assert_snapshot!(truncated, @"
line1
line2
line3
... (5 lines omitted)
");
}
#[test]
fn test_format_reproduction_command() {
let result = format_reproduction_command("git diff", "llm -m haiku");
assert_snapshot!(result, @"git diff | llm -m haiku");
let result = format_reproduction_command("git diff", "MAX_THINKING_TOKENS=0 claude -p");
assert_snapshot!(result, @"git diff | sh -c 'MAX_THINKING_TOKENS=0 claude -p'");
let result = format_reproduction_command("git diff", "cmd1 && cmd2");
assert_snapshot!(result, @"git diff | sh -c 'cmd1 && cmd2'");
}
}