Implementer git-integrasjon i synops-agent
- Ny modul git.rs: status, log, diff, blame, commit, push, branch - Nytt git-verktøy for LLM (8 subkommandoer) - Auto-inkluder git-kontekst i system prompt (branch, status, siste commits) - CLI-args: --commit-msg, --no-commit, --push/--no-push, --branch - Auto-commit og push etter fullført batch-oppgave - Diff-visning i output etter oppgave - 4 nye tester for git-modulen (alle bestått)
This commit is contained in:
parent
0c0a6210ad
commit
e38c77ea00
3 changed files with 431 additions and 5 deletions
264
tools/synops-agent/src/git.rs
Normal file
264
tools/synops-agent/src/git.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
//! Git integration — context gathering, commit, push, branch management.
|
||||||
|
//!
|
||||||
|
//! Used both by the harness (auto-commit, branch-per-task) and as a tool
|
||||||
|
//! the LLM can call directly.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
/// Run a git command in the working directory. Returns stdout on success.
|
||||||
|
async fn git(working_dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||||
|
let output = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(working_dir)
|
||||||
|
.output(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "git command timed out".to_string())?
|
||||||
|
.map_err(|e| format!("Failed to run git: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(stdout)
|
||||||
|
} else {
|
||||||
|
Err(format!("{}\n{}", stdout, stderr).trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the working directory is a git repo.
|
||||||
|
pub async fn is_git_repo(working_dir: &Path) -> bool {
|
||||||
|
git(working_dir, &["rev-parse", "--is-inside-work-tree"])
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current branch name.
|
||||||
|
pub async fn current_branch(working_dir: &Path) -> Result<String, String> {
|
||||||
|
git(working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
.await
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get git status (short format).
|
||||||
|
pub async fn status(working_dir: &Path) -> Result<String, String> {
|
||||||
|
git(working_dir, &["status", "--short"]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recent commits (one-line format).
|
||||||
|
pub async fn log_oneline(working_dir: &Path, count: usize) -> Result<String, String> {
|
||||||
|
let n = format!("-{}", count);
|
||||||
|
git(working_dir, &["log", "--oneline", &n]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get diff of staged and unstaged changes.
|
||||||
|
pub async fn diff(working_dir: &Path) -> Result<String, String> {
|
||||||
|
// Unstaged changes
|
||||||
|
let unstaged = git(working_dir, &["diff"]).await.unwrap_or_default();
|
||||||
|
// Staged changes
|
||||||
|
let staged = git(working_dir, &["diff", "--cached"]).await.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
if !staged.is_empty() {
|
||||||
|
result.push_str("=== Staged changes ===\n");
|
||||||
|
result.push_str(&staged);
|
||||||
|
}
|
||||||
|
if !unstaged.is_empty() {
|
||||||
|
if !result.is_empty() {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
result.push_str("=== Unstaged changes ===\n");
|
||||||
|
result.push_str(&unstaged);
|
||||||
|
}
|
||||||
|
if result.is_empty() {
|
||||||
|
result.push_str("No changes");
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get diff stat (summary of changes).
|
||||||
|
pub async fn diff_stat(working_dir: &Path) -> Result<String, String> {
|
||||||
|
git(working_dir, &["diff", "--stat", "HEAD"]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get blame for a file (or a range of lines).
|
||||||
|
pub async fn blame(
|
||||||
|
working_dir: &Path,
|
||||||
|
file: &str,
|
||||||
|
start_line: Option<u32>,
|
||||||
|
end_line: Option<u32>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut args = vec!["blame", "--date=short"];
|
||||||
|
let range;
|
||||||
|
if let (Some(start), Some(end)) = (start_line, end_line) {
|
||||||
|
range = format!("-L {},{}", start, end);
|
||||||
|
args.push(&range);
|
||||||
|
}
|
||||||
|
args.push(file);
|
||||||
|
git(working_dir, &args).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage all changes and commit.
|
||||||
|
pub async fn commit(working_dir: &Path, message: &str) -> Result<String, String> {
|
||||||
|
// Stage all tracked changes + new files
|
||||||
|
git(working_dir, &["add", "-A"]).await?;
|
||||||
|
|
||||||
|
// Check if there's anything to commit
|
||||||
|
let status = git(working_dir, &["status", "--porcelain"]).await?;
|
||||||
|
if status.trim().is_empty() {
|
||||||
|
return Ok("Nothing to commit".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
git(working_dir, &["commit", "-m", message]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push to remote.
|
||||||
|
pub async fn push(working_dir: &Path) -> Result<String, String> {
|
||||||
|
// Try regular push first
|
||||||
|
match git(working_dir, &["push"]).await {
|
||||||
|
Ok(out) => Ok(out),
|
||||||
|
Err(e) => {
|
||||||
|
// If no upstream, set it
|
||||||
|
if e.contains("no upstream") || e.contains("has no upstream") {
|
||||||
|
let branch = current_branch(working_dir).await?;
|
||||||
|
git(working_dir, &["push", "-u", "origin", &branch]).await
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and switch to a new branch.
|
||||||
|
pub async fn create_branch(working_dir: &Path, branch_name: &str) -> Result<String, String> {
|
||||||
|
git(working_dir, &["checkout", "-b", branch_name]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to an existing branch.
|
||||||
|
pub async fn checkout(working_dir: &Path, branch_name: &str) -> Result<String, String> {
|
||||||
|
git(working_dir, &["checkout", branch_name]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a git context string for the system prompt.
|
||||||
|
/// Includes branch, status, and recent commits.
|
||||||
|
pub async fn gather_context(working_dir: &Path) -> Option<String> {
|
||||||
|
if !is_git_repo(working_dir).await {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ctx = String::from("# Git-kontekst\n\n");
|
||||||
|
|
||||||
|
if let Ok(branch) = current_branch(working_dir).await {
|
||||||
|
ctx.push_str(&format!("Branch: {}\n", branch));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(st) = status(working_dir).await {
|
||||||
|
let st = st.trim();
|
||||||
|
if st.is_empty() {
|
||||||
|
ctx.push_str("Status: ren (ingen endringer)\n");
|
||||||
|
} else {
|
||||||
|
let line_count = st.lines().count();
|
||||||
|
if line_count > 20 {
|
||||||
|
// Summarize if too many changes
|
||||||
|
ctx.push_str(&format!("Status: {} endrede filer\n", line_count));
|
||||||
|
} else {
|
||||||
|
ctx.push_str(&format!("Status:\n{}\n", st));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(log) = log_oneline(working_dir, 10).await {
|
||||||
|
let log = log.trim();
|
||||||
|
if !log.is_empty() {
|
||||||
|
ctx.push_str(&format!("\nSiste commits:\n{}\n", log));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a commit message based on the diff.
|
||||||
|
/// Returns a short summary suitable for a commit message.
|
||||||
|
pub async fn auto_commit_message(working_dir: &Path) -> Result<String, String> {
|
||||||
|
let stat = match git(working_dir, &["diff", "--cached", "--stat"]).await {
|
||||||
|
Ok(s) if !s.trim().is_empty() => s,
|
||||||
|
_ => git(working_dir, &["diff", "--stat", "HEAD"]).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
if stat.trim().is_empty() {
|
||||||
|
return Err("No changes to describe".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file list from stat
|
||||||
|
let files: Vec<&str> = stat
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.contains('|'))
|
||||||
|
.map(|l| l.split('|').next().unwrap_or("").trim())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return Ok("Oppdatering".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a descriptive message
|
||||||
|
if files.len() == 1 {
|
||||||
|
Ok(format!("Oppdater {}", files[0]))
|
||||||
|
} else if files.len() <= 5 {
|
||||||
|
Ok(format!("Oppdater {} filer: {}", files.len(), files.join(", ")))
|
||||||
|
} else {
|
||||||
|
// Group by directory
|
||||||
|
let dirs: std::collections::HashSet<&str> = files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.rsplit_once('/').map(|(d, _)| d))
|
||||||
|
.collect();
|
||||||
|
if dirs.len() <= 3 {
|
||||||
|
let dirs_str: Vec<&&str> = dirs.iter().collect();
|
||||||
|
Ok(format!(
|
||||||
|
"Oppdater {} filer i {}",
|
||||||
|
files.len(),
|
||||||
|
dirs_str.iter().map(|d| **d).collect::<Vec<_>>().join(", ")
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(format!("Oppdater {} filer", files.len()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_is_git_repo() {
|
||||||
|
// The synops repo itself should be a git repo
|
||||||
|
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
assert!(is_git_repo(&repo).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_current_branch() {
|
||||||
|
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let branch = current_branch(&repo).await;
|
||||||
|
assert!(branch.is_ok());
|
||||||
|
assert!(!branch.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_gather_context() {
|
||||||
|
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let ctx = gather_context(&repo).await;
|
||||||
|
assert!(ctx.is_some());
|
||||||
|
let ctx = ctx.unwrap();
|
||||||
|
assert!(ctx.contains("Branch:"));
|
||||||
|
assert!(ctx.contains("Siste commits:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_not_git_repo() {
|
||||||
|
assert!(!is_git_repo(Path::new("/tmp")).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
//! synops-agent -i --model gemini/gemini-2.5-flash
|
//! synops-agent -i --model gemini/gemini-2.5-flash
|
||||||
|
|
||||||
mod context;
|
mod context;
|
||||||
|
mod git;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
|
|
@ -77,6 +78,26 @@ struct Cli {
|
||||||
/// Deaktiver automatisk planmodus (brukes normalt for komplekse oppgaver)
|
/// Deaktiver automatisk planmodus (brukes normalt for komplekse oppgaver)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_plan: bool,
|
no_plan: bool,
|
||||||
|
|
||||||
|
/// Commit-melding etter fullført oppgave (auto-generert hvis utelatt)
|
||||||
|
#[arg(long)]
|
||||||
|
commit_msg: Option<String>,
|
||||||
|
|
||||||
|
/// Ikke auto-commit etter fullført oppgave
|
||||||
|
#[arg(long)]
|
||||||
|
no_commit: bool,
|
||||||
|
|
||||||
|
/// Push etter commit (default: true, bruk --no-push for å deaktivere)
|
||||||
|
#[arg(long, default_value = "true", action = clap::ArgAction::Set)]
|
||||||
|
push: bool,
|
||||||
|
|
||||||
|
/// Deaktiver push etter commit
|
||||||
|
#[arg(long)]
|
||||||
|
no_push: bool,
|
||||||
|
|
||||||
|
/// Opprett og bytt til en egen branch for oppgaven
|
||||||
|
#[arg(long)]
|
||||||
|
branch: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared state for the agent session.
|
/// Shared state for the agent session.
|
||||||
|
|
@ -480,7 +501,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
"Starter agent"
|
"Starter agent"
|
||||||
);
|
);
|
||||||
|
|
||||||
let system_prompt = build_system_prompt(cli.system.as_deref(), &cli.working_dir);
|
let system_prompt = build_system_prompt(cli.system.as_deref(), &cli.working_dir).await;
|
||||||
let compaction_config = CompactionConfig {
|
let compaction_config = CompactionConfig {
|
||||||
context_window: provider.context_window(),
|
context_window: provider.context_window(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -525,6 +546,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
interrupted: interrupted.clone(),
|
interrupted: interrupted.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Git: branch-per-task
|
||||||
|
if let Some(ref branch_name) = cli.branch {
|
||||||
|
match git::create_branch(&cli.working_dir, branch_name).await {
|
||||||
|
Ok(_) => tracing::info!(branch = branch_name.as_str(), "Opprettet og byttet til branch"),
|
||||||
|
Err(e) => {
|
||||||
|
// Branch might already exist — try checkout
|
||||||
|
match git::checkout(&cli.working_dir, branch_name).await {
|
||||||
|
Ok(_) => tracing::info!(branch = branch_name.as_str(), "Byttet til eksisterende branch"),
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!(error = %e, "Kunne ikke opprette eller bytte til branch");
|
||||||
|
eprintln!("Feil: Kunne ikke opprette branch '{}': {}", branch_name, e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cli.interactive {
|
if cli.interactive {
|
||||||
run_interactive(&mut session).await?;
|
run_interactive(&mut session).await?;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -547,7 +586,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Direct execution
|
// Direct execution
|
||||||
session.messages.push(Message {
|
session.messages.push(Message {
|
||||||
role: "user".into(),
|
role: "user".into(),
|
||||||
content: Some(task),
|
content: Some(task.clone()),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
});
|
});
|
||||||
|
|
@ -556,6 +595,52 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
session.print_summary();
|
session.print_summary();
|
||||||
|
|
||||||
|
// Show diff after task
|
||||||
|
if let Ok(diff_output) = git::diff(&cli.working_dir).await {
|
||||||
|
if diff_output != "No changes" {
|
||||||
|
eprintln!("\n--- Endringer ---");
|
||||||
|
// Show stat instead of full diff for readability
|
||||||
|
if let Ok(stat) = git::diff_stat(&cli.working_dir).await {
|
||||||
|
eprintln!("{}", stat.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-commit after successful task
|
||||||
|
let should_commit = !cli.no_commit && matches!(result, TurnResult::Done);
|
||||||
|
if should_commit {
|
||||||
|
let commit_msg = if let Some(ref msg) = cli.commit_msg {
|
||||||
|
msg.clone()
|
||||||
|
} else {
|
||||||
|
// Auto-generate from diff
|
||||||
|
git::auto_commit_message(&cli.working_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| format!("synops-agent: {}", task.chars().take(72).collect::<String>()))
|
||||||
|
};
|
||||||
|
|
||||||
|
match git::commit(&cli.working_dir, &commit_msg).await {
|
||||||
|
Ok(output) => {
|
||||||
|
let output = output.trim();
|
||||||
|
if output == "Nothing to commit" {
|
||||||
|
tracing::info!("Ingen endringer å committe");
|
||||||
|
} else {
|
||||||
|
tracing::info!(message = commit_msg.as_str(), "Auto-commit");
|
||||||
|
eprintln!("\n--- Commit ---\n{}", output);
|
||||||
|
|
||||||
|
// Push if enabled
|
||||||
|
let should_push = cli.push && !cli.no_push;
|
||||||
|
if should_push {
|
||||||
|
match git::push(&cli.working_dir).await {
|
||||||
|
Ok(_) => tracing::info!("Pushet til remote"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "Push feilet"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "Commit feilet"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if matches!(result, TurnResult::BudgetExhausted) {
|
if matches!(result, TurnResult::BudgetExhausted) {
|
||||||
eprintln!("\n⚠ Budsjettgrense nådd. Oppgaven er ikke fullført.");
|
eprintln!("\n⚠ Budsjettgrense nådd. Oppgaven er ikke fullført.");
|
||||||
eprintln!(" Gjenstående arbeid bør fortsettes med høyere --max-cost");
|
eprintln!(" Gjenstående arbeid bør fortsettes med høyere --max-cost");
|
||||||
|
|
@ -765,8 +850,8 @@ fn dirs_history_path() -> PathBuf {
|
||||||
dir.join("agent_history.txt")
|
dir.join("agent_history.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build system prompt with context about the project.
|
/// Build system prompt with context about the project and git state.
|
||||||
fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
|
async fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
|
||||||
let mut prompt = String::new();
|
let mut prompt = String::new();
|
||||||
|
|
||||||
prompt.push_str("Du er synops-agent, en autonom utviklerassistent for Synops-plattformen.\n");
|
prompt.push_str("Du er synops-agent, en autonom utviklerassistent for Synops-plattformen.\n");
|
||||||
|
|
@ -797,11 +882,18 @@ fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
|
||||||
prompt.push_str("\n\n");
|
prompt.push_str("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Git context
|
||||||
|
if let Some(git_ctx) = git::gather_context(working_dir).await {
|
||||||
|
prompt.push_str(&git_ctx);
|
||||||
|
prompt.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
prompt.push_str("Regler:\n");
|
prompt.push_str("Regler:\n");
|
||||||
prompt.push_str("- Les relevante filer før du endrer dem\n");
|
prompt.push_str("- Les relevante filer før du endrer dem\n");
|
||||||
prompt.push_str("- Gjør minimale, fokuserte endringer\n");
|
prompt.push_str("- Gjør minimale, fokuserte endringer\n");
|
||||||
prompt.push_str("- Test at kode kompilerer (cargo check, npm run build)\n");
|
prompt.push_str("- Test at kode kompilerer (cargo check, npm run build)\n");
|
||||||
prompt.push_str("- Commit og push når oppgaven er ferdig\n");
|
prompt.push_str("- Bruk git-verktøyet for å se log, blame, diff og status\n");
|
||||||
|
prompt.push_str("- Commit og push når oppgaven er ferdig (med beskrivende melding)\n");
|
||||||
|
|
||||||
prompt
|
prompt
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! Each tool returns a string result that gets sent back to the LLM.
|
//! Each tool returns a string result that gets sent back to the LLM.
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
use crate::provider::{ToolDef, FunctionDef};
|
use crate::provider::{ToolDef, FunctionDef};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
@ -20,6 +21,7 @@ pub async fn execute_tool(
|
||||||
"grep" => grep(args, working_dir).await,
|
"grep" => grep(args, working_dir).await,
|
||||||
"glob" => glob_search(args, working_dir).await,
|
"glob" => glob_search(args, working_dir).await,
|
||||||
"list_files" => list_files(args, working_dir).await,
|
"list_files" => list_files(args, working_dir).await,
|
||||||
|
"git" => git_tool(args, working_dir).await,
|
||||||
_ => Err(format!("Unknown tool: {}", name)),
|
_ => Err(format!("Unknown tool: {}", name)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -139,6 +141,30 @@ pub fn tool_definitions() -> Vec<ToolDef> {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ToolDef {
|
||||||
|
r#type: "function".into(),
|
||||||
|
function: FunctionDef {
|
||||||
|
name: "git".into(),
|
||||||
|
description: "Run git operations. Subcommands: status, log, diff, blame, commit, push, branch, checkout.".into(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"subcommand": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Git operation: status, log, diff, blame, commit, push, branch, checkout",
|
||||||
|
"enum": ["status", "log", "diff", "blame", "commit", "push", "branch", "checkout"]
|
||||||
|
},
|
||||||
|
"message": { "type": "string", "description": "Commit message (for 'commit')" },
|
||||||
|
"file": { "type": "string", "description": "File path (for 'blame')" },
|
||||||
|
"start_line": { "type": "integer", "description": "Start line for blame range" },
|
||||||
|
"end_line": { "type": "integer", "description": "End line for blame range" },
|
||||||
|
"count": { "type": "integer", "description": "Number of log entries (default: 20)" },
|
||||||
|
"branch_name": { "type": "string", "description": "Branch name (for 'branch' or 'checkout')" }
|
||||||
|
},
|
||||||
|
"required": ["subcommand"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,6 +388,50 @@ async fn list_files(
|
||||||
// Helpers
|
// Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
async fn git_tool(
|
||||||
|
args: &serde_json::Value,
|
||||||
|
working_dir: &Path,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let subcommand = args["subcommand"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("subcommand is required")?;
|
||||||
|
|
||||||
|
match subcommand {
|
||||||
|
"status" => git::status(working_dir).await,
|
||||||
|
"log" => {
|
||||||
|
let count = args["count"].as_u64().unwrap_or(20) as usize;
|
||||||
|
git::log_oneline(working_dir, count).await
|
||||||
|
}
|
||||||
|
"diff" => git::diff(working_dir).await,
|
||||||
|
"blame" => {
|
||||||
|
let file = args["file"].as_str().ok_or("file is required for blame")?;
|
||||||
|
let start = args["start_line"].as_u64().map(|n| n as u32);
|
||||||
|
let end = args["end_line"].as_u64().map(|n| n as u32);
|
||||||
|
git::blame(working_dir, file, start, end).await
|
||||||
|
}
|
||||||
|
"commit" => {
|
||||||
|
let message = args["message"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("message is required for commit")?;
|
||||||
|
git::commit(working_dir, message).await
|
||||||
|
}
|
||||||
|
"push" => git::push(working_dir).await,
|
||||||
|
"branch" => {
|
||||||
|
let name = args["branch_name"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("branch_name is required")?;
|
||||||
|
git::create_branch(working_dir, name).await
|
||||||
|
}
|
||||||
|
"checkout" => {
|
||||||
|
let name = args["branch_name"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("branch_name is required")?;
|
||||||
|
git::checkout(working_dir, name).await
|
||||||
|
}
|
||||||
|
_ => Err(format!("Unknown git subcommand: {}", subcommand)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_path(file_path: &str, working_dir: &Path) -> std::path::PathBuf {
|
fn resolve_path(file_path: &str, working_dir: &Path) -> std::path::PathBuf {
|
||||||
let p = Path::new(file_path);
|
let p = Path::new(file_path);
|
||||||
if p.is_absolute() {
|
if p.is_absolute() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue