返回 CodeWhale
git_tool.rs
根目录 / crates / tui / src / tools / git_tool.rs
1 //! Canonical action-based wrapper for git inspection tools.
2 //!
3 //! The model sees one tool: `Git` with an `action` parameter
4 //! (status | diff | log | show | blame | commit_plan | fetch | merge_tree).
5 //! The per-action legacy execution aliases were removed in v0.9.3.
6 //! `commit_plan` (#3999) is the propose-only atomic-commit planner: it returns
7 //! a split plan and writes nothing. `fetch` is the family's one ref-mutating,
8 //! network-reaching action — the bounded verify-mode surface (#6298) — so the
9 //! family is read-only end to end *except* fetch; `merge_tree` is a pure read.
10
11 use async_trait::async_trait;
12 use serde_json::{Value, json};
13
14 use super::canonical_action::required_action;
15 use super::git::{GitCommitPlanTool, GitDiffTool, GitStatusTool};
16 use super::git_history::{GitBlameTool, GitFetchTool, GitLogTool, GitMergeTreeTool, GitShowTool};
17 use super::spec::{
18 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
19 };
20
21 pub struct GitTool {
22 name: &'static str,
23 forced_action: Option<&'static str>,
24 }
25
26 impl GitTool {
27 pub const fn new(name: &'static str) -> Self {
28 Self {
29 name,
30 forced_action: None,
31 }
32 }
33
34 const ACTIONS: &'static [&'static str] = &[
35 "status",
36 "diff",
37 "log",
38 "show",
39 "blame",
40 "commit_plan",
41 "fetch",
42 "merge_tree",
43 ];
44
45 fn required_action(&self, input: &Value) -> Result<String, ToolError> {
46 if let Some(forced) = self.forced_action {
47 return Ok(forced.to_string());
48 }
49 required_action(input, self.name, Self::ACTIONS)
50 }
51
52 fn strip_action(&self, input: Value) -> Result<Value, ToolError> {
53 let mut input = input;
54 if let Some(obj) = input.as_object_mut() {
55 obj.remove("action");
56 Ok(input)
57 } else {
58 Err(ToolError::invalid_input(
59 "Git tool input must be a JSON object, e.g. {\"action\": \"status\"}",
60 ))
61 }
62 }
63 }
64
65 #[async_trait]
66 impl ToolSpec for GitTool {
67 fn name(&self) -> &'static str {
68 self.name
69 }
70
71 fn model_visible(&self) -> bool {
72 self.name == "Git"
73 }
74
75 fn description(&self) -> &'static str {
76 "Inspect repository state and history with status, diff, log, show, blame, or merge_tree; commit_plan proposes an ordered atomic-commit split of the working tree; fetch updates remote-tracking refs from a configured remote. Only fetch touches the network or mutates refs; every other action is read-only and parallel-safe."
77 }
78
79 fn input_schema(&self) -> Value {
80 json!({
81 "type": "object",
82 "properties": {
83 "action": {
84 "type": "string",
85 "enum": ["status", "diff", "log", "show", "blame", "commit_plan", "fetch", "merge_tree"],
86 "description": "Action to perform. commit_plan returns a proposed split of the working tree into dependency-ordered commits (rejecting cycles) and writes nothing; land each group with git add/commit. fetch updates remote-tracking refs from a configured remote only; merge_tree computes a merge result without touching the working tree."
87 },
88 "path": {
89 "type": "string",
90 "description": "Optional subdirectory or file path to scope the git command"
91 },
92 "cached": {
93 "type": "boolean",
94 "description": "When true, diff staged changes (action=diff)"
95 },
96 "unified": {
97 "type": "integer",
98 "description": "Number of context lines for diff or show output"
99 },
100 "max_count": {
101 "type": "integer",
102 "description": "Maximum commits to return (action=log)"
103 },
104 "author": {
105 "type": "string",
106 "description": "Author filter (action=log)"
107 },
108 "since": {
109 "type": "string",
110 "description": "Lower date bound (action=log)"
111 },
112 "until": {
113 "type": "string",
114 "description": "Upper date bound (action=log)"
115 },
116 "rev": {
117 "type": "string",
118 "description": "Revision to show (action=show) or blame against (action=blame)"
119 },
120 "patch": {
121 "type": "boolean",
122 "description": "Include patch hunks (action=show)"
123 },
124 "stat": {
125 "type": "boolean",
126 "description": "Include stat summary (action=show)"
127 },
128 "start_line": {
129 "type": "integer",
130 "description": "First line to include (action=blame)"
131 },
132 "max_lines": {
133 "type": "integer",
134 "description": "Maximum lines to include (action=blame)"
135 },
136 "porcelain": {
137 "type": "boolean",
138 "description": "Emit line-porcelain output (action=blame)"
139 },
140 "remote": {
141 "type": "string",
142 "description": "Configured remote name to fetch from, default origin (action=fetch). Never a URL."
143 },
144 "refspecs": {
145 "type": "array",
146 "items": { "type": "string" },
147 "description": "Optional refspecs to fetch, e.g. pull/123/head (action=fetch). Empty fetches the remote's defaults."
148 },
149 "ours": {
150 "type": "string",
151 "description": "First revision (action=merge_tree)"
152 },
153 "theirs": {
154 "type": "string",
155 "description": "Second revision (action=merge_tree)"
156 },
157 "base": {
158 "type": "string",
159 "description": "Optional merge base (--merge-base); omit and git finds the bases itself (action=merge_tree)"
160 }
161 },
162 "required": ["action"]
163 })
164 }
165
166 fn capabilities(&self) -> Vec<ToolCapability> {
167 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
168 }
169
170 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
171 // `fetch` is the family's one network-reaching, ref-mutating action;
172 // it holds the same bar as the other code/process-executing tools.
173 if input.get("action").and_then(Value::as_str) == Some("fetch") {
174 ApprovalRequirement::Required
175 } else {
176 ApprovalRequirement::Auto
177 }
178 }
179
180 fn is_read_only_for(&self, input: &Value) -> bool {
181 // Only `fetch` mutates (remote-tracking refs). A missing action
182 // resolves to the `status` policy default, which is read-only.
183 input
184 .get("action")
185 .and_then(Value::as_str)
186 .is_none_or(|action| action != "fetch")
187 }
188
189 fn supports_parallel_for(&self, input: &Value) -> bool {
190 // Concurrent fetches contend on ref locks; inspection stays parallel.
191 input.get("action").and_then(Value::as_str) != Some("fetch")
192 }
193
194 fn starts_detached_for(&self, _input: &Value) -> bool {
195 false
196 }
197
198 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
199 let action = self.required_action(&input)?;
200 let input = self.strip_action(input)?;
201
202 match action.as_str() {
203 "status" => GitStatusTool.execute(input, context).await,
204 "diff" => GitDiffTool.execute(input, context).await,
205 "log" => GitLogTool.execute(input, context).await,
206 "show" => GitShowTool.execute(input, context).await,
207 "blame" => GitBlameTool.execute(input, context).await,
208 "commit_plan" => GitCommitPlanTool.execute(input, context).await,
209 "fetch" => GitFetchTool.execute(input, context).await,
210 "merge_tree" => GitMergeTreeTool.execute(input, context).await,
211 other => Err(ToolError::invalid_input(format!(
212 "Unknown Git action \"{other}\"; nothing was run. Pass one of: {}.",
213 Self::ACTIONS.join(", ")
214 ))),
215 }
216 }
217 }
218
219 #[cfg(test)]
220 mod tests {
221 use super::*;
222 use serde_json::json;
223 use tempfile::tempdir;
224
225 async fn err(input: Value) -> String {
226 let tmp = tempdir().expect("tempdir");
227 let ctx = ToolContext::new(tmp.path().to_path_buf());
228 GitTool::new("Git")
229 .execute(input, &ctx)
230 .await
231 .expect_err("call must be refused")
232 .to_string()
233 }
234
235 #[tokio::test]
236 async fn missing_action_is_refused_with_the_valid_values() {
237 let message = err(json!({"path": "src"})).await;
238 assert!(message.contains("requires an `action`"), "{message}");
239 assert!(message.contains("nothing was run"), "{message}");
240 assert!(message.contains("blame"), "{message}");
241 }
242
243 #[tokio::test]
244 async fn unknown_action_names_the_actions_that_dispatch() {
245 let message = err(json!({"action": "commit"})).await;
246 assert!(message.contains("commit"), "{message}");
247 assert!(
248 message.contains("status, diff, log, show, blame, commit_plan, fetch, merge_tree"),
249 "{message}"
250 );
251 }
252
253 /// `commit_plan` proposes and never writes, so the envelope must class it
254 /// with the other read-only Git actions rather than as a mutation (#3999).
255 #[test]
256 fn commit_plan_is_bounded_read_only_for_the_envelope() {
257 use crate::tools::execution_envelope::{CallClass, classify_call};
258 let tool = GitTool::new("Git");
259 let input = json!({"action": "commit_plan"});
260 assert!(tool.is_read_only_for(&input));
261 assert_eq!(classify_call("Git", &input, &tool), CallClass::Bounded);
262 }
263
264 /// `fetch` is the family's one ref-mutating action: held approval, not
265 /// read-only, not parallel-safe, and classed as bounded fetch — shell
266 /// plus network, never workspace write (#6298). `merge_tree` is a pure
267 /// read and stays with the inspection actions.
268 #[test]
269 fn fetch_and_merge_tree_classification() {
270 use crate::tools::execution_envelope::{CallClass, classify_call};
271 let tool = GitTool::new("Git");
272
273 let fetch = json!({"action": "fetch", "remote": "origin"});
274 assert!(!tool.is_read_only_for(&fetch));
275 assert_eq!(
276 tool.approval_requirement_for(&fetch),
277 ApprovalRequirement::Required
278 );
279 assert!(!tool.supports_parallel_for(&fetch));
280 assert_eq!(classify_call("Git", &fetch, &tool), CallClass::BoundedFetch);
281
282 let merge_tree = json!({"action": "merge_tree", "ours": "main", "theirs": "side"});
283 assert!(tool.is_read_only_for(&merge_tree));
284 assert_eq!(
285 tool.approval_requirement_for(&merge_tree),
286 ApprovalRequirement::Auto
287 );
288 assert!(tool.supports_parallel_for(&merge_tree));
289 assert_eq!(classify_call("Git", &merge_tree, &tool), CallClass::Bounded);
290 }
291
292 #[test]
293 fn advertised_actions_match_the_actions_that_dispatch() {
294 let schema = GitTool::new("Git").input_schema();
295 let advertised: Vec<&str> = schema["properties"]["action"]["enum"]
296 .as_array()
297 .expect("action enum")
298 .iter()
299 .map(|value| value.as_str().expect("string"))
300 .collect();
301 assert_eq!(advertised, GitTool::ACTIONS);
302 }
303 }
304
304 lines RUST