Skip to main content

ararajuba_tools_coding/git/
pull.rs

1//! `git_pull` tool — pull from a remote. **Requires approval.**
2
3use ararajuba_core::tools::tool::{tool, ToolDef};
4use git2::Repository;
5use serde_json::json;
6
7/// Create the `git_pull` tool.
8///
9/// Fetches and merges from the specified remote/branch. Requires approval as
10/// it modifies working directory from a remote source.
11pub fn git_pull_tool() -> ToolDef {
12    tool("git_pull")
13        .description("Pull (fetch + merge) from a remote. Requires approval.")
14        .input_schema(json!({
15            "type": "object",
16            "properties": {
17                "remote": { "type": "string", "description": "Remote name (default: origin)" },
18                "branch": { "type": "string", "description": "Branch to pull (default: current)" }
19            }
20        }))
21        .execute(|input| async move {
22            let remote_name = input["remote"].as_str().unwrap_or("origin");
23
24            let repo = Repository::discover(".")
25                .map_err(|e| format!("failed to open repository: {e}"))?;
26
27            let branch_name = if let Some(b) = input["branch"].as_str() {
28                b.to_string()
29            } else {
30                repo.head()
31                    .ok()
32                    .and_then(|h| h.shorthand().map(String::from))
33                    .ok_or_else(|| "cannot determine current branch".to_string())?
34            };
35
36            // Fetch
37            let mut remote = repo
38                .find_remote(remote_name)
39                .map_err(|e| format!("remote not found: {e}"))?;
40
41            remote
42                .fetch(&[&branch_name], None, None)
43                .map_err(|e| format!("fetch failed: {e}"))?;
44
45            // Get the fetch head
46            let fetch_head = repo
47                .find_reference(&format!("refs/remotes/{remote_name}/{branch_name}"))
48                .map_err(|e| format!("failed to find fetched ref: {e}"))?;
49
50            let fetch_commit = repo
51                .reference_to_annotated_commit(&fetch_head)
52                .map_err(|e| format!("failed to get annotated commit: {e}"))?;
53
54            // Merge analysis
55            let (merge_analysis, _) = repo
56                .merge_analysis(&[&fetch_commit])
57                .map_err(|e| format!("merge analysis failed: {e}"))?;
58
59            if merge_analysis.is_up_to_date() {
60                return Ok(json!({ "ok": true, "commits_pulled": 0 }));
61            }
62
63            if merge_analysis.is_fast_forward() {
64                // Fast-forward
65                let refname = format!("refs/heads/{branch_name}");
66                let mut reference = repo
67                    .find_reference(&refname)
68                    .map_err(|e| format!("failed to find reference: {e}"))?;
69                reference
70                    .set_target(fetch_commit.id(), "fast-forward pull")
71                    .map_err(|e| format!("failed to fast-forward: {e}"))?;
72                repo.set_head(&refname)
73                    .map_err(|e| format!("failed to set HEAD: {e}"))?;
74                repo.checkout_head(Some(
75                    git2::build::CheckoutBuilder::new().force(),
76                ))
77                .map_err(|e| format!("failed to checkout: {e}"))?;
78
79                Ok(json!({ "ok": true, "commits_pulled": 1 }))
80            } else {
81                // Normal merge — perform a merge commit
82                repo.merge(&[&fetch_commit], None, None)
83                    .map_err(|e| format!("merge failed: {e}"))?;
84
85                Ok(json!({ "ok": true, "commits_pulled": 1 }))
86            }
87        })
88        .needs_approval(|_input| true)
89        .build()
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn tool_metadata() {
98        let t = git_pull_tool();
99        assert_eq!(t.name, "git_pull");
100        assert!(t.execute.is_some());
101        assert!(t.needs_approval.is_some());
102    }
103}