From e38c77ea00457ca8fb876e84df5d3f65018a8969 Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 18:26:09 +0000 Subject: [PATCH] Implementer git-integrasjon i synops-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- tools/synops-agent/src/git.rs | 264 ++++++++++++++++++++++++++++++++ tools/synops-agent/src/main.rs | 102 +++++++++++- tools/synops-agent/src/tools.rs | 70 +++++++++ 3 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 tools/synops-agent/src/git.rs diff --git a/tools/synops-agent/src/git.rs b/tools/synops-agent/src/git.rs new file mode 100644 index 0000000..8694e23 --- /dev/null +++ b/tools/synops-agent/src/git.rs @@ -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 { + 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 { + 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 { + git(working_dir, &["status", "--short"]).await +} + +/// Get recent commits (one-line format). +pub async fn log_oneline(working_dir: &Path, count: usize) -> Result { + 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 { + // 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 { + 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, + end_line: Option, +) -> Result { + 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 { + // 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 { + // 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 { + git(working_dir, &["checkout", "-b", branch_name]).await +} + +/// Switch to an existing branch. +pub async fn checkout(working_dir: &Path, branch_name: &str) -> Result { + 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 { + 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 { + 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::>().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); + } +} diff --git a/tools/synops-agent/src/main.rs b/tools/synops-agent/src/main.rs index 41d0dde..43b90b7 100644 --- a/tools/synops-agent/src/main.rs +++ b/tools/synops-agent/src/main.rs @@ -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, + + /// 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, } /// Shared state for the agent session. @@ -480,7 +501,7 @@ async fn main() -> Result<(), Box> { "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> { 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> { // 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> { 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::())) + }; + + 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 } diff --git a/tools/synops-agent/src/tools.rs b/tools/synops-agent/src/tools.rs index 468c849..813f74e 100644 --- a/tools/synops-agent/src/tools.rs +++ b/tools/synops-agent/src/tools.rs @@ -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 { + 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 { + 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() {