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:
vegard 2026-03-19 18:26:09 +00:00
parent 0c0a6210ad
commit e38c77ea00
3 changed files with 431 additions and 5 deletions

View 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);
}
}

View file

@ -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
}

View file

@ -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() {