Skip to main content

atman_runtime/tools/
git_ops.rs

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
555/// Provenance for the git tools that mutate a repository. `cwd` is their only
556/// path-shaped argument (a sha, remote, branch, or pathspec is not a filesystem
557/// target the resolver can classify), and it resolves through the same resolver
558/// `extract_cwd` uses so the gate classifies the repository the command will
559/// actually run against.
560pub(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}