1use crate::client::git::sandbox;
4use crate::llm::actions::{
5 client_trait::{Client, ClientActionResult},
6 protocol_trait::Protocol,
7 ActionDefinition, Parameter, ParameterDefinition,
8};
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15pub static GIT_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17 EventType::new(
18 "git_connected",
19 "Git client initialized and ready for operations",
20 json!({
21 "type": "git_status"
22 }),
23 )
24 .with_parameters(vec![Parameter {
25 name: "repository_path".to_string(),
26 type_hint: "string".to_string(),
27 description: "Local path to the Git repository".to_string(),
28 required: true,
29 }])
30});
31
32pub static GIT_OPERATION_COMPLETED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
41 EventType::new(
42 "git_operation_completed",
43 "A Git operation finished; `output` carries what it produced",
44 json!({ "type": "disconnect" }),
45 )
46 .with_parameters(vec![
47 Parameter {
48 name: "operation".to_string(),
49 type_hint: "string".to_string(),
50 description: "The action that ran, e.g. 'git_clone' or 'git_log'".to_string(),
51 required: true,
52 },
53 Parameter {
54 name: "detail".to_string(),
55 type_hint: "string".to_string(),
56 description: "One-line summary of what the operation did".to_string(),
57 required: true,
58 },
59 Parameter {
60 name: "output".to_string(),
61 type_hint: "string".to_string(),
62 description:
63 "What the operation produced - the commit log, the diff, the branch list, the \
64 status. Truncated if very long. Absent for operations that produce no text."
65 .to_string(),
66 required: false,
67 },
68 ])
69});
70
71pub static GIT_OPERATION_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
73 EventType::new(
74 "git_operation_error",
75 "A Git operation failed",
76 json!({ "type": "disconnect" }),
77 )
78 .with_parameters(vec![
79 Parameter {
80 name: "operation".to_string(),
81 type_hint: "string".to_string(),
82 description: "The action that failed, e.g. 'git_clone'".to_string(),
83 required: true,
84 },
85 Parameter {
86 name: "error".to_string(),
87 type_hint: "string".to_string(),
88 description: "Why it failed, as reported by git".to_string(),
89 required: true,
90 },
91 ])
92});
93
94pub struct GitClientProtocol;
96
97impl GitClientProtocol {
98 pub fn new() -> Self {
99 Self
100 }
101}
102
103impl Protocol for GitClientProtocol {
105 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
106 vec![
107 ParameterDefinition {
108 name: "local_path".to_string(),
109 description: "Local path for Git operations: opened as the working repository \
115 if it already is one, and used as the clone destination when \
116 git_clone omits 'path'. Must be inside allowed_root; a relative \
117 path is taken relative to it"
118 .to_string(),
119 type_hint: "string".to_string(),
120 required: false,
121 example: json!("./my-repo"),
122 default: None,
123 },
124 ParameterDefinition {
125 name: sandbox::ALLOWED_ROOT_PARAM.to_string(),
131 description:
132 "Directory this client may read and write. Every path the model supplies \
133 is resolved and REFUSED (never relocated) if it falls outside, and a \
134 relative path is taken relative to this root. Defaults to a NetGet-owned \
135 workspace under the platform's local-data directory (on macOS \
136 ~/Library/Application Support/netget/git-workspace; on Linux \
137 ~/.local/share/netget/git-workspace). Point it at a wider directory only \
138 if you intend this client to reach there."
139 .to_string(),
140 type_hint: "string".to_string(),
141 required: false,
142 example: json!("/Users/you/netget-git-sandbox"),
143 default: None,
144 },
145 ParameterDefinition {
146 name: sandbox::ALLOW_REMOTE_WRITES_PARAM.to_string(),
149 description: "Permit operations that write to a REMOTE repository: git_push, and \
150 git_delete_branch when a 'remote' is given. Defaults to false. These are \
151 gated separately from allowed_root because no local directory boundary \
152 can bound where a push goes -- the remote URL comes from the cloned \
153 repository's own config and the push uses this client's credentials, so \
154 it can publish to or delete a branch on a real forge. Local-only \
155 destructive verbs (git_checkout, git_delete_branch with force and no \
156 remote) are NOT gated: their damage is confined to allowed_root."
157 .to_string(),
158 type_hint: "boolean".to_string(),
159 required: false,
160 example: json!(false),
161 default: None,
162 },
163 ParameterDefinition {
164 name: "username".to_string(),
165 description: "Git username for authentication".to_string(),
166 type_hint: "string".to_string(),
167 required: false,
168 example: json!("git-user"),
169 default: None,
170 },
171 ParameterDefinition {
172 name: "password".to_string(),
173 description: "Git password or personal access token".to_string(),
174 type_hint: "string".to_string(),
175 required: false,
176 example: json!("ghp_xxxxxxxxxxxxx"),
177 default: None,
178 },
179 ]
180 }
181 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
182 vec![
183 ActionDefinition {
184 name: "git_clone".to_string(),
185 description: "Clone a Git repository".to_string(),
186 parameters: vec![
187 Parameter {
188 name: "url".to_string(),
189 type_hint: "string".to_string(),
190 description: "Repository URL to clone".to_string(),
191 required: true,
192 },
193 Parameter {
194 name: "path".to_string(),
195 type_hint: "string".to_string(),
196 description:
197 "Local path to clone into (default: the client's local_path \
198 startup parameter, if one was given)"
199 .to_string(),
200 required: false,
201 },
202 ],
203 example: json!({
204 "type": "git_clone",
205 "url": "https://github.com/user/repo.git",
206 "path": "./repo"
207 }),
208 log_template: None,
209 },
210 ActionDefinition {
211 name: "git_fetch".to_string(),
212 description: "Fetch updates from remote repository".to_string(),
213 parameters: vec![
214 Parameter {
215 name: "remote".to_string(),
216 type_hint: "string".to_string(),
217 description: "Remote name (default: origin)".to_string(),
218 required: false,
219 },
220 ],
221 example: json!({
222 "type": "git_fetch",
223 "remote": "origin"
224 }),
225 log_template: None,
226 },
227 ActionDefinition {
228 name: "git_pull".to_string(),
229 description: "Pull and merge updates from remote repository".to_string(),
230 parameters: vec![
231 Parameter {
232 name: "remote".to_string(),
233 type_hint: "string".to_string(),
234 description: "Remote name (default: origin)".to_string(),
235 required: false,
236 },
237 Parameter {
238 name: "branch".to_string(),
239 type_hint: "string".to_string(),
240 description: "Branch name (default: current branch)".to_string(),
241 required: false,
242 },
243 ],
244 example: json!({
245 "type": "git_pull",
246 "remote": "origin",
247 "branch": "main"
248 }),
249 log_template: None,
250 },
251 ActionDefinition {
252 name: "git_push".to_string(),
253 description: "Push commits to remote repository".to_string(),
254 parameters: vec![
255 Parameter {
256 name: "remote".to_string(),
257 type_hint: "string".to_string(),
258 description: "Remote name (default: origin)".to_string(),
259 required: false,
260 },
261 Parameter {
262 name: "branch".to_string(),
263 type_hint: "string".to_string(),
264 description: "Branch name (default: current branch)".to_string(),
265 required: false,
266 },
267 ],
268 example: json!({
269 "type": "git_push",
270 "remote": "origin",
271 "branch": "main"
272 }),
273 log_template: None,
274 },
275 ActionDefinition {
276 name: "git_checkout".to_string(),
277 description: "Checkout a branch or commit".to_string(),
278 parameters: vec![
279 Parameter {
280 name: "target".to_string(),
281 type_hint: "string".to_string(),
282 description: "Branch name or commit hash to checkout".to_string(),
283 required: true,
284 },
285 Parameter {
286 name: "create".to_string(),
287 type_hint: "boolean".to_string(),
288 description: "Create new branch if it doesn't exist".to_string(),
289 required: false,
290 },
291 ],
292 example: json!({
293 "type": "git_checkout",
294 "target": "feature-branch",
295 "create": false
296 }),
297 log_template: None,
298 },
299 ActionDefinition {
300 name: "git_list_branches".to_string(),
301 description: "List all branches in the repository".to_string(),
302 parameters: vec![
303 Parameter {
304 name: "remote".to_string(),
305 type_hint: "boolean".to_string(),
306 description: "Include remote branches".to_string(),
307 required: false,
308 },
309 ],
310 example: json!({
311 "type": "git_list_branches",
312 "remote": true
313 }),
314 log_template: None,
315 },
316 ActionDefinition {
317 name: "git_log".to_string(),
318 description: "Get commit history".to_string(),
319 parameters: vec![
320 Parameter {
321 name: "max_count".to_string(),
322 type_hint: "number".to_string(),
323 description: "Maximum number of commits to retrieve".to_string(),
324 required: false,
325 },
326 ],
327 example: json!({
328 "type": "git_log",
329 "max_count": 10
330 }),
331 log_template: None,
332 },
333 ActionDefinition {
334 name: "git_status".to_string(),
335 description: "Get current repository status".to_string(),
336 parameters: vec![],
337 example: json!({
338 "type": "git_status"
339 }),
340 log_template: None,
341 },
342 ActionDefinition {
343 name: "git_delete_branch".to_string(),
344 description: "Delete a local or remote branch".to_string(),
345 parameters: vec![
346 Parameter {
347 name: "branch".to_string(),
348 type_hint: "string".to_string(),
349 description: "Branch name to delete".to_string(),
350 required: true,
351 },
352 Parameter {
353 name: "force".to_string(),
354 type_hint: "boolean".to_string(),
355 description: "Force delete even if not fully merged".to_string(),
356 required: false,
357 },
358 Parameter {
359 name: "remote".to_string(),
360 type_hint: "string".to_string(),
361 description: "Remote name to delete from (e.g., 'origin'). If not specified, deletes local branch only.".to_string(),
362 required: false,
363 },
364 ],
365 example: json!({
366 "type": "git_delete_branch",
367 "branch": "feature-branch",
368 "force": false,
369 "remote": "origin"
370 }),
371 log_template: None,
372 },
373 ActionDefinition {
374 name: "git_list_tags".to_string(),
375 description: "List all tags in the repository".to_string(),
376 parameters: vec![],
377 example: json!({
378 "type": "git_list_tags"
379 }),
380 log_template: None,
381 },
382 ActionDefinition {
383 name: "git_create_tag".to_string(),
384 description: "Create a new tag".to_string(),
385 parameters: vec![
386 Parameter {
387 name: "name".to_string(),
388 type_hint: "string".to_string(),
389 description: "Tag name".to_string(),
390 required: true,
391 },
392 Parameter {
393 name: "target".to_string(),
394 type_hint: "string".to_string(),
395 description: "Commit hash or branch name to tag (default: HEAD)".to_string(),
396 required: false,
397 },
398 Parameter {
399 name: "message".to_string(),
400 type_hint: "string".to_string(),
401 description: "Tag message (for annotated tags)".to_string(),
402 required: false,
403 },
404 ],
405 example: json!({
406 "type": "git_create_tag",
407 "name": "v1.0.0",
408 "target": "HEAD",
409 "message": "Release version 1.0.0"
410 }),
411 log_template: None,
412 },
413 ActionDefinition {
414 name: "git_diff".to_string(),
415 description: "View differences in the repository".to_string(),
416 parameters: vec![
417 Parameter {
418 name: "target".to_string(),
419 type_hint: "string".to_string(),
420 description: "Commit, branch, or file to diff against (default: working directory vs index)".to_string(),
421 required: false,
422 },
423 Parameter {
424 name: "staged".to_string(),
425 type_hint: "boolean".to_string(),
426 description: "Show staged changes (index vs HEAD)".to_string(),
427 required: false,
428 },
429 ],
430 example: json!({
431 "type": "git_diff",
432 "staged": true
433 }),
434 log_template: None,
435 },
436 ActionDefinition {
437 name: "disconnect".to_string(),
438 description: "Close the Git client".to_string(),
439 parameters: vec![],
440 example: json!({
441 "type": "disconnect"
442 }),
443 log_template: None,
444 },
445 ]
446 }
447 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
448 vec![
449 ]
453 }
454 fn protocol_name(&self) -> &'static str {
455 "Git"
456 }
457 fn get_event_types(&self) -> Vec<EventType> {
458 vec![
464 GIT_CLIENT_CONNECTED_EVENT.clone(),
465 GIT_OPERATION_COMPLETED_EVENT.clone(),
466 GIT_OPERATION_ERROR_EVENT.clone(),
467 ]
468 }
469 fn stack_name(&self) -> &'static str {
470 "APP>Git"
471 }
472 fn keywords(&self) -> Vec<&'static str> {
473 vec![
474 "git",
475 "git client",
476 "version control",
477 "clone",
478 "fetch",
479 "pull",
480 "push",
481 "branch",
482 "tag",
483 "diff",
484 ]
485 }
486 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
487 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
488
489 ProtocolMetadataV2::builder()
490 .state(DevelopmentState::Experimental)
491 .implementation("git2 library (libgit2 wrapper)")
492 .llm_control("Full control over Git operations")
493 .e2e_testing("Local Git repository testing")
494 .build()
495 }
496 fn description(&self) -> &'static str {
497 "Git client for version control operations"
498 }
499 fn example_prompt(&self) -> &'static str {
500 "Clone the repository https://github.com/user/repo.git to ./repo"
501 }
502 fn group_name(&self) -> &'static str {
503 "Version Control"
504 }
505 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
506 use crate::llm::actions::StartupExamples;
507 use serde_json::json;
508
509 StartupExamples::new(
510 json!({
512 "type": "open_client",
513 "remote_addr": "github.com",
514 "base_stack": "git",
515 "startup_params": {
516 "local_path": "./my-repo"
517 },
518 "instruction": "Clone the repository and show the last 5 commits"
519 }),
520 json!({
522 "type": "open_client",
523 "remote_addr": "github.com",
524 "base_stack": "git",
525 "startup_params": {
526 "local_path": "./my-repo"
527 },
528 "event_handlers": [{
529 "event_pattern": "git_operation_completed",
530 "handler": {
531 "type": "script",
532 "language": "python",
533 "code": "<git_client_handler>"
534 }
535 }]
536 }),
537 json!({
539 "type": "open_client",
540 "remote_addr": "github.com",
541 "base_stack": "git",
542 "startup_params": {
543 "local_path": "./my-repo"
544 },
545 "event_handlers": [
546 {
547 "event_pattern": "git_connected",
548 "handler": {
549 "type": "static",
550 "actions": [{
551 "type": "git_status"
552 }]
553 }
554 },
555 {
556 "event_pattern": "git_operation_completed",
557 "handler": {
558 "type": "static",
559 "actions": [{
560 "type": "disconnect"
561 }]
562 }
563 }
564 ]
565 }),
566 )
567 }
568}
569
570impl Client for GitClientProtocol {
572 fn connect(
573 &self,
574 ctx: crate::protocol::ConnectContext,
575 ) -> std::pin::Pin<
576 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
577 > {
578 Box::pin(async move {
579 use crate::client::git::GitClient;
580 GitClient::connect_with_llm_actions(
581 ctx.remote_addr,
582 ctx.llm_client,
583 ctx.state,
584 ctx.status_tx,
585 ctx.client_id,
586 ctx.startup_params,
593 )
594 .await
595 })
596 }
597 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
598 let action_type = action
599 .get("type")
600 .and_then(|v| v.as_str())
601 .context("Missing 'type' field in action")?;
602
603 match action_type {
604 "git_clone" => {
605 let url = action
606 .get("url")
607 .and_then(|v| v.as_str())
608 .context("Missing 'url' field")?
609 .to_string();
610 let path = action
611 .get("path")
612 .and_then(|v| v.as_str())
613 .context("Missing 'path' field")?
614 .to_string();
615
616 Ok(ClientActionResult::Custom {
617 name: "git_clone".to_string(),
618 data: json!({
619 "url": url,
620 "path": path,
621 }),
622 })
623 }
624 "git_fetch" => {
625 let remote = action
626 .get("remote")
627 .and_then(|v| v.as_str())
628 .unwrap_or("origin")
629 .to_string();
630
631 Ok(ClientActionResult::Custom {
632 name: "git_fetch".to_string(),
633 data: json!({
634 "remote": remote,
635 }),
636 })
637 }
638 "git_pull" => {
639 let remote = action
640 .get("remote")
641 .and_then(|v| v.as_str())
642 .unwrap_or("origin")
643 .to_string();
644 let branch = action
645 .get("branch")
646 .and_then(|v| v.as_str())
647 .map(|s| s.to_string());
648
649 Ok(ClientActionResult::Custom {
650 name: "git_pull".to_string(),
651 data: json!({
652 "remote": remote,
653 "branch": branch,
654 }),
655 })
656 }
657 "git_push" => {
658 let remote = action
659 .get("remote")
660 .and_then(|v| v.as_str())
661 .unwrap_or("origin")
662 .to_string();
663 let branch = action
664 .get("branch")
665 .and_then(|v| v.as_str())
666 .map(|s| s.to_string());
667
668 Ok(ClientActionResult::Custom {
669 name: "git_push".to_string(),
670 data: json!({
671 "remote": remote,
672 "branch": branch,
673 }),
674 })
675 }
676 "git_checkout" => {
677 let target = action
678 .get("target")
679 .and_then(|v| v.as_str())
680 .context("Missing 'target' field")?
681 .to_string();
682 let create = action
683 .get("create")
684 .and_then(|v| v.as_bool())
685 .unwrap_or(false);
686
687 Ok(ClientActionResult::Custom {
688 name: "git_checkout".to_string(),
689 data: json!({
690 "target": target,
691 "create": create,
692 }),
693 })
694 }
695 "git_list_branches" => {
696 let remote = action
697 .get("remote")
698 .and_then(|v| v.as_bool())
699 .unwrap_or(false);
700
701 Ok(ClientActionResult::Custom {
702 name: "git_list_branches".to_string(),
703 data: json!({
704 "remote": remote,
705 }),
706 })
707 }
708 "git_log" => {
709 let max_count = action
710 .get("max_count")
711 .and_then(|v| v.as_u64())
712 .unwrap_or(10) as usize;
713
714 Ok(ClientActionResult::Custom {
715 name: "git_log".to_string(),
716 data: json!({
717 "max_count": max_count,
718 }),
719 })
720 }
721 "git_status" => Ok(ClientActionResult::Custom {
722 name: "git_status".to_string(),
723 data: json!({}),
724 }),
725 "git_delete_branch" => {
726 let branch = action
727 .get("branch")
728 .and_then(|v| v.as_str())
729 .context("Missing 'branch' field")?
730 .to_string();
731 let force = action
732 .get("force")
733 .and_then(|v| v.as_bool())
734 .unwrap_or(false);
735 let remote = action
736 .get("remote")
737 .and_then(|v| v.as_str())
738 .map(|s| s.to_string());
739
740 Ok(ClientActionResult::Custom {
741 name: "git_delete_branch".to_string(),
742 data: json!({
743 "branch": branch,
744 "force": force,
745 "remote": remote,
746 }),
747 })
748 }
749 "git_list_tags" => Ok(ClientActionResult::Custom {
750 name: "git_list_tags".to_string(),
751 data: json!({}),
752 }),
753 "git_create_tag" => {
754 let name = action
755 .get("name")
756 .and_then(|v| v.as_str())
757 .context("Missing 'name' field")?
758 .to_string();
759 let target = action
760 .get("target")
761 .and_then(|v| v.as_str())
762 .map(|s| s.to_string());
763 let message = action
764 .get("message")
765 .and_then(|v| v.as_str())
766 .map(|s| s.to_string());
767
768 Ok(ClientActionResult::Custom {
769 name: "git_create_tag".to_string(),
770 data: json!({
771 "name": name,
772 "target": target,
773 "message": message,
774 }),
775 })
776 }
777 "git_diff" => {
778 let target = action
779 .get("target")
780 .and_then(|v| v.as_str())
781 .map(|s| s.to_string());
782 let staged = action
783 .get("staged")
784 .and_then(|v| v.as_bool())
785 .unwrap_or(false);
786
787 Ok(ClientActionResult::Custom {
788 name: "git_diff".to_string(),
789 data: json!({
790 "target": target,
791 "staged": staged,
792 }),
793 })
794 }
795 "disconnect" => Ok(ClientActionResult::Disconnect),
796 _ => Err(anyhow::anyhow!(
797 "Unknown Git client action: {}",
798 action_type
799 )),
800 }
801 }
802}