1use std::path::PathBuf;
2
3use git2::{BranchType, Repository};
4
5use crate::error::RuntimeError;
6use crate::git::{GitCli, has_changes};
7use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
8use crate::value::Value;
9
10pub struct GitBranchList;
11pub struct GitBranchCreate;
12pub struct GitBranchSwitch;
13pub struct GitBranchRename;
14pub struct GitBranchDelete;
15pub struct GitRemoteList;
16
17fn cwd(args: &ToolArgs, ctx: &ToolCtx) -> Result<PathBuf, RuntimeError> {
18 let explicit = args.named("cwd").and_then(|value| match value {
19 Value::Str(path) => Some(std::path::Path::new(path)),
20 _ => None,
21 });
22 ctx.resolve_cwd(explicit)
23}
24
25fn string_arg<'a>(args: &'a ToolArgs, name: &str) -> Result<&'a str, RuntimeError> {
26 match args.named(name) {
27 Some(Value::Str(value)) => Ok(value),
28 Some(other) => Err(RuntimeError::TypeMismatch {
29 expected: "string".into(),
30 actual: other.kind_name().into(),
31 }),
32 None => Err(RuntimeError::MissingArg(name.into())),
33 }
34}
35
36fn optional_string<'a>(args: &'a ToolArgs, name: &str) -> Result<Option<&'a str>, RuntimeError> {
37 match args.named(name) {
38 Some(Value::Str(value)) => Ok(Some(value)),
39 Some(other) => Err(RuntimeError::TypeMismatch {
40 expected: "string".into(),
41 actual: other.kind_name().into(),
42 }),
43 None => Ok(None),
44 }
45}
46
47fn bool_arg(args: &ToolArgs, name: &str, default: bool) -> Result<bool, RuntimeError> {
48 match args.named(name) {
49 Some(Value::Bool(value)) => Ok(*value),
50 Some(other) => Err(RuntimeError::TypeMismatch {
51 expected: "boolean".into(),
52 actual: other.kind_name().into(),
53 }),
54 None => Ok(default),
55 }
56}
57
58fn repo(args: &ToolArgs, ctx: &ToolCtx, tool: &str) -> Result<(PathBuf, Repository), RuntimeError> {
59 let cwd = cwd(args, ctx)?;
60 let repository = Repository::open(&cwd)
61 .map_err(|error| RuntimeError::ToolFailed(format!("{tool}: {error}")))?;
62 Ok((cwd, repository))
63}
64
65async fn repo_mut(
66 args: &ToolArgs,
67 ctx: &ToolCtx,
68 tool: &str,
69) -> Result<(PathBuf, Repository), RuntimeError> {
70 let cwd = cwd(args, ctx)?;
71 crate::fs_access::authorize_write(ctx, &cwd, tool, true).await?;
72 let repository = Repository::open(&cwd)
73 .map_err(|error| RuntimeError::ToolFailed(format!("{tool}: {error}")))?;
74 Ok((cwd, repository))
75}
76
77fn result(tool: &str, error: impl std::fmt::Display) -> RuntimeError {
78 RuntimeError::ToolFailed(format!("{tool}: {error}"))
79}
80
81fn branch_value(name: String, branch: &git2::Branch<'_>, current: bool) -> Value {
82 let reference = branch.get();
83 let commit = reference.target().map(|oid| oid.to_string());
84 let upstream = branch
85 .upstream()
86 .ok()
87 .and_then(|upstream| upstream.name().ok().flatten().map(str::to_owned));
88 Value::Struct(vec![
89 ("name".into(), Value::Str(name)),
90 ("sha".into(), commit.map(Value::Str).unwrap_or(Value::Unit)),
91 ("current".into(), Value::Bool(current)),
92 (
93 "upstream".into(),
94 upstream.map(Value::Str).unwrap_or(Value::Unit),
95 ),
96 ])
97}
98
99impl Tool for GitBranchList {
100 fn name(&self) -> &str {
101 "git.branch.list"
102 }
103 fn tier(&self) -> Tier {
104 Tier::Zero
105 }
106 fn description(&self) -> Option<&str> {
107 Some("List local branches and their current/upstream state.")
108 }
109 fn input_schema(&self) -> serde_json::Value {
110 serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"}}})
111 }
112 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
113 Box::pin(async move {
114 let (_, repository) = repo(&args, ctx, self.name())?;
115 let current = repository
116 .head()
117 .ok()
118 .and_then(|head| head.shorthand().map(str::to_owned));
119 let mut values = Vec::new();
120 for item in repository
121 .branches(Some(BranchType::Local))
122 .map_err(|e| result(self.name(), e))?
123 {
124 let (branch, _) = item.map_err(|e| result(self.name(), e))?;
125 let name = branch
126 .name()
127 .map_err(|e| result(self.name(), e))?
128 .unwrap_or_default()
129 .to_owned();
130 values.push(branch_value(
131 name.clone(),
132 &branch,
133 current.as_deref() == Some(name.as_str()),
134 ));
135 }
136 Ok(Value::List(values))
137 })
138 }
139}
140
141impl Tool for GitBranchCreate {
142 fn name(&self) -> &str {
143 "git.branch.create"
144 }
145 fn tier(&self) -> Tier {
146 Tier::Two
147 }
148 fn description(&self) -> Option<&str> {
149 Some("Create a local branch without checking it out.")
150 }
151 fn input_schema(&self) -> serde_json::Value {
152 serde_json::json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"start":{"type":"string"},"cwd":{"type":"string"}}})
153 }
154 fn invocation_provenance(
155 &self,
156 args: &ToolArgs,
157 ctx: &ToolCtx,
158 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
159 crate::tools::git_ops::git_mutation_provenance(args, ctx)
160 }
161
162 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
163 Box::pin(async move {
164 let (_, repository) = repo_mut(&args, ctx, self.name()).await?;
165 let name = string_arg(&args, "name")?;
166 let start = optional_string(&args, "start")?;
167 let commit = match start {
168 Some(revision) => repository
169 .revparse_single(revision)
170 .and_then(|object| object.peel_to_commit()),
171 None => repository.head().and_then(|head| head.peel_to_commit()),
172 }
173 .map_err(|e| result(self.name(), e))?;
174 repository
175 .branch(name, &commit, false)
176 .map_err(|e| result(self.name(), e))?;
177 Ok(Value::Struct(vec![
178 ("name".into(), Value::Str(name.into())),
179 ("sha".into(), Value::Str(commit.id().to_string())),
180 ]))
181 })
182 }
183}
184
185impl Tool for GitBranchSwitch {
186 fn name(&self) -> &str {
187 "git.branch.switch"
188 }
189 fn tier(&self) -> Tier {
190 Tier::Two
191 }
192 fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
193 match bool_arg(args, "force", false) {
194 Ok(true) => ApprovalLevel::Dangerous,
195 _ => ApprovalLevel::Approve,
196 }
197 }
198 fn description(&self) -> Option<&str> {
199 Some("Switch branches after refusing a dirty worktree unless force=true.")
200 }
201 fn input_schema(&self) -> serde_json::Value {
202 serde_json::json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"create":{"type":"boolean"},"start":{"type":"string"},"force":{"type":"boolean"},"cwd":{"type":"string"}}})
203 }
204 fn invocation_provenance(
205 &self,
206 args: &ToolArgs,
207 ctx: &ToolCtx,
208 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
209 crate::tools::git_ops::git_mutation_provenance(args, ctx)
210 }
211
212 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
213 Box::pin(async move {
214 let (cwd, repository) = repo_mut(&args, ctx, self.name()).await?;
215 let name = string_arg(&args, "name")?;
216 let create = bool_arg(&args, "create", false)?;
217 let force = bool_arg(&args, "force", false)?;
218 if !force && has_changes(&cwd).map_err(|e| result(self.name(), e))? {
219 return Err(result(self.name(), "dirty worktree requires force=true"));
220 }
221 if !create {
222 repository
223 .find_branch(name, BranchType::Local)
224 .map_err(|e| result(self.name(), e))?;
225 }
226 GitCli::at(&cwd)
227 .switch_branch(name, create, optional_string(&args, "start")?)
228 .map_err(|e| result(self.name(), e))?;
229 Ok(Value::Struct(vec![
230 ("name".into(), Value::Str(name.into())),
231 ("created".into(), Value::Bool(create)),
232 ]))
233 })
234 }
235}
236
237impl Tool for GitBranchRename {
238 fn name(&self) -> &str {
239 "git.branch.rename"
240 }
241 fn tier(&self) -> Tier {
242 Tier::Two
243 }
244 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
245 ApprovalLevel::Approve
246 }
247 fn description(&self) -> Option<&str> {
248 Some("Rename a local branch.")
249 }
250 fn input_schema(&self) -> serde_json::Value {
251 serde_json::json!({"type":"object","required":["old","new"],"properties":{"old":{"type":"string"},"new":{"type":"string"},"cwd":{"type":"string"}}})
252 }
253 fn invocation_provenance(
254 &self,
255 args: &ToolArgs,
256 ctx: &ToolCtx,
257 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
258 crate::tools::git_ops::git_mutation_provenance(args, ctx)
259 }
260
261 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
262 Box::pin(async move {
263 let (_, repository) = repo_mut(&args, ctx, self.name()).await?;
264 let old = string_arg(&args, "old")?;
265 let new = string_arg(&args, "new")?;
266 let mut branch = repository
267 .find_branch(old, BranchType::Local)
268 .map_err(|e| result(self.name(), e))?;
269 branch
270 .rename(new, false)
271 .map_err(|e| result(self.name(), e))?;
272 Ok(Value::Struct(vec![
273 ("old".into(), Value::Str(old.into())),
274 ("new".into(), Value::Str(new.into())),
275 ]))
276 })
277 }
278}
279
280impl Tool for GitBranchDelete {
281 fn name(&self) -> &str {
282 "git.branch.delete"
283 }
284 fn tier(&self) -> Tier {
285 Tier::Three
286 }
287 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
288 ApprovalLevel::Dangerous
289 }
290 fn description(&self) -> Option<&str> {
291 Some("Delete a local branch; current branches are refused.")
292 }
293 fn input_schema(&self) -> serde_json::Value {
294 serde_json::json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"force":{"type":"boolean"},"cwd":{"type":"string"}}})
295 }
296 fn invocation_provenance(
297 &self,
298 args: &ToolArgs,
299 ctx: &ToolCtx,
300 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
301 crate::tools::git_ops::git_mutation_provenance(args, ctx)
302 }
303
304 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
305 Box::pin(async move {
306 let (_, repository) = repo_mut(&args, ctx, self.name()).await?;
307 let name = string_arg(&args, "name")?;
308 if repository
309 .head()
310 .ok()
311 .and_then(|head| head.shorthand().map(str::to_owned))
312 .as_deref()
313 == Some(name)
314 {
315 return Err(result(self.name(), "cannot delete current branch"));
316 }
317 let mut branch = repository
318 .find_branch(name, BranchType::Local)
319 .map_err(|e| result(self.name(), e))?;
320 let force = bool_arg(&args, "force", false)?;
321 if force {
322 branch.delete().map_err(|e| result(self.name(), e))?;
323 } else {
324 branch.delete().map_err(|e| result(self.name(), e))?;
325 }
326 Ok(Value::Struct(vec![
327 ("name".into(), Value::Str(name.into())),
328 ("deleted".into(), Value::Bool(true)),
329 ]))
330 })
331 }
332}
333
334impl Tool for GitRemoteList {
335 fn name(&self) -> &str {
336 "git.remote.list"
337 }
338 fn tier(&self) -> Tier {
339 Tier::Zero
340 }
341 fn description(&self) -> Option<&str> {
342 Some("List configured Git remotes and fetch/push URLs.")
343 }
344 fn input_schema(&self) -> serde_json::Value {
345 serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"}}})
346 }
347 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
348 Box::pin(async move {
349 let (_, repository) = repo(&args, ctx, self.name())?;
350 let mut values = Vec::new();
351 for name in repository
352 .remotes()
353 .map_err(|e| result(self.name(), e))?
354 .iter()
355 .flatten()
356 {
357 let remote = repository
358 .find_remote(name)
359 .map_err(|e| result(self.name(), e))?;
360 values.push(Value::Struct(vec![
361 ("name".into(), Value::Str(name.to_string())),
362 (
363 "url".into(),
364 remote
365 .url()
366 .map(str::to_owned)
367 .map(Value::Str)
368 .unwrap_or(Value::Unit),
369 ),
370 (
371 "push_url".into(),
372 remote
373 .pushurl()
374 .map(str::to_owned)
375 .map(Value::Str)
376 .unwrap_or(Value::Unit),
377 ),
378 ]));
379 }
380 Ok(Value::List(values))
381 })
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use std::process::Command;
389
390 fn run(dir: &std::path::Path, args: &[&str]) -> String {
391 let output = Command::new("git")
392 .args(args)
393 .current_dir(dir)
394 .output()
395 .unwrap();
396 assert!(
397 output.status.success(),
398 "git {} failed: {}",
399 args.join(" "),
400 String::from_utf8_lossy(&output.stderr)
401 );
402 String::from_utf8_lossy(&output.stdout).trim().to_owned()
403 }
404
405 fn repo() -> tempfile::TempDir {
406 let dir = tempfile::tempdir().unwrap();
407 run(dir.path(), &["init", "-q", "-b", "main"]);
408 run(dir.path(), &["config", "user.name", "test"]);
409 run(dir.path(), &["config", "user.email", "test@example.com"]);
410 std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
411 run(dir.path(), &["add", "."]);
412 run(dir.path(), &["commit", "-qm", "initial"]);
413 dir
414 }
415
416 #[test]
417 fn branch_lifecycle_and_dirty_switch_safety() {
418 let dir = repo();
419 let args = ToolArgs {
420 named: vec![
421 ("cwd".into(), Value::Str(dir.path().display().to_string())),
422 ("name".into(), Value::Str("feature".into())),
423 ],
424 ..ToolArgs::default()
425 };
426 let ctx = ToolCtx::default();
427 futures::executor::block_on(GitBranchCreate.call(args, &ctx)).unwrap();
428 assert!(
429 run(dir.path(), &["show-ref", "--verify", "refs/heads/feature"]).contains("feature")
430 );
431 let switch_args = ToolArgs {
432 named: vec![
433 ("cwd".into(), Value::Str(dir.path().display().to_string())),
434 ("name".into(), Value::Str("feature".into())),
435 ],
436 ..ToolArgs::default()
437 };
438 futures::executor::block_on(GitBranchSwitch.call(switch_args, &ctx)).unwrap();
439 assert_eq!(run(dir.path(), &["branch", "--show-current"]), "feature");
440 std::fs::write(dir.path().join("dirty"), "x").unwrap();
441 let back_args = ToolArgs {
442 named: vec![
443 ("cwd".into(), Value::Str(dir.path().display().to_string())),
444 ("name".into(), Value::Str("main".into())),
445 ],
446 ..ToolArgs::default()
447 };
448 assert!(futures::executor::block_on(GitBranchSwitch.call(back_args, &ctx)).is_err());
449 let rename_args = ToolArgs {
450 named: vec![
451 ("cwd".into(), Value::Str(dir.path().display().to_string())),
452 ("old".into(), Value::Str("feature".into())),
453 ("new".into(), Value::Str("renamed".into())),
454 ],
455 ..ToolArgs::default()
456 };
457 futures::executor::block_on(GitBranchRename.call(rename_args, &ctx)).unwrap();
458 let delete_args = ToolArgs {
459 named: vec![
460 ("cwd".into(), Value::Str(dir.path().display().to_string())),
461 ("name".into(), Value::Str("renamed".into())),
462 ],
463 ..ToolArgs::default()
464 };
465 assert!(futures::executor::block_on(GitBranchDelete.call(delete_args, &ctx)).is_err());
466 }
467
468 #[test]
469 fn force_switch_requires_dangerous_approval() {
470 let tool = GitBranchSwitch;
471 let ctx = ToolCtx::default();
472 assert_eq!(
473 tool.approval_level(&ToolArgs::default(), &ctx),
474 ApprovalLevel::Approve
475 );
476 let args = ToolArgs {
477 named: vec![("force".into(), Value::Bool(true))],
478 ..ToolArgs::default()
479 };
480 assert_eq!(tool.approval_level(&args, &ctx), ApprovalLevel::Dangerous);
481 }
482
483 #[test]
484 fn branch_list_and_remote_list_return_structured_values() {
485 let dir = repo();
486 run(
487 dir.path(),
488 &[
489 "remote",
490 "add",
491 "origin",
492 "https://example.invalid/repo.git",
493 ],
494 );
495 let ctx = ToolCtx::default();
496 let args = ToolArgs {
497 named: vec![("cwd".into(), Value::Str(dir.path().display().to_string()))],
498 ..ToolArgs::default()
499 };
500 let branches = futures::executor::block_on(GitBranchList.call(args.clone(), &ctx)).unwrap();
501 assert!(matches!(branches, Value::List(items) if !items.is_empty()));
502 let remotes = futures::executor::block_on(GitRemoteList.call(args, &ctx)).unwrap();
503 assert!(matches!(remotes, Value::List(items) if items.len() == 1));
504 }
505}