Skip to content

Commit 5556770

Browse files
authored
feat: adds git tool (#729)
* feat: adds git tool * fix: fmt * fix: modifies display format
1 parent c58317a commit 5556770

File tree

3 files changed

+242
-0
lines changed

3 files changed

+242
-0
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
use std::collections::HashMap;
2+
use std::io::Write;
3+
use std::process::Stdio;
4+
5+
use bstr::ByteSlice;
6+
use crossterm::{
7+
queue,
8+
style,
9+
};
10+
use eyre::{
11+
Context as _,
12+
Result,
13+
};
14+
use fig_os_shim::Context;
15+
use serde::Deserialize;
16+
17+
use super::{
18+
InvokeOutput,
19+
OutputKind,
20+
};
21+
22+
const READONLY_COMMANDS: [&str; 12] = [
23+
"status",
24+
"log",
25+
"show",
26+
"diff",
27+
"grep",
28+
"ls-files",
29+
"ls-remote",
30+
"rev-parse",
31+
"blame",
32+
"describe",
33+
"cat-file",
34+
"check-ignore",
35+
];
36+
37+
const READONLY_SUBCOMMANDS: [(&str, &str); 4] = [
38+
("config", "get"),
39+
("config", "list"),
40+
("remote", "show"),
41+
("remote", "get-url"),
42+
];
43+
44+
#[derive(Debug, Clone, Deserialize)]
45+
pub struct Git {
46+
pub command: String,
47+
pub subcommand: Option<String>,
48+
pub repo: Option<String>,
49+
pub branch: Option<String>,
50+
pub parameters: Option<HashMap<String, serde_json::Value>>,
51+
pub label: Option<String>,
52+
}
53+
54+
impl Git {
55+
pub fn requires_consent(&self) -> bool {
56+
if READONLY_COMMANDS.contains(&self.command.as_str()) {
57+
return false;
58+
}
59+
60+
match (self.command.as_str(), self.subcommand.as_ref()) {
61+
("branch" | "tag" | "remote", None)
62+
if self.parameters.is_none() || self.parameters.as_ref().is_some_and(|p| p.is_empty()) =>
63+
{
64+
false
65+
},
66+
(cmd, Some(sub_command)) => !READONLY_SUBCOMMANDS.contains(&(cmd, sub_command)),
67+
_ => true,
68+
}
69+
}
70+
71+
pub async fn invoke(&self, _ctx: &Context, _updates: impl Write) -> Result<InvokeOutput> {
72+
let mut command = tokio::process::Command::new("git");
73+
command.arg(&self.command);
74+
if let Some(subcommand) = self.subcommand.as_ref() {
75+
command.arg(subcommand);
76+
}
77+
if let Some(repo) = self.repo.as_ref() {
78+
command.arg(repo);
79+
}
80+
if let Some(branch) = self.branch.as_ref() {
81+
command.arg(branch);
82+
}
83+
if let Some(parameters) = self.parameters.as_ref() {
84+
for (name, val) in parameters {
85+
let param_name = format!("--{}", name.trim_start_matches("--"));
86+
let param_val = val.as_str().map(|s| s.to_string()).unwrap_or(val.to_string());
87+
command.arg(param_name).arg(param_val);
88+
}
89+
}
90+
let output = command
91+
.stdout(Stdio::piped())
92+
.stderr(Stdio::piped())
93+
.spawn()
94+
.wrap_err_with(|| format!("Unable to spawn command '{:?}'", self))?
95+
.wait_with_output()
96+
.await
97+
.wrap_err_with(|| format!("Unable to spawn command '{:?}'", self))?;
98+
let status = output.stdout.to_str_lossy();
99+
let stdout = output.stdout.to_str_lossy();
100+
let stderr = output.stderr.to_str_lossy();
101+
102+
Ok(InvokeOutput {
103+
output: OutputKind::Json(serde_json::json!({
104+
"exit_status": status,
105+
"stdout": stdout,
106+
"stderr": stderr.clone()
107+
})),
108+
})
109+
}
110+
111+
pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> {
112+
if let Some(label) = self.label.as_ref() {
113+
queue!(updates, style::Print(label))?;
114+
}
115+
queue!(
116+
updates,
117+
style::Print("\n"),
118+
style::Print(format!("Command: git {}", self.command))
119+
)?;
120+
if let Some(subcommand) = self.subcommand.as_ref() {
121+
queue!(updates, style::Print(format!(" {}", subcommand)))?;
122+
}
123+
if let Some(repo) = self.repo.as_ref() {
124+
queue!(updates, style::Print(format!(" {}", repo)))?;
125+
}
126+
if let Some(branch) = self.branch.as_ref() {
127+
queue!(updates, style::Print(format!(" {}", branch)))?;
128+
}
129+
if let Some(parameters) = self.parameters.as_ref() {
130+
for (name, val) in parameters {
131+
let param_name = format!("--{}", name.trim_start_matches("--"));
132+
let param_val = val.as_str().map(|s| s.to_string()).unwrap_or(val.to_string());
133+
queue!(updates, style::Print(format!(" {} {}", param_name, param_val)))?;
134+
}
135+
}
136+
Ok(())
137+
}
138+
139+
pub async fn validate(&mut self, _ctx: &Context) -> Result<()> {
140+
Ok(())
141+
}
142+
}
143+
144+
#[cfg(test)]
145+
mod tests {
146+
use super::*;
147+
148+
macro_rules! git {
149+
($value:tt) => {
150+
serde_json::from_value::<Git>(serde_json::json!($value)).unwrap()
151+
};
152+
}
153+
154+
#[test]
155+
fn test_requires_consent() {
156+
let readonly_commands = [
157+
git!({"command": "log"}),
158+
git!({"command": "status"}),
159+
git!({"command": "diff"}),
160+
git!({"command": "show"}),
161+
git!({"command": "ls-files"}),
162+
git!({"command": "branch"}),
163+
git!({"command": "tag"}),
164+
git!({"command": "remote"}),
165+
git!({"command": "blame", "parameters": {"file": "src/main.rs"}}),
166+
git!({"command": "rev-parse", "parameters": {"show-toplevel": true}}),
167+
git!({"command": "ls-remote", "repo": "origin"}),
168+
git!({"command": "config", "subcommand": "get", "parameters": {"name": "user.email"}}),
169+
git!({"command": "config", "subcommand": "list"}),
170+
git!({"command": "describe", "parameters": {"tags": true}}),
171+
];
172+
173+
for cmd in readonly_commands {
174+
assert!(!cmd.requires_consent(), "Command should not require consent: {:?}", cmd);
175+
}
176+
177+
let write_commands = [
178+
git!({"command": "commit", "parameters": {"message": "Initial commit"}}),
179+
git!({"command": "push", "repo": "origin", "branch": "main"}),
180+
git!({"command": "pull", "repo": "origin", "branch": "main"}),
181+
git!({"command": "merge", "branch": "feature"}),
182+
git!({"command": "branch", "subcommand": "create", "branch": "new-feature"}),
183+
git!({"command": "branch", "parameters": {"-D": true}, "branch": "old-feature"}),
184+
git!({"command": "branch", "parameters": {"--delete": true}, "branch": "old-feature"}),
185+
git!({"command": "checkout", "branch": "develop"}),
186+
git!({"command": "switch", "branch": "develop"}),
187+
git!({"command": "reset", "parameters": {"hard": true}}),
188+
git!({"command": "clean", "parameters": {"-fd": true}}),
189+
git!({"command": "clone", "repo": "https://github.com/user/repo.git"}),
190+
git!({"command": "remote", "subcommand": "add", "repo": "https://github.com/user/repo.git", "parameters": {"name": "upstream"}}),
191+
git!({"command": "config", "subcommand": "set", "parameters": {"name": "user.email", "value": "[email protected]"}}),
192+
];
193+
194+
for cmd in write_commands {
195+
assert!(cmd.requires_consent(), "Command should require consent: {:?}", cmd);
196+
}
197+
}
198+
}

crates/q_cli/src/cli/chat/tools/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod execute_bash;
22
pub mod fs_read;
33
pub mod fs_write;
4+
pub mod git;
45
pub mod use_aws;
56

67
use std::io::Write;
@@ -28,6 +29,7 @@ use fig_api_client::model::{
2829
use fig_os_shim::Context;
2930
use fs_read::FsRead;
3031
use fs_write::FsWrite;
32+
use git::Git;
3133
use serde::Deserialize;
3234
use syntect::easy::HighlightLines;
3335
use syntect::highlighting::ThemeSet;
@@ -56,6 +58,7 @@ pub enum Tool {
5658
FsWrite(FsWrite),
5759
ExecuteBash(ExecuteBash),
5860
UseAws(UseAws),
61+
Git(Git),
5962
}
6063

6164
impl Tool {
@@ -66,6 +69,7 @@ impl Tool {
6669
Tool::FsWrite(_) => "Write to filesystem",
6770
Tool::ExecuteBash(_) => "Execute shell command",
6871
Tool::UseAws(_) => "Use AWS CLI",
72+
Tool::Git(_) => "Git",
6973
}
7074
}
7175

@@ -76,6 +80,7 @@ impl Tool {
7680
Tool::FsWrite(_) => "Writing to filesystem",
7781
Tool::ExecuteBash(execute_bash) => return format!("Executing `{}`", execute_bash.command),
7882
Tool::UseAws(_) => "Using AWS CLI",
83+
Tool::Git(_) => "Using Git CLI",
7984
}
8085
.to_owned()
8186
}
@@ -87,6 +92,7 @@ impl Tool {
8792
Tool::FsWrite(_) => true,
8893
Tool::ExecuteBash(execute_bash) => execute_bash.requires_consent(),
8994
Tool::UseAws(use_aws) => use_aws.requires_consent(),
95+
Tool::Git(git) => git.requires_consent(),
9096
}
9197
}
9298

