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