Skip to main content

hematite/tools/
git.rs

1use crate::agent::git::is_git_repo;
2use serde_json::Value;
3use std::path::Path;
4use std::process::Command;
5
6/// tool: git_commit
7///
8/// Action: Stage all changes (git add -A) and commit them using the 'Conventional Commits' style.
9pub async fn execute(args: &Value) -> Result<String, String> {
10    let message = args
11        .get("message")
12        .and_then(|v| v.as_str())
13        .ok_or_else(|| "Missing required argument: 'message'".to_string())?;
14
15    let repo_path = Path::new(".");
16    if !is_git_repo(repo_path) {
17        return Err("Current directory is not a Git repository".to_string());
18    }
19
20    // 1. Stage all changes
21    let add_status = std::process::Command::new("git")
22        .arg("add")
23        .arg("-A")
24        .stdout(std::process::Stdio::null())
25        .stderr(std::process::Stdio::null())
26        .status()
27        .map_err(|e| format!("Failed to run git add: {e}"))?;
28
29    if !add_status.success() {
30        return Err("Git 'add' failed".to_string());
31    }
32
33    // 2. Commit
34    let commit_status = std::process::Command::new("git")
35        .arg("commit")
36        .arg("-m")
37        .arg(message)
38        .stdout(std::process::Stdio::null())
39        .stderr(std::process::Stdio::null())
40        .status()
41        .map_err(|e| format!("Failed to run git commit: {e}"))?;
42
43    if commit_status.success() {
44        Ok(format!("Successfully committed changes: '{message}'"))
45    } else {
46        Err("Git 'commit' failed (maybe nothing to commit or malformed message?)".to_string())
47    }
48}
49
50/// tool: git_push
51pub async fn execute_push(_args: &Value) -> Result<String, String> {
52    let repo_path = Path::new(".");
53    if !is_git_repo(repo_path) {
54        return Err("Current directory is not a Git repository".to_string());
55    }
56
57    let output = Command::new("git")
58        .args(["push", "origin", "HEAD"])
59        .output()
60        .map_err(|e| format!("Failed to execution git push: {e}"))?;
61
62    if output.status.success() {
63        Ok("Changes successfully pushed to remote origin.".to_string())
64    } else {
65        let stderr = String::from_utf8_lossy(&output.stderr);
66        Err(format!("Git push failed: {}", stderr))
67    }
68}
69
70/// tool: git_remote
71pub async fn execute_remote(args: &Value) -> Result<String, String> {
72    let action = args
73        .get("action")
74        .and_then(|v| v.as_str())
75        .unwrap_or("list");
76    let repo_path = Path::new(".");
77    if !is_git_repo(repo_path) {
78        return Err("Current directory is not a Git repository".to_string());
79    }
80
81    match action {
82        "list" => {
83            let output = Command::new("git")
84                .arg("remote")
85                .arg("-v")
86                .output()
87                .map_err(|e| format!("Failed to list remotes: {e}"))?;
88            Ok(String::from_utf8_lossy(&output.stdout).into_owned())
89        }
90        "add" => {
91            let name = args
92                .get("name")
93                .and_then(|v| v.as_str())
94                .ok_or("Missing name for add")?;
95            let url = args
96                .get("url")
97                .and_then(|v| v.as_str())
98                .ok_or("Missing url for add")?;
99            let status = std::process::Command::new("git")
100                .args(["remote", "add", name, url])
101                .stdout(std::process::Stdio::null())
102                .stderr(std::process::Stdio::null())
103                .status()
104                .map_err(|e| format!("Failed to add remote: {e}"))?;
105            if status.success() {
106                Ok(format!("Successfully added remote '{}' -> {}", name, url))
107            } else {
108                Err("Failed to add remote (it might already exist)".to_string())
109            }
110        }
111        "remove" => {
112            let name = args
113                .get("name")
114                .and_then(|v| v.as_str())
115                .ok_or("Missing name for remove")?;
116            let status = std::process::Command::new("git")
117                .args(["remote", "remove", name])
118                .stdout(std::process::Stdio::null())
119                .stderr(std::process::Stdio::null())
120                .status()
121                .map_err(|e| format!("Failed to remove remote: {e}"))?;
122            if status.success() {
123                Ok(format!("Successfully removed remote '{}'", name))
124            } else {
125                Err("Failed to remove remote".to_string())
126            }
127        }
128        _ => Err(format!("Unknown action: {}", action)),
129    }
130}
131
132/// tool: git_worktree
133///
134/// Manage Git worktrees — isolated working directories on separate branches.
135/// Use this to do risky or experimental work without touching the main branch.
136pub async fn execute_worktree(args: &Value) -> Result<String, String> {
137    let action = args
138        .get("action")
139        .and_then(|v| v.as_str())
140        .ok_or_else(|| "Missing required argument: 'action' (list|add|remove|prune)".to_string())?;
141
142    let repo_path = Path::new(".");
143    if !is_git_repo(repo_path) {
144        return Err("Current directory is not a Git repository".to_string());
145    }
146
147    match action {
148        "list" => {
149            let output = Command::new("git")
150                .args(["worktree", "list"])
151                .output()
152                .map_err(|e| format!("Failed to list worktrees: {e}"))?;
153            let out = String::from_utf8_lossy(&output.stdout).into_owned();
154            if out.trim().is_empty() {
155                Ok("No worktrees (only main working tree)".to_string())
156            } else {
157                Ok(out)
158            }
159        }
160
161        "add" => {
162            let path = args
163                .get("path")
164                .and_then(|v| v.as_str())
165                .ok_or_else(|| "Missing 'path' for worktree add".to_string())?;
166
167            // Derive branch name from path basename if not explicitly provided.
168            let branch_arg = args.get("branch").and_then(|v| v.as_str());
169            let branch = branch_arg.unwrap_or_else(|| {
170                std::path::Path::new(path)
171                    .file_name()
172                    .and_then(|s| s.to_str())
173                    .unwrap_or(path)
174            });
175
176            // Check if the branch already exists.
177            let branch_check = Command::new("git")
178                .args(["branch", "--list", branch])
179                .output()
180                .map_err(|e| format!("Failed to check branch: {e}"))?;
181            let branch_exists = !String::from_utf8_lossy(&branch_check.stdout)
182                .trim()
183                .is_empty();
184
185            let output = if branch_exists {
186                // Check out existing branch.
187                Command::new("git")
188                    .args(["worktree", "add", path, branch])
189                    .output()
190                    .map_err(|e| format!("Failed to add worktree: {e}"))?
191            } else {
192                // Create new branch.
193                Command::new("git")
194                    .args(["worktree", "add", path, "-b", branch])
195                    .output()
196                    .map_err(|e| format!("Failed to add worktree: {e}"))?
197            };
198
199            if output.status.success() {
200                Ok(format!(
201                    "Worktree created at '{path}' on branch '{branch}'.\n\
202                     Work there independently, then commit and merge back when ready."
203                ))
204            } else {
205                let stderr = String::from_utf8_lossy(&output.stderr);
206                Err(format!("Failed to create worktree: {}", stderr.trim()))
207            }
208        }
209
210        "remove" => {
211            let path = args
212                .get("path")
213                .and_then(|v| v.as_str())
214                .ok_or_else(|| "Missing 'path' for worktree remove".to_string())?;
215
216            let output = Command::new("git")
217                .args(["worktree", "remove", path])
218                .output()
219                .map_err(|e| format!("Failed to remove worktree: {e}"))?;
220
221            if output.status.success() {
222                Ok(format!("Worktree '{path}' removed."))
223            } else {
224                let stderr = String::from_utf8_lossy(&output.stderr);
225                // If it has uncommitted changes, suggest --force.
226                if stderr.contains("contains modified or untracked files") {
227                    Err(format!(
228                        "Worktree '{path}' has uncommitted changes. \
229                         Commit or stash them first, or use action=remove with force=true."
230                    ))
231                } else {
232                    Err(format!("Failed to remove worktree: {}", stderr.trim()))
233                }
234            }
235        }
236
237        "prune" => {
238            let output = Command::new("git")
239                .args(["worktree", "prune", "-v"])
240                .output()
241                .map_err(|e| format!("Failed to prune worktrees: {e}"))?;
242            let out = String::from_utf8_lossy(&output.stdout).into_owned();
243            Ok(if out.trim().is_empty() {
244                "Nothing to prune.".to_string()
245            } else {
246                out
247            })
248        }
249
250        _ => Err(format!(
251            "Unknown worktree action '{action}'. Use: list | add | remove | prune"
252        )),
253    }
254}