@@ -97,6 +103,7 @@ impl Tool {
97103
Tool::FsWrite(fs_write) => fs_write.invoke(context, updates).await,
98104
Tool::ExecuteBash(execute_bash) => execute_bash.invoke(updates).await,
99105
Tool::UseAws(use_aws) => use_aws.invoke(context, updates).await,
106+
Tool::Git(git) => git.invoke(context, updates).await,
100107
}
101108
}
102109

@@ -107,6 +114,7 @@ impl Tool {
107114
Tool::FsWrite(fs_write) => fs_write.queue_description(ctx, updates),
108115
Tool::ExecuteBash(execute_bash) => execute_bash.queue_description(updates),
109116
Tool::UseAws(use_aws) => use_aws.queue_description(updates),
117+
Tool::Git(git) => git.queue_description(updates),
110118
}
111119
}
112120

@@ -117,6 +125,7 @@ impl Tool {
117125
Tool::FsWrite(fs_write) => fs_write.validate(ctx).await,
118126
Tool::ExecuteBash(execute_bash) => execute_bash.validate(ctx).await,
119127
Tool::UseAws(use_aws) => use_aws.validate(ctx).await,
128+
Tool::Git(git) => git.validate(ctx).await,
120129
}
121130
}
122131
}
@@ -138,6 +147,7 @@ impl TryFrom<ToolUse> for Tool {
138147
"fs_write" => Self::FsWrite(serde_json::from_value::<FsWrite>(value.args).map_err(map_err)?),
139148
"execute_bash" => Self::ExecuteBash(serde_json::from_value::<ExecuteBash>(value.args).map_err(map_err)?),
140149
"use_aws" => Self::UseAws(serde_json::from_value::<UseAws>(value.args).map_err(map_err)?),
150+
"git" => Self::Git(serde_json::from_value::<Git>(value.args).map_err(map_err)?),
141151
unknown => {
142152
return Err(ToolResult {
143153
tool_use_id: value.id,

crates/q_cli/src/cli/chat/tools/tool_index.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,5 +102,39 @@
102102
},
103103
"required": ["region", "service_name", "operation_name", "label"]
104104
}
105+
},
106+
"git": {
107+
"name": "git",
108+
"description": "Execute a git command with the specified parameters. All arguments must conform to standard git CLI specification.",
109+
"input_schema": {
110+
"type": "object",
111+
"properties": {
112+
"command": {
113+
"type": "string",
114+
"description": "The primary git command to execute (e.g., clone, pull, push, status)."
115+
},
116+
"subcommand": {
117+
"type": "string",
118+
"description": "Optional: The subcommand for git commands that support them (e.g., remote add, branch create)."
119+
},
120+
"repo": {
121+
"type": "string",
122+
"description": "Optional: The repository URL or name to operate on, depending on the command context."
123+
},
124+
"branch": {
125+
"type": "string",
126+
"description": "Optional: The branch name to use for operations that require a branch specification."
127+
},
128+
"parameters": {
129+
"type": "object",
130+
"description": "Optional: Additional parameters for the git command. The parameter keys must match git command-line options."
131+
},
132+
"label": {
133+
"type": "string",
134+
"description": "Human readable description of the git operation being performed."
135+
}
136+
},
137+
"required": ["command", "label"]
138+
}
105139
}
106140
}

0 commit comments

Comments
 (0)