1use std::path::PathBuf;
2use std::time::Duration;
3
4use git2::{BranchType, Commit, Diff, DiffFormat, Repository, Status, StatusOptions};
5
6pub use super::git::GitInit;
7
8use crate::error::RuntimeError;
9use crate::stream::StreamFrame;
10use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
11use crate::value::Value;
12
13pub struct GitStatus;
14
15pub struct GitShow;
16
17pub struct GitLog;
18
19impl Tool for GitLog {
20 fn name(&self) -> &str {
21 "git.log"
22 }
23
24 fn tier(&self) -> Tier {
25 Tier::Zero
26 }
27
28 fn description(&self) -> Option<&str> {
29 Some("List recent commits and preview the patch for the newest commit.")
30 }
31
32 fn input_schema(&self) -> serde_json::Value {
33 serde_json::json!({
34 "type": "object",
35 "properties": {
36 "limit": {"type": "integer", "default": 20, "minimum": 1, "maximum": 100, "description": "Maximum commits to return."},
37 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
38 }
39 })
40 }
41
42 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
43 Box::pin(async move {
44 let limit = extract_optional_int(&args, "limit")
45 .unwrap_or(20)
46 .clamp(1, 100) as usize;
47 let cwd = extract_cwd(&args, ctx, "git.log cwd")?;
48 let repo = Repository::open(&cwd)
49 .map_err(|e| RuntimeError::ToolFailed(format!("git.log: {e}")))?;
50 let mut revwalk = repo
51 .revwalk()
52 .map_err(|e| RuntimeError::ToolFailed(format!("git.log revwalk: {e}")))?;
53 revwalk
54 .push_head()
55 .map_err(|e| RuntimeError::ToolFailed(format!("git.log head: {e}")))?;
56
57 let mut commits = Vec::new();
58 let mut preview_diff = String::new();
59 let mut preview_files = Vec::new();
60 for oid in revwalk.take(limit) {
61 let oid = oid.map_err(|e| RuntimeError::ToolFailed(format!("git.log oid: {e}")))?;
62 let commit = repo
63 .find_commit(oid)
64 .map_err(|e| RuntimeError::ToolFailed(format!("git.log commit: {e}")))?;
65 let diff = commit_diff(&repo, &commit, "git.log")?;
66 let stats = diff
67 .stats()
68 .map_err(|e| RuntimeError::ToolFailed(format!("git.log stats: {e}")))?;
69 if commits.is_empty() {
70 preview_files = diff_files(&diff, "git.log")?;
71 preview_diff = diff_patch(&diff, "git.log")?;
72 }
73 commits.push(commit_entry(&commit, &stats));
74 }
75
76 if let Some(tx) = &ctx.stream_tx
77 && !preview_diff.is_empty()
78 {
79 let _ = tx.send(StreamFrame::DiffPreview {
80 title: "git log HEAD".into(),
81 tool_use_id: ctx.tool_use_id.clone(),
82 old_content: None,
83 new_content: None,
84 unified_diff: Some(preview_diff.clone()),
85 run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
86 });
87 }
88
89 Ok(Value::Struct(vec![
90 ("commits".into(), Value::List(commits)),
91 ("diff".into(), Value::Str(preview_diff)),
92 (
93 "files".into(),
94 Value::List(preview_files.into_iter().map(Value::Str).collect()),
95 ),
96 ]))
97 })
98 }
99}
100
101impl Tool for GitShow {
102 fn name(&self) -> &str {
103 "git.show"
104 }
105
106 fn tier(&self) -> Tier {
107 Tier::Zero
108 }
109
110 fn description(&self) -> Option<&str> {
111 Some("Show the patch introduced by one commit.")
112 }
113
114 fn input_schema(&self) -> serde_json::Value {
115 serde_json::json!({
116 "type": "object",
117 "properties": {
118 "sha": {"type": "string", "description": "Commit SHA or rev."},
119 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
120 },
121 "required": ["sha"]
122 })
123 }
124
125 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
126 Box::pin(async move {
127 let sha = extract_string(&args, "sha", 0)?;
128 let cwd = extract_cwd(&args, ctx, "git.show cwd")?;
129 let repo = Repository::open(&cwd)
130 .map_err(|e| RuntimeError::ToolFailed(format!("git.show: {e}")))?;
131 let object = repo
132 .revparse_single(&sha)
133 .map_err(|e| RuntimeError::ToolFailed(format!("git.show rev: {e}")))?;
134 let commit = object
135 .peel_to_commit()
136 .map_err(|e| RuntimeError::ToolFailed(format!("git.show commit: {e}")))?;
137 let diff = commit_diff(&repo, &commit, "git.show")?;
138 let files = diff_files(&diff, "git.show")?;
139 let body = diff_patch(&diff, "git.show")?;
140 let resolved = commit.id().to_string();
141 if let Some(tx) = &ctx.stream_tx {
142 let _ = tx.send(StreamFrame::DiffPreview {
143 title: format!("git show {sha}"),
144 tool_use_id: ctx.tool_use_id.clone(),
145 old_content: None,
146 new_content: None,
147 unified_diff: Some(body.clone()),
148 run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
149 });
150 }
151 Ok(Value::Struct(vec![
152 ("sha".into(), Value::Str(resolved)),
153 ("diff".into(), Value::Str(body)),
154 (
155 "files".into(),
156 Value::List(files.into_iter().map(Value::Str).collect()),
157 ),
158 ]))
159 })
160 }
161}
162
163impl Tool for GitStatus {
164 fn name(&self) -> &str {
165 "git.status"
166 }
167
168 fn tier(&self) -> Tier {
169 Tier::Zero
170 }
171
172 fn description(&self) -> Option<&str> {
173 Some("Show working tree status: staged, unstaged, and untracked files.")
174 }
175
176 fn input_schema(&self) -> serde_json::Value {
177 serde_json::json!({
178 "type": "object",
179 "properties": {
180 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
181 }
182 })
183 }
184
185 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
186 Box::pin(async move {
187 let cwd = extract_cwd(&args, ctx, "git.status cwd")?;
188 let repo = Repository::open(&cwd)
189 .map_err(|e| RuntimeError::ToolFailed(format!("git.status: {e}")))?;
190 let mut opts = StatusOptions::new();
191 opts.include_untracked(true)
192 .renames_head_to_index(true)
193 .renames_index_to_workdir(true);
194 let statuses = repo
195 .statuses(Some(&mut opts))
196 .map_err(|e| RuntimeError::ToolFailed(format!("git.status: {e}")))?;
197 let mut staged = Vec::new();
198 let mut unstaged = Vec::new();
199 let mut untracked = Vec::new();
200 for entry in statuses.iter() {
201 let status = entry.status();
202 let Some(path) = entry.path().map(str::to_string) else {
203 continue;
204 };
205 if status.is_wt_new() {
206 untracked.push(Value::Str(path.clone()));
207 }
208 if let Some(label) = index_status(status) {
209 staged.push(status_entry(path.clone(), label));
210 }
211 if let Some(label) = worktree_status(status) {
212 unstaged.push(status_entry(path, label));
213 }
214 }
215 Ok(Value::Struct(vec![
216 ("staged".into(), Value::List(staged)),
217 ("unstaged".into(), Value::List(unstaged)),
218 ("untracked".into(), Value::List(untracked)),
219 ]))
220 })
221 }
222}
223
224pub struct GitAdd;
225
226impl Tool for GitAdd {
227 fn name(&self) -> &str {
228 "git.add"
229 }
230
231 fn tier(&self) -> Tier {
232 Tier::Two
233 }
234
235 fn description(&self) -> Option<&str> {
236 Some("Stage files for commit. Pass specific paths — do NOT stage everything blindly.")
237 }
238
239 fn input_schema(&self) -> serde_json::Value {
240 serde_json::json!({
241 "type": "object",
242 "properties": {
243 "paths": {"type": "array", "items": {"type": "string"}, "description": "File paths to stage."},
244 "cwd": {"type": "string", "description": "Optional working dir."}
245 },
246 "required": ["paths"]
247 })
248 }
249
250 fn invocation_provenance(
251 &self,
252 args: &ToolArgs,
253 ctx: &ToolCtx,
254 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
255 git_mutation_provenance(args, ctx)
256 }
257
258 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
259 Box::pin(async move {
260 let paths = extract_string_list(&args, "paths")?;
261 let cwd = extract_cwd(&args, ctx, "git.add cwd")?;
262 crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
263 let repo = Repository::open(&cwd)
264 .map_err(|e| RuntimeError::ToolFailed(format!("git.add: {e}")))?;
265 let mut index = repo
266 .index()
267 .map_err(|e| RuntimeError::ToolFailed(format!("git.add index: {e}")))?;
268 for p in &paths {
269 index
270 .add_path(std::path::Path::new(p))
271 .map_err(|e| RuntimeError::ToolFailed(format!("git.add {p}: {e}")))?;
272 }
273 index
274 .write()
275 .map_err(|e| RuntimeError::ToolFailed(format!("git.add write: {e}")))?;
276 Ok(Value::Struct(vec![(
277 "staged".into(),
278 Value::List(paths.into_iter().map(Value::Str).collect()),
279 )]))
280 })
281 }
282}
283
284pub struct GitCommit;
285
286impl Tool for GitCommit {
287 fn name(&self) -> &str {
288 "git.commit"
289 }
290
291 fn tier(&self) -> Tier {
292 Tier::Two
293 }
294
295 fn description(&self) -> Option<&str> {
296 Some("Commit staged changes. Use 'amend: true' to amend the last commit.")
297 }
298
299 fn input_schema(&self) -> serde_json::Value {
300 serde_json::json!({
301 "type": "object",
302 "properties": {
303 "message": {"type": "string", "description": "Commit message."},
304 "amend": {"type": "boolean", "default": false, "description": "Amend the last commit instead of creating a new commit."},
305 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
306 },
307 "required": ["message"]
308 })
309 }
310
311 fn invocation_provenance(
312 &self,
313 args: &ToolArgs,
314 ctx: &ToolCtx,
315 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
316 git_mutation_provenance(args, ctx)
317 }
318
319 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
320 Box::pin(async move {
321 let message = extract_string(&args, "message", 0)?;
322 let amend = extract_optional_bool(&args, "amend").unwrap_or(false);
323 let cwd = extract_cwd(&args, ctx, "git.commit cwd")?;
324 crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
325 let repo = Repository::open(&cwd)
326 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
327 let files_count = staged_count(&repo, "git.commit")?;
328 let cli = crate::git::GitCli::at(&cwd);
329 cli.commit_with_options(&message, amend)
330 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
331 let sha = cli
332 .head_oid()
333 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit head: {e}")))?;
334 Ok(Value::Struct(vec![
335 ("sha".into(), Value::Str(sha)),
336 ("message".into(), Value::Str(message)),
337 ("files_count".into(), Value::Int(files_count)),
338 ]))
339 })
340 }
341}
342
343pub struct GitBranch;
344
345impl Tool for GitBranch {
346 fn name(&self) -> &str {
347 "git.branch"
348 }
349
350 fn tier(&self) -> Tier {
351 Tier::Two
352 }
353
354 fn description(&self) -> Option<&str> {
355 Some("Create and/or checkout a git branch.")
356 }
357
358 fn input_schema(&self) -> serde_json::Value {
359 serde_json::json!({
360 "type": "object",
361 "properties": {
362 "name": {"type": "string", "description": "Branch name."},
363 "create": {"type": "boolean", "default": true, "description": "Create the branch before checkout."},
364 "checkout": {"type": "boolean", "default": true, "description": "Checkout the branch."},
365 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
366 },
367 "required": ["name"]
368 })
369 }
370
371 fn invocation_provenance(
372 &self,
373 args: &ToolArgs,
374 ctx: &ToolCtx,
375 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
376 git_mutation_provenance(args, ctx)
377 }
378
379 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
380 Box::pin(async move {
381 let name = extract_string(&args, "name", 0)?;
382 let create = extract_optional_bool(&args, "create").unwrap_or(true);
383 let checkout = extract_optional_bool(&args, "checkout").unwrap_or(true);
384 let cwd = extract_cwd(&args, ctx, "git.branch cwd")?;
385 crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
386 let repo = Repository::open(&cwd)
387 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
388 if create {
389 let head = repo
390 .head()
391 .and_then(|h| h.peel_to_commit())
392 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch head: {e}")))?;
393 repo.branch(&name, &head, false)
394 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
395 } else {
396 repo.find_branch(&name, BranchType::Local)
397 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
398 }
399 if checkout {
400 repo.set_head(&format!("refs/heads/{name}"))
401 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch checkout: {e}")))?;
402 }
403 Ok(Value::Struct(vec![
404 ("branch".into(), Value::Str(name)),
405 ("created".into(), Value::Bool(create)),
406 ("checked_out".into(), Value::Bool(checkout)),
407 ]))
408 })
409 }
410}
411
412pub struct GitFetch;
413pub struct GitPush;
414
415impl Tool for GitFetch {
416 fn name(&self) -> &str {
417 "git.fetch"
418 }
419 fn tier(&self) -> Tier {
420 Tier::Two
421 }
422 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
423 ApprovalLevel::Approve
424 }
425 fn description(&self) -> Option<&str> {
426 Some("Fetch refs from a remote without changing the worktree.")
427 }
428 fn input_schema(&self) -> serde_json::Value {
429 serde_json::json!({"type":"object","properties":{"remote":{"type":"string","default":"origin"},"prune":{"type":"boolean","default":false},"cwd":{"type":"string"}}})
430 }
431
432 fn invocation_provenance(
433 &self,
434 args: &ToolArgs,
435 ctx: &ToolCtx,
436 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
437 git_mutation_provenance(args, ctx).map(|p| p.with_network())
438 }
439
440 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
441 Box::pin(async move {
442 let remote =
443 extract_optional_string(&args, "remote").unwrap_or_else(|| "origin".into());
444 let cwd = extract_cwd(&args, ctx, "git.fetch cwd")?;
445 crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
446 let prune = extract_optional_bool(&args, "prune").unwrap_or(false);
447 let cli = crate::git::GitCli::at(&cwd);
448 let output = if prune {
449 cli.run(&["fetch", "--prune", &remote])
450 } else {
451 cli.run(&["fetch", &remote])
452 };
453 let output = output.map_err(|e| RuntimeError::ToolFailed(format!("git.fetch: {e}")))?;
454 Ok(Value::Struct(vec![
455 ("remote".into(), Value::Str(remote)),
456 ("prune".into(), Value::Bool(prune)),
457 ("output".into(), Value::Str(output)),
458 ]))
459 })
460 }
461}
462
463impl Tool for GitPush {
464 fn name(&self) -> &str {
465 "git.push"
466 }
467
468 fn tier(&self) -> Tier {
469 Tier::Three
470 }
471
472 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
473 ApprovalLevel::Dangerous
474 }
475
476 fn description(&self) -> Option<&str> {
477 Some("Push current branch to remote. Requires approval.")
478 }
479
480 fn input_schema(&self) -> serde_json::Value {
481 serde_json::json!({
482 "type": "object",
483 "properties": {
484 "remote": {"type": "string", "default": "origin", "description": "Remote name."},
485 "branch": {"type": "string", "description": "Branch name; defaults to current branch."},
486 "force_with_lease": {"type": "boolean", "default": false, "description": "Use lease-protected force push."},
487 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
488 }
489 })
490 }
491
492 fn invocation_provenance(
493 &self,
494 args: &ToolArgs,
495 ctx: &ToolCtx,
496 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
497 git_mutation_provenance(args, ctx).map(|p| p.with_network())
498 }
499
500 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
501 Box::pin(async move {
502 let remote =
503 extract_optional_string(&args, "remote").unwrap_or_else(|| "origin".into());
504 let cwd = extract_cwd(&args, ctx, "git.push cwd")?;
505 crate::fs_access::authorize_write(ctx, &cwd, self.name(), true).await?;
506 let branch = match extract_optional_string(&args, "branch") {
507 Some(branch) => branch,
508 None => current_branch(&cwd)?,
509 };
510 if remote.is_empty()
511 || branch.is_empty()
512 || branch.starts_with('-')
513 || remote.starts_with('-')
514 {
515 return Err(RuntimeError::ToolFailed(
516 "git.push: remote and branch must be non-empty names".into(),
517 ));
518 }
519 if branch == ":" || branch.starts_with(':') || branch.contains("..") {
520 return Err(RuntimeError::ToolFailed(
521 "git.push: ref deletion and ambiguous refspecs are not allowed".into(),
522 ));
523 }
524 let force_with_lease =
525 extract_optional_bool(&args, "force_with_lease").unwrap_or(false);
526 let mut child = tokio::process::Command::new("git");
527 child.args(["push", "-u"]);
528 if force_with_lease {
529 child.arg("--force-with-lease");
530 }
531 child.args([&remote, &branch]).current_dir(&cwd);
532 let output = tokio::time::timeout(Duration::from_secs(300), child.output())
533 .await
534 .map_err(|_| RuntimeError::ToolFailed("git.push timeout after 300s".into()))?
535 .map_err(|e| RuntimeError::ToolFailed(format!("git.push spawn: {e}")))?;
536 let stdout = String::from_utf8_lossy(&output.stdout);
537 let stderr = String::from_utf8_lossy(&output.stderr);
538 let combined = match (stdout.is_empty(), stderr.is_empty()) {
539 (true, true) => String::new(),
540 (false, true) => stdout.into_owned(),
541 (true, false) => stderr.into_owned(),
542 (false, false) => format!("{stdout}\n{stderr}"),
543 };
544 Ok(Value::Struct(vec![
545 ("ok".into(), Value::Bool(output.status.success())),
546 ("remote".into(), Value::Str(remote)),
547 ("branch".into(), Value::Str(branch)),
548 ("force_with_lease".into(), Value::Bool(force_with_lease)),
549 ("output".into(), Value::Str(combined)),
550 ]))
551 })
552 }
553}
554
555pub(crate) fn git_mutation_provenance(
561 args: &ToolArgs,
562 ctx: &ToolCtx,
563) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
564 let explicit = cwd_path_arg(args)?;
565 Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
566 .with_cwd(ctx, explicit.as_deref())?
567 .with_risk(crate::trust::RiskKind::RepositoryMutation))
568}
569
570fn cwd_path_arg(args: &ToolArgs) -> Result<Option<PathBuf>, RuntimeError> {
571 match args.named("cwd") {
572 Some(Value::Path(p)) => Ok(Some(p.clone())),
573 Some(Value::Str(s)) => Ok(Some(PathBuf::from(s))),
574 Some(Value::Unit) | None => Ok(None),
575 Some(other) => Err(RuntimeError::TypeMismatch {
576 expected: "string".into(),
577 actual: other.kind_name().into(),
578 }),
579 }
580}
581
582fn extract_cwd(args: &ToolArgs, ctx: &ToolCtx, label: &str) -> Result<PathBuf, RuntimeError> {
583 let explicit = match args.named("cwd") {
584 Some(Value::Path(p)) => Some(p.as_path()),
585 Some(Value::Str(s)) => Some(std::path::Path::new(s)),
586 Some(other) => {
587 return Err(RuntimeError::TypeMismatch {
588 expected: "string".into(),
589 actual: other.kind_name().into(),
590 });
591 }
592 None => None,
593 };
594 ctx.resolve_cwd(explicit)
595 .map_err(|error| RuntimeError::ToolFailed(format!("{label}: {error}")))
596}
597
598fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
599 let value = match args.named(name) {
600 Some(v) => v,
601 None => args.positional(pos)?,
602 };
603 match value {
604 Value::Str(s) => Ok(s.clone()),
605 other => Err(RuntimeError::TypeMismatch {
606 expected: "string".into(),
607 actual: other.kind_name().into(),
608 }),
609 }
610}
611
612fn extract_string_list(args: &ToolArgs, name: &str) -> Result<Vec<String>, RuntimeError> {
613 match args.named(name) {
614 Some(Value::List(items)) => items
615 .iter()
616 .map(|v| match v {
617 Value::Str(s) => Ok(s.clone()),
618 other => Err(RuntimeError::TypeMismatch {
619 expected: "string".into(),
620 actual: other.kind_name().into(),
621 }),
622 })
623 .collect(),
624 Some(other) => Err(RuntimeError::TypeMismatch {
625 expected: "list<string>".into(),
626 actual: other.kind_name().into(),
627 }),
628 None => Err(RuntimeError::MissingArg(name.into())),
629 }
630}
631
632fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
633 match args.named(name)? {
634 Value::Str(s) => Some(s.clone()),
635 _ => None,
636 }
637}
638
639fn extract_optional_bool(args: &ToolArgs, name: &str) -> Option<bool> {
640 match args.named(name)? {
641 Value::Bool(b) => Some(*b),
642 _ => None,
643 }
644}
645
646fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
647 match args.named(name)? {
648 Value::Int(n) => Some(*n),
649 _ => None,
650 }
651}
652
653fn commit_diff<'repo>(
654 repo: &'repo Repository,
655 commit: &Commit<'repo>,
656 tool: &str,
657) -> Result<Diff<'repo>, RuntimeError> {
658 let new_tree = commit
659 .tree()
660 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} tree: {e}")))?;
661 let old_tree = if commit.parent_count() == 0 {
662 None
663 } else {
664 Some(
665 commit
666 .parent(0)
667 .and_then(|p| p.tree())
668 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} parent: {e}")))?,
669 )
670 };
671 repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)
672 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} diff: {e}")))
673}
674
675fn diff_files(diff: &Diff<'_>, tool: &str) -> Result<Vec<String>, RuntimeError> {
676 let mut files = Vec::new();
677 diff.foreach(
678 &mut |delta, _| {
679 let path = delta
680 .new_file()
681 .path()
682 .or_else(|| delta.old_file().path())
683 .map(|p| p.to_string_lossy().into_owned());
684 if let Some(path) = path
685 && !files.contains(&path)
686 {
687 files.push(path);
688 }
689 true
690 },
691 None,
692 None,
693 None,
694 )
695 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} files: {e}")))?;
696 Ok(files)
697}
698
699fn diff_patch(diff: &Diff<'_>, tool: &str) -> Result<String, RuntimeError> {
700 let mut body = String::new();
701 diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
702 match line.origin() {
703 'F' | 'H' => body.push_str(&String::from_utf8_lossy(line.content())),
704 '+' | '-' | ' ' => {
705 body.push(line.origin());
706 body.push_str(&String::from_utf8_lossy(line.content()));
707 }
708 _ => body.push_str(&String::from_utf8_lossy(line.content())),
709 }
710 true
711 })
712 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} patch: {e}")))?;
713 Ok(body)
714}
715
716fn commit_entry(commit: &Commit<'_>, stats: &git2::DiffStats) -> Value {
717 let author = commit.author();
718 let author_name = author.name().unwrap_or_default();
719 let author_email = author.email().unwrap_or_default();
720 let author_display = if author_email.is_empty() {
721 author_name.to_string()
722 } else if author_name.is_empty() {
723 author_email.to_string()
724 } else {
725 format!("{author_name} <{author_email}>")
726 };
727 Value::Struct(vec![
728 ("sha".into(), Value::Str(commit.id().to_string())),
729 ("author".into(), Value::Str(author_display)),
730 (
731 "date".into(),
732 Value::Str(commit.time().seconds().to_string()),
733 ),
734 (
735 "message".into(),
736 Value::Str(commit.summary().unwrap_or_default().to_string()),
737 ),
738 (
739 "stats".into(),
740 Value::Struct(vec![
741 ("files".into(), Value::Int(stats.files_changed() as i64)),
742 ("insertions".into(), Value::Int(stats.insertions() as i64)),
743 ("deletions".into(), Value::Int(stats.deletions() as i64)),
744 ]),
745 ),
746 ])
747}
748
749fn index_status(status: Status) -> Option<&'static str> {
750 if status.is_index_new() {
751 Some("new")
752 } else if status.is_index_modified() {
753 Some("modified")
754 } else if status.is_index_deleted() {
755 Some("deleted")
756 } else if status.is_index_renamed() {
757 Some("renamed")
758 } else {
759 None
760 }
761}
762
763fn worktree_status(status: Status) -> Option<&'static str> {
764 if status.is_wt_modified() {
765 Some("modified")
766 } else if status.is_wt_deleted() {
767 Some("deleted")
768 } else if status.is_wt_renamed() {
769 Some("renamed")
770 } else {
771 None
772 }
773}
774
775fn status_entry(path: String, status: &str) -> Value {
776 Value::Struct(vec![
777 ("path".into(), Value::Str(path)),
778 ("status".into(), Value::Str(status.into())),
779 ])
780}
781
782fn staged_count(repo: &Repository, tool: &str) -> Result<i64, RuntimeError> {
783 let mut opts = StatusOptions::new();
784 opts.include_untracked(false).renames_head_to_index(true);
785 let statuses = repo
786 .statuses(Some(&mut opts))
787 .map_err(|e| RuntimeError::ToolFailed(format!("{tool}: {e}")))?;
788 Ok(statuses
789 .iter()
790 .filter(|entry| index_status(entry.status()).is_some())
791 .count() as i64)
792}
793
794fn current_branch(cwd: &std::path::Path) -> Result<String, RuntimeError> {
795 let repo =
796 Repository::open(cwd).map_err(|e| RuntimeError::ToolFailed(format!("git.push: {e}")))?;
797 let head = repo
798 .head()
799 .map_err(|e| RuntimeError::ToolFailed(format!("git.push head: {e}")))?;
800 head.shorthand()
801 .map(str::to_string)
802 .ok_or_else(|| RuntimeError::ToolFailed("git.push: detached HEAD has no branch".into()))
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::git::GitCli;
809 use std::path::Path;
810
811 #[test]
812 fn mutation_provenance_uses_cwd_and_marks_repository_mutation() {
813 let dir = tempfile::tempdir().unwrap();
814 let ctx = ToolCtx::default();
815 let args = ToolArgs {
816 named: vec![
817 ("cwd".into(), Value::Str(dir.path().display().to_string())),
818 ("message".into(), Value::Str("wip".into())),
819 ],
820 ..ToolArgs::default()
821 };
822 let provenance = git_mutation_provenance(&args, &ctx).unwrap();
823 let cwd = provenance.cwd.expect("cwd recorded");
824 assert_eq!(
825 std::fs::canonicalize(&cwd).unwrap(),
826 std::fs::canonicalize(dir.path()).unwrap()
827 );
828 assert_eq!(provenance.path, None);
829 assert!(
830 provenance
831 .risks
832 .contains(&crate::trust::RiskKind::RepositoryMutation)
833 );
834 }
835
836 #[test]
837 fn push_and_fetch_declare_network_reach() {
838 let ctx = ToolCtx::default();
839 let args = ToolArgs::default();
840 assert!(GitPush.invocation_provenance(&args, &ctx).unwrap().network);
841 assert!(GitFetch.invocation_provenance(&args, &ctx).unwrap().network);
842 }
843
844 fn have_git() -> bool {
845 GitCli::ensure_available().is_ok()
846 }
847
848 fn seed_two_commits(dir: &Path) {
849 let cli = GitCli::at(dir);
850 cli.init("main").unwrap();
851 for (k, v) in [
852 ("user.email", "t@atman.local"),
853 ("user.name", "atman test"),
854 ("commit.gpgsign", "false"),
855 ] {
856 cli.run(&["config", k, v]).unwrap();
857 }
858 std::fs::write(dir.join("a.txt"), "one\n").unwrap();
859 cli.add_all().unwrap();
860 cli.commit("initial").unwrap();
861 std::fs::write(dir.join("a.txt"), "one\ntwo\n").unwrap();
862 cli.add_all().unwrap();
863 cli.commit("second").unwrap();
864 }
865
866 #[tokio::test]
867 async fn status_defaults_to_managed_workspace() {
868 let tmp = tempfile::tempdir().unwrap();
869 git2::Repository::init(tmp.path()).unwrap();
870 let ctx = ToolCtx::new().with_workspace(crate::git_workspace::WorkspaceBinding {
871 workspace_id: "test".into(),
872 repository_root: tmp.path().to_path_buf(),
873 path: tmp.path().to_path_buf(),
874 branch: None,
875 });
876
877 let value = GitStatus
878 .call(
879 ToolArgs {
880 positional: Vec::new(),
881 named: Vec::new(),
882 },
883 &ctx,
884 )
885 .await
886 .unwrap();
887 assert!(matches!(value.field("staged"), Some(Value::List(_))));
888 }
889
890 #[tokio::test]
891 async fn managed_git_external_read_allowed_but_mutations_leave_repo_unchanged() {
892 if !have_git() {
893 return;
894 }
895 let repo_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
896 .join("target")
897 .join(format!("r4-git-{}", uuid::Uuid::now_v7()));
898 std::fs::create_dir_all(&repo_dir).unwrap();
899 seed_two_commits(&repo_dir);
900 std::fs::write(repo_dir.join("new.txt"), "new\n").unwrap();
901 let workspace = tempfile::tempdir().unwrap();
902 let ctx = ToolCtx::new()
903 .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
904 workspace.path().into(),
905 ))
906 .with_workspace(crate::git_workspace::WorkspaceBinding {
907 workspace_id: "test".into(),
908 repository_root: workspace.path().into(),
909 path: workspace.path().into(),
910 branch: None,
911 });
912 let cwd = Value::Str(repo_dir.to_string_lossy().into());
913 let status = GitStatus
914 .call(
915 ToolArgs {
916 positional: vec![],
917 named: vec![("cwd".into(), cwd.clone())],
918 },
919 &ctx,
920 )
921 .await
922 .unwrap();
923 assert!(matches!(status.field("untracked"), Some(Value::List(paths)) if !paths.is_empty()));
924 let repo = Repository::open(&repo_dir).unwrap();
925 let index_before = repo.index().unwrap().write_tree().unwrap();
926 let head_before = repo.head().unwrap().target().unwrap();
927 let add_error = GitAdd
928 .call(
929 ToolArgs {
930 positional: vec![],
931 named: vec![
932 (
933 "paths".into(),
934 Value::List(vec![Value::Str("new.txt".into())]),
935 ),
936 ("cwd".into(), cwd.clone()),
937 ],
938 },
939 &ctx,
940 )
941 .await
942 .unwrap_err();
943 assert!(add_error.to_string().contains("outside workspace"));
944 assert_eq!(
945 Repository::open(&repo_dir)
946 .unwrap()
947 .index()
948 .unwrap()
949 .write_tree()
950 .unwrap(),
951 index_before
952 );
953 let commit_error = GitCommit
954 .call(
955 ToolArgs {
956 positional: vec![],
957 named: vec![
958 ("message".into(), Value::Str("blocked".into())),
959 ("cwd".into(), cwd),
960 ],
961 },
962 &ctx,
963 )
964 .await
965 .unwrap_err();
966 assert!(commit_error.to_string().contains("outside workspace"));
967 assert_eq!(
968 Repository::open(&repo_dir)
969 .unwrap()
970 .head()
971 .unwrap()
972 .target()
973 .unwrap(),
974 head_before
975 );
976 std::fs::remove_dir_all(repo_dir).unwrap();
977 }
978
979 #[tokio::test]
980 async fn log_returns_limited_commits_and_head_patch() {
981 if !have_git() {
982 eprintln!("skip: git not on PATH");
983 return;
984 }
985 let tmp = tempfile::tempdir().unwrap();
986 seed_two_commits(tmp.path());
987 let ctx = ToolCtx::new();
988 let args = ToolArgs {
989 positional: Vec::new(),
990 named: vec![
991 ("limit".into(), Value::Int(1)),
992 (
993 "cwd".into(),
994 Value::Str(tmp.path().to_string_lossy().into()),
995 ),
996 ],
997 };
998
999 let value = GitLog.call(args, &ctx).await.unwrap();
1000 let commits = value.field("commits").unwrap();
1001 let Value::List(commits) = commits else {
1002 panic!("expected commits list: {commits:?}");
1003 };
1004 assert_eq!(commits.len(), 1);
1005 let head = &commits[0];
1006 assert!(matches!(head.field("message"), Some(Value::Str(s)) if s == "second"));
1007 assert!(matches!(head.field("sha"), Some(Value::Str(sha)) if sha.len() == 40));
1008 let stats = head.field("stats").unwrap();
1009 assert!(matches!(stats.field("files"), Some(Value::Int(1))));
1010 assert!(matches!(stats.field("insertions"), Some(Value::Int(1))));
1011 let diff = value.field("diff").unwrap();
1012 assert!(
1013 matches!(diff, Value::Str(s) if s.contains("+two")),
1014 "diff={diff:?}"
1015 );
1016 }
1017}