use std::path::PathBuf;
use std::time::Duration;
use git2::{BranchType, Commit, Diff, DiffFormat, Repository, Status, StatusOptions};
pub use super::git::GitInit;
use crate::error::RuntimeError;
use crate::stream::StreamFrame;
use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
use crate::value::Value;
pub struct GitStatus;
pub struct GitShow;
pub struct GitLog;
impl Tool for GitLog {
fn name(&self) -> &str {
"git.log"
}
fn tier(&self) -> Tier {
Tier::Zero
}
fn description(&self) -> Option<&str> {
Some("List recent commits and preview the patch for the newest commit.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "default": 20, "minimum": 1, "maximum": 100, "description": "Maximum commits to return."},
"cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
}
})
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let limit = extract_optional_int(&args, "limit")
.unwrap_or(20)
.clamp(1, 100) as usize;
let cwd = extract_cwd(&args, ctx, "git.log cwd")?;
let repo = Repository::open(&cwd)
.map_err(|e| RuntimeError::ToolFailed(format!("git.log: {e}")))?;
let mut revwalk = repo
.revwalk()
.map_err(|e| RuntimeError::ToolFailed(format!("git.log revwalk: {e}")))?;
revwalk
.push_head()
.map_err(|e| RuntimeError::ToolFailed(format!("git.log head: {e}")))?;
let mut commits = Vec::new();
let mut preview_diff = String::new();
let mut preview_files = Vec::new();
for oid in revwalk.take(limit) {
let oid = oid.map_err(|e| RuntimeError::ToolFailed(format!("git.log oid: {e}")))?;
let commit = repo
.find_commit(oid)
.map_err(|e| RuntimeError::ToolFailed(format!("git.log commit: {e}")))?;
let diff = commit_diff(&repo, &commit, "git.log")?;
let stats = diff
.stats()
.map_err(|e| RuntimeError::ToolFailed(format!("git.log stats: {e}")))?;
if commits.is_empty() {
preview_files = diff_files(&diff, "git.log")?;
preview_diff = diff_patch(&diff, "git.log")?;
}
commits.push(commit_entry(&commit, &stats));
}
if let Some(tx) = &ctx.stream_tx
&& !preview_diff.is_empty()
{
let _ = tx.send(StreamFrame::DiffPreview {
title: "git log HEAD".into(),
tool_use_id: ctx.tool_use_id.clone(),
old_content: None,
new_content: None,
unified_diff: Some(preview_diff.clone()),
run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
});
}
Ok(Value::Struct(vec![
("commits".into(), Value::List(commits)),
("diff".into(), Value::Str(preview_diff)),
(
"files".into(),
Value::List(preview_files.into_iter().map(Value::Str).collect()),
),
]))
})
}
}
impl Tool for GitShow {
fn name(&self) -> &str {
"git.show"
}
fn tier(&self) -> Tier {
Tier::Zero
}
fn description(&self) -> Option<&str> {
Some("Show the patch introduced by one commit.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"sha": {"type": "string", "description": "Commit SHA or rev."},
"cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
},
"required": ["sha"]
})
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let sha = extract_string(&args, "sha", 0)?;
let cwd = extract_cwd(&args, ctx, "git.show cwd")?;
let repo = Repository::open(&cwd)
.map_err(|e| RuntimeError::ToolFailed(format!("git.show: {e}")))?;
let object = repo
.revparse_single(&sha)
.map_err(|e| RuntimeError::ToolFailed(format!("git.show rev: {e}")))?;
let commit = object
.peel_to_commit()
.map_err(|e| RuntimeError::ToolFailed(format!("git.show commit: {e}")))?;
let diff = commit_diff(&repo, &commit, "git.show")?;
let files = diff_files(&diff, "git.show")?;
let body = diff_patch(&diff, "git.show")?;
let resolved = commit.id().to_string();
if let Some(tx) = &ctx.stream_tx {
let _ = tx.send(StreamFrame::DiffPreview {
title: format!("git show {sha}"),
tool_use_id: ctx.tool_use_id.clone(),
old_content: None,
new_content: None,
unified_diff: Some(body.clone()),
run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
});
}
Ok(Value::Struct(vec![
("sha".into(), Value::Str(resolved)),
("diff".into(), Value::Str(body)),
(
"files".into(),
Value::List(files.into_iter().map(Value::Str).collect()),
),
]))
})
}
}
impl Tool for GitStatus {
fn name(&self) -> &str {
"git.status"
}
fn tier(&self) -> Tier {
Tier::Zero
}
fn description(&self) -> Option<&str> {
Some("Show working tree status: staged, unstaged, and untracked files.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
}
})
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let cwd = extract_cwd(&args, ctx, "git.status cwd")?;
let repo = Repository::open(&cwd)
.map_err(|e| RuntimeError::ToolFailed(format!("git.status: {e}")))?;
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.renames_head_to_index(true)
.renames_index_to_workdir(true);
let statuses = repo
.statuses(Some(&mut opts))
.map_err(|e| RuntimeError::ToolFailed(format!("git.status: {e}")))?;
let mut staged = Vec::new();
let mut unstaged = Vec::new();
let mut untracked = Vec::new();
for entry in statuses.iter() {
let status = entry.status();
let Some(path) = entry.path().map(str::to_string) else {
continue;
};
if status.is_wt_new() {
untracked.push(Value::Str(path.clone()));
}
if let Some(label) = index_status(status) {
staged.push(status_entry(path.clone(), label));
}
if let Some(label) = worktree_status(status) {
unstaged.push(status_entry(path, label));
}
}
Ok(Value::Struct(vec![
("staged".into(), Value::List(staged)),
("unstaged".into(), Value::List(unstaged)),
("untracked".into(), Value::List(untracked)),
]))
})
}
}
pub struct GitAdd;
impl Tool for GitAdd {
fn name(&self) -> &str {
"git.add"
}
fn tier(&self) -> Tier {
Tier::Two
}
fn description(&self) -> Option<&str> {
Some("Stage files for commit. Pass specific paths — do NOT stage everything blindly.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"paths": {"type": "array", "items": {"type": "string"}, "description": "File paths to stage."},
"cwd": {"type": "string", "description": "Optional working dir."}
},
"required": ["paths"]
})
}
fn invocation_provenance(
&self,
args: &ToolArgs,
ctx: &ToolCtx,
) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
git_mutation_provenance(args, ctx)
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let paths = extract_string_list(&args, "paths")?;
let cwd = extract_cwd(&args, ctx, "git.add cwd")?;
crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
let repo = Repository::open(&cwd)
.map_err(|e| RuntimeError::ToolFailed(format!("git.add: {e}")))?;
let mut index = repo
.index()
.map_err(|e| RuntimeError::ToolFailed(format!("git.add index: {e}")))?;
for p in &paths {
index
.add_path(std::path::Path::new(p))
.map_err(|e| RuntimeError::ToolFailed(format!("git.add {p}: {e}")))?;
}
index
.write()
.map_err(|e| RuntimeError::ToolFailed(format!("git.add write: {e}")))?;
Ok(Value::Struct(vec![(
"staged".into(),
Value::List(paths.into_iter().map(Value::Str).collect()),
)]))
})
}
}
pub struct GitCommit;
impl Tool for GitCommit {
fn name(&self) -> &str {
"git.commit"
}
fn tier(&self) -> Tier {
Tier::Two
}
fn description(&self) -> Option<&str> {
Some("Commit staged changes. Use 'amend: true' to amend the last commit.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {"type": "string", "description": "Commit message."},
"amend": {"type": "boolean", "default": false, "description": "Amend the last commit instead of creating a new commit."},
"cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
},
"required": ["message"]
})
}
fn invocation_provenance(
&self,
args: &ToolArgs,
ctx: &ToolCtx,
) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
git_mutation_provenance(args, ctx)
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let message = extract_string(&args, "message", 0)?;
let amend = extract_optional_bool(&args, "amend").unwrap_or(false);
let cwd = extract_cwd(&args, ctx, "git.commit cwd")?;
crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
let repo = Repository::open(&cwd)
.map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
let files_count = staged_count(&repo, "git.commit")?;
let cli = crate::git::GitCli::at(&cwd);
cli.commit_with_options(&message, amend)
.map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
let sha = cli
.head_oid()
.map_err(|e| RuntimeError::ToolFailed(format!("git.commit head: {e}")))?;
Ok(Value::Struct(vec![
("sha".into(), Value::Str(sha)),
("message".into(), Value::Str(message)),
("files_count".into(), Value::Int(files_count)),
]))
})
}
}
pub struct GitBranch;
impl Tool for GitBranch {
fn name(&self) -> &str {
"git.branch"
}
fn tier(&self) -> Tier {
Tier::Two
}
fn description(&self) -> Option<&str> {
Some("Create and/or checkout a git branch.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string", "description": "Branch name."},
"create": {"type": "boolean", "default": true, "description": "Create the branch before checkout."},
"checkout": {"type": "boolean", "default": true, "description": "Checkout the branch."},
"cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
},
"required": ["name"]
})
}
fn invocation_provenance(
&self,
args: &ToolArgs,
ctx: &ToolCtx,
) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
git_mutation_provenance(args, ctx)
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let name = extract_string(&args, "name", 0)?;
let create = extract_optional_bool(&args, "create").unwrap_or(true);
let checkout = extract_optional_bool(&args, "checkout").unwrap_or(true);
let cwd = extract_cwd(&args, ctx, "git.branch cwd")?;
crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
let repo = Repository::open(&cwd)
.map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
if create {
let head = repo
.head()
.and_then(|h| h.peel_to_commit())
.map_err(|e| RuntimeError::ToolFailed(format!("git.branch head: {e}")))?;
repo.branch(&name, &head, false)
.map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
} else {
repo.find_branch(&name, BranchType::Local)
.map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
}
if checkout {
repo.set_head(&format!("refs/heads/{name}"))
.map_err(|e| RuntimeError::ToolFailed(format!("git.branch checkout: {e}")))?;
}
Ok(Value::Struct(vec![
("branch".into(), Value::Str(name)),
("created".into(), Value::Bool(create)),
("checked_out".into(), Value::Bool(checkout)),
]))
})
}
}
pub struct GitFetch;
pub struct GitPush;
impl Tool for GitFetch {
fn name(&self) -> &str {
"git.fetch"
}
fn tier(&self) -> Tier {
Tier::Two
}
fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
ApprovalLevel::Approve
}
fn description(&self) -> Option<&str> {
Some("Fetch refs from a remote without changing the worktree.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object","properties":{"remote":{"type":"string","default":"origin"},"prune":{"type":"boolean","default":false},"cwd":{"type":"string"}}})
}
fn invocation_provenance(
&self,
args: &ToolArgs,
ctx: &ToolCtx,
) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
git_mutation_provenance(args, ctx).map(|p| p.with_network())
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let remote =
extract_optional_string(&args, "remote").unwrap_or_else(|| "origin".into());
let cwd = extract_cwd(&args, ctx, "git.fetch cwd")?;
crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
let prune = extract_optional_bool(&args, "prune").unwrap_or(false);
let cli = crate::git::GitCli::at(&cwd);
let output = if prune {
cli.run(&["fetch", "--prune", &remote])
} else {
cli.run(&["fetch", &remote])
};
let output = output.map_err(|e| RuntimeError::ToolFailed(format!("git.fetch: {e}")))?;
Ok(Value::Struct(vec![
("remote".into(), Value::Str(remote)),
("prune".into(), Value::Bool(prune)),
("output".into(), Value::Str(output)),
]))
})
}
}
impl Tool for GitPush {
fn name(&self) -> &str {
"git.push"
}
fn tier(&self) -> Tier {
Tier::Three
}
fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
ApprovalLevel::Dangerous
}
fn description(&self) -> Option<&str> {
Some("Push current branch to remote. Requires approval.")
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"remote": {"type": "string", "default": "origin", "description": "Remote name."},
"branch": {"type": "string", "description": "Branch name; defaults to current branch."},
"force_with_lease": {"type": "boolean", "default": false, "description": "Use lease-protected force push."},
"cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
}
})
}
fn invocation_provenance(
&self,
args: &ToolArgs,
ctx: &ToolCtx,
) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
git_mutation_provenance(args, ctx).map(|p| p.with_network())
}
fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
Box::pin(async move {
let remote =
extract_optional_string(&args, "remote").unwrap_or_else(|| "origin".into());
let cwd = extract_cwd(&args, ctx, "git.push cwd")?;
crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
let branch = match extract_optional_string(&args, "branch") {
Some(branch) => branch,
None => current_branch(&cwd)?,
};
if remote.is_empty()
|| branch.is_empty()
|| branch.starts_with('-')
|| remote.starts_with('-')
{
return Err(RuntimeError::ToolFailed(
"git.push: remote and branch must be non-empty names".into(),
));
}
if branch == ":" || branch.starts_with(':') || branch.contains("..") {
return Err(RuntimeError::ToolFailed(
"git.push: ref deletion and ambiguous refspecs are not allowed".into(),
));
}
let force_with_lease =
extract_optional_bool(&args, "force_with_lease").unwrap_or(false);
let mut child = tokio::process::Command::new("git");
child.args(["push", "-u"]);
if force_with_lease {
child.arg("--force-with-lease");
}
child.args([&remote, &branch]).current_dir(&cwd);
let output = tokio::time::timeout(Duration::from_secs(300), child.output())
.await
.map_err(|_| RuntimeError::ToolFailed("git.push timeout after 300s".into()))?
.map_err(|e| RuntimeError::ToolFailed(format!("git.push spawn: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.into_owned(),
(true, false) => stderr.into_owned(),
(false, false) => format!("{stdout}\n{stderr}"),
};
Ok(Value::Struct(vec![
("ok".into(), Value::Bool(output.status.success())),
("remote".into(), Value::Str(remote)),
("branch".into(), Value::Str(branch)),
("force_with_lease".into(), Value::Bool(force_with_lease)),
("output".into(), Value::Str(combined)),
]))
})
}
}
pub(crate) fn git_mutation_provenance(
args: &ToolArgs,
ctx: &ToolCtx,
) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
let explicit = cwd_path_arg(args)?;
Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
.with_cwd(ctx, explicit.as_deref())?
.with_risk(crate::trust::RiskKind::RepositoryMutation))
}
fn cwd_path_arg(args: &ToolArgs) -> Result<Option<PathBuf>, RuntimeError> {
match args.named("cwd") {
Some(Value::Path(p)) => Ok(Some(p.clone())),
Some(Value::Str(s)) => Ok(Some(PathBuf::from(s))),
Some(Value::Unit) | None => Ok(None),
Some(other) => Err(RuntimeError::TypeMismatch {
expected: "string".into(),
actual: other.kind_name().into(),
}),
}
}
fn extract_cwd(args: &ToolArgs, ctx: &ToolCtx, label: &str) -> Result<PathBuf, RuntimeError> {
let explicit = match args.named("cwd") {
Some(Value::Path(p)) => Some(p.as_path()),
Some(Value::Str(s)) => Some(std::path::Path::new(s)),
Some(other) => {
return Err(RuntimeError::TypeMismatch {
expected: "string".into(),
actual: other.kind_name().into(),
});
}
None => None,
};
ctx.resolve_cwd(explicit)
.map_err(|error| RuntimeError::ToolFailed(format!("{label}: {error}")))
}
fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
let value = match args.named(name) {
Some(v) => v,
None => args.positional(pos)?,
};
match value {
Value::Str(s) => Ok(s.clone()),
other => Err(RuntimeError::TypeMismatch {
expected: "string".into(),
actual: other.kind_name().into(),
}),
}
}
fn extract_string_list(args: &ToolArgs, name: &str) -> Result<Vec<String>, RuntimeError> {
match args.named(name) {
Some(Value::List(items)) => items
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
other => Err(RuntimeError::TypeMismatch {
expected: "string".into(),
actual: other.kind_name().into(),
}),
})
.collect(),
Some(other) => Err(RuntimeError::TypeMismatch {
expected: "list<string>".into(),
actual: other.kind_name().into(),
}),
None => Err(RuntimeError::MissingArg(name.into())),
}
}
fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
match args.named(name)? {
Value::Str(s) => Some(s.clone()),
_ => None,
}
}
fn extract_optional_bool(args: &ToolArgs, name: &str) -> Option<bool> {
match args.named(name)? {
Value::Bool(b) => Some(*b),
_ => None,
}
}
fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
match args.named(name)? {
Value::Int(n) => Some(*n),
_ => None,
}
}
fn commit_diff<'repo>(
repo: &'repo Repository,
commit: &Commit<'repo>,
tool: &str,
) -> Result<Diff<'repo>, RuntimeError> {
let new_tree = commit
.tree()
.map_err(|e| RuntimeError::ToolFailed(format!("{tool} tree: {e}")))?;
let old_tree = if commit.parent_count() == 0 {
None
} else {
Some(
commit
.parent(0)
.and_then(|p| p.tree())
.map_err(|e| RuntimeError::ToolFailed(format!("{tool} parent: {e}")))?,
)
};
repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)
.map_err(|e| RuntimeError::ToolFailed(format!("{tool} diff: {e}")))
}
fn diff_files(diff: &Diff<'_>, tool: &str) -> Result<Vec<String>, RuntimeError> {
let mut files = Vec::new();
diff.foreach(
&mut |delta, _| {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().into_owned());
if let Some(path) = path
&& !files.contains(&path)
{
files.push(path);
}
true
},
None,
None,
None,
)
.map_err(|e| RuntimeError::ToolFailed(format!("{tool} files: {e}")))?;
Ok(files)
}
fn diff_patch(diff: &Diff<'_>, tool: &str) -> Result<String, RuntimeError> {
let mut body = String::new();
diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
match line.origin() {
'F' | 'H' => body.push_str(&String::from_utf8_lossy(line.content())),
'+' | '-' | ' ' => {
body.push(line.origin());
body.push_str(&String::from_utf8_lossy(line.content()));
}
_ => body.push_str(&String::from_utf8_lossy(line.content())),
}
true
})
.map_err(|e| RuntimeError::ToolFailed(format!("{tool} patch: {e}")))?;
Ok(body)
}
fn commit_entry(commit: &Commit<'_>, stats: &git2::DiffStats) -> Value {
let author = commit.author();
let author_name = author.name().unwrap_or_default();
let author_email = author.email().unwrap_or_default();
let author_display = if author_email.is_empty() {
author_name.to_string()
} else if author_name.is_empty() {
author_email.to_string()
} else {
format!("{author_name} <{author_email}>")
};
Value::Struct(vec![
("sha".into(), Value::Str(commit.id().to_string())),
("author".into(), Value::Str(author_display)),
(
"date".into(),
Value::Str(commit.time().seconds().to_string()),
),
(
"message".into(),
Value::Str(commit.summary().unwrap_or_default().to_string()),
),
(
"stats".into(),
Value::Struct(vec![
("files".into(), Value::Int(stats.files_changed() as i64)),
("insertions".into(), Value::Int(stats.insertions() as i64)),
("deletions".into(), Value::Int(stats.deletions() as i64)),
]),
),
])
}
fn index_status(status: Status) -> Option<&'static str> {
if status.is_index_new() {
Some("new")
} else if status.is_index_modified() {
Some("modified")
} else if status.is_index_deleted() {
Some("deleted")
} else if status.is_index_renamed() {
Some("renamed")
} else {
None
}
}
fn worktree_status(status: Status) -> Option<&'static str> {
if status.is_wt_modified() {
Some("modified")
} else if status.is_wt_deleted() {
Some("deleted")
} else if status.is_wt_renamed() {
Some("renamed")
} else {
None
}
}
fn status_entry(path: String, status: &str) -> Value {
Value::Struct(vec![
("path".into(), Value::Str(path)),
("status".into(), Value::Str(status.into())),
])
}
fn staged_count(repo: &Repository, tool: &str) -> Result<i64, RuntimeError> {
let mut opts = StatusOptions::new();
opts.include_untracked(false).renames_head_to_index(true);
let statuses = repo
.statuses(Some(&mut opts))
.map_err(|e| RuntimeError::ToolFailed(format!("{tool}: {e}")))?;
Ok(statuses
.iter()
.filter(|entry| index_status(entry.status()).is_some())
.count() as i64)
}
fn current_branch(cwd: &std::path::Path) -> Result<String, RuntimeError> {
let repo =
Repository::open(cwd).map_err(|e| RuntimeError::ToolFailed(format!("git.push: {e}")))?;
let head = repo
.head()
.map_err(|e| RuntimeError::ToolFailed(format!("git.push head: {e}")))?;
head.shorthand()
.map(str::to_string)
.ok_or_else(|| RuntimeError::ToolFailed("git.push: detached HEAD has no branch".into()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::GitCli;
use std::path::Path;
#[test]
fn mutation_provenance_uses_cwd_and_marks_repository_mutation() {
let dir = tempfile::tempdir().unwrap();
let ctx = ToolCtx::default();
let args = ToolArgs {
named: vec![
("cwd".into(), Value::Str(dir.path().display().to_string())),
("message".into(), Value::Str("wip".into())),
],
..ToolArgs::default()
};
let provenance = git_mutation_provenance(&args, &ctx).unwrap();
let cwd = provenance.cwd.expect("cwd recorded");
assert_eq!(
std::fs::canonicalize(&cwd).unwrap(),
std::fs::canonicalize(dir.path()).unwrap()
);
assert_eq!(provenance.path, None);
assert!(
provenance
.risks
.contains(&crate::trust::RiskKind::RepositoryMutation)
);
}
#[test]
fn push_and_fetch_declare_network_reach() {
let ctx = ToolCtx::default();
let args = ToolArgs::default();
assert!(GitPush.invocation_provenance(&args, &ctx).unwrap().network);
assert!(GitFetch.invocation_provenance(&args, &ctx).unwrap().network);
}
fn have_git() -> bool {
GitCli::ensure_available().is_ok()
}
fn seed_two_commits(dir: &Path) {
let cli = GitCli::at(dir);
cli.init("main").unwrap();
for (k, v) in [
("user.email", "t@atman.local"),
("user.name", "atman test"),
("commit.gpgsign", "false"),
] {
cli.run(&["config", k, v]).unwrap();
}
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
cli.add_all().unwrap();
cli.commit("initial").unwrap();
std::fs::write(dir.join("a.txt"), "one\ntwo\n").unwrap();
cli.add_all().unwrap();
cli.commit("second").unwrap();
}
#[tokio::test]
async fn status_defaults_to_managed_workspace() {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
let ctx = ToolCtx::new().with_workspace(crate::git_workspace::WorkspaceBinding {
workspace_id: "test".into(),
repository_root: tmp.path().to_path_buf(),
path: tmp.path().to_path_buf(),
branch: None,
});
let value = GitStatus
.call(
ToolArgs {
positional: Vec::new(),
named: Vec::new(),
},
&ctx,
)
.await
.unwrap();
assert!(matches!(value.field("staged"), Some(Value::List(_))));
}
#[tokio::test]
async fn managed_git_external_read_allowed_but_mutations_leave_repo_unchanged() {
if !have_git() {
return;
}
let repo_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join(format!("r4-git-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&repo_dir).unwrap();
seed_two_commits(&repo_dir);
std::fs::write(repo_dir.join("new.txt"), "new\n").unwrap();
let workspace = tempfile::tempdir().unwrap();
let ctx = ToolCtx::new()
.with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
workspace.path().into(),
))
.with_workspace(crate::git_workspace::WorkspaceBinding {
workspace_id: "test".into(),
repository_root: workspace.path().into(),
path: workspace.path().into(),
branch: None,
});
let cwd = Value::Str(repo_dir.to_string_lossy().into());
let status = GitStatus
.call(
ToolArgs {
positional: vec![],
named: vec![("cwd".into(), cwd.clone())],
},
&ctx,
)
.await
.unwrap();
assert!(matches!(status.field("untracked"), Some(Value::List(paths)) if !paths.is_empty()));
let repo = Repository::open(&repo_dir).unwrap();
let index_before = repo.index().unwrap().write_tree().unwrap();
let head_before = repo.head().unwrap().target().unwrap();
let add_error = GitAdd
.call(
ToolArgs {
positional: vec![],
named: vec![
(
"paths".into(),
Value::List(vec![Value::Str("new.txt".into())]),
),
("cwd".into(), cwd.clone()),
],
},
&ctx,
)
.await
.unwrap_err();
assert!(add_error.to_string().contains("outside workspace"));
assert_eq!(
Repository::open(&repo_dir)
.unwrap()
.index()
.unwrap()
.write_tree()
.unwrap(),
index_before
);
let commit_error = GitCommit
.call(
ToolArgs {
positional: vec![],
named: vec![
("message".into(), Value::Str("blocked".into())),
("cwd".into(), cwd),
],
},
&ctx,
)
.await
.unwrap_err();
assert!(commit_error.to_string().contains("outside workspace"));
assert_eq!(
Repository::open(&repo_dir)
.unwrap()
.head()
.unwrap()
.target()
.unwrap(),
head_before
);
std::fs::remove_dir_all(repo_dir).unwrap();
}
#[tokio::test]
async fn log_returns_limited_commits_and_head_patch() {
if !have_git() {
eprintln!("skip: git not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
seed_two_commits(tmp.path());
let ctx = ToolCtx::new();
let args = ToolArgs {
positional: Vec::new(),
named: vec![
("limit".into(), Value::Int(1)),
(
"cwd".into(),
Value::Str(tmp.path().to_string_lossy().into()),
),
],
};
let value = GitLog.call(args, &ctx).await.unwrap();
let commits = value.field("commits").unwrap();
let Value::List(commits) = commits else {
panic!("expected commits list: {commits:?}");
};
assert_eq!(commits.len(), 1);
let head = &commits[0];
assert!(matches!(head.field("message"), Some(Value::Str(s)) if s == "second"));
assert!(matches!(head.field("sha"), Some(Value::Str(sha)) if sha.len() == 40));
let stats = head.field("stats").unwrap();
assert!(matches!(stats.field("files"), Some(Value::Int(1))));
assert!(matches!(stats.field("insertions"), Some(Value::Int(1))));
let diff = value.field("diff").unwrap();
assert!(
matches!(diff, Value::Str(s) if s.contains("+two")),
"diff={diff:?}"
);
}
}