feat(pro): implement all 3 commercial phases

Phase 1 — Cost Intelligence:
- budget.rs: per-project token budgets, soft/hard limits, usage logging
- router.rs: 3 preset profiles (CostSaver/QualityFirst/Balanced)

Phase 2 — Knowledge Base:
- memory.rs: persistent agent memory with FTS5, auto-extraction, TTL
- symbols.rs: regex-based symbol graph (tree-sitter stub)

Phase 3 — Git Integration:
- git_context.rs: branch/commit/modified file context injection
- branch_policy.rs: session-level branch protection

6 modules, 32 cargo tests, 22+ Tauri plugin commands.
This commit is contained in:
Hibryda 2026-03-17 03:27:40 +01:00
parent 3798bedc4d
commit 191b869b43
7 changed files with 1509 additions and 0 deletions

209
agor-pro/src/git_context.rs Normal file
View file

@ -0,0 +1,209 @@
// 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<CommitSummary>,
pub modified_files: Vec<String>,
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<String>,
pub ahead: i64,
pub behind: i64,
}
fn git_cmd(project_path: &str, args: &[&str]) -> Result<String, String> {
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<CommitSummary> {
// 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<GitContext, String> {
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<CommitSummary> = 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<String> = 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<i64>) -> Result<String, String> {
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<BranchInfo, String> {
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");
}
}