Rust fixes (HIGH): - symbols.rs: path validation (reject near-root, 50K file limit, symlink filter) - memory.rs: FTS5 query quoting (prevent operator injection), 1000 fragment cap, content length limit, transaction wrapping - budget.rs: atomic check-and-reserve via transaction, input validation, index on budget_log - export.rs: safe UTF-8 truncation via chars().take() - git_context.rs: reject paths starting with '-' (flag injection) - branch_policy.rs: action validation (block|warn only), path validation Rust fixes (MEDIUM): - export.rs: named column access (positional→named) - budget.rs: named column access, negative value guards Svelte fixes: - AccountSwitcher: 2-step confirmation before account switch - ProjectMemory: expand/collapse content, 2-step delete confirm, tags split fix - CodeIntelligence: min 2-char symbol query, CodeSymbol rename, aria-labels - BudgetManager: 10M upper bound, aria-label on input, named constants - SessionExporter: timeout cleanup on destroy, aria-live feedback - AnalyticsDashboard: SVG aria-label, removed unused import, named constant
212 lines
6.5 KiB
Rust
212 lines
6.5 KiB
Rust
// 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> {
|
|
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<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");
|
|
}
|
|
}
|