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
|
||||
|
||||
mod context;
|
||||
mod git;
|
||||
mod provider;
|
||||
mod tools;
|
||||
|
||||
|
|
@ -77,6 +78,26 @@ struct Cli {
|
|||
/// Deaktiver automatisk planmodus (brukes normalt for komplekse oppgaver)
|
||||
#[arg(long)]
|
||||
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.
|
||||
|
|
@ -480,7 +501,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
"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 {
|
||||
context_window: provider.context_window(),
|
||||
..Default::default()
|
||||
|
|
@ -525,6 +546,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
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 {
|
||||
run_interactive(&mut session).await?;
|
||||
} else {
|
||||
|
|
@ -547,7 +586,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
// Direct execution
|
||||
session.messages.push(Message {
|
||||
role: "user".into(),
|
||||
content: Some(task),
|
||||
content: Some(task.clone()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
|
@ -556,6 +595,52 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
|
||||
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) {
|
||||
eprintln!("\n⚠ Budsjettgrense nådd. Oppgaven er ikke fullført.");
|
||||
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")
|
||||
}
|
||||
|
||||
/// Build system prompt with context about the project.
|
||||
fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
|
||||
/// Build system prompt with context about the project and git state.
|
||||
async fn build_system_prompt(custom: Option<&str>, working_dir: &Path) -> String {
|
||||
let mut prompt = String::new();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// 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("- Les relevante filer før du endrer dem\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("- 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//!
|
||||
//! Each tool returns a string result that gets sent back to the LLM.
|
||||
|
||||
use crate::git;
|
||||
use crate::provider::{ToolDef, FunctionDef};
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
|
@ -20,6 +21,7 @@ pub async fn execute_tool(
|
|||
"grep" => grep(args, working_dir).await,
|
||||
"glob" => glob_search(args, working_dir).await,
|
||||
"list_files" => list_files(args, working_dir).await,
|
||||
"git" => git_tool(args, working_dir).await,
|
||||
_ => 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
|
||||
// ============================================================================
|
||||
|
||||
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 {
|
||||
let p = Path::new(file_path);
|
||||
if p.is_absolute() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue