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