// SPDX-License-Identifier: LicenseRef-Commercial // Git Context Injection — lightweight git CLI wrapper for agent session context. // Full git2/libgit2 implementation deferred until git2 dep is added. use serde::Serialize; use std::process::Command; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitContext { pub branch: String, pub last_commits: Vec, pub modified_files: Vec, pub has_unstaged: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CommitSummary { pub hash: String, pub message: String, pub author: String, pub timestamp: i64, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct BranchInfo { pub name: String, pub is_protected: bool, pub upstream: Option, pub ahead: i64, pub behind: i64, } fn git_cmd(project_path: &str, args: &[&str]) -> Result { if project_path.starts_with('-') { return Err("Invalid project path: cannot start with '-'".into()); } let output = Command::new("git") .args(["-C", project_path]) .args(args) .output() .map_err(|e| format!("Failed to run git: {e}"))?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); Err(format!("git error: {stderr}")) } } fn parse_log_line(line: &str) -> Option { // Format: hash|author|timestamp|message let parts: Vec<&str> = line.splitn(4, '|').collect(); if parts.len() < 4 { return None; } Some(CommitSummary { hash: parts[0].to_string(), author: parts[1].to_string(), timestamp: parts[2].parse().unwrap_or(0), message: parts[3].to_string(), }) } #[tauri::command] pub fn pro_git_context(project_path: String) -> Result { let branch = git_cmd(&project_path, &["branch", "--show-current"]) .unwrap_or_else(|_| "unknown".into()); let log_output = git_cmd( &project_path, &["log", "--format=%H|%an|%at|%s", "-10"], ).unwrap_or_default(); let last_commits: Vec = log_output .lines() .filter_map(parse_log_line) .collect(); let status_output = git_cmd(&project_path, &["status", "--porcelain"]) .unwrap_or_default(); let modified_files: Vec = status_output .lines() .filter(|l| !l.is_empty()) .map(|l| { // Format: XY filename (first 3 chars are status + space) if l.len() > 3 { l[3..].to_string() } else { l.to_string() } }) .collect(); let has_unstaged = status_output.lines().any(|l| { l.len() >= 2 && !l[1..2].eq(" ") && !l[1..2].eq("?") }); Ok(GitContext { branch, last_commits, modified_files, has_unstaged }) } #[tauri::command] pub fn pro_git_inject(project_path: String, max_tokens: Option) -> Result { let ctx = pro_git_context(project_path)?; let max_chars = (max_tokens.unwrap_or(1000) * 3) as usize; let mut md = String::new(); md.push_str(&format!("## Git Context\n\n**Branch:** {}\n\n", ctx.branch)); if !ctx.last_commits.is_empty() { md.push_str("**Recent commits:**\n"); for c in &ctx.last_commits { let short_hash = if c.hash.len() >= 7 { &c.hash[..7] } else { &c.hash }; let line = format!("- {} {}\n", short_hash, c.message); if md.len() + line.len() > max_chars { break; } md.push_str(&line); } md.push('\n'); } if !ctx.modified_files.is_empty() { md.push_str("**Modified files:**\n"); for f in &ctx.modified_files { let line = format!("- {f}\n"); if md.len() + line.len() > max_chars { break; } md.push_str(&line); } } Ok(md) } #[tauri::command] pub fn pro_git_branch_info(project_path: String) -> Result { let name = git_cmd(&project_path, &["branch", "--show-current"]) .unwrap_or_else(|_| "unknown".into()); let upstream = git_cmd( &project_path, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], ).ok(); let (ahead, behind) = if upstream.is_some() { let counts = git_cmd( &project_path, &["rev-list", "--left-right", "--count", "HEAD...@{u}"], ).unwrap_or_else(|_| "0\t0".into()); let parts: Vec<&str> = counts.split('\t').collect(); let a = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); let b = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); (a, b) } else { (0, 0) }; let is_protected = matches!(name.as_str(), "main" | "master") || name.starts_with("release/"); Ok(BranchInfo { name, is_protected, upstream, ahead, behind }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_git_context_serializes_camel_case() { let ctx = GitContext { branch: "main".into(), last_commits: vec![], modified_files: vec!["src/lib.rs".into()], has_unstaged: true, }; let json = serde_json::to_string(&ctx).unwrap(); assert!(json.contains("lastCommits")); assert!(json.contains("modifiedFiles")); assert!(json.contains("hasUnstaged")); } #[test] fn test_commit_summary_serializes_camel_case() { let c = CommitSummary { hash: "abc1234".into(), message: "feat: add router".into(), author: "dev".into(), timestamp: 1710000000, }; let json = serde_json::to_string(&c).unwrap(); assert!(json.contains("\"hash\":\"abc1234\"")); assert!(json.contains("\"timestamp\":1710000000")); } #[test] fn test_branch_info_serializes_camel_case() { let b = BranchInfo { name: "feature/test".into(), is_protected: false, upstream: Some("origin/feature/test".into()), ahead: 2, behind: 0, }; let json = serde_json::to_string(&b).unwrap(); assert!(json.contains("isProtected")); } #[test] fn test_parse_log_line() { let line = "abc123|Author Name|1710000000|feat: test commit"; let c = parse_log_line(line).unwrap(); assert_eq!(c.hash, "abc123"); assert_eq!(c.author, "Author Name"); assert_eq!(c.message, "feat: test commit"); } }